@kubun/store-delegation 0.11.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/LICENSE.md ADDED
@@ -0,0 +1,57 @@
1
+ # The Prosperity Public License 3.0.0
2
+
3
+ Contributor: Paul Le Cam
4
+
5
+ Source Code: https://github.com/PaulLeCam/kubun
6
+
7
+ ## Purpose
8
+
9
+ This license allows you to use and share this software for noncommercial purposes for free and to try this software for commercial purposes for thirty days.
10
+
11
+ ## Agreement
12
+
13
+ In order to receive this license, you have to agree to its rules. Those rules are both obligations under that agreement and conditions to your license. Don't do anything with this software that triggers a rule you can't or won't follow.
14
+
15
+ ## Notices
16
+
17
+ Make sure everyone who gets a copy of any part of this software from you, with or without changes, also gets the text of this license and the contributor and source code lines above.
18
+
19
+ ## Commercial Trial
20
+
21
+ Limit your use of this software for commercial purposes to a thirty-day trial period. If you use this software for work, your company gets one trial period for all personnel, not one trial per person.
22
+
23
+ ## Contributions Back
24
+
25
+ Developing feedback, changes, or additions that you contribute back to the contributor on the terms of a standardized public software license such as [the Blue Oak Model License 1.0.0](https://blueoakcouncil.org/license/1.0.0), [the Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.html), [the MIT license](https://spdx.org/licenses/MIT.html), or [the two-clause BSD license](https://spdx.org/licenses/BSD-2-Clause.html) doesn't count as use for a commercial purpose.
26
+
27
+ ## Personal Uses
28
+
29
+ Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, doesn't count as use for a commercial purpose.
30
+
31
+ ## Noncommercial Organizations
32
+
33
+ Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution doesn't count as use for a commercial purpose regardless of the source of funding or obligations resulting from the funding.
34
+
35
+ ## Defense
36
+
37
+ Don't make any legal claim against anyone accusing this software, with or without changes, alone or with other technology, of infringing any patent.
38
+
39
+ ## Copyright
40
+
41
+ The contributor licenses you to do everything with this software that would otherwise infringe their copyright in it.
42
+
43
+ ## Patent
44
+
45
+ The contributor licenses you to do everything with this software that would otherwise infringe any patents they can license or become able to license.
46
+
47
+ ## Reliability
48
+
49
+ The contributor can't revoke this license.
50
+
51
+ ## Excuse
52
+
53
+ You're excused for unknowingly breaking [Notices](#notices) if you take all practical steps to comply within thirty days of learning you broke the rule.
54
+
55
+ ## No Liability
56
+
57
+ ***As far as the law allows, this software comes as is, without any warranty or condition, and the contributor won't be liable to anyone for any damages related to this software or this license, under any kind of legal claim.***
package/lib/api.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { Adapter } from '@kubun/db-adapter';
2
+ import { type Logger } from '@kubun/logger';
3
+ import type { Kysely } from 'kysely';
4
+ import type { DelegationStoreTables, DelegationToken, HeldRevocation, InsertDelegationToken, InsertRevokedCapability, RevokedCapability } from './tables.js';
5
+ export type DelegationStoreAPI = {
6
+ addDelegationToken(token: InsertDelegationToken): Promise<boolean>;
7
+ getDelegationTokens(params: {
8
+ grantor: string;
9
+ audience: string;
10
+ }): Promise<Array<DelegationToken>>;
11
+ getHeldTokens(params: {
12
+ audience: string;
13
+ atTime: number;
14
+ }): Promise<Array<DelegationToken>>;
15
+ listIssuedTokens(grantor: string): Promise<Array<DelegationToken>>;
16
+ getDelegationTokenByJTI(jti: string): Promise<DelegationToken | null>;
17
+ removeDelegationToken(params: {
18
+ jti: string;
19
+ }): Promise<boolean>;
20
+ addRevocation(input: InsertRevokedCapability): Promise<boolean>;
21
+ getRevocation(jti: string): Promise<RevokedCapability | null>;
22
+ isRevoked(jti: string): Promise<boolean>;
23
+ getHeldRevocations(params: {
24
+ audience: string;
25
+ }): Promise<Array<HeldRevocation>>;
26
+ markRevocationVerified(jti: string, params: {
27
+ cap_exp: number;
28
+ verified_at?: number;
29
+ }): Promise<boolean>;
30
+ getPendingRevocationByJTI(jti: string): Promise<RevokedCapability | null>;
31
+ deletePendingRevocation(jti: string): Promise<boolean>;
32
+ purgeExpiredRevocations(params: {
33
+ graceSeconds: number;
34
+ }): Promise<number>;
35
+ purgeDeadPendingRevocations(params: {
36
+ maxAgeSeconds: number;
37
+ }): Promise<number>;
38
+ };
39
+ export declare function createDelegationStore(db: Kysely<DelegationStoreTables>, adapter: Adapter, logger?: Logger): DelegationStoreAPI;
package/lib/api.js ADDED
@@ -0,0 +1,234 @@
1
+ import { getKubunLogger } from '@kubun/logger';
2
+ // --- Revocation GC defaults ---
3
+ // Probability that each delegation-token insert triggers a revocation sweep.
4
+ // 1% keeps the sweep amortized cost negligible; over a busy stream it still
5
+ // runs often enough to bound table growth.
6
+ 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;
13
+ // --- Factory function ---
14
+ export function createDelegationStore(db, adapter, logger = getKubunLogger('store-delegation')) {
15
+ const api = {
16
+ // --- Delegation token operations ---
17
+ // LWW arbitration on the serialized HLC: the SQL string `>` compare is a
18
+ // stand-in for `HLC.compare`, valid only while the per-millisecond counter
19
+ // stays within the pad width; that ceiling is unreachable and the SQL
20
+ // compare avoids a read-modify-write race. The pre-read drives the returned
21
+ // "changed" flag (used by emission gates to suppress `delegationTokenAdded`
22
+ // on idempotent re-stores). It is not wrapped in a transaction because
23
+ // callers already run inside one — Kysely rejects nested transactions on
24
+ // shared connections. The narrow TOCTOU window between read and upsert can
25
+ // let a concurrent identical re-store race and emit twice; that is benign
26
+ // for emission gating, and the LWW upsert itself remains correct under the
27
+ // SQL `>` arbiter regardless.
28
+ async addDelegationToken (token) {
29
+ const existing = await db.selectFrom('kubun_delegation_tokens').select([
30
+ 'jti',
31
+ 'token',
32
+ 'act',
33
+ 'exp',
34
+ 'hlc'
35
+ ]).where('grantor', '=', token.grantor).where('audience', '=', token.audience).where('resource', '=', token.resource).executeTakeFirst();
36
+ await db.insertInto('kubun_delegation_tokens').values({
37
+ jti: token.jti,
38
+ grantor: token.grantor,
39
+ audience: token.audience,
40
+ token: token.token,
41
+ resource: token.resource,
42
+ act: token.act,
43
+ exp: token.exp,
44
+ hlc: token.hlc
45
+ }).onConflict((oc)=>oc.columns([
46
+ 'grantor',
47
+ 'audience',
48
+ 'resource'
49
+ ]).doUpdateSet((eb)=>({
50
+ jti: eb.ref('excluded.jti'),
51
+ token: eb.ref('excluded.token'),
52
+ act: eb.ref('excluded.act'),
53
+ exp: eb.ref('excluded.exp'),
54
+ hlc: eb.ref('excluded.hlc'),
55
+ updated_at: adapter.encodeTimestamp(new Date())
56
+ })).where((eb)=>eb('excluded.hlc', '>', eb.ref('kubun_delegation_tokens.hlc')))).execute();
57
+ let changed;
58
+ if (existing == null) {
59
+ // New PK — row was inserted.
60
+ changed = true;
61
+ } else if (token.hlc <= existing.hlc) {
62
+ // LWW lost (older or equal HLC) — stored row unchanged.
63
+ changed = false;
64
+ } else {
65
+ // LWW won — emit only if any content field actually differs. Identical
66
+ // re-broadcasts (same jti/token/act/exp with a newer hlc) are a no-op
67
+ // from the consumer's perspective.
68
+ changed = existing.jti !== token.jti || existing.token !== token.token || existing.act !== token.act || existing.exp !== token.exp;
69
+ }
70
+ // Amortize revocation GC across delegation-token inserts. Sample rate
71
+ // keeps the sweep cost negligible while ensuring the table doesn't grow
72
+ // unbounded when the cap stream is busy. Best-effort — sweep failures
73
+ // must not propagate up and break the insert path, but they must be
74
+ // logged so a regression (typo in a purge method, schema drift, etc.)
75
+ // surfaces in operator logs instead of silently going undetected.
76
+ if (Math.random() < REVOCATION_GC_SAMPLE_RATE) {
77
+ try {
78
+ await api.purgeExpiredRevocations({
79
+ graceSeconds: REVOCATION_GC_VERIFIED_GRACE_SECONDS
80
+ });
81
+ await api.purgeDeadPendingRevocations({
82
+ maxAgeSeconds: REVOCATION_GC_PENDING_MAX_AGE_SECONDS
83
+ });
84
+ } catch (error) {
85
+ logger.warn('revocation GC sweep failed during addDelegationToken: {error}', {
86
+ error
87
+ });
88
+ }
89
+ }
90
+ return changed;
91
+ },
92
+ async getDelegationTokens (params) {
93
+ return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', params.grantor).where('audience', '=', params.audience).execute();
94
+ },
95
+ 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
97
+ // cap-arrival cross-check verifies them, so they don't filter tokens.
98
+ .where((eb)=>eb.or([
99
+ eb('kubun_revoked_capabilities.jti', 'is', null),
100
+ eb('kubun_revoked_capabilities.verified_at', 'is', null)
101
+ ])).execute();
102
+ },
103
+ async listIssuedTokens (grantor) {
104
+ return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', grantor).execute();
105
+ },
106
+ async getDelegationTokenByJTI (jti) {
107
+ const row = await db.selectFrom('kubun_delegation_tokens').selectAll().where('jti', '=', jti).executeTakeFirst();
108
+ return row ?? null;
109
+ },
110
+ // Hard-delete a held token by `jti`. Used to drop a delegated write
111
+ // capability the server no longer needs — e.g. when a viewer disconnects a
112
+ // connector, the server relinquishes the ability to keep writing on the
113
+ // viewer's behalf. Not wrapped in a transaction because callers already run
114
+ // inside one.
115
+ async removeDelegationToken (params) {
116
+ const result = await db.deleteFrom('kubun_delegation_tokens').where('jti', '=', params.jti).executeTakeFirst();
117
+ return (result.numDeletedRows ?? 0n) > 0n;
118
+ },
119
+ // --- Revoked capability operations ---
120
+ // Same LWW arbitration as `addDelegationToken`: SQL string `>` on the
121
+ // serialized HLC is a stand-in for `HLC.compare`, valid only while the
122
+ // per-millisecond counter stays within the pad width. The pre-read drives
123
+ // the boolean change signal that emission gates consume; the upsert is
124
+ // not wrapped in a transaction because callers already run inside one.
125
+ async addRevocation (input) {
126
+ const existing = await db.selectFrom('kubun_revoked_capabilities').select([
127
+ 'revoker_did',
128
+ 'revoked_iat',
129
+ 'revocation_token',
130
+ 'verified_at',
131
+ 'cap_exp',
132
+ 'hlc'
133
+ ]).where('jti', '=', input.jti).executeTakeFirst();
134
+ await db.insertInto('kubun_revoked_capabilities').values({
135
+ jti: input.jti,
136
+ revoker_did: input.revoker_did,
137
+ revoked_iat: input.revoked_iat,
138
+ revocation_token: input.revocation_token,
139
+ verified_at: input.verified_at ?? null,
140
+ cap_exp: input.cap_exp ?? null,
141
+ hlc: input.hlc
142
+ }).onConflict((oc)=>oc.column('jti').doUpdateSet((eb)=>({
143
+ revoker_did: eb.ref('excluded.revoker_did'),
144
+ revoked_iat: eb.ref('excluded.revoked_iat'),
145
+ revocation_token: eb.ref('excluded.revocation_token'),
146
+ verified_at: eb.ref('excluded.verified_at'),
147
+ cap_exp: eb.ref('excluded.cap_exp'),
148
+ hlc: eb.ref('excluded.hlc'),
149
+ updated_at: adapter.encodeTimestamp(new Date())
150
+ })).where((eb)=>eb('excluded.hlc', '>', eb.ref('kubun_revoked_capabilities.hlc')))).execute();
151
+ if (existing == null) {
152
+ return true;
153
+ }
154
+ if (input.hlc <= existing.hlc) {
155
+ return false;
156
+ }
157
+ const incomingVerifiedAt = input.verified_at ?? null;
158
+ 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;
160
+ },
161
+ async getRevocation (jti) {
162
+ const row = await db.selectFrom('kubun_revoked_capabilities').selectAll().where('jti', '=', jti).executeTakeFirst();
163
+ return row ?? null;
164
+ },
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();
167
+ return row != null;
168
+ },
169
+ // Explicit column list (no `selectAll`) because `kubun_revoked_capabilities`
170
+ // and `kubun_delegation_tokens` share several column names (`jti`, `hlc`,
171
+ // `created_at`, `updated_at`); `selectAll` would produce an ambiguous
172
+ // projection. The INNER JOIN drops verified revocations whose cap row is not
173
+ // held locally, which is exactly the desired filter for an audience-scoped
174
+ // query.
175
+ 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([
177
+ 'kubun_revoked_capabilities.jti as jti',
178
+ 'kubun_delegation_tokens.grantor as grantor',
179
+ 'kubun_delegation_tokens.audience as audience',
180
+ 'kubun_revoked_capabilities.revoker_did as revoker_did',
181
+ 'kubun_revoked_capabilities.revoked_iat as revoked_iat',
182
+ 'kubun_revoked_capabilities.verified_at as verified_at',
183
+ '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();
185
+ return rows.map((row)=>({
186
+ jti: row.jti,
187
+ grantor: row.grantor,
188
+ audience: row.audience,
189
+ revoker_did: row.revoker_did,
190
+ revoked_iat: row.revoked_iat,
191
+ // verified_at is non-null by the WHERE above.
192
+ verified_at: row.verified_at,
193
+ cap_exp: row.cap_exp
194
+ }));
195
+ },
196
+ async markRevocationVerified (jti, params) {
197
+ const verifiedAt = params.verified_at ?? Math.floor(Date.now() / 1000);
198
+ const result = await db.updateTable('kubun_revoked_capabilities').set({
199
+ verified_at: verifiedAt,
200
+ cap_exp: params.cap_exp,
201
+ updated_at: adapter.encodeTimestamp(new Date())
202
+ }).where('jti', '=', jti).where('verified_at', 'is', null).executeTakeFirst();
203
+ return (result.numUpdatedRows ?? 0n) > 0n;
204
+ },
205
+ async getPendingRevocationByJTI (jti) {
206
+ const row = await db.selectFrom('kubun_revoked_capabilities').selectAll().where('jti', '=', jti).where('verified_at', 'is', null).executeTakeFirst();
207
+ return row ?? null;
208
+ },
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;
212
+ },
213
+ // `cap_exp` is a plain integer column of epoch seconds (no adapter
214
+ // coercion on either side); the predicate is rewritten as
215
+ // `cap_exp < now - grace` so the comparison stays in integer space on
216
+ // both SQLite and Postgres. Pending rows have `cap_exp` NULL and are
217
+ // excluded by the `is not null` guard.
218
+ async purgeExpiredRevocations (params) {
219
+ const cutoff = Math.floor(Date.now() / 1000) - params.graceSeconds;
220
+ 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
+ return Number(result.numDeletedRows ?? 0n);
222
+ },
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.
227
+ 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();
230
+ return Number(result.numDeletedRows ?? 0n);
231
+ }
232
+ };
233
+ return api;
234
+ }
@@ -0,0 +1,4 @@
1
+ import type { StoreDefinition } from '@kubun/db';
2
+ import type { DelegationStoreAPI } from './api.js';
3
+ import type { DelegationStoreTables } from './tables.js';
4
+ export declare const delegationStoreDefinition: StoreDefinition<DelegationStoreTables, DelegationStoreAPI>;
@@ -0,0 +1,7 @@
1
+ import { createDelegationStore } from './api.js';
2
+ import { getDelegationMigrations } from './migrations.js';
3
+ export const delegationStoreDefinition = {
4
+ name: 'delegation',
5
+ migrations: getDelegationMigrations,
6
+ createAPI: createDelegationStore
7
+ };
package/lib/index.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { StoreProvider } from '@kubun/db';
2
+ import type { DelegationStoreAPI } from './api.js';
3
+ export type { DelegationStoreAPI } from './api.js';
4
+ export { createDelegationStore } from './api.js';
5
+ export { delegationStoreDefinition } from './definition.js';
6
+ export { createRevocationBackend } from './revocation-backend.js';
7
+ export declare const DELEGATION_STORE: "delegation";
8
+ export declare function getDelegationStore(provider: StoreProvider): Promise<DelegationStoreAPI>;
9
+ export type { DelegationStoreTables, DelegationToken, DelegationTokenTable, HeldRevocation, InsertDelegationToken, InsertRevokedCapability, RevokedCapability, RevokedCapabilityTable, } from './tables.js';
package/lib/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { createDelegationStore } from './api.js';
2
+ export { delegationStoreDefinition } from './definition.js';
3
+ export { createRevocationBackend } from './revocation-backend.js';
4
+ export const DELEGATION_STORE = 'delegation';
5
+ export function getDelegationStore(provider) {
6
+ return provider.getStore(DELEGATION_STORE);
7
+ }
@@ -0,0 +1,3 @@
1
+ import type { MigrationContext } from '@kubun/db';
2
+ import type { Migration } from 'kysely/migration';
3
+ export declare function getDelegationMigrations(ctx: MigrationContext): Record<string, Migration>;
@@ -0,0 +1,55 @@
1
+ export function getDelegationMigrations(ctx) {
2
+ const t = ctx.types;
3
+ const now = ctx.functions.now;
4
+ const init = {
5
+ async up (db) {
6
+ // Delegation tokens
7
+ 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
+ // token for indexed validity filtering. bigint, not int32: a 32-bit
9
+ // seconds column ceilings at 2038. The adapter's int8 parser reads it
10
+ // 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', [
12
+ 'grantor',
13
+ 'audience',
14
+ 'resource'
15
+ ]).execute();
16
+ await db.schema.createIndex('idx_delegation_tokens_grantor_audience').ifNotExists().on('kubun_delegation_tokens').columns([
17
+ 'grantor',
18
+ 'audience'
19
+ ]).execute();
20
+ // Auto-attach reads held tokens by audience on every mutation; index the
21
+ // audience (+ exp for the validity filter) so that hot path is not a scan.
22
+ await db.schema.createIndex('idx_delegation_tokens_audience_exp').ifNotExists().on('kubun_delegation_tokens').columns([
23
+ 'audience',
24
+ 'exp'
25
+ ]).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.
29
+ await db.schema.createIndex('idx_delegation_tokens_jti').ifNotExists().on('kubun_delegation_tokens').column('jti').execute();
30
+ // Revoked capabilities. `verified_at` doubles as both the verified flag
31
+ // (NULL = pending cross-check vs the cap's iss) and a forensic
32
+ // timestamp. `cap_exp` is set when verification matches a known cap,
33
+ // and drives the GC sweep that prunes revocations once their cap has
34
+ // been expired long enough that no late-arriving mutation can still
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:
37
+ // 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();
39
+ // GC sweep predicate filters on verified-and-cap-expired; the secondary
40
+ // 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.
42
+ await db.schema.createIndex('idx_revoked_capabilities_verified_cap_exp').ifNotExists().on('kubun_revoked_capabilities').columns([
43
+ 'verified_at',
44
+ 'cap_exp'
45
+ ]).execute();
46
+ },
47
+ async down (db) {
48
+ await db.schema.dropTable('kubun_revoked_capabilities').ifExists().execute();
49
+ await db.schema.dropTable('kubun_delegation_tokens').ifExists().execute();
50
+ }
51
+ };
52
+ return {
53
+ '0-init': init
54
+ };
55
+ }
@@ -0,0 +1,3 @@
1
+ import type { RevocationBackend } from '@kokuin/capability';
2
+ import type { DelegationStoreAPI } from './api.js';
3
+ export declare function createRevocationBackend(api: DelegationStoreAPI): RevocationBackend;
@@ -0,0 +1,14 @@
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
+ }
@@ -0,0 +1,42 @@
1
+ import type { CreatedAtColumn, UpdatedAtColumn } from '@kubun/db-adapter';
2
+ import type { Insertable, Selectable } from 'kysely';
3
+ export type DelegationTokenTable = {
4
+ jti: string;
5
+ grantor: string;
6
+ audience: string;
7
+ token: string;
8
+ resource: string;
9
+ act: string;
10
+ exp: number;
11
+ hlc: string;
12
+ created_at: CreatedAtColumn;
13
+ updated_at: UpdatedAtColumn;
14
+ };
15
+ export type DelegationToken = Selectable<DelegationTokenTable>;
16
+ export type InsertDelegationToken = Insertable<DelegationTokenTable>;
17
+ export type RevokedCapabilityTable = {
18
+ jti: string;
19
+ revoker_did: string;
20
+ revoked_iat: number;
21
+ revocation_token: string;
22
+ verified_at: number | null;
23
+ cap_exp: number | null;
24
+ hlc: string;
25
+ created_at: CreatedAtColumn;
26
+ updated_at: UpdatedAtColumn;
27
+ };
28
+ export type RevokedCapability = Selectable<RevokedCapabilityTable>;
29
+ export type InsertRevokedCapability = Insertable<RevokedCapabilityTable>;
30
+ export type HeldRevocation = {
31
+ jti: string;
32
+ grantor: string;
33
+ audience: string;
34
+ revoker_did: string;
35
+ revoked_iat: number;
36
+ verified_at: number;
37
+ cap_exp: number | null;
38
+ };
39
+ export type DelegationStoreTables = {
40
+ kubun_delegation_tokens: DelegationTokenTable;
41
+ kubun_revoked_capabilities: RevokedCapabilityTable;
42
+ };
package/lib/tables.js ADDED
@@ -0,0 +1,2 @@
1
+ // --- Aggregate DelegationStoreTables type ---
2
+ export { };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@kubun/store-delegation",
3
+ "version": "0.11.0",
4
+ "license": "see LICENSE.md",
5
+ "keywords": [],
6
+ "type": "module",
7
+ "main": "lib/index.js",
8
+ "types": "lib/index.d.ts",
9
+ "exports": {
10
+ ".": "./lib/index.js"
11
+ },
12
+ "files": [
13
+ "lib/*",
14
+ "LICENSE.md"
15
+ ],
16
+ "sideEffects": false,
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"
23
+ },
24
+ "devDependencies": {
25
+ "@testcontainers/postgresql": "^12.0.4",
26
+ "@kubun/db-better-sqlite": "^0.11.0",
27
+ "@kubun/db-postgres": "^0.11.0"
28
+ },
29
+ "scripts": {
30
+ "build:clean": "del lib",
31
+ "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
32
+ "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
33
+ "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
34
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
35
+ "test:types": "tsc --noEmit -p tsconfig.test.json",
36
+ "test:unit": "vitest run",
37
+ "test": "pnpm run test:types && pnpm run test:unit"
38
+ }
39
+ }