@kubun/store-delegation 0.16.0 → 0.18.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.
package/lib/api.d.ts CHANGED
@@ -13,6 +13,16 @@ export type DelegationStoreAPI = {
13
13
  atTime: number;
14
14
  }): Promise<Array<DelegationToken>>;
15
15
  listIssuedTokens(grantor: string): Promise<Array<DelegationToken>>;
16
+ /**
17
+ * Superseded grants for one key that are still unexpired at `atTime` and carry no
18
+ * revocation from their grantor: replaced capabilities that remain valid if presented.
19
+ */
20
+ listUnrevokedSupersededTokens(params: {
21
+ grantor: string;
22
+ audience: string;
23
+ resource: string;
24
+ atTime: number;
25
+ }): Promise<Array<DelegationToken>>;
16
26
  getDelegationTokenByGrantorJTI(params: {
17
27
  jti: string;
18
28
  grantor: string;
package/lib/api.js CHANGED
@@ -46,63 +46,89 @@ const REVOCATION_GC_SAMPLE_RATE = 0.01;
46
46
  // is folded through `normalizeDID`, because a raw equality here silently
47
47
  // returns "not revoked".
48
48
  export function createDelegationStore(db, adapter, logger = getKubunLogger('store-delegation')) {
49
+ // Shared transaction locks do not serialise concurrent calls on their own connection.
50
+ let ambientWriteChain = Promise.resolve();
49
51
  const api = {
50
52
  // --- Delegation token operations ---
51
- // LWW arbitration on the serialized HLC: the SQL string `>` compare is a
52
- // stand-in for `HLC.compare`, valid only while the per-millisecond counter
53
- // stays within the pad width; that ceiling is unreachable and the SQL
54
- // compare avoids a read-modify-write race. The pre-read drives the returned
55
- // "changed" flag (used by emission gates to suppress `delegationTokenAdded`
56
- // on idempotent re-stores). It is not wrapped in a transaction because
57
- // callers already run inside one — Kysely rejects nested transactions on
58
- // shared connections. The narrow TOCTOU window between read and upsert can
59
- // let a concurrent identical re-store race and emit twice; that is benign
60
- // for emission gating, and the LWW upsert itself remains correct under the
61
- // SQL `>` arbiter regardless.
53
+ // HLC text order matches HLC.compare while its counter stays within the pad width.
54
+ // Lock before reading so arbitration and the history-preserving replacement are atomic.
62
55
  async addDelegationToken (token) {
63
56
  const grantor = normalizeDID(token.grantor);
64
57
  const audience = normalizeDID(token.audience);
65
- const existing = await db.selectFrom('kubun_delegation_tokens').select([
66
- 'jti',
67
- 'token',
68
- 'act',
69
- 'exp',
70
- 'hlc'
71
- ]).where('grantor', '=', grantor).where('audience', '=', audience).where('resource', '=', token.resource).executeTakeFirst();
72
- await db.insertInto('kubun_delegation_tokens').values({
73
- jti: token.jti,
58
+ const key = {
74
59
  grantor,
75
60
  audience,
76
- token: token.token,
77
- resource: token.resource,
78
- act: token.act,
79
- exp: token.exp,
80
- hlc: token.hlc
81
- }).onConflict((oc)=>oc.columns([
82
- 'grantor',
83
- 'audience',
84
- 'resource'
85
- ]).doUpdateSet((eb)=>({
86
- jti: eb.ref('excluded.jti'),
87
- token: eb.ref('excluded.token'),
88
- act: eb.ref('excluded.act'),
89
- exp: eb.ref('excluded.exp'),
90
- hlc: eb.ref('excluded.hlc'),
61
+ resource: token.resource
62
+ };
63
+ const replace = async (tx)=>{
64
+ // An upsert locks present and absent keys on Postgres, and reserves SQLite's writer.
65
+ await tx.insertInto('kubun_delegation_token_keys').values(key).onConflict((oc)=>oc.columns([
66
+ 'grantor',
67
+ 'audience',
68
+ 'resource'
69
+ ]).doUpdateSet({
70
+ resource: token.resource
71
+ })).execute();
72
+ const existing = await tx.selectFrom('kubun_delegation_tokens').select([
73
+ 'jti',
74
+ 'token',
75
+ 'act',
76
+ 'exp',
77
+ 'hlc'
78
+ ]).where('grantor', '=', grantor).where('audience', '=', audience).where('resource', '=', token.resource).where('superseded', '=', 0).executeTakeFirst();
79
+ if (existing != null && token.hlc <= existing.hlc) {
80
+ // A losing JTI may already have been delivered and must remain revocable.
81
+ await tx.insertInto('kubun_delegation_tokens').values({
82
+ ...key,
83
+ jti: token.jti,
84
+ token: token.token,
85
+ act: token.act,
86
+ exp: token.exp,
87
+ hlc: token.hlc,
88
+ superseded: 1
89
+ }).onConflict((oc)=>oc.columns([
90
+ 'grantor',
91
+ 'audience',
92
+ 'resource',
93
+ 'jti'
94
+ ]).doNothing()).execute();
95
+ return false;
96
+ }
97
+ if (existing != null && existing.jti !== token.jti) {
98
+ await tx.updateTable('kubun_delegation_tokens').set({
99
+ superseded: 1,
91
100
  updated_at: adapter.encodeTimestamp(new Date())
92
- })).where((eb)=>eb('excluded.hlc', '>', eb.ref('kubun_delegation_tokens.hlc')))).execute();
93
- let changed;
94
- if (existing == null) {
95
- // New PK — row was inserted.
96
- changed = true;
97
- } else if (token.hlc <= existing.hlc) {
98
- // LWW lost (older or equal HLC) — stored row unchanged.
99
- changed = false;
100
- } else {
101
- // LWW won — emit only if any content field actually differs. Identical
102
- // re-broadcasts (same jti/token/act/exp with a newer hlc) are a no-op
103
- // from the consumer's perspective.
104
- changed = existing.jti !== token.jti || existing.token !== token.token || existing.act !== token.act || existing.exp !== token.exp;
101
+ }).where('grantor', '=', grantor).where('audience', '=', audience).where('resource', '=', token.resource).where('superseded', '=', 0).execute();
102
+ }
103
+ await tx.insertInto('kubun_delegation_tokens').values({
104
+ ...key,
105
+ jti: token.jti,
106
+ token: token.token,
107
+ act: token.act,
108
+ exp: token.exp,
109
+ hlc: token.hlc,
110
+ superseded: 0
111
+ }).onConflict((oc)=>oc.columns([
112
+ 'grantor',
113
+ 'audience',
114
+ 'resource',
115
+ 'jti'
116
+ ]).doUpdateSet((eb)=>({
117
+ token: eb.ref('excluded.token'),
118
+ act: eb.ref('excluded.act'),
119
+ exp: eb.ref('excluded.exp'),
120
+ hlc: eb.ref('excluded.hlc'),
121
+ superseded: 0,
122
+ updated_at: adapter.encodeTimestamp(new Date())
123
+ }))).execute();
124
+ return existing == null || existing.jti !== token.jti || existing.token !== token.token || existing.act !== token.act || existing.exp !== token.exp;
125
+ };
126
+ // Use an ambient transaction inline: Kysely rejects nested transactions.
127
+ const write = db.isTransaction ? ambientWriteChain.then(()=>replace(db)) : db.transaction().execute(replace);
128
+ if (db.isTransaction) {
129
+ ambientWriteChain = write.then(()=>undefined, ()=>undefined);
105
130
  }
131
+ const changed = await write;
106
132
  // Amortize revocation GC across delegation-token inserts. Sample rate
107
133
  // keeps the sweep cost negligible while ensuring the table doesn't grow
108
134
  // unbounded when the cap stream is busy. Best-effort — sweep failures
@@ -126,7 +152,7 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
126
152
  return changed;
127
153
  },
128
154
  async getDelegationTokens (params) {
129
- return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', normalizeDID(params.grantor)).where('audience', '=', normalizeDID(params.audience)).execute();
155
+ return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', normalizeDID(params.grantor)).where('audience', '=', normalizeDID(params.audience)).where('superseded', '=', 0).execute();
130
156
  },
131
157
  // The join carries the author predicate for two reasons. It is an
132
158
  // authorization filter — only the grantor's own claim may withhold a token
@@ -137,7 +163,7 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
137
163
  // the list as a delegation chain, and a duplicate breaks a multi-hop chain
138
164
  // outright.
139
165
  async getHeldTokens (params) {
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
166
+ 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).where('kubun_delegation_tokens.superseded', '=', 0)// Pending revocations (verified_at IS NULL) are not binding until the
141
167
  // cap-arrival cross-check verifies them, so they don't filter tokens.
142
168
  .where((eb)=>eb.or([
143
169
  eb('kubun_revoked_capabilities.jti', 'is', null),
@@ -145,12 +171,16 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
145
171
  ])).execute();
146
172
  },
147
173
  async listIssuedTokens (grantor) {
148
- return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', normalizeDID(grantor)).execute();
174
+ return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', normalizeDID(grantor)).where('superseded', '=', 0).execute();
175
+ },
176
+ async listUnrevokedSupersededTokens (params) {
177
+ const grantor = normalizeDID(params.grantor);
178
+ 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('kubun_delegation_tokens.grantor', '=', grantor).where('audience', '=', normalizeDID(params.audience)).where('resource', '=', params.resource).where('kubun_delegation_tokens.superseded', '=', 1).where('exp', '>', params.atTime).where('kubun_revoked_capabilities.jti', 'is', null).orderBy('kubun_delegation_tokens.hlc').orderBy('kubun_delegation_tokens.jti').execute();
149
179
  },
150
180
  // `jti` is picked by whoever minted the capability and the grant travels to
151
181
  // the whole group, so any co-member can mint one reusing a `jti` it saw. It
152
182
  // 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
183
+ // is not a key — the primary key is `(grantor, audience, resource, jti)`. A
154
184
  // grantor that reused one `jti` across two of its own grants would leave
155
185
  // this read with no representable answer, so it throws rather than pick.
156
186
  async getDelegationTokenByGrantorJTI (params) {
@@ -178,7 +208,7 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
178
208
  return (result.numDeletedRows ?? 0n) > 0n;
179
209
  },
180
210
  // --- Revoked capability operations ---
181
- // Same LWW arbitration as `addDelegationToken`: SQL string `>` on the
211
+ // LWW arbitration uses SQL string `>` on the
182
212
  // serialized HLC is a stand-in for `HLC.compare`, valid only while the
183
213
  // per-millisecond counter stays within the pad width. The pre-read drives
184
214
  // the boolean change signal that emission gates consume; the upsert is
package/lib/definition.js CHANGED
@@ -3,5 +3,7 @@ import { getDelegationMigrations } from './migrations.js';
3
3
  export const delegationStoreDefinition = {
4
4
  name: 'delegation',
5
5
  migrations: getDelegationMigrations,
6
- createAPI: createDelegationStore
6
+ // Only the db and adapter: KubunDB passes a store context third, and this
7
+ // factory's third parameter means something else.
8
+ createAPI: (db, adapter)=>createDelegationStore(db, adapter)
7
9
  };
package/lib/index.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { DelegationStoreAPI } from './api.js';
3
3
  export type { DelegationStoreAPI } from './api.js';
4
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 { createDelegationRevocationChecker, type RevocationClaims, } from './revocation-checker.js';
6
+ export { createDelegationRevocationChecker, type RevocationClaims, VerifiedRevocationError, } 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
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 { createDelegationRevocationChecker } from './revocation-checker.js';
3
+ export { createDelegationRevocationChecker, VerifiedRevocationError } 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
@@ -1,18 +1,32 @@
1
+ import { sql } from 'kysely';
1
2
  export function getDelegationMigrations(ctx) {
2
3
  const t = ctx.types;
3
4
  const now = ctx.functions.now;
4
5
  const init = {
5
6
  async up (db) {
7
+ // A stable row serialises replacements even before the first grant exists.
8
+ await db.schema.createTable('kubun_delegation_token_keys').ifNotExists().addColumn('grantor', t.text, (col)=>col.notNull()).addColumn('audience', t.text, (col)=>col.notNull()).addColumn('resource', t.text, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_delegation_token_keys', [
9
+ 'grantor',
10
+ 'audience',
11
+ 'resource'
12
+ ]).execute();
6
13
  // Delegation tokens
7
14
  await db.schema.createTable('kubun_delegation_tokens').ifNotExists().addColumn('jti', t.text, (col)=>col.notNull()).addColumn('grantor', t.text, (col)=>col.notNull()).addColumn('audience', t.text, (col)=>col.notNull()).addColumn('token', t.text, (col)=>col.notNull()).addColumn('resource', t.text, (col)=>col.notNull()).addColumn('act', t.text, (col)=>col.notNull())// Capability expiry, a numeric unix-seconds claim mirrored from the
8
15
  // token for indexed validity filtering. bigint, not int32: a 32-bit
9
16
  // seconds column ceilings at 2038. The adapter's int8 parser reads it
10
17
  // back as a JS number, so the row type stays `number`.
11
- .addColumn('exp', t.bigint, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).addPrimaryKeyConstraint('pk_delegation_tokens', [
18
+ .addColumn('exp', t.bigint, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull())// Integer 0/1 reads uniformly across SQLite and Postgres.
19
+ .addColumn('superseded', 'integer', (col)=>col.defaultTo(0).notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).addPrimaryKeyConstraint('pk_delegation_tokens', [
12
20
  'grantor',
13
21
  'audience',
14
- 'resource'
22
+ 'resource',
23
+ 'jti'
15
24
  ]).execute();
25
+ await db.schema.createIndex('idx_delegation_tokens_active_key').ifNotExists().unique().on('kubun_delegation_tokens').columns([
26
+ 'grantor',
27
+ 'audience',
28
+ 'resource'
29
+ ]).where(sql.ref('superseded'), '=', 0).execute();
16
30
  await db.schema.createIndex('idx_delegation_tokens_grantor_audience').ifNotExists().on('kubun_delegation_tokens').columns([
17
31
  'grantor',
18
32
  'audience'
@@ -23,9 +37,7 @@ export function getDelegationMigrations(ctx) {
23
37
  'audience',
24
38
  'exp'
25
39
  ]).execute();
26
- // Revocation lookup keys on `jti`, which is not part of the composite PK
27
- // `(grantor, audience, resource)`. Index it so cap-by-jti lookups during
28
- // revocation are not a table scan.
40
+ // JTI is last in the composite key; index issuer-history lookups separately.
29
41
  await db.schema.createIndex('idx_delegation_tokens_jti').ifNotExists().on('kubun_delegation_tokens').column('jti').execute();
30
42
  // Revoked capabilities. `verified_at` doubles as both the verified flag
31
43
  // (NULL = pending cross-check vs the cap's iss) and a forensic
@@ -57,6 +69,7 @@ export function getDelegationMigrations(ctx) {
57
69
  async down (db) {
58
70
  await db.schema.dropTable('kubun_revoked_capabilities').ifExists().execute();
59
71
  await db.schema.dropTable('kubun_delegation_tokens').ifExists().execute();
72
+ await db.schema.dropTable('kubun_delegation_token_keys').ifExists().execute();
60
73
  }
61
74
  };
62
75
  return {
@@ -1,4 +1,4 @@
1
- import { type VerifyTokenHook } from '@kokuin/capability';
1
+ import { type CapabilityToken, type VerifyTokenHook } from '@kokuin/capability';
2
2
  import { type MethodRegistry } from '@kokuin/token';
3
3
  import type { DelegationStoreAPI } from './api.js';
4
4
  export type RevocationClaims = {
@@ -7,6 +7,11 @@ export type RevocationClaims = {
7
7
  rev: true;
8
8
  iat: number;
9
9
  };
10
+ /** A revocation proved by kokuin after the store and resolver returned successfully. */
11
+ export declare class VerifiedRevocationError extends Error {
12
+ get brand(): string;
13
+ constructor(message: string, options?: ErrorOptions);
14
+ }
10
15
  /**
11
16
  * Revocation hook backed by the delegation store.
12
17
  *
@@ -24,4 +29,9 @@ export type RevocationClaims = {
24
29
  * without the registry it fails to resolve, is dropped, and the revoked
25
30
  * capability passes (fail-open). Optional so non-controller callers stay 1-arg.
26
31
  */
27
- export declare function createDelegationRevocationChecker(api: DelegationStoreAPI, methods?: MethodRegistry): VerifyTokenHook;
32
+ /** Proves whether a token's delegation chain is unrevoked, without throwing. */
33
+ export type DelegationRevocationVerdict = (token: CapabilityToken, raw: string) => Promise<boolean>;
34
+ /** Adapt a proved verdict to kokuin's throwing VerifyTokenHook contract. */
35
+ export declare function createDelegationRevocationChecker(api: DelegationStoreAPI, methods?: MethodRegistry): VerifyTokenHook & {
36
+ verdict: DelegationRevocationVerdict;
37
+ };
@@ -1,25 +1,50 @@
1
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
- *
15
- * `methods` resolves the record's issuer: a revocation whose `iss` is a
16
- * `did:kokuin:` controller DID cannot verify from the identifier alone, so
17
- * without the registry it fails to resolve, is dropped, and the revoked
18
- * capability passes (fail-open). Optional so non-controller callers stay 1-arg.
19
- */ export function createDelegationRevocationChecker(api, methods) {
2
+ import { isIssuerKeyNotFoundError, isUnresolvableIssuerError, normalizeDID, verifyToken } from '@kokuin/token';
3
+ /** A revocation proved by kokuin after the store and resolver returned successfully. */ export class VerifiedRevocationError extends Error {
4
+ get brand() {
5
+ return '@kubun/store-delegation/VerifiedRevocationError';
6
+ }
7
+ constructor(message, options){
8
+ super(message, options);
9
+ this.name = 'VerifiedRevocationError';
10
+ }
11
+ }
12
+ function createDelegationRevocationVerdictChecker(api, methods) {
20
13
  // The backend is per-token: its `get` closes over the issuer of the
21
14
  // capability being checked, which is only known once the hook is called.
22
15
  return async (token, raw)=>{
16
+ let dependencyFault;
17
+ let hadDependencyFault = false;
18
+ let provedRevocation = false;
19
+ const recordFault = (error)=>{
20
+ if (!hadDependencyFault) {
21
+ dependencyFault = error;
22
+ hadDependencyFault = true;
23
+ }
24
+ };
25
+ const trackedMethods = methods?.map((base)=>{
26
+ const wrap = (fn)=>async (...args)=>{
27
+ try {
28
+ return await fn(...args);
29
+ } catch (error) {
30
+ recordFault(error);
31
+ throw error;
32
+ }
33
+ };
34
+ return {
35
+ method: base.method,
36
+ resolve: wrap(base.resolve.bind(base)),
37
+ ...base.resolveHistoric == null ? {} : {
38
+ resolveHistoric: wrap(base.resolveHistoric.bind(base))
39
+ },
40
+ ...base.resolveDenySet == null ? {} : {
41
+ resolveDenySet: wrap(base.resolveDenySet.bind(base))
42
+ },
43
+ ...base.resolveAgreementKey == null ? {} : {
44
+ resolveAgreementKey: wrap(base.resolveAgreementKey.bind(base))
45
+ }
46
+ };
47
+ });
23
48
  const issuer = token.payload.iss;
24
49
  const backend = {
25
50
  async add () {
@@ -30,25 +55,60 @@ import { verifyToken } from '@kokuin/token';
30
55
  // offering a second, lossy write path.
31
56
  },
32
57
  async get (jti) {
33
- const row = await api.getRevocationByIssuer(jti, issuer);
58
+ let row;
59
+ try {
60
+ row = await api.getRevocationByIssuer(jti, issuer);
61
+ } catch (error) {
62
+ recordFault(error);
63
+ throw error;
64
+ }
34
65
  if (row == null) {
35
66
  return undefined;
36
67
  }
37
68
  try {
38
69
  // Carries `methods` so a controller-issued (`did:kokuin:`) record can
39
70
  // resolve; must match the checker's own verify below or the two drift.
40
- return await verifyToken(row.revocation_token, {
41
- methods
71
+ const verified = await verifyToken(row.revocation_token, {
72
+ methods: trackedMethods
42
73
  });
43
- } catch {
44
- // A stored record that no longer verifies revokes nothing. The checker
45
- // re-verifies too, but failing here keeps a corrupt row from surfacing.
46
- return undefined;
74
+ provedRevocation = normalizeDID(verified.payload.iss) === normalizeDID(issuer);
75
+ return verified;
76
+ } catch (error) {
77
+ if (hadDependencyFault) throw dependencyFault;
78
+ // A corrupt record is not revocation evidence. A resolver that cannot
79
+ // answer is different: the record may be genuine, so retry the frame.
80
+ if (isUnresolvableIssuerError(error)) throw error.cause ?? error;
81
+ if (isIssuerKeyNotFoundError(error)) return undefined;
82
+ if (error instanceof SyntaxError || error instanceof Error && (error.message === 'Invalid signature' || error.message === 'Token expired' || error.message === 'Token not yet valid' || error.message.startsWith('Invalid token') || error.message.startsWith('Unsupported token') || error.message.startsWith('Unsupported signature algorithm'))) {
83
+ return undefined;
84
+ }
85
+ throw error;
47
86
  }
48
87
  }
49
88
  };
50
- await createRevocationChecker(backend, {
51
- methods
52
- })(token, raw);
89
+ try {
90
+ await createRevocationChecker(backend, {
91
+ methods: trackedMethods
92
+ })(token, raw);
93
+ } catch (error) {
94
+ if (hadDependencyFault) throw dependencyFault;
95
+ // The store read and record verification proved this issuer's revocation.
96
+ // kokuin re-verifies that same record and throws its refusal; its spelling
97
+ // is irrelevant to this verdict. Any dependency throw was handled above.
98
+ if (provedRevocation) return true;
99
+ throw error;
100
+ }
101
+ return false;
102
+ };
103
+ }
104
+ /** Adapt a proved verdict to kokuin's throwing VerifyTokenHook contract. */ export function createDelegationRevocationChecker(api, methods) {
105
+ const verdict = createDelegationRevocationVerdictChecker(api, methods);
106
+ const checker = async (token, raw)=>{
107
+ if (await verdict(token, raw)) {
108
+ throw new VerifiedRevocationError(`Token revoked: ${token.payload.jti}`);
109
+ }
53
110
  };
111
+ return Object.assign(checker, {
112
+ verdict
113
+ });
54
114
  }
package/lib/tables.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { CreatedAtColumn, UpdatedAtColumn } from '@kubun/db-adapter';
2
- import type { Insertable, Selectable } from 'kysely';
2
+ import type { Generated, Insertable, Selectable } from 'kysely';
3
3
  export type DelegationTokenTable = {
4
4
  jti: string;
5
5
  grantor: string;
@@ -9,6 +9,7 @@ export type DelegationTokenTable = {
9
9
  act: string;
10
10
  exp: number;
11
11
  hlc: string;
12
+ superseded: Generated<number>;
12
13
  created_at: CreatedAtColumn;
13
14
  updated_at: UpdatedAtColumn;
14
15
  };
@@ -37,6 +38,7 @@ export type HeldRevocation = {
37
38
  cap_exp: number | null;
38
39
  };
39
40
  export type DelegationStoreTables = {
41
+ kubun_delegation_token_keys: Pick<DelegationTokenTable, 'grantor' | 'audience' | 'resource'>;
40
42
  kubun_delegation_tokens: DelegationTokenTable;
41
43
  kubun_revoked_capabilities: RevokedCapabilityTable;
42
44
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/store-delegation",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,17 +15,17 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kokuin/capability": "^0.3.0",
19
- "@kokuin/token": "^0.5.0",
20
- "@kubun/db": "^0.16.0",
21
- "@kubun/db-adapter": "^0.16.0",
22
- "@kubun/logger": "^0.16.0",
23
- "kysely": "^0.29.5"
18
+ "@kokuin/capability": "^0.4.2",
19
+ "@kokuin/token": "^0.5.1",
20
+ "@kubun/db": "^0.18.0",
21
+ "@kubun/db-adapter": "^0.18.0",
22
+ "@kubun/logger": "^0.18.0",
23
+ "kysely": "^0.29.7"
24
24
  },
25
25
  "devDependencies": {
26
- "@kubun/db-better-sqlite": "^0.16.0",
27
- "@kubun/db-postgres": "^0.16.0",
28
- "@testcontainers/postgresql": "^12.1.0"
26
+ "@kubun/db-better-sqlite": "^0.18.0",
27
+ "@kubun/db-postgres": "^0.18.0",
28
+ "@testcontainers/postgresql": "^12.2.1"
29
29
  },
30
30
  "publishConfig": {
31
31
  "access": "public"
@@ -33,8 +33,8 @@
33
33
  "scripts": {
34
34
  "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
35
35
  "build:clean": "del lib",
36
- "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
37
- "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
36
+ "build:js": "del 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
37
+ "build:types": "del 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
38
38
  "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
39
39
  "test": "pnpm run test:types && pnpm run test:unit",
40
40
  "test:types": "tsc --noEmit -p tsconfig.test.json",