@cortexkit/common-auth 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/cachekeep/manager.d.ts +18 -6
  2. package/dist/cachekeep/manager.js +40 -10
  3. package/dist/claustrum/consumer.d.ts +13 -4
  4. package/dist/claustrum/consumer.js +11 -3
  5. package/dist/claustrum/custody.d.ts +47 -6
  6. package/dist/claustrum/custody.js +37 -7
  7. package/dist/claustrum/errors.d.ts +1 -1
  8. package/dist/claustrum/index.d.ts +3 -3
  9. package/dist/claustrum/index.js +2 -2
  10. package/dist/claustrum/interlock.d.ts +15 -17
  11. package/dist/claustrum/interlock.js +19 -26
  12. package/dist/claustrum/roster.d.ts +96 -6
  13. package/dist/claustrum/roster.js +209 -44
  14. package/dist/commands/builtins.d.ts +1 -1
  15. package/dist/commands/builtins.js +6 -1
  16. package/dist/commands/index.d.ts +2 -2
  17. package/dist/commands/index.js +1 -1
  18. package/dist/commands/menu.d.ts +8 -0
  19. package/dist/commands/menu.js +34 -13
  20. package/dist/commands/model.d.ts +9 -0
  21. package/dist/commands/seam.d.ts +40 -4
  22. package/dist/commands/seam.js +132 -19
  23. package/dist/dump/index.d.ts +94 -0
  24. package/dist/dump/index.js +236 -9
  25. package/dist/logger/engine.d.ts +52 -17
  26. package/dist/logger/engine.js +178 -135
  27. package/dist/logger/index.d.ts +2 -2
  28. package/dist/logger/index.js +1 -1
  29. package/dist/opencode2/install.d.ts +8 -3
  30. package/dist/opencode2/install.js +18 -9
  31. package/dist/opencode2/types.d.ts +22 -3
  32. package/dist/quota/projection.d.ts +11 -4
  33. package/dist/quota/projection.js +11 -4
  34. package/dist/routing/admission.js +3 -1
  35. package/dist/routing/index.d.ts +2 -2
  36. package/dist/routing/index.js +1 -1
  37. package/dist/routing/sticky.d.ts +19 -6
  38. package/dist/routing/sticky.js +34 -23
  39. package/dist/rpc/notifications.d.ts +20 -0
  40. package/dist/rpc/notifications.js +21 -0
  41. package/dist/rpc/rpc-server.d.ts +9 -1
  42. package/dist/rpc/rpc-server.js +8 -1
  43. package/dist/sidebar-file/index.d.ts +1 -1
  44. package/dist/sidebar-file/sidebar-file.d.ts +50 -2
  45. package/dist/sidebar-file/sidebar-file.js +92 -21
  46. package/dist/store/attribution.js +11 -2
  47. package/dist/store/errors.d.ts +6 -3
  48. package/dist/store/identity.d.ts +13 -4
  49. package/dist/store/mutate.d.ts +23 -3
  50. package/dist/store/mutate.js +43 -26
  51. package/dist/store/pool.d.ts +8 -1
  52. package/dist/store/pool.js +7 -2
  53. package/dist/store/rows.d.ts +17 -4
  54. package/dist/store/rows.js +82 -33
  55. package/dist/store/schema.d.ts +57 -4
  56. package/dist/store/schema.js +100 -7
  57. package/dist/store/torn.d.ts +29 -0
  58. package/dist/store/torn.js +113 -0
  59. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { type QuotaMap, type QuotaObservation } from '../quota/index.js';
2
2
  import type { RoutingRow } from '../routing/index.js';
3
- import type { ClaustrumScopedCustody, VaultCredential, VaultCredentialType, VaultInventory } from './custody.js';
3
+ import type { ClaustrumScopedAttempt, ClaustrumScopedCustody, SkippedVaultRecord, VaultCredential, VaultCredentialType, VaultInventory } from './custody.js';
4
4
  import { type DeclinedAccount } from './interlock.js';
5
5
  /**
6
6
  * One vault account as the pool sees it. It carries no bearer material: the
@@ -27,10 +27,25 @@ export interface VaultRosterRow {
27
27
  * row is the last good projection kept as it was rather than dropped.
28
28
  */
