@kubun/store-credential 0.17.4 → 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.
- package/lib/advisory-locks.d.ts +5 -0
- package/lib/advisory-locks.js +12 -0
- package/lib/api-aliases.d.ts +3 -0
- package/lib/api-aliases.js +58 -0
- package/lib/api-entries.d.ts +3 -0
- package/lib/api-entries.js +157 -0
- package/lib/api-keys.d.ts +3 -0
- package/lib/api-keys.js +249 -0
- package/lib/api-provisioning.d.ts +3 -0
- package/lib/api-provisioning.js +227 -0
- package/lib/api-records.d.ts +3 -0
- package/lib/api-records.js +74 -0
- package/lib/api-types.d.ts +412 -0
- package/lib/api-types.js +14 -0
- package/lib/api.d.ts +5 -448
- package/lib/api.js +24 -977
- package/lib/definition.js +3 -1
- package/lib/index.d.ts +3 -2
- package/lib/index.js +1 -0
- package/lib/migrations.js +62 -36
- package/lib/records.d.ts +206 -0
- package/lib/records.js +905 -0
- package/lib/store-context.d.ts +37 -0
- package/lib/store-context.js +139 -0
- package/lib/tables.d.ts +88 -64
- package/package.json +11 -8
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
export function createProvisioningApi(ctx) {
|
|
3
|
+
const { db, adapter, writeAmbient } = ctx;
|
|
4
|
+
return {
|
|
5
|
+
async nextGrantorSequence (params) {
|
|
6
|
+
// Atomic INSERT ... ON CONFLICT DO UPDATE increments in place, so two
|
|
7
|
+
// concurrent callers can't observe the same seq. Normalize the tuple
|
|
8
|
+
// HERE (not left to callers) so a did:peer:4 recipient's short/long
|
|
9
|
+
// forms converge on one counter instead of splitting into two.
|
|
10
|
+
const row = await db.insertInto('kubun_credential_grantor_counters').values({
|
|
11
|
+
owner_did: normalizeDID(params.ownerDID),
|
|
12
|
+
recipient_did: normalizeDID(params.recipientDID),
|
|
13
|
+
grantor_did: normalizeDID(params.grantorDID),
|
|
14
|
+
seq: 1
|
|
15
|
+
}).onConflict((oc)=>oc.columns([
|
|
16
|
+
'owner_did',
|
|
17
|
+
'recipient_did',
|
|
18
|
+
'grantor_did'
|
|
19
|
+
]).doUpdateSet((eb)=>({
|
|
20
|
+
seq: eb('kubun_credential_grantor_counters.seq', '+', 1)
|
|
21
|
+
}))).returning('seq').executeTakeFirstOrThrow();
|
|
22
|
+
return row.seq;
|
|
23
|
+
},
|
|
24
|
+
async nextProvisioningEpoch (params) {
|
|
25
|
+
// Atomic upsert to max(stored, minEpoch) + 1, guaranteeing monotonicity.
|
|
26
|
+
// Normalize the tuple HERE, same reasoning as `nextGrantorSequence`.
|
|
27
|
+
const ownerDid = normalizeDID(params.ownerDID);
|
|
28
|
+
const recipientDid = normalizeDID(params.recipientDID);
|
|
29
|
+
const grantorDid = normalizeDID(params.grantorDID);
|
|
30
|
+
const floor = Math.max(0, params.minEpoch);
|
|
31
|
+
const row = await db.insertInto('kubun_credential_provisioning_epochs').values({
|
|
32
|
+
owner_did: ownerDid,
|
|
33
|
+
recipient_did: recipientDid,
|
|
34
|
+
grantor_did: grantorDid,
|
|
35
|
+
epoch: floor + 1
|
|
36
|
+
}).onConflict((oc)=>oc.columns([
|
|
37
|
+
'owner_did',
|
|
38
|
+
'recipient_did',
|
|
39
|
+
'grantor_did'
|
|
40
|
+
]).doUpdateSet((eb)=>({
|
|
41
|
+
// max(stored, floor) + 1, expressed without a portable GREATEST:
|
|
42
|
+
epoch: eb.case().when('kubun_credential_provisioning_epochs.epoch', '<', floor).then(floor + 1).else(eb('kubun_credential_provisioning_epochs.epoch', '+', 1)).end()
|
|
43
|
+
}))).returning('epoch').executeTakeFirstOrThrow();
|
|
44
|
+
return row.epoch;
|
|
45
|
+
},
|
|
46
|
+
async putManifest (params) {
|
|
47
|
+
const { record, token, delegationTokens } = params;
|
|
48
|
+
const values = {
|
|
49
|
+
owner_did: normalizeDID(record.ownerDID),
|
|
50
|
+
recipient_did: normalizeDID(record.recipientDID),
|
|
51
|
+
grantor_did: normalizeDID(record.grantorDID),
|
|
52
|
+
epoch: record.epoch,
|
|
53
|
+
sequence: record.sequence,
|
|
54
|
+
keys_json: adapter.encodeJSON(record.keys),
|
|
55
|
+
manifest_token: token,
|
|
56
|
+
delegation_tokens: adapter.encodeJSON(delegationTokens ?? [])
|
|
57
|
+
};
|
|
58
|
+
await db.insertInto('kubun_credential_manifests').values(values).onConflict((oc)=>oc.columns([
|
|
59
|
+
'owner_did',
|
|
60
|
+
'recipient_did',
|
|
61
|
+
'grantor_did'
|
|
62
|
+
]).doUpdateSet({
|
|
63
|
+
epoch: values.epoch,
|
|
64
|
+
sequence: values.sequence,
|
|
65
|
+
keys_json: values.keys_json,
|
|
66
|
+
manifest_token: values.manifest_token,
|
|
67
|
+
delegation_tokens: values.delegation_tokens
|
|
68
|
+
})// Lexicographic: higher epoch always wins; within an epoch,
|
|
69
|
+
// strictly-greater sequence. A stale/replayed lower value is a silent no-op.
|
|
70
|
+
.where((eb)=>eb.or([
|
|
71
|
+
eb('kubun_credential_manifests.epoch', '<', values.epoch),
|
|
72
|
+
eb.and([
|
|
73
|
+
eb('kubun_credential_manifests.epoch', '=', values.epoch),
|
|
74
|
+
eb('kubun_credential_manifests.sequence', '<', values.sequence)
|
|
75
|
+
])
|
|
76
|
+
]))).execute();
|
|
77
|
+
},
|
|
78
|
+
async lockManifestRow (params) {
|
|
79
|
+
const owner = normalizeDID(params.ownerDID);
|
|
80
|
+
const recipient = normalizeDID(params.recipientDID);
|
|
81
|
+
const grantor = normalizeDID(params.grantorDID);
|
|
82
|
+
await writeAmbient(db, async (tx)=>{
|
|
83
|
+
await tx.updateTable('kubun_credential_manifests').set((eb)=>({
|
|
84
|
+
epoch: eb.ref('kubun_credential_manifests.epoch')
|
|
85
|
+
})).where('owner_did', '=', owner).where('recipient_did', '=', recipient).where('grantor_did', '=', grantor).execute();
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
async listManifests (params) {
|
|
89
|
+
const ownerDID = normalizeDID(params.ownerDID);
|
|
90
|
+
const recipientDID = normalizeDID(params.recipientDID);
|
|
91
|
+
const rows = await db.selectFrom('kubun_credential_manifests').select([
|
|
92
|
+
'owner_did',
|
|
93
|
+
'recipient_did',
|
|
94
|
+
'grantor_did',
|
|
95
|
+
'epoch',
|
|
96
|
+
'sequence',
|
|
97
|
+
'keys_json',
|
|
98
|
+
'manifest_token',
|
|
99
|
+
'delegation_tokens'
|
|
100
|
+
]).where('owner_did', '=', ownerDID).where('recipient_did', '=', recipientDID).orderBy('grantor_did', 'asc').execute();
|
|
101
|
+
return rows.map((row)=>({
|
|
102
|
+
record: {
|
|
103
|
+
ownerDID: row.owner_did,
|
|
104
|
+
recipientDID: row.recipient_did,
|
|
105
|
+
grantorDID: row.grantor_did,
|
|
106
|
+
epoch: row.epoch,
|
|
107
|
+
sequence: row.sequence,
|
|
108
|
+
keys: row.keys_json
|
|
109
|
+
},
|
|
110
|
+
token: row.manifest_token,
|
|
111
|
+
delegationTokens: row.delegation_tokens
|
|
112
|
+
}));
|
|
113
|
+
},
|
|
114
|
+
async listDistributableKeys (params) {
|
|
115
|
+
const owner = normalizeDID(params.ownerDID);
|
|
116
|
+
const grantor = normalizeDID(params.grantorDID);
|
|
117
|
+
// One snapshot: active owner keys joined to wrappings at the key's current version.
|
|
118
|
+
const rows = await db.selectFrom('kubun_credential_keys as k').innerJoin('kubun_credential_wrappings as w', (join)=>join.onRef('w.owner_did', '=', 'k.owner_did').onRef('w.key_id', '=', 'k.key_id').onRef('w.key_version', '=', 'k.version')).where('k.state', '=', 'active').select([
|
|
119
|
+
'k.key_id as keyID',
|
|
120
|
+
'k.version as version',
|
|
121
|
+
'k.owner_did as ownerDID',
|
|
122
|
+
'w.recipient_did as recipientDID'
|
|
123
|
+
]).execute();
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const row of rows){
|
|
127
|
+
if (normalizeDID(row.ownerDID) !== owner) continue;
|
|
128
|
+
if (row.recipientDID == null || normalizeDID(row.recipientDID) !== grantor) continue;
|
|
129
|
+
if (seen.has(row.keyID)) continue;
|
|
130
|
+
seen.add(row.keyID);
|
|
131
|
+
out.push({
|
|
132
|
+
keyID: row.keyID,
|
|
133
|
+
version: row.version
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
},
|
|
138
|
+
async beginProvisioningAttempt (params) {
|
|
139
|
+
const ownerDid = normalizeDID(params.ownerDID);
|
|
140
|
+
const grantorDid = normalizeDID(params.grantorDID);
|
|
141
|
+
await writeAmbient(db, async (tx)=>{
|
|
142
|
+
// Ensure the expectation floor row exists, idempotent at floor 0.
|
|
143
|
+
await tx.insertInto('kubun_credential_provisioning_expectations').values({
|
|
144
|
+
owner_did: ownerDid,
|
|
145
|
+
grantor_did: grantorDid,
|
|
146
|
+
epoch_floor: 0
|
|
147
|
+
}).onConflict((oc)=>oc.doNothing()).execute();
|
|
148
|
+
// Insert the outstanding attempt row, idempotent on the id.
|
|
149
|
+
await tx.insertInto('kubun_credential_provisioning_attempts').values({
|
|
150
|
+
owner_did: ownerDid,
|
|
151
|
+
grantor_did: grantorDid,
|
|
152
|
+
attempt_id: params.attemptID
|
|
153
|
+
}).onConflict((oc)=>oc.doNothing()).execute();
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
async recordProvisioningAttempt (params) {
|
|
157
|
+
const ownerDid = normalizeDID(params.ownerDID);
|
|
158
|
+
const grantorDid = normalizeDID(params.grantorDID);
|
|
159
|
+
// Single atomic operation: if epoch > heldEpoch, delete attempt and raise floor.
|
|
160
|
+
let result = {
|
|
161
|
+
status: 'retry',
|
|
162
|
+
minEpoch: params.heldEpoch
|
|
163
|
+
};
|
|
164
|
+
await writeAmbient(db, async (tx)=>{
|
|
165
|
+
if (params.epoch > params.heldEpoch) {
|
|
166
|
+
// Phantom-attempt guard: if the id names no live attempt, move
|
|
167
|
+
// nothing — raising the floor for it would wedge readiness forever.
|
|
168
|
+
const existing = await tx.selectFrom('kubun_credential_provisioning_attempts').select('attempt_id').where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).where('attempt_id', '=', params.attemptID).executeTakeFirst();
|
|
169
|
+
if (existing == null) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// Delete the attempt row.
|
|
173
|
+
await tx.deleteFrom('kubun_credential_provisioning_attempts').where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).where('attempt_id', '=', params.attemptID).execute();
|
|
174
|
+
// Raise the floor to max(stored, epoch).
|
|
175
|
+
await tx.updateTable('kubun_credential_provisioning_expectations').set((eb)=>({
|
|
176
|
+
epoch_floor: eb.case().when('epoch_floor', '<', params.epoch).then(params.epoch).else(eb.ref('epoch_floor')).end()
|
|
177
|
+
})).where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).execute();
|
|
178
|
+
result = {
|
|
179
|
+
status: 'recorded'
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
return result;
|
|
184
|
+
},
|
|
185
|
+
async abandonProvisioningAttempt (params) {
|
|
186
|
+
const ownerDid = normalizeDID(params.ownerDID);
|
|
187
|
+
const grantorDid = normalizeDID(params.grantorDID);
|
|
188
|
+
// Delete ONLY the attempt row, never the floor.
|
|
189
|
+
await db.deleteFrom('kubun_credential_provisioning_attempts').where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).where('attempt_id', '=', params.attemptID).execute();
|
|
190
|
+
},
|
|
191
|
+
async abandonProvisioningAttempts (params) {
|
|
192
|
+
const ownerDid = normalizeDID(params.ownerDID);
|
|
193
|
+
const grantorDid = normalizeDID(params.grantorDID);
|
|
194
|
+
// Delete every attempt row for the scope, never the floor.
|
|
195
|
+
await db.deleteFrom('kubun_credential_provisioning_attempts').where('owner_did', '=', ownerDid).where('grantor_did', '=', grantorDid).execute();
|
|
196
|
+
},
|
|
197
|
+
async listProvisioningExpectations (params) {
|
|
198
|
+
const ownerDid = normalizeDID(params.ownerDID);
|
|
199
|
+
// Left join expectations to outstanding attempts, grouped by (owner, grantor).
|
|
200
|
+
// Return one row per (owner, grantor) with floor and outstanding count.
|
|
201
|
+
const rows = await db.selectFrom('kubun_credential_provisioning_expectations as e').leftJoin('kubun_credential_provisioning_attempts as a', (join)=>join.onRef('a.owner_did', '=', 'e.owner_did').onRef('a.grantor_did', '=', 'e.grantor_did')).select((eb)=>[
|
|
202
|
+
'e.grantor_did as grantor_did',
|
|
203
|
+
'e.epoch_floor as epoch_floor',
|
|
204
|
+
eb.fn.count('a.attempt_id').as('outstanding')
|
|
205
|
+
]).where('e.owner_did', '=', ownerDid).groupBy([
|
|
206
|
+
'e.owner_did',
|
|
207
|
+
'e.grantor_did',
|
|
208
|
+
'e.epoch_floor'
|
|
209
|
+
]).orderBy('e.grantor_did', 'asc').execute();
|
|
210
|
+
return rows.map((row)=>({
|
|
211
|
+
grantorDID: row.grantor_did,
|
|
212
|
+
epochFloor: row.epoch_floor,
|
|
213
|
+
outstanding: Number(row.outstanding)
|
|
214
|
+
}));
|
|
215
|
+
},
|
|
216
|
+
async listProvisioningExpectationOwners () {
|
|
217
|
+
const rows = await db.selectFrom('kubun_credential_provisioning_expectations').select('owner_did').distinct().execute();
|
|
218
|
+
const owners = new Set();
|
|
219
|
+
for (const row of rows){
|
|
220
|
+
owners.add(normalizeDID(row.owner_did));
|
|
221
|
+
}
|
|
222
|
+
return [
|
|
223
|
+
...owners
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { CredentialStoreAPI } from './api-types.js';
|
|
2
|
+
import type { StoreContext } from './store-context.js';
|
|
3
|
+
export declare function createRecordsApi(ctx: StoreContext): Pick<CredentialStoreAPI, 'getRecordRow' | 'getRecordRows' | 'getRecordRowsForKeys' | 'deleteRecordCopy' | 'listRecordRows' | 'putRecord' | 'retainEntryCandidate' | 'listRevocations'>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { chunked } from '@kubun/db-adapter';
|
|
2
|
+
import { recordKeyText, verifySignedRecord } from '@kubun/records';
|
|
3
|
+
import { putCredentialRecord, retainCredentialEntryCandidate } from './records.js';
|
|
4
|
+
export function createRecordsApi(ctx) {
|
|
5
|
+
const { db, adapter, oneOwner, verifiedCopies, writeAmbient, lockKeyRowInTransaction } = ctx;
|
|
6
|
+
return {
|
|
7
|
+
async getRecordRow (type, id) {
|
|
8
|
+
return await db.selectFrom('kubun_credential_records').selectAll().where('type', '=', type).where('id', '=', id).executeTakeFirst();
|
|
9
|
+
},
|
|
10
|
+
async getRecordRows (key) {
|
|
11
|
+
return await db.selectFrom('kubun_credential_records').selectAll().where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id).orderBy('op_hash', 'asc').execute();
|
|
12
|
+
},
|
|
13
|
+
async getRecordRowsForKeys (keys) {
|
|
14
|
+
const wanted = new Set();
|
|
15
|
+
const idsByType = new Map();
|
|
16
|
+
for (const key of keys){
|
|
17
|
+
wanted.add(recordKeyText(key));
|
|
18
|
+
const ids = idsByType.get(key.type) ?? new Set();
|
|
19
|
+
ids.add(key.id);
|
|
20
|
+
idsByType.set(key.type, ids);
|
|
21
|
+
}
|
|
22
|
+
const rowsByKey = new Map();
|
|
23
|
+
for (const [type, idSet] of idsByType){
|
|
24
|
+
const ids = [
|
|
25
|
+
...idSet
|
|
26
|
+
];
|
|
27
|
+
for (const chunk of chunked(ids)){
|
|
28
|
+
const rows = await db.selectFrom('kubun_credential_records').selectAll().where('type', '=', type).where('id', 'in', chunk).orderBy('op_hash', 'asc').execute();
|
|
29
|
+
for (const row of rows){
|
|
30
|
+
const text = recordKeyText(row);
|
|
31
|
+
if (!wanted.has(text)) continue;
|
|
32
|
+
const group = rowsByKey.get(text) ?? [];
|
|
33
|
+
group.push(row);
|
|
34
|
+
rowsByKey.set(text, group);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return rowsByKey;
|
|
39
|
+
},
|
|
40
|
+
async deleteRecordCopy (key, opHash) {
|
|
41
|
+
await db.deleteFrom('kubun_credential_records').where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id).where('op_hash', '=', opHash).execute();
|
|
42
|
+
},
|
|
43
|
+
async listRecordRows (types) {
|
|
44
|
+
if (types.length === 0) return [];
|
|
45
|
+
return await db.selectFrom('kubun_credential_records').selectAll().where('type', 'in', [
|
|
46
|
+
...types
|
|
47
|
+
]).orderBy('type', 'asc').orderBy('owner', 'asc').orderBy('id', 'asc').orderBy('op_hash', 'asc').execute();
|
|
48
|
+
},
|
|
49
|
+
async putRecord (record, methods) {
|
|
50
|
+
await putCredentialRecord({
|
|
51
|
+
record,
|
|
52
|
+
methods,
|
|
53
|
+
verifiedCopies: db.isTransaction ? verifiedCopies : new Map(),
|
|
54
|
+
adapter,
|
|
55
|
+
write: (fn)=>writeAmbient(db, fn),
|
|
56
|
+
lockKey: lockKeyRowInTransaction
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
async retainEntryCandidate (record, methods) {
|
|
60
|
+
const op = await verifySignedRecord(record, methods);
|
|
61
|
+
if (op === null || record.type !== 'credential.entry') {
|
|
62
|
+
throw new Error('Cannot retain an unverified credential entry');
|
|
63
|
+
}
|
|
64
|
+
await writeAmbient(db, async (tx)=>{
|
|
65
|
+
await retainCredentialEntryCandidate(tx, record, op.claim, adapter);
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
async listRevocations (keyID, ownerDID) {
|
|
69
|
+
let query = db.selectFrom('kubun_credential_revocations').selectAll().where('key_id', '=', keyID);
|
|
70
|
+
if (ownerDID !== undefined) query = query.where('owner_did', '=', ownerDID);
|
|
71
|
+
return oneOwner(await query.orderBy('recipient_did', 'asc').execute(), `revocations ${keyID}`);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|