@kubun/credential 0.16.0 → 0.17.1

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/api.d.ts CHANGED
@@ -37,6 +37,19 @@ export type CredentialAPI = {
37
37
  credential: string;
38
38
  }) => Promise<void>;
39
39
  deleteCredential: (providerName: string, ownerDID: string) => Promise<void>;
40
+ /**
41
+ * Point `(providerName, ownerDID)` at an entry this device already holds,
42
+ * without minting anything.
43
+ *
44
+ * Gate B first: the entry's key must be owned by `ownerDID` (normalized on
45
+ * both sides), because the vault can decrypt any entry it holds a server
46
+ * wrapping for — decryptability is not authority. An idempotent retry that
47
+ * already points at `entryID` returns before that entry is ever decrypted, so
48
+ * a safe retry cannot fail on a branch/version check inside the read. Only
49
+ * then is the decrypted plaintext checked against the `Credential` shape, so a
50
+ * malformed or mistyped entry can never become a resolvable alias.
51
+ */
52
+ bindAlias: (providerName: string, ownerDID: string, entryID: string) => Promise<void>;
40
53
  /**
41
54
  * The normalized owner DIDs that currently hold a credential for `providerName`.
42
55
  *
@@ -54,6 +67,26 @@ export type CredentialAPI = {
54
67
  * agreement key is resolvable only through the controller resolver.
55
68
  */
56
69
  getControllerMethods: () => MethodRegistry;
70
+ /**
71
+ * Like `getCredential`, but pairs the serialized credential with the entryID
72
+ * it resolved through the alias — so a caller can then target that entry
73
+ * directly (e.g. `setCredentialForEntry`) without re-resolving the alias.
74
+ */
75
+ getCredentialRef: (providerName: string, ownerDID: string) => Promise<{
76
+ credential: string;
77
+ entryID: string;
78
+ } | null>;
79
+ /**
80
+ * Write to a specific entry, not the alias — the counterpart to
81
+ * `getCredentialRef`. Presence-gated on the entry itself (via
82
+ * `manager.updateEntryIfPresent`), so it never resurrects a deleted entry.
83
+ */
84
+ setCredentialForEntry: (params: {
85
+ providerName: string;
86
+ ownerDID: string;
87
+ entryID: string;
88
+ credential: string;
89
+ }) => Promise<'updated' | 'absent'>;
57
90
  };
