@fungi.computer/ear-tag 0.0.0-stage → 0.0.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/dist/keys.js ADDED
@@ -0,0 +1,567 @@
1
+ import { defineIdFamily } from "@fungi.computer/cairn/id";
2
+ import { z } from "zod";
3
+ import { generateKeyAndHash, hashKey } from "./index.js";
4
+ const rateLimitSchema = z.strictObject({
5
+ limit: z.number().int().positive(),
6
+ windowSeconds: z.number().int().positive(),
7
+ });
8
+ class Refusal extends Error {
9
+ code;
10
+ retryAfterSeconds;
11
+ constructor(code, retryAfterSeconds) {
12
+ super(`Ear Tag key store refusal: ${code}`);
13
+ this.code = code;
14
+ this.retryAfterSeconds = retryAfterSeconds;
15
+ this.name = "Refusal";
16
+ }
17
+ }
18
+ const PREFIX_PATTERN = /^[a-z][a-z0-9]*(?:_[a-z][a-z0-9]*)*$/;
19
+ const prefixSchema = z.string().min(1).max(24).regex(PREFIX_PATTERN);
20
+ const requestIdSchema = z.string().min(1).max(112);
21
+ /**
22
+ * Create one tenant-scoped key store: bearer-key issuance, verification and
23
+ * lifecycle over one host's capability vocabulary. One store instance is one
24
+ * tenant; namespacing between tenants is the host's own table or storage
25
+ * boundary, not a column inside this store.
26
+ */
27
+ export function createKeyStore(options) {
28
+ const prefix = prefixSchema.parse(options.prefix);
29
+ const now = options.now ?? Date.now;
30
+ const vocabulary = options.vocabulary;
31
+ const sql = options.sql;
32
+ const ids = defineIdFamily({
33
+ family: "ear-tag",
34
+ version: 1,
35
+ kinds: { key: { prefix } },
36
+ });
37
+ const keysTable = `ear_tag_${prefix}_keys`;
38
+ const receiptsTable = `ear_tag_${prefix}_key_receipts`;
39
+ const suspensionsTable = `ear_tag_${prefix}_key_suspensions`;
40
+ const useReceiptsTable = `ear_tag_${prefix}_key_use_receipts`;
41
+ const ensure = () => {
42
+ sql.exec(`CREATE TABLE IF NOT EXISTS ${keysTable} (
43
+ key_id TEXT PRIMARY KEY,
44
+ owner TEXT NOT NULL,
45
+ digest TEXT NOT NULL UNIQUE,
46
+ name TEXT NOT NULL,
47
+ grants TEXT NOT NULL,
48
+ grant_version INTEGER NOT NULL CHECK(grant_version > 0),
49
+ expires_at INTEGER,
50
+ revoked_at INTEGER,
51
+ rate_limit TEXT,
52
+ last_used_at INTEGER,
53
+ created_at INTEGER NOT NULL,
54
+ updated_at INTEGER NOT NULL
55
+ ) STRICT`);
56
+ sql.exec(`CREATE TABLE IF NOT EXISTS ${receiptsTable} (
57
+ request_id TEXT PRIMARY KEY,
58
+ kind TEXT NOT NULL CHECK(kind IN ('grants','revoke')),
59
+ actor_id TEXT NOT NULL,
60
+ input TEXT NOT NULL,
61
+ metadata TEXT NOT NULL
62
+ ) STRICT`);
63
+ sql.exec(`CREATE TABLE IF NOT EXISTS ${suspensionsTable} (
64
+ key_id TEXT PRIMARY KEY, suspended_at INTEGER NOT NULL
65
+ ) STRICT`);
66
+ sql.exec(`CREATE TABLE IF NOT EXISTS ${useReceiptsTable} (
67
+ request_id TEXT PRIMARY KEY, key_id TEXT NOT NULL, grant_version INTEGER NOT NULL,
68
+ capability TEXT NOT NULL, target TEXT, at INTEGER NOT NULL
69
+ ) STRICT`);
70
+ };
71
+ const keyRowSchema = z.strictObject({
72
+ key_id: z.string().min(1).max(256),
73
+ owner: z.string().min(1).max(256),
74
+ digest: z.string().length(64),
75
+ name: z.string().min(1).max(128),
76
+ grants: z.string(),
77
+ grant_version: z.number().int().positive(),
78
+ expires_at: z.number().int().positive().nullable(),
79
+ revoked_at: z.number().int().nonnegative().nullable(),
80
+ rate_limit: z.string().nullable(),
81
+ last_used_at: z.number().int().nonnegative().nullable(),
82
+ created_at: z.number().int().nonnegative(),
83
+ updated_at: z.number().int().nonnegative(),
84
+ });
85
+ const grantsArraySchema = z.array(vocabulary.grantSchema).min(1).max(64);
86
+ const issueInputSchema = z.strictObject({
87
+ name: z.string().trim().min(1).max(128),
88
+ grants: grantsArraySchema,
89
+ keyId: z.string().min(1).max(256).optional(),
90
+ expiresAt: z.number().int().positive().optional(),
91
+ rateLimit: rateLimitSchema.optional(),
92
+ });
93
+ const updateGrantsInputSchema = z.strictObject({
94
+ requestId: requestIdSchema,
95
+ keyId: z.string().min(1).max(256),
96
+ expectedGrantVersion: z.number().int().positive(),
97
+ grants: grantsArraySchema,
98
+ });
99
+ const keyRecordSchema = z.strictObject({
100
+ keyId: z.string().min(1).max(256),
101
+ owner: z.string().min(1).max(256),
102
+ name: z.string().min(1).max(128),
103
+ scope: vocabulary.scopeSchema.nullable(),
104
+ grantVersion: z.number().int().positive(),
105
+ expiresAt: z.number().int().positive().nullable(),
106
+ revokedAt: z.number().int().nonnegative().nullable(),
107
+ rateLimit: rateLimitSchema.nullable(),
108
+ lastUsedAt: z.number().int().nonnegative().nullable(),
109
+ createdAt: z.number().int().nonnegative(),
110
+ updatedAt: z.number().int().nonnegative(),
111
+ });
112
+ const revokeInputSchema = z.strictObject({
113
+ requestId: requestIdSchema,
114
+ keyId: z.string().min(1).max(256),
115
+ });
116
+ const admitUseInputSchema = z.strictObject({
117
+ capability: vocabulary.capabilityNameSchema,
118
+ target: z.string().min(1).max(256).optional(),
119
+ requestId: z.string().min(1).max(256),
120
+ });
121
+ const keyPrincipalSchema = z.strictObject({
122
+ keyId: z.string().min(1).max(256),
123
+ owner: z.string().min(1).max(256),
124
+ name: z.string().min(1).max(128),
125
+ scope: vocabulary.scopeSchema,
126
+ grantVersion: z.number().int().positive(),
127
+ expiresAt: z.number().int().positive().nullable(),
128
+ });
129
+ const mutationReceiptRowSchema = z.strictObject({
130
+ request_id: requestIdSchema,
131
+ kind: z.enum(["grants", "revoke"]),
132
+ actor_id: z.string().min(1).max(256),
133
+ input: z.string().min(1),
134
+ metadata: z.string().min(1),
135
+ });
136
+ const columns = "key_id,owner,digest,name,grants,grant_version,expires_at,revoked_at,rate_limit,last_used_at,created_at,updated_at";
137
+ const decodeScope = (json) => {
138
+ let parsed;
139
+ try {
140
+ parsed = JSON.parse(json);
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ const result = vocabulary.scopeSchema.safeParse(parsed);
146
+ if (!result.success)
147
+ return null;
148
+ return { grants: vocabulary.canonicalGrants(result.data.grants) };
149
+ };
150
+ // Unlike a corrupt scope (which fails closed to no authority), a corrupt
151
+ // rate limit must never fail open to unlimited: a stored, non-null policy
152
+ // that cannot be read back refuses the whole record as unavailable.
153
+ const decodeRateLimit = (json) => {
154
+ if (json === null)
155
+ return null;
156
+ let parsed;
157
+ try {
158
+ parsed = JSON.parse(json);
159
+ }
160
+ catch {
161
+ throw new Refusal("unavailable");
162
+ }
163
+ const result = rateLimitSchema.safeParse(parsed);
164
+ if (!result.success)
165
+ throw new Refusal("unavailable");
166
+ return result.data;
167
+ };
168
+ const decode = (row) => {
169
+ const parsed = keyRowSchema.safeParse(row);
170
+ if (!parsed.success)
171
+ throw new Refusal("unavailable");
172
+ // A key id is either store-generated (`ids.key.generate()`) or
173
+ // host-chosen at issuance (`IssueKeyInput.keyId`); only the former has
174
+ // this id family's own shape, so decode trusts it as the plain, bounded
175
+ // string `keyRowSchema` already validated above, not a family member.
176
+ return {
177
+ keyId: parsed.data.key_id,
178
+ owner: parsed.data.owner,
179
+ name: parsed.data.name,
180
+ scope: decodeScope(parsed.data.grants),
181
+ grantVersion: parsed.data.grant_version,
182
+ expiresAt: parsed.data.expires_at,
183
+ revokedAt: parsed.data.revoked_at,
184
+ rateLimit: decodeRateLimit(parsed.data.rate_limit),
185
+ lastUsedAt: parsed.data.last_used_at,
186
+ createdAt: parsed.data.created_at,
187
+ updatedAt: parsed.data.updated_at,
188
+ };
189
+ };
190
+ const readById = (keyId) => {
191
+ ensure();
192
+ const row = sql
193
+ .exec(`SELECT ${columns} FROM ${keysTable} WHERE key_id=?`, keyId)
194
+ .toArray()[0];
195
+ return row === undefined ? null : decode(row);
196
+ };
197
+ const isSuspended = (keyId) => sql
198
+ .exec(`SELECT key_id FROM ${suspensionsTable} WHERE key_id=?`, keyId)
199
+ .toArray().length > 0;
200
+ const requireActive = (record) => {
201
+ if (record.scope === null)
202
+ throw new Refusal("denied");
203
+ if (record.revokedAt !== null)
204
+ throw new Refusal("denied");
205
+ if (isSuspended(record.keyId))
206
+ throw new Refusal("denied");
207
+ if (record.expiresAt !== null && record.expiresAt <= now())
208
+ throw new Refusal("expired");
209
+ return { ...record, scope: record.scope };
210
+ };
211
+ const principalFor = (record) => ({
212
+ keyId: record.keyId,
213
+ owner: record.owner,
214
+ name: record.name,
215
+ scope: record.scope,
216
+ grantVersion: record.grantVersion,
217
+ expiresAt: record.expiresAt,
218
+ });
219
+ const assertIssuer = async (actorId, grants) => {
220
+ const authority = await options.authorizeIssuer(actorId);
221
+ if (authority === null)
222
+ throw new Refusal("denied");
223
+ if (authority.kind === "bounded" &&
224
+ !vocabulary.isSubset(grants, authority.grants))
225
+ throw new Refusal("denied");
226
+ };
227
+ const rateWindows = new Map();
228
+ const admitRate = (keyId, policy) => {
229
+ const at = now();
230
+ for (const [id, window] of rateWindows)
231
+ if (at - window.windowStart >= window.windowMs)
232
+ rateWindows.delete(id);
233
+ const windowMs = policy.windowSeconds * 1_000;
234
+ const window = rateWindows.get(keyId);
235
+ if (window === undefined || at - window.windowStart >= window.windowMs) {
236
+ rateWindows.set(keyId, { windowStart: at, count: 1, windowMs });
237
+ return null;
238
+ }
239
+ if (window.count >= policy.limit)
240
+ return Math.max(1, Math.ceil((window.windowStart + window.windowMs - at) / 1_000));
241
+ rateWindows.set(keyId, { ...window, count: window.count + 1 });
242
+ return null;
243
+ };
244
+ const currentImpl = (rawPrincipal) => {
245
+ const principal = keyPrincipalSchema.parse(rawPrincipal);
246
+ const record = readById(principal.keyId);
247
+ if (record === null)
248
+ throw new Refusal("denied");
249
+ const active = requireActive(record);
250
+ if (active.grantVersion !== principal.grantVersion)
251
+ throw new Refusal("denied");
252
+ return principalFor(active);
253
+ };
254
+ const currentLiveImpl = async (principal) => {
255
+ const record = currentImpl(principal);
256
+ await assertIssuer(record.owner, record.scope.grants);
257
+ return currentImpl(record);
258
+ };
259
+ const verifyImpl = async (secret) => {
260
+ const digest = await hashKey(secret);
261
+ ensure();
262
+ const row = sql
263
+ .exec(`SELECT ${columns} FROM ${keysTable} WHERE digest=?`, digest)
264
+ .toArray()[0];
265
+ if (row === undefined)
266
+ throw new Refusal("denied");
267
+ const record = requireActive(decode(row));
268
+ await assertIssuer(record.owner, record.scope.grants);
269
+ const freshRecord = readById(record.keyId);
270
+ if (freshRecord === null)
271
+ throw new Refusal("denied");
272
+ const fresh = requireActive(freshRecord);
273
+ if (fresh.rateLimit !== null) {
274
+ const retryAfterSeconds = admitRate(fresh.keyId, fresh.rateLimit);
275
+ if (retryAfterSeconds !== null)
276
+ throw new Refusal("rate_limited", retryAfterSeconds);
277
+ }
278
+ return principalFor(fresh);
279
+ };
280
+ // Merging grants that share a capability can push one grant's `ids` past
281
+ // the bound `grantTargetsSchema` allows even when every grant the caller
282
+ // sent was itself within bounds (two disjoint 129-id grants merge into
283
+ // one 258-id grant). The merged, canonical form is what gets stored and
284
+ // later read back through this same `scopeSchema`, so it must pass that
285
+ // schema here too, before anything is written: an over-bound merge is
286
+ // the caller's own invalid input, never a bound this store relaxes, and
287
+ // refusing it before any write leaves the key exactly as it was.
288
+ const requireValidScope = (grants) => {
289
+ if (!vocabulary.scopeSchema.safeParse({ grants }).success)
290
+ throw new Refusal("invalid");
291
+ };
292
+ const issueImpl = async (actorId, rawInput) => {
293
+ const input = issueInputSchema.parse(rawInput);
294
+ const grants = vocabulary.canonicalGrants(input.grants);
295
+ requireValidScope(grants);
296
+ if (input.expiresAt !== undefined && input.expiresAt <= now())
297
+ throw new Refusal("denied");
298
+ await assertIssuer(actorId, grants);
299
+ const generated = await generateKeyAndHash({ prefix });
300
+ const createdAt = now();
301
+ const record = await options.commit(() => {
302
+ ensure();
303
+ const keyId = input.keyId ?? ids.key.generate();
304
+ // An id is never reused, including one a now-revoked key already
305
+ // held: revoke tombstones the row rather than deleting it, so it
306
+ // still answers here. The check and the insert share this one
307
+ // commit, so no other write can land between them; the PRIMARY KEY
308
+ // on key_id is the storage-level backstop behind this refusal.
309
+ if (readById(keyId) !== null)
310
+ throw new Refusal("conflict");
311
+ sql.exec(`INSERT INTO ${keysTable} (${columns}) VALUES (?,?,?,?,?,1,?,NULL,?,NULL,?,?)`, keyId, actorId, generated.digest, input.name, JSON.stringify({ grants }), input.expiresAt ?? null, input.rateLimit ? JSON.stringify(input.rateLimit) : null, createdAt, createdAt);
312
+ const stored = readById(keyId);
313
+ if (stored === null)
314
+ throw new Refusal("unavailable");
315
+ return requireActive(stored);
316
+ });
317
+ return { key: generated.key, record };
318
+ };
319
+ const readMutationReceipt = (requestId) => {
320
+ const raw = sql
321
+ .exec(`SELECT request_id,kind,actor_id,input,metadata FROM ${receiptsTable} WHERE request_id=?`, requestId)
322
+ .toArray()[0];
323
+ if (raw === undefined)
324
+ return null;
325
+ const row = mutationReceiptRowSchema.safeParse(raw);
326
+ if (!row.success)
327
+ throw new Refusal("unavailable");
328
+ let metadata;
329
+ try {
330
+ metadata = JSON.parse(row.data.metadata);
331
+ }
332
+ catch {
333
+ throw new Refusal("unavailable");
334
+ }
335
+ const parsedMetadata = keyRecordSchema.safeParse(metadata);
336
+ if (!parsedMetadata.success)
337
+ throw new Refusal("unavailable");
338
+ return {
339
+ kind: row.data.kind,
340
+ actorId: row.data.actor_id,
341
+ input: row.data.input,
342
+ metadata: parsedMetadata.data,
343
+ };
344
+ };
345
+ const recordMutationReceipt = (requestId, receipt) => {
346
+ sql.exec(`INSERT INTO ${receiptsTable} (request_id,kind,actor_id,input,metadata) VALUES (?,?,?,?,?)`, requestId, receipt.kind, receipt.actorId, receipt.input, JSON.stringify(receipt.metadata));
347
+ };
348
+ const replayMutation = (requestId, actorId, kind, input) => {
349
+ const prior = readMutationReceipt(requestId);
350
+ if (prior === null)
351
+ return null;
352
+ if (prior.kind !== kind ||
353
+ prior.actorId !== actorId ||
354
+ prior.input !== input)
355
+ throw new Refusal("conflict");
356
+ return prior.metadata;
357
+ };
358
+ const fence = (requestId, record) => {
359
+ options.onAuthorityChange?.({
360
+ requestId,
361
+ keyId: record.keyId,
362
+ grantVersion: record.grantVersion,
363
+ expiresAt: record.expiresAt,
364
+ });
365
+ };
366
+ const updateGrantsImpl = async (actorId, rawInput) => {
367
+ const input = updateGrantsInputSchema.parse(rawInput);
368
+ const grants = vocabulary.canonicalGrants(input.grants);
369
+ requireValidScope(grants);
370
+ await assertIssuer(actorId, grants);
371
+ const inputKey = JSON.stringify({
372
+ keyId: input.keyId,
373
+ expectedGrantVersion: input.expectedGrantVersion,
374
+ grants,
375
+ });
376
+ const replay = replayMutation(input.requestId, actorId, "grants", inputKey);
377
+ if (replay !== null)
378
+ return replay;
379
+ return options.commit(() => {
380
+ const repeated = replayMutation(input.requestId, actorId, "grants", inputKey);
381
+ if (repeated !== null)
382
+ return repeated;
383
+ const current = readById(input.keyId);
384
+ if (current === null)
385
+ throw new Refusal("denied");
386
+ if (current.revokedAt !== null)
387
+ throw new Refusal("denied");
388
+ if (current.grantVersion !== input.expectedGrantVersion)
389
+ throw new Refusal("conflict");
390
+ const updatedAt = now();
391
+ sql.exec(`UPDATE ${keysTable} SET grants=?,grant_version=grant_version+1,updated_at=? WHERE key_id=? AND grant_version=? AND revoked_at IS NULL`, JSON.stringify({ grants }), updatedAt, input.keyId, input.expectedGrantVersion);
392
+ const after = readById(input.keyId);
393
+ if (after === null)
394
+ throw new Refusal("conflict");
395
+ if (after.grantVersion !== input.expectedGrantVersion + 1)
396
+ throw new Refusal("conflict");
397
+ fence(input.requestId, after);
398
+ recordMutationReceipt(input.requestId, {
399
+ kind: "grants",
400
+ actorId,
401
+ input: inputKey,
402
+ metadata: after,
403
+ });
404
+ return after;
405
+ });
406
+ };
407
+ const revokeImpl = async (actorId, rawInput) => {
408
+ const input = revokeInputSchema.parse(rawInput);
409
+ const authority = await options.authorizeIssuer(actorId);
410
+ if (authority === null)
411
+ throw new Refusal("denied");
412
+ const inputKey = JSON.stringify({ keyId: input.keyId });
413
+ const replay = replayMutation(input.requestId, actorId, "revoke", inputKey);
414
+ if (replay !== null)
415
+ return replay;
416
+ return options.commit(() => {
417
+ const repeated = replayMutation(input.requestId, actorId, "revoke", inputKey);
418
+ if (repeated !== null)
419
+ return repeated;
420
+ const current = readById(input.keyId);
421
+ if (current === null)
422
+ throw new Refusal("denied");
423
+ if (current.revokedAt === null) {
424
+ const revokedAt = now();
425
+ sql.exec(`UPDATE ${keysTable} SET revoked_at=?,updated_at=? WHERE key_id=? AND revoked_at IS NULL`, revokedAt, revokedAt, current.keyId);
426
+ }
427
+ const revoked = readById(current.keyId);
428
+ if (revoked === null)
429
+ throw new Refusal("conflict");
430
+ if (revoked.revokedAt === null)
431
+ throw new Refusal("conflict");
432
+ fence(input.requestId, revoked);
433
+ recordMutationReceipt(input.requestId, {
434
+ kind: "revoke",
435
+ actorId,
436
+ input: inputKey,
437
+ metadata: revoked,
438
+ });
439
+ return revoked;
440
+ });
441
+ };
442
+ const listImpl = async (actorId, owner) => {
443
+ const authority = await options.authorizeIssuer(actorId);
444
+ if (authority === null)
445
+ throw new Refusal("denied");
446
+ ensure();
447
+ const rows = owner === undefined
448
+ ? sql
449
+ .exec(`SELECT ${columns} FROM ${keysTable} ORDER BY created_at DESC,key_id DESC`)
450
+ .toArray()
451
+ : sql
452
+ .exec(`SELECT ${columns} FROM ${keysTable} WHERE owner=? ORDER BY created_at DESC,key_id DESC`, owner)
453
+ .toArray();
454
+ return rows.map(decode);
455
+ };
456
+ const readImpl = async (actorId, keyId) => {
457
+ const record = readById(keyId);
458
+ if (record === null)
459
+ throw new Refusal("denied");
460
+ const active = requireActive(record);
461
+ await assertIssuer(actorId, active.scope.grants);
462
+ return principalFor(active);
463
+ };
464
+ const admitUseImpl = async (principal, rawInput) => {
465
+ const input = admitUseInputSchema.parse(rawInput);
466
+ const live = await currentLiveImpl(principal);
467
+ if (!vocabulary.grantsAllow(live.scope.grants, input.capability, input.target))
468
+ throw new Refusal("denied");
469
+ const at = now();
470
+ await options.commit(() => {
471
+ currentImpl(live);
472
+ const prior = sql
473
+ .exec(`SELECT key_id,grant_version,capability,target FROM ${useReceiptsTable} WHERE request_id=?`, input.requestId)
474
+ .toArray()[0];
475
+ if (prior !== undefined) {
476
+ if (prior.key_id !== live.keyId ||
477
+ prior.grant_version !== live.grantVersion ||
478
+ prior.capability !== input.capability ||
479
+ prior.target !== (input.target ?? null))
480
+ throw new Refusal("conflict");
481
+ return;
482
+ }
483
+ sql.exec(`INSERT INTO ${useReceiptsTable} (request_id,key_id,grant_version,capability,target,at) VALUES (?,?,?,?,?,?)`, input.requestId, live.keyId, live.grantVersion, input.capability, input.target ?? null, at);
484
+ });
485
+ return live;
486
+ };
487
+ const suspendOwnerImpl = async (owner, requestId) => {
488
+ ensure();
489
+ await options.commit(() => {
490
+ const rows = sql
491
+ .exec(`SELECT ${columns} FROM ${keysTable} WHERE owner=?`, owner)
492
+ .toArray()
493
+ .map(decode);
494
+ for (const record of rows) {
495
+ sql.exec(`INSERT OR IGNORE INTO ${suspensionsTable} (key_id,suspended_at) VALUES (?,?)`, record.keyId, now());
496
+ fence(JSON.stringify(["owner-suspension", requestId, record.keyId]), record);
497
+ }
498
+ });
499
+ };
500
+ const revokeAllImpl = (closedAt) => {
501
+ ensure();
502
+ const rows = sql
503
+ .exec(`SELECT ${columns} FROM ${keysTable} WHERE revoked_at IS NULL`)
504
+ .toArray()
505
+ .map(decode);
506
+ for (const record of rows) {
507
+ sql.exec(`UPDATE ${keysTable} SET revoked_at=?,updated_at=? WHERE key_id=? AND revoked_at IS NULL`, closedAt, closedAt, record.keyId);
508
+ // Fence the post-revoke row, not the pre-revoke `record` read above;
509
+ // grantVersion and expiresAt do not change on revoke, but a fence
510
+ // naming a now-revoked key must never read as still live.
511
+ const revoked = readById(record.keyId);
512
+ if (revoked !== null)
513
+ fence(JSON.stringify(["revoke-all", closedAt, record.keyId]), revoked);
514
+ }
515
+ };
516
+ const touchImpl = async (keyId) => {
517
+ const record = readById(keyId);
518
+ if (record === null)
519
+ throw new Refusal("denied");
520
+ await options.commit(() => {
521
+ ensure();
522
+ sql.exec(`UPDATE ${keysTable} SET last_used_at=? WHERE key_id=?`, now(), keyId);
523
+ });
524
+ };
525
+ const boundary = async (operation) => {
526
+ try {
527
+ return { kind: "ok", value: await operation() };
528
+ }
529
+ catch (error) {
530
+ if (error instanceof Refusal)
531
+ return {
532
+ kind: "error",
533
+ error: error.retryAfterSeconds === undefined
534
+ ? { code: error.code }
535
+ : {
536
+ code: error.code,
537
+ retryAfterSeconds: error.retryAfterSeconds,
538
+ },
539
+ };
540
+ // Every `.parse()` call in this store runs only on caller-supplied
541
+ // input (stored data is always read through `.safeParse()` and a
542
+ // `Refusal` above), so a ZodError here is always the caller's own
543
+ // malformed request, never the store's state.
544
+ if (error instanceof z.ZodError)
545
+ return { kind: "error", error: { code: "invalid" } };
546
+ // An unexpected, unclassified failure: never silently relabel it as
547
+ // one of the closed reasons above without a trace of what it was.
548
+ console.error("Ear Tag key store: unexpected failure", error);
549
+ return { kind: "error", error: { code: "unavailable" } };
550
+ }
551
+ };
552
+ return {
553
+ issue: (actorId, input) => boundary(() => issueImpl(actorId, input)),
554
+ verify: (secret) => boundary(() => verifyImpl(secret)),
555
+ current: (principal) => boundary(() => currentImpl(principal)),
556
+ list: (actorId, owner) => boundary(() => listImpl(actorId, owner)),
557
+ read: (actorId, keyId) => boundary(() => readImpl(actorId, keyId)),
558
+ updateGrants: (actorId, input) => boundary(() => updateGrantsImpl(actorId, input)),
559
+ revoke: (actorId, input) => boundary(() => revokeImpl(actorId, input)),
560
+ suspendOwner: (owner, requestId) => boundary(() => suspendOwnerImpl(owner, requestId)),
561
+ revokeAll: (closedAt) => boundary(() => {
562
+ revokeAllImpl(closedAt);
563
+ }),
564
+ admitUse: (principal, input) => boundary(() => admitUseImpl(principal, input)),
565
+ touch: (keyId) => boundary(() => touchImpl(keyId)),
566
+ };
567
+ }
@@ -0,0 +1,16 @@
1
+ <!-- Generated by Botanical API docs (TypeDoc + typedoc-plugin-markdown). Do not edit. -->
2
+
3
+ **@fungi.computer/ear-tag**
4
+
5
+ ---
6
+
7
+ # @fungi.computer/ear-tag
8
+
9
+ ## Modules
10
+
11
+ | Module | Description |
12
+ | ------------------------- | ----------- |
13
+ | [grants](grants.md) | - |
14
+ | [index](index.md) | - |
15
+ | [keys-node](keys-node.md) | - |
16
+ | [keys](keys.md) | - |