@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.
- package/README.md +126 -2
- package/lib/api.d.ts +5 -0
- package/lib/api.js +504 -0
- package/lib/batch.d.ts +89 -0
- package/lib/batch.js +159 -0
- package/lib/coordinator-context.d.ts +71 -0
- package/lib/coordinator-context.js +410 -0
- package/lib/coordinator-entries.d.ts +20 -0
- package/lib/coordinator-entries.js +258 -0
- package/lib/coordinator-open.d.ts +9 -0
- package/lib/coordinator-open.js +141 -0
- package/lib/coordinator-records.d.ts +27 -0
- package/lib/coordinator-records.js +132 -0
- package/lib/coordinator-rotate.d.ts +9 -0
- package/lib/coordinator-rotate.js +436 -0
- package/lib/coordinator-types.d.ts +139 -0
- package/lib/coordinator-types.js +1 -0
- package/lib/coordinator.d.ts +2 -0
- package/lib/coordinator.js +459 -0
- package/lib/definition.d.ts +6 -0
- package/lib/definition.js +12 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.js +7 -0
- package/lib/migrations.d.ts +2 -0
- package/lib/migrations.js +52 -0
- package/lib/record-context.d.ts +24 -0
- package/lib/record-context.js +34 -0
- package/lib/record-definition.d.ts +7 -0
- package/lib/record-definition.js +93 -0
- package/lib/record-tables.d.ts +47 -0
- package/lib/record-tables.js +1 -0
- package/lib/records-adapter.d.ts +3 -0
- package/lib/records-adapter.js +197 -0
- package/lib/records-ports.d.ts +4 -0
- package/lib/records-ports.js +72 -0
- package/lib/records-projection.d.ts +3 -0
- package/lib/records-projection.js +105 -0
- package/lib/records-rows.d.ts +54 -0
- package/lib/records-rows.js +155 -0
- package/lib/records.d.ts +59 -0
- package/lib/records.js +233 -0
- package/lib/settle.d.ts +12 -0
- package/lib/settle.js +18 -0
- package/lib/tables.d.ts +42 -0
- package/lib/tables.js +1 -0
- package/lib/types.d.ts +74 -0
- package/lib/types.js +1 -0
- package/package.json +45 -3
package/README.md
CHANGED
|
@@ -1,3 +1,127 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @kokuin/store-vault
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Local vault persistence for hozon. Register `vaultStoreDefinition`, then call `getVaultStore(provider)`.
|
|
4
|
+
All addresses require an explicit owner DID. Owners and recipients round-trip verbatim.
|
|
5
|
+
|
|
6
|
+
Keys contain public suite, version and state metadata. Wrappings and entries contain opaque encrypted bytes.
|
|
7
|
+
Entry `kind`, `label` and string attributes remain public. Filters combine exact kind/label equality with conjunctive attribute-subset matching.
|
|
8
|
+
|
|
9
|
+
Ordinary key puts accept equal or greater versions. Entry puts replace bytes without comparing HLC and refuse key reassociation.
|
|
10
|
+
`moveEntry` stores caller-encrypted destination bytes while preserving the owner-qualified entry ID.
|
|
11
|
+
It does nothing when the observed source disappears or changes key before locking.
|
|
12
|
+
Conditional updates never insert or move rows. HLC CAS returns `updated`, `absent` or `superseded`.
|
|
13
|
+
The caller supplies all encryption, authorisation and HLC values.
|
|
14
|
+
|
|
15
|
+
`transaction` joins an enclosing host transaction. Obtain the store from the host's transaction provider for journal/outbox atomicity.
|
|
16
|
+
Explicit key locks require a transaction. Declare the complete key set before authoritative reads when touching multiple keys.
|
|
17
|
+
Writes use hozon key locks, including absent keys. Multi-key locking orders actual advisory IDs through hozon.
|
|
18
|
+
Generic reads never acquire write locks or change rows.
|
|
19
|
+
|
|
20
|
+
The shared lock namespace uses table `vault_keys` and tuple `[JSON.stringify([ownerDID, keyID])]`.
|
|
21
|
+
This maps the database-free vault record lock `{ type: 'vault.key', key: JSON.stringify([ownerDID, keyID]) }`.
|
|
22
|
+
Replicated adapters must reuse this mapping.
|
|
23
|
+
|
|
24
|
+
The base schema contains only keys, wrappings and entries. Each row has a `local` or `replicated` mode.
|
|
25
|
+
The package supplies coordinators. The consumer supplies transport.
|
|
26
|
+
|
|
27
|
+
## Optional records
|
|
28
|
+
|
|
29
|
+
Register `vaultRecordsStoreDefinition` alongside the base definition, then initialise it with
|
|
30
|
+
`await getVaultRecordsStore(provider, opKind)` before opening caller transactions. It depends on `vault`.
|
|
31
|
+
The base registration alone never creates records, decisions, candidates, branch proofs or revocations.
|
|
32
|
+
|
|
33
|
+
`createVaultRecords({ store, opKind, authority, validateConsumerMetadata, nowMS, maxDriftMS })`
|
|
34
|
+
creates a facade over the initialised store. The operation kind has no default.
|
|
35
|
+
Initialisation binds the database permanently to that kind without taking vault key locks.
|
|
36
|
+
Supplying a kind inside a caller transaction throws. Omit it only when retrieving already initialised storage.
|
|
37
|
+
Apply only reads and compares the binding. Reads and writes refuse an absent or different durable kind.
|
|
38
|
+
|
|
39
|
+
Use `apply(records, transportContext)` for receipt. Use `write({ records, preconditions })` for signed local output.
|
|
40
|
+
Both run `@kokuin/records` apply. There is no direct signed put method.
|
|
41
|
+
`preconditions.key` compares the expected version, including `null` for absence, and an optional complete branch set.
|
|
42
|
+
`preconditions.entry` compares an entry's exact HLC, including `null` for absence.
|
|
43
|
+
Every output must be covered by the named key or entry. Preconditions and apply share the transaction and key locks.
|
|
44
|
+
Obtain the base store from the caller's transaction provider to join journal/outbox writes.
|
|
45
|
+
An error requires the caller to roll back that transaction.
|
|
46
|
+
|
|
47
|
+
The `adapter` implements `RecordStoreAdapter` for transport reconciliation and routed apply.
|
|
48
|
+
Its `putCopy` and `pruneCopy` are trusted engine persistence ports, requiring an adapter transaction.
|
|
49
|
+
Never submit untrusted signed records directly to those ports.
|
|
50
|
+
`vaultRecordPortsFor(adapter)` resolves transaction-bound definition effects, including through `adapterForType` routing.
|
|
51
|
+
Authority and signed consumer metadata validation always come from the caller.
|
|
52
|
+
Transport and membership policy remain consumer-owned.
|
|
53
|
+
|
|
54
|
+
Local mutation methods refuse replicated addresses with `VaultModeConflict`.
|
|
55
|
+
Projections refuse local addresses, and their entire apply transaction rolls back.
|
|
56
|
+
Mode reservations survive signed tombstones, suppressed wrappings and local physical deletion after optional registration.
|
|
57
|
+
Entry mode is independent of the parent key. There is no adoption or unrevocation API.
|
|
58
|
+
|
|
59
|
+
Branch reads return verified introducing operations. Wrappings and entries carry their signed branch as `provenance`.
|
|
60
|
+
Candidate reads retain received authorised entry losers and prior tombstones.
|
|
61
|
+
Exchange lists selected decisions and their signed copies, rather than all candidate history.
|
|
62
|
+
Revocations permanently suppress recipient and DID-factor wrappings across versions.
|
|
63
|
+
A survivor's same-decision endorsement can replace a removed signer's copy without losing content.
|
|
64
|
+
|
|
65
|
+
`get` and `getMany` fail closed on unreadable decisions. `list` omits corrupt decisions and reports them through `onCorruptRow`.
|
|
66
|
+
Generic reads never repair data or write history. Range leaves and fingerprints use the records package's functions.
|
|
67
|
+
|
|
68
|
+
## Coordinators
|
|
69
|
+
|
|
70
|
+
`createVaultCoordinator(params)` adds authority, opening, key lifecycle, rotation, repair and encrypted entry operations.
|
|
71
|
+
Select `mode: 'local'` or `mode: 'replicated'` explicitly. Both require `CredentialAuthority`, a principal, runtime and `nextHLC`.
|
|
72
|
+
Supply an agreement identity, or `null` for secret-only opening. Replicated mode additionally requires a signer and records configuration.
|
|
73
|
+
Initialise the optional records store before constructing a replicated coordinator. Local coordinators need only the base store.
|
|
74
|
+
|
|
75
|
+
The store-vault `CredentialAuthority` accepts unsigned operations and the vault's existing verified record requests.
|
|
76
|
+
Unsigned requests use `source: 'local'` and include the selected mode, operation, verb, address and unchanged transport context.
|
|
77
|
+
This includes replicated reads. No fabricated signed operation represents a local request.
|
|
78
|
+
Record admission uses `source: 'record'`, verified fields and the transaction-bound records read port.
|
|
79
|
+
Every public operation calls authority, including cached opens, missing entries, listings, repair and explicit re-endorsement.
|
|
80
|
+
Reads use `read`. Persistent mutations use `administer`. Authority refusal stops the operation before database writes.
|
|
81
|
+
Consumer metadata and effective membership remain consumer decisions. There is no default authority.
|
|
82
|
+
|
|
83
|
+
`openKey` returns a private content-key buffer. The caller must erase that buffer after use.
|
|
84
|
+
`unlock`, `lock`, `lockAll` and `status` manage the optional memory cache.
|
|
85
|
+
Input-free opens share in-flight work. Input-bearing opens verify independently, even when a key is cached.
|
|
86
|
+
All PIN-bearing opening paths share one throttle. Locks invalidate pending cache warming.
|
|
87
|
+
Replicated opening selects authenticated branches by claim digest, then operation hash.
|
|
88
|
+
It passes the signed wrapping's descriptor digest to unwrap. Local rows provide no independent trusted descriptor digest.
|
|
89
|
+
|
|
90
|
+
`createKey` takes an explicit canonical owner DID and optional ordered wrapping factor sets.
|
|
91
|
+
The default wrapping addresses the agreement identity in its resolvable form.
|
|
92
|
+
`grant`, `addFactor` and `removeFactor` require administration and operate on the current version.
|
|
93
|
+
A historical `atVersion` on those mutations refuses. `setKeyState` and `deleteKey` change lifecycle metadata.
|
|
94
|
+
Replicated arbitration can refuse same-version reactivation after retirement. The coordinator reports that refusal as `VaultWriteConflict`.
|
|
95
|
+
|
|
96
|
+
Entry operations accept explicit owner-qualified addresses, byte plaintext and public kind, label and string attributes.
|
|
97
|
+
`putEntry` creates an absent address. Updates require an existing entry. HLC CAS returns updated, absent or superseded.
|
|
98
|
+
`reKeyEntry` authorises both keys, preserves the entry address, and re-encrypts under the destination key.
|
|
99
|
+
`getEntry`, `listEntries`, `listKeys`, `readEntry` and `openKey` never write or initiate recovery.
|
|
100
|
+
Returned entry rows include stored provenance. No entry ID has application-specific meaning.
|
|
101
|
+
|
|
102
|
+
`rotate` and `repairFork` require `excludedRecipients` and `reissue` arrays, including explicit empty arrays.
|
|
103
|
+
Replicated rotation carries generic plaintext and public metadata forward, preserving content intent unless an instruction supplies another `contentHLC`.
|
|
104
|
+
Local rotation stores a fresh entry HLC and ignores `contentHLC` and `consumer` instructions.
|
|
105
|
+
A reissue instruction names one entry and selects `preserve`, `replace`, `delete` or `skip`.
|
|
106
|
+
`replace` supplies new plaintext. In replicated mode, `consumer` optionally replaces signed opaque metadata.
|
|
107
|
+
`skip` leaves the old entry untouched. Duplicate or unknown entry instructions refuse the whole operation.
|
|
108
|
+
Replacement instructions target live entries. Tombstone preservation and explicit deletion require no plaintext.
|
|
109
|
+
|
|
110
|
+
Exclusions match explicit recipients and DID factors. Replicated exclusions also publish permanent recipient denials through apply.
|
|
111
|
+
Local exclusions delete existing matching wrappings. Local mode has no permanent revocation table.
|
|
112
|
+
Repair uses received authenticated candidates, prior tombstones and vault branch proofs. It never discovers removed members.
|
|
113
|
+
Repair returns rotated, not-needed or pending. Missing preservation factors leave the fork pending for an explicit retry.
|
|
114
|
+
Received candidate history has no all-peers completeness guarantee.
|
|
115
|
+
|
|
116
|
+
Replicated coordinators submit every signed output through `write` and apply with operation-specific preconditions.
|
|
117
|
+
Rotation checks key version and branch set. Lifecycle changes check key version. Entry writes check the entry HLC.
|
|
118
|
+
They refuse parked, rejected or losing output and roll back the operation. There is no direct signed put.
|
|
119
|
+
Retained wrapping factors and reissued entry projections are checked against signed evidence before they become new signed output.
|
|
120
|
+
`reendorse({ ownerDID, keyID, records })` re-signs only named stored decisions, including key tombstones, without changing their claims or payloads.
|
|
121
|
+
The configured signer represents the effective consumer member. The consumer decides which records require re-endorsement.
|
|
122
|
+
|
|
123
|
+
Mutations hold hozon's complete key-lock set through their joined transaction, including cryptographic work.
|
|
124
|
+
Create the coordinator from the host transaction's store when journal/outbox changes must commit together.
|
|
125
|
+
Propagate errors out of the host transaction so it rolls back. Temporary keys and decrypted plaintext are erased on success and failure.
|
|
126
|
+
`nextHLC` belongs to the host. It must provide canonical, increasing stamps and any required restart floor.
|
|
127
|
+
Crypto-only hosts continue to use `@kokuin/vault` directly.
|
package/lib/api.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Adapter } from '@hozon/adapter';
|
|
2
|
+
import { type Kysely, type StoreContext } from '@hozon/db';
|
|
3
|
+
import type { VaultStoreTables } from './tables.js';
|
|
4
|
+
import type { VaultStoreAPI } from './types.js';
|
|
5
|
+
export declare function createVaultStore(db: Kysely<VaultStoreTables>, adapter: Adapter, context: StoreContext): VaultStoreAPI;
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { sql, withKeyLock, withKeyLocks, withStoreTransaction } from '@hozon/db';
|
|
2
|
+
import { deleteEntryRows, deleteWrappingRows, ENTRY_COLUMNS, selectEntries, selectWrappings, statementChunks, upsertEntries, upsertWrappings, WRAPPING_COLUMNS } from './batch.js';
|
|
3
|
+
import { claimModes, VaultModeConflict, vaultDatabases } from './record-context.js';
|
|
4
|
+
import { getVaultRecordsStore } from './record-definition.js';
|
|
5
|
+
import { settleAll } from './settle.js';
|
|
6
|
+
const databases = new WeakMap();
|
|
7
|
+
async function transactionDatabase(provider) {
|
|
8
|
+
const store = await provider.getStore('vault');
|
|
9
|
+
const db = databases.get(store);
|
|
10
|
+
if (db == null) throw new Error('Vault store database is unavailable');
|
|
11
|
+
return db;
|
|
12
|
+
}
|
|
13
|
+
// The vault record lock's key is already framed as [owner, keyID]. Keep that identity intact.
|
|
14
|
+
function lockTuple(address) {
|
|
15
|
+
return [
|
|
16
|
+
JSON.stringify([
|
|
17
|
+
address.ownerDID,
|
|
18
|
+
address.keyID
|
|
19
|
+
])
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
function decodeJSON(value) {
|
|
23
|
+
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
24
|
+
}
|
|
25
|
+
function keyRow(row) {
|
|
26
|
+
return {
|
|
27
|
+
ownerDID: row.owner_did,
|
|
28
|
+
keyID: row.key_id,
|
|
29
|
+
suite: row.suite,
|
|
30
|
+
version: row.version,
|
|
31
|
+
state: row.state
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function entryRow(row) {
|
|
35
|
+
return {
|
|
36
|
+
ownerDID: row.owner_did,
|
|
37
|
+
entryID: row.entry_id,
|
|
38
|
+
keyID: row.key_id,
|
|
39
|
+
keyVersion: row.key_version,
|
|
40
|
+
kind: row.kind,
|
|
41
|
+
label: row.label,
|
|
42
|
+
attributes: decodeJSON(row.attributes),
|
|
43
|
+
iv: Uint8Array.from(row.iv),
|
|
44
|
+
ciphertext: Uint8Array.from(row.ciphertext),
|
|
45
|
+
hlc: row.hlc,
|
|
46
|
+
provenance: row.provenance
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function wrappingRow(row) {
|
|
50
|
+
return {
|
|
51
|
+
ownerDID: row.owner_did,
|
|
52
|
+
wrappingID: row.wrapping_id,
|
|
53
|
+
keyID: row.key_id,
|
|
54
|
+
keyVersion: row.key_version,
|
|
55
|
+
descriptors: decodeJSON(row.descriptors),
|
|
56
|
+
descriptorDigest: row.descriptor_digest,
|
|
57
|
+
iv: Uint8Array.from(row.iv),
|
|
58
|
+
wrappedKey: Uint8Array.from(row.wrapped_key),
|
|
59
|
+
recipientDID: row.recipient_did,
|
|
60
|
+
provenance: row.provenance
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function createVaultStore(db, adapter, context) {
|
|
64
|
+
const read = (fn)=>{
|
|
65
|
+
return withStoreTransaction(db, fn);
|
|
66
|
+
};
|
|
67
|
+
const locked = async (addresses, fn)=>{
|
|
68
|
+
const run = async (provider)=>{
|
|
69
|
+
return withStoreTransaction(await transactionDatabase(provider), fn);
|
|
70
|
+
};
|
|
71
|
+
const address = addresses[0];
|
|
72
|
+
if (addresses.length === 1 && address != null) {
|
|
73
|
+
return withKeyLock(context.provider, 'vault_keys', lockTuple(address), run);
|
|
74
|
+
}
|
|
75
|
+
return withKeyLocks(context.provider, 'vault_keys', addresses.map(lockTuple), run);
|
|
76
|
+
};
|
|
77
|
+
const localModes = async (trx, type, keys)=>{
|
|
78
|
+
if (keys.length === 0) return;
|
|
79
|
+
const table = type === 'vault.key' ? 'vault_keys' : type === 'vault.entry' ? 'vault_entries' : 'vault_wrappings';
|
|
80
|
+
const column = type === 'vault.key' ? 'key_id' : type === 'vault.entry' ? 'entry_id' : 'wrapping_id';
|
|
81
|
+
const rows = await settleAll(statementChunks(keys, 2).map((part)=>{
|
|
82
|
+
return trx.selectFrom(table).select('mode').where((eb)=>{
|
|
83
|
+
return eb.or(part.map((key)=>eb.and([
|
|
84
|
+
eb('owner_did', '=', key.owner),
|
|
85
|
+
eb(column, '=', key.id)
|
|
86
|
+
])));
|
|
87
|
+
}).execute();
|
|
88
|
+
}));
|
|
89
|
+
if (rows.flat().some((row)=>row.mode === 'replicated')) throw new VaultModeConflict();
|
|
90
|
+
if (context.provider.hasStore('vault-records')) {
|
|
91
|
+
await getVaultRecordsStore(context.provider);
|
|
92
|
+
await claimModes(trx, keys.map((key)=>({
|
|
93
|
+
type,
|
|
94
|
+
owner: key.owner,
|
|
95
|
+
id: key.id
|
|
96
|
+
})), 'local');
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const localMode = (trx, type, owner, id)=>{
|
|
100
|
+
return localModes(trx, type, [
|
|
101
|
+
{
|
|
102
|
+
owner,
|
|
103
|
+
id
|
|
104
|
+
}
|
|
105
|
+
]);
|
|
106
|
+
};
|
|
107
|
+
// One lock per key, however many rows name it.
|
|
108
|
+
const keysOf = (rows)=>{
|
|
109
|
+
return [
|
|
110
|
+
...new Map(rows.map((row)=>[
|
|
111
|
+
JSON.stringify([
|
|
112
|
+
row.ownerDID,
|
|
113
|
+
row.keyID
|
|
114
|
+
]),
|
|
115
|
+
{
|
|
116
|
+
ownerDID: row.ownerDID,
|
|
117
|
+
keyID: row.keyID
|
|
118
|
+
}
|
|
119
|
+
])).values()
|
|
120
|
+
];
|
|
121
|
+
};
|
|
122
|
+
// The last write of each address wins, as sequential writes would. One address written under
|
|
123
|
+
// two keys is reported, so the caller can fail as the second sequential write would, after the
|
|
124
|
+
// mode checks that write would have passed first.
|
|
125
|
+
const lastWrites = (rows, address)=>{
|
|
126
|
+
const unique = new Map();
|
|
127
|
+
let rekeyed = false;
|
|
128
|
+
for (const row of rows){
|
|
129
|
+
const id = address(row);
|
|
130
|
+
const previous = unique.get(id);
|
|
131
|
+
if (previous !== undefined && previous.keyID !== row.keyID) rekeyed = true;
|
|
132
|
+
unique.delete(id);
|
|
133
|
+
unique.set(id, row);
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
rows: [
|
|
137
|
+
...unique.values()
|
|
138
|
+
],
|
|
139
|
+
rekeyed
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
const wrappingValues = (row)=>{
|
|
143
|
+
return {
|
|
144
|
+
owner_did: row.ownerDID,
|
|
145
|
+
wrapping_id: row.wrappingID,
|
|
146
|
+
key_id: row.keyID,
|
|
147
|
+
key_version: row.keyVersion,
|
|
148
|
+
descriptors: adapter.encodeJSON(row.descriptors),
|
|
149
|
+
descriptor_digest: row.descriptorDigest,
|
|
150
|
+
iv: adapter.encodeBinary(row.iv),
|
|
151
|
+
wrapped_key: adapter.encodeBinary(row.wrappedKey),
|
|
152
|
+
recipient_did: row.recipientDID,
|
|
153
|
+
provenance: row.provenance
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
const written = (results)=>{
|
|
157
|
+
return results.reduce((sum, result)=>sum + (result.numInsertedOrUpdatedRows ?? 0n), 0n);
|
|
158
|
+
};
|
|
159
|
+
const selectEntry = (trx, address)=>{
|
|
160
|
+
return trx.selectFrom('vault_entries').selectAll().where('owner_did', '=', address.ownerDID).where('entry_id', '=', address.entryID);
|
|
161
|
+
};
|
|
162
|
+
const entryValues = (row)=>{
|
|
163
|
+
return {
|
|
164
|
+
owner_did: row.ownerDID,
|
|
165
|
+
entry_id: row.entryID,
|
|
166
|
+
key_id: row.keyID,
|
|
167
|
+
key_version: row.keyVersion,
|
|
168
|
+
kind: row.kind,
|
|
169
|
+
label: row.label,
|
|
170
|
+
attributes: adapter.encodeJSON(row.attributes),
|
|
171
|
+
iv: adapter.encodeBinary(row.iv),
|
|
172
|
+
ciphertext: adapter.encodeBinary(row.ciphertext),
|
|
173
|
+
hlc: row.hlc,
|
|
174
|
+
provenance: row.provenance
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
const updateEntry = async (row, expectedHLC)=>{
|
|
178
|
+
return locked([
|
|
179
|
+
row
|
|
180
|
+
], async (trx)=>{
|
|
181
|
+
await localMode(trx, 'vault.entry', row.ownerDID, row.entryID);
|
|
182
|
+
let update = trx.updateTable('vault_entries').set(entryValues(row)).where('owner_did', '=', row.ownerDID).where('entry_id', '=', row.entryID).where('key_id', '=', row.keyID);
|
|
183
|
+
if (expectedHLC !== undefined) update = update.where('hlc', '=', expectedHLC);
|
|
184
|
+
const result = await update.executeTakeFirst();
|
|
185
|
+
if (result.numUpdatedRows > 0n) return 'updated';
|
|
186
|
+
if (expectedHLC !== undefined) {
|
|
187
|
+
const existing = await selectEntry(trx, row).where('key_id', '=', row.keyID).executeTakeFirst();
|
|
188
|
+
if (existing != null && existing.hlc !== expectedHLC) return 'superseded';
|
|
189
|
+
}
|
|
190
|
+
return 'absent';
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
const api = {
|
|
194
|
+
isTransaction: db.isTransaction,
|
|
195
|
+
async transaction (fn) {
|
|
196
|
+
return context.provider.withTransaction(async (tx)=>{
|
|
197
|
+
const store = await tx.getStore('vault');
|
|
198
|
+
return withStoreTransaction(await transactionDatabase(tx), async ()=>fn(store));
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
async lockKeyRow (address) {
|
|
202
|
+
if (!context.provider.isTransactionBound) throw new Error('Key locks require a transaction');
|
|
203
|
+
await locked([
|
|
204
|
+
address
|
|
205
|
+
], async ()=>{});
|
|
206
|
+
},
|
|
207
|
+
async lockKeyRows (addresses) {
|
|
208
|
+
if (!context.provider.isTransactionBound) throw new Error('Key locks require a transaction');
|
|
209
|
+
await locked(addresses, async ()=>{});
|
|
210
|
+
},
|
|
211
|
+
async putKey (row) {
|
|
212
|
+
await locked([
|
|
213
|
+
row
|
|
214
|
+
], async (trx)=>{
|
|
215
|
+
await localMode(trx, 'vault.key', row.ownerDID, row.keyID);
|
|
216
|
+
const values = {
|
|
217
|
+
owner_did: row.ownerDID,
|
|
218
|
+
key_id: row.keyID,
|
|
219
|
+
suite: row.suite,
|
|
220
|
+
version: row.version,
|
|
221
|
+
state: row.state
|
|
222
|
+
};
|
|
223
|
+
await trx.insertInto('vault_keys').values(values).onConflict((oc)=>{
|
|
224
|
+
return oc.columns([
|
|
225
|
+
'owner_did',
|
|
226
|
+
'key_id'
|
|
227
|
+
]).doUpdateSet({
|
|
228
|
+
suite: row.suite,
|
|
229
|
+
version: row.version,
|
|
230
|
+
state: row.state
|
|
231
|
+
}).where('vault_keys.version', '<=', row.version);
|
|
232
|
+
}).execute();
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
async getKey (address) {
|
|
236
|
+
return read(async (trx)=>{
|
|
237
|
+
const row = await trx.selectFrom('vault_keys').selectAll().where('owner_did', '=', address.ownerDID).where('key_id', '=', address.keyID).executeTakeFirst();
|
|
238
|
+
return row == null ? undefined : keyRow(row);
|
|
239
|
+
});
|
|
240
|
+
},
|
|
241
|
+
async listKeys (ownerDID) {
|
|
242
|
+
return read(async (trx)=>{
|
|
243
|
+
const rows = await trx.selectFrom('vault_keys').selectAll().where('owner_did', '=', ownerDID).orderBy('key_id').execute();
|
|
244
|
+
return rows.map(keyRow);
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
async setKeyState (row) {
|
|
248
|
+
await locked([
|
|
249
|
+
row
|
|
250
|
+
], async (trx)=>{
|
|
251
|
+
await localMode(trx, 'vault.key', row.ownerDID, row.keyID);
|
|
252
|
+
await trx.updateTable('vault_keys').set({
|
|
253
|
+
state: row.state
|
|
254
|
+
}).where('owner_did', '=', row.ownerDID).where('key_id', '=', row.keyID).execute();
|
|
255
|
+
});
|
|
256
|
+
},
|
|
257
|
+
async setKeyVersion (row) {
|
|
258
|
+
await locked([
|
|
259
|
+
row
|
|
260
|
+
], async (trx)=>{
|
|
261
|
+
await localMode(trx, 'vault.key', row.ownerDID, row.keyID);
|
|
262
|
+
await trx.updateTable('vault_keys').set({
|
|
263
|
+
version: row.version
|
|
264
|
+
}).where('owner_did', '=', row.ownerDID).where('key_id', '=', row.keyID).where('version', '<=', row.version).execute();
|
|
265
|
+
});
|
|
266
|
+
},
|
|
267
|
+
async deleteKey (address) {
|
|
268
|
+
await locked([
|
|
269
|
+
address
|
|
270
|
+
], async (trx)=>{
|
|
271
|
+
await localMode(trx, 'vault.key', address.ownerDID, address.keyID);
|
|
272
|
+
await trx.deleteFrom('vault_keys').where('owner_did', '=', address.ownerDID).where('key_id', '=', address.keyID).execute();
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
async putWrapping (row) {
|
|
276
|
+
await api.putWrappings([
|
|
277
|
+
row
|
|
278
|
+
]);
|
|
279
|
+
},
|
|
280
|
+
async putWrappings (input) {
|
|
281
|
+
const { rows, rekeyed } = lastWrites(input, (row)=>JSON.stringify([
|
|
282
|
+
row.ownerDID,
|
|
283
|
+
row.wrappingID
|
|
284
|
+
]));
|
|
285
|
+
if (rows.length === 0) return;
|
|
286
|
+
await locked(keysOf(input), async (trx)=>{
|
|
287
|
+
await localModes(trx, 'vault.wrapping', rows.map((row)=>({
|
|
288
|
+
owner: row.ownerDID,
|
|
289
|
+
id: row.wrappingID
|
|
290
|
+
})));
|
|
291
|
+
if (rekeyed) throw new Error('Wrapping belongs to another key');
|
|
292
|
+
const keyOf = new Map(rows.map((row)=>[
|
|
293
|
+
JSON.stringify([
|
|
294
|
+
row.ownerDID,
|
|
295
|
+
row.wrappingID
|
|
296
|
+
]),
|
|
297
|
+
row.keyID
|
|
298
|
+
]));
|
|
299
|
+
const existing = await settleAll(statementChunks(rows, 2).map((part)=>{
|
|
300
|
+
return selectWrappings(trx, part.map((row)=>({
|
|
301
|
+
owner_did: row.ownerDID,
|
|
302
|
+
wrapping_id: row.wrappingID
|
|
303
|
+
}))).execute();
|
|
304
|
+
}));
|
|
305
|
+
if (existing.flat().some((held)=>keyOf.get(JSON.stringify([
|
|
306
|
+
held.owner_did,
|
|
307
|
+
held.wrapping_id
|
|
308
|
+
])) !== held.key_id)) throw new Error('Wrapping belongs to another key');
|
|
309
|
+
const results = await settleAll(statementChunks(rows, WRAPPING_COLUMNS).map((part)=>{
|
|
310
|
+
return upsertWrappings(trx, part.map(wrappingValues)).executeTakeFirstOrThrow();
|
|
311
|
+
}));
|
|
312
|
+
if (written(results) !== BigInt(rows.length)) throw new Error('Wrapping belongs to another key');
|
|
313
|
+
});
|
|
314
|
+
},
|
|
315
|
+
async getWrapping (address) {
|
|
316
|
+
return read(async (trx)=>{
|
|
317
|
+
const row = await trx.selectFrom('vault_wrappings').selectAll().where('owner_did', '=', address.ownerDID).where('wrapping_id', '=', address.wrappingID).executeTakeFirst();
|
|
318
|
+
return row == null ? undefined : wrappingRow(row);
|
|
319
|
+
});
|
|
320
|
+
},
|
|
321
|
+
async listWrappings (address) {
|
|
322
|
+
return read(async (trx)=>{
|
|
323
|
+
let query = trx.selectFrom('vault_wrappings').selectAll().where('owner_did', '=', address.ownerDID).where('key_id', '=', address.keyID);
|
|
324
|
+
if (address.keyVersion !== undefined) query = query.where('key_version', '=', address.keyVersion);
|
|
325
|
+
return (await query.orderBy('wrapping_id').execute()).map(wrappingRow);
|
|
326
|
+
});
|
|
327
|
+
},
|
|
328
|
+
async deleteWrapping (address) {
|
|
329
|
+
await api.deleteWrappings([
|
|
330
|
+
address
|
|
331
|
+
]);
|
|
332
|
+
},
|
|
333
|
+
async deleteWrappings (addresses) {
|
|
334
|
+
if (addresses.length === 0) return;
|
|
335
|
+
// Discover each key before locking, then qualify deletion so a changed association survives.
|
|
336
|
+
const rows = (await read((trx)=>{
|
|
337
|
+
return settleAll(statementChunks(addresses, 2).map((part)=>{
|
|
338
|
+
return selectWrappings(trx, part.map((address)=>{
|
|
339
|
+
return {
|
|
340
|
+
owner_did: address.ownerDID,
|
|
341
|
+
wrapping_id: address.wrappingID
|
|
342
|
+
};
|
|
343
|
+
})).execute();
|
|
344
|
+
}));
|
|
345
|
+
})).flat();
|
|
346
|
+
if (rows.length === 0) return;
|
|
347
|
+
await locked(keysOf(rows.map((row)=>({
|
|
348
|
+
ownerDID: row.owner_did,
|
|
349
|
+
keyID: row.key_id
|
|
350
|
+
}))), async (trx)=>{
|
|
351
|
+
await localModes(trx, 'vault.wrapping', rows.map((row)=>({
|
|
352
|
+
owner: row.owner_did,
|
|
353
|
+
id: row.wrapping_id
|
|
354
|
+
})));
|
|
355
|
+
await settleAll(statementChunks(rows, 3).map((part)=>deleteWrappingRows(trx, part).execute()));
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
async deleteWrappingsBelowVersion (address) {
|
|
359
|
+
await locked([
|
|
360
|
+
address
|
|
361
|
+
], async (trx)=>{
|
|
362
|
+
const rows = await trx.selectFrom('vault_wrappings').selectAll().where('owner_did', '=', address.ownerDID).where('key_id', '=', address.keyID).where('key_version', '<', address.keyVersion).execute();
|
|
363
|
+
await localModes(trx, 'vault.wrapping', rows.map((row)=>({
|
|
364
|
+
owner: row.owner_did,
|
|
365
|
+
id: row.wrapping_id
|
|
366
|
+
})));
|
|
367
|
+
await trx.deleteFrom('vault_wrappings').where('owner_did', '=', address.ownerDID).where('key_id', '=', address.keyID).where('key_version', '<', address.keyVersion).execute();
|
|
368
|
+
});
|
|
369
|
+
},
|
|
370
|
+
async putEntry (row) {
|
|
371
|
+
await api.putEntries([
|
|
372
|
+
row
|
|
373
|
+
]);
|
|
374
|
+
},
|
|
375
|
+
async putEntries (input) {
|
|
376
|
+
const { rows, rekeyed } = lastWrites(input, (row)=>JSON.stringify([
|
|
377
|
+
row.ownerDID,
|
|
378
|
+
row.entryID
|
|
379
|
+
]));
|
|
380
|
+
if (rows.length === 0) return;
|
|
381
|
+
await locked(keysOf(input), async (trx)=>{
|
|
382
|
+
await localModes(trx, 'vault.entry', rows.map((row)=>({
|
|
383
|
+
owner: row.ownerDID,
|
|
384
|
+
id: row.entryID
|
|
385
|
+
})));
|
|
386
|
+
if (rekeyed) throw new Error('Entry belongs to another key');
|
|
387
|
+
const keyOf = new Map(rows.map((row)=>[
|
|
388
|
+
JSON.stringify([
|
|
389
|
+
row.ownerDID,
|
|
390
|
+
row.entryID
|
|
391
|
+
]),
|
|
392
|
+
row.keyID
|
|
393
|
+
]));
|
|
394
|
+
const existing = await settleAll(statementChunks(rows, 2).map((part)=>{
|
|
395
|
+
return selectEntries(trx, part.map((row)=>({
|
|
396
|
+
owner_did: row.ownerDID,
|
|
397
|
+
entry_id: row.entryID
|
|
398
|
+
}))).execute();
|
|
399
|
+
}));
|
|
400
|
+
if (existing.flat().some((held)=>keyOf.get(JSON.stringify([
|
|
401
|
+
held.owner_did,
|
|
402
|
+
held.entry_id
|
|
403
|
+
])) !== held.key_id)) throw new Error('Entry belongs to another key');
|
|
404
|
+
const results = await settleAll(statementChunks(rows, ENTRY_COLUMNS).map((part)=>{
|
|
405
|
+
return upsertEntries(trx, part.map(entryValues)).executeTakeFirstOrThrow();
|
|
406
|
+
}));
|
|
407
|
+
if (written(results) !== BigInt(rows.length)) throw new Error('Entry belongs to another key');
|
|
408
|
+
});
|
|
409
|
+
},
|
|
410
|
+
async moveEntry (row) {
|
|
411
|
+
const source = await api.getEntry(row);
|
|
412
|
+
if (source == null) return;
|
|
413
|
+
await locked([
|
|
414
|
+
source,
|
|
415
|
+
row
|
|
416
|
+
], async (trx)=>{
|
|
417
|
+
await localMode(trx, 'vault.entry', row.ownerDID, row.entryID);
|
|
418
|
+
// UPDATE-only plus the source predicate prevents resurrection and stale reassociation.
|
|
419
|
+
await trx.updateTable('vault_entries').set(entryValues(row)).where('owner_did', '=', row.ownerDID).where('entry_id', '=', row.entryID).where('key_id', '=', source.keyID).execute();
|
|
420
|
+
});
|
|
421
|
+
},
|
|
422
|
+
async updateEntryIfPresent (row) {
|
|
423
|
+
return await updateEntry(row);
|
|
424
|
+
},
|
|
425
|
+
async updateEntryIfUnchanged (row) {
|
|
426
|
+
return updateEntry(row, row.expectedHLC);
|
|
427
|
+
},
|
|
428
|
+
async getEntry (address) {
|
|
429
|
+
return read(async (trx)=>{
|
|
430
|
+
const row = await selectEntry(trx, address).executeTakeFirst();
|
|
431
|
+
return row == null ? undefined : entryRow(row);
|
|
432
|
+
});
|
|
433
|
+
},
|
|
434
|
+
async getEntryWithProvenance (address) {
|
|
435
|
+
return api.getEntry(address);
|
|
436
|
+
},
|
|
437
|
+
async listEntryOwners (entryID) {
|
|
438
|
+
return read(async (trx)=>{
|
|
439
|
+
const rows = await trx.selectFrom('vault_entries').select('owner_did').where('entry_id', '=', entryID).orderBy('owner_did').execute();
|
|
440
|
+
return rows.map((row)=>row.owner_did);
|
|
441
|
+
});
|
|
442
|
+
},
|
|
443
|
+
async listEntries (filter) {
|
|
444
|
+
return read(async (trx)=>{
|
|
445
|
+
let query = trx.selectFrom('vault_entries').selectAll().where('owner_did', '=', filter.ownerDID).where('key_id', '=', filter.keyID);
|
|
446
|
+
if (filter.kind !== undefined) query = query.where('kind', '=', filter.kind);
|
|
447
|
+
if (filter.label !== undefined) query = query.where('label', '=', filter.label);
|
|
448
|
+
if (filter.attributes !== undefined) {
|
|
449
|
+
// One JSON bind handles arbitrary attribute sets without dynamic paths or bind growth.
|
|
450
|
+
const attributes = JSON.stringify(filter.attributes);
|
|
451
|
+
const column = sql.ref('vault_entries.attributes');
|
|
452
|
+
query = query.where(adapter.kind === 'postgres' ? sql`${column} @> ${attributes}::text::jsonb` : sql`not exists (
|
|
453
|
+
select 1 from json_each(${attributes}) as wanted
|
|
454
|
+
where not exists (
|
|
455
|
+
select 1 from json_each(${column}) as held
|
|
456
|
+
where held.key = wanted.key and held.type = 'text' and held.value = wanted.value
|
|
457
|
+
)
|
|
458
|
+
)`);
|
|
459
|
+
}
|
|
460
|
+
return (await query.orderBy('entry_id').execute()).map(entryRow);
|
|
461
|
+
});
|
|
462
|
+
},
|
|
463
|
+
async deleteEntry (address) {
|
|
464
|
+
await api.deleteEntries([
|
|
465
|
+
address
|
|
466
|
+
]);
|
|
467
|
+
},
|
|
468
|
+
async deleteEntries (addresses) {
|
|
469
|
+
if (addresses.length === 0) return;
|
|
470
|
+
const rows = (await read((trx)=>{
|
|
471
|
+
return settleAll(statementChunks(addresses, 2).map((part)=>{
|
|
472
|
+
return selectEntries(trx, part.map((address)=>({
|
|
473
|
+
owner_did: address.ownerDID,
|
|
474
|
+
entry_id: address.entryID
|
|
475
|
+
}))).execute();
|
|
476
|
+
}));
|
|
477
|
+
})).flat();
|
|
478
|
+
if (rows.length === 0) return;
|
|
479
|
+
await locked(keysOf(rows.map((row)=>({
|
|
480
|
+
ownerDID: row.owner_did,
|
|
481
|
+
keyID: row.key_id
|
|
482
|
+
}))), async (trx)=>{
|
|
483
|
+
await localModes(trx, 'vault.entry', rows.map((row)=>({
|
|
484
|
+
owner: row.owner_did,
|
|
485
|
+
id: row.entry_id
|
|
486
|
+
})));
|
|
487
|
+
await settleAll(statementChunks(rows, 3).map((part)=>deleteEntryRows(trx, part).execute()));
|
|
488
|
+
});
|
|
489
|
+
},
|
|
490
|
+
async getMaxEntryHLC () {
|
|
491
|
+
return read(async (trx)=>{
|
|
492
|
+
const row = await trx.selectFrom('vault_entries').select((eb)=>eb.fn.max('hlc').as('max_hlc')).executeTakeFirst();
|
|
493
|
+
return row?.max_hlc ?? null;
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
vaultDatabases.set(api, {
|
|
498
|
+
db: db,
|
|
499
|
+
adapter,
|
|
500
|
+
context
|
|
501
|
+
});
|
|
502
|
+
databases.set(api, db);
|
|
503
|
+
return api;
|
|
504
|
+
}
|