@cortexkit/opencode-openai-auth 1.4.0 → 1.6.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 (66) hide show
  1. package/dist/bin.js +8 -8
  2. package/dist/{codex-request-hw0XcldE.d.ts → codex-request-D_In8uaR.d.ts} +52 -27
  3. package/dist/{index-1eq2s1cp.js → index-1qxz4s8a.js} +2 -2
  4. package/dist/{index-1tdnrt68.js → index-79y7w9bz.js} +1 -1
  5. package/dist/index-7nmdsmpp.js +2 -0
  6. package/dist/{index-ag04nyv7.js → index-8xq099qf.js} +2 -2
  7. package/dist/index-agaatt62.js +65 -0
  8. package/dist/{index-wgwze391.js → index-b0ty9qam.js} +2 -2
  9. package/dist/index-ge60xry3.js +90 -0
  10. package/dist/{index-s88n2bjn.js → index-jkpg9d1p.js} +1 -1
  11. package/dist/index-k5anexvs.js +69 -0
  12. package/dist/{index-yhw3hnsh.js → index-rf0ht3gc.js} +2 -2
  13. package/dist/{index-t5m0dhmq.js → index-x9nffaye.js} +1 -1
  14. package/dist/{index-d98f1xdp.js → index-ya9c5e4m.js} +1 -1
  15. package/dist/{index-rsvnabbe.js → index-z8bptbrk.js} +2 -2
  16. package/dist/index.d.ts +2 -2
  17. package/dist/index.js +1 -1
  18. package/dist/{model-DphbMQce.d.ts → model-D22uj0OB.d.ts} +1 -1
  19. package/dist/{port-file-Cx-wwe5Q.d.ts → port-file-8ElfwDcq.d.ts} +1 -1
  20. package/dist/{protocol-CDqdjh74.d.ts → protocol-CK9guiFy.d.ts} +1 -1
  21. package/dist/rpc/port-file.d.ts +1 -1
  22. package/dist/rpc/port-file.js +1 -1
  23. package/dist/rpc/protocol.d.ts +1 -1
  24. package/dist/rpc/protocol.js +1 -1
  25. package/dist/rpc/rpc-client.d.ts +3 -3
  26. package/dist/rpc/rpc-client.js +1 -1
  27. package/dist/rpc/rpc-dir.js +1 -1
  28. package/dist/{sidebar-state-pTd9gg9W.d.ts → sidebar-state-Bb9L9oPq.d.ts} +4 -4
  29. package/dist/sidebar-state.d.ts +1 -1
  30. package/dist/sidebar-state.js +1 -1
  31. package/dist/token-util-nyvn9866.js +1 -0
  32. package/dist/{token-0z5g59p8.js → token-xtq3457d.js} +1 -1
  33. package/dist/tui-preferences.js +1 -1
  34. package/dist/tui.d.ts +2 -2
  35. package/dist/v2/server.d.ts +2 -2
  36. package/dist/v2/server.js +2 -2
  37. package/package.json +3 -3
  38. package/src/tui-compiled/raw/shared/169b2b4bf30909bc-consumer.js +17 -2
  39. package/src/tui-compiled/raw/shared/30f5bc480f31f51c-roster.js +72 -0
  40. package/src/tui-compiled/raw/shared/375d9cca2def037d-private-files.js +49 -3
  41. package/src/tui-compiled/raw/shared/754d6017f8e419cf-builtins.js +95 -13
  42. package/src/tui-compiled/raw/shared/81dddd203f1b92a3-quota-writer.js +4 -54
  43. package/src/tui-compiled/raw/shared/a62a27a204266acd-attribution.js +39 -25
  44. package/src/tui-compiled/raw/shared/a910277ac43a8626-pull.js +3 -1
  45. package/src/tui-compiled/raw/shared/b9588ad4725c1d10-enrollment.js +27 -0
  46. package/src/tui-compiled/raw/shared/c852a15cfe489d12-pool.js +18 -3
  47. package/src/tui-compiled/raw/shared/c911dbb64c9d85c7-quota-writer.js +298 -0
  48. package/src/tui-compiled/raw/shared/e1c5e56df0ba4053-pending.js +48 -0
  49. package/src/tui-compiled/raw/shared/e8d3c8da776ef9cd-index.js +1 -1
  50. package/src/tui-compiled/runtime/shared/169b2b4bf30909bc-consumer.js +17 -2
  51. package/src/tui-compiled/runtime/shared/30f5bc480f31f51c-roster.js +72 -0
  52. package/src/tui-compiled/runtime/shared/375d9cca2def037d-private-files.js +49 -3
  53. package/src/tui-compiled/runtime/shared/754d6017f8e419cf-builtins.js +95 -13
  54. package/src/tui-compiled/runtime/shared/81dddd203f1b92a3-quota-writer.js +4 -54
  55. package/src/tui-compiled/runtime/shared/a62a27a204266acd-attribution.js +39 -25
  56. package/src/tui-compiled/runtime/shared/a910277ac43a8626-pull.js +3 -1
  57. package/src/tui-compiled/runtime/shared/b9588ad4725c1d10-enrollment.js +27 -0
  58. package/src/tui-compiled/runtime/shared/c852a15cfe489d12-pool.js +18 -3
  59. package/src/tui-compiled/runtime/shared/c911dbb64c9d85c7-quota-writer.js +298 -0
  60. package/src/tui-compiled/runtime/shared/e1c5e56df0ba4053-pending.js +48 -0
  61. package/src/tui-compiled/runtime/shared/e8d3c8da776ef9cd-index.js +1 -1
  62. package/dist/index-5230x6s8.js +0 -2
  63. package/dist/index-brbqqer5.js +0 -90
  64. package/dist/index-jcyrm58f.js +0 -65
  65. package/dist/index-vy729a0b.js +0 -69
  66. package/dist/token-util-jf9g6stk.js +0 -1