29
29
  stale?: true;
30
+ /**
31
+ * The vault's latest list named no account for `credentialId`, so
32
+ * `accountIdentity` is the last account that credential was known to log
33
+ * into, kept until the vault names one again. It proves neither that the
34
+ * credential still logs into that account nor that it changed accounts.
35
+ */
36
+ unclaimed?: true;
30
37
  }
31
38
  export interface VaultRosterFile {
32
39
  version: 1;
33
40
  view?: string;
41
+ /**
42
+ * False when the vault's reply carried records this consumer could not use.
43
+ * An incomplete reply never removes an account it may still hold: every
44
+ * previous member it does not account for is kept.
45
+ */
46
+ complete: boolean;
47
+ /** The unusable records of an incomplete reply: a safe id and a fixed reason. */
48
+ rejected?: SkippedVaultRecord[];
34
49
  rows: VaultRosterRow[];
35
50
  declined: DeclinedAccount[];
36
51
  }
@@ -52,6 +67,16 @@ export declare const DEFAULT_ROUTE_PREFIX = "vault:";
52
67
  * discovery and commit. Route ids, quota and the time an account was added
53
68
  * survive for the same account; a credential that now logs into a different
54
69
  * known account gets a new route id and no inherited quota.
70
+ *
71
+ * A record listed without an identity keeps the last account it was known to
72
+ * log into (the row is marked `unclaimed`). Dropping that binding would let a
73
+ * later different account look like an identity learned for the first time
74
+ * and inherit the first account's route and quota.
75
+ *
76
+ * A reply with unusable records is incomplete: a previous member it does not
77
+ * account for (its record was rejected, or a rejected record has no usable id
78
+ * and could be any of them) is kept, as an alias of its live account or as a
79
+ * `stale` row. Only a complete reply removes an account.
55
80
  */
56
81
  export declare function projectVaultRoster(previous: VaultRosterFile | undefined, inventory: VaultInventory, options?: ProjectionOptions): VaultRosterFile;
57
82
  /**
@@ -76,16 +101,22 @@ export declare function mutateVaultRoster<T>(path: string, change: (current: Vau
76
101
  export declare function declineVaultRoute(path: string, routeId: string): Promise<undefined>;
77
102
  /** Lift the user's decline for a vault account and every alias of it. */
78
103
  export declare function acceptVaultRoute(path: string, routeId: string): Promise<undefined>;
104
+ /**
105
+ * The receipt fields a quota or profile observation must carry to say which
106
+ * send (credential and account) it came from. Pass the
107
+ * `ClaustrumScopedAttempt` that send was authorized with.
108
+ */
109
+ export type QuotaReceipt = Pick<ClaustrumScopedAttempt, 'credentialId' | 'accountIdentity' | 'accountIdentitySource' | 'expectedAccountIdentity'>;
79
110
  /**
80
111
  * Merge a quota observation into a vault row through `/quota`'s merge. The
81
- * write is fenced on the account: an observation taken for an identity the
82
- * row no longer holds is dropped, so a slow read for a replaced account can
83
- * never land on its successor. Returns whether the observation was kept.
112
+ * write is fenced on the receipt the reading was taken with: it lands only
113
+ * while the row still holds that receipt's credential (as representative or
114
+ * alias) and account, so a slow read for a replaced account can never land on
115
+ * its successor. Returns whether the observation was kept.
84
116
  */
