@cortexkit/common-auth 0.11.1 → 0.11.3
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/dist/claustrum/custody.d.ts +6 -0
- package/dist/claustrum/custody.js +3 -0
- package/dist/opencode2/errors.d.ts +3 -1
- package/dist/opencode2/install.js +124 -68
- package/dist/opencode2/types.d.ts +5 -2
- package/dist/rpc/rpc-server.js +3 -3
- package/dist/store/pool.d.ts +4 -0
- package/dist/store/rows.d.ts +3 -2
- package/dist/store/rows.js +88 -6
- package/dist/store/torn.js +6 -0
- package/package.json +1 -1
|
@@ -87,6 +87,12 @@ export interface ClaustrumScopedAttempt {
|
|
|
87
87
|
* in from the roster or from a token parse.
|
|
88
88
|
*/
|
|
89
89
|
readonly assertedAccountIdentity?: string;
|
|
90
|
+
/**
|
|
91
|
+
* The non-secret Google Cloud project id the vault served in get_scoped for
|
|
92
|
+
* this attempt, if any. Never filled in from list_scoped, the roster or a
|
|
93
|
+
* token parse. A project change in the vault takes effect on the next send.
|
|
94
|
+
*/
|
|
95
|
+
readonly projectId?: string;
|
|
90
96
|
/**
|
|
91
97
|
* Kept in memory only and hidden from JSON.stringify and object spreads, so
|
|
92
98
|
* logging a receipt never leaks it. Authorize again for every dispatch and retry.
|
|
@@ -298,6 +298,9 @@ export class ClaustrumScopedCustody {
|
|
|
298
298
|
...(assertedIdentity !== undefined && {
|
|
299
299
|
assertedAccountIdentity: assertedIdentity,
|
|
300
300
|
}),
|
|
301
|
+
...(served.projectId !== undefined && {
|
|
302
|
+
projectId: served.projectId,
|
|
303
|
+
}),
|
|
301
304
|
recordVersion: served.recordVersion,
|
|
302
305
|
expiresAtMs,
|
|
303
306
|
}, 'accessToken', { value: accessToken, enumerable: false }));
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Why an OpenCode 2 auth hook refused a request.
|
|
3
3
|
*
|
|
4
4
|
* - `no-account`: the adapter had no account to offer for this request, so
|
|
5
|
-
* the request is stopped before the host picks a transport.
|
|
5
|
+
* the request is stopped before the host picks a transport. Also used when
|
|
6
|
+
* session forgetting or installation disposal revokes an in-flight auth
|
|
7
|
+
* selection: that selection no longer has an account it may send under.
|
|
6
8
|
* - `host-credential-on-wire`: after every rewrite, a header still carried
|
|
7
9
|
* one of the host's placeholder credentials. Sending it would leak a
|
|
8
10
|
* non-routable value to the provider and fail with a confusing error.
|
|
@@ -129,6 +129,9 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
129
129
|
let warnedUnmarkedSend = false;
|
|
130
130
|
const listeners = new Map();
|
|
131
131
|
let seq = 0;
|
|
132
|
+
let disposed = false;
|
|
133
|
+
/** Only active hook calls are held; forgetting never leaves a session tombstone. */
|
|
134
|
+
const inFlight = new Map();
|
|
132
135
|
const warn = (message, data) => {
|
|
133
136
|
try {
|
|
134
137
|
logger?.warn(message, data);
|
|
@@ -200,12 +203,47 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
200
203
|
const accountOf = (rec) => rec.accountId === undefined
|
|
201
204
|
? undefined
|
|
202
205
|
: { ...rec.scope, accountId: rec.accountId };
|
|
203
|
-
const noAccount = (scope) => new OpenCode2AuthError({
|
|
206
|
+
const noAccount = (scope, message) => new OpenCode2AuthError({
|
|
204
207
|
kind: 'no-account',
|
|
205
208
|
providerID,
|
|
206
209
|
sessionID: scope.sessionID,
|
|
207
210
|
requestKind: scope.kind,
|
|
211
|
+
...(message === undefined ? {} : { message }),
|
|
208
212
|
});
|
|
213
|
+
const revokeSelections = (sessionID) => {
|
|
214
|
+
inFlight.get(sessionID)?.clear();
|
|
215
|
+
inFlight.delete(sessionID);
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* Owns selection through its transport handoff, not just the adapter calls.
|
|
219
|
+
* Deleting a session's set revokes its old calls without poisoning reuse of
|
|
220
|
+
* that id. Each await must recheck before calling the adapter or publishing.
|
|
221
|
+
*/
|
|
222
|
+
const withSelection = async (scope, run) => {
|
|
223
|
+
const revoked = () => noAccount(scope, `${providerID} ${scope.kind} request has no account: its auth selection was revoked by session forgetting or installation disposal`);
|
|
224
|
+
if (disposed)
|
|
225
|
+
throw revoked();
|
|
226
|
+
let selections = inFlight.get(scope.sessionID);
|
|
227
|
+
if (!selections) {
|
|
228
|
+
selections = new Set();
|
|
229
|
+
inFlight.set(scope.sessionID, selections);
|
|
230
|
+
}
|
|
231
|
+
const token = {};
|
|
232
|
+
selections.add(token);
|
|
233
|
+
const assertActive = () => {
|
|
234
|
+
if (!selections.has(token))
|
|
235
|
+
throw revoked();
|
|
236
|
+
};
|
|
237
|
+
try {
|
|
238
|
+
return await run(assertActive);
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
selections.delete(token);
|
|
242
|
+
// An old call may settle after a new call has reused the session id.
|
|
243
|
+
if (selections.size === 0 && inFlight.get(scope.sessionID) === selections)
|
|
244
|
+
inFlight.delete(scope.sessionID);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
209
247
|
const guard = (scope, headers) => {
|
|
210
248
|
for (const [name, value] of headers) {
|
|
211
249
|
if (forbidden.some((secret) => value.includes(secret))) {
|
|
@@ -240,7 +278,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
240
278
|
}
|
|
241
279
|
}
|
|
242
280
|
};
|
|
243
|
-
const select = async (scope, hook, transport) => {
|
|
281
|
+
const select = async (scope, hook, assertActive, transport) => {
|
|
244
282
|
const key = keyOf(scope.sessionID, scope.kind);
|
|
245
283
|
const retried = pendingRetries.get(key);
|
|
246
284
|
pendingRetries.delete(key);
|
|
@@ -254,10 +292,12 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
254
292
|
: { rerouteFrom: retried.rerouteFrom }),
|
|
255
293
|
};
|
|
256
294
|
const accountId = await adapter.chooseAccount(input);
|
|
295
|
+
assertActive();
|
|
257
296
|
let headers = {};
|
|
258
297
|
let data;
|
|
259
298
|
if (accountId !== undefined) {
|
|
260
299
|
const result = await adapter.accountHeaders({ ...scope, accountId });
|
|
300
|
+
assertActive();
|
|
261
301
|
if (isHeadersResult(result)) {
|
|
262
302
|
headers = result.headers;
|
|
263
303
|
data = result.attempt;
|
|
@@ -306,7 +346,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
306
346
|
// of the attempt it started. Choosing here too keeps a request that skipped
|
|
307
347
|
// it from going out under the host credential, and a second send of the
|
|
308
348
|
// same attempt from reusing the first send's attempt.
|
|
309
|
-
const bind = async (scope, hook, transport, mark) => {
|
|
349
|
+
const bind = async (scope, hook, transport, mark, assertActive) => {
|
|
310
350
|
const key = keyOf(scope.sessionID, scope.kind);
|
|
311
351
|
let rec = mark === undefined ? undefined : attempts.get(mark);
|
|
312
352
|
if (rec?.key !== key)
|
|
@@ -322,7 +362,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
322
362
|
}
|
|
323
363
|
}
|
|
324
364
|
if (!rec || rec.transport !== undefined || rec.ended)
|
|
325
|
-
rec = await select(scope, hook, transport);
|
|
365
|
+
rec = await select(scope, hook, assertActive, transport);
|
|
326
366
|
else {
|
|
327
367
|
rec.transport = transport;
|
|
328
368
|
if (rec.attempt)
|
|
@@ -443,18 +483,20 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
443
483
|
};
|
|
444
484
|
const scoped = { providerID };
|
|
445
485
|
const registrations = [];
|
|
446
|
-
registrations.push(await ctx.session.hook('model.request',
|
|
447
|
-
const rec = await select(scopeOf(draft), 'model.request');
|
|
486
|
+
registrations.push(await ctx.session.hook('model.request', (draft) => withSelection(scopeOf(draft), async (assertActive) => {
|
|
487
|
+
const rec = await select(scopeOf(draft), 'model.request', assertActive);
|
|
488
|
+
assertActive();
|
|
448
489
|
if (rec.accountId === undefined)
|
|
449
490
|
throw noAccount(rec.scope);
|
|
450
491
|
applyHeaderEdits(draft.headers, {
|
|
451
492
|
...rec.headers,
|
|
452
493
|
[ATTEMPT_HEADER]: rec.id,
|
|
453
494
|
});
|
|
454
|
-
}, scoped));
|
|
455
|
-
registrations.push(await ctx.session.hook('http.request',
|
|
495
|
+
}), scoped));
|
|
496
|
+
registrations.push(await ctx.session.hook('http.request', (draft) => withSelection(scopeOf(draft), async (assertActive) => {
|
|
456
497
|
const mark = draft.request.headers.get(ATTEMPT_HEADER) ?? undefined;
|
|
457
|
-
const rec = await bind(scopeOf(draft), 'http.request', 'http', mark);
|
|
498
|
+
const rec = await bind(scopeOf(draft), 'http.request', 'http', mark, assertActive);
|
|
499
|
+
assertActive();
|
|
458
500
|
const account = accountOf(rec);
|
|
459
501
|
const attempt = rec.attempt;
|
|
460
502
|
if (!account || !attempt)
|
|
@@ -475,6 +517,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
475
517
|
request,
|
|
476
518
|
attempt,
|
|
477
519
|
})) ?? request;
|
|
520
|
+
assertActive();
|
|
478
521
|
}
|
|
479
522
|
const headers = new Headers(request.headers);
|
|
480
523
|
headers.delete(ATTEMPT_HEADER);
|
|
@@ -484,7 +527,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
484
527
|
byRequest.set(final, rec);
|
|
485
528
|
draft.request = final;
|
|
486
529
|
});
|
|
487
|
-
}, scoped));
|
|
530
|
+
}), scoped));
|
|
488
531
|
registrations.push(await ctx.session.hook('http.response', async (draft) => {
|
|
489
532
|
const rec = byRequest.get(draft.request);
|
|
490
533
|
if (!rec) {
|
|
@@ -554,7 +597,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
554
597
|
if (response !== original)
|
|
555
598
|
draft.response = response;
|
|
556
599
|
}, scoped));
|
|
557
|
-
registrations.push(await ctx.session.hook('experimental.ws.handshake',
|
|
600
|
+
registrations.push(await ctx.session.hook('experimental.ws.handshake', (draft) => withSelection(scopeOf(draft), async (assertActive) => {
|
|
558
601
|
let mark;
|
|
559
602
|
for (const name of Object.keys(draft.headers)) {
|
|
560
603
|
if (name.toLowerCase() !== ATTEMPT_HEADER)
|
|
@@ -562,7 +605,8 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
562
605
|
mark ??= draft.headers[name];
|
|
563
606
|
delete draft.headers[name];
|
|
564
607
|
}
|
|
565
|
-
const rec = await bind(scopeOf(draft), 'experimental.ws.handshake', 'ws', mark);
|
|
608
|
+
const rec = await bind(scopeOf(draft), 'experimental.ws.handshake', 'ws', mark, assertActive);
|
|
609
|
+
assertActive();
|
|
566
610
|
const account = accountOf(rec);
|
|
567
611
|
const attempt = rec.attempt;
|
|
568
612
|
if (!account || !attempt)
|
|
@@ -578,7 +622,7 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
578
622
|
draft.url = url;
|
|
579
623
|
guard(rec.scope, Object.entries(draft.headers));
|
|
580
624
|
});
|
|
581
|
-
}, scoped));
|
|
625
|
+
}), scoped));
|
|
582
626
|
const rewriteFrame = adapter.rewriteWebSocketFrame?.bind(adapter);
|
|
583
627
|
if (rewriteFrame) {
|
|
584
628
|
registrations.push(await ctx.session.hook('experimental.ws.send', async (draft) => {
|
|
@@ -624,68 +668,79 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
624
668
|
return;
|
|
625
669
|
}
|
|
626
670
|
const rec = picked;
|
|
627
|
-
rec.
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
else if (rec.outputStarted) {
|
|
637
|
-
// The user has already seen part of this answer; a retry would
|
|
638
|
-
// send it again.
|
|
639
|
-
decision = { retry: false };
|
|
640
|
-
reason = 'output-started';
|
|
641
|
-
}
|
|
642
|
-
else {
|
|
643
|
-
if (!rec.limit) {
|
|
644
|
-
const signal = adapter.limitFromError?.(draft.error, attempt);
|
|
645
|
-
if (signal)
|
|
646
|
-
noteLimit(rec, signal, 'error');
|
|
671
|
+
await withSelection(rec.scope, async (assertActive) => {
|
|
672
|
+
rec.judged = true;
|
|
673
|
+
let decision = hostDecision;
|
|
674
|
+
let reason;
|
|
675
|
+
let rerouteFrom;
|
|
676
|
+
const attempt = rec.attempt;
|
|
677
|
+
if (rec.accountId === undefined || !attempt) {
|
|
678
|
+
decision = { retry: false };
|
|
679
|
+
reason = 'no-account';
|
|
647
680
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
await rec.limit.delivered;
|
|
654
|
-
rec.rerouted = true;
|
|
655
|
-
rerouteFrom = { accountId: rec.accountId, limit: rec.limit.signal };
|
|
656
|
-
// No delay: the next attempt goes to another account, and the
|
|
657
|
-
// host would otherwise wait out the refused account's backoff,
|
|
658
|
-
// or not retry at all for errors it deems final.
|
|
659
|
-
decision = { retry: true, delay: 0 };
|
|
660
|
-
reason = 'reroute';
|
|
681
|
+
else if (rec.outputStarted) {
|
|
682
|
+
// The user has already seen part of this answer; a retry would
|
|
683
|
+
// send it again.
|
|
684
|
+
decision = { retry: false };
|
|
685
|
+
reason = 'output-started';
|
|
661
686
|
}
|
|
662
687
|
else {
|
|
663
|
-
|
|
688
|
+
if (!rec.limit) {
|
|
689
|
+
const signal = adapter.limitFromError?.(draft.error, attempt);
|
|
690
|
+
if (signal)
|
|
691
|
+
noteLimit(rec, signal, 'error');
|
|
692
|
+
}
|
|
693
|
+
// The next `chooseAccount` should see whatever the plugin learnt
|
|
694
|
+
// from how this attempt ended.
|
|
695
|
+
if (rec.ended) {
|
|
696
|
+
await rec.ended;
|
|
697
|
+
assertActive();
|
|
698
|
+
}
|
|
699
|
+
if (rec.limit) {
|
|
700
|
+
await rec.limit.delivered;
|
|
701
|
+
assertActive();
|
|
702
|
+
rec.rerouted = true;
|
|
703
|
+
rerouteFrom = {
|
|
704
|
+
accountId: rec.accountId,
|
|
705
|
+
limit: rec.limit.signal,
|
|
706
|
+
};
|
|
707
|
+
// No delay: the next attempt goes to another account, and the
|
|
708
|
+
// host would otherwise wait out the refused account's backoff,
|
|
709
|
+
// or not retry at all for errors it deems final.
|
|
710
|
+
decision = { retry: true, delay: 0 };
|
|
711
|
+
reason = 'reroute';
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
reason = 'host-decides';
|
|
715
|
+
}
|
|
664
716
|
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
717
|
+
draft.decision = decision;
|
|
718
|
+
// The host's retry runs `model.request` again for this attempt's
|
|
719
|
+
// session and kind; that next attempt, and no other attempt of the
|
|
720
|
+
// session, is told what this one ended with.
|
|
721
|
+
if (decision.retry && rec.accountId !== undefined) {
|
|
722
|
+
pendingRetries.set(rec.key, {
|
|
723
|
+
sessionID: rec.scope.sessionID,
|
|
724
|
+
accountId: rec.accountId,
|
|
725
|
+
...(rerouteFrom === undefined ? {} : { rerouteFrom }),
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
await emit('retry', {
|
|
729
|
+
sessionID: draft.sessionID,
|
|
730
|
+
...(rec.accountId === undefined
|
|
731
|
+
? {}
|
|
732
|
+
: { accountId: rec.accountId }),
|
|
733
|
+
kind: rec.scope.kind,
|
|
734
|
+
attempt: draft.attempt,
|
|
735
|
+
reason,
|
|
736
|
+
hostDecision,
|
|
737
|
+
decision,
|
|
738
|
+
...(attempt === undefined ? {} : { handle: attempt }),
|
|
675
739
|
});
|
|
676
|
-
}
|
|
677
|
-
await emit('retry', {
|
|
678
|
-
sessionID: draft.sessionID,
|
|
679
|
-
...(rec.accountId === undefined ? {} : { accountId: rec.accountId }),
|
|
680
|
-
kind: rec.scope.kind,
|
|
681
|
-
attempt: draft.attempt,
|
|
682
|
-
reason,
|
|
683
|
-
hostDecision,
|
|
684
|
-
decision,
|
|
685
|
-
...(attempt === undefined ? {} : { handle: attempt }),
|
|
686
740
|
});
|
|
687
741
|
}, scoped));
|
|
688
742
|
const forgetSession = (sessionID) => {
|
|
743
|
+
revokeSelections(sessionID);
|
|
689
744
|
for (const rec of [...attempts.values()]) {
|
|
690
745
|
if (rec.scope.sessionID !== sessionID)
|
|
691
746
|
continue;
|
|
@@ -716,7 +771,6 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
716
771
|
}
|
|
717
772
|
})();
|
|
718
773
|
}
|
|
719
|
-
let disposed = false;
|
|
720
774
|
return {
|
|
721
775
|
on(event, listener) {
|
|
722
776
|
let set = listeners.get(event);
|
|
@@ -741,6 +795,8 @@ export async function installOpenCode2Auth(ctx, adapter, options = {}) {
|
|
|
741
795
|
if (disposed)
|
|
742
796
|
return;
|
|
743
797
|
disposed = true;
|
|
798
|
+
for (const sessionID of inFlight.keys())
|
|
799
|
+
revokeSelections(sessionID);
|
|
744
800
|
abort.abort();
|
|
745
801
|
for (const rec of attempts.values())
|
|
746
802
|
abandon(rec, 'the installation was disposed');
|
|
@@ -381,10 +381,13 @@ export interface OpenCode2AuthInstallation<Q, A = unknown> {
|
|
|
381
381
|
* handle.
|
|
382
382
|
*/
|
|
383
383
|
accountFor(sessionID: string, kind: RequestKind): string | undefined;
|
|
384
|
-
/**
|
|
384
|
+
/**
|
|
385
|
+
* Drops every attempt and revokes in-flight auth selections of a session.
|
|
386
|
+
* Session deletion does this itself. Later requests may reuse the session id.
|
|
387
|
+
*/
|
|
385
388
|
forgetSession(sessionID: string): void;
|
|
386
389
|
/** Number of attempts held. */
|
|
387
390
|
readonly size: number;
|
|
388
|
-
/** Removes every hook and stops listening for session deletion. */
|
|
391
|
+
/** Removes every hook, revokes in-flight auth and stops listening for session deletion. */
|
|
389
392
|
dispose(): Promise<void>;
|
|
390
393
|
}
|
package/dist/rpc/rpc-server.js
CHANGED
|
@@ -293,10 +293,10 @@ export async function startRpcServer(options) {
|
|
|
293
293
|
token,
|
|
294
294
|
async stop() {
|
|
295
295
|
await new Promise((resolve) => {
|
|
296
|
-
|
|
297
|
-
//
|
|
296
|
+
// Bun's close() disables closeAllConnections(), and a peer sending
|
|
297
|
+
// incomplete headers may not have reached the tracked connection set.
|
|
298
298
|
server.closeAllConnections?.();
|
|
299
|
-
|
|
299
|
+
server.close(() => resolve());
|
|
300
300
|
for (const socket of connections)
|
|
301
301
|
socket.destroy();
|
|
302
302
|
});
|
package/dist/store/pool.d.ts
CHANGED
|
@@ -33,6 +33,10 @@ export interface OpenPoolStoreOptions {
|
|
|
33
33
|
* provider call or write, checked again under the locks and at commit.
|
|
34
34
|
* Nothing makes such a row bound except `replace`, which starts a new
|
|
35
35
|
* credential epoch and drops what was observed about the old one.
|
|
36
|
+
* An interrupted add's state-only orphan is recovered only when its stamp
|
|
37
|
+
* binds the stored and incoming material, including a supplied identity and
|
|
38
|
+
* an API key's endpoint/header. Otherwise add refuses `unbound-credential`
|
|
39
|
+
* without writing; `remove(id)` explicitly discards the orphan.
|
|
36
40
|
*/
|
|
37
41
|
requireCredentialStamps?: boolean;
|
|
38
42
|
/** Injected clock for leases, refresh stamps and `addedAt`. */
|
package/dist/store/rows.d.ts
CHANGED
|
@@ -46,7 +46,7 @@ export type RowToggleOptions = Pick<RowOperationOptions, 'onFailure' | 'extraLoc
|
|
|
46
46
|
export interface RemoveView {
|
|
47
47
|
/**
|
|
48
48
|
* The row as loaded; undefined when the roster no longer holds the id and
|
|
49
|
-
* only its state-file entry is left (
|
|
49
|
+
* only its state-file entry is left (an add or removal interrupted between writes).
|
|
50
50
|
*/
|
|
51
51
|
row: PoolRow | undefined;
|
|
52
52
|
/** The config file as read under the store locks. */
|
|
@@ -70,7 +70,8 @@ export type RemoveResult = {
|
|
|
70
70
|
/**
|
|
71
71
|
* `removed`: the roster row was dropped (and its state entry, if any).
|
|
72
72
|
* `completed`: only a state-file entry was left, by a removal interrupted
|
|
73
|
-
* between its config and state writes
|
|
73
|
+
* between its config and state writes or an add interrupted before its
|
|
74
|
+
* config write, and it is now dropped.
|
|
74
75
|
*/
|
|
75
76
|
outcome: 'removed' | 'completed';
|
|
76
77
|
};
|
package/dist/store/rows.js
CHANGED
|
@@ -5,7 +5,7 @@ import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, enable
|
|
|
5
5
|
import { notReadyError, readPool, runOperation, withTransaction, } from './mutate.js';
|
|
6
6
|
import { acceptProviderState, mergedProviderState, planProviderStateIn, providerStateCoverage, replacementProviderState, } from './provider-state.js';
|
|
7
7
|
import { readRow, refusal, requireBound, rowLockSpec, unknownRow, } from './runtime.js';
|
|
8
|
-
import { boundProviderStateDigest, CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, nextAddEpochIn, PROVIDER_STATE_KEY, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
|
|
8
|
+
import { boundProviderStateDigest, buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, credentialProblem, dispatchDigest, fingerprintOf, idProblem, isCredentialEpoch, isRecord, nextAddEpochIn, POOL_KEY, PROVIDER_STATE_KEY, parseStamp, providerStateDigest, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
|
|
9
9
|
import { applyTransition, bindReplacement, TRANSITION_STAMP_KEY, } from './torn.js';
|
|
10
10
|
/** Fields of a state entry that belong to the credential it replaces. */
|
|
11
11
|
const CREDENTIAL_STATE_FIELDS = [
|
|
@@ -203,6 +203,74 @@ function identityMismatch(operation, id) {
|
|
|
203
203
|
function keyChanged(operation, id) {
|
|
204
204
|
return refusal(operation, id, 'row-key-changed', `row ${id}'s wire identity changed while its lock was being taken`, true);
|
|
205
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* A strict replay may finish a state-first add, but must not replace what the
|
|
208
|
+
* interrupted write stamped. Load the orphan against its stamped descriptor
|
|
209
|
+
* using the ordinary row/stamp machinery, then compare the caller's material.
|
|
210
|
+
* The roster has no endpoint or identity yet, so neither can be inferred from
|
|
211
|
+
* the incoming add. Refusal happens before even repairing other torn rows.
|
|
212
|
+
*/
|
|
213
|
+
function matchingInterruptedAdd(rt, tx, input, incoming) {
|
|
214
|
+
const { id, credential, identity } = input;
|
|
215
|
+
if (!rt.ctx.requireCredentialStamps ||
|
|
216
|
+
tx.rosterRow(id) ||
|
|
217
|
+
!hasStateAccount(tx.state, id))
|
|
218
|
+
return undefined;
|
|
219
|
+
const refuse = () => {
|
|
220
|
+
throw refusal('add', id, 'unbound-credential', `orphan ${id} cannot be bound to this add's material; remove it explicitly before adding different material`);
|
|
221
|
+
};
|
|
222
|
+
const account = tx.stateAccount(id);
|
|
223
|
+
const stamp = parseStamp(account?.[CREDENTIAL_STAMP_KEY]);
|
|
224
|
+
if (!stamp?.binding || stamp.dispatch === undefined || stamp.replace)
|
|
225
|
+
return refuse();
|
|
226
|
+
if (credential.type === 'api' &&
|
|
227
|
+
(stamp.binding.baseURL === undefined ||
|
|
228
|
+
stamp.binding.authHeader === undefined))
|
|
229
|
+
return refuse();
|
|
230
|
+
const descriptor = credential.type === 'api'
|
|
231
|
+
? {
|
|
232
|
+
...credential,
|
|
233
|
+
baseURL: stamp.binding.baseURL,
|
|
234
|
+
authHeader: stamp.binding.authHeader,
|
|
235
|
+
}
|
|
236
|
+
: credential;
|
|
237
|
+
const [orphan] = buildRawRows({
|
|
238
|
+
accounts: [
|
|
239
|
+
rosterRowFor({
|
|
240
|
+
id,
|
|
241
|
+
credential: descriptor,
|
|
242
|
+
identity: stamp.binding.identity,
|
|
243
|
+
addedAt: 0,
|
|
244
|
+
}),
|
|
245
|
+
],
|
|
246
|
+
[POOL_KEY]: {
|
|
247
|
+
rows: { [id]: { credentialEpoch: nextAddEpochIn(tx.config, id) } },
|
|
248
|
+
},
|
|
249
|
+
}, tx.state, rt.ctx.codec, rt.ctx.providerState);
|
|
250
|
+
if (!orphan?.credential || orphan.invalid || orphan.stamp !== 'bound')
|
|
251
|
+
return refuse();
|
|
252
|
+
if (identity !== undefined && identity !== orphan.identity)
|
|
253
|
+
return refuse();
|
|
254
|
+
if (credentialDigest(credential) !== stamp.digest)
|
|
255
|
+
return refuse();
|
|
256
|
+
if (credential.type === 'api') {
|
|
257
|
+
if (credential.baseURL.trim() !== stamp.binding.baseURL)
|
|
258
|
+
return refuse();
|
|
259
|
+
if ((credential.authHeader ?? 'authorization-bearer') !==
|
|
260
|
+
stamp.binding.authHeader)
|
|
261
|
+
return refuse();
|
|
262
|
+
}
|
|
263
|
+
else if (dispatchDigest(credential) !== stamp.dispatch)
|
|
264
|
+
return refuse();
|
|
265
|
+
// Provider-state coverage is separate from credential stamp status. Keep
|
|
266
|
+
// the interrupted write's value, but never complete a changed bound value.
|
|
267
|
+
if (stamp.providerState !== undefined && orphan.providerState === undefined)
|
|
268
|
+
return refuse();
|
|
269
|
+
if (incoming !== undefined &&
|
|
270
|
+
providerStateDigest(rt.ctx.providerState, incoming) !== stamp.providerState)
|
|
271
|
+
return refuse();
|
|
272
|
+
return orphan;
|
|
273
|
+
}
|
|
206
274
|
export async function addRow(rt, input, options = {}) {
|
|
207
275
|
assertNotInsideHook('add');
|
|
208
276
|
const { ctx } = rt;
|
|
@@ -220,9 +288,13 @@ export async function addRow(rt, input, options = {}) {
|
|
|
220
288
|
for (const extra of options.extraLocks ?? [])
|
|
221
289
|
await locks.acquire(extra);
|
|
222
290
|
return withTransaction(ctx, locks, progress, { operation: 'add', rowId: id }, async (tx) => {
|
|
291
|
+
const orphan = matchingInterruptedAdd(rt, tx, input, incoming);
|
|
292
|
+
if (!orphan)
|
|
293
|
+
await tx.completeTorn();
|
|
223
294
|
const rows = tx.rows();
|
|
224
295
|
const fingerprint = fingerprintOf(credential);
|
|
225
|
-
const same =
|
|
296
|
+
const same = !orphan &&
|
|
297
|
+
rows.find((row) => row.invalid === undefined && row.fingerprint === fingerprint);
|
|
226
298
|
if (same) {
|
|
227
299
|
// Re-adding a secret whose stamp is not proved would rotate it in
|
|
228
300
|
// and keep the identity and quota recorded beside it, making that
|
|
@@ -286,10 +358,13 @@ export async function addRow(rt, input, options = {}) {
|
|
|
286
358
|
const credentialEpoch = nextAddEpochIn(tx.config, id);
|
|
287
359
|
if (!isCredentialEpoch(credentialEpoch))
|
|
288
360
|
throw refusal('add', id, 'id-removed', `id ${id} has held every credential epoch and is not reused; add the credential under another id`);
|
|
361
|
+
const addedIdentity = orphan?.identity ?? identity;
|
|
289
362
|
tx.roster().push(rosterRowFor({
|
|
290
363
|
id,
|
|
291
364
|
credential,
|
|
292
|
-
...(
|
|
365
|
+
...(addedIdentity !== undefined
|
|
366
|
+
? { identity: addedIdentity }
|
|
367
|
+
: {}),
|
|
293
368
|
...(label !== undefined ? { label } : {}),
|
|
294
369
|
addedAt: ctx.now(),
|
|
295
370
|
}));
|
|
@@ -298,17 +373,24 @@ export async function addRow(rt, input, options = {}) {
|
|
|
298
373
|
needsFirstReading: credential.type === 'oauth',
|
|
299
374
|
});
|
|
300
375
|
let outcome = 'added';
|
|
301
|
-
if (
|
|
376
|
+
if (addedIdentity !== undefined && credential.type === 'oauth') {
|
|
302
377
|
// Same account, different credential: kept on disk, disabled.
|
|
303
378
|
const holder = rows.find((row) => row.invalid === undefined &&
|
|
304
379
|
row.type === 'oauth' &&
|
|
305
380
|
row.enabled &&
|
|
306
|
-
row.identity ===
|
|
381
|
+
row.identity === addedIdentity);
|
|
307
382
|
if (holder) {
|
|
308
383
|
disableIn(tx, id, DUPLICATE_IDENTITY_REASON);
|
|
309
384
|
outcome = 'added-disabled';
|
|
310
385
|
}
|
|
311
386
|
}
|
|
387
|
+
if (orphan?.credential) {
|
|
388
|
+
// The state half is already durable and proved. A config-only
|
|
389
|
+
// completion preserves the credential, stamp and provider state
|
|
390
|
+
// byte for byte; a crash leaves either this orphan or a whole row.
|
|
391
|
+
await tx.commitConfig();
|
|
392
|
+
return { id, outcome, credential: orphan.credential };
|
|
393
|
+
}
|
|
312
394
|
// The credential is written first. A crash before the config write
|
|
313
395
|
// then leaves a state entry no roster row names, which no reader
|
|
314
396
|
// loads and `remove` drops; written the other way round, the new
|
|
@@ -322,7 +404,7 @@ export async function addRow(rt, input, options = {}) {
|
|
|
322
404
|
});
|
|
323
405
|
await tx.commitConfig();
|
|
324
406
|
return { id, outcome, credential: stored };
|
|
325
|
-
});
|
|
407
|
+
}, { completeTorn: false });
|
|
326
408
|
});
|
|
327
409
|
if (credential.type === 'oauth' &&
|
|
328
410
|
(result.outcome === 'added' || result.outcome === 'completed'))
|
package/dist/store/torn.js
CHANGED
|
@@ -186,6 +186,12 @@ export function tornStamps(config, state, codec, options = {}) {
|
|
|
186
186
|
}
|
|
187
187
|
else if (stamp.credentialEpoch === epoch &&
|
|
188
188
|
stamp.binding.identity === row.identity) {
|
|
189
|
+
// The lineage digest alone misses an access-token or expiry edit. A
|
|
190
|
+
// strict store must not repair the config from a transition whose stamp
|
|
191
|
+
// no longer describes the credential; the row stays unbound instead.
|
|
192
|
+
if (options.requireCredentialStamps &&
|
|
193
|
+
stamp.dispatch !== dispatchDigest(row.credential))
|
|
194
|
+
continue;
|
|
189
195
|
const transition = stampedTransition(account[CREDENTIAL_STAMP_KEY]);
|
|
190
196
|
if (transition &&
|
|
191
197
|
entryIn(config, row.id)?.[TRANSITION_MARK_KEY] !== transition.mark)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/common-auth",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
4
4
|
"description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|