@@ -0,0 +1,298 @@
1
+ import { createLogger } from "./cc9e0702a752cea7-index.js";
2
+ import { assertQuotaEpoch, assertQuotaRow, mergedQuota, } from "./a62a27a204266acd-attribution.js";
3
+ import { PoolOperationError } from "./04a6357627de5ed0-errors.js";
4
+ import { toFailure, withTransaction } from "./458b934e92490331-mutate.js";
5
+ import { LockStack } from "./43fc9e6b994519c7-refresh-lock.js";
6
+ import { readRow, refusal } from "./83bc3ba9d0fe2761-runtime.js";
7
+ import { entryIn } from "./cd94316a888b0a8e-schema.js";
8
+ const COALESCE_MS = 100;
9
+ /**
10
+ * The most readings one row keeps waiting for a flush. Hosts record a reading
11
+ * about every 100 ms at most, so for them a queue this long means another
12
+ * writer has held the store locks for many seconds. Reaching it starts
13
+ * a flush at once instead of waiting for the coalescing timer or a retry
14
+ * backoff; a further reading for the row waits for room (never dropping an
15
+ * older one) for as long as the store's normal lock budget, and is then
16
+ * refused with `lock-contention`, as the immediate writer would refuse it.
17
+ */
18
+ export const POOL_QUOTA_QUEUE_LIMIT = 128;
19
+ // Readings are queued whole per row and applied at flush in arrival order
20
+ // through the codec's own merge, so the codec alone decides which fields a
21
+ // newer or older reading replaces. Like the roster writer, a reading stays
22
+ // queued until the flush that took it commits: one queued while a flush waits
23
+ // for its locks must survive that flush.
24
+ export class PoolQuotaWriter {
25
+ rt;
26
+ timeoutMs;
27
+ pending = new Map();
28
+ warned = new Set();
29
+ wake = new Set();
30
+ logger;
31
+ timer;
32
+ running;
33
+ closing;
34
+ closed = false;
35
+ stopped = false;
36
+ failures = 0;
37
+ constructor(rt, timeoutMs = 1_000) {
38
+ this.rt = rt;
39
+ this.timeoutMs = timeoutMs;
40
+ this.logger = rt.ctx.logger ?? createLogger('store');
41
+ }
42
+ async record(id, attribution, observation) {
43
+ try {
44
+ this.assertOpen(id);
45
+ assertQuotaEpoch(id, attribution);
46
+ // Snapshot before the first await: callers may reuse their headers object.
47
+ const publication = structuredClone({
48
+ id,
49
+ attribution,
50
+ observation,
51
+ });
52
+ const { row, result } = await readRow(this.rt, 'pull', id);
53
+ assertQuotaRow(id, publication.attribution, row);
54
+ // Merge into what read() shows now, so a reading the codec refuses is
55
+ // refused to its caller here, as the immediate writer would refuse it.
56
+ mergedQuota(this.rt.ctx, id, this.overlayRow({ ...row, quota: entryIn(result.config, id)?.quota })
57
+ .quota, publication.observation);
58
+ await this.room(id);
59
+ this.assertOpen(id);
60
+ // No await between the room check and the push, so the cap holds.
61
+ const queue = this.pending.get(id) ?? [];
62
+ queue.push(publication);
63
+ this.pending.set(id, queue);
64
+ if (queue.length >= POOL_QUOTA_QUEUE_LIMIT)
65
+ this.flushNow();
66
+ else
67
+ this.schedule(COALESCE_MS);
68
+ }
69
+ catch (error) {
70
+ throw toFailure(error, 'pull', id, { writes: 0 });
71
+ }
72
+ }
73
+ queued(id) {
74
+ return this.pending.get(id)?.length ?? 0;
75
+ }
76
+ // Waits while the row's queue is full, flushing at once on every wake-up,
77
+ // for at most the store's own lock acquisition budget.
78
+ async room(id) {
79
+ if (this.queued(id) < POOL_QUOTA_QUEUE_LIMIT)
80
+ return;
81
+ const { ctx } = this.rt;
82
+ const budget = Math.max(0, ...ctx.storeLocks.map((spec) => spec.timeoutMs ?? ctx.lockDefaults.timeoutMs));
83
+ const deadline = performance.now() + budget;
84
+ while (this.queued(id) >= POOL_QUOTA_QUEUE_LIMIT) {
85
+ this.assertOpen(id);
86
+ this.flushNow();
87
+ const left = deadline - performance.now();
88
+ if (left <= 0)
89
+ throw refusal('pull', id, 'lock-contention', `pool quota queue for ${id} is full (${POOL_QUOTA_QUEUE_LIMIT} readings) and the store locks stayed held`, true);
90
+ await new Promise((resolve) => {
91
+ const done = () => {
92
+ clearTimeout(timer);
93
+ this.wake.delete(done);
94
+ resolve();
95
+ };
96
+ const timer = setTimeout(done, left);
97
+ this.wake.add(done);
98
+ });
99
+ }
100
+ }
101
+ assertOpen(id) {
102
+ if (this.closed)
103
+ throw refusal('pull', id, 'invalid-input', 'pool quota writer is closed');
104
+ }
105
+ // What read() and load() show: the stored map, then each queued reading of
106
+ // the row still bound to it, in arrival order through the codec's merge.
107
+ // A reading the codec now refuses is left out, as the flush drops it.
108
+ overlayRow(row) {
109
+ let next = row;
110
+ for (const publication of this.pending.get(row.id) ?? []) {
111
+ try {
112
+ assertQuotaRow(row.id, publication.attribution, row);
113
+ next = {
114
+ ...next,
115
+ quota: mergedQuota(this.rt.ctx, row.id, next.quota, publication.observation),
116
+ needsFirstReading: false,
117
+ };
118
+ }
119
+ catch { }
120
+ }
121
+ return next;
122
+ }
123
+ overlay(rows) {
124
+ return rows.map((row) => this.overlayRow(row));
125
+ }
126
+ schedule(delay) {
127
+ if (this.running || this.timer || !this.pending.size || this.stopped)
128
+ return;
129
+ this.timer = setTimeout(() => {
130
+ this.timer = undefined;
131
+ this.flush();
132
+ }, delay);
133
+ if (!this.closed)
134
+ this.timer.unref?.();
135
+ }
136
+ // Starts a flush now rather than at the coalescing timer or retry backoff;
137
+ // a flush already running is left to finish.
138
+ flushNow() {
139
+ if (this.running)
140
+ return;
141
+ clearTimeout(this.timer);
142
+ this.timer = undefined;
143
+ this.flush();
144
+ }
145
+ flush() {
146
+ if (this.running || this.stopped || !this.pending.size)
147
+ return;
148
+ // Every queued reading, each row's in arrival order.
149
+ const batch = [...this.pending.values()].flat();
150
+ // Short lock attempts allow retries to pick up freshly queued readings and
151
+ // bound shutdown even when the normal store acquisition budget is long.
152
+ const ctx = {
153
+ ...this.rt.ctx,
154
+ storeLocks: this.rt.ctx.storeLocks.map((spec) => ({
155
+ ...spec,
156
+ timeoutMs: Math.min(spec.timeoutMs ?? this.rt.ctx.lockDefaults.timeoutMs, 100),
157
+ })),
158
+ };
159
+ const locks = new LockStack(ctx.lockDefaults, ctx.lockEnv);
160
+ this.running = (async () => {
161
+ try {
162
+ await withTransaction(ctx, locks, { writes: 0 }, { operation: 'pull', rowId: undefined }, async (tx) => {
163
+ if (this.stopped)
164
+ return;
165
+ let changed = false;
166
+ for (const publication of batch) {
167
+ const { id, attribution, observation } = publication;
168
+ const witness = JSON.stringify([
169
+ id,
170
+ attribution.credentialEpoch,
171
+ attribution.identity,
172
+ ]);
173
+ try {
174
+ tx.assertNotStaged(id);
175
+ assertQuotaRow(id, attribution, tx.row(id));
176
+ }
177
+ catch (error) {
178
+ if (!(error instanceof PoolOperationError))
179
+ throw error;
180
+ if (!this.warned.has(witness)) {
181
+ this.warned.add(witness);
182
+ this.warn('pool quota publication dropped; account binding changed', { id, kind: error.kind });
183
+ }
184
+ continue;
185
+ }
186
+ const entry = tx.entry(id);
187
+ if (!entry)
188
+ continue;
189
+ let quota;
190
+ try {
191
+ quota = mergedQuota(ctx, id, entry.quota, observation);
192
+ }
193
+ catch (error) {
194
+ // The codec accepted this reading when it was queued, but not
195
+ // against what is stored now (another writer changed it).
196
+ // Drop only this reading: failing the transaction would retry
197
+ // the same refusal forever and hold back every other row.
198
+ const key = `codec:${witness}`;
199
+ if (!this.warned.has(key)) {
200
+ this.warned.add(key);
201
+ this.warn('pool quota publication dropped; the quota codec refused it', {
202
+ id,
203
+ error: error instanceof Error ? error.message : String(error),
204
+ });
205
+ }
206
+ continue;
207
+ }
208
+ tx.setEntry(id, { ...entry, quota, needsFirstReading: false });
209
+ changed = true;
210
+ }
211
+ if (changed && !this.stopped)
212
+ await tx.commitConfig();
213
+ }, { completeTorn: false });
214
+ const flushed = new Set(batch);
215
+ for (const [id, queue] of this.pending) {
216
+ const rest = queue.filter((publication) => !flushed.has(publication));
217
+ if (rest.length)
218
+ this.pending.set(id, rest);
219
+ else
220
+ this.pending.delete(id);
221
+ }
222
+ this.failures = 0;
223
+ }
224
+ catch (error) {
225
+ if (!this.stopped && this.failures++ === 0)
226
+ this.warn('pool quota publication delayed; retrying', {
227
+ error: error instanceof Error ? error.message : String(error),
228
+ });
229
+ // Retain the mailbox on failure, including fresher readings queued meanwhile.
230
+ }
231
+ finally {
232
+ await locks.releaseAll();
233
+ this.running = undefined;
234
+ for (const wake of this.wake)
235
+ wake();
236
+ const delay = this.failures
237
+ ? Math.min(5_000, 100 * 2 ** Math.min(this.failures, 6)) *
238
+ (0.5 + Math.random())
239
+ : COALESCE_MS;
240
+ this.schedule(delay);
241
+ }
242
+ })();
243
+ }
244
+ warn(message, data) {
245
+ try {
246
+ this.logger.warn(message, data);
247
+ }
248
+ catch {
249
+ /* Logging cannot stop publication. */
250
+ }
251
+ }
252
+ close() {
253
+ if (this.closing)
254
+ return this.closing;
255
+ this.closed = true;
256
+ this.closing = this.drain();
257
+ return this.closing;
258
+ }
259
+ async drain() {
260
+ clearTimeout(this.timer);
261
+ this.timer = undefined;
262
+ let deadline;
263
+ let wake;
264
+ const drained = new Promise((done) => {
265
+ wake = () => {
266
+ if (!this.pending.size)
267
+ done();
268
+ };
269
+ this.wake.add(wake);
270
+ wake();
271
+ });
272
+ try {
273
+ this.flush();
274
+ await Promise.race([
275
+ drained,
276
+ new Promise((done) => {
277
+ deadline = setTimeout(done, Math.max(0, this.timeoutMs));
278
+ }),
279
+ ]);
280
+ }
281
+ finally {
282
+ this.stopped = true;
283
+ clearTimeout(deadline);
284
+ clearTimeout(this.timer);
285
+ this.wake.delete(wake);
286
+ if (this.pending.size)
287
+ this.warn('pool quota drain timed out; pending readings left', {
288
+ accounts: this.pending.size,
289
+ readings: [...this.pending.values()].reduce((sum, queue) => sum + queue.length, 0),
290
+ });
291
+ this.pending.clear();
292
+ // A reading waiting for room in a full queue is refused as closed when
293
+ // the drain ends, not when its own lock budget runs out.
294
+ for (const wake of this.wake)
295
+ wake();
296
+ }
297
+ }
298
+ }
@@ -0,0 +1,48 @@
1
+ // Use the same independently dated parts as mergeQuotaObservation. A header
2
+ // reading cannot erase a poll-only window, counter, credits record or budget.
3
+ export function quotaFields(publication, witness = '') {
4
+ const result = new Map();
5
+ const { observation } = publication;
6
+ const add = (key, at, part) => result.set(`${witness}:${key}`, {
7
+ at,
8
+ order: 0,
9
+ publication: { ...publication, observation: part },
10
+ });
11
+ const keyFor = (scope, label) => JSON.stringify([scope ?? 'all', label]);
12
+ const read = new Set();
13
+ for (const reading of observation.readings ?? []) {
14
+ const key = keyFor(reading.scope, reading.label);
15
+ read.add(key);
16
+ add(key, observation.checkedAt, {
17
+ checkedAt: observation.checkedAt,
18
+ readings: [{ ...reading }],
19
+ });
20
+ }
21
+ for (const pair of observation.coverage ?? []) {
22
+ const key = keyFor(pair.scope, pair.label);
23
+ if (!read.has(key))
24
+ add(key, observation.checkedAt, {
25
+ checkedAt: observation.checkedAt,
26
+ coverage: [{ ...pair }],
27
+ });
28
+ }
29
+ if (observation.budget)
30
+ add('budget', observation.checkedAt, {
31
+ checkedAt: observation.checkedAt,
32
+ budget: { ...observation.budget },
33
+ });
34
+ for (const [name, counter] of Object.entries(observation.counters ?? {})) {
35
+ add(`counter:${name}`, counter.checkedAt, {
36
+ checkedAt: observation.checkedAt,
37
+ counters: { [name]: { ...counter } },
38
+ });
39
+ }
40
+ if (observation.credits)
41
+ add('credits', observation.credits.checkedAt, {
42
+ checkedAt: observation.checkedAt,
43
+ credits: { ...observation.credits },
44
+ });
45
+ if (!result.size)
46
+ add('empty', observation.checkedAt, { checkedAt: observation.checkedAt });
47
+ return result;
48
+ }
@@ -15,7 +15,7 @@ export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHO
15
15
  export { acquireInsulaQuotaSource, } from "./d0f0b6be200311af-insula.js";