85
- export declare function recordVaultQuota(path: string, input: {
117
+ export declare function recordVaultQuota(path: string, input: QuotaReceipt & {
86
118
  routeId: string;
87
119
  observation: QuotaObservation;
88
- accountIdentity?: string;
89
120
  }): Promise<boolean>;
90
121
  /**
91
122
  * Discover and commit the roster. The lease covers the list call and the
@@ -101,3 +132,62 @@ export declare function refreshVaultRoster(options: {
101
132
  signal?: AbortSignal;
102
133
  projection?: ProjectionOptions | (() => ProjectionOptions);
103
134
  }): Promise<VaultRosterFile | undefined>;
135
+ /**
136
+ * A host-owned main account as last verified from the vault: the plugin's
137
+ * logical route, the credential that serves it and the account it logs into.
138
+ */
139
+ export interface VaultPrimaryBinding {
140
+ routeId: string;
141
+ credentialId: string;
142
+ accountIdentity: string;
143
+ view: string;
144
+ }
145
+ export type VaultPrimaryUnavailableReason = 'malformed' | 'unclaimed' | 'incomplete' | 'identity-changed';
146
+ export type VaultPrimary =
147
+ /**
148
+ * Main is bound. `replaced` is the previous binding when it named a
149
+ * different account: everything owned by that account (quota, profile,
150
+ * backoff, cache affinity) must be dropped, and outstanding observations
151
+ * for it fenced, even though the logical route stays the same.
152
+ */
153
+ {
154
+ status: 'ready';
155
+ binding: VaultPrimaryBinding;
156
+ replaced?: VaultPrimaryBinding;
157
+ }
158
+ /** A complete reply lists no primary record: there is no main. */
159
+ | {
160
+ status: 'absent';
161
+ }
162
+ /**
163
+ * The reply cannot say who main is. Not the same as absent: the last
164
+ * verified binding is handed back untouched, and serving it still needs a
165
+ * fresh receipt that matches it.
166
+ */
167
+ | {
168
+ status: 'unavailable';
169
+ reason: VaultPrimaryUnavailableReason;
170
+ lastVerified?: VaultPrimaryBinding;
171
+ };
172
+ /**
173
+ * The seam for a plugin that keeps a main account: one fixed account the host
174
+ * owns under a logical route, outside the pool's rotating rows. The library
175
+ * has no main convention of its own: the plugin names the
176
+ * vault record that holds the role (`primaryCredentialId`) and its logical
177
+ * route. Main follows the account that record claims; another credential of
178
+ * that same account may serve it when the record itself is not active, but a
179
+ * credential of any other account is never promoted into main.
180
+ *
181
+ * Inputs are the plugin's filtered inventory, whether that inventory is
182
+ * complete (defaults to the reply having no unusable records; pass the
183
+ * roster's `complete` or false to override), the previous verified binding
184
+ * and, on the request path, the account the chosen request expects.
185
+ */
186
+ export declare function resolveVaultPrimary(input: {
187
+ inventory: VaultInventory;
188
+ complete?: boolean;
189
+ previous?: VaultPrimaryBinding;
190
+ expectedAccountIdentity?: string;
191
+ primaryCredentialId: string;
192
+ routeId: string;
193
+ }): VaultPrimary;
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import { acquireRefreshFileLock, withLock, writeJsonAtomic, } from '../fs/index.js';
4
4
  import { isQuotaMap, mergeQuotaObservation, } from '../quota/index.js';
5
5
  import { ClaustrumConsumerError } from './errors.js';
6
- import { acceptAccount, declineAccount, isDeclined, pruneDeclined, } from './interlock.js';
6
+ import { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
7
7
  export const DEFAULT_ROUTE_PREFIX = 'vault:';
8
8
  function hash(value) {
9
9
  return createHash('sha256').update(value).digest('hex');
@@ -15,51 +15,88 @@ function defaultLabel(credential) {
15
15
  const label = parts.length >= 3 ? parts.slice(2).join(':').trim() : undefined;
16
16
  return label || credential.email || credential.credentialId;
17
17
  }
18
- function compatible(left, right) {
19
- return left === undefined || right === undefined || left === right;
18
+ function groupKey(member) {
19
+ return member.identity !== undefined
20
+ ? `identity:${member.identity}`
21
+ : `credential:${member.credential.credentialId}`;
20
22
  }
21
- function groupKey(credential) {
22
- return credential.accountIdentity !== undefined
23
- ? `identity:${credential.accountIdentity}`
24
- : `credential:${credential.credentialId}`;
23
+ function memberIds(row) {
24
+ return [row.credentialId, ...(row.aliases ?? [])];
25
25
  }
26
26
  /**
27
27
  * Project the vault's list onto the previous roster. Pure: callers serialize
28
28
  * discovery and commit. Route ids, quota and the time an account was added
29
29
  * survive for the same account; a credential that now logs into a different
30
30
  * known account gets a new route id and no inherited quota.
31
+ *
32
+ * A record listed without an identity keeps the last account it was known to
33
+ * log into (the row is marked `unclaimed`). Dropping that binding would let a
34
+ * later different account look like an identity learned for the first time
35
+ * and inherit the first account's route and quota.
36
+ *
37
+ * A reply with unusable records is incomplete: a previous member it does not
38
+ * account for (its record was rejected, or a rejected record has no usable id
39
+ * and could be any of them) is kept, as an alias of its live account or as a
40
+ * `stale` row. Only a complete reply removes an account.
31
41
  */
32
42
  export function projectVaultRoster(previous, inventory, options = {}) {
33
43
  const now = options.now ?? Date.now();
34
44
  const prefix = options.routePrefix ?? DEFAULT_ROUTE_PREFIX;
35
45
  const reserved = options.reservedRouteIds ?? new Set();
36
46
  const previousRows = previous?.rows ?? [];
37
- const declined = pruneDeclined(previous?.declined ?? [], inventory.credentials);
47
+ // Declines follow the account and outlive its records, so none is pruned.
48
+ const declined = [...(previous?.declined ?? [])];
49
+ const complete = inventory.skipped.length === 0;
50
+ const listed = new Set(inventory.credentials.map((credential) => credential.credentialId));
51
+ const rejectedIds = new Set(inventory.skipped.flatMap((record) => record.credentialId === undefined ? [] : [record.credentialId]));
52
+ const unrecoverable = inventory.skipped.some((record) => record.credentialId === undefined);
53
+ const unaccounted = (id) => !listed.has(id) && (rejectedIds.has(id) || unrecoverable);
54
+ const lastKnown = new Map();
55
+ for (const row of previousRows) {
56
+ if (row.accountIdentity === undefined)
57
+ continue;
58
+ for (const id of memberIds(row))
59
+ if (!lastKnown.has(id))
60
+ lastKnown.set(id, row.accountIdentity);
61
+ }
38
62
  const groups = new Map();
39
63
  for (const credential of inventory.credentials) {
40
- const key = groupKey(credential);
64
+ const member = credential.accountIdentity !== undefined
65
+ ? { credential, identity: credential.accountIdentity, claimed: true }
66
+ : {
67
+ credential,
68
+ identity: lastKnown.get(credential.credentialId),
69
+ claimed: false,
70
+ };
71
+ const key = groupKey(member);
41
72
  const group = groups.get(key) ?? [];
42
- group.push(credential);
73
+ group.push(member);
43
74
  groups.set(key, group);
44
75
  }
45
76
  const used = new Set();
46
77
  const taken = new Set(reserved);
47
78
  const projected = [];
48
- const sortedGroups = [...groups.values()].sort((left, right) => (left[0]?.credentialId ?? '').localeCompare(right[0]?.credentialId ?? ''));
49
- for (const group of sortedGroups) {
50
- const ordered = group.toSorted((left, right) => left.credentialId.localeCompare(right.credentialId));
51
- const active = ordered.filter((entry) => entry.state === 'active');
52
- const choices = active.length ? active : ordered;
53
- const members = new Set(ordered.map((entry) => entry.credentialId));
79
+ const byId = (left, right) => left.credential.credentialId.localeCompare(right.credential.credentialId);
80
+ const sortedGroups = [...groups.values()]
81
+ .map((group) => group.toSorted(byId))
82
+ .sort((left, right) => (left[0] && right[0] ? byId(left[0], right[0]) : 0));
83
+ for (const ordered of sortedGroups) {
84
+ const identity = ordered[0]?.identity;
85
+ const active = ordered.filter((entry) => entry.credential.state === 'active');
86
+ const usable = active.length ? active : ordered;
87
+ // A record that claims the account itself is preferred over one that is
88
+ // only remembered as belonging to it.
89
+ const claimed = usable.filter((entry) => entry.claimed);
90
+ const choices = claimed.length ? claimed : usable;
91
+ const members = new Set(ordered.map((entry) => entry.credential.credentialId));
92
+ // An unknown previous account may be learned now; a known one must match.
54
93
  const bound = previousRows.find((row) => !used.has(row) &&
55
- members.has(row.credentialId) &&
56
- compatible(row.accountIdentity, ordered.find((entry) => entry.credentialId === row.credentialId)
57
- ?.accountIdentity));
58
- const representative = choices.find((entry) => entry.credentialId === bound?.credentialId) ??
59
- choices[0];
60
- if (!representative)
94
+ memberIds(row).some((id) => members.has(id)) &&
95
+ (row.accountIdentity === undefined || row.accountIdentity === identity));
96
+ const chosen = choices.find((entry) => entry.credential.credentialId === bound?.credentialId) ?? choices[0];
97
+ if (!chosen)
61
98
  continue;
62
- const identity = representative.accountIdentity;
99
+ const representative = chosen.credential;
63
100
  const existing = bound ??
64
101
  (identity === undefined
65
102
  ? undefined
@@ -74,9 +111,15 @@ export function projectVaultRoster(previous, inventory, options = {}) {
74
111
  routeId = `${base}~${suffix}`;
75
112
  }
76
113
  taken.add(routeId);
77
- const aliases = ordered
78
- .map((entry) => entry.credentialId)
79
- .filter((id) => id !== representative.credentialId);
114
+ const retained = existing ? memberIds(existing).filter(unaccounted) : [];
115
+ const aliases = [
116
+ ...new Set([
117
+ ...ordered.map((entry) => entry.credential.credentialId),
118
+ ...retained,
119
+ ]),
120
+ ]
121
+ .filter((id) => id !== representative.credentialId)
122
+ .sort((left, right) => left.localeCompare(right));
80
123
  const mapped = options.mapAccount?.(representative) ?? {};
81
124
  projected.push({
82
125
  previousIndex: existing ? previousRows.indexOf(existing) : Infinity,
@@ -94,22 +137,38 @@ export function projectVaultRoster(previous, inventory, options = {}) {
94
137
  ...(representative.orgName !== undefined && {
95
138
  orgName: representative.orgName,
96
139
  }),
97
- enabled: !ordered.some((entry) => isDeclined(declined, entry.credentialId, entry.accountIdentity)),
140
+ enabled: ![representative.credentialId, ...aliases].some((id) => isDeclined(declined, id, identity)),
98
141
  addedAt: existing?.addedAt ?? now,
99
142
  ...(existing?.quota !== undefined && { quota: existing.quota }),
143
+ ...(!chosen.claimed && identity !== undefined && { unclaimed: true }),
100
144
  },
101
145
  });
102
146
  }
103
- // A record skipped as malformed keeps its last good projection: skipping it
104
- // must not read as the account having been removed from the vault.
105
- const skippedIds = new Set(inventory.skipped.flatMap((record) => record.credentialId === undefined ? [] : [record.credentialId]));
147
+ // An account the reply does not account for keeps its last good projection:
148
+ // a rejected record must not read as the account having been removed. A
149
+ // credential this reply lists belongs to the live row it was projected into,
150
+ // so it is taken out of the stale row: otherwise one credential could be
151
+ // authorized, and its readings recorded, under two account bindings. A
152
+ // stale row left with no member is dropped.
106
153
  previousRows.forEach((row, previousIndex) => {
107
- if (used.has(row) || !skippedIds.has(row.credentialId))
154
+ if (used.has(row) || !memberIds(row).some(unaccounted))
108
155
  return;
109
156
  if (taken.has(row.routeId))
110
157
  return;
158
+ const [credentialId, ...aliases] = memberIds(row).filter((id) => !listed.has(id));
159
+ if (credentialId === undefined)
160
+ return;
111
161
  taken.add(row.routeId);
112
- projected.push({ previousIndex, row: { ...row, stale: true } });
162
+ const { aliases: _previousAliases, ...rest } = row;
163
+ projected.push({
164
+ previousIndex,
165
+ row: {
166
+ ...rest,
167
+ credentialId,
168
+ ...(aliases.length && { aliases }),
169
+ stale: true,
170
+ },
171
+ });
113
172
  });
114
173
  projected.sort((left, right) => left.previousIndex === right.previousIndex
115
174
  ? left.row.credentialId.localeCompare(right.row.credentialId)
@@ -117,6 +176,15 @@ export function projectVaultRoster(previous, inventory, options = {}) {
117
176
  return {
118
177
  version: 1,
119
178
  view: inventory.view,
179
+ complete,
180
+ ...(!complete && {
181
+ rejected: inventory.skipped.map((record) => ({
182
+ ...(record.credentialId !== undefined && {
183
+ credentialId: record.credentialId,
184
+ }),
185
+ reason: record.reason,
186
+ })),
187
+ }),
120
188
  rows: projected.map((entry) => entry.row),
121
189
  declined,
122
190
  };
@@ -156,16 +224,40 @@ function decodeRow(value) {
156
224
  (value.aliases !== undefined &&
157
225
  (!Array.isArray(value.aliases) ||
158
226
  !value.aliases.every((alias) => typeof alias === 'string'))) ||
159
- (value.quota !== undefined && !isQuotaMap(value.quota)))
227
+ (value.quota !== undefined && !isQuotaMap(value.quota)) ||
228
+ (value.stale !== undefined && value.stale !== true) ||
229
+ (value.unclaimed !== undefined && value.unclaimed !== true))
160
230
  return undefined;
161
231
  return value;
162
232
  }
233
+ const SKIPPED_REASONS = new Set([
234
+ 'empty credential id',
235
+ 'duplicate credential id',
236
+ 'blank account identity',
237
+ 'empty state',
238
+ ]);
239
+ function decodeRejected(value) {
240
+ if (!Array.isArray(value))
241
+ return undefined;
242
+ const records = [];
243
+ for (const entry of value) {
244
+ if (!isRecord(entry) ||
245
+ !optionalString(entry.credentialId) ||
246
+ typeof entry.reason !== 'string' ||
247
+ !SKIPPED_REASONS.has(entry.reason))
248
+ return undefined;
249
+ records.push(entry);
250
+ }
251
+ return records;
252
+ }
163
253
  function decodeRoster(value) {
164
254
  if (!isRecord(value) ||
165
255
  value.version !== 1 ||
166
256
  !optionalString(value.view) ||
167
257
  !Array.isArray(value.rows) ||
168
- !Array.isArray(value.declined))
258
+ !Array.isArray(value.declined) ||
259
+ (value.complete !== undefined && typeof value.complete !== 'boolean') ||
260
+ (value.rejected !== undefined && !decodeRejected(value.rejected)))
169
261
  throw new ClaustrumConsumerError('invalid-state', 'Claustrum roster file is not valid');
170
262
  const rows = [];
171
263
  for (const entry of value.rows) {
@@ -182,9 +274,14 @@ function decodeRoster(value) {
182
274
  throw new ClaustrumConsumerError('invalid-state', 'Claustrum roster file is not valid');
183
275
  declined.push(entry);
184
276
  }
277
+ const rejected = decodeRejected(value.rejected);
185
278
  return {
186
279
  version: 1,
187
280
  ...(typeof value.view === 'string' && { view: value.view }),
281
+ // A file written before completeness was recorded came from a reply
282
+ // that was used whole, so it reads as complete.
283
+ complete: value.complete !== false,
284
+ ...(rejected && { rejected }),
188
285
  rows,
189
286
  declined,
190
287
  };
@@ -255,28 +352,41 @@ export function acceptVaultRoute(path, routeId) {
255
352
  return {
256
353
  next: {
257
354
  ...current,
258
- declined: acceptAccount(current.declined, [
259
- row.credentialId,
260
- ...(row.aliases ?? []),
261
- ]),
355
+ declined: acceptAccount(current.declined, memberIds(row), row.accountIdentity),
262
356
  rows: current.rows.map((entry) => entry === row ? { ...entry, enabled: true } : entry),
263
357
  },
264
358
  result: undefined,
265
359
  };
266
360
  });
267
361
  }
362
+ function holdsReceipt(row, receipt) {
363
+ if (!memberIds(row).includes(receipt.credentialId))
364
+ return false;
365
+ // While the vault makes no claim, the roster's own expectation proves
366
+ // nothing about which account the reading came from.
367
+ if (row.unclaimed &&
368
+ (receipt.accountIdentitySource === 'expected' ||
369
+ receipt.accountIdentitySource === 'none'))
370
+ return false;
371
+ // Equality, never compatibility: a known account on one side does not match
372
+ // an absent one on the other. The one exception is a row still without any
373
+ // known account, reached by a receipt issued for it in that state; the
374
+ // credential id above is then the whole binding.
375
+ return (row.accountIdentity === receipt.accountIdentity ||
376
+ (row.accountIdentity === undefined &&
377
+ receipt.expectedAccountIdentity === undefined));
378
+ }
268
379
  /**
269
380
  * Merge a quota observation into a vault row through `/quota`'s merge. The
270
- * write is fenced on the account: an observation taken for an identity the
271
- * row no longer holds is dropped, so a slow read for a replaced account can
272
- * never land on its successor. Returns whether the observation was kept.
381
+ * write is fenced on the receipt the reading was taken with: it lands only
382
+ * while the row still holds that receipt's credential (as representative or
383
+ * alias) and account, so a slow read for a replaced account can never land on
384
+ * its successor. Returns whether the observation was kept.
273
385
  */
274
386
  export function recordVaultQuota(path, input) {
275
387
  return mutateVaultRoster(path, (current) => {
276
388
  const row = current?.rows.find((entry) => entry.routeId === input.routeId);
277
- if (!current ||
278
- !row ||
279
- !compatible(row.accountIdentity, input.accountIdentity))
389
+ if (!current || !row || !holdsReceipt(row, input))
280
390
  return { result: false };
281
391
  const quota = mergeQuotaObservation(row.quota, input.observation);
282
392
  return {
@@ -332,3 +442,58 @@ export async function refreshVaultRoster(options) {
332
442
  await lease.release();
333
443
  }
334
444
  }
445
+ /**
446
+ * The seam for a plugin that keeps a main account: one fixed account the host
447
+ * owns under a logical route, outside the pool's rotating rows. The library
448
+ * has no main convention of its own: the plugin names the
449
+ * vault record that holds the role (`primaryCredentialId`) and its logical
450
+ * route. Main follows the account that record claims; another credential of
451
+ * that same account may serve it when the record itself is not active, but a
452
+ * credential of any other account is never promoted into main.
453
+ *
454
+ * Inputs are the plugin's filtered inventory, whether that inventory is
455
+ * complete (defaults to the reply having no unusable records; pass the
456
+ * roster's `complete` or false to override), the previous verified binding
457
+ * and, on the request path, the account the chosen request expects.
458
+ */
459
+ export function resolveVaultPrimary(input) {
460
+ const { inventory, previous } = input;
461
+ const unavailable = (reason) => ({
462
+ status: 'unavailable',
463
+ reason,
464
+ ...(previous && { lastVerified: previous }),
465
+ });
466
+ const record = inventory.credentials.find((credential) => credential.credentialId === input.primaryCredentialId);
467
+ if (!record) {
468
+ if (inventory.skipped.some((skipped) => skipped.credentialId === input.primaryCredentialId))
469
+ return unavailable('malformed');
470
+ // A rejected record without a usable id could be the primary record.
471
+ const incomplete = input.complete === false ||
472
+ inventory.skipped.some((skipped) => skipped.credentialId === undefined);
473
+ return incomplete ? unavailable('incomplete') : { status: 'absent' };
474
+ }
475
+ const identity = record.accountIdentity;
476
+ if (identity === undefined)
477
+ return unavailable('unclaimed');
478
+ if (input.expectedAccountIdentity !== undefined &&
479
+ input.expectedAccountIdentity !== identity)
480
+ return unavailable('identity-changed');
481
+ const representative = record.state === 'active'
482
+ ? record
483
+ : (inventory.credentials
484
+ .filter((credential) => credential.accountIdentity === identity &&
485
+ credential.state === 'active')
486
+ .toSorted((left, right) => left.credentialId.localeCompare(right.credentialId))[0] ?? record);
487
+ const binding = {
488
+ routeId: input.routeId,
489
+ credentialId: representative.credentialId,
490
+ accountIdentity: identity,
491
+ view: inventory.view,
492
+ };
493
+ return {
494
+ status: 'ready',
495
+ binding,
496
+ ...(previous &&
497
+ previous.accountIdentity !== identity && { replaced: previous }),
498
+ };
499
+ }
@@ -1,6 +1,6 @@
1
1
  import type { AddInput, PoolLockSpec, PoolRow, PoolStore, RemoveOptions } from '../store/index.js';
2
2
  import type { CommandInvocation, KnobValues, MenuChoice, MenuKnob } from './model.js';
3
- import type { ResolvedSection } from './seam.js';
3
+ import { type ResolvedSection } from './seam.js';
4
4
  /**
5
5
  * What a plugin's login hands back. `ready` adds the account now. `pending`
6
6
  * means the user must finish somewhere else (a browser, a device code):
@@ -4,6 +4,7 @@
4
4
  // the same locks as any other writer of those files.
5
5
  import { isQuotaMap, projectQuota, } from '../quota/index.js';
6
6
  import { DEFAULT_FORMER_MAIN_ID, orderForPlacement, resolveRoutingMode, } from '../routing/index.js';
7
+ import { projectFailure, } from './seam.js';
7
8
  /** `disabledReason` recorded when the user disables an account from the menu. */
8
9
  export const MENU_DISABLED_REASON = 'disabled from the command menu';
9
10
  function isRecord(value) {
@@ -99,8 +100,12 @@ function addedText(name, result) {
99
100
  return `${name} was already in the pool; its credential was updated.`;
100
101
  }
101
102
  }
103
+ /**
104
+ * What the user is told about a failed late login: the projected message,
105
+ * never the exception's own text, which can quote the login's request.
106
+ */
102
107
  function failureMessage(error) {
103
- return error instanceof Error ? error.message : String(error);
108
+ return projectFailure(error).text;
104
109
  }
105
110
  async function snapshot(store) {
106
111
  const load = await store.read();
@@ -6,5 +6,5 @@ export type { ActionDefinition, ActionInput, ActionOutcome, CommandApplyRequest,
6
6
  export { SECTION_SLOTS } from './model.js';
7
7
  export type { PiMenuOptions, PiMenuUi } from './pi.js';
8
8
  export { runPiCommandMenu } from './pi.js';
9
- export type { SeamLogger } from './seam.js';
10
- export { DEFAULT_IRREVERSIBLE_CONFIRMATION } from './seam.js';
9
+ export type { ProjectedFailure, SeamLogger, TextRedactor } from './seam.js';
10
+ export { ACTION_FAILED, CommandError, createTextRedactor, DEFAULT_IRREVERSIBLE_CONFIRMATION, projectFailure, } from './seam.js';
@@ -2,4 +2,4 @@ export { MENU_DISABLED_REASON } from './builtins.js';
2
2
  export { createCommandMenu, parseApplyRequest } from './menu.js';
3
3
  export { SECTION_SLOTS } from './model.js';
4
4
  export { runPiCommandMenu } from './pi.js';
5
- export { DEFAULT_IRREVERSIBLE_CONFIRMATION } from './seam.js';
5
+ export { ACTION_FAILED, CommandError, createTextRedactor, DEFAULT_IRREVERSIBLE_CONFIRMATION, projectFailure, } from './seam.js';
@@ -1,3 +1,4 @@
1
+ import { type RedactionOptions } from '../logger/index.js';
1
2
  import type { PoolLockSpec, PoolStore } from '../store/index.js';
2
3
  import { type AccountsSectionOptions, type LimitsSectionOptions, type QuotaSectionOptions, type RoutingSectionOptions } from './builtins.js';
3
4
  import type { CommandApplyRequest, CommandApplyResult, CommandDialogPayload, CommandInvocation, PluginExtraSection, PluginSection } from './model.js';
@@ -22,6 +23,13 @@ export interface CommandMenuOptions {
22
23
  extras?: readonly PluginExtraSection[];
23
24
  /** Receives the seam's warnings and failed actions; defaults to the library logger. */
24
25
  logger?: SeamLogger;
26
+ /**
27
+ * The provider's secret shapes, added to the redactor every string that
28
+ * leaves the menu goes through (payloads and notifications). Without a
29
+ * pattern for its key format, a plugin's API key quoted in an outcome's
30
+ * text is not recognised.
31
+ */
32
+ redaction?: RedactionOptions;
25
33
  now?: () => number;
26
34
  }
27
35
  export interface CommandMenu {