@fonderie/admin 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -173,6 +173,62 @@ A refused request is still logged — the caller learns nothing from a 404,
173
173
  and you learn that someone went looking. `GET /_admin/manifest` reports the
174
174
  binding as `admin.host`, so the Configuration page can show it.
175
175
 
176
+ ## Operators: people sign in, tokens are for machines
177
+
178
+ A pasted token is a shared secret with no name on it. With a `store`, the
179
+ console instead has **operator accounts**: email, password, and a mandatory
180
+ authenticator app, with backup codes as the fallback.
181
+
182
+ ```ts
183
+ new AdminModule({
184
+ adminToken: process.env.ADMIN_TOKEN, // machines (CLI, CI) + the break-glass
185
+ // operatorKey: optional — see below
186
+ store,
187
+ ui: true,
188
+ });
189
+ ```
190
+
191
+ - **No registration.** The first operator is claimed once, on the console's
192
+ own sign-in page: paste the root `adminToken`, then create your account. Every other operator is invited
193
+ by an Owner: a single-use link that expires, where they set a password and
194
+ scan the QR code before anything opens.
195
+ - **Two factors, always.** Password, then a six-digit code (RFC 6238). Ten
196
+ single-use backup codes are shown once at setup. A code cannot be replayed
197
+ within its window.
198
+ - **A fresh code for dangerous actions.** Revealing a secret, minting a token
199
+ or link, applying a migration and deleting anything ask for a code from the
200
+ last five minutes. The console prompts and retries the action for you.
201
+ - **Sessions** are an HttpOnly, SameSite=Strict cookie (`__Host-` over
202
+ HTTPS), 30 minutes idle and 12 hours at most, rotated at every privilege
203
+ change. Cookie-authenticated writes must come from this origin.
204
+ - **Brute force** locks the account after 5 failures, doubling from a minute
205
+ to an hour; one address is also limited across accounts. Error messages do
206
+ not say which part was wrong.
207
+ - **Recovery without email.** A lost phone uses a backup code. Lost both, or a
208
+ forgotten password: another Owner creates a recovery link (new password, new
209
+ authenticator, signed out everywhere). Every Owner locked out: the root token
210
+ does it from the terminal —
211
+
212
+ ```bash
213
+ FONDERIE_ADMIN_URL=https://api.example.com FONDERIE_ADMIN_TOKEN=$ADMIN_TOKEN \
214
+ npx fonderie admin operator recover you@example.com
215
+ ```
216
+
217
+ Access levels map to scopes: **Read only** (`read`), **Editor** (`read`,
218
+ `write`), **Owner** (`read`, `write`, `secrets` — and operators and tokens).
219
+ Operator actions are logged under `operator:<email>`, which a client header
220
+ cannot override.
221
+
222
+ **Nothing to configure.** First sign-in on the console is the admin token
223
+ alone; the page then asks you to create your account and set up an
224
+ authenticator. `operatorKey` is optional hardening: it encrypts authenticator
225
+ secrets at rest so a database leak alone does not yield them (without it they
226
+ are stored as-is — a leaked one still needs the operator's password). Adding
227
+ it later is safe — existing secrets keep working and are sealed at the next
228
+ enrollment. Changing it once set invalidates every enrolled
229
+ authenticator. `operators: false` turns the sign-in routes off and brings
230
+ back the token gate.
231
+
176
232
  ## Scoped tokens
177
233
 
178
234
  The `adminToken` you configure is the **root**: every scope, and the only
package/brain/outcomes.md CHANGED
@@ -9,6 +9,21 @@ downloading tarballs.
9
9
 
10
10
  ## Database tables (after all migrations)
11
11
 
12
+ ### `fonderie_admin_invites`
13
+
14
+ ```sql
15
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid()
16
+ kind TEXT NOT NULL
17
+ token_hash TEXT NOT NULL UNIQUE
18
+ email TEXT NOT NULL
19
+ scopes TEXT[] NOT NULL DEFAULT '{}'
20
+ operator_id UUID REFERENCES fonderie_admin_operators(id) ON DELETE CASCADE
21
+ created_by TEXT NOT NULL
22
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
23
+ expires_at TIMESTAMPTZ NOT NULL
24
+ used_at TIMESTAMPTZ
25
+ ```
26
+
12
27
  ### `fonderie_admin_log`
13
28
 
14
29
  ```sql
@@ -25,6 +40,41 @@ request_id TEXT
25
40
  client_ip TEXT
26
41
  ```
27
42
 
43
+ ### `fonderie_admin_operators`
44
+
45
+ ```sql
46
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid()
47
+ email TEXT NOT NULL UNIQUE
48
+ name TEXT
49
+ password_hash TEXT NOT NULL
50
+ scopes TEXT[] NOT NULL
51
+ totp_secret TEXT
52
+ totp_confirmed_at TIMESTAMPTZ
53
+ totp_last_step BIGINT
54
+ backup_codes TEXT[] NOT NULL DEFAULT '{}'
55
+ failed_attempts INTEGER NOT NULL DEFAULT 0
56
+ locked_until TIMESTAMPTZ
57
+ created_by TEXT NOT NULL
58
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
59
+ last_login_at TIMESTAMPTZ
60
+ disabled_at TIMESTAMPTZ
61
+ ```
62
+
63
+ ### `fonderie_admin_sessions`
64
+
65
+ ```sql
66
+ id_hash TEXT PRIMARY KEY
67
+ operator_id UUID NOT NULL REFERENCES fonderie_admin_operators(id) ON DELETE CASCADE
68
+ stage TEXT NOT NULL
69
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
70
+ last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
71
+ expires_at TIMESTAMPTZ NOT NULL
72
+ step_up_at TIMESTAMPTZ
73
+ client_ip TEXT
74
+ user_agent TEXT
75
+ -- INDEX fonderie_admin_sessions_operator (operator_id)
76
+ ```
77
+
28
78
  ### `fonderie_admin_tokens`
29
79
 
30
80
  ```sql
@@ -46,6 +96,11 @@ Raw SQL ships in `node_modules/@fonderie/admin/dist/migrations/sql/` — read it
46
96
  | Method | Path | Middleware chain (auth / validation / handler) |
47
97
  |---|---|---|
48
98
  | GET | `/_admin` | `async () => setApiResponse( HTTP.OK, 'ADMIN_ATTENTION', 'What needs attention', attention(app, await doctor()), )` |
99
+ | GET | `/_admin/access/operators` | `[async () => setApiResponse(HTTP.OK, 'OPERATORS', 'Operators', await listOperators(store))] → 'read'` |
100
+ | PUT | `/_admin/access/operators/:id` | `[ validate(updateOperatorSchema), async (ctx) => { const b = body(ctx); const op = await findOperator(store, { id: idOf(ctx) }); if (!op) return setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such operator'); const scopes = b['scopes'] === undefined ? op.scopes : validScopes(b['scopes']); if (!scopes) return setApiResponse( HTTP.UNPROCESSABLE, 'INVALID', `scopes must be a non-empty list of ${SCOPES.join(', ')}`, ); if ( ctx.meta['adminOperator'] === op.email && !scopes.includes('secrets') && op.scopes.includes('secrets') ) { return setApiResponse( HTTP.CONFLICT, 'SELF_DEMOTION', 'You cannot remove your own highest scope. Ask another operator.', ); } const disabled = b['disabled']; if (disabled === true && ctx.meta['adminOperator'] === op.email) { return setApiResponse( HTTP.CONFLICT, 'SELF_DISABLE', 'You cannot disable yourself. Ask another operator.', ); } const [row] = await store.query<IOperatorRow>( `UPDATE fonderie_admin_operators SET scopes = $2, name = coalesce($3, name), disabled_at = CASE WHEN $4::boolean IS NULL THEN disabled_at WHEN $4 THEN coalesce(disabled_at, now()) ELSE NULL END WHERE id = $1 RETURNING id, email, name, password_hash AS "passwordHash", scopes, totp_secret AS "totpSecret", totp_confirmed_at AS "totpConfirmedAt", totp_last_step AS "totpLastStep", backup_codes AS "backupCodes", failed_attempts AS "failedAttempts", locked_until AS "lockedUntil", created_by AS "createdBy", created_at AS "createdAt", last_login_at AS "lastLoginAt", disabled_at AS "disabledAt"`, [ op.id, scopes, typeof b['name'] === 'string' ? b['name'] : null, typeof disabled === 'boolean' ? disabled : null, ], ); if (!row) return setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such operator'); // Any change of rights ends their sessions: new rights apply at next sign-in. await deleteOperatorSessions(store, op.id); return setApiResponse( HTTP.OK, 'OPERATOR_UPDATED', 'Operator updated', publicOperator(row), ); }, ] → 'root'` |
101
+ | POST | `/_admin/access/operators/:id/recovery` | `[ async (ctx) => { const op = await findOperator(store, { id: idOf(ctx) }); if (!op) return setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such operator'); await deleteOperatorSessions(store, op.id); const l = await createLink(store, { kind: 'recovery', email: op.email, operatorId: op.id, createdBy: actorOf(ctx), hours: RECOVERY_HOURS, }); return setApiResponse(HTTP.CREATED, 'RECOVERY_CREATED', 'Recovery link — shown once', { id: l.id, email: op.email, expiresAt: l.expiresAt, token: l.token, url: link(ctx, l.token), }); }, ] → 'root'` |
102
+ | POST | `/_admin/access/operators/invites` | `[ validate(inviteSchema), async (ctx) => { const b = body(ctx); const email = normalizeEmail(str(b['email'])); if (!EMAIL_RE.test(email)) return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID', 'Enter a valid email address.'); const scopes = validScopes(b['scopes']); if (!scopes) return setApiResponse( HTTP.UNPROCESSABLE, 'INVALID', `scopes must be a non-empty list of ${SCOPES.join(', ')}`, ); if (await findOperator(store, { email })) return setApiResponse( HTTP.CONFLICT, 'ALREADY_OPERATOR', 'That email is already an operator.', ); const hours = Number.isInteger(b['expiresInHours']) ? Math.min(Math.max(b['expiresInHours'] as number, 1), 336) : INVITE_HOURS; const l = await createLink(store, { kind: 'invite', email, scopes, createdBy: actorOf(ctx), hours, }); return setApiResponse(HTTP.CREATED, 'INVITE_CREATED', 'Invite link — shown once', { id: l.id, email, scopes, expiresAt: l.expiresAt, token: l.token, url: link(ctx, l.token), }); }, ] → 'root'` |
103
+ | DELETE | `/_admin/access/operators/links/:id` | `[ async (ctx) => { const rows = await store.query( `UPDATE fonderie_admin_invites SET used_at = now() WHERE id = $1 AND used_at IS NULL RETURNING id`, [idOf(ctx)], ); return rows.length ? setApiResponse(HTTP.OK, 'LINK_REVOKED', 'Link revoked') : setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such live link'); }, ] → 'root'` |
49
104
  | GET | `/_admin/access/tokens` | `async () => setApiResponse( HTTP.OK, 'ADMIN_TOKENS', 'Admin tokens', tokensReport(app, this.name, store ? await listTokens(store) : null), )` |
50
105
  | POST | `/_admin/access/tokens` | `[ validate(issueTokenSchema), async (ctx) => { const body = ctx.meta['body'] as { name: string; scopes: AdminScope[]; expiresInDays?: number; }; const createdBy = ctx.request.headers.get('x-actor') || 'admin-token'; const { token: plaintext, record } = await issueToken(store, { ...body, createdBy, }); // The plaintext is returned once and never stored. return setApiResponse(HTTP.CREATED, 'TOKEN_ISSUED', 'Token issued — shown once', { token: plaintext, ...record, }); }, ]` |
51
106
  | DELETE | `/_admin/access/tokens/:id` | `[ async (ctx) => { const ok = await revokeToken(store, ctx.meta.params?.['id'] ?? ''); return ok ? setApiResponse(HTTP.OK, 'TOKEN_REVOKED', 'Token revoked') : setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such live token'); }, ]` |
@@ -56,3 +111,13 @@ Raw SQL ships in `node_modules/@fonderie/admin/dist/migrations/sql/` — read it
56
111
  | GET | `/_admin/migrations` | `[ async () => setApiResponse( HTTP.OK, 'MIGRATIONS', 'Pending migrations by module', await migrationsReport(store, migrationSets), ), ]` |
57
112
  | POST | `/_admin/migrations/:module/apply` | `[ validate(applyMigrationsSchema), async (ctx) => { const { expect } = ctx.meta['body'] as { expect: string[] }; const name = ctx.meta.params?.['module'] ?? ''; const out = await applyModuleMigrations(store, migrationSets, name, expect); if (out.ok) { return setApiResponse( HTTP.OK, out.reason, out.reason === 'MIGRATIONS_APPLIED' ? `Applied. ${out.module.pending.length} still pending in ${name}.` : `${name} is already up to date`, out.module, ); } switch (out.reason) { case 'NOT_FOUND': return setApiResponse( HTTP.NOT_FOUND, 'NOT_FOUND', `No migration set named "${name}"`, ); case 'MIGRATIONS_OUT_OF_ORDER': return setApiResponse( HTTP.CONFLICT, out.reason, `Apply "${out.blockedBy}" first — it runs before "${name}" and is behind.`, { blockedBy: out.blockedBy }, ); case 'MIGRATIONS_CHANGED': return setApiResponse( HTTP.CONFLICT, out.reason, 'What is pending changed since you looked. Refresh and read it again.', { expected: out.expected, actual: out.actual }, ); default: return setApiResponse( HTTP.UNPROCESSABLE, out.reason, 'A pending migration deletes data. No down-migration brings it back — ' + 'apply it through CI or `npm run migrate`, not from here.', { files: out.files }, ); } }, ]` |
58
113
  | GET | `/_admin/routes` | `async () => setApiResponse(HTTP.OK, 'ADMIN_ROUTES', 'Exposed routes', routesReport(app, this.name))` |
114
+ | DELETE | `/_admin/session` | `[ check, async (ctx) => { const cookie = readCookie(ctx); if (cookie) await deleteSession(store, cookie); return clearCookie(setApiResponse(HTTP.OK, 'SIGNED_OUT', 'Signed out'), ctx); }, ]` |
115
+ | GET | `/_admin/session` | `[ check, async (ctx) => { const found = await readSession(store, readCookie(ctx)); const claimable = (await operatorCount(store)) === 0; return setApiResponse(HTTP.OK, 'ADMIN_SESSION', 'Session', { state: found ? stateOf(found.session.stage) : 'signed-out', operator: found ? publicOperator(found.op) : null, claimable, stepUpFresh: found ? stepUpFresh(found.session) : false, }); }, ]` |
116
+ | POST | `/_admin/session/claim` | `[ check, validate(claimSchema), async (ctx) => { if (!constantTimeEqual(bearer(ctx), deps.rootToken)) { return setApiResponse( HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Claiming needs the root admin token.', ); } const b = body(ctx); const email = normalizeEmail(str(b['email'])); if (!EMAIL_RE.test(email)) return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID', 'Enter a valid email address.'); const problem = passwordProblem(b['password']); if (problem) return setApiResponse(HTTP.UNPROCESSABLE, 'WEAK_PASSWORD', problem); const op = await claimFirstOperator(store, { email, name: str(b['name']) || undefined, password: str(b['password']), }); if (!op) return setApiResponse( HTTP.CONFLICT, 'ALREADY_CLAIMED', 'This console already has operators. Ask one for an invite.', ); return startSession(deps, ctx, op, 'enroll'); }, ]` |
117
+ | GET | `/_admin/session/enrollment` | `[ check, async (ctx) => { const s = await sessionAt(deps, ctx, 'enroll'); if (!s) return WRONG_STAGE(); const secret = await enrollmentSecret(store, box, s.op); const issuer = `Admin · ${new URL(ctx.request.url).hostname}`; const res = setApiResponse(HTTP.OK, 'ENROLLMENT', 'Scan this with an authenticator app', { secret, uri: totpUri(issuer, s.op.email, secret), account: s.op.email, issuer, }); res.headers.set('cache-control', 'no-store'); return res; }, ]` |
118
+ | POST | `/_admin/session/enrollment` | `[ check, validate(enrollmentSchema), async (ctx) => { const s = await sessionAt(deps, ctx, 'enroll'); if (!s) return WRONG_STAGE(); const codes = await confirmEnrollment(store, box, s.op, str(body(ctx)['code'])); if (!codes) { const fresh = await findOperator(store, { id: s.op.id }); return fresh && lockMinutes(fresh) ? LOCKED(fresh) : BAD_CODE(); } // A new session id at the privilege change: a pending id that leaked // never becomes a signed-in one. await deleteSession(store, s.cookie); const op = (await findOperator(store, { id: s.op.id })) ?? s.op; return startSession(deps, ctx, op, 'active', { backupCodes: codes }); }, ]` |
119
+ | POST | `/_admin/session/link` | `[ check, validate(redeemLinkSchema), async (ctx) => { const b = body(ctx); const problem = passwordProblem(b['password']); if (problem) return setApiResponse(HTTP.UNPROCESSABLE, 'WEAK_PASSWORD', problem); const r = await redeemLink(store, str(b['token']), { password: str(b['password']), name: str(b['name']) || undefined, }); if ('error' in r) { return r.error === 'ALREADY_OPERATOR' ? setApiResponse( HTTP.CONFLICT, 'ALREADY_OPERATOR', 'That email is already an operator. Sign in instead.', ) : setApiResponse( HTTP.NOT_FOUND, 'INVALID_LINK', 'This link has expired or was already used. Ask for a new one.', ); } return startSession(deps, ctx, r.op, 'enroll'); }, ]` |
120
+ | POST | `/_admin/session/link/inspect` | `[ check, validate(inspectLinkSchema), async (ctx) => { const link = await findLink(store, str(body(ctx)['token'])); return link ? setApiResponse(HTTP.OK, 'LINK', 'Link', { kind: link.kind, email: link.email }) : setApiResponse( HTTP.NOT_FOUND, 'INVALID_LINK', 'This link has expired or was already used. Ask for a new one.', ); }, ]` |
121
+ | POST | `/_admin/session/login` | `[ check, validate(loginSchema), async (ctx) => { const b = body(ctx); const result = await checkPassword(store, str(b['email']), str(b['password'])); if (result.op && 'locked' in result && result.locked) return LOCKED(result.op); if (!result.ok || !result.op) return BAD_CREDENTIALS(); return startSession( deps, ctx, result.op, result.op.totpConfirmedAt ? 'password' : 'enroll', ); }, ]` |
122
+ | POST | `/_admin/session/step-up` | `[ check, validate(factorSchema), async (ctx) => { const s = await sessionAt(deps, ctx, 'active'); if (!s) return WRONG_STAGE(); const b = body(ctx); const r = await checkSecondFactor(store, box, s.op, { code: b['code'], backupCode: b['backupCode'], }); if (!r.ok) return BAD_CODE(); await markStepUp(store, s.session.idHash); return setApiResponse(HTTP.OK, 'STEPPED_UP', 'Confirmed for five minutes', { stepUpFresh: true, ...(r.via === 'backup' ? { backupCodesLeft: r.backupLeft } : {}), }); }, ]` |
123
+ | POST | `/_admin/session/verify` | `[ check, validate(factorSchema), async (ctx) => { const s = await sessionAt(deps, ctx, 'password'); if (!s) return WRONG_STAGE(); const b = body(ctx); const r = await checkSecondFactor(store, box, s.op, { code: b['code'], backupCode: b['backupCode'], }); if (!r.ok) { const fresh = await findOperator(store, { id: s.op.id }); return fresh && lockMinutes(fresh) ? LOCKED(fresh) : BAD_CODE(); } await deleteSession(store, s.cookie); await store.query( `UPDATE fonderie_admin_operators SET last_login_at = now() WHERE id = $1`, [s.op.id], ); return startSession( deps, ctx, s.op, 'active', r.via === 'backup' ? { backupCodesLeft: r.backupLeft } : {}, ); }, ]` |
@@ -49,7 +49,7 @@ function applyModuleMigrations(store: IStoreAdapter, sets: readonly IMigrationSe
49
49
 
50
50
  const applyMigrationsSchema: IRequestSchema
51
51
 
52
- function requireAdminScope(bootstrap: string, store: IStoreAdapter | undefined, needed: AdminScope | "root"): Middleware
52
+ function requireAdminScope(bootstrap: string, store: IStoreAdapter | undefined, needed: AdminScope | "root", opts?: { stepUp?: boolean; operators?: boolean; }): Middleware
53
53
 
54
54
  function scopeFor(method: string, path: string): AdminScope
55
55
 
@@ -75,6 +75,8 @@ interface IAdminOptions {
75
75
  host?: string | string[];
76
76
  store?: IStoreAdapter;
77
77
  env?: string[];
78
+ operators?: boolean;
79
+ operatorKey?: string;
78
80
  }
79
81
 
80
82
  interface IAdminManifest {