@kubun/store-delegation 0.11.0 → 0.12.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/lib/api.d.ts CHANGED
@@ -13,27 +13,73 @@ export type DelegationStoreAPI = {
13
13
  atTime: number;
14
14
  }): Promise<Array<DelegationToken>>;
15
15
  listIssuedTokens(grantor: string): Promise<Array<DelegationToken>>;
16
- getDelegationTokenByJTI(jti: string): Promise<DelegationToken | null>;
16
+ getDelegationTokenByGrantorJTI(params: {
17
+ jti: string;
18
+ grantor: string;
19
+ }): Promise<DelegationToken | null>;
20
+ listDelegationTokensByJTI(jti: string): Promise<Array<DelegationToken>>;
17
21
  removeDelegationToken(params: {
18
22
  jti: string;
19
23
  }): Promise<boolean>;
20
24
  addRevocation(input: InsertRevokedCapability): Promise<boolean>;
21
- getRevocation(jti: string): Promise<RevokedCapability | null>;
22
- isRevoked(jti: string): Promise<boolean>;
25
+ getRevocationByIssuer(jti: string, issuer: string): Promise<RevokedCapability | null>;
26
+ listRevocations(jti: string): Promise<Array<RevokedCapability>>;
27
+ /**
28
+ * Diagnostic only — **not** an authorization read. The `verified_at IS NOT NULL`
29
+ * predicate is the inverse of the enforcement rule: a co-member holds no copy
30
+ * of the capability, so its genuine revocation stays pending forever and this
31
+ * answers `false` for it. Authorization goes through
32
+ * `createDelegationRevocationChecker`, which is what closes that fail-open.
33
+ */
34
+ isRevokedBy(jti: string, issuer: string): Promise<boolean>;
23
35
  getHeldRevocations(params: {
24
36
  audience: string;
25
37
  }): Promise<Array<HeldRevocation>>;
