@kokuin/store-vault 0.0.0-stage → 0.1.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.
Files changed (48) hide show
  1. package/README.md +126 -2
  2. package/lib/api.d.ts +5 -0
  3. package/lib/api.js +504 -0
  4. package/lib/batch.d.ts +89 -0
  5. package/lib/batch.js +159 -0
  6. package/lib/coordinator-context.d.ts +71 -0
  7. package/lib/coordinator-context.js +410 -0
  8. package/lib/coordinator-entries.d.ts +20 -0
  9. package/lib/coordinator-entries.js +258 -0
  10. package/lib/coordinator-open.d.ts +9 -0
  11. package/lib/coordinator-open.js +141 -0
  12. package/lib/coordinator-records.d.ts +27 -0
  13. package/lib/coordinator-records.js +132 -0
  14. package/lib/coordinator-rotate.d.ts +9 -0
  15. package/lib/coordinator-rotate.js +436 -0
  16. package/lib/coordinator-types.d.ts +139 -0
  17. package/lib/coordinator-types.js +1 -0
  18. package/lib/coordinator.d.ts +2 -0
  19. package/lib/coordinator.js +459 -0
  20. package/lib/definition.d.ts +6 -0
  21. package/lib/definition.js +12 -0
  22. package/lib/index.d.ts +12 -0
  23. package/lib/index.js +7 -0
  24. package/lib/migrations.d.ts +2 -0
  25. package/lib/migrations.js +52 -0
  26. package/lib/record-context.d.ts +24 -0
  27. package/lib/record-context.js +34 -0
  28. package/lib/record-definition.d.ts +7 -0
  29. package/lib/record-definition.js +93 -0
  30. package/lib/record-tables.d.ts +47 -0
  31. package/lib/record-tables.js +1 -0
  32. package/lib/records-adapter.d.ts +3 -0
  33. package/lib/records-adapter.js +197 -0
  34. package/lib/records-ports.d.ts +4 -0
  35. package/lib/records-ports.js +72 -0
  36. package/lib/records-projection.d.ts +3 -0
  37. package/lib/records-projection.js +105 -0
  38. package/lib/records-rows.d.ts +54 -0
  39. package/lib/records-rows.js +155 -0
  40. package/lib/records.d.ts +59 -0
  41. package/lib/records.js +233 -0
  42. package/lib/settle.d.ts +12 -0
  43. package/lib/settle.js +18 -0
  44. package/lib/tables.d.ts +42 -0
  45. package/lib/tables.js +1 -0
  46. package/lib/types.d.ts +74 -0
  47. package/lib/types.js +1 -0
  48. package/package.json +45 -3