58
91
  export type CreateCredentialAPIParams = {
59
92
  stores: StoreProvider;
package/lib/api.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
2
  import { getCredentialStore } from '@kubun/store-credential';
3
3
  import { fromUTF, toUTF } from '@sozai/codec';
4
- import { CredentialSupersededBranch } from './errors.js';
4
+ import { CredentialAliasAlreadyBound, CredentialBindNotAuthorized, CredentialEntryAlreadyAliased, CredentialInvalidEntry, CredentialNotFound, CredentialSupersededBranch } from './errors.js';
5
+ import { assertValidCredentialShape } from './validate.js';
5
6
  export function createCredentialAPI(params) {
6
7
  const getAliasStore = ()=>getCredentialStore(params.stores);
7
8
  const getCredentials = ()=>params.getCredentialManager(params.stores);
@@ -13,8 +14,13 @@ export function createCredentialAPI(params) {
13
14
  const store = await getAliasStore();
14
15
  const entryID = await store.getAliasEntryID(providerName, normalizeDID(ownerDID));
15
16
  if (entryID == null) return null;
17
+ // A dangling alias (its entry was deleted out from under it) resolves to null,
18
+ // not an error: a missing ENTRY is checked structurally here so a missing KEY
19
+ // inside readEntry still surfaces as CredentialNotFound below.
20
+ if (await store.getEntry(entryID) == null) return null;
16
21
  try {
17
- return toUTF(await (await getCredentials()).readEntry(entryID));
22
+ const credentials = await getCredentials();
23
+ return toUTF(await credentials.readEntry(entryID));
18
24
  } catch (error) {
19
25
  if (error instanceof CredentialSupersededBranch) {
20
26
  params.logger.warn('Credential entry {entryID} for provider {providerName} belongs to a superseded branch', {
@@ -44,7 +50,8 @@ export function createCredentialAPI(params) {
44
50
  // Opens the key through the server's own wrapping and rewrites the entry
45
51
  // under it. No key is minted and no wrapping is touched, so a refresh
46
52
  // cannot change who can read the credential.
47
- await (await getCredentials()).updateEntry(entryID, fromUTF(credential));
53
+ const credentials = await getCredentials();
54
+ await credentials.updateEntry(entryID, fromUTF(credential));
48
55
  },
49
56
  async createCredential (input) {
50
57
  const { providerName, ownerDID, ownerWrappableDID, credential } = input;
@@ -113,15 +120,100 @@ export function createCredentialAPI(params) {
113
120
  const store = await getAliasStore();
114
121
  const owner = normalizeDID(ownerDID);
115
122
  const entryID = await store.getAliasEntryID(providerName, owner);
116
- await store.deleteAliasEntryID(providerName, owner);
117
- if (entryID != null) {
118
- // The pointer and the entry go together. Leaving the entry would keep
119
- // the token readable by anyone who kept its id after a disconnect.
120
- await (await getCredentials()).deleteEntry(entryID);
123
+ // Entry first, THEN the pointer. This ordering keeps a concurrent bindAlias
124
+ // from leaving a dangling alias: once the entry is gone, bindAliasIfAbsent's
125
+ // entry-existence-conditioned insert refuses (there is no row to point at),
126
+ // so no new alias can be installed against the entry being removed. The brief
127
+ // window where the old pointer outlives its entry is covered by getCredential/
128
+ // getCredentialRef, which treat a missing entry as null. The pointer and the
129
+ // entry still go together — leaving the entry would keep the token readable by
130
+ // anyone who kept its id after a disconnect.
131
+ // No pointer to delete: there is nothing of ours to remove. An unconditional
132
+ // clear here would only drop an alias a concurrent bindAlias installed between
133
+ // the read above and now, so leave the slot untouched.
134
+ if (entryID == null) return;
135
+ const credentials = await getCredentials();
136
+ await credentials.deleteEntry(entryID);
137
+ // Clear the pointer ONLY while it still names the entry we removed. A concurrent
138
+ // bindAlias can repair a briefly-dangling pointer onto a fresh entry in the gap
139
+ // after deleteEntry; an unconditional delete would drop that valid new binding.
140
+ // If the pointer already moved, leave it.
141
+ await store.deleteAliasIfEntry(providerName, owner, entryID);
142
+ },
143
+ async bindAlias (providerName, ownerDID, entryID) {
144
+ const owner = normalizeDID(ownerDID);
145
+ const credentials = await getCredentials();
146
+ // Gate B: the acting subject must own the entry's key. The vault can
147
+ // decrypt any entry it holds a server wrapping for, so decryptability is
148
+ // not authority.
149
+ const keyOwner = await credentials.getEntryKeyOwnerDID(entryID);
150
+ if (keyOwner == null) {
151
+ throw new CredentialNotFound('Credential entry', entryID);
152
+ }
153
+ if (normalizeDID(keyOwner) !== owner) {
154
+ throw new CredentialBindNotAuthorized(providerName, ownerDID);
155
+ }
156
+ const store = await getAliasStore();
157
+ // Idempotent short-circuit BEFORE decrypt, so a safe retry cannot fail on
158
+ // a branch/version check inside readEntry.
159
+ if (await store.getAliasEntryID(providerName, owner) === entryID) {
160
+ return;
121
161
  }
162
+ // Validate the decrypted plaintext is actually a credential before it can
163
+ // ever be resolved back out through getCredential.
164
+ const bytes = await credentials.readEntry(entryID);
165
+ let parsed;
166
+ try {
167
+ parsed = JSON.parse(toUTF(bytes));
168
+ } catch (cause) {
169
+ throw new CredentialInvalidEntry('credential entry is not valid JSON', {
170
+ cause
171
+ });
172
+ }
173
+ assertValidCredentialShape(parsed);
174
+ const result = await store.bindAliasIfAbsent(providerName, owner, entryID);
175
+ if (result === 'inserted') return;
176
+ if (result === 'entry-missing') {
177
+ throw new CredentialNotFound('Credential entry', entryID);
178
+ }
179
+ if (result.conflict === 'alias') {
180
+ if (result.existing === entryID) return; // concurrent idempotent retry
181
+ throw new CredentialAliasAlreadyBound(providerName, ownerDID);
182
+ }
183
+ throw new CredentialEntryAlreadyAliased(providerName, entryID) // conflict: 'entry'
184
+ ;
122
185
  },
123
186
  getControllerMethods () {
124
187
  return params.getControllerMethods(params.stores);
188
+ },
189
+ async getCredentialRef (providerName, ownerDID) {
190
+ const store = await getAliasStore();
191
+ const entryID = await store.getAliasEntryID(providerName, normalizeDID(ownerDID));
192
+ if (entryID == null) return null;
193
+ // Dangling alias → null, checked structurally (see getCredential): a missing
194
+ // entry is not an error, a missing key inside readEntry still throws.
195
+ if (await store.getEntry(entryID) == null) return null;
196
+ try {
197
+ const credentials = await getCredentials();
198
+ const bytes = await credentials.readEntry(entryID);
199
+ return {
200
+ credential: toUTF(bytes),
201
+ entryID
202
+ };
203
+ } catch (error) {
204
+ if (error instanceof CredentialSupersededBranch) {
205
+ params.logger.warn('Credential entry {entryID} for provider {providerName} belongs to a superseded branch', {
206
+ providerName,
207
+ entryID
208
+ });
209
+ return null;
210
+ }
211
+ throw error;
212
+ }
213
+ },
214
+ async setCredentialForEntry (params) {
215
+ const credentials = await getCredentials();
216
+ return await credentials.updateEntryIfPresent(params.entryID, fromUTF(params.credential));
125
217
  }
126
218
  };
127
219
  }
package/lib/errors.d.ts CHANGED
@@ -6,7 +6,7 @@ import type { CredentialAuthorization } from './authority.js';
6
6
  * without deciding what a caller is meant to match on. Messages are for people
7
7
  * and change; these do not.
8
8
  */
9
- export type CredentialErrorCode = 'CREDENTIAL_ERROR' | 'CREDENTIAL_FACTOR_UNAVAILABLE' | 'CREDENTIAL_FACTORS_INVALID' | 'CREDENTIAL_KEY_LOCKED' | 'CREDENTIAL_KEY_VERSION_MISMATCH' | 'CREDENTIAL_NOT_AUTHORIZED' | 'CREDENTIAL_NOT_FOUND' | 'CREDENTIAL_PIN_REJECTED' | 'CREDENTIAL_SECRET_REJECTED' | 'CREDENTIAL_SUPERSEDED_BRANCH' | 'CREDENTIAL_SUITE_UNSUPPORTED' | 'CREDENTIAL_TAMPERED' | 'CREDENTIAL_UNOPENABLE' | 'CREDENTIAL_WRAPPING_AMBIGUOUS';
9
+ export type CredentialErrorCode = 'CREDENTIAL_ALIAS_ALREADY_BOUND' | 'CREDENTIAL_BIND_NOT_AUTHORIZED' | 'CREDENTIAL_ENTRY_ALREADY_ALIASED' | 'CREDENTIAL_ERROR' | 'CREDENTIAL_FACTOR_UNAVAILABLE' | 'CREDENTIAL_FACTORS_INVALID' | 'CREDENTIAL_INVALID_ENTRY' | 'CREDENTIAL_KEY_LOCKED' | 'CREDENTIAL_KEY_VERSION_MISMATCH' | 'CREDENTIAL_NOT_AUTHORIZED' | 'CREDENTIAL_NOT_FOUND' | 'CREDENTIAL_PIN_REJECTED' | 'CREDENTIAL_SECRET_REJECTED' | 'CREDENTIAL_SUPERSEDED_BRANCH' | 'CREDENTIAL_SUITE_UNSUPPORTED' | 'CREDENTIAL_TAMPERED' | 'CREDENTIAL_UNOPENABLE' | 'CREDENTIAL_WRAPPING_AMBIGUOUS';
10
10
  export type CredentialErrorOptions = ErrorOptions & {
11
11
  code: CredentialErrorCode;
12
12
  };
@@ -167,3 +167,52 @@ export declare class CredentialUnopenable extends CredentialError {
167
167
  constructor(keyID: string, reason: string);
168
168
  get keyID(): string;
169
169
  }
170
+ /**
171
+ * The caller is not the entry's key owner, so the alias bind is refused.
172
+ *
173
+ * Distinct from the existing {@link CredentialNotAuthorized}, which is a
174
+ * verb-scoped key-authority check (`'read' | 'administer'` against a keyID).
175
+ * This is an owner-identity check on a (providerName, ownerDID) pair ahead of
176
+ * the bind-alias seam's Gate B, not a key authority the device may hold —
177
+ * widening `CredentialAuthorization` to cover it would let this check feed
178
+ * `CredentialAuthority.authorize`, which it must not.
179
+ */
180
+ export declare class CredentialBindNotAuthorized extends CredentialError {
181
+ constructor(providerName: string, ownerDID: string);
182
+ }
183
+ /**
184
+ * A provider/owner alias already points at a different entry.
185
+ *
186
+ * `UNIQUE(namespace, alias_key)` in the alias table enforces this; the bind
187
+ * seam classifies the conflicting read-back into this error rather than
188
+ * silently overwriting the existing binding.
189
+ */
190
+ export declare class CredentialAliasAlreadyBound extends CredentialError {
191
+ #private;
192
+ constructor(providerName: string, ownerDID: string);
193
+ get providerName(): string;
194
+ get ownerDID(): string;
195
+ }
196
+ /**
197
+ * An entry already holds an alias, and this bind would give it a second one.
198
+ *
199
+ * `UNIQUE(entry_id)` in the alias table enforces one-alias-per-entry; the bind
200
+ * seam classifies the conflicting read-back into this error rather than
201
+ * attaching a second provider name to the same entry.
202
+ */
203
+ export declare class CredentialEntryAlreadyAliased extends CredentialError {
204
+ #private;
205
+ constructor(providerName: string, entryID: string);
206
+ get providerName(): string;
207
+ get entryID(): string;
208
+ }
209
+ /**
210
+ * Decrypted entry bytes do not match the `Credential` shape.
211
+ *
212
+ * Bind and refresh decrypt arbitrary stored bytes; without this check a
213
+ * malformed or mistyped entry (`{}`, a non-string `accessToken`) would be
214
+ * trusted as a credential instead of refused at the boundary.
215
+ */
216
+ export declare class CredentialInvalidEntry extends CredentialError {
217
+ constructor(message: string, options?: ErrorOptions);
218
+ }
package/lib/errors.js CHANGED
@@ -246,3 +246,83 @@
246
246
  return this.#keyID;
247
247
  }
248
248
  }
249
+ /**
250
+ * The caller is not the entry's key owner, so the alias bind is refused.
251
+ *
252
+ * Distinct from the existing {@link CredentialNotAuthorized}, which is a
253
+ * verb-scoped key-authority check (`'read' | 'administer'` against a keyID).
254
+ * This is an owner-identity check on a (providerName, ownerDID) pair ahead of
255
+ * the bind-alias seam's Gate B, not a key authority the device may hold —
256
+ * widening `CredentialAuthorization` to cover it would let this check feed
257
+ * `CredentialAuthority.authorize`, which it must not.
258
+ */ export class CredentialBindNotAuthorized extends CredentialError {
259
+ constructor(providerName, ownerDID){
260
+ super(`Not authorized to bind ${providerName} alias for ${ownerDID}`, {
261
+ code: 'CREDENTIAL_BIND_NOT_AUTHORIZED'
262
+ });
263
+ this.name = 'CredentialBindNotAuthorized';
264
+ }
265
+ }
266
+ /**
267
+ * A provider/owner alias already points at a different entry.
268
+ *
269
+ * `UNIQUE(namespace, alias_key)` in the alias table enforces this; the bind
270
+ * seam classifies the conflicting read-back into this error rather than
271
+ * silently overwriting the existing binding.
272
+ */ export class CredentialAliasAlreadyBound extends CredentialError {
273
+ #providerName;
274
+ #ownerDID;
275
+ constructor(providerName, ownerDID){
276
+ super(`Alias ${providerName} for ${ownerDID} is already bound to a different credential entry`, {
277
+ code: 'CREDENTIAL_ALIAS_ALREADY_BOUND'
278
+ });
279
+ this.name = 'CredentialAliasAlreadyBound';
280
+ this.#providerName = providerName;
281
+ this.#ownerDID = ownerDID;
282
+ }
283
+ get providerName() {
284
+ return this.#providerName;
285
+ }
286
+ get ownerDID() {
287
+ return this.#ownerDID;
288
+ }
289
+ }
290
+ /**
291
+ * An entry already holds an alias, and this bind would give it a second one.
292
+ *
293
+ * `UNIQUE(entry_id)` in the alias table enforces one-alias-per-entry; the bind
294
+ * seam classifies the conflicting read-back into this error rather than
295
+ * attaching a second provider name to the same entry.
296
+ */ export class CredentialEntryAlreadyAliased extends CredentialError {
297
+ #providerName;
298
+ #entryID;
299
+ constructor(providerName, entryID){
300
+ super(`Credential entry ${entryID} is already aliased; cannot bind it under ${providerName}`, {
301
+ code: 'CREDENTIAL_ENTRY_ALREADY_ALIASED'
302
+ });
303
+ this.name = 'CredentialEntryAlreadyAliased';
304
+ this.#providerName = providerName;
305
+ this.#entryID = entryID;
306
+ }
307
+ get providerName() {
308
+ return this.#providerName;
309
+ }
310
+ get entryID() {
311
+ return this.#entryID;
312
+ }
313
+ }
314
+ /**
315
+ * Decrypted entry bytes do not match the `Credential` shape.
316
+ *
317
+ * Bind and refresh decrypt arbitrary stored bytes; without this check a
318
+ * malformed or mistyped entry (`{}`, a non-string `accessToken`) would be
319
+ * trusted as a credential instead of refused at the boundary.
320
+ */ export class CredentialInvalidEntry extends CredentialError {
321
+ constructor(message, options){
322
+ super(message, {
323
+ ...options,
324
+ code: 'CREDENTIAL_INVALID_ENTRY'
325
+ });
326
+ this.name = 'CredentialInvalidEntry';
327
+ }
328
+ }
package/lib/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export { buildCredentialEntry, buildCredentialKey } from './builder.js';
7
7
  export type { CredentialEntryDigestInput, CredentialKeyDigestInput, CredentialWrappingDigestInput, } from './digest.js';
8
8
  export { credentialEntriesDigest, credentialEntryDigest, credentialKeyDigest, credentialWrappingDigest, digestParts, } from './digest.js';
9
9
  export type { CredentialErrorCode, CredentialErrorOptions } from './errors.js';
10
- export { AmbiguousCredentialWrapping, CredentialError, CredentialKeyLocked, CredentialKeyVersionMismatch, CredentialNotAuthorized, CredentialNotFound, CredentialPinRejected, CredentialSecretRejected, CredentialSupersededBranch, CredentialTampered, CredentialUnopenable, InvalidCredentialFactors, NoAvailableFactor, UnsupportedCredentialSuite, } from './errors.js';
10
+ export { AmbiguousCredentialWrapping, CredentialAliasAlreadyBound, CredentialBindNotAuthorized, CredentialEntryAlreadyAliased, CredentialError, CredentialInvalidEntry, CredentialKeyLocked, CredentialKeyVersionMismatch, CredentialNotAuthorized, CredentialNotFound, CredentialPinRejected, CredentialSecretRejected, CredentialSupersededBranch, CredentialTampered, CredentialUnopenable, InvalidCredentialFactors, NoAvailableFactor, UnsupportedCredentialSuite, } from './errors.js';
11
11
  export type { CreateCredentialManagerParams, CreateKeyParams, CredentialGrantResult, CredentialKeyStatus, CredentialManager, CredentialUnopenableReason, FactorChangeParams, GrantKeyParams, ReKeyEntryParams, RotateKeyParams, } from './manager.js';
12
12
  export { createCredentialManager } from './manager.js';
13
13
  export type { CredentialOpClaim, CredentialOpSigner, CredentialOpSubject, CredentialPutSubject, CredentialRowSubject, VerifiedCredentialOp, } from './op.js';
@@ -22,4 +22,5 @@ export type { RefreshEvents, RefreshedEvent, RefreshFailedEvent, RefreshFailureC
22
22
  export { RefreshingCredentialProvider } from './refreshing-credential-provider.js';
23
23
  export type { Argon2Cost, CredentialSuite, FactorInput, FactorInputs, SecretFactorKind, } from './suite.js';
24
24
  export { DEFAULT_ARGON2_COST, getSuite, isSupportedSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
25
+ export { assertValidCredentialShape } from './validate.js';
25
26
  export { CREDENTIAL_ADMINISTER_ACTION, credentialUserResource } from './vocabulary.js';
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@ export { createCredentialAPI } from './api.js';
2
2
  export { createDeviceAuthority } from './authority.js';
3
3
  export { buildCredentialEntry, buildCredentialKey } from './builder.js';
4
4
  export { credentialEntriesDigest, credentialEntryDigest, credentialKeyDigest, credentialWrappingDigest, digestParts } from './digest.js';
5
- export { AmbiguousCredentialWrapping, CredentialError, CredentialKeyLocked, CredentialKeyVersionMismatch, CredentialNotAuthorized, CredentialNotFound, CredentialPinRejected, CredentialSecretRejected, CredentialSupersededBranch, CredentialTampered, CredentialUnopenable, InvalidCredentialFactors, NoAvailableFactor, UnsupportedCredentialSuite } from './errors.js';
5
+ export { AmbiguousCredentialWrapping, CredentialAliasAlreadyBound, CredentialBindNotAuthorized, CredentialEntryAlreadyAliased, CredentialError, CredentialInvalidEntry, CredentialKeyLocked, CredentialKeyVersionMismatch, CredentialNotAuthorized, CredentialNotFound, CredentialPinRejected, CredentialSecretRejected, CredentialSupersededBranch, CredentialTampered, CredentialUnopenable, InvalidCredentialFactors, NoAvailableFactor, UnsupportedCredentialSuite } from './errors.js';
6
6
  export { createCredentialManager } from './manager.js';
7
7
  export { compareBranchIDs, createCredentialOpSigner, credentialOpMatchesPut, credentialOpMatchesRow, credentialPutDigest, credentialRowDigest, selectWinningBranch, verifyCredentialOp } from './op.js';
8
8
  export { createPinThrottle, DEFAULT_BASE_DELAY, DEFAULT_FREE_ATTEMPTS, DEFAULT_MAX_DELAY } from './pin-throttle.js';
@@ -10,4 +10,5 @@ export { entryProvenanceFields } from './provenance.js';
10
10
  export { DBCredentialProvider, deserializeCredential, serializeCredential } from './provider.js';
11
11
  export { RefreshingCredentialProvider } from './refreshing-credential-provider.js';
12
12
  export { DEFAULT_ARGON2_COST, getSuite, isSupportedSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
13
+ export { assertValidCredentialShape } from './validate.js';
13
14
  export { CREDENTIAL_ADMINISTER_ACTION, credentialUserResource } from './vocabulary.js';
package/lib/manager.d.ts CHANGED
@@ -257,8 +257,22 @@ export type CredentialManager = {
257
257
  deleteKey(keyID: string): Promise<void>;
258
258
  putEntry(keyID: string, plaintext: Uint8Array): Promise<string>;
259
259
  updateEntry(entryID: string, plaintext: Uint8Array): Promise<void>;
260
+ /**
261
+ * As {@link updateEntry}, but never inserts: an entry gone by the time the
262
+ * write lands reports `'absent'` instead of raising. For a writeback racing a
263
+ * concurrent delete, where the caller has no `keyID` to fall back to `putEntry`
264
+ * with and raising would just be a `catch` the caller re-swallows.
265
+ */
266
+ updateEntryIfPresent(entryID: string, plaintext: Uint8Array): Promise<'updated' | 'absent'>;
260
267
  readEntry(entryID: string): Promise<Uint8Array>;
261
268
  getEntryProvenance(entryID: string): Promise<CredentialEntryProvenance>;
269
+ /**
270
+ * The RAW `owner_did` off the entry's key row — never normalized. Callers
271
+ * comparing against another DID normalize it themselves; folding it here
272
+ * would make the answer wrong for whichever caller wanted the unfolded form.
273
+ * `null` when the entry, or the key it sits on, is absent.
274
+ */
275
+ getEntryKeyOwnerDID(entryID: string): Promise<string | null>;
262
276
  listEntries(keyID: string): Promise<Array<string>>;
263
277
  deleteEntry(entryID: string): Promise<void>;
264
278
  /**
package/lib/manager.js CHANGED
@@ -898,6 +898,43 @@ export function createCredentialManager(params) {
898
898
  };
899
899
  await store.putEntry(updated, await ops.entry(updated, branchID));
900
900
  },
901
+ async updateEntryIfPresent (entryID, plaintext) {
902
+ const existing = await store.getEntry(entryID);
903
+ if (existing == null) {
904
+ return 'absent';
905
+ }
906
+ const { contentKey, version, branchID } = await openKey(existing.key_id);
907
+ // Same re-encrypt-under-current-version shape as `updateEntry`: the suite
908
+ // lookup's store read stays inside the `try` so a rejection cannot leak the
909
+ // key copy.
910
+ let encrypted;
911
+ try {
912
+ const suite = getSuite((await store.getKey(existing.key_id))?.suite ?? SUITE_KUBUN_CRED_1);
913
+ encrypted = suite.encryptEntry({
914
+ runtime,
915
+ contentKey,
916
+ plaintext,
917
+ keyID: existing.key_id,
918
+ keyVersion: version,
919
+ entryID
920
+ });
921
+ } finally{
922
+ contentKey.fill(0);
923
+ }
924
+ const { iv, ciphertext } = encrypted;
925
+ const updated = {
926
+ entryID,
927
+ keyID: existing.key_id,
928
+ keyVersion: version,
929
+ iv,
930
+ ciphertext,
931
+ // A fresh stamp every time, not `existing.hlc` carried forward: the store
932
+ // primitive is op-first and gates the row write on this op winning LWW, so
933
+ // the writeback needs a current stamp to land at all.
934
+ hlc: await nextHLC()
935
+ };
936
+ return await store.updateEntryIfPresent(updated, await ops.entry(updated, branchID));
937
+ },
901
938
  async readEntry (entryID) {
902
939
  const entry = await store.getEntry(entryID);
903
940
  if (entry == null) {
@@ -943,6 +980,14 @@ export function createCredentialManager(params) {
943
980
  }
944
981
  return entryProvenanceFields(entry);
945
982
  },
983
+ async getEntryKeyOwnerDID (entryID) {
984
+ const entry = await store.getEntry(entryID);
985
+ if (entry == null) {
986
+ return null;
987
+ }
988
+ const key = await store.getKey(entry.key_id);
989
+ return key == null ? null : key.owner_did;
990
+ },
946
991
  async listEntries (keyID) {
947
992
  return (await store.listEntries(keyID)).map((entry)=>entry.entry_id);
948
993
  },
package/lib/provider.d.ts CHANGED
@@ -1,11 +1,11 @@
1
- import type { Credential, CredentialProvider } from '@kubun/credential-types';
1
+ import type { AliasBindingCredentialProvider, Credential, CredentialProvider, EntryPairedCredentialProvider } from '@kubun/credential-types';
2
2
  import type { CredentialAPI } from './api.js';
3
3
  export declare function serializeCredential(credential: Credential): string;
4
4
  export declare function deserializeCredential(raw: string | Record<string, unknown>): Credential;
5
5
  export type DBCredentialProviderParams = {
6
6
  api: CredentialAPI;
7
7
  };
8
- export declare class DBCredentialProvider implements CredentialProvider {
8
+ export declare class DBCredentialProvider implements CredentialProvider, AliasBindingCredentialProvider, EntryPairedCredentialProvider {
9
9
  #private;
10
10
  constructor(params: DBCredentialProviderParams);
11
11
  get(providerName: string, ownerDID: string): Promise<Credential | null>;
@@ -17,4 +17,15 @@ export declare class DBCredentialProvider implements CredentialProvider {
17
17
  controllerWrappableDID?: string;
18
18
  }): Promise<void>;
19
19
  delete(providerName: string, ownerDID: string): Promise<void>;
20
+ bindAlias(providerName: string, ownerDID: string, entryID: string): Promise<void>;
21
+ getCredentialRef(providerName: string, ownerDID: string): Promise<{
22
+ credential: Credential;
23
+ entryID: string;
24
+ } | null>;
25
+ setCredentialForEntry(params: {
26
+ providerName: string;
27
+ ownerDID: string;
28
+ entryID: string;
29
+ credential: Credential;
30
+ }): Promise<'updated' | 'absent'>;
20
31
  }
package/lib/provider.js CHANGED
@@ -72,4 +72,23 @@ export class DBCredentialProvider {
72
72
  async delete(providerName, ownerDID) {
73
73
  await this.#api.deleteCredential(providerName, ownerDID);
74
74
  }
75
+ bindAlias(providerName, ownerDID, entryID) {
76
+ return this.#api.bindAlias(providerName, ownerDID, entryID);
77
+ }
78
+ async getCredentialRef(providerName, ownerDID) {
79
+ const ref = await this.#api.getCredentialRef(providerName, ownerDID);
80
+ if (ref == null) return null;
81
+ return {
82
+ credential: deserializeCredential(ref.credential),
83
+ entryID: ref.entryID
84
+ };
85
+ }
86
+ async setCredentialForEntry(params) {
87
+ return await this.#api.setCredentialForEntry({
88
+ providerName: params.providerName,
89
+ ownerDID: params.ownerDID,
90
+ entryID: params.entryID,
91
+ credential: serializeCredential(params.credential)
92
+ });
93
+ }
75
94
  }
@@ -1,8 +1,9 @@
1
- import type { Credential, CredentialProvider, OAuthProviderDefinition } from '@kubun/credential-types';
1
+ import type { OAuthProviderDefinition } from '@kokuin/oauth';
2
+ import type { AliasBindingCredentialProvider, Credential, CredentialProvider, EntryPairedCredentialProvider } from '@kubun/credential-types';
2
3
  import type { Logger } from '@kubun/logger';
3
4
  import { EventEmitter } from '@sozai/event';
4
5
  import type { Runtime } from '@sozai/runtime';
5
- export type RefreshFailureCategory = 'invalid_grant' | 'transient' | 'configuration' | 'persistence';
6
+ export type RefreshFailureCategory = 'invalid_grant' | 'transient' | 'configuration' | 'persistence' | 'stale';
6
7
  export type RefreshedEvent = {
7
8
  providerName: string;
8
9
  ownerDID: string;
@@ -26,14 +27,14 @@ export type RefreshOutcome = {
26
27
  category: RefreshFailureCategory;
27
28
  };
28
29
  export type RefreshingCredentialProviderParams = {
29
- inner: CredentialProvider;
30
+ inner: CredentialProvider & EntryPairedCredentialProvider & AliasBindingCredentialProvider;
30
31
  runtime: Runtime;
31
32
  providers: Array<OAuthProviderDefinition>;
32
33
  bufferSeconds?: number;
33
34
  logger?: Logger;
34
35
  refreshInFlight?: Map<string, Promise<RefreshOutcome>>;
35
36
  };
36
- export declare class RefreshingCredentialProvider implements CredentialProvider {
37
+ export declare class RefreshingCredentialProvider implements CredentialProvider, AliasBindingCredentialProvider {
37
38
  #private;
38
39
  constructor(params: RefreshingCredentialProviderParams);
39
40
  get refreshEvents(): EventEmitter<RefreshEvents>;
@@ -47,4 +48,5 @@ export declare class RefreshingCredentialProvider implements CredentialProvider
47
48
  controllerWrappableDID?: string;
48
49
  }): Promise<void>;
49
50
  delete(providerName: string, ownerDID: string): Promise<void>;
51
+ bindAlias(providerName: string, ownerDID: string, entryID: string): Promise<void>;
50
52
  }
@@ -1,3 +1,4 @@
1
+ import { OAuthTokenError, refreshToken as requestRefreshedToken } from '@kokuin/oauth';
1
2
  import { normalizeDID } from '@kokuin/token';
2
3
  import { EventEmitter } from '@sozai/event';
3
4
  const DEFAULT_BUFFER_SECONDS = 300;
@@ -37,10 +38,11 @@ export class RefreshingCredentialProvider {
37
38
  return this.#inner.get(providerName, ownerDID);
38
39
  }
39
40
  async get(providerName, ownerDID) {
40
- const credential = await this.#inner.get(providerName, ownerDID);
41
- if (credential == null) return null;
41
+ const ref = await this.#inner.getCredentialRef(providerName, ownerDID);
42
+ if (ref == null) return null;
43
+ const { credential, entryID } = ref;
42
44
  if (this.#isExpiringSoon(credential) && credential.refreshToken != null) {
43
- const refreshed = await this.#refresh(providerName, ownerDID, credential, credential.refreshToken);
45
+ const refreshed = await this.#refresh(providerName, ownerDID, entryID, credential, credential.refreshToken);
44
46
  return refreshed ?? credential;
45
47
  }
46
48
  return credential;
@@ -51,6 +53,12 @@ export class RefreshingCredentialProvider {
51
53
  delete(providerName, ownerDID) {
52
54
  return this.#inner.delete(providerName, ownerDID);
53
55
  }
56
+ // Passthrough: alias binding points an owner at an already-existing entry
57
+ // without minting or refreshing anything, so it needs none of this
58
+ // decorator's expiry/refresh logic.
59
+ bindAlias(providerName, ownerDID, entryID) {
60
+ return this.#inner.bindAlias(providerName, ownerDID, entryID);
61
+ }
54
62
  #isExpiringSoon(credential) {
55
63
  if (credential.expiresAt == null) return false;
56
64
  return credential.expiresAt.getTime() - Date.now() < this.#bufferMs;
@@ -66,14 +74,18 @@ export class RefreshingCredentialProvider {
66
74
  // `get`). Followers await the coordinator's promise and reuse its outcome for
67
75
  // their own stale-fallback decision without emitting a second event. The key is
68
76
  // normalized so the two `did:peer:4` spellings of one owner share an entry.
69
- async #refresh(providerName, ownerDID, credential, refreshToken) {
70
- const key = `${providerName}:${normalizeDID(ownerDID)}`;
77
+ async #refresh(providerName, ownerDID, entryID, credential, refreshToken) {
78
+ // entryID is part of the key: a caller that read a DIFFERENT entry (a
79
+ // delete+rebind landed between its read and this one) must never join an
80
+ // in-flight exchange started against the old entry and receive its
81
+ // refreshed credential.
82
+ const key = `${providerName}:${normalizeDID(ownerDID)}:${entryID}`;
71
83
  const inFlight = this.#refreshInFlight.get(key);
72
84
  if (inFlight != null) {
73
85
  const outcome = await inFlight;
74
86
  return outcome.ok ? outcome.credential : null;
75
87
  }
76
- const exchange = this.#exchange(providerName, ownerDID, credential, refreshToken);
88
+ const exchange = this.#exchange(providerName, ownerDID, entryID, credential, refreshToken);
77
89
  this.#refreshInFlight.set(key, exchange);
78
90
  let outcome;
79
91
  try {
@@ -101,44 +113,28 @@ export class RefreshingCredentialProvider {
101
113
  // one provider+owner never reach here twice: `#refresh` shares a single pending
102
114
  // `#exchange` promise through the single-flight map. Returns a discriminated
103
115
  // outcome and does not emit or fall back itself.
104
- async #exchange(providerName, ownerDID, credential, refreshToken) {
116
+ async #exchange(providerName, ownerDID, entryID, credential, refreshToken) {
105
117
  const provider = this.#providers.find((p)=>p.name === providerName);
106
- if (provider == null || provider.clientID == null || provider.clientSecret == null) {
118
+ if (provider == null) {
107
119
  return {
108
120
  ok: false,
109
121
  category: 'configuration'
110
122
  };
111
123
  }
112
- let response;
113
- try {
114
- response = await this.#runtime.fetch(provider.tokenEndpoint, {
115
- method: 'POST',
116
- headers: {
117
- 'Content-Type': 'application/x-www-form-urlencoded'
118
- },
119
- body: new URLSearchParams({
120
- grant_type: 'refresh_token',
121
- refresh_token: refreshToken,
122
- client_id: provider.clientID,
123
- client_secret: provider.clientSecret
124
- })
125
- });
126
- } catch {
127
- return {
128
- ok: false,
129
- category: 'transient'
130
- };
131
- }
132
- if (!response.ok) {
133
- return {
134
- ok: false,
135
- category: await this.#classifyErrorResponse(response)
136
- };
137
- }
138
124
  let tokenData;
139
125
  try {
140
- tokenData = await response.json();
141
- } catch {
126
+ tokenData = await requestRefreshedToken({
127
+ definition: provider,
128
+ runtime: this.#runtime,
129
+ refreshToken
130
+ });
131
+ } catch (err) {
132
+ if (err instanceof OAuthTokenError) {
133
+ return {
134
+ ok: false,
135
+ category: err.code === 'invalid_grant' ? 'invalid_grant' : 'transient'
136
+ };
137
+ }
142
138
  return {
143
139
  ok: false,
144
140
  category: 'transient'
@@ -152,16 +148,35 @@ export class RefreshingCredentialProvider {
152
148
  accountLabel: credential.accountLabel,
153
149
  metadata: credential.metadata
154
150
  };
151
+ let write;
155
152
  try {
156
- await this.#inner.set({
153
+ write = await this.#inner.setCredentialForEntry({
157
154
  providerName,
158
155
  ownerDID,
156
+ entryID,
159
157
  credential: refreshed
160
158
  });
161
159
  } catch {
160
+ // A rotated refresh token (the endpoint issued a new one) means the prior
161
+ // token may already be invalidated server-side, so a lost write here
162
+ // cannot fall back to the stale credential — that would silently retry a
163
+ // refresh token the provider has already discarded. Surface it as the
164
+ // terminal re-auth category instead.
165
+ const rotated = tokenData.refresh_token != null && tokenData.refresh_token !== refreshToken;
162
166
  return {
163
167
  ok: false,
164
- category: 'persistence'
168
+ category: rotated ? 'invalid_grant' : 'persistence'
169
+ };
170
+ }
171
+ // The entry read at the start of this refresh is gone (a delete+rebind
172
+ // landed mid-exchange) — checked BEFORE any rotation reasoning, because
173
+ // this is not a persistence failure or an invalidated refresh token: the
174
+ // user's own delete removed the target, and the refreshed credential must
175
+ // never overwrite whatever now sits at that provider+owner slot.
176
+ if (write === 'absent') {
177
+ return {
178
+ ok: false,
179
+ category: 'stale'
165
180
  };
166
181
  }
167
182
  return {
@@ -169,17 +184,4 @@ export class RefreshingCredentialProvider {
169
184
  credential: refreshed
170
185
  };
171
186
  }
172
- // Distinguishes a confirmed OAuth invalidation from a generic non-OK status.
173
- // The standard OAuth2 error response is `{ "error": "invalid_grant", ... }` in
174
- // the JSON body of a 400; anything else — including an empty or non-JSON body —
175
- // is treated as transient so a stale-but-valid token is never abandoned.
176
- async #classifyErrorResponse(response) {
177
- try {
178
- const body = await response.json();
179
- if (body?.error === 'invalid_grant') return 'invalid_grant';
180
- } catch {
181
- // Empty or non-JSON error body: cannot confirm invalidation.
182
- }
183
- return 'transient';
184
- }
185
187
  }
@@ -0,0 +1,2 @@
1
+ import type { Credential } from '@kubun/credential-types';
2
+ export declare function assertValidCredentialShape(value: unknown): asserts value is Credential;
@@ -0,0 +1,30 @@
1
+ import { CredentialInvalidEntry } from './errors.js';
2
+ // Bind/refresh decrypt arbitrary stored bytes; deserializeCredential only JSON.parses
3
+ // and casts, so `{}` or mistyped fields would pass. Validate against the typed
4
+ // contract before trusting an entry as a credential.
5
+ export function assertValidCredentialShape(value) {
6
+ if (value == null || typeof value !== 'object') {
7
+ throw new CredentialInvalidEntry('credential is not an object');
8
+ }
9
+ const c = value;
10
+ if (typeof c.accessToken !== 'string' || c.accessToken.length === 0) {
11
+ throw new CredentialInvalidEntry('accessToken must be a non-empty string');
12
+ }
13
+ if (!Array.isArray(c.scopes) || c.scopes.some((s)=>typeof s !== 'string')) {
14
+ throw new CredentialInvalidEntry('scopes must be an array of strings');
15
+ }
16
+ if (c.refreshToken != null && typeof c.refreshToken !== 'string') {
17
+ throw new CredentialInvalidEntry('refreshToken must be a string when present');
18
+ }
19
+ if (c.accountLabel != null && typeof c.accountLabel !== 'string') {
20
+ throw new CredentialInvalidEntry('accountLabel must be a string when present');
21
+ }
22
+ // expiresAt is serialized as an ISO string; an unparseable one deserializes to
23
+ // an Invalid Date, which silently disables the refresh comparison. Reject it here.
24
+ if (c.expiresAt != null && (typeof c.expiresAt !== 'string' || Number.isNaN(Date.parse(c.expiresAt)))) {
25
+ throw new CredentialInvalidEntry('expiresAt must be an ISO date string when present');
26
+ }
27
+ if (c.metadata != null && (typeof c.metadata !== 'object' || Array.isArray(c.metadata))) {
28
+ throw new CredentialInvalidEntry('metadata must be an object when present');
29
+ }
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/credential",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -16,13 +16,14 @@
16
16
  ],
17
17
  "dependencies": {
18
18
  "@kokuin/jwe": "^0.1.0",
19
+ "@kokuin/oauth": "^0.1.0",
19
20
  "@kokuin/token": "^0.5.0",
20
- "@kubun/credential-types": "^0.16.0",
21
- "@kubun/db": "^0.16.0",
22
- "@kubun/hlc": "^0.16.0",
23
- "@kubun/id": "^0.16.0",
24
- "@kubun/logger": "^0.16.0",
25
- "@kubun/store-credential": "^0.16.0",
21
+ "@kubun/credential-types": "^0.17.1",
22
+ "@kubun/db": "^0.17.0",
23
+ "@kubun/hlc": "^0.17.0",
24
+ "@kubun/id": "^0.17.0",
25
+ "@kubun/logger": "^0.17.0",
26
+ "@kubun/store-credential": "^0.17.3",
26
27
  "@noble/ciphers": "^2.4.0",
27
28
  "@noble/hashes": "^2.4.0",
28
29
  "@sozai/codec": "^0.4.0",
@@ -30,7 +31,11 @@
30
31
  "@sozai/runtime": "^0.1.0"
31
32
  },
32
33
  "devDependencies": {
33
- "@kubun/db-better-sqlite": "^0.16.0"
34
+ "@kubun/db-adapter": "^0.17.0",
35
+ "@kubun/db-better-sqlite": "^0.17.0",
36
+ "@kubun/db-postgres": "^0.17.1",
37
+ "@testcontainers/postgresql": "^12.1.0",
38
+ "kysely": "^0.29.6"
34
39
  },
35
40
  "publishConfig": {
36
41
  "access": "public"
@@ -38,8 +43,8 @@
38
43
  "scripts": {
39
44
  "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
40
45
  "build:clean": "del lib",
41
- "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
42
- "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
46
+ "build:js": "del 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
47
+ "build:types": "del 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
43
48
  "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
44
49
  "test": "pnpm run test:types && pnpm run test:unit",
45
50
  "test:types": "tsc --noEmit -p tsconfig.test.json",