@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 +57 -0
- package/lib/api.d.ts +39 -0
- package/lib/api.js +234 -0
- package/lib/definition.d.ts +4 -0
- package/lib/definition.js +7 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.js +7 -0
- package/lib/migrations.d.ts +3 -0
- package/lib/migrations.js +55 -0
- package/lib/revocation-backend.d.ts +3 -0
- package/lib/revocation-backend.js +14 -0
- package/lib/tables.d.ts +42 -0
- package/lib/tables.js +2 -0
- package/package.json +39 -0
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
|
+
}
|
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,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,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
|
+
}
|
package/lib/tables.d.ts
ADDED
|
@@ -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
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
|
+
}
|