@@ -0,0 +1,155 @@
1
+ import { chunk, withStoreTransaction } from '@hozon/db';
2
+ import { recordClaimDigest, recordKeyText, recordPayloadMatches, verifySignedRecord } from '@kokuin/records';
3
+ import { decodeVaultRecord, VAULT_RECORD_TYPES } from '@kokuin/vault';
4
+ import { PARAMETER_BUDGET } from './batch.js';
5
+ import { settleAll } from './settle.js';
6
+ export class VaultRecordRowError extends Error {
7
+ #type;
8
+ #id;
9
+ constructor(key){
10
+ super(`Stored vault record ${key.type} ${key.id} is corrupt`, {
11
+ cause: key.cause
12
+ });
13
+ this.name = 'VaultRecordRowError';
14
+ this.#type = key.type;
15
+ this.#id = key.id;
16
+ }
17
+ get type() {
18
+ return this.#type;
19
+ }
20
+ get id() {
21
+ return this.#id;
22
+ }
23
+ }
24
+ // Keyed OR predicates per getMany statement, within SQLite's default bind limit.
25
+ export const GET_MANY_CHUNK = 160;
26
+ export const boundAdapters = new WeakMap();
27
+ export const acceptedKinds = new WeakMap();
28
+ export function supported(type) {
29
+ return VAULT_RECORD_TYPES.includes(type);
30
+ }
31
+ export function json(value) {
32
+ return typeof value === 'string' ? JSON.parse(value) : value;
33
+ }
34
+ export function groupRows(rows) {
35
+ const groups = new Map();
36
+ for (const row of rows){
37
+ const key = recordKeyText(row);
38
+ const group = groups.get(key) ?? [];
39
+ group.push(row);
40
+ groups.set(key, group);
41
+ }
42
+ return groups;
43
+ }
44
+ /** Deletes the named copies of one record, in statements of at most `PARAMETER_BUDGET` binds. */ export async function deleteCopies(db, key, opHashes) {
45
+ await settleAll(// Three binds name the record. The rest name copies.
46
+ chunk(opHashes, PARAMETER_BUDGET - 3).map((part)=>{
47
+ return db.deleteFrom('vault_records').where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id).where('op_hash', 'in', part).execute();
48
+ }));
49
+ }
50
+ export function whereKey(db, key) {
51
+ return db.selectFrom('vault_records').selectAll().where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id);
52
+ }
53
+ export async function assertDomain(bound) {
54
+ // The domain row is written once, in its own committed transaction, and never changed. Once it
55
+ // matches, it matches for the bound adapter's lifetime.
56
+ if (bound.domainChecked === true) return;
57
+ const row = await withStoreTransaction(bound.db, (db)=>{
58
+ return db.selectFrom('vault_record_domain').selectAll().executeTakeFirst();
59
+ });
60
+ if (row === undefined) throw new Error('Initialise the vault operation kind outside caller transactions');
61
+ if (row.op_kind !== bound.opKind) throw new TypeError('Vault records store belongs to another operation kind');
62
+ bound.domainChecked = true;
63
+ }
64
+ export async function checked(row, bound) {
65
+ const key = {
66
+ type: row.type,
67
+ owner: row.owner,
68
+ id: row.id
69
+ };
70
+ try {
71
+ if (row.deleted !== 0 && row.deleted !== 1) throw new Error('invalid deleted flag');
72
+ const payload = row.payload === null ? null : Uint8Array.from(row.payload);
73
+ if (row.deleted === 1 !== (payload === null)) throw new Error('invalid payload liveness');
74
+ const meta = json(row.meta);
75
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) throw new Error('invalid metadata');
76
+ const copy = {
77
+ ...key,
78
+ hlc: row.hlc,
79
+ opJWT: row.op_jwt,
80
+ opHash: row.op_hash,
81
+ payload
82
+ };
83
+ const op = await verifySignedRecord(copy, bound.opKind, bound.params.methods);
84
+ if (op === null) return null;
85
+ if (!recordPayloadMatches(copy, op.claim) || row.payload_digest !== op.claim.payloadDigest || row.deleted !== Number(op.claim.deleted) || recordClaimDigest({
86
+ ...op.claim,
87
+ meta: meta
88
+ }) !== recordClaimDigest(op.claim)) throw new Error('stored claim mismatch');
89
+ if (!op.claim.deleted) decodeVaultRecord(copy, op.claim.meta);
90
+ return {
91
+ copy,
92
+ op
93
+ };
94
+ } catch (cause) {
95
+ // biome-ignore lint/style/useErrorCause: the constructor forwards cause
96
+ throw new VaultRecordRowError({
97
+ ...key,
98
+ cause
99
+ });
100
+ }
101
+ }
102
+ export async function decision(rows, bound) {
103
+ const copies = [];
104
+ let selected;
105
+ let failure;
106
+ // Checks run together. Results are consumed in row order, as the sequential checks were.
107
+ const results = await Promise.allSettled(rows.map((row)=>checked(row, bound)));
108
+ for (const result of results){
109
+ if (result.status === 'rejected') {
110
+ failure ??= result.reason;
111
+ continue;
112
+ }
113
+ const value = result.value;
114
+ if (value === null) continue;
115
+ const contentDigest = recordClaimDigest(value.op.claim);
116
+ const decisionHLC = typeof value.op.claim.meta.version === 'string' ? value.op.claim.meta.version : value.op.claim.hlc;
117
+ if (selected !== undefined && (selected.contentDigest !== contentDigest || selected.decisionHLC !== decisionHLC)) throw new VaultRecordRowError(value.copy);
118
+ selected = {
119
+ contentDigest,
120
+ decisionHLC
121
+ };
122
+ copies.push(value.copy);
123
+ }
124
+ if (selected === undefined) {
125
+ if (failure !== undefined) throw failure;
126
+ // Invalid signatures cannot make an existing decision appear absent.
127
+ if (rows[0] !== undefined) throw new VaultRecordRowError(rows[0]);
128
+ return null;
129
+ }
130
+ const first = copies[0];
131
+ if (first === undefined) return null;
132
+ return {
133
+ key: {
134
+ type: first.type,
135
+ owner: first.owner,
136
+ id: first.id
137
+ },
138
+ ...selected,
139
+ copies: copies.sort((a, b)=>a.opHash.localeCompare(b.opHash))
140
+ };
141
+ }
142
+ export function values(record, op, bound) {
143
+ return {
144
+ type: record.type,
145
+ owner: record.owner,
146
+ id: record.id,
147
+ hlc: record.hlc,
148
+ op_jwt: record.opJWT,
149
+ op_hash: record.opHash,
150
+ payload: record.payload === null ? null : bound.adapter.encodeBinary(record.payload),
151
+ payload_digest: op.claim.payloadDigest,
152
+ deleted: Number(op.claim.deleted),
153
+ meta: bound.adapter.encodeJSON(op.claim.meta)
154
+ };
155
+ }
@@ -0,0 +1,59 @@
1
+ import type { ApplyRecordChunkParams, ApplyResult, RecordStoreAdapter, SignedRecord, VerifiedRecordOp } from '@kokuin/records';
2
+ import type { VaultRecordTypeParams } from '@kokuin/vault';
3
+ import type { EntryAddress, KeyAddress, VaultStoreAPI, WrappingRow } from './types.js';
4
+ export { vaultRecordPortsFor } from './records-ports.js';
5
+ export { VaultRecordRowError } from './records-rows.js';
6
+ export type VaultWritePreconditions = {
7
+ key?: KeyAddress & {
8
+ expectedVersion: number | null;
9
+ expectedBranches?: ReadonlyArray<string>;
10
+ };
11
+ entry?: EntryAddress & {
12
+ expectedHLC: string | null;
13
+ };
14
+ };
15
+ export type VaultRecordsParams = Omit<VaultRecordTypeParams, 'portsFor'> & {
16
+ store: VaultStoreAPI;
17
+ nowMS: () => number;
18
+ maxDriftMS: number;
19
+ receiveClock?: ApplyRecordChunkParams['receiveClock'];
20
+ onCorruptRow?: (info: {
21
+ type: string;
22
+ id: string;
23
+ error: unknown;
24
+ }) => void;
25
+ };
26
+ export type VaultKeyBranch = {
27
+ branchID: string;
28
+ /** The `vault.key-branch` record that projected this branch. */
29
+ branchRecordID: string;
30
+ introducingOp: VerifiedRecordOp;
31
+ };
32
+ export type VaultRecords = {
33
+ adapter: RecordStoreAdapter;
34
+ apply(records: ReadonlyArray<SignedRecord>, transportContext?: unknown): Promise<ApplyResult>;
35
+ write(params: {
36
+ records: ReadonlyArray<SignedRecord>;
37
+ preconditions: VaultWritePreconditions;
38
+ transportContext?: unknown;
39
+ }): Promise<ApplyResult>;
40
+ listKeyBranches(address: KeyAddress & {
41
+ keyVersion: number;
42
+ }): Promise<Array<VaultKeyBranch>>;
43
+ /**
44
+ * Rewrites the key's branch projection from its stored, verified `vault.key-branch` records.
45
+ * Writes only when the projection differs. Returns whether it did.
46
+ */
47
+ rebuildKeyBranches(address: KeyAddress): Promise<boolean>;
48
+ listWrappingsWithBranch(address: KeyAddress & {
49
+ keyVersion?: number;
50
+ }): Promise<Array<WrappingRow>>;
51
+ listEntryCandidates(address: KeyAddress & {
52
+ keyVersion: number;
53
+ }): Promise<Array<SignedRecord>>;
54
+ listPriorEntryTombstones(address: KeyAddress & {
55
+ beforeVersion: number;
56
+ }): Promise<Array<SignedRecord>>;
57
+ listRevocations(address: KeyAddress): Promise<Array<string>>;
58
+ };
59
+ export declare function createVaultRecords(params: VaultRecordsParams): VaultRecords;
package/lib/records.js ADDED
@@ -0,0 +1,233 @@
1
+ import { withStoreTransaction } from '@hozon/db';
2
+ import { applyRecordChunk, getRecordDecisions, recordKeyText, verifyRecordOp, verifySignedRecord } from '@kokuin/records';
3
+ import { decodeVaultRecord, vaultRecordTypes } from '@kokuin/vault';
4
+ import { KEY_BRANCH_COLUMNS, statementChunks, upsertKeyBranches } from './batch.js';
5
+ import { VaultWriteConflict, vaultDatabases } from './record-context.js';
6
+ import { makeAdapter } from './records-adapter.js';
7
+ import { vaultRecordPortsFor } from './records-ports.js';
8
+ import { acceptedKinds, assertDomain, boundAdapters, checked, VaultRecordRowError } from './records-rows.js';
9
+ import { settleAll, settledValue } from './settle.js';
10
+ export { vaultRecordPortsFor } from './records-ports.js';
11
+ export { VaultRecordRowError } from './records-rows.js';
12
+ export function createVaultRecords(params) {
13
+ if (typeof params.opKind !== 'string' || params.opKind.length === 0) throw new TypeError('An explicit operation kind is required');
14
+ const storage = vaultDatabases.get(params.store);
15
+ if (storage === undefined || !storage.context.provider.hasStore('vault-records')) throw new Error('Register the optional vault records store first');
16
+ const existingKind = acceptedKinds.get(params.store);
17
+ if (existingKind !== undefined && existingKind !== params.opKind) throw new TypeError('Vault records store belongs to another operation kind');
18
+ acceptedKinds.set(params.store, params.opKind);
19
+ const bound = {
20
+ ...storage,
21
+ params,
22
+ opKind: params.opKind,
23
+ store: params.store
24
+ };
25
+ const adapter = makeAdapter(bound);
26
+ const types = vaultRecordTypes({
27
+ ...params,
28
+ portsFor: vaultRecordPortsFor
29
+ });
30
+ const apply = (records, store, transportContext)=>{
31
+ return applyRecordChunk({
32
+ records,
33
+ store,
34
+ types,
35
+ opKind: params.opKind,
36
+ nowMS: params.nowMS(),
37
+ maxDriftMS: params.maxDriftMS,
38
+ receiveClock: params.receiveClock ?? (()=>{}),
39
+ methods: params.methods,
40
+ transportContext,
41
+ resolveController: async ()=>null,
42
+ verifyCapability: async ()=>false
43
+ });
44
+ };
45
+ const candidates = async (address, version, prior)=>{
46
+ await assertDomain(bound);
47
+ return withStoreTransaction(bound.db, async (tx)=>{
48
+ let query = tx.selectFrom('vault_entry_candidates').selectAll().where('owner', '=', address.ownerDID).where('key_id', '=', address.keyID);
49
+ query = prior ? query.where('key_version', '<', version).where('deleted', '=', 1) : query.where('key_version', '=', version);
50
+ const rows = await query.orderBy('id').orderBy('op_hash').execute();
51
+ return settleAll(rows.map(async (row)=>{
52
+ const value = await checked(row, bound);
53
+ if (value === null) throw new VaultRecordRowError(row);
54
+ return value.copy;
55
+ }));
56
+ });
57
+ };
58
+ const api = {
59
+ adapter,
60
+ apply: (records, transportContext)=>apply(records, adapter, transportContext),
61
+ async write (request) {
62
+ if (request.preconditions === undefined) throw new TypeError('Signed writes require explicit preconditions');
63
+ return adapter.transaction(async (tx)=>{
64
+ const inner = boundAdapters.get(tx);
65
+ if (inner === undefined) throw new Error('Missing transaction adapter');
66
+ const expectedKey = request.preconditions.key;
67
+ const expectedEntry = request.preconditions.entry;
68
+ if (expectedKey === undefined && expectedEntry === undefined) throw new TypeError('Signed writes require a key or entry precondition');
69
+ // The address precondition reads nothing, so it runs before any store read, as it did.
70
+ const ops = await settleAll(request.records.map(async (record)=>{
71
+ const op = await verifySignedRecord(record, params.opKind, params.methods);
72
+ if (op !== null && !(expectedKey !== undefined && expectedKey.ownerDID === op.claim.owner && expectedKey.keyID === op.claim.meta.keyID || expectedEntry !== undefined && record.type === 'vault.entry' && expectedEntry.ownerDID === record.owner && expectedEntry.entryID === record.id)) throw new TypeError('Every signed output requires an address precondition');
73
+ return op;
74
+ }));
75
+ const [old, observedEntry] = await settleAll([
76
+ getRecordDecisions(tx, request.records),
77
+ expectedEntry === undefined ? Promise.resolve(undefined) : inner.store.getEntry(expectedEntry)
78
+ ]);
79
+ const recordAddresses = await settleAll(request.records.map(async (record, position)=>{
80
+ const op = ops[position] ?? null;
81
+ const found = [];
82
+ if (op !== null && typeof op.claim.meta.keyID === 'string') found.push({
83
+ ownerDID: record.owner,
84
+ keyID: op.claim.meta.keyID
85
+ });
86
+ const copies = old.get(recordKeyText(record))?.copies ?? [];
87
+ const priors = await settleAll(copies.map((copy)=>verifySignedRecord(copy, params.opKind, params.methods)));
88
+ for (const [index, prior] of priors.entries()){
89
+ const copy = copies[index];
90
+ if (copy !== undefined && typeof prior?.claim.meta.keyID === 'string') found.push({
91
+ ownerDID: copy.owner,
92
+ keyID: prior.claim.meta.keyID
93
+ });
94
+ }
95
+ return found;
96
+ }));
97
+ const addresses = recordAddresses.flat();
98
+ if (expectedKey !== undefined) addresses.push(expectedKey);
99
+ if (observedEntry !== undefined) addresses.push(observedEntry);
100
+ await tx.lock?.(addresses.map((address)=>{
101
+ return {
102
+ type: 'vault.key',
103
+ key: JSON.stringify([
104
+ address.ownerDID,
105
+ address.keyID
106
+ ])
107
+ };
108
+ }));
109
+ const heldKeys = new Set(addresses.map((address)=>JSON.stringify([
110
+ address.ownerDID,
111
+ address.keyID
112
+ ])));
113
+ // Authoritative reads follow the lock. They are independent of each other.
114
+ const keyCheck = async ()=>{
115
+ if (expectedKey === undefined) return true;
116
+ const row = await inner.store.getKey(expectedKey);
117
+ if ((row?.version ?? null) !== expectedKey.expectedVersion) return false;
118
+ if (expectedKey.expectedBranches === undefined) return true;
119
+ const branches = await createVaultRecords({
120
+ ...params,
121
+ store: inner.store
122
+ }).listKeyBranches({
123
+ ...expectedKey,
124
+ keyVersion: expectedKey.expectedVersion ?? 0
125
+ });
126
+ const current = branches.map((branch)=>branch.branchID).sort();
127
+ const expected = [
128
+ ...expectedKey.expectedBranches
129
+ ].sort();
130
+ return JSON.stringify(current) === JSON.stringify(expected);
131
+ };
132
+ // All three run together. Results are checked in the old order: held copies, entry key,
133
+ // key version and branches, entry HLC.
134
+ const [currentRead, entryRead, keyRead] = await Promise.allSettled([
135
+ getRecordDecisions(tx, request.records),
136
+ expectedEntry === undefined ? Promise.resolve(undefined) : inner.store.getEntry(expectedEntry),
137
+ keyCheck()
138
+ ]);
139
+ const current = settledValue(currentRead);
140
+ const currentOps = await settleAll(request.records.flatMap((record)=>{
141
+ return (current.get(recordKeyText(record))?.copies ?? []).map((copy)=>{
142
+ return verifySignedRecord(copy, params.opKind, params.methods);
143
+ });
144
+ }));
145
+ for (const op of currentOps)if (op !== null && !heldKeys.has(JSON.stringify([
146
+ op.claim.owner,
147
+ op.claim.meta.keyID
148
+ ]))) throw new VaultWriteConflict();
149
+ const currentEntry = settledValue(entryRead);
150
+ if (expectedEntry !== undefined && currentEntry?.keyID !== observedEntry?.keyID) throw new VaultWriteConflict();
151
+ if (!settledValue(keyRead)) throw new VaultWriteConflict();
152
+ if (expectedEntry !== undefined && (currentEntry?.hlc ?? null) !== expectedEntry.expectedHLC) throw new VaultWriteConflict();
153
+ return apply(request.records, tx, request.transportContext);
154
+ });
155
+ },
156
+ async listKeyBranches (address) {
157
+ await assertDomain(bound);
158
+ return withStoreTransaction(bound.db, async (tx)=>{
159
+ const rows = await tx.selectFrom('vault_key_branches').selectAll().where('owner_did', '=', address.ownerDID).where('key_id', '=', address.keyID).where('key_version', '=', address.keyVersion).orderBy('branch_id').execute();
160
+ const ops = await settleAll(rows.map((row)=>verifyRecordOp(row.op_jwt, params.opKind, params.methods)));
161
+ const result = [];
162
+ for (const [index, row] of rows.entries()){
163
+ const op = ops[index] ?? null;
164
+ if (op === null || op.claim.owner !== address.ownerDID || op.claim.id !== address.keyID || op.claim.meta.version !== address.keyVersion || row.branch_id !== `${op.claim.hlc}:${op.opHash}`) throw new VaultRecordRowError({
165
+ type: 'vault.key-branch',
166
+ owner: row.owner_did,
167
+ id: row.record_id
168
+ });
169
+ result.push({
170
+ branchID: row.branch_id,
171
+ branchRecordID: row.record_id,
172
+ introducingOp: op
173
+ });
174
+ }
175
+ return result;
176
+ });
177
+ },
178
+ async listWrappingsWithBranch (address) {
179
+ await assertDomain(bound);
180
+ return params.store.listWrappings(address);
181
+ },
182
+ async rebuildKeyBranches (address) {
183
+ await assertDomain(bound);
184
+ const decisions = await adapter.list([
185
+ 'vault.key-branch'
186
+ ]);
187
+ const candidates = decisions.flatMap((decision)=>{
188
+ const record = decision.copies[0];
189
+ return record !== undefined && record.owner === address.ownerDID ? [
190
+ record
191
+ ] : [];
192
+ });
193
+ const ops = await settleAll(candidates.map((record)=>verifySignedRecord(record, params.opKind, params.methods)));
194
+ const rows = [];
195
+ for (const [index, record] of candidates.entries()){
196
+ const op = ops[index] ?? null;
197
+ if (op === null || op.claim.meta.keyID !== address.keyID) continue;
198
+ const decoded = decodeVaultRecord(record, op.claim.meta);
199
+ if (decoded.type !== 'vault.key-branch') continue;
200
+ rows.push({
201
+ owner_did: decoded.owner,
202
+ key_id: decoded.meta.keyID,
203
+ key_version: decoded.meta.keyVersion,
204
+ record_id: decoded.id,
205
+ branch_id: decoded.meta.branchID,
206
+ op_jwt: decoded.payload.opJWT
207
+ });
208
+ }
209
+ const text = (list)=>{
210
+ return JSON.stringify(list.map((row)=>[
211
+ row.record_id,
212
+ row.key_version,
213
+ row.branch_id,
214
+ row.op_jwt
215
+ ]).sort((a, b)=>String(a[0]).localeCompare(String(b[0]))));
216
+ };
217
+ return withStoreTransaction(bound.db, async (tx)=>{
218
+ const held = await tx.selectFrom('vault_key_branches').selectAll().where('owner_did', '=', address.ownerDID).where('key_id', '=', address.keyID).execute();
219
+ if (text(held) === text(rows)) return false;
220
+ await tx.deleteFrom('vault_key_branches').where('owner_did', '=', address.ownerDID).where('key_id', '=', address.keyID).execute();
221
+ // A record ID still projected under another key is taken over, as one upsert per row did.
222
+ await settleAll(statementChunks(rows, KEY_BRANCH_COLUMNS).map((part)=>{
223
+ return upsertKeyBranches(tx, part).execute();
224
+ }));
225
+ return true;
226
+ });
227
+ },
228
+ listRevocations: (address)=>vaultRecordPortsFor(adapter).listRevocations(address),
229
+ listEntryCandidates: (address)=>candidates(address, address.keyVersion, false),
230
+ listPriorEntryTombstones: (address)=>candidates(address, address.beforeVersion, true)
231
+ };
232
+ return api;
233
+ }
@@ -0,0 +1,12 @@
1
+ export type SettleRelease<Values extends ReadonlyArray<unknown>> = {
2
+ [Index in keyof Values]?: (value: Values[Index]) => void;
3
+ };
4
+ /**
5
+ * Waits for every task, then throws the first rejection in task order. Before throwing, runs
6
+ * `release` on each fulfilled value, so a secret from a fulfilled sibling is still zeroed.
7
+ */
8
+ export declare function settleAll<Values extends ReadonlyArray<unknown>>(tasks: {
9
+ [Index in keyof Values]: Promise<Values[Index]>;
10
+ }, release?: SettleRelease<Values>): Promise<Values>;
11
+ /** The value of a settled task, or its rejection thrown. Lets the caller pick the throw order. */
12
+ export declare function settledValue<Value>(result: PromiseSettledResult<Value>): Value;
package/lib/settle.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Waits for every task, then throws the first rejection in task order. Before throwing, runs
3
+ * `release` on each fulfilled value, so a secret from a fulfilled sibling is still zeroed.
4
+ */ export async function settleAll(tasks, release) {
5
+ const settled = await Promise.allSettled(tasks);
6
+ const failure = settled.find((result)=>result.status === 'rejected');
7
+ if (failure === undefined) {
8
+ const values = settled.map((result)=>result.value);
9
+ return values;
10
+ }
11
+ const releases = release;
12
+ for (const [index, result] of settled.entries())if (result.status === 'fulfilled') releases?.[index]?.(result.value);
13
+ throw failure.reason;
14
+ }
15
+ /** The value of a settled task, or its rejection thrown. Lets the caller pick the throw order. */ export function settledValue(result) {
16
+ if (result.status === 'rejected') throw result.reason;
17
+ return result.value;
18
+ }
@@ -0,0 +1,42 @@
1
+ import type { Generated } from '@hozon/db';
2
+ import type { FactorDescriptor } from '@kokuin/vault';
3
+ export type VaultKeyTable = {
4
+ owner_did: string;
5
+ key_id: string;
6
+ suite: number;
7
+ version: number;
8
+ state: 'active' | 'retired';
9
+ mode: Generated<'local' | 'replicated'>;
10
+ };
11
+ export type VaultWrappingTable = {
12
+ owner_did: string;
13
+ wrapping_id: string;
14
+ key_id: string;
15
+ key_version: number;
16
+ descriptors: Array<FactorDescriptor>;
17
+ descriptor_digest: string;
18
+ iv: Uint8Array;
19
+ wrapped_key: Uint8Array;
20
+ recipient_did: string | null;
21
+ provenance: string;
22
+ mode: Generated<'local' | 'replicated'>;
23
+ };
24
+ export type VaultEntryTable = {
25
+ owner_did: string;
26
+ entry_id: string;
27
+ key_id: string;
28
+ key_version: number;
29
+ kind: string;
30
+ label: string;
31
+ attributes: Record<string, string>;
32
+ iv: Uint8Array;
33
+ ciphertext: Uint8Array;
34
+ hlc: string;
35
+ provenance: string;
36
+ mode: Generated<'local' | 'replicated'>;
37
+ };
38
+ export type VaultStoreTables = {
39
+ vault_keys: VaultKeyTable;
40
+ vault_wrappings: VaultWrappingTable;
41
+ vault_entries: VaultEntryTable;
42
+ };
package/lib/tables.js ADDED
@@ -0,0 +1 @@
1
+ export { };
package/lib/types.d.ts ADDED
@@ -0,0 +1,74 @@
1
+ import type { EncryptEntryResult, VaultEntryMetadata, WrapResult } from '@kokuin/vault';
2
+ export type KeyAddress = {
3
+ ownerDID: string;
4
+ keyID: string;
5
+ };
6
+ export type EntryAddress = {
7
+ ownerDID: string;
8
+ entryID: string;
9
+ };
10
+ export type WrappingAddress = {
11
+ ownerDID: string;
12
+ wrappingID: string;
13
+ };
14
+ export type KeyRow = KeyAddress & {
15
+ suite: number;
16
+ version: number;
17
+ state: 'active' | 'retired';
18
+ };
19
+ export type EntryMetadata = VaultEntryMetadata;
20
+ export type WrappingRow = KeyAddress & WrapResult & WrappingAddress & {
21
+ keyVersion: number;
22
+ recipientDID: string | null;
23
+ provenance: string;
24
+ };
25
+ export type EntryRow = KeyAddress & EntryAddress & EntryMetadata & EncryptEntryResult & {
26
+ keyVersion: number;
27
+ hlc: string;
28
+ provenance: string;
29
+ };
30
+ export type EntryFilter = KeyAddress & Partial<EntryMetadata>;
31
+ export type UpdateEntryResult = 'updated' | 'absent' | 'superseded';
32
+ export type VaultStoreAPI = {
33
+ isTransaction: boolean;
34
+ transaction<Result>(fn: (store: VaultStoreAPI) => Promise<Result>): Promise<Result>;
35
+ /** Declare the complete key set before authoritative reads. Requires a transaction. */
36
+ lockKeyRows(addresses: ReadonlyArray<KeyAddress>): Promise<void>;
37
+ lockKeyRow(address: KeyAddress): Promise<void>;
38
+ putKey(row: KeyRow): Promise<void>;
39
+ getKey(address: KeyAddress): Promise<KeyRow | undefined>;
40
+ listKeys(ownerDID: string): Promise<Array<KeyRow>>;
41
+ setKeyState(row: KeyAddress & Pick<KeyRow, 'state'>): Promise<void>;
42
+ setKeyVersion(row: KeyAddress & Pick<KeyRow, 'version'>): Promise<void>;
43
+ deleteKey(address: KeyAddress): Promise<void>;
44
+ putWrapping(row: WrappingRow): Promise<void>;
45
+ /** As `putWrapping` for each row, in batched statements. A later row for one address wins. */
46
+ putWrappings(rows: ReadonlyArray<WrappingRow>): Promise<void>;
47
+ getWrapping(address: WrappingAddress): Promise<WrappingRow | undefined>;
48
+ listWrappings(address: KeyAddress & {
49
+ keyVersion?: number;
50
+ }): Promise<Array<WrappingRow>>;
51
+ deleteWrapping(address: WrappingAddress): Promise<void>;
52
+ /** As `deleteWrapping` for each address, in batched statements. */
53
+ deleteWrappings(addresses: ReadonlyArray<WrappingAddress>): Promise<void>;
54
+ deleteWrappingsBelowVersion(address: KeyAddress & {
55
+ keyVersion: number;
56
+ }): Promise<void>;
57
+ putEntry(row: EntryRow): Promise<void>;
58
+ /** As `putEntry` for each row, in batched statements. A later row for one address wins. */
59
+ putEntries(rows: ReadonlyArray<EntryRow>): Promise<void>;
60
+ /** Stores caller-encrypted bytes. Does nothing if the observed source disappears or changes key. */
61
+ moveEntry(row: EntryRow): Promise<void>;
62
+ updateEntryIfPresent(row: EntryRow): Promise<'updated' | 'absent'>;
63
+ updateEntryIfUnchanged(row: EntryRow & {
64
+ expectedHLC: string;
65
+ }): Promise<UpdateEntryResult>;
66
+ getEntry(address: EntryAddress): Promise<EntryRow | undefined>;
67
+ getEntryWithProvenance(address: EntryAddress): Promise<EntryRow | undefined>;
68
+ listEntryOwners(entryID: string): Promise<Array<string>>;
69
+ listEntries(filter: EntryFilter): Promise<Array<EntryRow>>;
70
+ deleteEntry(address: EntryAddress): Promise<void>;
71
+ /** As `deleteEntry` for each address, in batched statements. */
72
+ deleteEntries(addresses: ReadonlyArray<EntryAddress>): Promise<void>;
73
+ getMaxEntryHLC(): Promise<string | null>;
74
+ };
package/lib/types.js ADDED
@@ -0,0 +1 @@
1
+ export { };
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@kokuin/store-vault",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Hozon-backed local vault storage",
5
+ "keywords": [
6
+ "vault",
7
+ "store"
8
+ ],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/TairuFramework/kokuin",
12
+ "directory": "packages/store-vault"
13
+ },
14
+ "license": "MIT",
15
+ "sideEffects": false,
16
+ "type": "module",
17
+ "exports": {
18
+ ".": "./lib/index.js"
19
+ },
20
+ "main": "lib/index.js",
21
+ "types": "lib/index.d.ts",
22
+ "files": [
23
+ "lib/*"
24
+ ],
25
+ "dependencies": {
26
+ "@hozon/adapter": "^0.2.1",
27
+ "@hozon/db": "^0.5.0",
28
+ "@kokuin/records": "^0.2.0",
29
+ "@kokuin/vault": "^0.1.0"
30
+ },
31
+ "devDependencies": {
32
+ "@hozon/node-sqlite": "^0.2.0",
33
+ "@types/node": "^26.6.5",
34
+ "kysely": "^0.29.7"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
41
+ "build:clean": "del-cli lib",
42
+ "build:js": "del-cli 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
43
+ "build:types": "del-cli 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
44
+ "test": "pnpm run test:types && pnpm run test:unit",
45
+ "test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
46
+ "test:unit": "vitest run"
47
+ }
6
48
  }