Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}

Expand Down
117 changes: 117 additions & 0 deletions scripts/create-superadmin.ts
Original file line number Diff line number Diff line change
@@ -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=<G...> --name="<full 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<void> => {
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());
}
98 changes: 98 additions & 0 deletions tests/unit/create-superadmin.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading