@kubun/store-credential 0.17.3 → 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.
@@ -0,0 +1,37 @@
1
+ import { type Adapter } from '@kubun/db-adapter';
2
+ import { type Kysely } from 'kysely';
3
+ import type { CredentialStoreAPI } from './api-types.js';
4
+ import type { VerifiedRecordCopies } from './records.js';
5
+ import type { CredentialTables } from './tables.js';
6
+ export type StoreContext = {
7
+ db: Kysely<CredentialTables>;
8
+ adapter: Adapter;
9
+ advisoryIDForKey?: (keyID: string) => bigint | Promise<bigint>;
10
+ api: () => CredentialStoreAPI;
11
+ verifiedCopies: VerifiedRecordCopies;
12
+ oneOwner: <T extends {
13
+ owner_did: string;
14
+ }>(rows: Array<T>, address: string) => Array<T>;
15
+ ownerForKey: (keyID: string, ownerDID?: string) => Promise<string>;
16
+ lockKeyRowInTransaction: (tx: Kysely<CredentialTables>, keyID: string) => Promise<void>;
17
+ lockKeyRowsInTransaction: (tx: Kysely<CredentialTables>, keyIDs: ReadonlyArray<string>) => Promise<void>;
18
+ writeSubject: (params: {
19
+ keyID: string;
20
+ keyIDs?: Array<string>;
21
+ write: (tx: Kysely<CredentialTables>) => Promise<void>;
22
+ accept?: (tx: Kysely<CredentialTables>) => Promise<boolean>;
23
+ }) => Promise<void>;
24
+ deleteSubject: (params: {
25
+ keyID: string;
26
+ remove: (tx: Kysely<CredentialTables>) => Promise<void>;
27
+ }) => Promise<void>;
28
+ writeAmbient: (innerDb: Kysely<CredentialTables>, write: (tx: Kysely<CredentialTables>) => Promise<void>) => Promise<void>;
29
+ gatedIfAmbient: <T>(fn: () => Promise<T>) => Promise<T>;
30
+ };
31
+ export type StoreContextParams = {
32
+ db: Kysely<CredentialTables>;
33
+ adapter: Adapter;
34
+ advisoryIDForKey: ((keyID: string) => bigint | Promise<bigint>) | undefined;
35
+ getAPI: () => CredentialStoreAPI;
36
+ };
37
+ export declare function createStoreContext(params: StoreContextParams): StoreContext;
@@ -0,0 +1,139 @@
1
+ import { AbstractPostgresAdapter } from '@kubun/db-adapter';
2
+ import { sql } from 'kysely';
3
+ import { orderAdvisoryLockIDs } from './advisory-locks.js';
4
+ /**
5
+ * Run `write` as one atomic unit.
6
+ *
7
+ * A row and the op that speaks for it must land together or not at all: a crash
8
+ * between a delete and its tombstone loses the deletion, and one between a put
9
+ * and its op leaves a row nothing authenticates. Kysely rejects a nested
10
+ * `transaction()`, and an ambient one already provides the atomicity, so inside
11
+ * a caller's transaction the statements run inline.
12
+ */ async function writeAtomically(db, write) {
13
+ if (db.isTransaction) {
14
+ await write(db);
15
+ return;
16
+ }
17
+ await db.transaction().execute(async (tx)=>{
18
+ await write(tx);
19
+ });
20
+ }
21
+ export function createStoreContext(params) {
22
+ const { db, adapter, advisoryIDForKey, getAPI } = params;
23
+ const verifiedCopies = new Map();
24
+ function oneOwner(rows, address) {
25
+ if (new Set(rows.map((row)=>row.owner_did)).size > 1) {
26
+ throw new Error(`Credential ${address} needs an owner`);
27
+ }
28
+ return rows;
29
+ }
30
+ async function ownerForKey(keyID, ownerDID) {
31
+ if (ownerDID !== undefined) return ownerDID;
32
+ const owners = new Set();
33
+ for (const table of [
34
+ 'kubun_credential_keys',
35
+ 'kubun_credential_key_branches',
36
+ 'kubun_credential_wrappings',
37
+ 'kubun_credential_entries',
38
+ 'kubun_credential_entry_candidates',
39
+ 'kubun_credential_revocations',
40
+ 'kubun_credential_key_owner_delegation'
41
+ ]){
42
+ const rows = await db.selectFrom(table).select('owner_did').where('key_id', '=', keyID).execute();
43
+ for (const row of rows)owners.add(row.owner_did);
44
+ if (owners.size > 1) throw new Error(`Credential key ${keyID} needs an owner`);
45
+ }
46
+ // Legacy local rows can precede a key row; they live in the empty local namespace.
47
+ return owners.values().next().value ?? '';
48
+ }
49
+ async function lockKeyRowsInTransaction(tx, keyIDs) {
50
+ if (!tx.isTransaction) throw new Error('lockKeyRows requires a store transaction');
51
+ const keys = [
52
+ ...new Set(keyIDs)
53
+ ];
54
+ if (keys.length === 0) return;
55
+ if (adapter instanceof AbstractPostgresAdapter) {
56
+ // A row lock cannot protect a key before its first insert. The first 64
57
+ // MD5 bits give every transaction the same lock for present and absent keys.
58
+ // Hash every key before locking so colliding IDs are deduplicated and all
59
+ // transactions acquire their IDs in the same signed numeric order.
60
+ const keyedIDs = [];
61
+ for (const keyID of keys){
62
+ const advisoryID = advisoryIDForKey == null ? BigInt((await sql`SELECT (('x' || md5(${`credential-key:${keyID}`}::text))::bit(64)::bigint)::text AS lock_id`.execute(tx)).rows[0]?.lock_id ?? '') : await advisoryIDForKey(keyID);
63
+ keyedIDs.push({
64
+ keyID,
65
+ advisoryID
66
+ });
67
+ }
68
+ for (const advisoryID of orderAdvisoryLockIDs(keyedIDs)){
69
+ await sql`SELECT pg_advisory_xact_lock(${advisoryID.toString()}::bigint)`.execute(tx);
70
+ }
71
+ } else {
72
+ // SQLite's deferred BEGIN does not reserve its single writer until a
73
+ // write. Acquire it before the first read, matching Postgres ordering.
74
+ await tx.updateTable('kubun_credential_keys').set((eb)=>({
75
+ version: eb.ref('kubun_credential_keys.version')
76
+ })).where('key_id', 'in', keys).executeTakeFirst();
77
+ }
78
+ }
79
+ async function lockKeyRowInTransaction(tx, keyID) {
80
+ await lockKeyRowsInTransaction(tx, [
81
+ keyID
82
+ ]);
83
+ }
84
+ async function writeSubject(params) {
85
+ await writeAmbient(db, async (tx)=>{
86
+ await lockKeyRowsInTransaction(tx, params.keyIDs ?? [
87
+ params.keyID
88
+ ]);
89
+ if (params.accept == null || await params.accept(tx)) await params.write(tx);
90
+ });
91
+ }
92
+ async function deleteSubject(params) {
93
+ await writeAmbient(db, async (tx)=>{
94
+ await lockKeyRowInTransaction(tx, params.keyID);
95
+ await params.remove(tx);
96
+ });
97
+ }
98
+ // One async gate serializes EVERY write that joins a caller-supplied ambient
99
+ // transaction.
100
+ // then holds the connection exclusively for its
101
+ // whole open → rollback/release window, so a ROLLBACK TO SAVEPOINT can never undo
102
+ // another concurrent write's committed work — a savepoint rollback reverts ALL work
103
+ // since the savepoint, not just this operation's statements. getStates resolves its
104
+ // connectors with Promise.all over one shared request tx, the source of that
105
+ // concurrency. The gate covers not only the savepoint sections but every ambient
106
+ // write (writeAmbient, and the raw alias writers via gatedIfAmbient), so no ungated
107
+ // statement can land inside a savepoint window. Reads are never gated. Owned
108
+ // transactions (db.isTransaction === false) open their own connection and are
109
+ // serialized by the driver, so they skip the gate. The gate is per api instance:
110
+ // `db.isTransaction` is fixed per instance, so a fresh transaction builds a fresh
111
+ // store with its own gate.
112
+ let ambientWriteChain = Promise.resolve();
113
+ const withAmbientWrite = (fn)=>{
114
+ const run = ambientWriteChain.then(fn, fn);
115
+ ambientWriteChain = run.then(()=>undefined, ()=>undefined);
116
+ return run;
117
+ };
118
+ // Gate a single raw ambient statement; run it directly when this store owns no
119
+ // ambient transaction (nothing else can interleave a savepoint there).
120
+ const gatedIfAmbient = (fn)=>db.isTransaction ? withAmbientWrite(fn) : fn();
121
+ // writeAtomically, but the inline (ambient) branch runs under the gate so it cannot
122
+ // interleave a concurrent savepoint section. The owned branch opens its own tx.
123
+ const writeAmbient = (innerDb, write)=>innerDb.isTransaction ? withAmbientWrite(()=>writeAtomically(innerDb, write)) : writeAtomically(innerDb, write);
124
+ return {
125
+ db,
126
+ adapter,
127
+ advisoryIDForKey,
128
+ api: getAPI,
129
+ verifiedCopies,
130
+ oneOwner,
131
+ ownerForKey,
132
+ lockKeyRowInTransaction,
133
+ lockKeyRowsInTransaction,
134
+ writeSubject,
135
+ deleteSubject,
136
+ writeAmbient,
137
+ gatedIfAmbient
138
+ };
139
+ }
package/lib/tables.d.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import type { CreatedAtColumn } from '@kubun/db-adapter';
2
2
  import type { ColumnType, Insertable, Selectable } from 'kysely';
3
3
  type JSONArrayColumn<T> = ColumnType<Array<T>, Array<T> | undefined, Array<T>>;
4
- type DefaultedNumberColumn = ColumnType<number, number | undefined, number>;
4
+ /**
5
+ * The content-key branch a wrapping or entry joins: its introducing key op's
6
+ * `${hlc}:${opHash}` label, taken from the op that wrote the row. Empty when no op
7
+ * named one, which readers treat as branch-agnostic.
8
+ */
9
+ type BranchColumn = ColumnType<string, string | undefined, string>;
5
10
  /**
6
11
  * Whether a key is the one new entries encrypt under.
7
12
  *
@@ -35,6 +40,7 @@ export type CredentialKey = Selectable<CredentialKeyTable>;
35
40
  export type InsertCredentialKey = Insertable<CredentialKeyTable>;
36
41
  /** An authenticated key-op retained as append-only branch provenance. */
37
42
  export type CredentialKeyBranchTable = {
43
+ owner_did: string;
38
44
  key_id: string;
39
45
  key_version: number;
40
46
  op_hash: string;
@@ -64,6 +70,7 @@ export type CredentialKeyBranch = Selectable<CredentialKeyBranchTable>;
64
70
  * regardless.
65
71
  */
66
72
  export type CredentialWrappingTable = {
73
+ owner_did: string;
67
74
  wrapping_id: string;
68
75
  key_id: string;
69
76
  key_version: number;
@@ -71,6 +78,7 @@ export type CredentialWrappingTable = {
71
78
  iv: Uint8Array;
72
79
  wrapped_key: Uint8Array;
73
80
  recipient_did: string | null;
81
+ branch_id: BranchColumn;
74
82
  created_at: CreatedAtColumn;
75
83
  };
76
84
  export type CredentialWrapping = Selectable<CredentialWrappingTable>;
@@ -92,12 +100,14 @@ export type CredentialWrappingWithBranch = CredentialWrapping & {
92
100
  * readable, and the version is what selects the wrapping that opens them.
93
101
  */
94
102
  export type CredentialEntryTable = {
103
+ owner_did: string;
95
104
  entry_id: string;
96
105
  key_id: string;
97
106
  key_version: number;
98
107
  iv: Uint8Array;
99
108
  ciphertext: Uint8Array;
100
109
  hlc: string;
110
+ branch_id: BranchColumn;
101
111
  created_at: CreatedAtColumn;
102
112
  };
103
113
  export type CredentialEntry = Selectable<CredentialEntryTable>;
@@ -105,67 +115,6 @@ export type InsertCredentialEntry = Insertable<CredentialEntryTable>;
105
115
  export type CredentialEntryWithProvenance = CredentialEntry & {
106
116
  author_did: string | null;
107
117
  };
108
- /** Which of the three tables a signed op speaks for. */
109
- export type CredentialOpSubjectKind = 'key' | 'wrapping' | 'entry';
110
- /**
111
- * The latest signed op for one key, wrapping or entry — replaced on update, not
112
- * appended. There is no op history: a credential op is a whole-row replacement,
113
- * and a log would retain the wrapped key and the ciphertext that a rotation
114
- * exists to destroy.
115
- *
116
- * One table for all three subjects because a merkle tree wants its leaves from
117
- * one scan rather than a union, and because a deletion has to travel: a lane
118
- * that only adds cannot express absence, so a delete leaves a row here with
119
- * `deleted` set and removes nothing else.
120
- *
121
- * `deleted` is an integer 0/1 rather than a boolean so it reads back identically
122
- * on SQLite and Postgres, matching the other flags in this stack.
123
- *
124
- * `hlc` is the op author's stamp, not the local write time. For an entry it is
125
- * the same string the entry row carries — the two must agree, and a test pins
126
- * that.
127
- */
128
- export type CredentialOpTable = {
129
- subject_kind: string;
130
- subject_id: string;
131
- /** So a whole vault can be served, swept or dropped together. */
132
- key_id: string;
133
- /** The op's verified issuer, normalized — this one IS compared. */
134
- author_did: string;
135
- hlc: string;
136
- op_jwt: string;
137
- /** Digest of `op_jwt`, as the graph mutation log addresses its mutations. */
138
- op_hash: string;
139
- /**
140
- * Which content-key branch this op belongs to, `${hlc}:${opHash}` of the
141
- * key-op that introduced the branch. A keyless party partitions and ranks
142
- * branches by this without holding a wrapping. Empty for an op minted outside
143
- * a rotation/create, which does not yet resolve the branch it joins.
144
- */
145
- branch_id: string;
146
- deleted: DefaultedNumberColumn;
147
- created_at: CreatedAtColumn;
148
- };
149
- export type CredentialOp = Selectable<CredentialOpTable>;
150
- export type InsertCredentialOp = Insertable<CredentialOpTable>;
151
- /**
152
- * A local pointer from an opaque `(namespace, key)` to the entry id it
153
- * currently names. Not an op subject: it carries no `hlc` and never appears in
154
- * `kubun_credential_ops` or the merkle scan, because it is a per-device index
155
- * rather than a replicated fact — each device that resolves the same
156
- * `(namespace, key)` points it at the entry it converges on locally.
157
- *
158
- * The store does not interpret `namespace`, `alias_key` or `entry_id` — they
159
- * are stored and returned verbatim, whatever a caller passes.
160
- */
161
- export type KubunCredentialAliasTable = {
162
- namespace: string;
163
- alias_key: string;
164
- entry_id: string;
165
- created_at: CreatedAtColumn;
166
- };
167
- export type CredentialAlias = Selectable<KubunCredentialAliasTable>;
168
- export type InsertCredentialAlias = Insertable<KubunCredentialAliasTable>;
169
118
  /**
170
119
  * The grantor-authority envelope backing one wrapping — a signed token proving
171
120
  * whoever minted the wrapping held the right to grant it, carried beside it
@@ -173,6 +122,7 @@ export type InsertCredentialAlias = Insertable<KubunCredentialAliasTable>;
173
122
  * every `put` strictly-greater, so a stale/replayed envelope is a silent no-op.
174
123
  */
175
124
  export type CredentialWrappingAuthorityTable = {
125
+ owner_did: string;
176
126
  wrapping_id: string;
177
127
  authority_token: string;
178
128
  refresh_seq: number;
@@ -267,6 +217,7 @@ export type InsertCredentialManifest = Insertable<CredentialManifestTable>;
267
217
  * shape as {@link CredentialWrappingAuthorityTable}.
268
218
  */
269
219
  export type CredentialKeyOwnerDelegationTable = {
220
+ owner_did: string;
270
221
  key_id: string;
271
222
  delegation_tokens: JSONArrayColumn<string>;
272
223
  refresh_seq: number;
@@ -274,13 +225,82 @@ export type CredentialKeyOwnerDelegationTable = {
274
225
  };
275
226
  export type CredentialKeyOwnerDelegation = Selectable<CredentialKeyOwnerDelegationTable>;
276
227
  export type InsertCredentialKeyOwnerDelegation = Insertable<CredentialKeyOwnerDelegationTable>;
228
+ type JSONObjectColumn = ColumnType<Record<string, unknown>, Record<string, unknown> | string, Record<string, unknown> | string>;
229
+ /**
230
+ * A signed copy of the selected owner-qualified credential decision, addressed by
231
+ * `(type, owner, id, op_hash)`, tombstones included. A deleted record stays with no payload, so an
232
+ * older write cannot come back.
233
+ *
234
+ * The indexed tables (keys, key branches, wrappings, wrapping authority, entries, manifests,
235
+ * revocations) are projections of the live rows here, written in the same transaction. A
236
+ * row the legacy verbs wrote into an indexed table has no row here: it is local state that
237
+ * no record speaks for, and the record lane never lists it.
238
+ *
239
+ * `owner`, `hlc`, `payload_digest`, `deleted` and `meta` repeat fields of the claim that
240
+ * `op_jwt` signs, so the table can be indexed without verifying a token. `payload` is the
241
+ * exact byte string the claim's digest covers. `deleted` is an integer 0/1 so it reads back
242
+ * identically on SQLite and Postgres.
243
+ */
244
+ export type CredentialRecordTable = {
245
+ type: string;
246
+ id: string;
247
+ owner: string;
248
+ hlc: string;
249
+ op_jwt: string;
250
+ op_hash: string;
251
+ payload_digest: string | null;
252
+ payload: Uint8Array | null;
253
+ deleted: number;
254
+ meta: JSONObjectColumn;
255
+ created_at: CreatedAtColumn;
256
+ };
257
+ export type CredentialRecordRow = Selectable<CredentialRecordTable>;
258
+ /** Signed entry operations retained per branch for fork comparison and repair. */
259
+ export type CredentialEntryCandidateTable = {
260
+ owner_did: string;
261
+ key_id: string;
262
+ key_version: number;
263
+ branch_id: string;
264
+ entry_id: string;
265
+ owner: string;
266
+ hlc: string;
267
+ op_jwt: string;
268
+ op_hash: string;
269
+ payload: Uint8Array | null;
270
+ created_at: CreatedAtColumn;
271
+ };
272
+ /**
273
+ * A recipient revoked from a key, projected from a `credential.revocation` record. Keyed by
274
+ * `(key_id, recipient_did)` with the recipient normalized. Only a revocation record writes
275
+ * here: removing a wrapping, as rotation cleanup does, never touches it, which is what lets
276
+ * a revocation be told apart from cleanup after replicas merge.
277
+ */
278
+ export type CredentialRevocationTable = {
279
+ key_id: string;
280
+ recipient_did: string;
281
+ owner_did: string;
282
+ created_at: CreatedAtColumn;
283
+ };
284
+ export type CredentialRevocation = Selectable<CredentialRevocationTable>;
285
+ /**
286
+ * A local pointer from `(provider, owner, device)` to the entry holding that device's own
287
+ * grant. Like the two-key alias it is a per-device index, not a replicated fact, and the
288
+ * store stores and returns its values verbatim: a caller normalizes the DIDs.
289
+ */
290
+ export type CredentialDeviceAliasTable = {
291
+ provider_name: string;
292
+ owner_did: string;
293
+ device_did: string;
294
+ entry_id: string;
295
+ entry_owner_did: string;
296
+ created_at: CreatedAtColumn;
297
+ };
298
+ export type CredentialDeviceAlias = Selectable<CredentialDeviceAliasTable>;
277
299
  export type CredentialTables = {
278
300
  kubun_credential_keys: CredentialKeyTable;
279
301
  kubun_credential_key_branches: CredentialKeyBranchTable;
280
302
  kubun_credential_wrappings: CredentialWrappingTable;
281
303
  kubun_credential_entries: CredentialEntryTable;
282
- kubun_credential_ops: CredentialOpTable;
283
- kubun_credential_aliases: KubunCredentialAliasTable;
284
304
  kubun_credential_wrapping_authority: CredentialWrappingAuthorityTable;
285
305
  kubun_credential_grantor_counters: CredentialGrantorCounterTable;
286
306
  kubun_credential_provisioning_epochs: CredentialProvisioningEpochTable;
@@ -288,5 +308,9 @@ export type CredentialTables = {
288
308
  kubun_credential_provisioning_attempts: CredentialProvisioningAttemptTable;
289
309
  kubun_credential_manifests: CredentialManifestTable;
290
310
  kubun_credential_key_owner_delegation: CredentialKeyOwnerDelegationTable;
311
+ kubun_credential_records: CredentialRecordTable;
312
+ kubun_credential_entry_candidates: CredentialEntryCandidateTable;
313
+ kubun_credential_revocations: CredentialRevocationTable;
314
+ kubun_credential_device_aliases: CredentialDeviceAliasTable;
291
315
  };
292
316
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/store-credential",
3
- "version": "0.17.3",
3
+ "version": "0.18.0",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,15 +15,18 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kokuin/token": "^0.5.0",
19
- "@kubun/db": "^0.17.0",
20
- "@kubun/db-adapter": "^0.17.0",
21
- "kysely": "^0.29.6"
18
+ "@kokuin/token": "^0.5.1",
19
+ "@kubun/db": "^0.18.0",
20
+ "@kubun/db-adapter": "^0.18.0",
21
+ "@kubun/records": "^0.18.0",
22
+ "@sozai/codec": "^0.4.3",
23
+ "kysely": "^0.29.7"
22
24
  },
23
25
  "devDependencies": {
24
- "@kubun/db-better-sqlite": "^0.17.0",
25
- "@kubun/db-postgres": "^0.17.1",
26
- "@testcontainers/postgresql": "^12.1.0"
26
+ "@kubun/db-better-sqlite": "^0.18.0",
27
+ "@kubun/db-postgres": "^0.18.0",
28
+ "@kubun/hlc": "^0.18.0",
29
+ "@testcontainers/postgresql": "^12.2.1"
27
30
  },
28
31
  "publishConfig": {
29
32
  "access": "public"