16
16
  export { acceptAccount, declineAccount, isDeclined, } from "./7c140680fc81be92-interlock.js";
17
17
  export { drainVaultQuota } from "./81dddd203f1b92a3-quota-writer.js";
18
- export { acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, projectVaultRoster, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, vaultRoutingRows, } from "./30f5bc480f31f51c-roster.js";
18
+ export { acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, projectVaultRoster, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, reorderVaultRoster, resolveVaultPrimary, vaultRoutingRows, } from "./30f5bc480f31f51c-roster.js";
19
19
  export { ChangeNoticeReader, RESUBSCRIBE_BACKOFF_MS, SAFETY_POLL_MS, ScopedRosterWatch, WATCH_RETRY_MS, } from "./dc9290c2a7552ba6-watch.js";
20
20
  /**
21
21
  * Connect the client that lists and fetches this consumer's vault credentials
@@ -5,7 +5,7 @@ import { readClaustrumEnrollmentToken } from "./b9588ad4725c1d10-enrollment.js";
5
5
  import { ClaustrumConsumerError } from "./ca0fccaba1d87af4-errors.js";
6
6
  import { acquireInsulaQuotaSource, } from "./d0f0b6be200311af-insula.js";
7
7
  import { pendingVaultQuota, retainVaultQuotaWriter } from "./81dddd203f1b92a3-quota-writer.js";
8
- import { acceptVaultRoute, applyVaultQuota, declineVaultRoute, forgetPendingVaultDiscovery, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from "./30f5bc480f31f51c-roster.js";
8
+ import { acceptVaultRoute, applyVaultQuota, declineVaultRoute, forgetPendingVaultDiscovery, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, reorderVaultRoster, vaultRoutingRows, } from "./30f5bc480f31f51c-roster.js";
9
9
  import { SAFETY_POLL_MS } from "./dc9290c2a7552ba6-watch.js";
10
10
  /**
11
11
  * One plugin host's view of its vault accounts. It turns vault credentials
@@ -485,8 +485,12 @@ export class ClaustrumConsumer {
485
485
  let current;
486
486
  try {
487
487
  current = await this.authorize(routeId, signal);
488
+ // Cancellation can land after authorize settles, before this continuation.
489
+ signal?.throwIfAborted();
488
490
  }
489
- catch {
491
+ catch (error) {
492
+ if (signal?.aborted)
493
+ throw error;
490
494
  // Re-authorization failed: keep this 401 and report it against the
491
495
  // receipt (served record version) this request was sent with.
492
496
  }
@@ -522,6 +526,17 @@ export class ClaustrumConsumer {
522
526
  await acceptVaultRoute(this.#options.rosterPath, routeId);
523
527
  this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
524
528
  }
529
+ /**
530
+ * Set the order vault accounts are tried in (`reorderVaultRoster`), then
531
+ * re-read the roster, so the next routing decision in this process uses
532
+ * the new order without waiting for a discovery. Never contacts the vault.
533
+ */
534
+ async reorder(routeIds) {
535
+ this.#assertOpen();
536
+ const result = await reorderVaultRoster(this.#options.rosterPath, routeIds);
537
+ this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
538
+ return result;
539
+ }
525
540
  /**
526
541
  * Store a quota or profile observation for a vault route. The receipt the
527
542
  * reading was taken with is required: the observation lands only while the
@@ -378,6 +378,78 @@ export function acceptVaultRoute(path, routeId) {
378
378
  };
379
379
  });
380
380
  }
381
+ function invalidOrder(message) {
382
+ return new ClaustrumConsumerError('invalid-order', message);
383
+ }
384
+ /**
385
+ * Why `routeIds` is not an order of these rows: it must name every distinct
386
+ * route id exactly once and nothing else. The message quotes only ids the
387
+ * caller supplied; route ids it left out are counted, not named.
388
+ */
389
+ function vaultOrderProblem(rows, routeIds) {
390
+ if (!Array.isArray(routeIds))
391
+ return 'route ids must be an array of route ids';
392
+ const rosterIds = new Set(rows.map((row) => row.routeId));
393
+ const given = new Set();
394
+ for (const id of routeIds) {
395
+ if (typeof id !== 'string')
396
+ return 'route ids must be an array of route ids';
397
+ if (given.has(id))
398
+ return `route id ${id} appears more than once`;
399
+ if (!rosterIds.has(id))
400
+ return `route id ${id} is not in the roster`;
401
+ given.add(id);
402
+ }
403
+ const missing = [...rosterIds].filter((id) => !given.has(id)).length;
404
+ if (missing > 0)
405
+ return `the order leaves out ${missing} roster route id${missing === 1 ? '' : 's'}`;
406
+ return undefined;
407
+ }
408
+ /**
409
+ * Set the order vault accounts are tried in. Routing reads that order from
410
+ * the roster's row order (`vaultRoutingRows`), and a later discovery keeps
411
+ * each account at its position, so the order lasts until the next reorder.
412
+ * The order is this host's routing preference: the vault has no order of its
413
+ * own, and this never contacts it.
414
+ *
415
+ * `routeIds` must name every roster route id exactly once; anything else
416
+ * (an unknown, missing or repeated id) refuses with `invalid-order` and
417
+ * writes nothing. It runs under the roster's write lock, like every other
418
+ * roster write, so a quota reading or decline committed meanwhile is kept.
419
+ * Each row is kept as the same object, so only the positions change: every
420
+ * row's fields and the rest of the file stay as they were. An order equal to
421
+ * the current one writes nothing. A host with no roster file yet has no
422
+ * routes, so only the empty order is accepted.
423
+ *
424
+ * A `ClaustrumConsumer` in the same process does not see the new order until
425
+ * it re-reads the file: call `consumer.reorder` instead to update both.
426
+ */
427
+ export function reorderVaultRoster(path, routeIds, options = {}) {
428
+ return mutateVaultRoster(path, (current) => {
429
+ const rows = current?.rows ?? [];
430
+ const problem = vaultOrderProblem(rows, routeIds);
431
+ if (problem)
432
+ throw invalidOrder(problem);
433
+ // Group by route id so a hand-edited file that repeats one keeps every
434
+ // copy: the later copies travel right after the first.
435
+ const byId = new Map();
436
+ for (const row of rows) {
437
+ const group = byId.get(row.routeId);
438
+ if (group)
439
+ group.push(row);
440
+ else
441
+ byId.set(row.routeId, [row]);
442
+ }
443
+ const ids = [...routeIds];
444
+ const next = ids.flatMap((id) => byId.get(id) ?? []);
445
+ if (!current || next.every((row, index) => row === rows[index]))
446
+ return { result: { ids, outcome: 'unchanged' } };
447
+ return {
448
+ next: { ...current, rows: next },
449
+ result: { ids, outcome: 'reordered' },
450
+ };
451
+ }, { ...(options.signal && { signal: options.signal }) });
452
+ }
381
453
  /**
382
454
  * What a receipt may do to a row's quota: `hold` (the row holds the receipt's
383
455
  * credential and account), `bind` (the row has never had a known account and
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { lstat, readFile, rename, unlink } from "node:fs/promises";
3
- import { dirname, resolve } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
4
5
  export const windowsPrivateFiles = process.platform === 'win32';
5
6
  const migrationNotices = new Set();
6
7
  function ioCode(detail) {
@@ -20,8 +21,51 @@ let backend;
20
21
  export function setPrivateFilesBackendForTest(api) {
21
22
  backend = api ? Promise.resolve(api) : undefined;
22
23
  }
24
+ /** The fixed `reason` of a refusal because the Windows native addon did not load. */
25
+ export const PRIVATE_FILES_UNAVAILABLE_REASON = 'native-addon-unavailable';
26
+ function privateFilesUnavailable(cause) {
27
+ return Object.assign(new Error('Windows private-files native addon is unavailable', { cause }), {
28
+ code: 'ERR_PRIVATE_FILES_UNAVAILABLE',
29
+ reason: PRIVATE_FILES_UNAVAILABLE_REASON,
30
+ });
31
+ }
32
+ function isUnavailable(api, error) {
33
+ return (error instanceof api.PrivateFilesUnavailableError ||
34
+ error?.code ===
35
+ 'ERR_PRIVATE_FILES_UNAVAILABLE');
36
+ }
37
+ /**
38
+ * Importing @cortexkit/private-files succeeds even when its Windows addon
39
+ * cannot load: the package loads the addon on first use, and some of its
40
+ * checks then report the failure as an ordinary refusal such as
41
+ * `unsupported-filesystem`. So prove the addon loads before handing the
42
+ * package out. Verifying a path that does not exist loads the addon first and
43
+ * then fails harmlessly; only the package's own "unavailable" error means the
44
+ * addon is missing.
45
+ */
46
+ async function assertNativeAddonLoads(api) {
47
+ try {
48
+ await api.verifyPrivatePath(join(tmpdir(), `.common-auth-private-files-probe-${randomUUID()}`), 'file');
49
+ }
50
+ catch (error) {
51
+ if (isUnavailable(api, error))
52
+ throw privateFilesUnavailable(error);
53
+ }
54
+ }
55
+ /**
56
+ * Load the package once per process. On Windows a failed import or addon load
57
+ * is kept, so every later private-file operation refuses at once with
58
+ * ERR_PRIVATE_FILES_UNAVAILABLE. There is no fallback to permission bits:
59
+ * they do not protect files on Windows.
60
+ */
23
61
  function loadBackend() {
24
- backend ??= import("@cortexkit/private-files");
62
+ backend ??= import("@cortexkit/private-files").then(async (api) => {
63
+ if (windowsPrivateFiles)
64
+ await assertNativeAddonLoads(api);
65
+ return api;
66
+ }, (cause) => {
67
+ throw windowsPrivateFiles ? privateFilesUnavailable(cause) : cause;
68
+ });
25
69
  return backend;
26
70
  }
27
71
  export class PrivateFileError extends Error {
@@ -38,7 +82,9 @@ export class PrivateFileError extends Error {
38
82
  orphanPath;
39
83
  constructor(path, stage, cause) {
40
84
  const detail = cause;
41
- super(`Private file refused at ${stage}: ${path} (${detail?.reason ?? detail?.code ?? 'io-error'}). Use a safe parent directory. Repair only a named secret file in safe ancestry whose owner the published policy trusts, without changing its owner or any existing directory. Previously exposed credentials may need rotation. Windows requires the private-files native prebuild and an ACL-persisting local filesystem such as NTFS.`, { cause });
85
+ super(detail?.code === 'ERR_PRIVATE_FILES_UNAVAILABLE'
86
+ ? `Private file refused at ${stage}: ${path} (${PRIVATE_FILES_UNAVAILABLE_REASON}). The Windows native addon of @cortexkit/private-files could not be loaded, so owner-only access cannot be enforced and nothing was read or written. Reinstall the plugin so the private-files Windows prebuild for this architecture is present; Windows has no safe fallback to permission bits.`
87
+ : `Private file refused at ${stage}: ${path} (${detail?.reason ?? detail?.code ?? 'io-error'}). Keep this file in a folder that only you, the system or administrators can change, such as a folder in your home directory. If other users could read the file before, sign in again to replace the credentials it held. On Windows the file must also be on a local drive that keeps Windows file permissions, such as NTFS.`, { cause });
42
88
  this.path = path;
43
89
  this.stage = stage;
44
90
  this.name = 'PrivateFileError';