diff --git a/package.json b/package.json index 9a1369d..da018fc 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,12 @@ "start": "node src/server.js", "dev": "tsx watch src/server.ts", "indexer": "tsx watch src/indexer/run.ts", + "admin:create-superadmin": "tsx scripts/create-superadmin.ts", "migration:generate": "typeorm-ts-node-commonjs migration:generate -d src/config/database.ts", "migration:run": "typeorm-ts-node-commonjs migration:run -d src/config/database.ts", "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/config/database.ts", - "format": "prettier --write \"src/**/*.{ts,js,json,md}\"", - "format:check": "prettier --check \"src/**/*.{ts,js,json,md}\"", + "format": "prettier --write \"{src,scripts}/**/*.{ts,js,json,md}\"", + "format:check": "prettier --check \"{src,scripts}/**/*.{ts,js,json,md}\"", "lint": "eslint . --fix", "lint:check": "eslint .", "check": "npm run format:check && npm run lint:check && tsc --noEmit", diff --git a/prisma/migrations/20260822000000_add_admin_name_and_created_by/migration.sql b/prisma/migrations/20260822000000_add_admin_name_and_created_by/migration.sql new file mode 100644 index 0000000..e9095c1 --- /dev/null +++ b/prisma/migrations/20260822000000_add_admin_name_and_created_by/migration.sql @@ -0,0 +1,13 @@ +-- AlterTable +-- `name` is required with no default in the schema. Nothing can have written an +-- Admin row before this migration (there is no self-registration endpoint and +-- the bootstrap script arrives with it), so the table is empty in practice — +-- but the add/drop-default pair keeps the migration safe for any dev database +-- that had a row hand-inserted, instead of failing on the NOT NULL. +ALTER TABLE "Admin" ADD COLUMN "name" TEXT NOT NULL DEFAULT ''; +ALTER TABLE "Admin" ALTER COLUMN "name" DROP DEFAULT; + +ALTER TABLE "Admin" ADD COLUMN "createdBy" TEXT; + +-- AddForeignKey +ALTER TABLE "Admin" ADD CONSTRAINT "Admin_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 85bafe1..3bf23e8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,11 +10,17 @@ datasource db { model Admin { id String @id @default(uuid()) address String @unique + name String active Boolean @default(true) isSuperAdmin Boolean @default(false) + // Null only for the admin bootstrapped by scripts/create-superadmin.ts, which + // by definition has no creator. Every admin created through the app has one. + createdBy String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + creator Admin? @relation("AdminCreatedBy", fields: [createdBy], references: [id]) + createdAdmins Admin[] @relation("AdminCreatedBy") refreshTokens AdminRefreshToken[] } diff --git a/scripts/create-superadmin.ts b/scripts/create-superadmin.ts new file mode 100644 index 0000000..9188704 --- /dev/null +++ b/scripts/create-superadmin.ts @@ -0,0 +1,117 @@ +/** + * Bootstraps the first superadmin. + * + * There is deliberately no admin self-registration endpoint — unlike merchants, + * who provision themselves on first wallet sign-in, an Admin row can only be + * created here or by an admin who already exists. This script is the only way + * to get the first one, and it is intentionally CLI-only: no HTTP route + * anywhere reaches this code. + * + * npm run admin:create-superadmin -- --address=G... --name="Jane Doe" + */ +import { StrKey } from '@stellar/stellar-sdk'; +import prisma from '../src/config/prisma.js'; + +export interface SuperadminInput { + address: string; + name: string; +} + +/** A refusal the operator can act on, as opposed to an unexpected crash. */ +export class BootstrapError extends Error { + constructor(message: string) { + super(message); + this.name = 'BootstrapError'; + Object.setPrototypeOf(this, BootstrapError.prototype); + } +} + +const USAGE = 'Usage: npm run admin:create-superadmin -- --address= --name=""'; + +/** + * Accepts both `--flag=value` and `--flag value`, since operators type these by + * hand and both spellings are habitual. + */ +const readFlag = (argv: string[], flag: string): string | undefined => { + const prefixed = argv.find(arg => arg.startsWith(`--${flag}=`)); + if (prefixed) { + return prefixed.slice(`--${flag}=`.length); + } + + const index = argv.indexOf(`--${flag}`); + if (index !== -1) { + const value = argv[index + 1]; + // `--name --address=G...` means the name was omitted, not that it is "--address=G...". + return value?.startsWith('--') ? undefined : value; + } + + return undefined; +}; + +export const parseArgs = (argv: string[]): SuperadminInput => { + const address = readFlag(argv, 'address')?.trim(); + const name = readFlag(argv, 'name')?.trim(); + + if (!address) { + throw new BootstrapError(`--address is required.\n${USAGE}`); + } + + if (!StrKey.isValidEd25519PublicKey(address)) { + throw new BootstrapError(`"${address}" is not a valid Stellar public key.`); + } + + if (!name) { + throw new BootstrapError(`--name is required.\n${USAGE}`); + } + + return { address, name }; +}; + +/** + * Refuses an address that already has an Admin row. Re-running with the same + * address is an operator mistake worth surfacing — never an overwrite, and + * never a silently swallowed no-op. + */ +export const createSuperadmin = async ({ address, name }: SuperadminInput) => { + const existing = await prisma.admin.findUnique({ where: { address } }); + + if (existing) { + throw new BootstrapError( + `An admin already exists for ${address} (id ${existing.id}, superadmin: ${existing.isSuperAdmin}). Refusing to overwrite or duplicate it.`, + ); + } + + return prisma.admin.create({ + data: { + address, + name, + isSuperAdmin: true, + active: true, + // The bootstrap admin has no creator; every later admin is created by one. + createdBy: null, + }, + }); +}; + +const main = async (): Promise => { + const admin = await createSuperadmin(parseArgs(process.argv.slice(2))); + + console.log('Superadmin created:'); + console.log(` id: ${admin.id}`); + console.log(` address: ${admin.address}`); + console.log(` name: ${admin.name}`); +}; + +// Only run when executed directly, so tests can import the pieces above. +if (process.argv[1]?.includes('create-superadmin')) { + main() + .catch((error: unknown) => { + if (error instanceof BootstrapError) { + console.error(error.message); + } else { + console.error('Failed to create superadmin:', error); + } + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); +} diff --git a/tests/unit/create-superadmin.test.ts b/tests/unit/create-superadmin.test.ts new file mode 100644 index 0000000..f34eea2 --- /dev/null +++ b/tests/unit/create-superadmin.test.ts @@ -0,0 +1,98 @@ +import { beforeEach } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { parseArgs, createSuperadmin, BootstrapError } = await import( + '../../scripts/create-superadmin.js' +); + +// A real key, checked against the real StrKey — the point of the validation is +// that it rejects anything the network would. +const ADDRESS = 'GBNFW62V7GWGPVW6BGK4KZQEWHNB3JL7K4WZFVHP3DHJWKUYLOAQK5YY'; +const NAME = 'Jane Doe'; + +describe('parseArgs', () => { + test('reads --flag=value', () => { + expect(parseArgs([`--address=${ADDRESS}`, `--name=${NAME}`])).toEqual({ + address: ADDRESS, + name: NAME, + }); + }); + + test('reads --flag value', () => { + expect(parseArgs(['--address', ADDRESS, '--name', NAME])).toEqual({ + address: ADDRESS, + name: NAME, + }); + }); + + test('rejects an invalid Stellar address', () => { + expect(() => parseArgs(['--address=GNOTAVALIDADDRESS', `--name=${NAME}`])).toThrow( + BootstrapError, + ); + }); + + test('rejects a well-formed address with a bad checksum', () => { + const corrupted = `${ADDRESS.slice(0, -1)}A`; + + expect(() => parseArgs([`--address=${corrupted}`, `--name=${NAME}`])).toThrow(BootstrapError); + }); + + test.each([ + ['no address', [`--name=${NAME}`]], + ['no name', [`--address=${ADDRESS}`]], + ['blank name', [`--address=${ADDRESS}`, '--name= ']], + ['name flag swallowed by the next flag', ['--name', `--address=${ADDRESS}`]], + ])('rejects %s', (_label, argv) => { + expect(() => parseArgs(argv)).toThrow(BootstrapError); + }); +}); + +describe('createSuperadmin', () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + test('creates an active superadmin with no creator', async () => { + prismaMock.admin.findUnique.mockResolvedValue(null); + prismaMock.admin.create.mockResolvedValue({ id: 'admin-uuid', address: ADDRESS, name: NAME }); + + await createSuperadmin({ address: ADDRESS, name: NAME }); + + expect(prismaMock.admin.create).toHaveBeenCalledWith({ + data: { + address: ADDRESS, + name: NAME, + isSuperAdmin: true, + active: true, + createdBy: null, + }, + }); + }); + + test('refuses an address that already has an admin, without writing', async () => { + prismaMock.admin.findUnique.mockResolvedValue({ + id: 'existing-uuid', + address: ADDRESS, + isSuperAdmin: true, + }); + + await expect(createSuperadmin({ address: ADDRESS, name: NAME })).rejects.toThrow( + BootstrapError, + ); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); + + test('refuses a duplicate even when the existing admin is not a superadmin', async () => { + prismaMock.admin.findUnique.mockResolvedValue({ + id: 'existing-uuid', + address: ADDRESS, + isSuperAdmin: false, + }); + + await expect(createSuperadmin({ address: ADDRESS, name: NAME })).rejects.toThrow( + /Refusing to overwrite or duplicate/, + ); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); +});