@cogenta/auth 0.2.0 → 0.5.0

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.
Files changed (61) hide show
  1. package/dist/api-keys.d.ts +108 -0
  2. package/dist/api-keys.d.ts.map +1 -0
  3. package/dist/api-keys.js +345 -0
  4. package/dist/api-keys.js.map +1 -0
  5. package/dist/audit-integrity.d.ts +61 -0
  6. package/dist/audit-integrity.d.ts.map +1 -0
  7. package/dist/audit-integrity.js +130 -0
  8. package/dist/audit-integrity.js.map +1 -0
  9. package/dist/audit.d.ts +78 -2
  10. package/dist/audit.d.ts.map +1 -1
  11. package/dist/audit.js +168 -24
  12. package/dist/audit.js.map +1 -1
  13. package/dist/credentials.d.ts +27 -0
  14. package/dist/credentials.d.ts.map +1 -1
  15. package/dist/credentials.js +61 -0
  16. package/dist/credentials.js.map +1 -1
  17. package/dist/index.d.ts +11 -4
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +5 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/login.d.ts +63 -6
  22. package/dist/login.d.ts.map +1 -1
  23. package/dist/login.js +90 -10
  24. package/dist/login.js.map +1 -1
  25. package/dist/mfa.d.ts +1 -1
  26. package/dist/mfa.d.ts.map +1 -1
  27. package/dist/mfa.js +3 -1
  28. package/dist/mfa.js.map +1 -1
  29. package/dist/recovery-codes.d.ts +25 -0
  30. package/dist/recovery-codes.d.ts.map +1 -0
  31. package/dist/recovery-codes.js +56 -0
  32. package/dist/recovery-codes.js.map +1 -0
  33. package/dist/resets.d.ts +15 -1
  34. package/dist/resets.d.ts.map +1 -1
  35. package/dist/resets.js +10 -0
  36. package/dist/resets.js.map +1 -1
  37. package/dist/sessions.d.ts +24 -0
  38. package/dist/sessions.d.ts.map +1 -1
  39. package/dist/sessions.js +25 -4
  40. package/dist/sessions.js.map +1 -1
  41. package/dist/store.d.ts +8 -0
  42. package/dist/store.d.ts.map +1 -1
  43. package/dist/store.js +8 -1
  44. package/dist/store.js.map +1 -1
  45. package/dist/tables.d.ts +18 -0
  46. package/dist/tables.d.ts.map +1 -1
  47. package/dist/tables.js +152 -0
  48. package/dist/tables.js.map +1 -1
  49. package/dist/types.d.ts +119 -2
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/types.js +10 -1
  52. package/dist/types.js.map +1 -1
  53. package/dist/user-agent.d.ts +18 -0
  54. package/dist/user-agent.d.ts.map +1 -0
  55. package/dist/user-agent.js +39 -0
  56. package/dist/user-agent.js.map +1 -0
  57. package/dist/users.d.ts +24 -1
  58. package/dist/users.d.ts.map +1 -1
  59. package/dist/users.js +72 -3
  60. package/dist/users.js.map +1 -1
  61. package/package.json +3 -3
