@wtfalch/keys 0.1.0 → 0.3.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.
@@ -1,10 +1,12 @@
1
1
  import { and, eq, inArray, isNull } from 'drizzle-orm';
2
2
  import { sql } from 'drizzle-orm';
3
3
  import { fromBase64Url, toBase64Url } from './codec.js';
4
+ import { canonicalJson } from './encoding.js';
4
5
  import { keyPrefixOf, mintSecret, rotateSecret, secretMatches } from './secret.js';
5
6
  import { keysIssuedCredentials } from './tables.js';
6
7
  import { verifyIssuedRowSignature } from './verify.js';
7
8
  export { ID_PATTERN, SIGNED_PREFIX, } from '../worker-contract.js';
9
+ export { createPostgresRateLimiter, } from './ratelimit.js';
8
10
  function rowsOf(result) {
9
11
  if (Array.isArray(result))
10
12
  return result;
@@ -26,11 +28,11 @@ function toSignableRow(row) {
26
28
  };
27
29
  }
28
30
  export function createCredentialIssuer(options) {
29
- const { db, worker, credential, verifyKeys, audit, prefix } = options;
31
+ const { db, worker, credential, verifyKeys, audit, prefix, rateLimiter } = options;
30
32
  const now = options.now ?? (() => Date.now());
31
33
  const table = keysIssuedCredentials;
32
- async function loadById(id) {
33
- const [row] = await db.select().from(table).where(eq(table.id, id)).limit(1);
34
+ async function loadById(id, handle = db) {
35
+ const [row] = await handle.select().from(table).where(eq(table.id, id)).limit(1);
34
36
  return row;
35
37
  }
36
38
  async function loadByPrefix(keyPrefix) {
@@ -42,23 +44,29 @@ export function createCredentialIssuer(options) {
42
44
  return row;
43
45
  }
44
46
  /** Every descendant of `id`, `id` itself included. Mirrors valet's `subtreeOf` (`self-service.ts`). */
45
- async function subtreeIds(id) {
46
- const result = await db.execute(sql `
47
+ async function subtreeIds(id, handle) {
48
+ const result = await handle.execute(sql `
47
49
  with recursive descendants as (
48
- select id from keys_issued_credentials where id = ${id}
50
+ select id from public.keys_issued_credentials where id = ${id}
49
51
  union
50
52
  select c.id
51
- from keys_issued_credentials c
53
+ from public.keys_issued_credentials c
52
54
  join descendants d on c.issued_by_id = d.id
53
55
  )
54
56
  select id from descendants
55
57
  `);
56
58
  return rowsOf(result).map((r) => r.id);
57
59
  }
60
+ // One short database-wide lifecycle lock. Never held across a Worker call.
61
+ // The cascade snapshot and child publication must share the same lock; row
62
+ // locks on only the immediate parent miss concurrent ancestor revocations.
63
+ async function lockLifecycle(tx) {
64
+ await tx.execute(sql `select pg_advisory_xact_lock(1801812339, 1)`);
65
+ }
58
66
  async function emit(event) {
59
67
  await audit?.(event);
60
68
  }
61
- async function issue(request) {
69
+ async function issue(request, attempt = 0) {
62
70
  if (request.idempotencyKey) {
63
71
  const existing = await loadByIdempotencyKey(request.idempotencyKey);
64
72
  if (existing) {
@@ -71,6 +79,7 @@ export function createCredentialIssuer(options) {
71
79
  if (request.expiresAt <= now()) {
72
80
  return { ok: false, reason: 'expires_at_past' };
73
81
  }
82
+ const environment = request.environment ?? 'live';
74
83
  let minterAttestation = null;
75
84
  if (request.minter !== null) {
76
85
  const minterRow = await loadById(request.minter);
@@ -83,6 +92,11 @@ export function createCredentialIssuer(options) {
83
92
  return { ok: false, reason: 'minter_revoked' };
84
93
  if (minterRow.expiresAt <= now())
85
94
  return { ok: false, reason: 'minter_expired' };
95
+ // #47: a test key can only mint a test key, a live key only a live
96
+ // one -- the pairing the issue asked for, not just a label.
97
+ if (minterRow.environment !== environment) {
98
+ return { ok: false, reason: 'environment_mismatch' };
99
+ }
86
100
  minterAttestation = {
87
101
  row: toSignableRow(minterRow),
88
102
  signature: fromBase64Url(minterRow.signature),
@@ -110,17 +124,40 @@ export function createCredentialIssuer(options) {
110
124
  if (!result.ok)
111
125
  return { ok: false, reason: result.error.code };
112
126
  try {
113
- await db.insert(table).values({
114
- id,
115
- issuedById: candidate.issuedById,
116
- keyPrefix: candidate.keyPrefix,
117
- secretHash: candidate.secretHash,
118
- grants: candidate.grants,
119
- expiresAt: candidate.expiresAt,
120
- signature: toBase64Url(result.signature),
121
- signingGenerationId: result.generationId,
122
- idempotencyKey: request.idempotencyKey ?? null,
127
+ const refusal = await db.transaction(async (tx) => {
128
+ await lockLifecycle(tx);
129
+ if (request.minter !== null) {
130
+ const parent = await loadById(request.minter, tx);
131
+ if (!parent)
132
+ return 'minter_not_found';
133
+ if (parent.revokedAt !== null)
134
+ return 'minter_revoked';
135
+ if (parent.expiresAt <= now())
136
+ return 'minter_expired';
137
+ if (parent.environment !== environment)
138
+ return 'environment_mismatch';
139
+ if (!minterAttestation ||
140
+ parent.signature !== toBase64Url(minterAttestation.signature) ||
141
+ canonicalJson(toSignableRow(parent)) !== canonicalJson(minterAttestation.row)) {
142
+ return 'bad_minter_signature';
143
+ }
144
+ }
145
+ await tx.insert(table).values({
146
+ id,
147
+ issuedById: candidate.issuedById,
148
+ keyPrefix: candidate.keyPrefix,
149
+ secretHash: candidate.secretHash,
150
+ grants: candidate.grants,
151
+ expiresAt: candidate.expiresAt,
152
+ signature: toBase64Url(result.signature),
153
+ signingGenerationId: result.generationId,
154
+ idempotencyKey: request.idempotencyKey ?? null,
155
+ environment,
156
+ });
157
+ return null;
123
158
  });
159
+ if (refusal)
160
+ return { ok: false, reason: refusal };
124
161
  }
125
162
  catch (err) {
126
163
  // A concurrent issue() with the same idempotencyKey won the race: the
@@ -131,6 +168,9 @@ export function createCredentialIssuer(options) {
131
168
  return { ok: true, id: existing.id, keyPrefix: existing.keyPrefix, secret: null };
132
169
  }
133
170
  }
171
+ if (attempt < 3 && isUniqueViolation(err) && (await loadByPrefix(candidate.keyPrefix))) {
172
+ return issue(request, attempt + 1);
173
+ }
134
174
  throw err;
135
175
  }
136
176
  await emit({ name: 'credential.minted', credentialId: id, requestId, at: now() });
@@ -143,6 +183,24 @@ export function createCredentialIssuer(options) {
143
183
  const row = await loadByPrefix(keyPrefix);
144
184
  if (!row)
145
185
  return { ok: false, reason: 'not_found' };
186
+ // #45: spent on every attempt against a known credential, matched
187
+ // secret or not -- an unlimited number of wrong guesses against an
188
+ // identified row would otherwise let an attacker brute-force it past
189
+ // any per-successful-use limit. Before the signature check below, so a
190
+ // flood of requests against one credential id never reaches the more
191
+ // expensive Ed25519 verify.
192
+ // A `rateLimiter.check` failure is NOT caught, unlike #46's usage
193
+ // write below: rate limiting is a security control the host opted
194
+ // into, and failing open on it (silently never limiting anything
195
+ // whenever its store is unreachable) is a worse failure mode than a
196
+ // loud one a host notices in its own staging environment the first
197
+ // time the migration is missing or the store is down.
198
+ if (rateLimiter) {
199
+ const limit = await rateLimiter.check(row.id, now());
200
+ if (limit.limited) {
201
+ return { ok: false, reason: 'rate_limited', retryAfterMs: limit.retryAfterMs };
202
+ }
203
+ }
146
204
  // Verified before anything else is trusted about the row: a signature
147
205
  // check over the row's CURRENT fields refuses a row a raw UPDATE
148
206
  // tampered with, whatever that tamper changed.
@@ -154,26 +212,34 @@ export function createCredentialIssuer(options) {
154
212
  return { ok: false, reason: 'revoked' };
155
213
  if (now() >= row.expiresAt)
156
214
  return { ok: false, reason: 'expired' };
157
- if (secretMatches(secret, row.secretHash)) {
158
- return {
159
- ok: true,
160
- id: row.id,
161
- issuedById: row.issuedById,
162
- grants: row.grants,
163
- };
215
+ const matched = secretMatches(secret, row.secretHash) ||
216
+ (row.previousSecretHash !== null &&
217
+ row.previousValidUntil !== null &&
218
+ now() < row.previousValidUntil &&
219
+ secretMatches(secret, row.previousSecretHash));
220
+ if (!matched)
221
+ return { ok: false, reason: 'secret_mismatch' };
222
+ // #46: last-used/use-count, for an admin UI to read directly off the
223
+ // row -- best-effort. A write failure here is observability lost, not
224
+ // an authorization decision, so it must never turn an otherwise-genuine
225
+ // secret into a refusal; check() stays available for the one thing it
226
+ // exists to do even if this bookkeeping write cannot land.
227
+ try {
228
+ await db
229
+ .update(table)
230
+ .set({ lastUsedAt: new Date(now()), useCount: sql `${table.useCount} + 1` })
231
+ .where(eq(table.id, row.id));
164
232
  }
165
- if (row.previousSecretHash !== null &&
166
- row.previousValidUntil !== null &&
167
- now() < row.previousValidUntil &&
168
- secretMatches(secret, row.previousSecretHash)) {
169
- return {
170
- ok: true,
171
- id: row.id,
172
- issuedById: row.issuedById,
173
- grants: row.grants,
174
- };
233
+ catch {
234
+ // Swallowed by design -- see comment above.
175
235
  }
176
- return { ok: false, reason: 'secret_mismatch' };
236
+ return {
237
+ ok: true,
238
+ id: row.id,
239
+ issuedById: row.issuedById,
240
+ grants: row.grants,
241
+ environment: row.environment,
242
+ };
177
243
  }
178
244
  async function rotate(id, opts) {
179
245
  const row = await loadById(id);
@@ -181,6 +247,14 @@ export function createCredentialIssuer(options) {
181
247
  return { ok: false, reason: 'not_found' };
182
248
  if (row.revokedAt !== null)
183
249
  return { ok: false, reason: 'revoked' };
250
+ if (!(await verifyIssuedRowSignature(toSignableRow(row), fromBase64Url(row.signature), verifyKeys))) {
251
+ return { ok: false, reason: 'signature_invalid' };
252
+ }
253
+ if (!Number.isSafeInteger(opts.graceMs) ||
254
+ opts.graceMs < 0 ||
255
+ !Number.isSafeInteger(now() + opts.graceMs)) {
256
+ return { ok: false, reason: 'bad_request' };
257
+ }
184
258
  const minted = rotateSecret(row.keyPrefix);
185
259
  const previousValidUntil = now() + opts.graceMs;
186
260
  const candidate = {
@@ -217,30 +291,55 @@ export function createCredentialIssuer(options) {
217
291
  });
218
292
  if (!result.ok)
219
293
  return { ok: false, reason: result.error.code };
220
- await db
221
- .update(table)
222
- .set({
223
- secretHash: candidate.secretHash,
224
- previousSecretHash: candidate.previousSecretHash,
225
- previousValidUntil: candidate.previousValidUntil,
226
- signature: toBase64Url(result.signature),
227
- signingGenerationId: result.generationId,
228
- })
229
- .where(eq(table.id, id));
294
+ const refusal = await db.transaction(async (tx) => {
295
+ await lockLifecycle(tx);
296
+ const [current] = await tx.select().from(table).where(eq(table.id, id)).for('update');
297
+ if (!current)
298
+ return 'not_found';
299
+ if (current.revokedAt !== null)
300
+ return 'revoked';
301
+ if (current.signature !== row.signature ||
302
+ canonicalJson(toSignableRow(current)) !== canonicalJson(toSignableRow(row))) {
303
+ return 'conflict';
304
+ }
305
+ if (row.issuedById !== null) {
306
+ const parent = await loadById(row.issuedById, tx);
307
+ if (!parent)
308
+ return 'minter_not_found';
309
+ if (parent.revokedAt !== null)
310
+ return 'minter_revoked';
311
+ if (parent.expiresAt <= now())
312
+ return 'minter_expired';
313
+ }
314
+ await tx
315
+ .update(table)
316
+ .set({
317
+ secretHash: candidate.secretHash,
318
+ previousSecretHash: candidate.previousSecretHash,
319
+ previousValidUntil: candidate.previousValidUntil,
320
+ signature: toBase64Url(result.signature),
321
+ signingGenerationId: result.generationId,
322
+ })
323
+ .where(eq(table.id, id));
324
+ return null;
325
+ });
326
+ if (refusal)
327
+ return { ok: false, reason: refusal };
230
328
  await emit({ name: 'credential.rotated', credentialId: id, requestId, at: now() });
231
329
  return { ok: true, secret: minted.secret };
232
330
  }
233
331
  async function revoke(id, opts = {}) {
234
- const cascade = opts.cascade ?? true;
235
- const ids = cascade ? await subtreeIds(id) : [id];
236
- if (ids.length === 0)
237
- return [];
238
- const revokedAt = new Date(now());
239
- const revokedRows = await db
240
- .update(table)
241
- .set({ revokedAt })
242
- .where(and(inArray(table.id, ids), isNull(table.revokedAt)))
243
- .returning({ id: table.id });
332
+ const revokedRows = await db.transaction(async (tx) => {
333
+ await lockLifecycle(tx);
334
+ const ids = (opts.cascade ?? true) ? await subtreeIds(id, tx) : [id];
335
+ if (ids.length === 0)
336
+ return [];
337
+ return tx
338
+ .update(table)
339
+ .set({ revokedAt: new Date(now()) })
340
+ .where(and(inArray(table.id, ids), isNull(table.revokedAt)))
341
+ .returning({ id: table.id });
342
+ });
244
343
  for (const r of revokedRows) {
245
344
  await emit({ name: 'credential.revoked', credentialId: r.id, at: now() });
246
345
  }
@@ -261,6 +360,7 @@ export function createCredentialIssuer(options) {
261
360
  grants: row.grants,
262
361
  expiresAt: row.expiresAt,
263
362
  revokedAt: row.revokedAt ? row.revokedAt.getTime() : null,
363
+ environment: row.environment,
264
364
  });
265
365
  currentId = row.issuedById;
266
366
  }
@@ -269,5 +369,8 @@ export function createCredentialIssuer(options) {
269
369
  return { issue, check, rotate, revoke, lineageOf };
270
370
  }
271
371
  function isUniqueViolation(err) {
272
- return err?.code === '23505';
372
+ if (!err || typeof err !== 'object')
373
+ return false;
374
+ const e = err;
375
+ return e.code === '23505' || (e.cause !== err && isUniqueViolation(e.cause));
273
376
  }
@@ -0,0 +1,34 @@
1
+ import type { Db } from './index.js';
2
+ /**
3
+ * #45: a per-issued-credential abuse limit. `packages/worker/src/ratelimit.ts`
4
+ * already limits per `hostId` + the HOST's own service credential; nothing
5
+ * limited an END USER's individual issued key until this. `check()` consults
6
+ * `CreateCredentialIssuerOptions.rateLimiter`, when one is given, keyed by
7
+ * the credential's own `id` -- once the row is found (so a known
8
+ * credential's budget is spent on every attempt, matched secret or not,
9
+ * not only on a successful one), before its secret is verified.
10
+ */
11
+ export interface RateLimitResult {
12
+ readonly limited: boolean;
13
+ /** Milliseconds until the current window resets. Meaningful whether or not `limited`. */
14
+ readonly retryAfterMs: number;
15
+ }
16
+ export interface RateLimiter {
17
+ /** `now`: Unix milliseconds, the same clock `check()`'s own `now` option gives -- never read internally, so a fake clock in a test reaches this too. */
18
+ check(credentialId: string, now: number): Promise<RateLimitResult>;
19
+ }
20
+ export interface CreatePostgresRateLimiterOptions {
21
+ readonly db: Db;
22
+ /** Requests a single credential may make per window before `limited` is true. */
23
+ readonly limitPerWindow: number;
24
+ /** Milliseconds. Default 60_000. */
25
+ readonly windowMs?: number;
26
+ }
27
+ /**
28
+ * A fixed-window counter in the host's own Postgres, one bounded row per
29
+ * credential (`keys_issued_rate_limit_windows`, `0007_keys_rate_limit.sql`),
30
+ * atomically reset each window by a single upsert -- the same shape
31
+ * `packages/worker/src/ratelimit.ts`'s D1 `host_rate_limit_windows` uses, a
32
+ * level down: keyed by the end user's credential id, not the host's.
33
+ */
34
+ export declare function createPostgresRateLimiter(options: CreatePostgresRateLimiterOptions): RateLimiter;
@@ -0,0 +1,49 @@
1
+ import { sql } from 'drizzle-orm';
2
+ function rowsOf(result) {
3
+ if (Array.isArray(result))
4
+ return result;
5
+ return (result.rows ?? []);
6
+ }
7
+ /**
8
+ * A fixed-window counter in the host's own Postgres, one bounded row per
9
+ * credential (`keys_issued_rate_limit_windows`, `0007_keys_rate_limit.sql`),
10
+ * atomically reset each window by a single upsert -- the same shape
11
+ * `packages/worker/src/ratelimit.ts`'s D1 `host_rate_limit_windows` uses, a
12
+ * level down: keyed by the end user's credential id, not the host's.
13
+ */
14
+ export function createPostgresRateLimiter(options) {
15
+ const { db, limitPerWindow } = options;
16
+ const windowMs = options.windowMs ?? 60_000;
17
+ return {
18
+ async check(credentialId, now) {
19
+ const windowStart = Math.floor(now / windowMs) * windowMs;
20
+ const result = await db.execute(sql `
21
+ insert into keys_issued_rate_limit_windows (credential_id, window_start, count)
22
+ values (${credentialId}, ${windowStart}, 1)
23
+ on conflict (credential_id) do update set
24
+ window_start = excluded.window_start,
25
+ count = case
26
+ when keys_issued_rate_limit_windows.window_start = excluded.window_start
27
+ then keys_issued_rate_limit_windows.count + 1
28
+ else 1
29
+ end
30
+ returning window_start, count
31
+ `);
32
+ const [row] = rowsOf(result);
33
+ if (!row)
34
+ throw new Error('rate limit write returned no row');
35
+ // `count` is `integer` (int4), always JS-number-safe -- but a raw
36
+ // (non-query-builder) result is still driver-dependent about handing
37
+ // it back as a number or a numeric string, so `Number(...)` either
38
+ // way. `window_start` itself is never re-read from the row: the
39
+ // upsert always sets it to `excluded.window_start`, never
40
+ // conditionally kept, so it equals the `windowStart` just computed
41
+ // above.
42
+ const count = Number(row.count);
43
+ return {
44
+ limited: count > limitPerWindow,
45
+ retryAfterMs: windowStart + windowMs - now,
46
+ };
47
+ },
48
+ };
49
+ }
@@ -2,12 +2,12 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
2
  /**
3
3
  * The split-secret format, carried over from valet's `src/lib/auth/keys.ts`:
4
4
  *
5
- * <prefix><8-char public id><20-byte secret tail>
5
+ * <prefix><8-char public id><32-char tail encoding 20 random bytes>
6
6
  *
7
7
  * The prefix through the public id is stored in the clear and is what a
8
8
  * lookup indexes on; the tail is never stored, only the SHA-256 hash of the
9
9
  * whole secret. `prefix` is host-supplied here (valet hardcodes `vk_live_`;
10
- * a package used by several hosts cannot), everything after it is fixed.
10
+ * a package used by several hosts cannot), legacy 20-character tails remain accepted during migration.
11
11
  *
12
12
  * `node:crypto` rather than WebCrypto: this is a Node-side, host-only
13
13
  * mechanic (unlike encoding.ts, nothing here runs in the Worker), and
@@ -15,6 +15,8 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
15
15
  */
16
16
  const PUBLIC_ID_LEN = 8;
17
17
  const SECRET_BYTES = 20;
18
+ const SECRET_CHARS = 32;
19
+ const LEGACY_SECRET_CHARS = 20;
18
20
  /** Crockford-ish base32: no padding, no vowels-to-digits confusion in logs. */
19
21
  const ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz';
20
22
  function encode(bytes) {
@@ -33,7 +35,19 @@ export function mintSecret(prefix) {
33
35
  }
34
36
  /** A fresh tail under an EXISTING `keyPrefix`: what rotate() calls, so the row keeps its indexed identity. */
35
37
  export function rotateSecret(keyPrefix) {
36
- const tail = encode(randomBytes(SECRET_BYTES));
38
+ // Encode all 160 random bits, instead of discarding three bits per byte.
39
+ const bytes = randomBytes(SECRET_BYTES);
40
+ let tail = '';
41
+ let bits = 0;
42
+ let buffer = 0;
43
+ for (const byte of bytes) {
44
+ buffer = (buffer << 8) | byte;
45
+ bits += 8;
46
+ while (bits >= 5) {
47
+ bits -= 5;
48
+ tail += ALPHABET[(buffer >>> bits) & 31];
49
+ }
50
+ }
37
51
  const secret = `${keyPrefix}${tail}`;
38
52
  return { secret, keyPrefix, secretHash: hashSecret(secret) };
39
53
  }
@@ -48,8 +62,8 @@ export function rotateSecret(keyPrefix) {
48
62
  export function keyPrefixOf(presented, prefix) {
49
63
  if (!presented.startsWith(prefix))
50
64
  return null;
51
- const expected = prefix.length + PUBLIC_ID_LEN + SECRET_BYTES;
52
- if (presented.length !== expected)
65
+ const tailLength = presented.length - prefix.length - PUBLIC_ID_LEN;
66
+ if (tailLength !== SECRET_CHARS && tailLength !== LEGACY_SECRET_CHARS)
53
67
  return null;
54
68
  return presented.slice(0, prefix.length + PUBLIC_ID_LEN);
55
69
  }
@@ -16,6 +16,16 @@
16
16
  * a lazily-declared drizzle table needs an `AnyPgColumn` callback and buys
17
17
  * nothing this package's own tests check, since the constraint that actually
18
18
  * runs is the one in the SQL migration.
19
+ *
20
+ * `environment` (#47) is not part of `SignableRow` -- unsigned metadata, same
21
+ * footing as `idempotencyKey`/`createdAt`. `0005_keys_environment.sql`'s
22
+ * extended `keys_issued_guard()` trigger is what keeps it trustworthy: set
23
+ * once at `issue()`, immutable for the row's life.
24
+ *
25
+ * `lastUsedAt`/`useCount` (#46, `0006_keys_usage.sql`) are the one pair on
26
+ * this row that is NOT set-once: `check()` writes both on every successful
27
+ * verification, so -- unlike `environment` -- `keys_issued_guard()` leaves
28
+ * them out of its immutable-columns check.
19
29
  */
20
30
  export declare const keysIssuedCredentials: import("drizzle-orm/pg-core").PgTableWithColumns<{
21
31
  name: "keys_issued_credentials";
@@ -208,6 +218,23 @@ export declare const keysIssuedCredentials: import("drizzle-orm/pg-core").PgTabl
208
218
  identity: undefined;
209
219
  generated: undefined;
210
220
  }, {}, {}>;
221
+ environment: import("drizzle-orm/pg-core").PgColumn<{
222
+ name: "environment";
223
+ tableName: "keys_issued_credentials";
224
+ dataType: "string";
225
+ columnType: "PgText";
226
+ data: string;
227
+ driverParam: string;
228
+ notNull: true;
229
+ hasDefault: false;
230
+ isPrimaryKey: false;
231
+ isAutoincrement: false;
232
+ hasRuntimeDefault: false;
233
+ enumValues: [string, ...string[]];
234
+ baseColumn: never;
235
+ identity: undefined;
236
+ generated: undefined;
237
+ }, {}, {}>;
211
238
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
212
239
  name: "created_at";
213
240
  tableName: "keys_issued_credentials";
@@ -242,6 +269,40 @@ export declare const keysIssuedCredentials: import("drizzle-orm/pg-core").PgTabl
242
269
  identity: undefined;
243
270
  generated: undefined;
244
271
  }, {}, {}>;
272
+ lastUsedAt: import("drizzle-orm/pg-core").PgColumn<{
273
+ name: "last_used_at";
274
+ tableName: "keys_issued_credentials";
275
+ dataType: "date";
276
+ columnType: "PgTimestamp";
277
+ data: Date;
278
+ driverParam: string;
279
+ notNull: false;
280
+ hasDefault: false;
281
+ isPrimaryKey: false;
282
+ isAutoincrement: false;
283
+ hasRuntimeDefault: false;
284
+ enumValues: undefined;
285
+ baseColumn: never;
286
+ identity: undefined;
287
+ generated: undefined;
288
+ }, {}, {}>;
289
+ useCount: import("drizzle-orm/pg-core").PgColumn<{
290
+ name: "use_count";
291
+ tableName: "keys_issued_credentials";
292
+ dataType: "number";
293
+ columnType: "PgBigInt53";
294
+ data: number;
295
+ driverParam: string | number;
296
+ notNull: true;
297
+ hasDefault: true;
298
+ isPrimaryKey: false;
299
+ isAutoincrement: false;
300
+ hasRuntimeDefault: false;
301
+ enumValues: undefined;
302
+ baseColumn: never;
303
+ identity: undefined;
304
+ generated: undefined;
305
+ }, {}, {}>;
245
306
  };
246
307
  dialect: "pg";
247
308
  }>;
@@ -17,6 +17,16 @@ import { bigint, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
17
17
  * a lazily-declared drizzle table needs an `AnyPgColumn` callback and buys
18
18
  * nothing this package's own tests check, since the constraint that actually
19
19
  * runs is the one in the SQL migration.
20
+ *
21
+ * `environment` (#47) is not part of `SignableRow` -- unsigned metadata, same
22
+ * footing as `idempotencyKey`/`createdAt`. `0005_keys_environment.sql`'s
23
+ * extended `keys_issued_guard()` trigger is what keeps it trustworthy: set
24
+ * once at `issue()`, immutable for the row's life.
25
+ *
26
+ * `lastUsedAt`/`useCount` (#46, `0006_keys_usage.sql`) are the one pair on
27
+ * this row that is NOT set-once: `check()` writes both on every successful
28
+ * verification, so -- unlike `environment` -- `keys_issued_guard()` leaves
29
+ * them out of its immutable-columns check.
20
30
  */
21
31
  export const keysIssuedCredentials = pgTable('keys_issued_credentials', {
22
32
  id: text('id').primaryKey(),
@@ -30,6 +40,9 @@ export const keysIssuedCredentials = pgTable('keys_issued_credentials', {
30
40
  signature: text('signature').notNull(),
31
41
  signingGenerationId: text('signing_generation_id').notNull(),
32
42
  idempotencyKey: text('idempotency_key'),
43
+ environment: text('environment').notNull(),
33
44
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
34
45
  revokedAt: timestamp('revoked_at', { withTimezone: true }),
46
+ lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
47
+ useCount: bigint('use_count', { mode: 'number' }).notNull().default(0),
35
48
  });