"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.OAuthStore = void 0;
const node_assert_1 = __importDefault(require("node:assert"));
const lib_1 = require("@did-plc/lib");
const crypto_1 = require("@atproto/crypto");
const oauth_provider_1 = require("@atproto/oauth-provider");
const xrpc_server_1 = require("@atproto/xrpc-server");
const db_1 = require("../db");
const logger_1 = require("../logger");
const sequencer_1 = require("../sequencer");
const accountHelper = __importStar(require("./helpers/account"));
const account_1 = require("./helpers/account");
const accountDeviceHelper = __importStar(require("./helpers/account-device"));
const authRequestHelper = __importStar(require("./helpers/authorization-request"));
const authorizedClientHelper = __importStar(require("./helpers/authorized-client"));
const deviceHelper = __importStar(require("./helpers/device"));
const lexiconHelper = __importStar(require("./helpers/lexicon"));
const tokenHelper = __importStar(require("./helpers/token"));
const usedRefreshTokenHelper = __importStar(require("./helpers/used-refresh-token"));
/**
 * This class' purpose is to implement the interface needed by the OAuthProvider
 * to interact with the account database (through the {@link AccountManager}).
 *
 * @note The use of this class assumes that there is no entryway.
 */
class OAuthStore {
    constructor(accountManager, actorStore, imageUrlBuilder, backgroundQueue, mailer, sequencer, plcClient, plcRotationKey, publicUrl, recoveryDidKey) {
        Object.defineProperty(this, "accountManager", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: accountManager
        });
        Object.defineProperty(this, "actorStore", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: actorStore
        });
        Object.defineProperty(this, "imageUrlBuilder", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: imageUrlBuilder
        });
        Object.defineProperty(this, "backgroundQueue", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: backgroundQueue
        });
        Object.defineProperty(this, "mailer", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: mailer
        });
        Object.defineProperty(this, "sequencer", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: sequencer
        });
        Object.defineProperty(this, "plcClient", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: plcClient
        });
        Object.defineProperty(this, "plcRotationKey", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: plcRotationKey
        });
        Object.defineProperty(this, "publicUrl", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: publicUrl
        });
        Object.defineProperty(this, "recoveryDidKey", {
            enumerable: true,
            configurable: true,
            writable: true,
            value: recoveryDidKey
        });
    }
    get db() {
        const { db } = this.accountManager;
        if (db.destroyed)
            throw new Error('Database connection is closed');
        return db;
    }
    get serviceDid() {
        return this.accountManager.serviceDid;
    }
    async verifyEmailAvailability(email) {
        // @NOTE Email validity & disposability check performed by the OAuthProvider
        const account = await this.accountManager.getAccountByEmail(email, {
            includeDeactivated: true,
            includeTakenDown: true,
        });
        if (account) {
            throw new oauth_provider_1.InvalidRequestError(`Email already taken`);
        }
    }
    async verifyInviteCode(code) {
        try {
            await this.accountManager.ensureInviteIsAvailable(code);
        }
        catch (err) {
            const message = err instanceof xrpc_server_1.InvalidRequestError ? err.message : undefined;
            throw new oauth_provider_1.InvalidInviteCodeError(message, err);
        }
    }
    // AccountStore
    async createAccount({ locale: _locale, inviteCode, handle, email, password, }) {
        // @TODO Send an account creation confirmation email (+verification link) to the user (in their locale)
        // @NOTE Password strength & length already enforced by the OAuthProvider
        await Promise.all([
            this.verifyEmailAvailability(email),
            this.verifyHandleAvailability(handle),
            !inviteCode || this.verifyInviteCode(inviteCode),
        ]);
        // @TODO The code bellow should probably be refactored to be common with the
        // code of the `com.atproto.server.createAccount` XRPC endpoint.
        const signingKey = await crypto_1.Secp256k1Keypair.create({ exportable: true });
        const signingKeyDid = signingKey.did();
        const plcCreate = await (0, lib_1.createOp)({
            signingKey: signingKeyDid,
            rotationKeys: this.recoveryDidKey
                ? [this.recoveryDidKey, this.plcRotationKey.did()]
                : [this.plcRotationKey.did()],
            handle,
            pds: this.publicUrl,
            signer: this.plcRotationKey,
        });
        const { did, op } = plcCreate;
        try {
            await this.actorStore.create(did, signingKey);
            try {
                const commit = await this.actorStore.transact(did, (actorTxn) => actorTxn.repo.createRepo([]));
                await this.plcClient.sendOperation(did, op);
                await this.accountManager.createAccount({
                    did,
                    handle,
                    email,
                    password,
                    inviteCode,
                    repoCid: commit.cid,
                    repoRev: commit.rev,
                });
                try {
                    await this.sequencer.sequenceIdentityEvt(did, handle);
                    await this.sequencer.sequenceAccountEvt(did, account_1.AccountStatus.Active);
                    await this.sequencer.sequenceCommit(did, commit);
                    await this.sequencer.sequenceSyncEvt(did, (0, sequencer_1.syncEvtDataFromCommit)(commit));
                    await this.accountManager.updateRepoRoot(did, commit.cid, commit.rev);
                    await this.actorStore.clearReservedKeypair(signingKeyDid, did);
                    const account = await this.accountManager.getAccount(did);
                    if (!account)
                        throw new Error('Account not found');
                    return await this.buildAccount(account);
                }
                catch (err) {
                    this.accountManager.deleteAccount(did);
                    throw err;
                }
            }
            catch (err) {
                await this.actorStore.destroy(did);
                throw err;
            }
        }
        catch (err) {
            // XrpcError => OAuthError
            if (err instanceof xrpc_server_1.InvalidRequestError) {
                throw new oauth_provider_1.InvalidRequestError(err.message, err);
            }
            throw err;
        }
    }
    async authenticateAccount({ locale: _locale, username: identifier, password, 
    // Not supported by the PDS (yet?)
    emailOtp = undefined, }) {
        // @TODO (?) Send an email to the user to notify them of the login attempt
        try {
            // Should never happen
            if (emailOtp != null) {
                throw new Error('Email OTP is not supported');
            }
            const { user, appPassword, isSoftDeleted } = await this.accountManager.login({ identifier, password });
            if (isSoftDeleted) {
                throw new oauth_provider_1.InvalidRequestError('Account was taken down');
            }
            if (appPassword) {
                throw new oauth_provider_1.InvalidRequestError('App passwords are not allowed');
            }
            return this.buildAccount(user);
        }
        catch (err) {
            if (err instanceof xrpc_server_1.AuthRequiredError) {
                throw new oauth_provider_1.InvalidRequestError(err.message, err);
            }
            throw err;
        }
    }
    async setAuthorizedClient(sub, clientId, data) {
        await authorizedClientHelper.upsert(this.db, sub, clientId, data);
    }
    async getAccount(sub) {
        const accountRow = await accountHelper.getAccount(this.db, sub, {
            includeDeactivated: true,
        });
        (0, node_assert_1.default)(accountRow, 'Account not found');
        const account = await this.buildAccount(accountRow);
        const authorizedClients = await authorizedClientHelper.getAuthorizedClients(this.db, sub);
        return { account, authorizedClients };
    }
    async upsertDeviceAccount(deviceId, sub) {
        await this.db.executeWithRetry(accountDeviceHelper.upsertQB(this.db, deviceId, sub));
    }
    async getDeviceAccount(deviceId, sub) {
        const row = await accountDeviceHelper
            .selectQB(this.db, { deviceId, sub })
            .executeTakeFirst();
        if (!row)
            return null;
        return {
            deviceId,
            deviceData: deviceHelper.rowToDeviceData(row),
            account: await this.buildAccount(row),
            authorizedClients: await authorizedClientHelper.getAuthorizedClients(this.db, sub),
            createdAt: (0, db_1.fromDateISO)(row.adCreatedAt),
            updatedAt: (0, db_1.fromDateISO)(row.adUpdatedAt),
        };
    }
    async removeDeviceAccount(deviceId, sub) {
        await this.db.executeWithRetry(accountDeviceHelper.removeQB(this.db, deviceId, sub));
    }
    async listDeviceAccounts(filter) {
        const rows = await accountDeviceHelper.selectQB(this.db, filter).execute();
        const uniqueDids = [...new Set(rows.map((row) => row.did))];
        // Enrich all distinct account with their profile data
        const accounts = new Map(await Promise.all(Array.from(uniqueDids, async (did) => {
            const row = rows.find((r) => r.did === did);
            return [did, await this.buildAccount(row)];
        })));
        const authorizedClientsMap = await authorizedClientHelper.getAuthorizedClientsMulti(this.db, uniqueDids);
        return rows.map((row) => ({
            deviceId: row.deviceId,
            deviceData: deviceHelper.rowToDeviceData(row),
            account: accounts.get(row.did),
            authorizedClients: authorizedClientsMap.get(row.did),
            createdAt: (0, db_1.fromDateISO)(row.adCreatedAt),
            updatedAt: (0, db_1.fromDateISO)(row.adUpdatedAt),
        }));
    }
    async resetPasswordRequest({ locale: _locale, email, }) {
        const account = await this.accountManager.getAccountByEmail(email, {
            includeDeactivated: true,
            includeTakenDown: true,
        });
        if (!account?.email || !account?.handle)
            return null;
        const { handle } = account;
        const token = await this.accountManager.createEmailToken(account.did, 'reset_password');
        // @TODO Use the locale to send the email in the right language
        await this.mailer.sendResetPassword({ handle, token }, { to: account.email });
        return this.buildAccount(account);
    }
    async resetPasswordConfirm(data) {
        try {
            const did = await this.accountManager.resetPassword(data);
            const account = await this.accountManager.getAccount(did, {
                includeDeactivated: true,
                includeTakenDown: true,
            });
            return account ? this.buildAccount(account) : null;
        }
        catch (err) {
            if (err instanceof xrpc_server_1.InvalidRequestError) {
                return null;
            }
            throw err;
        }
    }
    async verifyHandleAvailability(handle) {
        // @NOTE Handle validity & normalization already enforced by the OAuthProvider
        try {
            const normalized = await this.accountManager.normalizeAndValidateHandle(handle);
            // Should never happen (OAuthProvider should have already validated the
            // handle) This check is just a safeguard against future normalization
            // changes.
            if (normalized !== handle) {
                throw new oauth_provider_1.HandleUnavailableError('syntax', 'Invalid handle');
            }
            const account = await this.accountManager.getAccount(normalized, {
                includeDeactivated: true,
                includeTakenDown: true,
            });
            if (account) {
                throw new oauth_provider_1.HandleUnavailableError('taken');
            }
        }
        catch (err) {
            if (err instanceof xrpc_server_1.InvalidRequestError) {
                throw err.customErrorName === 'HandleNotAvailable'
                    ? new oauth_provider_1.HandleUnavailableError('taken', err.message)
                    : new oauth_provider_1.HandleUnavailableError('syntax', err.message);
            }
            throw err;
        }
    }
    // RequestStore
    async createRequest(id, data) {
        await this.db.executeWithRetry(authRequestHelper.createQB(this.db, id, data));
    }
    async readRequest(id) {
        try {
            const row = await authRequestHelper.readQB(this.db, id).executeTakeFirst();
            if (!row)
                return null;
            return authRequestHelper.rowToRequestData(row);
        }
        finally {
            // Take the opportunity to clean up expired requests. Do this after we got
            // the current (potentially expired) request data to allow the provider to
            // handle expired requests.
            this.backgroundQueue.add(async () => {
                await this.db.executeWithRetry(authRequestHelper.removeOldExpiredQB(this.db));
            });
        }
    }
    async updateRequest(id, data) {
        await this.db.executeWithRetry(authRequestHelper.updateQB(this.db, id, data));
    }
    async deleteRequest(id) {
        await this.db.executeWithRetry(authRequestHelper.removeByIdQB(this.db, id));
    }
    async consumeRequestCode(code) {
        const row = await authRequestHelper
            .consumeByCodeQB(this.db, code)
            .executeTakeFirst();
        return row ? authRequestHelper.rowToFoundRequestResult(row) : null;
    }
    // DeviceStore
    async createDevice(deviceId, data) {
        await this.db.executeWithRetry(deviceHelper.createQB(this.db, deviceId, data));
    }
    async readDevice(deviceId) {
        const row = await deviceHelper.readQB(this.db, deviceId).executeTakeFirst();
        return row ? deviceHelper.rowToDeviceData(row) : null;
    }
    async updateDevice(deviceId, data) {
        await this.db.executeWithRetry(deviceHelper.updateQB(this.db, deviceId, data));
    }
    async deleteDevice(deviceId) {
        // Will cascade to device_account (device_account_device_id_fk)
        await this.db.executeWithRetry(deviceHelper.removeQB(this.db, deviceId));
    }
    // LexiconStore
    async findLexicon(nsid) {
        return lexiconHelper.find(this.db, nsid);
    }
    async storeLexicon(nsid, data) {
        return lexiconHelper.upsert(this.db, nsid, data);
    }
    async deleteLexicon(nsid) {
        return lexiconHelper.remove(this.db, nsid);
    }
    // TokenStore
    async createToken(id, data, refreshToken) {
        await this.db.transaction(async (dbTxn) => {
            if (refreshToken) {
                const { count } = await usedRefreshTokenHelper
                    .countQB(dbTxn, refreshToken)
                    .executeTakeFirstOrThrow();
                if (count > 0) {
                    throw new Error('Refresh token already in use');
                }
            }
            return tokenHelper.createQB(dbTxn, id, data, refreshToken).execute();
        });
    }
    async listAccountTokens(sub) {
        const rows = await tokenHelper.findByQB(this.db, { did: sub }).execute();
        return Promise.all(rows.map((row) => this.toTokenInfo(row)));
    }
    async readToken(tokenId) {
        const row = await tokenHelper
            .findByQB(this.db, { tokenId })
            .executeTakeFirst();
        return row ? this.toTokenInfo(row) : null;
    }
    async deleteToken(tokenId) {
        // Will cascade to used_refresh_token (used_refresh_token_fk)
        await this.db.executeWithRetry(tokenHelper.removeQB(this.db, tokenId));
    }
    async rotateToken(tokenId, newTokenId, newRefreshToken, newData) {
        const err = await this.db.transaction(async (dbTxn) => {
            const { id, currentRefreshToken } = await tokenHelper
                .forRotateQB(dbTxn, tokenId)
                .executeTakeFirstOrThrow();
            if (currentRefreshToken) {
                await usedRefreshTokenHelper
                    .insertQB(dbTxn, id, currentRefreshToken)
                    .execute();
            }
            const { count } = await usedRefreshTokenHelper
                .countQB(dbTxn, newRefreshToken)
                .executeTakeFirstOrThrow();
            if (count > 0) {
                // Do NOT throw (we don't want the transaction to be rolled back)
                return new Error('New refresh token already in use');
            }
            await tokenHelper
                .rotateQB(dbTxn, id, newTokenId, newRefreshToken, newData)
                .execute();
        });
        if (err)
            throw err;
    }
    async findTokenByRefreshToken(refreshToken) {
        const used = await usedRefreshTokenHelper
            .findByTokenQB(this.db, refreshToken)
            .executeTakeFirst();
        const search = used
            ? { id: used.tokenId }
            : { currentRefreshToken: refreshToken };
        const row = await tokenHelper.findByQB(this.db, search).executeTakeFirst();
        return row ? this.toTokenInfo(row) : null;
    }
    async findTokenByCode(code) {
        const row = await tokenHelper.findByQB(this.db, { code }).executeTakeFirst();
        return row ? this.toTokenInfo(row) : null;
    }
    async toTokenInfo(row) {
        return {
            id: row.tokenId,
            data: tokenHelper.toTokenData(row),
            account: await this.buildAccount(row),
            currentRefreshToken: row.currentRefreshToken,
        };
    }
    async buildAccount(row) {
        const account = {
            sub: row.did,
            aud: this.serviceDid,
            email: row.email || undefined,
            email_verified: row.email ? row.emailConfirmedAt != null : undefined,
            preferred_username: row.handle || undefined,
        };
        if (!account.name || !account.picture) {
            const did = account.sub;
            const profile = await this.actorStore
                .read(did, async (store) => {
                return store.record.getProfileRecord();
            })
                .catch((err) => {
                logger_1.dbLogger.error({ err }, 'Failed to get profile record');
                return null; // No need to propagate
            });
            if (profile) {
                const { avatar, displayName } = profile;
                account.name || (account.name = displayName);
                account.picture || (account.picture = avatar
                    ? this.imageUrlBuilder.build('avatar', did, avatar.ref.toString())
                    : undefined);
            }
        }
        return account;
    }
}
exports.OAuthStore = OAuthStore;
//# sourceMappingURL=oauth-store.js.map