@wtfalch/keys 0.1.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.
@@ -0,0 +1,278 @@
1
+ export declare const keysHeldEntries: import("drizzle-orm/pg-core").PgTableWithColumns<{
2
+ name: "keys_held_entries";
3
+ schema: undefined;
4
+ columns: {
5
+ tenantId: import("drizzle-orm/pg-core").PgColumn<{
6
+ name: "tenant_id";
7
+ tableName: "keys_held_entries";
8
+ dataType: "string";
9
+ columnType: "PgText";
10
+ data: string;
11
+ driverParam: string;
12
+ notNull: true;
13
+ hasDefault: false;
14
+ isPrimaryKey: false;
15
+ isAutoincrement: false;
16
+ hasRuntimeDefault: false;
17
+ enumValues: [string, ...string[]];
18
+ baseColumn: never;
19
+ identity: undefined;
20
+ generated: undefined;
21
+ }, {}, {}>;
22
+ entryId: import("drizzle-orm/pg-core").PgColumn<{
23
+ name: "entry_id";
24
+ tableName: "keys_held_entries";
25
+ dataType: "string";
26
+ columnType: "PgText";
27
+ data: string;
28
+ driverParam: string;
29
+ notNull: true;
30
+ hasDefault: false;
31
+ isPrimaryKey: false;
32
+ isAutoincrement: false;
33
+ hasRuntimeDefault: false;
34
+ enumValues: [string, ...string[]];
35
+ baseColumn: never;
36
+ identity: undefined;
37
+ generated: undefined;
38
+ }, {}, {}>;
39
+ currentVersion: import("drizzle-orm/pg-core").PgColumn<{
40
+ name: "current_version";
41
+ tableName: "keys_held_entries";
42
+ dataType: "number";
43
+ columnType: "PgInteger";
44
+ data: number;
45
+ driverParam: string | number;
46
+ notNull: true;
47
+ hasDefault: true;
48
+ isPrimaryKey: false;
49
+ isAutoincrement: false;
50
+ hasRuntimeDefault: false;
51
+ enumValues: undefined;
52
+ baseColumn: never;
53
+ identity: undefined;
54
+ generated: undefined;
55
+ }, {}, {}>;
56
+ revokedAt: import("drizzle-orm/pg-core").PgColumn<{
57
+ name: "revoked_at";
58
+ tableName: "keys_held_entries";
59
+ dataType: "date";
60
+ columnType: "PgTimestamp";
61
+ data: Date;
62
+ driverParam: string;
63
+ notNull: false;
64
+ hasDefault: false;
65
+ isPrimaryKey: false;
66
+ isAutoincrement: false;
67
+ hasRuntimeDefault: false;
68
+ enumValues: undefined;
69
+ baseColumn: never;
70
+ identity: undefined;
71
+ generated: undefined;
72
+ }, {}, {}>;
73
+ tenantArchivedAt: import("drizzle-orm/pg-core").PgColumn<{
74
+ name: "tenant_archived_at";
75
+ tableName: "keys_held_entries";
76
+ dataType: "date";
77
+ columnType: "PgTimestamp";
78
+ data: Date;
79
+ driverParam: string;
80
+ notNull: false;
81
+ hasDefault: false;
82
+ isPrimaryKey: false;
83
+ isAutoincrement: false;
84
+ hasRuntimeDefault: false;
85
+ enumValues: undefined;
86
+ baseColumn: never;
87
+ identity: undefined;
88
+ generated: undefined;
89
+ }, {}, {}>;
90
+ createdAt: import("drizzle-orm/pg-core").PgColumn<{
91
+ name: "created_at";
92
+ tableName: "keys_held_entries";
93
+ dataType: "date";
94
+ columnType: "PgTimestamp";
95
+ data: Date;
96
+ driverParam: string;
97
+ notNull: true;
98
+ hasDefault: true;
99
+ isPrimaryKey: false;
100
+ isAutoincrement: false;
101
+ hasRuntimeDefault: false;
102
+ enumValues: undefined;
103
+ baseColumn: never;
104
+ identity: undefined;
105
+ generated: undefined;
106
+ }, {}, {}>;
107
+ };
108
+ dialect: "pg";
109
+ }>;
110
+ /** Append-only in spirit: nothing here updates a row except `current_version`, `revoked_at` and, from #232 on, `wrapped_key`. */
111
+ export declare const keysHeldVersions: import("drizzle-orm/pg-core").PgTableWithColumns<{
112
+ name: "keys_held_versions";
113
+ schema: undefined;
114
+ columns: {
115
+ tenantId: import("drizzle-orm/pg-core").PgColumn<{
116
+ name: "tenant_id";
117
+ tableName: "keys_held_versions";
118
+ dataType: "string";
119
+ columnType: "PgText";
120
+ data: string;
121
+ driverParam: string;
122
+ notNull: true;
123
+ hasDefault: false;
124
+ isPrimaryKey: false;
125
+ isAutoincrement: false;
126
+ hasRuntimeDefault: false;
127
+ enumValues: [string, ...string[]];
128
+ baseColumn: never;
129
+ identity: undefined;
130
+ generated: undefined;
131
+ }, {}, {}>;
132
+ entryId: import("drizzle-orm/pg-core").PgColumn<{
133
+ name: "entry_id";
134
+ tableName: "keys_held_versions";
135
+ dataType: "string";
136
+ columnType: "PgText";
137
+ data: string;
138
+ driverParam: string;
139
+ notNull: true;
140
+ hasDefault: false;
141
+ isPrimaryKey: false;
142
+ isAutoincrement: false;
143
+ hasRuntimeDefault: false;
144
+ enumValues: [string, ...string[]];
145
+ baseColumn: never;
146
+ identity: undefined;
147
+ generated: undefined;
148
+ }, {}, {}>;
149
+ version: import("drizzle-orm/pg-core").PgColumn<{
150
+ name: "version";
151
+ tableName: "keys_held_versions";
152
+ dataType: "number";
153
+ columnType: "PgInteger";
154
+ data: number;
155
+ driverParam: string | number;
156
+ notNull: true;
157
+ hasDefault: false;
158
+ isPrimaryKey: false;
159
+ isAutoincrement: false;
160
+ hasRuntimeDefault: false;
161
+ enumValues: undefined;
162
+ baseColumn: never;
163
+ identity: undefined;
164
+ generated: undefined;
165
+ }, {}, {}>;
166
+ kekId: import("drizzle-orm/pg-core").PgColumn<{
167
+ name: "kek_id";
168
+ tableName: "keys_held_versions";
169
+ dataType: "string";
170
+ columnType: "PgText";
171
+ data: string;
172
+ driverParam: string;
173
+ notNull: true;
174
+ hasDefault: false;
175
+ isPrimaryKey: false;
176
+ isAutoincrement: false;
177
+ hasRuntimeDefault: false;
178
+ enumValues: [string, ...string[]];
179
+ baseColumn: never;
180
+ identity: undefined;
181
+ generated: undefined;
182
+ }, {}, {}>;
183
+ wrappedKey: import("drizzle-orm/pg-core").PgColumn<{
184
+ name: "wrapped_key";
185
+ tableName: "keys_held_versions";
186
+ dataType: "custom";
187
+ columnType: "PgCustomColumn";
188
+ data: Buffer<ArrayBufferLike>;
189
+ driverParam: Buffer<ArrayBufferLike>;
190
+ notNull: false;
191
+ hasDefault: false;
192
+ isPrimaryKey: false;
193
+ isAutoincrement: false;
194
+ hasRuntimeDefault: false;
195
+ enumValues: undefined;
196
+ baseColumn: never;
197
+ identity: undefined;
198
+ generated: undefined;
199
+ }, {}, {
200
+ pgColumnBuilderBrand: "PgCustomColumnBuilderBrand";
201
+ }>;
202
+ iv: import("drizzle-orm/pg-core").PgColumn<{
203
+ name: "iv";
204
+ tableName: "keys_held_versions";
205
+ dataType: "custom";
206
+ columnType: "PgCustomColumn";
207
+ data: Buffer<ArrayBufferLike>;
208
+ driverParam: Buffer<ArrayBufferLike>;
209
+ notNull: true;
210
+ hasDefault: false;
211
+ isPrimaryKey: false;
212
+ isAutoincrement: false;
213
+ hasRuntimeDefault: false;
214
+ enumValues: undefined;
215
+ baseColumn: never;
216
+ identity: undefined;
217
+ generated: undefined;
218
+ }, {}, {
219
+ pgColumnBuilderBrand: "PgCustomColumnBuilderBrand";
220
+ }>;
221
+ ciphertext: import("drizzle-orm/pg-core").PgColumn<{
222
+ name: "ciphertext";
223
+ tableName: "keys_held_versions";
224
+ dataType: "custom";
225
+ columnType: "PgCustomColumn";
226
+ data: Buffer<ArrayBufferLike>;
227
+ driverParam: Buffer<ArrayBufferLike>;
228
+ notNull: true;
229
+ hasDefault: false;
230
+ isPrimaryKey: false;
231
+ isAutoincrement: false;
232
+ hasRuntimeDefault: false;
233
+ enumValues: undefined;
234
+ baseColumn: never;
235
+ identity: undefined;
236
+ generated: undefined;
237
+ }, {}, {
238
+ pgColumnBuilderBrand: "PgCustomColumnBuilderBrand";
239
+ }>;
240
+ retiredAt: import("drizzle-orm/pg-core").PgColumn<{
241
+ name: "retired_at";
242
+ tableName: "keys_held_versions";
243
+ dataType: "date";
244
+ columnType: "PgTimestamp";
245
+ data: Date;
246
+ driverParam: string;
247
+ notNull: false;
248
+ hasDefault: false;
249
+ isPrimaryKey: false;
250
+ isAutoincrement: false;
251
+ hasRuntimeDefault: false;
252
+ enumValues: undefined;
253
+ baseColumn: never;
254
+ identity: undefined;
255
+ generated: undefined;
256
+ }, {}, {}>;
257
+ createdAt: import("drizzle-orm/pg-core").PgColumn<{
258
+ name: "created_at";
259
+ tableName: "keys_held_versions";
260
+ dataType: "date";
261
+ columnType: "PgTimestamp";
262
+ data: Date;
263
+ driverParam: string;
264
+ notNull: true;
265
+ hasDefault: true;
266
+ isPrimaryKey: false;
267
+ isAutoincrement: false;
268
+ hasRuntimeDefault: false;
269
+ enumValues: undefined;
270
+ baseColumn: never;
271
+ identity: undefined;
272
+ generated: undefined;
273
+ }, {}, {}>;
274
+ };
275
+ dialect: "pg";
276
+ }>;
277
+ export type KeysHeldEntryRow = typeof keysHeldEntries.$inferSelect;
278
+ export type KeysHeldVersionRow = typeof keysHeldVersions.$inferSelect;
@@ -0,0 +1,43 @@
1
+ import { customType, index, integer, pgTable, primaryKey, text, timestamp, } from 'drizzle-orm/pg-core';
2
+ /**
3
+ * Mirrors `migrations/0002_keys_held.sql`, which is the source of truth: the
4
+ * SQL carries the CHECKs and the foreign key that drizzle-kit does not
5
+ * generate. Unlike `@wtfalch/audit`'s tables, a host never declares its own
6
+ * columns alongside these -- `./held` owns the whole row, so the tables stay
7
+ * internal to this module rather than a parameter a host supplies.
8
+ *
9
+ * `bytea`, the same way `app-template/src/lib/db/keys.ts` does it: drizzle's
10
+ * Postgres dialect has no built-in bytea column.
11
+ */
12
+ const bytea = customType({
13
+ dataType: () => 'bytea',
14
+ });
15
+ export const keysHeldEntries = pgTable('keys_held_entries', {
16
+ tenantId: text('tenant_id').notNull(),
17
+ entryId: text('entry_id').notNull(),
18
+ currentVersion: integer('current_version').notNull().default(0),
19
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
20
+ /**
21
+ * Set once, by `archiveTenant`, on every entry row this tenant holds.
22
+ * This package has no `tenants` table of its own, so "tenant archived"
23
+ * -- one of #232's three shred-eligibility clocks -- is recorded here,
24
+ * per entry, rather than on a row this package doesn't own.
25
+ */
26
+ tenantArchivedAt: timestamp('tenant_archived_at', { withTimezone: true }),
27
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
28
+ }, (table) => [primaryKey({ columns: [table.tenantId, table.entryId] })]);
29
+ /** Append-only in spirit: nothing here updates a row except `current_version`, `revoked_at` and, from #232 on, `wrapped_key`. */
30
+ export const keysHeldVersions = pgTable('keys_held_versions', {
31
+ tenantId: text('tenant_id').notNull(),
32
+ entryId: text('entry_id').notNull(),
33
+ version: integer('version').notNull(),
34
+ kekId: text('kek_id').notNull(),
35
+ wrappedKey: bytea('wrapped_key'),
36
+ iv: bytea('iv').notNull(),
37
+ ciphertext: bytea('ciphertext').notNull(),
38
+ retiredAt: timestamp('retired_at', { withTimezone: true }),
39
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
40
+ }, (table) => [
41
+ primaryKey({ columns: [table.tenantId, table.entryId, table.version] }),
42
+ index('keys_held_versions_kek_idx').on(table.kekId),
43
+ ]);
@@ -0,0 +1,34 @@
1
+ import type { HeldKeysDb } from './index.js';
2
+ /**
3
+ * Calls `keys_shred_expired()` -- 0003_keys_shred.sql's `SECURITY DEFINER`
4
+ * sweep -- and returns how many `keys_held_versions` rows it shredded. Takes
5
+ * no row-targeting argument itself, matching the function: the function
6
+ * decides eligibility on its own from `retired_at`, `revoked_at` and
7
+ * `tenant_archived_at`, never from anything this caller supplies.
8
+ */
9
+ export declare function shredExpired(db: HeldKeysDb): Promise<number>;
10
+ /** Which of #232's three shred-eligibility clocks a version's is. */
11
+ export type ShredClockReason = 'retired' | 'entry_revoked' | 'tenant_archived';
12
+ export interface ShredClock {
13
+ readonly tenantId: string;
14
+ readonly entryId: string;
15
+ readonly version: number;
16
+ readonly reason: ShredClockReason;
17
+ readonly startedAt: Date;
18
+ }
19
+ /**
20
+ * Every still-wrapped version whose shred clock started after `since` -- the
21
+ * earliest of its own `retired_at`, its entry's `revoked_at`, or its
22
+ * tenant's `tenant_archived_at`, whichever is set. #225's revised answer:
23
+ * "the write that starts the clock is itself an alert" -- a host's
24
+ * housekeeping tick calls this once a tick, with `since` set to the last
25
+ * tick's time, and alerts on a non-empty result, so an operator can reverse
26
+ * a forged retire/revoke/archive inside the 30-day
27
+ * `KEYSTORE_SHRED_DELAY_DAYS` window before `keys_shred_expired()` fires.
28
+ *
29
+ * A row already shredded (`wrapped_key is null`) has no clock left to alert
30
+ * on and is excluded.
31
+ */
32
+ export declare function shredClocks(db: HeldKeysDb, options: {
33
+ readonly since: Date;
34
+ }): Promise<readonly ShredClock[]>;
@@ -0,0 +1,76 @@
1
+ import { and, eq, sql } from 'drizzle-orm';
2
+ import { keysHeldEntries, keysHeldVersions } from './schema.js';
3
+ /**
4
+ * Calls `keys_shred_expired()` -- 0003_keys_shred.sql's `SECURITY DEFINER`
5
+ * sweep -- and returns how many `keys_held_versions` rows it shredded. Takes
6
+ * no row-targeting argument itself, matching the function: the function
7
+ * decides eligibility on its own from `retired_at`, `revoked_at` and
8
+ * `tenant_archived_at`, never from anything this caller supplies.
9
+ */
10
+ export async function shredExpired(db) {
11
+ const result = await db.execute(sql `select keys_shred_expired() as n`);
12
+ const [row] = rowsOf(result);
13
+ return Number(row?.n ?? 0);
14
+ }
15
+ /**
16
+ * Every still-wrapped version whose shred clock started after `since` -- the
17
+ * earliest of its own `retired_at`, its entry's `revoked_at`, or its
18
+ * tenant's `tenant_archived_at`, whichever is set. #225's revised answer:
19
+ * "the write that starts the clock is itself an alert" -- a host's
20
+ * housekeeping tick calls this once a tick, with `since` set to the last
21
+ * tick's time, and alerts on a non-empty result, so an operator can reverse
22
+ * a forged retire/revoke/archive inside the 30-day
23
+ * `KEYSTORE_SHRED_DELAY_DAYS` window before `keys_shred_expired()` fires.
24
+ *
25
+ * A row already shredded (`wrapped_key is null`) has no clock left to alert
26
+ * on and is excluded.
27
+ */
28
+ export async function shredClocks(db, options) {
29
+ const rows = await db
30
+ .select({
31
+ tenantId: keysHeldVersions.tenantId,
32
+ entryId: keysHeldVersions.entryId,
33
+ version: keysHeldVersions.version,
34
+ retiredAt: keysHeldVersions.retiredAt,
35
+ revokedAt: keysHeldEntries.revokedAt,
36
+ tenantArchivedAt: keysHeldEntries.tenantArchivedAt,
37
+ })
38
+ .from(keysHeldVersions)
39
+ .innerJoin(keysHeldEntries, and(eq(keysHeldVersions.tenantId, keysHeldEntries.tenantId), eq(keysHeldVersions.entryId, keysHeldEntries.entryId)))
40
+ .where(sql `${keysHeldVersions.wrappedKey} is not null`);
41
+ const clocks = [];
42
+ for (const row of rows) {
43
+ const candidates = [
44
+ { reason: 'retired', at: row.retiredAt },
45
+ { reason: 'entry_revoked', at: row.revokedAt },
46
+ { reason: 'tenant_archived', at: row.tenantArchivedAt },
47
+ ];
48
+ let earliest;
49
+ for (const candidate of candidates) {
50
+ if (candidate.at === null)
51
+ continue;
52
+ if (!earliest || candidate.at.getTime() < earliest.at.getTime()) {
53
+ earliest = { reason: candidate.reason, at: candidate.at };
54
+ }
55
+ }
56
+ if (earliest && earliest.at.getTime() > options.since.getTime()) {
57
+ clocks.push({
58
+ tenantId: row.tenantId,
59
+ entryId: row.entryId,
60
+ version: row.version,
61
+ reason: earliest.reason,
62
+ startedAt: earliest.at,
63
+ });
64
+ }
65
+ }
66
+ return clocks;
67
+ }
68
+ // Mirrors the same driver-shape helper in src/issued/index.ts: postgres-js's
69
+ // db.execute returns an array-like directly, PGlite's returns `{ rows }`.
70
+ // Kept as its own copy rather than a shared import -- ./issued and ./held
71
+ // store nothing in common by design (map Notes, 2026-09-13).
72
+ function rowsOf(result) {
73
+ if (Array.isArray(result))
74
+ return result;
75
+ return (result.rows ?? []);
76
+ }
@@ -0,0 +1,3 @@
1
+ /** Base64url, used only to store a `Uint8Array` (the Worker's signature) in a `text` column. */
2
+ export declare function toBase64Url(bytes: Uint8Array): string;
3
+ export declare function fromBase64Url(value: string): Uint8Array;
@@ -0,0 +1,7 @@
1
+ /** Base64url, used only to store a `Uint8Array` (the Worker's signature) in a `text` column. */
2
+ export function toBase64Url(bytes) {
3
+ return Buffer.from(bytes).toString('base64url');
4
+ }
5
+ export function fromBase64Url(value) {
6
+ return new Uint8Array(Buffer.from(value, 'base64url'));
7
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The bytes a signature is over, and nothing else.
3
+ *
4
+ * No Node-only APIs in this file: the Worker (#233) imports it too, and a
5
+ * Cloudflare Worker has `TextEncoder` but not `Buffer` or `node:crypto`.
6
+ */
7
+ import { type SignableRow, type SignedKind } from '../worker-contract.js';
8
+ /**
9
+ * RFC 8785 JSON Canonicalization: object keys sorted by UTF-16 code unit, no
10
+ * insignificant whitespace, numbers in ECMAScript's shortest round-trip form,
11
+ * strings escaped exactly as `JSON.stringify` already escapes them.
12
+ *
13
+ * Refuses `undefined`, functions, symbols, non-finite numbers and any object
14
+ * that is not a plain object or an array — silently coercing one of these
15
+ * (the way `JSON.stringify` drops an `undefined` property, or turns `NaN`
16
+ * into `null`) would change what gets signed without changing what the
17
+ * caller thinks it asked to sign.
18
+ *
19
+ * A JS `number`'s own `String()` already implements ECMA-262's
20
+ * `Number::toString` for radix 10 — the same "shortest string that parses
21
+ * back to this value" algorithm JCS specifies — so no separate formatting
22
+ * step is needed. `String(-0)` and `String(0)` both give `"0"`, matching
23
+ * JCS's own worked example.
24
+ *
25
+ * Default `Array.prototype.sort()` on strings compares by UTF-16 code unit
26
+ * already (JS strings are UTF-16 sequences and the default comparator
27
+ * converts to strings and compares them lexicographically by code unit), so
28
+ * `Object.keys(obj).sort()` alone gives JCS's key order.
29
+ */
30
+ export declare function canonicalJson(value: unknown): string;
31
+ /**
32
+ * `wtfalch-keys:${kind}:v1\n` + `canonicalJson(value)`, as UTF-8 bytes.
33
+ *
34
+ * The domain tag separates what an `'issued-row'` signature can authenticate
35
+ * from what a `'caller-credential'` signature can, so the same Ed25519 key
36
+ * signing both kinds never lets one message's bytes be replayed as the
37
+ * other's.
38
+ */
39
+ export declare function encodeSigned(kind: SignedKind, value: unknown): Uint8Array;
40
+ /**
41
+ * The eight `SignableRow` fields, by explicit allowlist — never the stored
42
+ * row object itself. A column added to the table later (or a driver that
43
+ * hands back extra properties) must not silently join the signed bytes.
44
+ */
45
+ export declare function pickSignable<TGrant>(row: SignableRow<TGrant>): SignableRow<TGrant>;
46
+ /** Every id in the contract matches `ID_PATTERN`. Throws with `label` naming the field. */
47
+ export declare function assertValidId(id: string, label: string): void;
48
+ export declare function isValidId(id: string): boolean;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The bytes a signature is over, and nothing else.
3
+ *
4
+ * No Node-only APIs in this file: the Worker (#233) imports it too, and a
5
+ * Cloudflare Worker has `TextEncoder` but not `Buffer` or `node:crypto`.
6
+ */
7
+ import { ID_PATTERN, SIGNED_PREFIX, } from '../worker-contract.js';
8
+ // ---------------------------------------------------------------------------
9
+ // canonicalJson — RFC 8785 (JCS)
10
+ // ---------------------------------------------------------------------------
11
+ /**
12
+ * RFC 8785 JSON Canonicalization: object keys sorted by UTF-16 code unit, no
13
+ * insignificant whitespace, numbers in ECMAScript's shortest round-trip form,
14
+ * strings escaped exactly as `JSON.stringify` already escapes them.
15
+ *
16
+ * Refuses `undefined`, functions, symbols, non-finite numbers and any object
17
+ * that is not a plain object or an array — silently coercing one of these
18
+ * (the way `JSON.stringify` drops an `undefined` property, or turns `NaN`
19
+ * into `null`) would change what gets signed without changing what the
20
+ * caller thinks it asked to sign.
21
+ *
22
+ * A JS `number`'s own `String()` already implements ECMA-262's
23
+ * `Number::toString` for radix 10 — the same "shortest string that parses
24
+ * back to this value" algorithm JCS specifies — so no separate formatting
25
+ * step is needed. `String(-0)` and `String(0)` both give `"0"`, matching
26
+ * JCS's own worked example.
27
+ *
28
+ * Default `Array.prototype.sort()` on strings compares by UTF-16 code unit
29
+ * already (JS strings are UTF-16 sequences and the default comparator
30
+ * converts to strings and compares them lexicographically by code unit), so
31
+ * `Object.keys(obj).sort()` alone gives JCS's key order.
32
+ */
33
+ export function canonicalJson(value) {
34
+ return encodeValue(value);
35
+ }
36
+ function encodeValue(value) {
37
+ if (value === null)
38
+ return 'null';
39
+ const t = typeof value;
40
+ if (t === 'boolean')
41
+ return value ? 'true' : 'false';
42
+ if (t === 'number') {
43
+ const n = value;
44
+ if (!Number.isFinite(n)) {
45
+ throw new TypeError('canonicalJson: non-finite numbers (NaN, Infinity) are refused');
46
+ }
47
+ return String(n);
48
+ }
49
+ if (t === 'string')
50
+ return encodeString(value);
51
+ if (t === 'undefined')
52
+ throw new TypeError('canonicalJson: undefined is refused');
53
+ if (t === 'function')
54
+ throw new TypeError('canonicalJson: a function is refused');
55
+ if (t === 'symbol')
56
+ throw new TypeError('canonicalJson: a symbol is refused');
57
+ if (t === 'bigint')
58
+ throw new TypeError('canonicalJson: a bigint is refused');
59
+ if (Array.isArray(value)) {
60
+ return `[${value.map((item) => encodeValue(item)).join(',')}]`;
61
+ }
62
+ if (t === 'object') {
63
+ if (!isPlainObject(value)) {
64
+ throw new TypeError('canonicalJson: only plain objects and arrays are refused otherwise');
65
+ }
66
+ const keys = Object.keys(value).sort();
67
+ const parts = keys.map((key) => `${encodeString(key)}:${encodeValue(value[key])}`);
68
+ return `{${parts.join(',')}}`;
69
+ }
70
+ throw new TypeError(`canonicalJson: unsupported type ${t}`);
71
+ }
72
+ function isPlainObject(value) {
73
+ if (value === null || typeof value !== 'object')
74
+ return false;
75
+ const proto = Object.getPrototypeOf(value);
76
+ return proto === Object.prototype || proto === null;
77
+ }
78
+ /**
79
+ * String literals use the same escaping `JSON.stringify` already produces —
80
+ * control characters, `"` and `\` escaped, `/` left alone — which is exactly
81
+ * what RFC 8785 specifies, so no separate escaper is written here.
82
+ */
83
+ function encodeString(value) {
84
+ return JSON.stringify(value);
85
+ }
86
+ // ---------------------------------------------------------------------------
87
+ // encodeSigned
88
+ // ---------------------------------------------------------------------------
89
+ /**
90
+ * `wtfalch-keys:${kind}:v1\n` + `canonicalJson(value)`, as UTF-8 bytes.
91
+ *
92
+ * The domain tag separates what an `'issued-row'` signature can authenticate
93
+ * from what a `'caller-credential'` signature can, so the same Ed25519 key
94
+ * signing both kinds never lets one message's bytes be replayed as the
95
+ * other's.
96
+ */
97
+ export function encodeSigned(kind, value) {
98
+ const header = `${SIGNED_PREFIX}${kind}:v1\n`;
99
+ return new TextEncoder().encode(header + canonicalJson(value));
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // pickSignable
103
+ // ---------------------------------------------------------------------------
104
+ /**
105
+ * The eight `SignableRow` fields, by explicit allowlist — never the stored
106
+ * row object itself. A column added to the table later (or a driver that
107
+ * hands back extra properties) must not silently join the signed bytes.
108
+ */
109
+ export function pickSignable(row) {
110
+ return {
111
+ id: row.id,
112
+ issuedById: row.issuedById,
113
+ keyPrefix: row.keyPrefix,
114
+ secretHash: row.secretHash,
115
+ previousSecretHash: row.previousSecretHash,
116
+ previousValidUntil: row.previousValidUntil,
117
+ grants: row.grants,
118
+ expiresAt: row.expiresAt,
119
+ };
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // Id validation
123
+ // ---------------------------------------------------------------------------
124
+ /** Every id in the contract matches `ID_PATTERN`. Throws with `label` naming the field. */
125
+ export function assertValidId(id, label) {
126
+ if (!ID_PATTERN.test(id)) {
127
+ throw new TypeError(`${label} does not match ID_PATTERN: ${JSON.stringify(id)}`);
128
+ }
129
+ }
130
+ export function isValidId(id) {
131
+ return ID_PATTERN.test(id);
132
+ }