@kubun/store-credential 0.17.3 → 0.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/api.d.ts +20 -1
  2. package/lib/api.js +12 -3
  3. package/package.json +1 -1
package/lib/api.d.ts CHANGED
@@ -235,6 +235,11 @@ export type CredentialStoreAPI = {
235
235
  * whatever it means by "key" before calling.
236
236
  */
237
237
  getAliasEntryID(namespace: string, key: string): Promise<string | null>;
238
+ /**
239
+ * The provider namespace this entry is aliased under, or null when the entry
240
+ * has no alias row. UNIQUE(entry_id) makes this a 1:1 reverse lookup.
241
+ */
242
+ aliasNamespaceForEntry(entryID: string): Promise<string | null>;
238
243
  /** Point `(namespace, key)` at `entryID`, replacing any prior target. */
239
244
  setAliasEntryID(namespace: string, key: string, entryID: string): Promise<void>;
240
245
  /**
@@ -243,8 +248,22 @@ export type CredentialStoreAPI = {
243
248
  * existing alias and never binds a nonexistent entry. Classifies the outcome
244
249
  * instead of throwing, since both unique constraints are expected outcomes a
245
250
  * caller branches on, not exceptional ones.
251
+ *
252
+ * When `expectedKeyID` is given, the bind is additionally conditioned, in the
253
+ * same statement, on the entry belonging to that key. This closes a
254
+ * time-of-check/time-of-use gap for callers that cannot wrap the check and the
255
+ * bind in one transaction: an entry verified to belong to a key can be moved to
256
+ * another key (a supported manager operation) before an unconditioned insert
257
+ * fires, which would point this alias at an entry now owned by a different key.
258
+ * Folding the key check into the insert refuses atomically. When omitted, only
259
+ * entry existence is required (owner-gated callers verify authority elsewhere).
246
260
  */
247
- bindAliasIfAbsent(namespace: string, aliasKey: string, entryID: string): Promise<'inserted' | 'entry-missing' | {
261
+ bindAliasIfAbsent(params: {
262
+ namespace: string;
263
+ aliasKey: string;
264
+ entryID: string;
265
+ expectedKeyID?: string;
266
+ }): Promise<'inserted' | 'entry-missing' | {
248
267
  conflict: 'alias';
249
268
  existing: string;
250
269
  } | {
package/lib/api.js CHANGED
@@ -566,6 +566,10 @@ export function createCredentialStore(db, adapter) {
566
566
  const row = await db.selectFrom('kubun_credential_aliases').select('entry_id').where('namespace', '=', namespace).where('alias_key', '=', key).executeTakeFirst();
567
567
  return row?.entry_id ?? null;
568
568
  },
569
+ async aliasNamespaceForEntry (entryID) {
570
+ const row = await db.selectFrom('kubun_credential_aliases').select('namespace').where('entry_id', '=', entryID).executeTakeFirst();
571
+ return row?.namespace ?? null;
572
+ },
569
573
  async setAliasEntryID (namespace, key, entryID) {
570
574
  await gatedIfAmbient(()=>db.insertInto('kubun_credential_aliases').values({
571
575
  namespace,
@@ -578,7 +582,7 @@ export function createCredentialStore(db, adapter) {
578
582
  entry_id: entryID
579
583
  })).execute());
580
584
  },
581
- async bindAliasIfAbsent (namespace, aliasKey, entryID) {
585
+ async bindAliasIfAbsent ({ namespace, aliasKey, entryID, expectedKeyID }) {
582
586
  // Entry-existence-conditioned insert: the alias row is written only in the same
583
587
  // statement that confirms the entry exists. ON CONFLICT DO NOTHING fires for either
584
588
  // the (namespace, alias_key) PK or the UNIQUE(entry_id); the read-back names which.
@@ -595,7 +599,10 @@ export function createCredentialStore(db, adapter) {
595
599
  'namespace',
596
600
  'alias_key',
597
601
  'entry_id'
598
- ]).expression((eb)=>eb.selectFrom('kubun_credential_entries').where('entry_id', '=', entryID).select((eb2)=>[
602
+ ]).expression((eb)=>eb.selectFrom('kubun_credential_entries').where('entry_id', '=', entryID)// Same-statement key gate (see doc): bind only while the entry
603
+ // still belongs to expectedKeyID, so a concurrent cross-key move
604
+ // cannot slip an alias onto an entry that has left this key.
605
+ .$if(expectedKeyID != null, (qb)=>qb.where('key_id', '=', expectedKeyID)).select((eb2)=>[
599
606
  eb2.val(namespace).as('namespace'),
600
607
  eb2.val(aliasKey).as('alias_key'),
601
608
  eb2.ref('entry_id').as('entry_id')
@@ -627,7 +634,9 @@ export function createCredentialStore(db, adapter) {
627
634
  .where('entry_id', '=', observedExisting)// The observed pointer must still be dangling (its entry absent)...
628
635
  .where((eb)=>eb.not(eb.exists(eb.selectFrom('kubun_credential_entries as e').select(sql`1`.as('one')).whereRef('e.entry_id', '=', 'kubun_credential_aliases.entry_id'))))// ...and the target must be present, so repair can never form a NEW dangling
629
636
  // pointer even if the target is deleted between the pre-check and here.
630
- .where((eb)=>eb.exists(eb.selectFrom('kubun_credential_entries as t').select(sql`1`.as('one')).where('t.entry_id', '=', entryID))).executeTakeFirst();
637
+ // When key-gated, the target must also still belong to expectedKeyID, so a
638
+ // repair cannot repoint onto an entry that has moved to another key.
639
+ .where((eb)=>eb.exists(eb.selectFrom('kubun_credential_entries as t').select(sql`1`.as('one')).where('t.entry_id', '=', entryID).$if(expectedKeyID != null, (qb)=>qb.where('t.key_id', '=', expectedKeyID)))).executeTakeFirst();
631
640
  return (res.numUpdatedRows ?? 0n) > 0n;
632
641
  };
633
642
  if (!db.isTransaction) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/store-credential",
3
- "version": "0.17.3",
3
+ "version": "0.17.4",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,