26
- markRevocationVerified(jti: string, params: {
38
+ markRevocationVerified(jti: string, issuer: string, params: {
27
39
  cap_exp: number;
28
40
  verified_at?: number;
29
41
  }): Promise<boolean>;
30
- getPendingRevocationByJTI(jti: string): Promise<RevokedCapability | null>;
31
- deletePendingRevocation(jti: string): Promise<boolean>;
42
+ getPendingRevocationByIssuer(jti: string, issuer: string): Promise<RevokedCapability | null>;
43
+ deletePendingRevocationsFromOtherIssuers(jti: string, issuer: string): Promise<number>;
32
44
  purgeExpiredRevocations(params: {
33
45
  graceSeconds: number;
34
46
  }): Promise<number>;
35
47
  purgeDeadPendingRevocations(params: {
36
- maxAgeSeconds: number;
48
+ graceSeconds: number;
37
49
  }): Promise<number>;
38
50
  };
51
+ /**
52
+ * Longest lifetime (`exp - iat`, seconds) a `document/write` capability may
53
+ * carry — 30 days.
54
+ *
55
+ * This is a retention bound, not a policy preference. A co-member holds no copy
56
+ * of the capability a revocation names, so the pending-revocation sweep can only
57
+ * bound the row's useful life from the revocation itself:
58
+ * `cap.exp ≤ cap.iat + MAX_CAP_TTL_SECONDS ≤ revoked_iat + MAX_CAP_TTL_SECONDS`.
59
+ * That inequality holds only while every *accepted* capability respects the
60
+ * limit, so it is enforced on receive as well as on mint — a capability minted
61
+ * outside kubun's resolver would otherwise outlive its own revocation's
62
+ * retention and start being honored again.
63
+ */
64
+ export declare const MAX_CAP_TTL_SECONDS = 2592000;
65
+ /**
66
+ * Verified revocations remain queryable this long past the capability's
67
+ * expiry — enough for audit lookbacks before the row is pruned. Pending rows
68
+ * reuse it as the margin past `revoked_iat + MAX_CAP_TTL_SECONDS`, so both
69
+ * kinds age out on the same policy.
70
+ */
71
+ export declare const REVOCATION_GC_VERIFIED_GRACE_SECONDS: number;
72
+ /**
73
+ * How far ahead of the local clock a revocation's `iat` may sit before it is
74
+ * floored on write — one hour.
75
+ *
76
+ * A pending row's retention is computed from `revoked_iat`, and `revoked_iat`
77
+ * comes off a broadcast any co-member can sign, so an unbounded stamp buys an
78
+ * immortal row. Floored rather than rejected: dropping a genuine revocation is
79
+ * the failure this store exists to prevent, and a grantor with real clock skew
80
+ * must not lose its record. `KubunEngine`'s `maxDriftMS` (`engine.ts:208`,
81
+ * default `3_600_000`) bounds the same hazard on incoming mutations with the
82
+ * same tolerance, but it rejects — the two are not interchangeable.
83
+ */
84
+ export declare const MAX_REVOCATION_FUTURE_DRIFT_SECONDS = 3600;
39
85
  export declare function createDelegationStore(db: Kysely<DelegationStoreTables>, adapter: Adapter, logger?: Logger): DelegationStoreAPI;
package/lib/api.js CHANGED
@@ -1,16 +1,50 @@
1
+ import { normalizeDID } from '@kokuin/token';
1
2
  import { getKubunLogger } from '@kubun/logger';
2
3
  // --- Revocation GC defaults ---
4
+ /**
5
+ * Longest lifetime (`exp - iat`, seconds) a `document/write` capability may
6
+ * carry — 30 days.
7
+ *
8
+ * This is a retention bound, not a policy preference. A co-member holds no copy
9
+ * of the capability a revocation names, so the pending-revocation sweep can only
10
+ * bound the row's useful life from the revocation itself:
11
+ * `cap.exp ≤ cap.iat + MAX_CAP_TTL_SECONDS ≤ revoked_iat + MAX_CAP_TTL_SECONDS`.
12
+ * That inequality holds only while every *accepted* capability respects the
13
+ * limit, so it is enforced on receive as well as on mint — a capability minted
14
+ * outside kubun's resolver would otherwise outlive its own revocation's
15
+ * retention and start being honored again.
16
+ */ export const MAX_CAP_TTL_SECONDS = 2_592_000;
3
17
  // Probability that each delegation-token insert triggers a revocation sweep.
4
18
  // 1% keeps the sweep amortized cost negligible; over a busy stream it still
5
19
  // runs often enough to bound table growth.
6
20
  const REVOCATION_GC_SAMPLE_RATE = 0.01;
7
- // Verified revocations remain queryable this long past the capability's
8
- // expiry — enough for audit lookbacks before the row is pruned.
9
- const REVOCATION_GC_VERIFIED_GRACE_SECONDS = 86_400 * 30;
10
- // Pending revocations that never receive a matching capability are dropped
11
- // after this age — past this window the broadcast is treated as dead.
12
- const REVOCATION_GC_PENDING_MAX_AGE_SECONDS = 86_400 * 7;
21
+ /**
22
+ * Verified revocations remain queryable this long past the capability's
23
+ * expiry — enough for audit lookbacks before the row is pruned. Pending rows
24
+ * reuse it as the margin past `revoked_iat + MAX_CAP_TTL_SECONDS`, so both
25
+ * kinds age out on the same policy.
26
+ */ export const REVOCATION_GC_VERIFIED_GRACE_SECONDS = 86_400 * 30;
27
+ /**
28
+ * How far ahead of the local clock a revocation's `iat` may sit before it is
29
+ * floored on write — one hour.
30
+ *
31
+ * A pending row's retention is computed from `revoked_iat`, and `revoked_iat`
32
+ * comes off a broadcast any co-member can sign, so an unbounded stamp buys an
33
+ * immortal row. Floored rather than rejected: dropping a genuine revocation is
34
+ * the failure this store exists to prevent, and a grantor with real clock skew
35
+ * must not lose its record. `KubunEngine`'s `maxDriftMS` (`engine.ts:208`,
36
+ * default `3_600_000`) bounds the same hazard on incoming mutations with the
37
+ * same tolerance, but it rejects — the two are not interchangeable.
38
+ */ export const MAX_REVOCATION_FUTURE_DRIFT_SECONDS = 3_600;
13
39
  // --- Factory function ---
40
+ // One identity reaches this store in two spellings: a `did:peer:4` signer
41
+ // embeds its long form as the `iss` of its first token to a given audience and
42
+ // its short form thereafter, while `identity.id` and `sub` are always short. So
43
+ // the DID a capability presents at check time and the DID its revocation was
44
+ // filed under are genuinely different strings for the same peer, with no
45
+ // attacker involved. Every DID crossing this boundary — written or compared —
46
+ // is folded through `normalizeDID`, because a raw equality here silently
47
+ // returns "not revoked".
14
48
  export function createDelegationStore(db, adapter, logger = getKubunLogger('store-delegation')) {
15
49
  const api = {
16
50
  // --- Delegation token operations ---
@@ -26,17 +60,19 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
26
60
  // for emission gating, and the LWW upsert itself remains correct under the
27
61
  // SQL `>` arbiter regardless.
28
62
  async addDelegationToken (token) {
63
+ const grantor = normalizeDID(token.grantor);
64
+ const audience = normalizeDID(token.audience);
29
65
  const existing = await db.selectFrom('kubun_delegation_tokens').select([
30
66
  'jti',
31
67
  'token',
32
68
  'act',
33
69
  'exp',
34
70
  'hlc'
35
- ]).where('grantor', '=', token.grantor).where('audience', '=', token.audience).where('resource', '=', token.resource).executeTakeFirst();
71
+ ]).where('grantor', '=', grantor).where('audience', '=', audience).where('resource', '=', token.resource).executeTakeFirst();
36
72
  await db.insertInto('kubun_delegation_tokens').values({
37
73
  jti: token.jti,
38
- grantor: token.grantor,
39
- audience: token.audience,
74
+ grantor,
75
+ audience,
40
76
  token: token.token,
41
77
  resource: token.resource,
42
78
  act: token.act,
@@ -79,7 +115,7 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
79
115
  graceSeconds: REVOCATION_GC_VERIFIED_GRACE_SECONDS
80
116
  });
81
117
  await api.purgeDeadPendingRevocations({
82
- maxAgeSeconds: REVOCATION_GC_PENDING_MAX_AGE_SECONDS
118
+ graceSeconds: REVOCATION_GC_VERIFIED_GRACE_SECONDS
83
119
  });
84
120
  } catch (error) {
85
121
  logger.warn('revocation GC sweep failed during addDelegationToken: {error}', {
@@ -90,10 +126,18 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
90
126
  return changed;
91
127
  },
92
128
  async getDelegationTokens (params) {
93
- return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', params.grantor).where('audience', '=', params.audience).execute();
129
+ return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', normalizeDID(params.grantor)).where('audience', '=', normalizeDID(params.audience)).execute();
94
130
  },
131
+ // The join carries the author predicate for two reasons. It is an
132
+ // authorization filter — only the grantor's own claim may withhold a token
133
+ // from the audience holding it. It is also what keeps the result one row
134
+ // per token: `(jti, revoker_did)` is the revocation key, so pinning
135
+ // `revoker_did` to the token's grantor makes the fan-out at most 1. A
136
+ // repeated token would not merely be redundant — `checkCapability` reads
137
+ // the list as a delegation chain, and a duplicate breaks a multi-hop chain
138
+ // outright.
95
139
  async getHeldTokens (params) {
96
- return await db.selectFrom('kubun_delegation_tokens').leftJoin('kubun_revoked_capabilities', 'kubun_revoked_capabilities.jti', 'kubun_delegation_tokens.jti').selectAll('kubun_delegation_tokens').where('audience', '=', params.audience).where('exp', '>', params.atTime)// Pending revocations (verified_at IS NULL) are not binding until the
140
+ return await db.selectFrom('kubun_delegation_tokens').leftJoin('kubun_revoked_capabilities', (join)=>join.onRef('kubun_revoked_capabilities.jti', '=', 'kubun_delegation_tokens.jti').onRef('kubun_revoked_capabilities.revoker_did', '=', 'kubun_delegation_tokens.grantor')).selectAll('kubun_delegation_tokens').where('audience', '=', normalizeDID(params.audience)).where('exp', '>', params.atTime)// Pending revocations (verified_at IS NULL) are not binding until the
97
141
  // cap-arrival cross-check verifies them, so they don't filter tokens.
98
142
  .where((eb)=>eb.or([
99
143
  eb('kubun_revoked_capabilities.jti', 'is', null),
@@ -101,11 +145,28 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
101
145
  ])).execute();
102
146
  },
103
147
  async listIssuedTokens (grantor) {
104
- return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', grantor).execute();
148
+ return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', normalizeDID(grantor)).execute();
105
149
  },
106
- async getDelegationTokenByJTI (jti) {
107
- const row = await db.selectFrom('kubun_delegation_tokens').selectAll().where('jti', '=', jti).executeTakeFirst();
108
- return row ?? null;
150
+ // `jti` is picked by whoever minted the capability and the grant travels to
151
+ // the whole group, so any co-member can mint one reusing a `jti` it saw. It
152
+ // names a row only inside a single grantor's namespace, and even there it
153
+ // is not a key — the primary key is `(grantor, audience, resource)`. A
154
+ // grantor that reused one `jti` across two of its own grants would leave
155
+ // this read with no representable answer, so it throws rather than pick.
156
+ async getDelegationTokenByGrantorJTI (params) {
157
+ const grantor = normalizeDID(params.grantor);
158
+ const rows = await db.selectFrom('kubun_delegation_tokens').selectAll().where('jti', '=', params.jti).where('grantor', '=', grantor).limit(2).execute();
159
+ if (rows.length > 1) {
160
+ throw new Error(`Ambiguous delegation token: grantor ${grantor} holds more than one row for jti ${params.jti}`);
161
+ }
162
+ return rows[0] ?? null;
163
+ },
164
+ // Every grantor's row for one `jti`. For diagnostics and for absence
165
+ // assertions that must hold across grantors — an authorization decision
166
+ // needs `getDelegationTokenByGrantorJTI`, since any row here may be a
167
+ // co-member's.
168
+ async listDelegationTokensByJTI (jti) {
169
+ return await db.selectFrom('kubun_delegation_tokens').selectAll().where('jti', '=', jti).orderBy('grantor').execute();
109
170
  },
110
171
  // Hard-delete a held token by `jti`. Used to drop a delegated write
111
172
  // capability the server no longer needs — e.g. when a viewer disconnects a
@@ -122,25 +183,47 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
122
183
  // per-millisecond counter stays within the pad width. The pre-read drives
123
184
  // the boolean change signal that emission gates consume; the upsert is
124
185
  // not wrapped in a transaction because callers already run inside one.
186
+ //
187
+ // Both the pre-read and the conflict target are scoped to `(jti,
188
+ // revoker_did)`: several revokers may name the same `jti`, and each may
189
+ // only arbitrate against its own row. A `jti`-only match here would let one
190
+ // author's HLC decide another author's outcome.
125
191
  async addRevocation (input) {
192
+ const revokerDID = normalizeDID(input.revoker_did);
193
+ // `revoked_iat` is a claim from a signed broadcast, and on a pending row
194
+ // no issuer has been matched yet — so any co-member can name a `jti` with
195
+ // a far-future stamp and, since the pending sweep is keyed off this
196
+ // column, keep the row past every sweep. Floored, not rejected: the
197
+ // record still binds, only its retention is bounded.
198
+ const maxIat = Math.floor(Date.now() / 1000) + MAX_REVOCATION_FUTURE_DRIFT_SECONDS;
199
+ const revokedIat = Math.min(input.revoked_iat, maxIat);
200
+ if (revokedIat !== input.revoked_iat) {
201
+ logger.warn('revocation {jti} from {revoker} carries a future iat {iat}, floored to {floored}', {
202
+ jti: input.jti,
203
+ revoker: revokerDID,
204
+ iat: input.revoked_iat,
205
+ floored: revokedIat
206
+ });
207
+ }
126
208
  const existing = await db.selectFrom('kubun_revoked_capabilities').select([
127
- 'revoker_did',
128
209
  'revoked_iat',
129
210
  'revocation_token',
130
211
  'verified_at',
131
212
  'cap_exp',
132
213
  'hlc'
133
- ]).where('jti', '=', input.jti).executeTakeFirst();
214
+ ]).where('jti', '=', input.jti).where('revoker_did', '=', revokerDID).executeTakeFirst();
134
215
  await db.insertInto('kubun_revoked_capabilities').values({
135
216
  jti: input.jti,
136
- revoker_did: input.revoker_did,
137
- revoked_iat: input.revoked_iat,
217
+ revoker_did: revokerDID,
218
+ revoked_iat: revokedIat,
138
219
  revocation_token: input.revocation_token,
139
220
  verified_at: input.verified_at ?? null,
140
221
  cap_exp: input.cap_exp ?? null,
141
222
  hlc: input.hlc
142
- }).onConflict((oc)=>oc.column('jti').doUpdateSet((eb)=>({
143
- revoker_did: eb.ref('excluded.revoker_did'),
223
+ }).onConflict((oc)=>oc.columns([
224
+ 'jti',
225
+ 'revoker_did'
226
+ ]).doUpdateSet((eb)=>({
144
227
  revoked_iat: eb.ref('excluded.revoked_iat'),
145
228
  revocation_token: eb.ref('excluded.revocation_token'),
146
229
  verified_at: eb.ref('excluded.verified_at'),
@@ -156,14 +239,23 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
156
239
  }
157
240
  const incomingVerifiedAt = input.verified_at ?? null;
158
241
  const incomingCapExp = input.cap_exp ?? null;
159
- return existing.revoker_did !== input.revoker_did || existing.revoked_iat !== input.revoked_iat || existing.revocation_token !== input.revocation_token || existing.verified_at !== incomingVerifiedAt || existing.cap_exp !== incomingCapExp;
242
+ return existing.revoked_iat !== revokedIat || existing.revocation_token !== input.revocation_token || existing.verified_at !== incomingVerifiedAt || existing.cap_exp !== incomingCapExp;
160
243
  },
161
- async getRevocation (jti) {
162
- const row = await db.selectFrom('kubun_revoked_capabilities').selectAll().where('jti', '=', jti).executeTakeFirst();
244
+ // `jti` alone does not identify a row: several revokers may name the same
245
+ // capability and only the one signed by its issuer binds. Every read names
246
+ // the author it is asking about.
247
+ async getRevocationByIssuer (jti, issuer) {
248
+ const row = await db.selectFrom('kubun_revoked_capabilities').selectAll().where('jti', '=', jti).where('revoker_did', '=', normalizeDID(issuer)).executeTakeFirst();
163
249
  return row ?? null;
164
250
  },
165
- async isRevoked (jti) {
166
- const row = await db.selectFrom('kubun_revoked_capabilities').select('jti').where('jti', '=', jti).where('verified_at', 'is not', null).limit(1).executeTakeFirst();
251
+ // Every author's claim about one `jti`. For diagnostics and for absence
252
+ // assertions that must hold across all authors — an enforcement decision
253
+ // needs `getRevocationByIssuer`, since any row here may be a co-member's.
254
+ async listRevocations (jti) {
255
+ return await db.selectFrom('kubun_revoked_capabilities').selectAll().where('jti', '=', jti).orderBy('revoker_did').execute();
256
+ },
257
+ async isRevokedBy (jti, issuer) {
258
+ const row = await db.selectFrom('kubun_revoked_capabilities').select('jti').where('jti', '=', jti).where('revoker_did', '=', normalizeDID(issuer)).where('verified_at', 'is not', null).executeTakeFirst();
167
259
  return row != null;
168
260
  },
169
261
  // Explicit column list (no `selectAll`) because `kubun_revoked_capabilities`
@@ -171,9 +263,11 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
171
263
  // `created_at`, `updated_at`); `selectAll` would produce an ambiguous
172
264
  // projection. The INNER JOIN drops verified revocations whose cap row is not
173
265
  // held locally, which is exactly the desired filter for an audience-scoped
174
- // query.
266
+ // query. It also joins on `revoker_did = grantor`: a co-member's row for the
267
+ // same `jti` revokes nothing, so surfacing it here would report a held
268
+ // capability as revoked on someone else's say-so.
175
269
  async getHeldRevocations (params) {
176
- const rows = await db.selectFrom('kubun_revoked_capabilities').innerJoin('kubun_delegation_tokens', 'kubun_delegation_tokens.jti', 'kubun_revoked_capabilities.jti').select([
270
+ const rows = await db.selectFrom('kubun_revoked_capabilities').innerJoin('kubun_delegation_tokens', (join)=>join.onRef('kubun_delegation_tokens.jti', '=', 'kubun_revoked_capabilities.jti').onRef('kubun_delegation_tokens.grantor', '=', 'kubun_revoked_capabilities.revoker_did')).select([
177
271
  'kubun_revoked_capabilities.jti as jti',
178
272
  'kubun_delegation_tokens.grantor as grantor',
179
273
  'kubun_delegation_tokens.audience as audience',
@@ -181,7 +275,7 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
181
275
  'kubun_revoked_capabilities.revoked_iat as revoked_iat',
182
276
  'kubun_revoked_capabilities.verified_at as verified_at',
183
277
  'kubun_revoked_capabilities.cap_exp as cap_exp'
184
- ]).where('kubun_revoked_capabilities.verified_at', 'is not', null).where('kubun_delegation_tokens.audience', '=', params.audience).execute();
278
+ ]).where('kubun_revoked_capabilities.verified_at', 'is not', null).where('kubun_delegation_tokens.audience', '=', normalizeDID(params.audience)).execute();
185
279
  return rows.map((row)=>({
186
280
  jti: row.jti,
187
281
  grantor: row.grantor,
@@ -193,22 +287,30 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
193
287
  cap_exp: row.cap_exp
194
288
  }));
195
289
  },
196
- async markRevocationVerified (jti, params) {
290
+ // Promotion is an authorization decision about one author's claim: the
291
+ // arriving capability proves only that its own issuer's record is genuine.
292
+ // Scoped to that author, so a co-member's row for the same `jti` is not
293
+ // promoted on the strength of someone else's capability.
294
+ async markRevocationVerified (jti, issuer, params) {
197
295
  const verifiedAt = params.verified_at ?? Math.floor(Date.now() / 1000);
198
296
  const result = await db.updateTable('kubun_revoked_capabilities').set({
199
297
  verified_at: verifiedAt,
200
298
  cap_exp: params.cap_exp,
201
299
  updated_at: adapter.encodeTimestamp(new Date())
202
- }).where('jti', '=', jti).where('verified_at', 'is', null).executeTakeFirst();
300
+ }).where('jti', '=', jti).where('revoker_did', '=', normalizeDID(issuer)).where('verified_at', 'is', null).executeTakeFirst();
203
301
  return (result.numUpdatedRows ?? 0n) > 0n;
204
302
  },
205
- async getPendingRevocationByJTI (jti) {
206
- const row = await db.selectFrom('kubun_revoked_capabilities').selectAll().where('jti', '=', jti).where('verified_at', 'is', null).executeTakeFirst();
303
+ async getPendingRevocationByIssuer (jti, issuer) {
304
+ const row = await db.selectFrom('kubun_revoked_capabilities').selectAll().where('jti', '=', jti).where('revoker_did', '=', normalizeDID(issuer)).where('verified_at', 'is', null).executeTakeFirst();
207
305
  return row ?? null;
208
306
  },
209
- async deletePendingRevocation (jti) {
210
- const result = await db.deleteFrom('kubun_revoked_capabilities').where('jti', '=', jti).where('verified_at', 'is', null).executeTakeFirst();
211
- return (result.numDeletedRows ?? 0n) > 0n;
307
+ // Drop the pending rows a capability from `issuer` proves nothing about.
308
+ // The inverse scoping of `markRevocationVerified`: what that promotes this
309
+ // discards, so the two together settle every pending row for the `jti`
310
+ // without either one answering for an author it did not name.
311
+ async deletePendingRevocationsFromOtherIssuers (jti, issuer) {
312
+ const result = await db.deleteFrom('kubun_revoked_capabilities').where('jti', '=', jti).where('revoker_did', '!=', normalizeDID(issuer)).where('verified_at', 'is', null).executeTakeFirst();
313
+ return Number(result.numDeletedRows ?? 0n);
212
314
  },
213
315
  // `cap_exp` is a plain integer column of epoch seconds (no adapter
214
316
  // coercion on either side); the predicate is rewritten as
@@ -220,13 +322,22 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
220
322
  const result = await db.deleteFrom('kubun_revoked_capabilities').where('verified_at', 'is not', null).where('cap_exp', 'is not', null).where('cap_exp', '<', cutoff).executeTakeFirst();
221
323
  return Number(result.numDeletedRows ?? 0n);
222
324
  },
223
- // `created_at` is adapter-encoded (integer seconds on SQLite,
224
- // timestamptz on Postgres), so the cutoff is produced via
225
- // `adapter.encodeTimestamp` to match the stored representation on both
226
- // back-ends.
325
+ // A pending row names a capability this device does not hold, so `cap_exp`
326
+ // is unknown and the row's useful life can only be bounded from the
327
+ // revocation itself: the capability was live when it was revoked, so
328
+ // `cap.exp ≤ revoked_iat + MAX_CAP_TTL_SECONDS`. Keyed off `revoked_iat`
329
+ // rather than `created_at` because a row dropped while its capability is
330
+ // still honored un-revokes it.
331
+ //
332
+ // `revoked_iat` is a bigint of unix seconds — not an adapter-encoded
333
+ // timestamp like `created_at`, which is integer seconds on SQLite but
334
+ // timestamptz on Postgres. Building this cutoff through
335
+ // `adapter.encodeTimestamp` would compare a Postgres ISO string against a
336
+ // bigint. Written as `revoked_iat < now - ttl - grace` so it stays plain
337
+ // integer arithmetic on both back-ends.
227
338
  async purgeDeadPendingRevocations (params) {
228
- const threshold = adapter.encodeTimestamp(new Date(Date.now() - params.maxAgeSeconds * 1000));
229
- const result = await db.deleteFrom('kubun_revoked_capabilities').where('verified_at', 'is', null).where('created_at', '<', threshold).executeTakeFirst();
339
+ const cutoff = Math.floor(Date.now() / 1000) - MAX_CAP_TTL_SECONDS - Math.floor(params.graceSeconds);
340
+ const result = await db.deleteFrom('kubun_revoked_capabilities').where('verified_at', 'is', null).where('revoked_iat', '<', cutoff).executeTakeFirst();
230
341
  return Number(result.numDeletedRows ?? 0n);
231
342
  }
232
343
  };
package/lib/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { StoreProvider } from '@kubun/db';
2
2
  import type { DelegationStoreAPI } from './api.js';
3
3
  export type { DelegationStoreAPI } from './api.js';
4
- export { createDelegationStore } from './api.js';
4
+ export { createDelegationStore, MAX_CAP_TTL_SECONDS, MAX_REVOCATION_FUTURE_DRIFT_SECONDS, REVOCATION_GC_VERIFIED_GRACE_SECONDS, } from './api.js';
5
5
  export { delegationStoreDefinition } from './definition.js';
6
- export { createRevocationBackend } from './revocation-backend.js';
6
+ export { createDelegationRevocationChecker, type RevocationClaims, } from './revocation-checker.js';
7
7
  export declare const DELEGATION_STORE: "delegation";
8
8
  export declare function getDelegationStore(provider: StoreProvider): Promise<DelegationStoreAPI>;
9
9
  export type { DelegationStoreTables, DelegationToken, DelegationTokenTable, HeldRevocation, InsertDelegationToken, InsertRevokedCapability, RevokedCapability, RevokedCapabilityTable, } from './tables.js';
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
- export { createDelegationStore } from './api.js';
1
+ export { createDelegationStore, MAX_CAP_TTL_SECONDS, MAX_REVOCATION_FUTURE_DRIFT_SECONDS, REVOCATION_GC_VERIFIED_GRACE_SECONDS } from './api.js';
2
2
  export { delegationStoreDefinition } from './definition.js';
3
- export { createRevocationBackend } from './revocation-backend.js';
3
+ export { createDelegationRevocationChecker } from './revocation-checker.js';
4
4
  export const DELEGATION_STORE = 'delegation';
5
5
  export function getDelegationStore(provider) {
6
6
  return provider.getStore(DELEGATION_STORE);
package/lib/migrations.js CHANGED
@@ -33,12 +33,22 @@ export function getDelegationMigrations(ctx) {
33
33
  // and drives the GC sweep that prunes revocations once their cap has
34
34
  // been expired long enough that no late-arriving mutation can still
35
35
  // reference them.
36
- await db.schema.createTable('kubun_revoked_capabilities').ifNotExists().addColumn('jti', t.text, (col)=>col.notNull().primaryKey()).addColumn('revoker_did', t.text, (col)=>col.notNull())// Unix-seconds timestamps, bigint for the same reason as `exp` above:
36
+ //
37
+ // Keyed by `(jti, revoker_did)`, not `jti` alone: one row per `jti`
38
+ // cannot represent two revokers naming the same capability, so the LWW
39
+ // upsert would let any group member's higher-HLC broadcast overwrite the
40
+ // grantor's row. Each author owns its own row and can only displace
41
+ // itself. Every read of this table names an issuer for the same reason.
42
+ await db.schema.createTable('kubun_revoked_capabilities').ifNotExists().addColumn('jti', t.text, (col)=>col.notNull()).addColumn('revoker_did', t.text, (col)=>col.notNull())// Unix-seconds timestamps, bigint for the same reason as `exp` above:
37
43
  // int32 seconds ceilings at 2038. int8 parser reads back as a number.
38
- .addColumn('revoked_iat', t.bigint, (col)=>col.notNull()).addColumn('revocation_token', t.text, (col)=>col.notNull()).addColumn('verified_at', t.bigint).addColumn('cap_exp', t.bigint).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).execute();
44
+ .addColumn('revoked_iat', t.bigint, (col)=>col.notNull()).addColumn('revocation_token', t.text, (col)=>col.notNull()).addColumn('verified_at', t.bigint).addColumn('cap_exp', t.bigint).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).addPrimaryKeyConstraint('pk_revoked_capabilities', [
45
+ 'jti',
46
+ 'revoker_did'
47
+ ]).execute();
39
48
  // GC sweep predicate filters on verified-and-cap-expired; the secondary
40
49
  // index keeps that scan cheap at the cost of one extra index on the
41
- // write path. The `jti` PK already serves the hot-path isRevoked check.
50
+ // write path. `jti` leads the primary key, so the per-issuer lookups on
51
+ // the hot path use it as a prefix and need no index of their own.
42
52
  await db.schema.createIndex('idx_revoked_capabilities_verified_cap_exp').ifNotExists().on('kubun_revoked_capabilities').columns([
43
53
  'verified_at',
44
54
  'cap_exp'
@@ -0,0 +1,21 @@
1
+ import { type VerifyTokenHook } from '@kokuin/capability';
2
+ import type { DelegationStoreAPI } from './api.js';
3
+ export type RevocationClaims = {
4
+ jti: string;
5
+ iss: string;
6
+ rev: true;
7
+ iat: number;
8
+ };
9
+ /**
10
+ * Revocation hook backed by the delegation store.
11
+ *
12
+ * The store holds one row per (capability, revoker): a `jti` alone names
13
+ * several claims, of which only the one signed by the capability's own issuer
14
+ * binds. The lookup is scoped to that issuer so a co-member's claim about the
15
+ * same `jti` can neither answer in its place nor hide it.
16
+ *
17
+ * kokuin's checker still re-verifies the record's signature and re-compares the
18
+ * issuer, so a row this device could not cross-check against a locally held
19
+ * grant is safe to hand over.
20
+ */
21
+ export declare function createDelegationRevocationChecker(api: DelegationStoreAPI): VerifyTokenHook;
@@ -0,0 +1,43 @@
1
+ import { createRevocationChecker } from '@kokuin/capability';
2
+ import { verifyToken } from '@kokuin/token';
3
+ /**
4
+ * Revocation hook backed by the delegation store.
5
+ *
6
+ * The store holds one row per (capability, revoker): a `jti` alone names
7
+ * several claims, of which only the one signed by the capability's own issuer
8
+ * binds. The lookup is scoped to that issuer so a co-member's claim about the
9
+ * same `jti` can neither answer in its place nor hide it.
10
+ *
11
+ * kokuin's checker still re-verifies the record's signature and re-compares the
12
+ * issuer, so a row this device could not cross-check against a locally held
13
+ * grant is safe to hand over.
14
+ */ export function createDelegationRevocationChecker(api) {
15
+ // The backend is per-token: its `get` closes over the issuer of the
16
+ // capability being checked, which is only known once the hook is called.
17
+ return async (token, raw)=>{
18
+ const issuer = token.payload.iss;
19
+ const backend = {
20
+ async add () {
21
+ // No-op. Revocations enter the delegation store through explicit local
22
+ // mint and broadcast/invite arrival paths, both of which carry context
23
+ // this adapter does not have (hlc, capability cross-check). The checker
24
+ // only consumes `get`, so this stub satisfies the interface without
25
+ // offering a second, lossy write path.
26
+ },
27
+ async get (jti) {
28
+ const row = await api.getRevocationByIssuer(jti, issuer);
29
+ if (row == null) {
30
+ return undefined;
31
+ }
32
+ try {
33
+ return await verifyToken(row.revocation_token);
34
+ } catch {
35
+ // A stored record that no longer verifies revokes nothing. The checker
36
+ // re-verifies too, but failing here keeps a corrupt row from surfacing.
37
+ return undefined;
38
+ }
39
+ }
40
+ };
41
+ await createRevocationChecker(backend)(token, raw);
42
+ };
43
+ }
package/package.json CHANGED
@@ -1,39 +1,43 @@
1
1
  {
2
2
  "name": "@kubun/store-delegation",
3
- "version": "0.11.0",
4
- "license": "see LICENSE.md",
3
+ "version": "0.12.1",
5
4
  "keywords": [],
5
+ "license": "see LICENSE.md",
6
+ "sideEffects": false,
6
7
  "type": "module",
7
- "main": "lib/index.js",
8
- "types": "lib/index.d.ts",
9
8
  "exports": {
10
9
  ".": "./lib/index.js"
11
10
  },
11
+ "main": "lib/index.js",
12
+ "types": "lib/index.d.ts",
12
13
  "files": [
13
14
  "lib/*",
14
15
  "LICENSE.md"
15
16
  ],
16
- "sideEffects": false,
17
17
  "dependencies": {
18
- "@kokuin/capability": "^0.1.0",
19
- "kysely": "^0.29.2",
20
- "@kubun/db": "^0.11.0",
21
- "@kubun/db-adapter": "^0.11.0",
22
- "@kubun/logger": "^0.11.0"
18
+ "@kokuin/capability": "^0.2.2",
19
+ "@kokuin/token": "^0.4.0",
20
+ "kysely": "^0.29.5",
21
+ "@kubun/db": "^0.12.1",
22
+ "@kubun/logger": "^0.12.0",
23
+ "@kubun/db-adapter": "^0.13.0"
23
24
  },
24
25
  "devDependencies": {
25
- "@testcontainers/postgresql": "^12.0.4",
26
- "@kubun/db-better-sqlite": "^0.11.0",
27
- "@kubun/db-postgres": "^0.11.0"
26
+ "@testcontainers/postgresql": "^12.1.0",
27
+ "@kubun/db-postgres": "^0.12.1",
28
+ "@kubun/db-better-sqlite": "^0.12.1"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
28
32
  },
29
33
  "scripts": {
34
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
30
35
  "build:clean": "del lib",
31
36
  "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
32
37
  "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
33
38
  "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
34
- "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
39
+ "test": "pnpm run test:types && pnpm run test:unit",
35
40
  "test:types": "tsc --noEmit -p tsconfig.test.json",
36
- "test:unit": "vitest run",
37
- "test": "pnpm run test:types && pnpm run test:unit"
41
+ "test:unit": "vitest run"
38
42
  }
39
43
  }
@@ -1,3 +0,0 @@
1
- import type { RevocationBackend } from '@kokuin/capability';
2
- import type { DelegationStoreAPI } from './api.js';
3
- export declare function createRevocationBackend(api: DelegationStoreAPI): RevocationBackend;
@@ -1,14 +0,0 @@
1
- export function createRevocationBackend(api) {
2
- return {
3
- async add (_record) {
4
- // No-op. Revocations enter the delegation store through explicit local
5
- // mint and broadcast/invite arrival paths, both of which carry context
6
- // this adapter does not have (signed token, hlc). Enkaku's revocation
7
- // checker only consumes `isRevoked`, so this stub satisfies the interface
8
- // without offering a second, lossy write path.
9
- },
10
- async isRevoked (jti) {
11
- return await api.isRevoked(jti);
12
- }
13
- };
14
- }