@@ -0,0 +1,108 @@
1
+ import { type DatabaseHandle } from '@cogenta/core';
2
+ import type { ApiKey, ApiKeyRotationResult, ApiKeyUsage, CreateApiKeyInput, IssuedApiKey } from './types.js';
3
+ /**
4
+ * A generous but real per-key request quota (fiche 20, "décisions à
5
+ * prendre"): high enough that no legitimate integration ever notices it,
6
+ * low enough that a leaked key cannot read the whole site as fast as the
7
+ * network allows. Applied whenever a key is minted — by `create` or by
8
+ * `rotate` — without an explicit quota of its own.
9
+ */
10
+ export declare const DEFAULT_RATE_LIMIT_PER_MINUTE = 600;
11
+ /**
12
+ * Fiche 62 task 2: how long a revoked key has to sit before it can be
13
+ * purged — the same reasoning and the same default as content's own
14
+ * `DEFAULT_TRASH_RETAIN_DAYS` (`@cogenta/schema`), applied here because a
15
+ * revoked key is not stored anywhere else: purging too early is the one
16
+ * mistake `purge()` must never make, since it is a real `DELETE`, not a
17
+ * second layer of soft state on top of `revoked_at`.
18
+ */
19
+ export declare const MIN_PURGE_AFTER_REVOKED_DAYS = 30;
20
+ /**
21
+ * Fiche 62 task 3 (decision "(b)", recommended by the fiche): a key revoked
22
+ * by mistake is not reactivated — `revoked_at` never goes back to `null` —
23
+ * but a short window after the mistake, `recover()` mints a replacement the
24
+ * same way `rotate()` does for an active key. Past this window the only way
25
+ * back is minting a brand new key from scratch, on purpose: the longer a key
26
+ * has been dead, the less likely "revoked by mistake" is the true story.
27
+ */
28
+ export declare const RECOVERY_WINDOW_MS: number;
29
+ /** A raw bearer token is an API key only if it has this shape — checked before any DB lookup. */
30
+ export declare function looksLikeApiKey(token: string): boolean;
31
+ export interface RotateApiKeyOptions {
32
+ /** How long the replaced key keeps working alongside the new one. */
33
+ readonly graceMs: number;
34
+ }
35
+ /**
36
+ * Fiche 67 task 5. Both omitted (the historical call shape, `mcp.tsx`'s
37
+ * picker among others) still returns every key, unpaginated, byte for byte —
38
+ * this is additive, not a breaking change to an existing publishable
39
+ * interface.
40
+ */
41
+ export interface ListApiKeysOptions {
42
+ readonly limit?: number;
43
+ readonly offset?: number;
44
+ }
45
+ /**
46
+ * Fiche feedback: a key's name/scope/quota had no edit path at all after
47
+ * creation — only `rotate()` existed, and it reissues the secret under the
48
+ * *same* name/scope, never a changed one. Every field absent from the patch
49
+ * is left exactly as saved; `rateLimitPerMinute: null` clears an explicit
50
+ * quota back to `DEFAULT_RATE_LIMIT_PER_MINUTE`, matching how omitting it at
51
+ * `create()` time already behaves.
52
+ */
53
+ export interface UpdateApiKeyInput {
54
+ readonly name?: string;
55
+ readonly scope?: readonly string[];
56
+ readonly rateLimitPerMinute?: number | null;
57
+ }
58
+ export interface ApiKeyStore {
59
+ /** Mints a key and returns the raw secret once. It is never stored or returned again. */
60
+ create(input: CreateApiKeyInput): Promise<IssuedApiKey>;
61
+ /** Tri-state patch — see `UpdateApiKeyInput`. Never touches the key's secret, prefix, or lifecycle fields. */
62
+ update(id: string, patch: UpdateApiKeyInput): Promise<ApiKey>;
63
+ /** Never the raw key or its hash — a prefix is all a list ever shows. Newest first; `limit`/`offset` page it (fiche 67 task 5), both absent means "every key". */
64
+ list(options?: ListApiKeysOptions): Promise<readonly ApiKey[]>;
65
+ getById(id: string): Promise<ApiKey | null>;
66
+ revoke(id: string): Promise<void>;
67
+ /**
68
+ * Fiche 62 task 2: a real, permanent `DELETE` — the row and its usage
69
+ * history are gone, never just hidden. Refuses anything but a key that is
70
+ * both revoked and has been for at least `MIN_PURGE_AFTER_REVOKED_DAYS`:
71
+ * an active key (even one technically past its `expiresAt`) is never
72
+ * purgeable, and neither is a key revoked too recently — see the piège
73
+ * connu in fiche 62 §5.
74
+ */
75
+ purge(id: string): Promise<void>;
76
+ /**
77
+ * Fiche 62 task 3, decision (b): recovers from a key revoked by mistake
78
+ * *without* lifting `revoked_at` — the revoked row stays revoked forever,
79
+ * exactly as `statusOf` in the admin already assumes. Instead, within
80
+ * `RECOVERY_WINDOW_MS` of the revocation, this mints a fresh replacement
81
+ * carrying the same name, scope and quota, the same way `rotate` does for
82
+ * a live key. Refuses a key that was never revoked (nothing to recover
83
+ * from — rotate it instead) and a key revoked longer ago than the window.
84
+ */
85
+ recover(id: string): Promise<IssuedApiKey>;
86
+ /**
87
+ * Resolves a raw bearer token to the key it names, or `null` if it does not
88
+ * exist, is revoked, or has expired. Touches `lastUsedAt` on success, the
89
+ * same sliding-window courtesy a session gets, and counts the call toward
90
+ * this key's aggregated daily usage (fiche 20 task 4).
91
+ */
92
+ verify(rawKey: string): Promise<ApiKey | null>;
93
+ /**
94
+ * "Faire tourner cette clé" (fiche 20 task 2): mints a replacement carrying
95
+ * the same name, scope and quota, and leaves the original valid for
96
+ * `graceMs` longer rather than revoking it outright — a rotation with no
97
+ * window where nothing authenticates.
98
+ *
99
+ * Refuses a key that is already revoked or expired: there is nothing left
100
+ * to hand a grace period to, and calling that "rotation" would hide that
101
+ * the integration already stopped working.
102
+ */
103
+ rotate(id: string, options: RotateApiKeyOptions): Promise<ApiKeyRotationResult>;
104
+ /** Aggregated call counts over the last 7 and 30 days — never a line per call. */
105
+ usage(id: string): Promise<ApiKeyUsage>;
106
+ }
107
+ export declare function createApiKeyStore(db: DatabaseHandle, now?: () => number): ApiKeyStore;
108
+ //# sourceMappingURL=api-keys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-keys.d.ts","sourceRoot":"","sources":["../src/api-keys.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,cAAc,EAKpB,MAAM,eAAe,CAAA;AAEtB,OAAO,KAAK,EACV,MAAM,EACN,oBAAoB,EACpB,WAAW,EACX,iBAAiB,EACjB,YAAY,EACb,MAAM,YAAY,CAAA;AA0BnB;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,MAAM,CAAA;AAEhD;;;;;;;GAOG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAA;AAE9C;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,QAAsB,CAAA;AAWrD,iGAAiG;AACjG,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEtD;AAuDD,MAAM,WAAW,mBAAmB;IAClC,qEAAqE;IACrE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB;AAED;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CACzB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAClC,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5C;AAED,MAAM,WAAW,WAAW;IAC1B,yFAAyF;IACzF,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,YAAY,CAAC,CAAA;IACvD,8GAA8G;IAC9G,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAC7D,kKAAkK;IAClK,IAAI,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAAA;IAC9D,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAC3C,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACjC;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAChC;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAAA;IAC1C;;;;;OAKG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAC9C;;;;;;;;;OASG;IACH,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAA;IAC/E,kFAAkF;IAClF,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;CACxC;AAED,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,GAAE,MAAM,MAAiB,GAAG,WAAW,CA6S/F"}
@@ -0,0 +1,345 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { CogentaError, identifier, newId, sql, limit as sqlLimit, } from '@cogenta/core';
3
+ import { TABLES } from './tables.js';
4
+ /**
5
+ * Machine-to-machine bearer credentials (L13 task 8; lifecycle, quota and
6
+ * usage added by fiche 20).
7
+ *
8
+ * `resolveActor` in `@cogenta/api` already recognises one bearer-token shape:
9
+ * a session, minted at sign-in for a human. This store mints the other
10
+ * shape — a long-lived key, minted once by an admin for a script or an
11
+ * integration — kept deliberately close to `sessions.ts` in every way that
12
+ * matters: an opaque random secret, hashed before it ever reaches disk, and
13
+ * looked up by that hash, never by the secret itself.
14
+ *
15
+ * A key is not a password. A password is chosen by a person and has to
16
+ * survive being typed and remembered, which is exactly what makes it
17
+ * guessable and worth slowing down with scrypt. A key is generated here,
18
+ * 256 bits of randomness no human ever picks — the same property a session
19
+ * token has — so it is hashed the same fast way a session is, and the
20
+ * defence against guessing is the key space, not the hash cost.
21
+ */
22
+ const KEY_BYTES = 32;
23
+ const KEY_PREFIX = 'cogenta_sk_';
24
+ /** How much of the raw key stays visible forever, so a list of keys is recognisable. */
25
+ const VISIBLE_PREFIX_LENGTH = 12;
26
+ /**
27
+ * A generous but real per-key request quota (fiche 20, "décisions à
28
+ * prendre"): high enough that no legitimate integration ever notices it,
29
+ * low enough that a leaked key cannot read the whole site as fast as the
30
+ * network allows. Applied whenever a key is minted — by `create` or by
31
+ * `rotate` — without an explicit quota of its own.
32
+ */
33
+ export const DEFAULT_RATE_LIMIT_PER_MINUTE = 600;
34
+ /**
35
+ * Fiche 62 task 2: how long a revoked key has to sit before it can be
36
+ * purged — the same reasoning and the same default as content's own
37
+ * `DEFAULT_TRASH_RETAIN_DAYS` (`@cogenta/schema`), applied here because a
38
+ * revoked key is not stored anywhere else: purging too early is the one
39
+ * mistake `purge()` must never make, since it is a real `DELETE`, not a
40
+ * second layer of soft state on top of `revoked_at`.
41
+ */
42
+ export const MIN_PURGE_AFTER_REVOKED_DAYS = 30;
43
+ /**
44
+ * Fiche 62 task 3 (decision "(b)", recommended by the fiche): a key revoked
45
+ * by mistake is not reactivated — `revoked_at` never goes back to `null` —
46
+ * but a short window after the mistake, `recover()` mints a replacement the
47
+ * same way `rotate()` does for an active key. Past this window the only way
48
+ * back is minting a brand new key from scratch, on purpose: the longer a key
49
+ * has been dead, the less likely "revoked by mistake" is the true story.
50
+ */
51
+ export const RECOVERY_WINDOW_MS = 24 * 60 * 60 * 1000;
52
+ function issueKey() {
53
+ return `${KEY_PREFIX}${randomBytes(KEY_BYTES).toString('base64url')}`;
54
+ }
55
+ /** Stored hashed, like a session token — a leaked table hands out nothing live. */
56
+ function hashKey(key) {
57
+ return createHash('sha256').update(key).digest('base64url');
58
+ }
59
+ /** A raw bearer token is an API key only if it has this shape — checked before any DB lookup. */
60
+ export function looksLikeApiKey(token) {
61
+ return token.startsWith(KEY_PREFIX) && token.length > KEY_PREFIX.length;
62
+ }
63
+ /** `YYYY-MM-DD`, UTC — the same day boundary everywhere, regardless of server timezone. */
64
+ function dayKey(ms) {
65
+ return new Date(ms).toISOString().slice(0, 10);
66
+ }
67
+ function fromRow(row) {
68
+ return {
69
+ id: row.id,
70
+ name: row.name,
71
+ prefix: row.key_prefix,
72
+ scope: JSON.parse(row.scope),
73
+ createdBy: row.created_by,
74
+ createdAt: row.created_at,
75
+ expiresAt: row.expires_at ?? undefined,
76
+ revokedAt: row.revoked_at ?? undefined,
77
+ lastUsedAt: row.last_used_at ?? undefined,
78
+ rateLimitPerMinute: row.rate_limit_per_minute ?? DEFAULT_RATE_LIMIT_PER_MINUTE,
79
+ supersededBy: row.superseded_by ?? undefined,
80
+ };
81
+ }
82
+ function assertScope(scope) {
83
+ if (scope.length === 0 || scope.some((role) => role.trim().length === 0)) {
84
+ throw new CogentaError({
85
+ code: 'QUERY_INVALID',
86
+ message: 'An API key must be granted at least one non-empty role name.',
87
+ hint: 'A key with no role can authenticate and do nothing at all — pass at least one role in "scope".',
88
+ });
89
+ }
90
+ }
91
+ function keyNotFound() {
92
+ return new CogentaError({
93
+ code: 'API_KEY_NOT_FOUND',
94
+ message: 'No API key with that id.',
95
+ hint: 'It may already have been revoked and removed from the list, or the id may be mistyped.',
96
+ });
97
+ }
98
+ export function createApiKeyStore(db, now = Date.now) {
99
+ const table = identifier(TABLES.apiKeys, db.dialect);
100
+ const usageTable = identifier(TABLES.apiKeyUsage, db.dialect);
101
+ async function create(input) {
102
+ assertScope(input.scope);
103
+ const key = issueKey();
104
+ const id = newId(now);
105
+ const created = new Date(now()).toISOString();
106
+ const prefix = key.slice(0, VISIBLE_PREFIX_LENGTH);
107
+ const rateLimitPerMinute = input.rateLimitPerMinute ?? DEFAULT_RATE_LIMIT_PER_MINUTE;
108
+ await db.query(sql `
109
+ insert into ${table}
110
+ (id, name, key_hash, key_prefix, scope, created_by, created_at, expires_at, revoked_at,
111
+ last_used_at, rate_limit_per_minute, superseded_by)
112
+ values
113
+ (${id}, ${input.name}, ${hashKey(key)}, ${prefix}, ${JSON.stringify(input.scope)},
114
+ ${input.createdBy}, ${created}, ${input.expiresAt ?? null}, ${null},
115
+ ${null}, ${rateLimitPerMinute}, ${null})`);
116
+ return {
117
+ id,
118
+ name: input.name,
119
+ prefix,
120
+ scope: input.scope,
121
+ createdBy: input.createdBy,
122
+ createdAt: created,
123
+ expiresAt: input.expiresAt,
124
+ revokedAt: undefined,
125
+ lastUsedAt: undefined,
126
+ rateLimitPerMinute,
127
+ supersededBy: undefined,
128
+ key,
129
+ };
130
+ }
131
+ async function recordUsage(id) {
132
+ const day = dayKey(now());
133
+ const updated = await db.query(sql `update ${usageTable} set count = count + 1 where key_id = ${id} and day = ${day}`);
134
+ if (updated.rowsAffected > 0)
135
+ return;
136
+ // First call of the day for this key: insert the row. A concurrent first
137
+ // call could lose this race — the unique index on (key_id, day) then
138
+ // refuses the second insert, and the fallback update picks up the count
139
+ // that insert would have started at 1.
140
+ await db
141
+ .query(sql `insert into ${usageTable} (id, key_id, day, count) values (${newId(now)}, ${id}, ${day}, ${1})`)
142
+ .catch(() => db.query(sql `update ${usageTable} set count = count + 1 where key_id = ${id} and day = ${day}`));
143
+ }
144
+ return {
145
+ create,
146
+ list: async (options) => {
147
+ let statement = sql `select * from ${table} order by created_at desc, id desc`;
148
+ if (options?.limit !== undefined) {
149
+ statement = sql `${statement} limit ${sqlLimit(options.limit)} offset ${sqlLimit(options.offset ?? 0)}`;
150
+ }
151
+ const result = await db.query(statement);
152
+ return result.rows.map(fromRow);
153
+ },
154
+ getById: async (id) => {
155
+ const result = await db.query(sql `select * from ${table} where id = ${id}`);
156
+ const row = result.rows[0];
157
+ return row === undefined ? null : fromRow(row);
158
+ },
159
+ update: async (id, patch) => {
160
+ const result = await db.query(sql `select * from ${table} where id = ${id}`);
161
+ const row = result.rows[0];
162
+ if (row === undefined)
163
+ throw keyNotFound();
164
+ if (patch.scope !== undefined)
165
+ assertScope(patch.scope);
166
+ const name = patch.name ?? row.name;
167
+ const scopeJson = patch.scope === undefined ? row.scope : JSON.stringify(patch.scope);
168
+ const rateLimitPerMinute = patch.rateLimitPerMinute === undefined
169
+ ? row.rate_limit_per_minute
170
+ : patch.rateLimitPerMinute;
171
+ await db.query(sql `
172
+ update ${table}
173
+ set name = ${name}, scope = ${scopeJson}, rate_limit_per_minute = ${rateLimitPerMinute}
174
+ where id = ${id}`);
175
+ return fromRow({
176
+ ...row,
177
+ name,
178
+ scope: scopeJson,
179
+ rate_limit_per_minute: rateLimitPerMinute,
180
+ });
181
+ },
182
+ revoke: async (id) => {
183
+ const revokedAt = new Date(now()).toISOString();
184
+ await db.query(sql `update ${table} set revoked_at = ${revokedAt} where id = ${id} and revoked_at is null`);
185
+ },
186
+ purge: async (id) => {
187
+ const result = await db.query(sql `select * from ${table} where id = ${id}`);
188
+ const row = result.rows[0];
189
+ if (row === undefined)
190
+ throw keyNotFound();
191
+ if (row.revoked_at === null) {
192
+ throw new CogentaError({
193
+ code: 'API_KEY_PURGE_INVALID',
194
+ message: 'Only a revoked key can be purged.',
195
+ hint: 'Revoke this key first if it should no longer exist at all.',
196
+ });
197
+ }
198
+ const revokedAgoMs = now() - new Date(row.revoked_at).getTime();
199
+ const minAgeMs = MIN_PURGE_AFTER_REVOKED_DAYS * 24 * 60 * 60 * 1000;
200
+ if (revokedAgoMs < minAgeMs) {
201
+ throw new CogentaError({
202
+ code: 'API_KEY_PURGE_INVALID',
203
+ message: `A revoked key can only be purged after ${MIN_PURGE_AFTER_REVOKED_DAYS} days.`,
204
+ hint: 'Wait for the retention window to pass, or leave it revoked — a revoked key can never authenticate again.',
205
+ });
206
+ }
207
+ // Its usage history first: nothing references it by foreign key, but a
208
+ // dangling row for a key id that no longer exists would only ever be
209
+ // dead weight, never something `usage()` is asked about again.
210
+ await db.query(sql `delete from ${usageTable} where key_id = ${id}`);
211
+ await db.query(sql `delete from ${table} where id = ${id}`);
212
+ },
213
+ recover: async (id) => {
214
+ const result = await db.query(sql `select * from ${table} where id = ${id}`);
215
+ const row = result.rows[0];
216
+ if (row === undefined)
217
+ throw keyNotFound();
218
+ if (row.revoked_at === null) {
219
+ throw new CogentaError({
220
+ code: 'API_KEY_RECOVERY_INVALID',
221
+ message: 'Only a revoked key can be recovered — an active key should be rotated instead.',
222
+ hint: 'Use rotate() for a key that is still active.',
223
+ });
224
+ }
225
+ const revokedAgoMs = now() - new Date(row.revoked_at).getTime();
226
+ if (revokedAgoMs > RECOVERY_WINDOW_MS) {
227
+ throw new CogentaError({
228
+ code: 'API_KEY_RECOVERY_INVALID',
229
+ message: `This key was revoked more than ${RECOVERY_WINDOW_MS / (60 * 60 * 1000)} hours ago — recovery is only for a very recent, mistaken revocation.`,
230
+ hint: 'Create a brand new key instead. The revoked key stays revoked either way.',
231
+ });
232
+ }
233
+ const scope = JSON.parse(row.scope);
234
+ const rateLimitPerMinute = row.rate_limit_per_minute ?? DEFAULT_RATE_LIMIT_PER_MINUTE;
235
+ const currentTime = now();
236
+ // Same duration-preserving policy as rotate(): a replacement for a key
237
+ // that never expired keeps not expiring, and one that had a fixed
238
+ // lifetime gets that same duration, restarted from now.
239
+ const expiresAt = row.expires_at === null
240
+ ? undefined
241
+ : new Date(currentTime +
242
+ Math.max(new Date(row.expires_at).getTime() - new Date(row.created_at).getTime(), 0)).toISOString();
243
+ const issued = await create({
244
+ name: row.name,
245
+ scope,
246
+ createdBy: row.created_by,
247
+ rateLimitPerMinute,
248
+ ...(expiresAt === undefined ? {} : { expiresAt }),
249
+ });
250
+ // `revoked_at` is deliberately never touched: the row this recovers
251
+ // from stays revoked forever, exactly as decision (b) in fiche 62 §3
252
+ // requires. `superseded_by` only records what replaced it, the same
253
+ // way rotation already links a live key to what came after it.
254
+ await db.query(sql `update ${table} set superseded_by = ${issued.id} where id = ${id}`);
255
+ return issued;
256
+ },
257
+ verify: async (rawKey) => {
258
+ if (!looksLikeApiKey(rawKey))
259
+ return null;
260
+ const targetHash = hashKey(rawKey);
261
+ const result = await db.query(sql `select * from ${table} where key_hash = ${targetHash}`);
262
+ const row = result.rows[0];
263
+ if (row === undefined)
264
+ return null;
265
+ if (row.revoked_at !== null)
266
+ return null;
267
+ if (row.expires_at !== null && new Date(row.expires_at).getTime() <= now())
268
+ return null;
269
+ const lastUsedAt = new Date(now()).toISOString();
270
+ await db.query(sql `update ${table} set last_used_at = ${lastUsedAt} where id = ${row.id}`);
271
+ await recordUsage(row.id);
272
+ return fromRow({ ...row, last_used_at: lastUsedAt });
273
+ },
274
+ rotate: async (id, options) => {
275
+ const result = await db.query(sql `select * from ${table} where id = ${id}`);
276
+ const row = result.rows[0];
277
+ if (row === undefined)
278
+ throw keyNotFound();
279
+ if (row.revoked_at !== null) {
280
+ throw new CogentaError({
281
+ code: 'API_KEY_ROTATION_INVALID',
282
+ message: 'A revoked key cannot be rotated.',
283
+ hint: 'Create a new key instead — there is nothing left to hand a grace period to.',
284
+ });
285
+ }
286
+ const currentTime = now();
287
+ if (row.expires_at !== null && new Date(row.expires_at).getTime() <= currentTime) {
288
+ throw new CogentaError({
289
+ code: 'API_KEY_ROTATION_INVALID',
290
+ message: 'An expired key cannot be rotated.',
291
+ hint: 'Create a new key instead — there is nothing left to hand a grace period to.',
292
+ });
293
+ }
294
+ const scope = JSON.parse(row.scope);
295
+ const rateLimitPerMinute = row.rate_limit_per_minute ?? DEFAULT_RATE_LIMIT_PER_MINUTE;
296
+ // The new key's own expiry policy preserves the *duration* the old key
297
+ // was valid for, restarted from now — never its literal calendar date,
298
+ // which could already be imminent. A key that never expired keeps not
299
+ // expiring; nothing here invents a default the caller did not ask for.
300
+ const expiresAt = row.expires_at === null
301
+ ? undefined
302
+ : new Date(currentTime +
303
+ Math.max(new Date(row.expires_at).getTime() - new Date(row.created_at).getTime(), 0)).toISOString();
304
+ const issued = await create({
305
+ name: row.name,
306
+ scope,
307
+ createdBy: row.created_by,
308
+ rateLimitPerMinute,
309
+ ...(expiresAt === undefined ? {} : { expiresAt }),
310
+ });
311
+ // The old key's grace window: it keeps working until whichever comes
312
+ // first, its own existing expiry or the chosen grace period — rotating
313
+ // a key that was about to expire tomorrow must not grant it another
314
+ // week.
315
+ const graceExpiry = new Date(currentTime + options.graceMs).toISOString();
316
+ const previousExpiresAt = row.expires_at !== null && row.expires_at < graceExpiry ? row.expires_at : graceExpiry;
317
+ await db.query(sql `
318
+ update ${table} set expires_at = ${previousExpiresAt}, superseded_by = ${issued.id}
319
+ where id = ${id}`);
320
+ return {
321
+ issued,
322
+ previous: fromRow({
323
+ ...row,
324
+ expires_at: previousExpiresAt,
325
+ superseded_by: issued.id,
326
+ }),
327
+ };
328
+ },
329
+ usage: async (id) => {
330
+ const since30 = dayKey(now() - 29 * 24 * 60 * 60 * 1000);
331
+ const since7 = dayKey(now() - 6 * 24 * 60 * 60 * 1000);
332
+ const result = await db.query(sql `select day, count from ${usageTable} where key_id = ${id} and day >= ${since30}`);
333
+ let last7Days = 0;
334
+ let last30Days = 0;
335
+ for (const row of result.rows) {
336
+ const count = Number(row.count);
337
+ last30Days += count;
338
+ if (row.day >= since7)
339
+ last7Days += count;
340
+ }
341
+ return { last7Days, last30Days };
342
+ },
343
+ };
344
+ }
345
+ //# sourceMappingURL=api-keys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-keys.js","sourceRoot":"","sources":["../src/api-keys.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,61 @@
1
+ import { type DatabaseHandle } from '@cogenta/core';
2
+ import type { AuditChainPoint, AuditLog } from './audit.js';
3
+ /**
4
+ * Fiche 21 task 3: "un bouton qu'on ne presse jamais ne protège de rien" — a
5
+ * scheduled counterpart to the manual "verify now" button, with somewhere to
6
+ * remember what it last found so the admin screen can say "checked 40
7
+ * seconds ago, chain intact" without replaying the whole log to answer that.
8
+ *
9
+ * Two things this store is careful about:
10
+ * - **Bounded.** Most runs are `audit.verifyRange(checkpoint)`, not
11
+ * `audit.verify()` — a million-entry log cannot be replayed on every
12
+ * tick. A full replay still happens periodically (`fullCheckIntervalMs`,
13
+ * a week by default) as the "vérification complète plus rare en secours"
14
+ * the fiche asks for, because an incremental check can only prove the
15
+ * *suffix* since the last checkpoint is intact — it does not re-read
16
+ * entries before it, so tampering with already-checkpointed history is
17
+ * only ever caught by a full run.
18
+ * - **Sticky once broken.** A break is not quietly retried away: once
19
+ * `state` is `'broken'`, only an explicit `check({ full: true })` (the
20
+ * admin's own "verify now", or an operator who has since pruned the bad
21
+ * prefix) attempts to move past it. Every other tick just re-stamps
22
+ * `lastCheckedAt` so "still checking" is visible without pretending the
23
+ * problem went away on its own.
24
+ */
25
+ export type AuditIntegrityMode = 'incremental' | 'full';
26
+ export type AuditIntegrityState = 'never-run' | 'ok' | 'broken';
27
+ export interface AuditIntegrityStatus {
28
+ readonly state: AuditIntegrityState;
29
+ readonly checkpoint: AuditChainPoint | null;
30
+ readonly entriesChecked: number;
31
+ readonly lastCheckedAt: string | null;
32
+ readonly lastMode: AuditIntegrityMode | null;
33
+ readonly lastFullCheckedAt: string | null;
34
+ readonly brokenAt: string | null;
35
+ readonly brokenEntryId: string | null;
36
+ readonly brokenMessage: string | null;
37
+ }
38
+ export interface AuditIntegrityCheckResult {
39
+ readonly status: AuditIntegrityStatus;
40
+ /**
41
+ * `true` only on the run that first found the chain broken — the signal
42
+ * an alert-sending caller should use to actually send one, rather than
43
+ * re-alerting on every subsequent tick while the same break stands.
44
+ */
45
+ readonly newlyBroken: boolean;
46
+ }
47
+ export interface AuditIntegrityStore {
48
+ /** Reads the last outcome without running a new check — cheap, one row. */
49
+ status(): Promise<AuditIntegrityStatus>;
50
+ /** Runs a check now. `full: true` forces a full replay instead of the usual incremental one — also what re-attempts past a `'broken'` state. */
51
+ check(options?: {
52
+ readonly full?: boolean;
53
+ }): Promise<AuditIntegrityCheckResult>;
54
+ }
55
+ export interface AuditIntegrityOptions {
56
+ readonly now?: () => number;
57
+ /** How rarely a full replay runs on its own. Default: 7 days. */
58
+ readonly fullCheckIntervalMs?: number;
59
+ }
60
+ export declare function createAuditIntegrityStore(db: DatabaseHandle, audit: Pick<AuditLog, 'verifyRange'>, options?: AuditIntegrityOptions): AuditIntegrityStore;
61
+ //# sourceMappingURL=audit-integrity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audit-integrity.d.ts","sourceRoot":"","sources":["../src/audit-integrity.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,cAAc,EAAmC,MAAM,eAAe,CAAA;AACpF,OAAO,KAAK,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAG3D;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,MAAM,MAAM,kBAAkB,GAAG,aAAa,GAAG,MAAM,CAAA;AACvD,MAAM,MAAM,mBAAmB,GAAG,WAAW,GAAG,IAAI,GAAG,QAAQ,CAAA;AAE/D,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,KAAK,EAAE,mBAAmB,CAAA;IACnC,QAAQ,CAAC,UAAU,EAAE,eAAe,GAAG,IAAI,CAAA;IAC3C,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,QAAQ,CAAC,QAAQ,EAAE,kBAAkB,GAAG,IAAI,CAAA;IAC5C,QAAQ,CAAC,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAA;IACzC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IAChC,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;CACtC;AAED,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,MAAM,EAAE,oBAAoB,CAAA;IACrC;;;;OAIG;IACH,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAA;CAC9B;AAED,MAAM,WAAW,mBAAmB;IAClC,2EAA2E;IAC3E,MAAM,IAAI,OAAO,CAAC,oBAAoB,CAAC,CAAA;IACvC,gJAAgJ;IAChJ,KAAK,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAAA;CACjF;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAC3B,iEAAiE;IACjE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;CACtC;AA4DD,wBAAgB,yBAAyB,CACvC,EAAE,EAAE,cAAc,EAClB,KAAK,EAAE,IAAI,CAAC,QAAQ,EAAE,aAAa,CAAC,EACpC,OAAO,GAAE,qBAA0B,GAClC,mBAAmB,CAiGrB"}