dsh-keys-manager 0.2.2 → 0.2.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.
- package/CHANGELOG.md +91 -19
- package/lib/client/format.d.ts +11 -1
- package/lib/client/styles.d.ts +1 -1
- package/lib/client/types.d.ts +14 -2
- package/lib/client/ui.d.ts +14 -0
- package/lib/client.js +333 -51
- package/lib/core.d.ts +7 -8
- package/lib/core.js +7 -0
- package/lib/core.js.map +1 -1
- package/lib/domain.d.ts +23 -0
- package/lib/index.d.ts +170 -23
- package/lib/index.js +1078 -165
- package/lib/index.js.map +1 -1
- package/locale/en.json +18 -2
- package/locale/zh.json +18 -2
- package/package.json +2 -2
- package/scripts/prepare-release.mjs +318 -128
package/lib/index.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
17
17
|
import { createHash, randomBytes } from 'node:crypto';
|
|
18
|
-
import {
|
|
18
|
+
import { CURSOR_ROUTE, cursorCatalogModels } from 'dsh-llm-omp-transports';
|
|
19
|
+
import { KEY_LIMIT, TEST_SUCCESS_FINISH_KINDS, accountRef, applyFailureCooldown, bindingKey, classifyOAuthFailure, isSelectable, markKeySuccess, markKeyUsed, normalizeSettings, patchSettings, pickHealthyExcluding, pickRoundRobin, shouldFailover, statusOfKey, statusOfProvider, validateKeyInput, validateKeyPatch, } from './core.js';
|
|
19
20
|
import { EXTRA_PROVIDER_MAP, EXTRA_PROVIDERS } from './providers.js';
|
|
20
21
|
import { KeysManagerStore, ensureStateFile, statePath } from './store.js';
|
|
21
22
|
export const name = 'keys-manager';
|
|
@@ -255,6 +256,11 @@ const INVOCATIONS = [
|
|
|
255
256
|
endpoint('beginTransportLogin', ['provider']),
|
|
256
257
|
endpoint('activateTransportAccount', ['provider']),
|
|
257
258
|
endpoint('removeTransportAccount', ['provider']),
|
|
259
|
+
// The transport card's model picker: the catalog lives in the transports
|
|
260
|
+
// package (listModels answers only the allow-list), and the selection is
|
|
261
|
+
// written to the route's own settings path.
|
|
262
|
+
endpoint('transportModels', ['provider']),
|
|
263
|
+
endpoint('saveTransportModels', ['provider', 'models']),
|
|
258
264
|
];
|
|
259
265
|
/** Read a nested value by path. */
|
|
260
266
|
function getPath(value, path) {
|
|
@@ -517,6 +523,15 @@ function transportAccountOf(flow, record, now) {
|
|
|
517
523
|
function transportMethodOf(flow) {
|
|
518
524
|
return flow.methods[0]?.id;
|
|
519
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* The catalog a transport route advertises to its model picker.
|
|
528
|
+
*
|
|
529
|
+
* Only the Cursor route ships a catalog projection today; a route without one
|
|
530
|
+
* reports no models rather than borrowing another route's catalog.
|
|
531
|
+
*/
|
|
532
|
+
function transportCatalogOf(route) {
|
|
533
|
+
return route === CURSOR_ROUTE ? cursorCatalogModels() : [];
|
|
534
|
+
}
|
|
520
535
|
/** Env names that are a single API-key credential, not an OAuth/federation token. */
|
|
521
536
|
function isKeyishEnv(name) {
|
|
522
537
|
if (!/(?:API_KEY|_TOKEN)$/.test(name))
|
|
@@ -844,12 +859,15 @@ export class KeysManagerService {
|
|
|
844
859
|
* order is preserved so the older observation can never commit after the
|
|
845
860
|
* newer one and leave the row on a stale generation.
|
|
846
861
|
*
|
|
847
|
-
* Round-8 finding I-000106
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
862
|
+
* Round-8 finding I-000106 assumed activation would join this chain. It does
|
|
863
|
+
* not: activation runs its compare-and-write inside the mutation queue turn,
|
|
864
|
+
* and the only writer of `row.oauth.grantRef` is {@link reconcileGrantRecord},
|
|
865
|
+
* which runs outside that queue. The fence is therefore a generation re-check
|
|
866
|
+
* on both sides of the write — before it (the row must still name the
|
|
867
|
+
* generation that was read) and after it (a rotation commit can land inside
|
|
868
|
+
* the seam's compare-and-write await, which only reads the projection key).
|
|
869
|
+
* Chain members are the echo reconciler, the key-test borrow and adoption
|
|
870
|
+
* classification.
|
|
853
871
|
*/
|
|
854
872
|
grantReconciliations = new Map();
|
|
855
873
|
/**
|
|
@@ -944,6 +962,20 @@ export class KeysManagerService {
|
|
|
944
962
|
credentialsMounted = false;
|
|
945
963
|
/** Providers whose bounded-wait warning already fired (warn once). */
|
|
946
964
|
hydrationWaitWarned = new Set();
|
|
965
|
+
/**
|
|
966
|
+
* Stream attempts each provider served without an attributed account
|
|
967
|
+
* (`I-000120`, D8): the ownership read had not landed, or it had failed.
|
|
968
|
+
* Counted at the one managed serving site (`wrapStream`), never in the
|
|
969
|
+
* hydration pass — a failed read serves no request, it only makes later
|
|
970
|
+
* attempts unattributed. Two earlier branches are outside this definition and
|
|
971
|
+
* stay uncounted: a provider this manager does not manage (no ownership is
|
|
972
|
+
* ever asked for) and the `mismatch` profile shape, which hands the request
|
|
973
|
+
* to pi-ai untouched precisely because this manager's own binding cannot
|
|
974
|
+
* describe what serves it. Cleared only when a later read decides the route's
|
|
975
|
+
* attribution; process-local, never persisted and never on the warnings
|
|
976
|
+
* channel.
|
|
977
|
+
*/
|
|
978
|
+
unattributedRequests = new Map();
|
|
947
979
|
/** Channels whose lost-subscription warning already fired (warn once). */
|
|
948
980
|
subscriptionLostWarnings = new Set();
|
|
949
981
|
/** Disposer for the `authorization/settled` subscription. */
|
|
@@ -1011,7 +1043,13 @@ export class KeysManagerService {
|
|
|
1011
1043
|
}
|
|
1012
1044
|
return (async () => {
|
|
1013
1045
|
try {
|
|
1014
|
-
|
|
1046
|
+
// allSettled, never all: under `all`, a rejection from any single
|
|
1047
|
+
// provider task settles this awaited pass at once, so the finally below
|
|
1048
|
+
// flips `hydrationSettled` while the other providers' reads are still
|
|
1049
|
+
// in flight and `awaitProjectionOwnership` calls them ready. No task
|
|
1050
|
+
// body can reject today (the read error is caught inside it), so this
|
|
1051
|
+
// is a structural guard — do not flip it back for style.
|
|
1052
|
+
await Promise.allSettled(Object.values(this.store.providers).map(async (providerRecord) => {
|
|
1015
1053
|
const provider = providerRecord.provider;
|
|
1016
1054
|
const accounts = providerRecord.keys.filter(candidate => candidate.kind === 'oauth' && candidate.oauth !== null);
|
|
1017
1055
|
try {
|
|
@@ -1045,9 +1083,10 @@ export class KeysManagerService {
|
|
|
1045
1083
|
// serves the route ahead of any broker reference exactly like an
|
|
1046
1084
|
// unowned grant, so it is classified as foreign below (PR review
|
|
1047
1085
|
// rounds 4 and 13).
|
|
1048
|
-
const
|
|
1049
|
-
?
|
|
1086
|
+
const projection = record.kind === 'grant' && isOAuthGrantPayload(record.payload)
|
|
1087
|
+
? record.payload
|
|
1050
1088
|
: undefined;
|
|
1089
|
+
const owner = projection === undefined ? undefined : matchOwningAccount(provider, accounts, projection);
|
|
1051
1090
|
// A newer activation/login claimed the owner while this read was in
|
|
1052
1091
|
// flight: the stale read is discarded, never written over it. A
|
|
1053
1092
|
// remount replaced the whole store: its classification is gone too.
|
|
@@ -1071,8 +1110,13 @@ export class KeysManagerService {
|
|
|
1071
1110
|
// attributable now and must not wait behind another provider's
|
|
1072
1111
|
// stalled read (round-10 should-fix SF1). A superseded pass does
|
|
1073
1112
|
// not report readiness for a store it no longer describes.
|
|
1074
|
-
if (this.hydrationEpoch === epoch)
|
|
1113
|
+
if (this.hydrationEpoch === epoch) {
|
|
1114
|
+
// A landed read decided this route's attribution; a failed one
|
|
1115
|
+
// decided nothing and never clears the count (I-000120).
|
|
1116
|
+
if (!this.ownershipReadFailures.has(provider))
|
|
1117
|
+
this.unattributedRequests.delete(provider);
|
|
1075
1118
|
this.noteProviderHydrated(provider);
|
|
1119
|
+
}
|
|
1076
1120
|
}
|
|
1077
1121
|
}));
|
|
1078
1122
|
}
|
|
@@ -1129,10 +1173,24 @@ export class KeysManagerService {
|
|
|
1129
1173
|
* repair was skipped while that shape was unknown.
|
|
1130
1174
|
*/
|
|
1131
1175
|
noteProjectionReadSucceeded(provider) {
|
|
1132
|
-
|
|
1133
|
-
|
|
1176
|
+
// This read decided the route's attribution, so a count of requests served
|
|
1177
|
+
// without one no longer describes it (I-000120).
|
|
1178
|
+
this.unattributedRequests.delete(provider);
|
|
1179
|
+
const recovering = this.ownershipReadFailures.delete(provider);
|
|
1180
|
+
// Readiness is decided by the read, not by whether it was recovering: a
|
|
1181
|
+
// `record-updated` reconcile can answer the projection while startup
|
|
1182
|
+
// hydration is still pending for another provider, and the old early return
|
|
1183
|
+
// left this one out of `hydratedProviders` — waits kept timing out, routing
|
|
1184
|
+
// stayed skipped, and the count this read had just cleared started growing
|
|
1185
|
+
// again although attribution was already decided. `noteProviderHydrated` is
|
|
1186
|
+
// idempotent (a set add plus waking its waiters).
|
|
1134
1187
|
this.noteProviderHydrated(provider);
|
|
1135
|
-
|
|
1188
|
+
// Only recovery re-derives the binding: the normal call arrives from a
|
|
1189
|
+
// reconciliation that already owns that re-derive behind it, and the
|
|
1190
|
+
// startup repair is what a failed read skipped.
|
|
1191
|
+
if (recovering) {
|
|
1192
|
+
void this.scheduleGrantReconciliation(provider, () => this.syncBindingAfterProjectionChange(provider));
|
|
1193
|
+
}
|
|
1136
1194
|
}
|
|
1137
1195
|
/** Release every provider's readiness wait when hydration cannot proceed. */
|
|
1138
1196
|
settleHydrationWaits() {
|
|
@@ -1184,6 +1242,9 @@ export class KeysManagerService {
|
|
|
1184
1242
|
timer = setTimeout(() => {
|
|
1185
1243
|
wake(false);
|
|
1186
1244
|
}, ms);
|
|
1245
|
+
// I-000116: a request waiting on hydration must not hold the process
|
|
1246
|
+
// open for the rest of the wait; every exit path still clears it.
|
|
1247
|
+
timer.unref?.();
|
|
1187
1248
|
live.add(onLanded);
|
|
1188
1249
|
});
|
|
1189
1250
|
}
|
|
@@ -1270,6 +1331,16 @@ export class KeysManagerService {
|
|
|
1270
1331
|
this.ownershipGenerations.set(provider, generation);
|
|
1271
1332
|
return generation;
|
|
1272
1333
|
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Whether a claim landed after `generation` was captured. A reconciliation
|
|
1336
|
+
* reads its snapshot before its awaited record read and decides after it, so
|
|
1337
|
+
* an activation (or login) that claimed the route in between owns the answer
|
|
1338
|
+
* now: the stale body must not clear the claim, mark the route foreign, or
|
|
1339
|
+
* re-point the binding. The hydration write uses the same guard.
|
|
1340
|
+
*/
|
|
1341
|
+
ownershipSuperseded(provider, generation) {
|
|
1342
|
+
return (this.ownershipGenerations.get(provider) ?? 0) !== generation;
|
|
1343
|
+
}
|
|
1273
1344
|
/**
|
|
1274
1345
|
* Claim the projection owner for one account row, bumping the provider's
|
|
1275
1346
|
* ownership generation so an in-flight hydration read of an older state
|
|
@@ -1279,6 +1350,8 @@ export class KeysManagerService {
|
|
|
1279
1350
|
this.activeAccounts.set(provider, accountId);
|
|
1280
1351
|
this.foreignProjections.delete(provider);
|
|
1281
1352
|
this.noteProjectionCommitted(provider);
|
|
1353
|
+
// The route has an owner now, so requests are attributed from here on.
|
|
1354
|
+
this.unattributedRequests.delete(provider);
|
|
1282
1355
|
}
|
|
1283
1356
|
/**
|
|
1284
1357
|
* Log without ever echoing secret material. */
|
|
@@ -1307,17 +1380,32 @@ export class KeysManagerService {
|
|
|
1307
1380
|
}
|
|
1308
1381
|
}
|
|
1309
1382
|
/** Serialize mutations, rejecting submissions that arrive during shutdown. */
|
|
1310
|
-
run(operation) {
|
|
1383
|
+
run(operation, onFailure) {
|
|
1311
1384
|
if (this.closing)
|
|
1312
1385
|
return Promise.reject(new Error('Keys Manager is shutting down'));
|
|
1313
1386
|
const guarded = async () => {
|
|
1314
1387
|
if (this.store.readOnly) {
|
|
1315
1388
|
throw new Error('The state file could not be read; refusing to write over it. Restart after fixing file access.');
|
|
1316
1389
|
}
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1390
|
+
try {
|
|
1391
|
+
const result = await operation();
|
|
1392
|
+
// A mutation is not successful until its state document is durable.
|
|
1393
|
+
await this.store.flush();
|
|
1394
|
+
return result;
|
|
1395
|
+
}
|
|
1396
|
+
catch (error) {
|
|
1397
|
+
// Failure cleanup belongs to THIS turn's exclusive window, not a later
|
|
1398
|
+
// one: a queued mutation (a removal, say) would otherwise run first and
|
|
1399
|
+
// destroy the state the undo needs to restore. `guarded` resolves only
|
|
1400
|
+
// after the hook, so everything already submitted behind this turn sees
|
|
1401
|
+
// the compensated state. Both awaits are best-effort — the caller must
|
|
1402
|
+
// receive the original error, not the cleanup's.
|
|
1403
|
+
if (onFailure !== undefined) {
|
|
1404
|
+
await onFailure(error).catch(() => undefined);
|
|
1405
|
+
await this.store.flush().catch(() => undefined);
|
|
1406
|
+
}
|
|
1407
|
+
throw error;
|
|
1408
|
+
}
|
|
1321
1409
|
};
|
|
1322
1410
|
const next = this.queue.then(guarded, guarded);
|
|
1323
1411
|
this.queue = next.catch(() => { });
|
|
@@ -2033,6 +2121,85 @@ export class KeysManagerService {
|
|
|
2033
2121
|
transportGenerationOf(route) {
|
|
2034
2122
|
return this.transportRemovals.get(route) ?? 0;
|
|
2035
2123
|
}
|
|
2124
|
+
/**
|
|
2125
|
+
* Remote: read one transport route's catalog and configured allow-list.
|
|
2126
|
+
*
|
|
2127
|
+
* @remarks
|
|
2128
|
+
* The catalog comes from the transports package, because the route's
|
|
2129
|
+
* `listModels` answers only the allow-list. An empty allow-list means "all
|
|
2130
|
+
* models", so an unreadable settings store must not answer `[]` — a save
|
|
2131
|
+
* would then clear a narrowed selection — and it rejects instead.
|
|
2132
|
+
*/
|
|
2133
|
+
async transportModels(route) {
|
|
2134
|
+
this.assertTransportRoute(route);
|
|
2135
|
+
const configured = this.transportModelIds(route);
|
|
2136
|
+
const catalog = transportCatalogOf(route);
|
|
2137
|
+
// A settings profile can outlive a catalog id; the picker renders the
|
|
2138
|
+
// catalog, so an unknown id would count toward {n} of {total} while
|
|
2139
|
+
// matching no checkbox and failing every save.
|
|
2140
|
+
const known = new Set(catalog.map(row => row.id));
|
|
2141
|
+
return { catalog, models: configured.filter(id => known.has(id)) };
|
|
2142
|
+
}
|
|
2143
|
+
/**
|
|
2144
|
+
* Remote: replace one transport route's model allow-list.
|
|
2145
|
+
*
|
|
2146
|
+
* Writes only the route's own `providers.<route>.models` path through the
|
|
2147
|
+
* live settings revision; an empty list restores the full catalog.
|
|
2148
|
+
*/
|
|
2149
|
+
async saveTransportModels(route, models) {
|
|
2150
|
+
this.assertTransportRoute(route);
|
|
2151
|
+
if (!Array.isArray(models) || models.some(id => typeof id !== 'string')) {
|
|
2152
|
+
throw new TypeError('models must be a list of model ids');
|
|
2153
|
+
}
|
|
2154
|
+
const catalog = transportCatalogOf(route);
|
|
2155
|
+
if (catalog.length === 0)
|
|
2156
|
+
throw new Error(`Provider "${route}" offers no model list`);
|
|
2157
|
+
const known = new Map(catalog.map(row => [row.id, row.name]));
|
|
2158
|
+
const selected = [...new Set(models)];
|
|
2159
|
+
const unknown = selected.find(id => !known.has(id));
|
|
2160
|
+
if (unknown !== undefined)
|
|
2161
|
+
throw new Error(`Provider "${route}" has no model "${unknown}"`);
|
|
2162
|
+
await this.mutateSettings(TRANSPORT_CREDENTIAL_SCOPE, [
|
|
2163
|
+
{
|
|
2164
|
+
op: 'set',
|
|
2165
|
+
path: ['providers', route, 'models'],
|
|
2166
|
+
value: selected.map(id => ({ id, name: known.get(id) })),
|
|
2167
|
+
},
|
|
2168
|
+
]);
|
|
2169
|
+
return this.transportModels(route);
|
|
2170
|
+
}
|
|
2171
|
+
/** Refuse a route the transports package does not register. */
|
|
2172
|
+
assertTransportRoute(route) {
|
|
2173
|
+
if (typeof route !== 'string' || route.length === 0)
|
|
2174
|
+
throw new TypeError('provider is required');
|
|
2175
|
+
if (this.transportFlowFor(route) === undefined)
|
|
2176
|
+
throw new Error(`Provider "${route}" offers no transport account`);
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* One transport route's configured model ids, read from the live settings.
|
|
2180
|
+
*
|
|
2181
|
+
* @remarks
|
|
2182
|
+
* Rejects when the descriptor is unreadable or this plugin's namespace is
|
|
2183
|
+
* not mounted: a registered route always owns that namespace, so answering
|
|
2184
|
+
* "no models configured" would report the all-models state for a store the
|
|
2185
|
+
* host simply could not read.
|
|
2186
|
+
*/
|
|
2187
|
+
transportModelIds(route) {
|
|
2188
|
+
const descriptor = this.describeSettings();
|
|
2189
|
+
if (!descriptor.readable)
|
|
2190
|
+
throw new Error('Cannot read settings: the settings store is unavailable');
|
|
2191
|
+
const namespace = this.namespace(descriptor, TRANSPORT_CREDENTIAL_SCOPE);
|
|
2192
|
+
if (namespace === undefined) {
|
|
2193
|
+
throw new Error(`Settings namespace "${TRANSPORT_CREDENTIAL_SCOPE}" is not mounted`);
|
|
2194
|
+
}
|
|
2195
|
+
const value = getPath(namespace.value, ['providers', route, 'models']);
|
|
2196
|
+
if (!Array.isArray(value))
|
|
2197
|
+
return [];
|
|
2198
|
+
return value.flatMap(row => {
|
|
2199
|
+
const id = row !== null && typeof row === 'object' ? row.id : undefined;
|
|
2200
|
+
return typeof id === 'string' ? [id] : [];
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2036
2203
|
/**
|
|
2037
2204
|
* Drop a grant a withdrawn sign-in wrote after its account was removed.
|
|
2038
2205
|
*
|
|
@@ -2564,11 +2731,20 @@ export class KeysManagerService {
|
|
|
2564
2731
|
return;
|
|
2565
2732
|
}
|
|
2566
2733
|
if (harvest.wroteRef) {
|
|
2567
|
-
|
|
2568
|
-
|
|
2734
|
+
// A live row still names this ref: a same-ref re-authentication writes
|
|
2735
|
+
// over the record its own pre-existing row depends on, and this
|
|
2736
|
+
// compensation must not leave that row dangling (same guard as the
|
|
2737
|
+
// rotation cleanup, R5-MF1).
|
|
2738
|
+
if (this.grantRefServed(attempt.provider, harvest.grantRef)) {
|
|
2739
|
+
this.warn(`harvest compensation kept ${harvest.grantRef} for ${attempt.provider}: an account row still references it`);
|
|
2569
2740
|
}
|
|
2570
|
-
|
|
2571
|
-
|
|
2741
|
+
else {
|
|
2742
|
+
try {
|
|
2743
|
+
await records.deleteRecord(harvest.grantRef);
|
|
2744
|
+
}
|
|
2745
|
+
catch (error) {
|
|
2746
|
+
this.warn(`harvest compensation could not delete ${harvest.grantRef}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2747
|
+
}
|
|
2572
2748
|
}
|
|
2573
2749
|
}
|
|
2574
2750
|
await this.revertHarvestProjection(attempt, harvest);
|
|
@@ -2872,7 +3048,7 @@ export class KeysManagerService {
|
|
|
2872
3048
|
// can already be superseded. The newer claim owns the route and settled
|
|
2873
3049
|
// the binding itself: this stale removal decides nothing — no owner
|
|
2874
3050
|
// delete, no rebind — it only consumes its own one-shot removal marker.
|
|
2875
|
-
if (
|
|
3051
|
+
if (this.ownershipSuperseded(provider, generation)) {
|
|
2876
3052
|
this.intentionalProjectionRemovals.delete(provider);
|
|
2877
3053
|
return;
|
|
2878
3054
|
}
|
|
@@ -2928,6 +3104,11 @@ export class KeysManagerService {
|
|
|
2928
3104
|
// reference over it. With no OAuth row the unowned-grant branch below
|
|
2929
3105
|
// classifies a valid grant the same way.
|
|
2930
3106
|
if (record.kind === 'api-key') {
|
|
3107
|
+
// A newer claim (an activation that wrote and claimed after this body
|
|
3108
|
+
// captured its snapshot) owns the route now; this stale classification
|
|
3109
|
+
// must not clear it.
|
|
3110
|
+
if (this.ownershipSuperseded(provider, generation))
|
|
3111
|
+
return;
|
|
2931
3112
|
// Native api-key management took the route over: the manager no
|
|
2932
3113
|
// longer owns any projection here.
|
|
2933
3114
|
this.activeAccounts.delete(provider);
|
|
@@ -2939,11 +3120,15 @@ export class KeysManagerService {
|
|
|
2939
3120
|
this.noteProjectionCommitted(provider);
|
|
2940
3121
|
this.pushWarning(`The ${provider} route now holds a natively managed API-key credential; the Keys Manager account pool is not serving it.`);
|
|
2941
3122
|
// pi-ai resolves the broker reference before this record, so a route
|
|
2942
|
-
// left bound would keep serving the pool (PR review round 4).
|
|
3123
|
+
// left bound would keep serving the pool (PR review round 4). The sync
|
|
3124
|
+
// stamps the generation it entered with and re-checks before its own
|
|
3125
|
+
// write, so this body's own bump cannot strand it.
|
|
2943
3126
|
await this.syncBindingAfterProjectionChange(provider);
|
|
2944
3127
|
return;
|
|
2945
3128
|
}
|
|
2946
3129
|
if (!isOAuthGrantPayload(record.payload)) {
|
|
3130
|
+
if (this.ownershipSuperseded(provider, generation))
|
|
3131
|
+
return;
|
|
2947
3132
|
// An unrecognized payload is not ours to interpret: drop the stale
|
|
2948
3133
|
// owner entry so failure attribution and demotion never fire on it.
|
|
2949
3134
|
this.activeAccounts.delete(provider);
|
|
@@ -2961,6 +3146,8 @@ export class KeysManagerService {
|
|
|
2961
3146
|
// applies, so a restarted process and the live reconciler agree.
|
|
2962
3147
|
const owner = matchOwningAccount(provider, accounts, payload);
|
|
2963
3148
|
if (owner === undefined) {
|
|
3149
|
+
if (this.ownershipSuperseded(provider, generation))
|
|
3150
|
+
return;
|
|
2964
3151
|
// A foreign account's grant serves the route: no pool row owns the
|
|
2965
3152
|
// projection, so the previous owner entry must not survive it — and the
|
|
2966
3153
|
// live record keeps the pool from claiming the route (round 3).
|
|
@@ -2981,6 +3168,11 @@ export class KeysManagerService {
|
|
|
2981
3168
|
// is re-adopted below. Only a removal in progress is refused.
|
|
2982
3169
|
if (!this.accountRowIsManaged(provider, owner))
|
|
2983
3170
|
return;
|
|
3171
|
+
// A newer claim landed while this body was resolving its read: the
|
|
3172
|
+
// rotation write, the row commit and the binding sync below all describe a
|
|
3173
|
+
// generation that claim already superseded.
|
|
3174
|
+
if (this.ownershipSuperseded(provider, generation))
|
|
3175
|
+
return;
|
|
2984
3176
|
// F1 (I-000081): the matched grant IS the live projection, so this row
|
|
2985
3177
|
// owns it from now on — even when no in-process login or activation
|
|
2986
3178
|
// ran (the post-restart echo-rotation case: a hydrated row, no owner
|
|
@@ -2992,30 +3184,29 @@ export class KeysManagerService {
|
|
|
2992
3184
|
// the stale echo claim is discarded exactly like a stale hydration
|
|
2993
3185
|
// read (the rotation sync below still runs, but a commit that a newer
|
|
2994
3186
|
// claim has superseded is rejected — round-9 P1).
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
}
|
|
3187
|
+
// Unconditional: the guard above already returned when a newer claim
|
|
3188
|
+
// landed, and nothing between the two awaits.
|
|
3189
|
+
this.claimProjectionOwner(provider, owner.id);
|
|
3190
|
+
const claimedGeneration = this.ownershipGenerations.get(provider);
|
|
3191
|
+
// R7-SF3: the live projection proves this row's identity again, so a
|
|
3192
|
+
// demoted row is re-adopted — restore its owner claim (above) and end
|
|
3193
|
+
// the demoted state. `valueMissing` means "not serving but still
|
|
3194
|
+
// managed", not "removal in progress": a native sign-in or pi-ai
|
|
3195
|
+
// write that re-projects the grant recovers the account without a
|
|
3196
|
+
// manual re-activation. The flag is persisted with the rest of the
|
|
3197
|
+
// state document.
|
|
3198
|
+
if (owner.valueMissing === true) {
|
|
3199
|
+
owner.valueMissing = false;
|
|
3200
|
+
// R14-SF3: a demotion is earned by a failure, so the row usually
|
|
3201
|
+
// still carries the cooldown `markFailure` wrote. Re-adoption is the
|
|
3202
|
+
// same manual-recovery equivalence `activateAccount` and re-auth
|
|
3203
|
+
// implement: the live projection is serving again, so the old
|
|
3204
|
+
// window ends with it — otherwise the resurrected row renders
|
|
3205
|
+
// "cooling" and stays out of the healthy counts until the timer
|
|
3206
|
+
// expires. Only this proven-adoption branch clears; a row whose
|
|
3207
|
+
// projection did not reappear keeps its window.
|
|
3208
|
+
owner.cooldownUntil = 0;
|
|
3209
|
+
this.store.touch();
|
|
3019
3210
|
}
|
|
3020
3211
|
// Rotation sync: the rotated payload is authoritative (the plan's
|
|
3021
3212
|
// single-writer contract — "sync them back to the owning plugin-scope
|
|
@@ -3106,7 +3297,12 @@ export class KeysManagerService {
|
|
|
3106
3297
|
// shutdown that began under any of the awaits above refuses it.
|
|
3107
3298
|
if (this.persistenceRefused())
|
|
3108
3299
|
return;
|
|
3109
|
-
|
|
3300
|
+
// The commit is the row repoint plus the superseded record's delete, both
|
|
3301
|
+
// after the awaits above (most recently `rotationStillCurrent`'s own record
|
|
3302
|
+
// read). A claim that landed in that read owns the route now, so the commit
|
|
3303
|
+
// refuses like every other mutation in this body.
|
|
3304
|
+
const commitGeneration = claimedGeneration ?? generation;
|
|
3305
|
+
await this.commitGrantGeneration(provider, owner, payload, rotatedRef, () => !this.ownershipSuperseded(provider, commitGeneration));
|
|
3110
3306
|
// PR review round 44: the projection now serves this managed account again,
|
|
3111
3307
|
// so a profile that still names the broker would make pi-ai resolve the
|
|
3112
3308
|
// pool first and fail with MISSING_CREDENTIAL. Converge the binding with the
|
|
@@ -3227,66 +3423,655 @@ export class KeysManagerService {
|
|
|
3227
3423
|
* Make one account's grant payload the route's stored credential
|
|
3228
3424
|
* (D1 activation projection).
|
|
3229
3425
|
*
|
|
3230
|
-
*
|
|
3231
|
-
*
|
|
3232
|
-
*
|
|
3233
|
-
*
|
|
3234
|
-
*
|
|
3235
|
-
* read → project → claim section therefore runs on the provider's grant
|
|
3236
|
-
* chain ({@link scheduleGrantReconciliation}) — the same chain the
|
|
3237
|
-
* reconciler bodies use. A rotation committed before this body is observed
|
|
3238
|
-
* in `row.oauth.grantRef`; one that arrives while the body runs is queued
|
|
3239
|
-
* behind it and reconciles the generation this body projected. Either way
|
|
3240
|
-
* the projection holds exactly the generation the row names.
|
|
3426
|
+
* Plan iter-pool-activate-queue, "Spike outcome" — the contract this method
|
|
3427
|
+
* implements. The slow half (read the row's grant, validate it, capture what
|
|
3428
|
+
* the route serves now) runs outside the mutation queue, so a slow credential
|
|
3429
|
+
* store does not delay unrelated mutations. The mutation queue turn owns every
|
|
3430
|
+
* write:
|
|
3241
3431
|
*
|
|
3242
|
-
*
|
|
3243
|
-
* `
|
|
3244
|
-
*
|
|
3245
|
-
*
|
|
3432
|
+
* 1. admission checks in queue order, which produce this method's error
|
|
3433
|
+
* surface (`Provider "…" is not managed`, `Account "…" not found on …`,
|
|
3434
|
+
* `Key "…" is not an OAuth account`, `The credential store does not support
|
|
3435
|
+
* records`) — plus a re-check that the row still names the generation the
|
|
3436
|
+
* read used;
|
|
3437
|
+
* 2. the projection write as a compare-and-write under the seam's per-key
|
|
3438
|
+
* lock (credentials/src/index.ts:236-250): it lands only while the live
|
|
3439
|
+
* record still equals the captured baseline, "both absent" included. A
|
|
3440
|
+
* rotation, a removal or any foreign writer that replaced the projection
|
|
3441
|
+
* meanwhile makes the compare fail, so this call refuses with the retryable
|
|
3442
|
+
* `could not be projected` message and leaves the newer projection
|
|
3443
|
+
* untouched — there is nothing to delete, because nothing was written;
|
|
3444
|
+
* 3. the owner claim, the row writes and the returned `buildState`.
|
|
3445
|
+
*
|
|
3446
|
+
* There is deliberately no shutdown check after the write: `close()` awaits
|
|
3447
|
+
* this queue before it closes the store, so a turn that started in time always
|
|
3448
|
+
* finishes (measured in the spike that produced this contract). A check there
|
|
3449
|
+
* is what used to leave an orphan projection behind.
|
|
3450
|
+
*
|
|
3451
|
+
* The one write the turn cannot make durable by itself is a rejected state
|
|
3452
|
+
* flush; see {@link compensateActivationFlush} for that compensation.
|
|
3246
3453
|
*/
|
|
3247
|
-
activateAccount(provider, accountId) {
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3454
|
+
async activateAccount(provider, accountId) {
|
|
3455
|
+
const records = this.credentialRecords();
|
|
3456
|
+
// Locator only: it keeps the row's grantRef reachable for the read below.
|
|
3457
|
+
// The turn re-reads the store and decides; this lookup decides nothing.
|
|
3458
|
+
const located = this.store.provider(provider)?.keys.find(candidate => candidate.id === accountId);
|
|
3459
|
+
// The refusals `run` applies (1683, 1685-1687) are hoisted ahead of the
|
|
3460
|
+
// credential read: a refused call should not pay for it at all. They are
|
|
3461
|
+
// not the guarantee — the turn's own admission checks are.
|
|
3462
|
+
if (this.closing)
|
|
3463
|
+
throw new Error('Keys Manager is shutting down');
|
|
3464
|
+
if (this.store.readOnly) {
|
|
3465
|
+
throw new Error('The state file could not be read; refusing to write over it. Restart after fixing file access.');
|
|
3466
|
+
}
|
|
3467
|
+
// The slow half, outside the queue: read the grant this activation would
|
|
3468
|
+
// project and capture what the route serves now as the compare baseline.
|
|
3469
|
+
// Nothing here decides — admission order belongs to the turn.
|
|
3470
|
+
const prepared = located !== undefined
|
|
3471
|
+
&& located.kind === 'oauth'
|
|
3472
|
+
&& located.oauth !== null
|
|
3473
|
+
&& records !== undefined
|
|
3474
|
+
? await this.prepareActivation(provider, located, records)
|
|
3475
|
+
: undefined;
|
|
3476
|
+
if (prepared !== undefined && records !== undefined) {
|
|
3477
|
+
// Same-`grantRef` rotation: pi-ai can refresh the ACCESS token while
|
|
3478
|
+
// keeping the refresh token, so the reference never moves, both `grantRef`
|
|
3479
|
+
// fences pass, and the projection can already carry the fresh payload
|
|
3480
|
+
// while the plugin-scope record still holds the stale one. Projecting
|
|
3481
|
+
// `prepared.payload` then writes an older credential over a fresher
|
|
3482
|
+
// projection. Re-read the source as late as possible and refuse unless it
|
|
3483
|
+
// still says what this activation prepared.
|
|
3484
|
+
//
|
|
3485
|
+
// This read sits OUTSIDE the queue, exactly where `prepareActivation` does
|
|
3486
|
+
// its credential work: a read inside the turn would put credential I/O
|
|
3487
|
+
// back on the global mutation queue, which is the cross-provider stall —
|
|
3488
|
+
// and the shutdown wait — the activation split exists to remove.
|
|
3489
|
+
//
|
|
3490
|
+
// Residual, named: reading outside the turn only narrows the same-ref
|
|
3491
|
+
// rotation window to this activation's QUEUE WAIT instead of closing it.
|
|
3492
|
+
// Closing it fully needs a seam that lets one read and one write share a
|
|
3493
|
+
// single lock, which `CredentialRecordsLike` (host-types.ts:136-143) does
|
|
3494
|
+
// not promise. What still covers the rest: the in-lock baseline compare on
|
|
3495
|
+
// the projection side, and the post-write `grantRef` recheck for reference
|
|
3496
|
+
// changes. A same-ref rotation that lands while the activation waits in
|
|
3497
|
+
// the queue is the uncovered case.
|
|
3498
|
+
const source = await records.readRecord(prepared.grantRef);
|
|
3499
|
+
const sourceMatches = source !== undefined
|
|
3500
|
+
&& source.kind === 'grant'
|
|
3501
|
+
&& JSON.stringify(source.payload) === prepared.written;
|
|
3502
|
+
// The preparation can carry a payload its own repair write did not
|
|
3503
|
+
// persist: the ownership-verified projection is then the live credential,
|
|
3504
|
+
// and an absent or unusable ref is that state — not a concurrent writer.
|
|
3505
|
+
const sourceAbsentOrUnusable = source === undefined
|
|
3506
|
+
|| source.kind !== 'grant'
|
|
3507
|
+
|| !isOAuthGrantPayload(source.payload);
|
|
3508
|
+
if (!sourceMatches && !(prepared.recoveredFromProjection && sourceAbsentOrUnusable)) {
|
|
3509
|
+
throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
let undo;
|
|
3513
|
+
try {
|
|
3514
|
+
await this.run(async () => {
|
|
3515
|
+
const providerRecord = this.store.provider(provider);
|
|
3516
|
+
if (providerRecord === undefined)
|
|
3517
|
+
throw new Error(`Provider "${provider}" is not managed`);
|
|
3518
|
+
const row = providerRecord.keys.find(candidate => candidate.id === accountId);
|
|
3519
|
+
if (row === undefined)
|
|
3520
|
+
throw new Error(`Account "${accountId}" not found on ${provider}`);
|
|
3521
|
+
if (row.kind !== 'oauth' || row.oauth === null)
|
|
3522
|
+
throw new Error(`Key "${accountId}" is not an OAuth account`);
|
|
3523
|
+
if (records === undefined) {
|
|
3524
|
+
throw new Error('The credential store does not support records');
|
|
3267
3525
|
}
|
|
3268
|
-
|
|
3269
|
-
|
|
3526
|
+
if (prepared === undefined) {
|
|
3527
|
+
// The locator only hints; the turn decides. A row that was not an
|
|
3528
|
+
// OAuth account when it was located (created, re-added, or repointed
|
|
3529
|
+
// in between) has nothing prepared for it — that is a stale locator,
|
|
3530
|
+
// not a store without record support.
|
|
3531
|
+
throw new Error(`The account "${accountId}" on ${provider} changed before it could be activated; activate it again`);
|
|
3532
|
+
}
|
|
3533
|
+
// A remount replaced the credential backend while the read ran; the
|
|
3534
|
+
// payload came from records this manager no longer serves.
|
|
3535
|
+
//
|
|
3536
|
+
// The row must still name the generation this activation read. A
|
|
3537
|
+
// rotation echo or a removal + re-add that repointed it meanwhile
|
|
3538
|
+
// belongs to a different grant, and projecting the old payload would
|
|
3539
|
+
// either overwrite a newer generation or report a success the row does
|
|
3540
|
+
// not serve. Refusing keeps the record untouched; the caller retries.
|
|
3541
|
+
if (this.credentialRecords() !== records || row.oauth.grantRef !== prepared.grantRef) {
|
|
3542
|
+
throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
|
|
3543
|
+
}
|
|
3544
|
+
// The projection write is a compare-and-write under the seam's per-key
|
|
3545
|
+
// lock: it lands only while the route still serves the baseline read
|
|
3546
|
+
// above. A removal or a foreign writer that got there first makes the
|
|
3547
|
+
// compare fail, and this activation refuses without touching the
|
|
3548
|
+
// record — there is nothing to compensate afterwards.
|
|
3549
|
+
const written = await this.compareAndWriteProjection(records, provider, prepared);
|
|
3550
|
+
if (written === undefined) {
|
|
3551
|
+
throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
|
|
3552
|
+
}
|
|
3553
|
+
// No post-write shutdown check: `close()` awaits this queue before it
|
|
3554
|
+
// closes the store (`1758-1762`), so a run that starts in time always
|
|
3555
|
+
// finishes before the store goes away. Refusing here after the write
|
|
3556
|
+
// is what used to manufacture an orphan.
|
|
3557
|
+
undo = { valueMissing: row.valueMissing, cooldownUntil: row.cooldownUntil, generation: 0 };
|
|
3270
3558
|
this.claimProjectionOwner(provider, row.id);
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
//
|
|
3559
|
+
undo.generation = this.ownershipGenerations.get(provider) ?? 0;
|
|
3560
|
+
row.valueMissing = false;
|
|
3561
|
+
// Round-5 finding M1: manual recovery ends the cooling window too, the
|
|
3562
|
+
// same reset a successful re-authentication applies. A demoted account
|
|
3563
|
+
// is re-activated while its stale cooldown still sits on the row, which
|
|
3564
|
+
// would render the freshly projected grant as cooling and keep it out
|
|
3565
|
+
// of the healthy counts until the timer expires. Clearing the window
|
|
3566
|
+
// also resets escalation: `applyFailureCooldown` derives the next level
|
|
3567
|
+
// from `cooldownUntil` only (core.ts:168-174).
|
|
3568
|
+
row.cooldownUntil = 0;
|
|
3569
|
+
this.store.touch();
|
|
3570
|
+
// The pre-write check closed the read window, but a rotation commit can
|
|
3571
|
+
// land inside the seam's compare-and-write await: it repoints the row
|
|
3572
|
+
// (a different record key) while the projection write is in flight, and
|
|
3573
|
+
// the in-lock compare cannot see the row. Projecting then leaves the
|
|
3574
|
+
// route serving a generation the row has already superseded, so the
|
|
3575
|
+
// activation refuses and the flush compensation above undoes both — the
|
|
3576
|
+
// claim is set, so the undo path owns it.
|
|
3577
|
+
if (row.oauth.grantRef !== prepared.grantRef) {
|
|
3578
|
+
throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
|
|
3579
|
+
}
|
|
3580
|
+
}, async () => {
|
|
3581
|
+
// A rejected flush lands here AFTER the turn resolved its work: `run`
|
|
3582
|
+
// does not roll the mutation back, so the row is still cleared in memory
|
|
3583
|
+
// and the projection still serves the route. Undo both once, by
|
|
3584
|
+
// identity, inside THIS turn's window — a later turn would lose the race
|
|
3585
|
+
// against every mutation already queued behind the activation (a removal
|
|
3586
|
+
// of the account deletes the half-committed projection first, and the
|
|
3587
|
+
// captured baseline could then no longer be restored). Failures before
|
|
3588
|
+
// the write leave `undo` unset: nothing to undo.
|
|
3589
|
+
//
|
|
3590
|
+
// Residual (I-000090 territory): `undo` is assigned only once the write
|
|
3591
|
+
// resolved, so a `modifyRecord` that applies its mutation and THEN
|
|
3592
|
+
// rejects refuses with the raw seam error and keeps the projection. What
|
|
3593
|
+
// compensation could run is undecidable from this repo — the seam would
|
|
3594
|
+
// have to promise it never commits then rejects — so this shape is left
|
|
3595
|
+
// as the seam reports it rather than guessed at.
|
|
3596
|
+
if (undo === undefined)
|
|
3597
|
+
return;
|
|
3598
|
+
await this.compensateActivationFlush(provider, accountId, prepared, undo, records);
|
|
3599
|
+
});
|
|
3600
|
+
// The re-activated grant is the serving credential again, so a broker
|
|
3601
|
+
// reference left by the key pool must go (see syncBrokerBinding). A
|
|
3602
|
+
// settings refusal does not roll back the committed projection.
|
|
3603
|
+
//
|
|
3604
|
+
// Both the sync and the returned snapshot run in a second queue turn:
|
|
3605
|
+
// the binding attempt must have happened before the view is built (a
|
|
3606
|
+
// refusal pushes its warning, and building the view inside the write turn
|
|
3607
|
+
// drained it, so the RPC reported a clean activation while requests kept
|
|
3608
|
+
// using the pool), and the snapshot itself is state: taken outside the
|
|
3609
|
+
// queue, another mutation could enter, mutate and be mid-flush while
|
|
3610
|
+
// `buildState` pruned and drained, exposing state that is not durable.
|
|
3611
|
+
// The turn waits for such a mutation to finish or fail first.
|
|
3612
|
+
if (this.closing) {
|
|
3613
|
+
// Shutdown opened while this activation was writing, so no new turn may
|
|
3614
|
+
// be appended: `closeOnce` captured `this.queue` when `closing` went up
|
|
3615
|
+
// and closes the store once THAT reference settles, so a turn submitted
|
|
3616
|
+
// here would run after close(). Instead, join the reference shutdown is
|
|
3617
|
+
// itself draining — mutations already queued behind this turn are still
|
|
3618
|
+
// running, and `buildState` prunes and drains, so the snapshot must come
|
|
3619
|
+
// after they settle rather than beside them.
|
|
3620
|
+
await this.queue;
|
|
3621
|
+
// The binding's last-point guard refuses the write once `closing` is
|
|
3622
|
+
// set and reports that refusal itself, so a route that needs converging
|
|
3623
|
+
// now says so in the returned state instead of reporting a clean
|
|
3624
|
+
// success; a route that is already converged needs no write at all. The
|
|
3625
|
+
// next start's repair pass owns the convergence either way.
|
|
3274
3626
|
await this.syncBrokerBinding(provider);
|
|
3627
|
+
return this.buildState();
|
|
3628
|
+
}
|
|
3629
|
+
// The projection turn above already committed AND flushed, so the
|
|
3630
|
+
// activation is durable and serving. This second turn only converges the
|
|
3631
|
+
// binding and takes the snapshot; if its flush fails, that activation must
|
|
3632
|
+
// not be reported as failed — a caller would retry an activation that
|
|
3633
|
+
// already landed. `built` is the discriminator: `guarded` runs the body
|
|
3634
|
+
// before the flush, so a BODY failure leaves it unset and still
|
|
3635
|
+
// propagates (shutdown, a refusal), while a post-commit flush failure is
|
|
3636
|
+
// reported in the state the caller receives.
|
|
3637
|
+
let built;
|
|
3638
|
+
try {
|
|
3639
|
+
return await this.run(async () => {
|
|
3640
|
+
await this.syncBrokerBinding(provider);
|
|
3641
|
+
built = this.buildState();
|
|
3642
|
+
return built;
|
|
3643
|
+
});
|
|
3644
|
+
}
|
|
3645
|
+
catch (error) {
|
|
3646
|
+
if (built === undefined)
|
|
3647
|
+
throw error;
|
|
3648
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
3649
|
+
const warning = `The ${provider} activation is saved, but its binding and state snapshot could not be persisted (${reason}). The account is serving; the next successful change saves the rest.`;
|
|
3650
|
+
// One channel only: the returned snapshot. `buildState` has already
|
|
3651
|
+
// drained the warning queue, so ALSO queueing this copy would surface
|
|
3652
|
+
// the same failure twice (once in this payload, once on the next
|
|
3653
|
+
// refresh). It still reaches the operator log, which is what a caller
|
|
3654
|
+
// that ignores the returned state leaves visible.
|
|
3655
|
+
this.warn(`activation post-commit persistence failed for ${provider}: ${reason}`);
|
|
3656
|
+
// The snapshot was taken before this warning existed, and re-taking one
|
|
3657
|
+
// outside the queue is the race this turn exists to avoid, so the view
|
|
3658
|
+
// is annotated rather than rebuilt.
|
|
3659
|
+
return { ...built, warnings: [...built.warnings, warning] };
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
catch (error) {
|
|
3663
|
+
// Which path covers which case:
|
|
3664
|
+
// - `run` refuses the submission while `closing`, and `guarded` refuses a
|
|
3665
|
+
// read-only store before the operation runs: nothing was written, so
|
|
3666
|
+
// `undo` stayed unset and there is nothing to compensate.
|
|
3667
|
+
// - a failure INSIDE the turn (the flush, or the seam) runs the
|
|
3668
|
+
// compensation hook above, inside that turn's exclusive window, even
|
|
3669
|
+
// when closing began during it; that hook is the fallback the previous
|
|
3670
|
+
// independent turn used to be, and it needs no closing branch.
|
|
3671
|
+
// - the hook's own flush is best-effort: a still-unwritable store leaves
|
|
3672
|
+
// memory restored and the next successful flush persists it.
|
|
3673
|
+
throw error;
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
/**
|
|
3677
|
+
* The row's own grant payload, recovered from the live projection when the
|
|
3678
|
+
* plugin-scope record is gone. The projection IS the route's credential, so a
|
|
3679
|
+
* matching payload is written back (compare-at-absent) and used immediately.
|
|
3680
|
+
*
|
|
3681
|
+
* `recoveredFromProjection` marks a payload the ownership-verified projection
|
|
3682
|
+
* supplied when the repair write did not persist it: the ref stays absent or
|
|
3683
|
+
* unusable, so a caller that re-reads it must treat the payload as the live
|
|
3684
|
+
* credential instead of a concurrent writer's record. That case also returns
|
|
3685
|
+
* the projection record it validated: a caller that compares before writing
|
|
3686
|
+
* must use this snapshot as its baseline, or the compare would accept a
|
|
3687
|
+
* rotation that landed after the payload was read. It never marks a
|
|
3688
|
+
* projection whose row has been detached or repointed since the snapshot —
|
|
3689
|
+
* that recovery is refused, because the projection then belongs to a
|
|
3690
|
+
* generation this row no longer names. For the same reason a repair the
|
|
3691
|
+
* updater declined (row gone, retiring, or repointed) resolves `undefined`:
|
|
3692
|
+
* the callers' own "missing or unusable" failure is the truthful answer, not
|
|
3693
|
+
* the payload of a generation this row has left behind.
|
|
3694
|
+
*/
|
|
3695
|
+
async recoverGrantPayload(provider, row, records) {
|
|
3696
|
+
const grantRef = row.oauth.grantRef;
|
|
3697
|
+
const stored = await records.readRecord(grantRef);
|
|
3698
|
+
if (stored !== undefined && stored.kind === 'grant' && isOAuthGrantPayload(stored.payload)) {
|
|
3699
|
+
return { payload: stored.payload, recoveredFromProjection: false };
|
|
3700
|
+
}
|
|
3701
|
+
const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
|
|
3702
|
+
// The seam can hand back its own live record and this repair awaits before
|
|
3703
|
+
// it writes: snapshot it once so the ownership check, the write and the
|
|
3704
|
+
// returned baseline all see the payload that was verified, even if the
|
|
3705
|
+
// source is updated in place meanwhile.
|
|
3706
|
+
const projection = structuredClone(await records.readRecord(projectionKey));
|
|
3707
|
+
if (projection === undefined || projection.kind !== 'grant' || !isOAuthGrantPayload(projection.payload))
|
|
3708
|
+
return undefined;
|
|
3709
|
+
// Only a projection this row owns is adopted; recovering a foreign grant
|
|
3710
|
+
// would attach another account's credential to this row.
|
|
3711
|
+
const oauthRows = this.store.provider(provider)?.keys.filter(candidate => candidate.kind === 'oauth' && candidate.oauth !== null) ?? [];
|
|
3712
|
+
if (matchOwningAccount(provider, oauthRows, projection.payload)?.id !== row.id)
|
|
3713
|
+
return undefined;
|
|
3714
|
+
// `row` is a snapshot taken before the reads: a removal or a re-sign-in can
|
|
3715
|
+
// have detached or repointed it since, and the repair must not land on a
|
|
3716
|
+
// ref this row no longer names.
|
|
3717
|
+
if (oauthRows.find(candidate => candidate.id === row.id)?.oauth?.grantRef !== grantRef) {
|
|
3718
|
+
// The row no longer names this ref, so the projection that matched it
|
|
3719
|
+
// belongs to a previous generation: refusing leaves the caller's own
|
|
3720
|
+
// "missing or unusable" failure in place instead of reviving it.
|
|
3721
|
+
return undefined;
|
|
3722
|
+
}
|
|
3723
|
+
// A declined updater is NOT "the ref is occupied by an unusable record":
|
|
3724
|
+
// it means nothing was written for this row, so the projection read above
|
|
3725
|
+
// is a previous generation's payload and must not be served.
|
|
3726
|
+
let declined = false;
|
|
3727
|
+
try {
|
|
3728
|
+
// Compare-at-absent: an occupied ref returns `current`, which the seam
|
|
3729
|
+
// keeps, so a concurrent login's or rotation's record wins the ref.
|
|
3730
|
+
const liveNow = await records.modifyRecord(grantRef, current => {
|
|
3731
|
+
if (current !== undefined)
|
|
3732
|
+
return Promise.resolve(current);
|
|
3733
|
+
// Runs under the seam's per-key lock, so this is the state the write
|
|
3734
|
+
// commits against: a removal that deleted the ref and marked (or
|
|
3735
|
+
// spliced) the row since the checks above must not be undone by
|
|
3736
|
+
// re-creating the credential it deleted.
|
|
3737
|
+
const liveRow = this.store.provider(provider)?.keys.find(candidate => candidate.id === row.id);
|
|
3738
|
+
if (liveRow === undefined
|
|
3739
|
+
|| this.retiringAccounts.has(liveRow)
|
|
3740
|
+
|| liveRow.kind !== 'oauth'
|
|
3741
|
+
|| liveRow.oauth === null
|
|
3742
|
+
|| liveRow.oauth.grantRef !== grantRef) {
|
|
3743
|
+
declined = true;
|
|
3744
|
+
return Promise.resolve(undefined);
|
|
3745
|
+
}
|
|
3746
|
+
return Promise.resolve({ kind: 'grant', payload: structuredClone(projection.payload) });
|
|
3275
3747
|
});
|
|
3276
|
-
|
|
3277
|
-
//
|
|
3278
|
-
//
|
|
3279
|
-
//
|
|
3280
|
-
//
|
|
3281
|
-
//
|
|
3282
|
-
//
|
|
3283
|
-
//
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
//
|
|
3287
|
-
|
|
3288
|
-
|
|
3748
|
+
// No post-write compensation (PR #37 I-000233): it would prove what THIS
|
|
3749
|
+
// call wrote, not that the record it deleted was still ours — a same-ref
|
|
3750
|
+
// sign-in can commit a fresh record before attaching its row, and the
|
|
3751
|
+
// seam has no compare-and-delete (`deleteRecord(key)` takes no expected
|
|
3752
|
+
// payload). It is redundant: the removal path owns the ref it retires.
|
|
3753
|
+
// `removeOAuthGrant` marks the row in `retiringAccounts` BEFORE its first
|
|
3754
|
+
// awaited delete, and this updater revalidates synchronously under the
|
|
3755
|
+
// seam's per-key lock, so a removal that started first makes the update
|
|
3756
|
+
// decline, and one that starts after this write deletes
|
|
3757
|
+
// `row.oauth.grantRef` — the very ref this updater just proved the row
|
|
3758
|
+
// names — itself.
|
|
3759
|
+
if (declined)
|
|
3760
|
+
return undefined;
|
|
3761
|
+
// The ref may have been taken while this read was parked: the record's
|
|
3762
|
+
// payload is the credential the caller must borrow now, not the
|
|
3763
|
+
// projection this repair declined to write over it.
|
|
3764
|
+
if (liveNow !== undefined && liveNow.kind === 'grant' && isOAuthGrantPayload(liveNow.payload)) {
|
|
3765
|
+
return { payload: liveNow.payload, recoveredFromProjection: false };
|
|
3766
|
+
}
|
|
3767
|
+
}
|
|
3768
|
+
catch (error) {
|
|
3769
|
+
// Best-effort repair: the projection still served this read.
|
|
3770
|
+
this.warn(`could not restore ${grantRef} for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3771
|
+
}
|
|
3772
|
+
return { payload: projection.payload, recoveredFromProjection: true, projection };
|
|
3773
|
+
}
|
|
3774
|
+
/**
|
|
3775
|
+
* The slow half of an activation, outside the queue: read the row's grant
|
|
3776
|
+
* generation (recovered from the live projection when its record is gone),
|
|
3777
|
+
* validate it, and capture the projection the route serves now.
|
|
3778
|
+
*/
|
|
3779
|
+
async prepareActivation(provider, row, records) {
|
|
3780
|
+
const grantRef = row.oauth.grantRef;
|
|
3781
|
+
const recovered = await this.recoverGrantPayload(provider, row, records);
|
|
3782
|
+
if (recovered === undefined) {
|
|
3783
|
+
throw new Error(`The grant payload at ${grantRef} is missing or unusable; sign in to this account again`);
|
|
3784
|
+
}
|
|
3785
|
+
const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
|
|
3786
|
+
// A projection-only recovery already read and ownership-verified the route.
|
|
3787
|
+
// Re-reading it here would open a gap: a same-ref rotation landing in it
|
|
3788
|
+
// leaves the payload from the first read and a baseline from the second, so
|
|
3789
|
+
// the compare would pass and write the older credential. The record the
|
|
3790
|
+
// payload came from is the only truthful baseline for it.
|
|
3791
|
+
const live = recovered.projection ?? await records.readRecord(projectionKey);
|
|
3792
|
+
const payload = structuredClone(recovered.payload);
|
|
3793
|
+
// F5: the restored record decides its owner, so the classification is
|
|
3794
|
+
// derived from the baseline payload through the shared matcher — the same
|
|
3795
|
+
// rule the reconciler applies — rather than from an owner id captured
|
|
3796
|
+
// earlier that the record may no longer belong to.
|
|
3797
|
+
const accounts = this.store.provider(provider)?.keys.filter(candidate => candidate.kind === 'oauth' && candidate.oauth !== null) ?? [];
|
|
3798
|
+
const baselineOwner = live !== undefined && live.kind === 'grant' && isOAuthGrantPayload(live.payload)
|
|
3799
|
+
? matchOwningAccount(provider, accounts, live.payload)
|
|
3800
|
+
: undefined;
|
|
3801
|
+
return {
|
|
3802
|
+
grantRef,
|
|
3803
|
+
payload,
|
|
3804
|
+
written: JSON.stringify(payload),
|
|
3805
|
+
recoveredFromProjection: recovered.recoveredFromProjection,
|
|
3806
|
+
baseline: live === undefined ? { state: 'absent' } : { state: 'present', record: structuredClone(live) },
|
|
3807
|
+
// Captured with the baseline, before the turn claims the route: the
|
|
3808
|
+
// compensation has to put back the classification that matches the
|
|
3809
|
+
// record it restores, and only this snapshot still knows it. Any present
|
|
3810
|
+
// record no row's payload matches is foreign (a native api-key
|
|
3811
|
+
// credential, an unrecognized payload, an unowned grant), so `none` is
|
|
3812
|
+
// only the absent-baseline case.
|
|
3813
|
+
classification: live === undefined
|
|
3814
|
+
? { state: 'none' }
|
|
3815
|
+
: baselineOwner !== undefined ? { state: 'owned', rowId: baselineOwner.id } : { state: 'foreign' },
|
|
3816
|
+
};
|
|
3817
|
+
}
|
|
3818
|
+
/** Whether the projection still holds the value this activation read. */
|
|
3819
|
+
matchesActivationBaseline(current, baseline) {
|
|
3820
|
+
if (baseline.state === 'absent')
|
|
3821
|
+
return current === undefined;
|
|
3822
|
+
if (current === undefined)
|
|
3823
|
+
return false;
|
|
3824
|
+
// Compare the FIELDS, never the object shape: `CredentialRecordLike` makes
|
|
3825
|
+
// `key`/`env`/`payload` optional, and a seam whose read path and
|
|
3826
|
+
// `modifyRecord` path materialise different key sets (one omits them, one
|
|
3827
|
+
// sets them to `undefined`) would defeat a structural comparison and refuse
|
|
3828
|
+
// every activation over an already-serving route.
|
|
3829
|
+
//
|
|
3830
|
+
// An api-key projection keeps its secret in `key`/`env` with `payload`
|
|
3831
|
+
// absent, so every field is compared: payload plus the native fields.
|
|
3832
|
+
const baselineRecord = baseline.record;
|
|
3833
|
+
const sameSecret = current.key === baselineRecord.key;
|
|
3834
|
+
const sameEnv = JSON.stringify(current.env ?? null) === JSON.stringify(baselineRecord.env ?? null);
|
|
3835
|
+
const samePayload = JSON.stringify(current.payload ?? null) === JSON.stringify(baselineRecord.payload ?? null);
|
|
3836
|
+
return current.kind === baselineRecord.kind && sameSecret && sameEnv && samePayload;
|
|
3837
|
+
}
|
|
3838
|
+
/**
|
|
3839
|
+
* Write the projection only while the route still serves the baseline. The
|
|
3840
|
+
* seam's `modifyRecord` mutate body runs under the per-key lock, so this is
|
|
3841
|
+
* the one atomic primitive the activation has (I-000090: `deleteRecord` has
|
|
3842
|
+
* no expected-payload form). Returns the payload written, or `undefined` when
|
|
3843
|
+
* the compare failed and nothing was touched.
|
|
3844
|
+
*/
|
|
3845
|
+
async compareAndWriteProjection(records, provider, prepared) {
|
|
3846
|
+
const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
|
|
3847
|
+
let landed = false;
|
|
3848
|
+
await records.modifyRecord(projectionKey, async (current) => {
|
|
3849
|
+
if (!this.matchesActivationBaseline(current, prepared.baseline))
|
|
3850
|
+
return undefined;
|
|
3851
|
+
landed = true;
|
|
3852
|
+
// Only this write supersedes a pending own-removal marker: a projection
|
|
3853
|
+
// is about to serve again, so a removal echo still in flight cannot
|
|
3854
|
+
// describe the live record. A refused compare must leave the marker
|
|
3855
|
+
// armed for the removal it belongs to — clearing it there would let that
|
|
3856
|
+
// removal's echo read as an out-of-band deletion (a spurious "removed
|
|
3857
|
+
// outside Keys Manager" warning and a `valueMissing` row).
|
|
3858
|
+
this.intentionalProjectionRemovals.delete(provider);
|
|
3859
|
+
return { kind: 'grant', payload: prepared.payload };
|
|
3289
3860
|
});
|
|
3861
|
+
return landed ? prepared.written : undefined;
|
|
3862
|
+
}
|
|
3863
|
+
/**
|
|
3864
|
+
* Undo one activation whose state flush was rejected: the row this turn
|
|
3865
|
+
* cleared in memory, the owner claim it took, and the projection it wrote
|
|
3866
|
+
* (back to the captured baseline).
|
|
3867
|
+
*
|
|
3868
|
+
* The baseline decides the mechanism. A present baseline is restored with the
|
|
3869
|
+
* same compare-and-write the projection used; only an absent baseline has to
|
|
3870
|
+
* remove a record, and only while that record is still this activation's own
|
|
3871
|
+
* payload. A foreign writer's record is never deleted.
|
|
3872
|
+
*/
|
|
3873
|
+
async compensateActivationFlush(provider, accountId, prepared, undo, records) {
|
|
3874
|
+
const outcome = prepared.baseline.state === 'absent'
|
|
3875
|
+
? await this.deleteActivationProjection(records, provider, prepared.written, undo.generation)
|
|
3876
|
+
: await this.restoreActivationProjection(records, provider, prepared, undo.generation);
|
|
3877
|
+
// Both helpers awaited, so the classification below decides after the same
|
|
3878
|
+
// read the guard above made before it: a claim that landed inside that I/O
|
|
3879
|
+
// already settled what serves the route, and overwriting it would revert a
|
|
3880
|
+
// newer owner or mark its route foreign. No await separates this check from
|
|
3881
|
+
// the writes in the switch.
|
|
3882
|
+
if (this.ownershipSuperseded(provider, undo.generation))
|
|
3883
|
+
return;
|
|
3884
|
+
// The compensation is about to replace what the route serves (or which row
|
|
3885
|
+
// owns it), so every reconciler that captured the earlier generation read a
|
|
3886
|
+
// world this undoes and must not commit: its projection read may predate the
|
|
3887
|
+
// restore, and its row/binding writes would re-claim an account the restored
|
|
3888
|
+
// baseline does not serve. `ours` keeps the activation's projection and claim
|
|
3889
|
+
// in place — it changes nothing a reconciler could misread — so it needs no
|
|
3890
|
+
// bump.
|
|
3891
|
+
if (outcome !== 'ours')
|
|
3892
|
+
this.noteProjectionCommitted(provider);
|
|
3893
|
+
// P2-B: the row rollback belongs to the outcomes that actually changed what
|
|
3894
|
+
// serves the route. `ours` keeps the activation's projection and claim — the
|
|
3895
|
+
// account IS serving — so restoring the pre-activation demotion would render
|
|
3896
|
+
// a live account as missing/cooling, and a restart without an echo would
|
|
3897
|
+
// persist that lie.
|
|
3898
|
+
if (outcome !== 'ours') {
|
|
3899
|
+
const row = this.store.provider(provider)?.keys.find(candidate => candidate.id === accountId);
|
|
3900
|
+
if (row !== undefined) {
|
|
3901
|
+
row.valueMissing = undo.valueMissing;
|
|
3902
|
+
row.cooldownUntil = undo.cooldownUntil;
|
|
3903
|
+
this.store.touch();
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
switch (outcome) {
|
|
3907
|
+
case 'superseded':
|
|
3908
|
+
// The newer claim that moved the generation owns the route and has
|
|
3909
|
+
// already settled what serves it; this undo decides nothing.
|
|
3910
|
+
return;
|
|
3911
|
+
case 'ours':
|
|
3912
|
+
// The activation's projection is still the live record, so its claim
|
|
3913
|
+
// stays: deleting it would call the route pool-served while a record
|
|
3914
|
+
// serves it, and selection plus failure attribution would be wrong
|
|
3915
|
+
// until an echo reconciled the route (never, if the optional
|
|
3916
|
+
// `record-updated` subscription is unavailable).
|
|
3917
|
+
return;
|
|
3918
|
+
case 'baseline':
|
|
3919
|
+
this.applyActivationClassification(provider, prepared.classification);
|
|
3920
|
+
return;
|
|
3921
|
+
case 'foreign':
|
|
3922
|
+
this.activeAccounts.delete(provider);
|
|
3923
|
+
this.foreignProjections.add(provider);
|
|
3924
|
+
return;
|
|
3925
|
+
case 'gone':
|
|
3926
|
+
// Nothing serves the route any more, so the pool does.
|
|
3927
|
+
this.activeAccounts.delete(provider);
|
|
3928
|
+
this.foreignProjections.delete(provider);
|
|
3929
|
+
return;
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
/** Put the route's owner classification back to a captured snapshot. */
|
|
3933
|
+
applyActivationClassification(provider, classification) {
|
|
3934
|
+
if (classification.state === 'owned') {
|
|
3935
|
+
this.activeAccounts.set(provider, classification.rowId);
|
|
3936
|
+
this.foreignProjections.delete(provider);
|
|
3937
|
+
return;
|
|
3938
|
+
}
|
|
3939
|
+
this.activeAccounts.delete(provider);
|
|
3940
|
+
if (classification.state === 'foreign')
|
|
3941
|
+
this.foreignProjections.add(provider);
|
|
3942
|
+
else
|
|
3943
|
+
this.foreignProjections.delete(provider);
|
|
3944
|
+
}
|
|
3945
|
+
/** Put the projection back to the present baseline, if it is still ours. */
|
|
3946
|
+
async restoreActivationProjection(records, provider, prepared, generation) {
|
|
3947
|
+
const baseline = prepared.baseline;
|
|
3948
|
+
if (baseline.state !== 'present')
|
|
3949
|
+
return 'baseline';
|
|
3950
|
+
const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
|
|
3951
|
+
let outcome = 'ours';
|
|
3952
|
+
try {
|
|
3953
|
+
await records.modifyRecord(projectionKey, async (current) => {
|
|
3954
|
+
// The generation is decided HERE, under the seam's per-key lock and in
|
|
3955
|
+
// the same synchronous step as the payload compare: a claim that lands
|
|
3956
|
+
// between this compensation's entry and this callback would otherwise
|
|
3957
|
+
// see its own projection matched and get the baseline written back over
|
|
3958
|
+
// a route it owns, while the post-I/O check then exits early and leaves
|
|
3959
|
+
// that newer account claimed beside the restored credential.
|
|
3960
|
+
if (this.ownershipSuperseded(provider, generation)) {
|
|
3961
|
+
outcome = 'superseded';
|
|
3962
|
+
return undefined;
|
|
3963
|
+
}
|
|
3964
|
+
if (current === undefined) {
|
|
3965
|
+
outcome = 'gone';
|
|
3966
|
+
return undefined;
|
|
3967
|
+
}
|
|
3968
|
+
if (current.kind !== 'grant' || JSON.stringify(current.payload) !== prepared.written) {
|
|
3969
|
+
// A record this activation did not write serves the route now.
|
|
3970
|
+
outcome = 'foreign';
|
|
3971
|
+
return undefined;
|
|
3972
|
+
}
|
|
3973
|
+
// Return the captured record itself: rebuilding it from `kind` and
|
|
3974
|
+
// `payload` would drop the `key`/`env` an api-key projection carries and
|
|
3975
|
+
// leave the route unusable after a failed flush.
|
|
3976
|
+
outcome = 'baseline';
|
|
3977
|
+
return structuredClone(baseline.record);
|
|
3978
|
+
});
|
|
3979
|
+
}
|
|
3980
|
+
catch (error) {
|
|
3981
|
+
// An unreadable or refused seam proves nothing: the activation's
|
|
3982
|
+
// projection may still be live, so `ours` keeps its claim.
|
|
3983
|
+
this.warn(`activation projection restore failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3984
|
+
return 'ours';
|
|
3985
|
+
}
|
|
3986
|
+
return outcome;
|
|
3987
|
+
}
|
|
3988
|
+
/**
|
|
3989
|
+
* The delete authority's proof for an activation's own projection, as an
|
|
3990
|
+
* explicit three-valued answer (QC R2): only `matches` may authorize touching
|
|
3991
|
+
* the record. `unknown` is a store failure — a closed seam, an unreadable
|
|
3992
|
+
* record — and proves nothing, so it is never permission.
|
|
3993
|
+
*/
|
|
3994
|
+
async activationProjectionProof(records, provider, written, generation) {
|
|
3995
|
+
const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
|
|
3996
|
+
try {
|
|
3997
|
+
// Absent and "another writer's record" are different answers: absent
|
|
3998
|
+
// means nothing serves the route any more (our own demotion can remove
|
|
3999
|
+
// the projection without superseding the claim), while `differs` means a
|
|
4000
|
+
// replacement does. Folding them together marked a serviceless route
|
|
4001
|
+
// foreign, which makes `keyPoolServes` false and leaves no usable
|
|
4002
|
+
// credential at all.
|
|
4003
|
+
let state = 'absent';
|
|
4004
|
+
await records.modifyRecord(projectionKey, async (current) => {
|
|
4005
|
+
// The claim is decided under the same lock as the identity compare, for
|
|
4006
|
+
// the same reason as the restore: a newer owner must not have its
|
|
4007
|
+
// projection deleted by an undo that only checks the generation later.
|
|
4008
|
+
if (this.ownershipSuperseded(provider, generation)) {
|
|
4009
|
+
state = 'superseded';
|
|
4010
|
+
return undefined;
|
|
4011
|
+
}
|
|
4012
|
+
state = current === undefined
|
|
4013
|
+
? 'absent'
|
|
4014
|
+
: current.kind === 'grant' && JSON.stringify(current.payload) === written ? 'matches' : 'differs';
|
|
4015
|
+
return undefined;
|
|
4016
|
+
});
|
|
4017
|
+
return state;
|
|
4018
|
+
}
|
|
4019
|
+
catch (error) {
|
|
4020
|
+
this.warn(`activation projection proof failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4021
|
+
return 'unknown';
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
/**
|
|
4025
|
+
* Delete one projection an activation just proved it still owns, through the
|
|
4026
|
+
* same guarded shape the demotion path uses (MF2): the intentional-removal
|
|
4027
|
+
* marker is armed before the delete, so the deletion's own echo is read as
|
|
4028
|
+
* ours instead of as an out-of-band removal.
|
|
4029
|
+
*/
|
|
4030
|
+
async deleteActivationProjection(records, provider, written, generation) {
|
|
4031
|
+
const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
|
|
4032
|
+
// F2: the identity read and the delete cannot be atomic here — the seam has
|
|
4033
|
+
// no compare-and-delete, `deleteRecord(key)` takes no expected payload
|
|
4034
|
+
// (host-types.ts:142, tracked as I-000090). This read immediately before the
|
|
4035
|
+
// delete narrows the window to the seam call itself; a writer that replaces
|
|
4036
|
+
// the record inside it still loses its grant, and that residual needs the
|
|
4037
|
+
// seam CAS, not another read here. Anything but a proven match is left
|
|
4038
|
+
// alone.
|
|
4039
|
+
const proof = await this.activationProjectionProof(records, provider, written, generation);
|
|
4040
|
+
if (proof === 'superseded')
|
|
4041
|
+
return 'superseded';
|
|
4042
|
+
// `absent` needs no delete and leaves nothing serving (the pool does);
|
|
4043
|
+
// `differs` means another writer's record serves the route; `unknown`
|
|
4044
|
+
// proves nothing and must keep the activation's claim.
|
|
4045
|
+
if (proof === 'absent')
|
|
4046
|
+
return 'gone';
|
|
4047
|
+
if (proof !== 'matches')
|
|
4048
|
+
return proof === 'differs' ? 'foreign' : 'ours';
|
|
4049
|
+
const markedAt = Date.now();
|
|
4050
|
+
this.intentionalProjectionRemovals.set(provider, markedAt);
|
|
4051
|
+
try {
|
|
4052
|
+
await records.deleteRecord(projectionKey);
|
|
4053
|
+
}
|
|
4054
|
+
catch (error) {
|
|
4055
|
+
// A delete that threw removed nothing, so no own-removal echo is coming:
|
|
4056
|
+
// revoke this call's marker or it would mask a genuine one for the TTL.
|
|
4057
|
+
if (this.intentionalProjectionRemovals.get(provider) === markedAt) {
|
|
4058
|
+
this.intentionalProjectionRemovals.delete(provider);
|
|
4059
|
+
}
|
|
4060
|
+
this.warn(`cancelled activation projection cleanup failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4061
|
+
// R14-SF2 applies here too: a throwing delete may have removed the record
|
|
4062
|
+
// before throwing, so the outcome is re-proved rather than assumed —
|
|
4063
|
+
// guessing is what this file forbids. A second failure
|
|
4064
|
+
// proves nothing and keeps the claim (`ours`).
|
|
4065
|
+
const after = await this.activationProjectionProof(records, provider, written, generation);
|
|
4066
|
+
if (after === 'superseded')
|
|
4067
|
+
return 'superseded';
|
|
4068
|
+
if (after === 'absent')
|
|
4069
|
+
return 'gone';
|
|
4070
|
+
if (after === 'differs')
|
|
4071
|
+
return 'foreign';
|
|
4072
|
+
return 'ours';
|
|
4073
|
+
}
|
|
4074
|
+
return 'gone';
|
|
3290
4075
|
}
|
|
3291
4076
|
/**
|
|
3292
4077
|
* Remote: send one minimal request with the selected key or account row.
|
|
@@ -3883,11 +4668,68 @@ export class KeysManagerService {
|
|
|
3883
4668
|
test: true,
|
|
3884
4669
|
});
|
|
3885
4670
|
}
|
|
4671
|
+
/**
|
|
4672
|
+
* Whether this test may still borrow the route, or the result it must report
|
|
4673
|
+
* instead: the window is live, shutdown has not begun, and the row still
|
|
4674
|
+
* exists, is not retiring, and still names the `grantRef` the test captured.
|
|
4675
|
+
*
|
|
4676
|
+
* Read before the borrow and again after every await that precedes it. The
|
|
4677
|
+
* grant recovery is one: it outlives the isolation deadline on a slow seam,
|
|
4678
|
+
* and a window the watchdog already released no longer compensates its late
|
|
4679
|
+
* borrow (that branch is guarded by `abandonedWindows.get(provider) === window`).
|
|
4680
|
+
*/
|
|
4681
|
+
borrowBarrier(window, provider, row, grantRef) {
|
|
4682
|
+
if (window.cleaned) {
|
|
4683
|
+
// QC fix wave 2: the watchdog abandoned this window while an await was in
|
|
4684
|
+
// flight. Starting (or resuming) the borrow now would land it after the
|
|
4685
|
+
// cleanup that already ran, so the test stops here.
|
|
4686
|
+
return this.abandonedTestResult();
|
|
4687
|
+
}
|
|
4688
|
+
if (this.shutdownRequested) {
|
|
4689
|
+
// PR review round 30: shutdown abandons the chain parked here, so the
|
|
4690
|
+
// borrow write must not land after `close()`. Round 34: the marker
|
|
4691
|
+
// is up from shutdown's entry, so the pre-close restoration is covered
|
|
4692
|
+
// too — it does not borrow, and this test must not either.
|
|
4693
|
+
return this.shutdownEntryResult();
|
|
4694
|
+
}
|
|
4695
|
+
// PR review round 12: the reads and the recovery above are awaits, so a
|
|
4696
|
+
// removal can have deleted the provider or this row while they were in
|
|
4697
|
+
// flight (a test that registered and one that did not are covered: the
|
|
4698
|
+
// marker refuses the latter, this re-check the former). Never write the
|
|
4699
|
+
// borrow — and never re-project the removed account's grant — on that state.
|
|
4700
|
+
// PR review round 15: the existence check alone still holds while a removal
|
|
4701
|
+
// is deleting credentials (the row is spliced only after its grant delete),
|
|
4702
|
+
// so the row's retirement mark is part of the assertion. The provider-wide
|
|
4703
|
+
// marker is deliberately not read here: a removal that began while this
|
|
4704
|
+
// test is registered waits for it in {@link settleTestBeforeRemoval} and is
|
|
4705
|
+
// the designed handshake, while the row mark is only set by a removal that
|
|
4706
|
+
// already passed that check.
|
|
4707
|
+
const live = this.store.provider(provider);
|
|
4708
|
+
const removing = this.retiringAccounts.has(row);
|
|
4709
|
+
const liveRow = live?.keys.find(candidate => candidate.id === row.id);
|
|
4710
|
+
if (removing || liveRow === undefined) {
|
|
4711
|
+
return removing
|
|
4712
|
+
? { ok: false, latencyMs: 0, error: `Provider "${provider}" is being removed; the test did not borrow its credential` }
|
|
4713
|
+
: { ok: false, latencyMs: 0, error: `Key "${row.id}" was removed from ${provider} before its test could borrow it` };
|
|
4714
|
+
}
|
|
4715
|
+
// PR #37 review (I-000234): a re-sign-in repoints the row instead of
|
|
4716
|
+
// removing it. The recovery read then belongs to the generation the row no
|
|
4717
|
+
// longer names, so borrowing it would test the previous credential and
|
|
4718
|
+
// report the outcome against this row.
|
|
4719
|
+
if (liveRow.oauth === null || liveRow.oauth.grantRef !== grantRef) {
|
|
4720
|
+
return { ok: false, latencyMs: 0, error: `Key "${row.id}" was repointed on ${provider} before its test could borrow it` };
|
|
4721
|
+
}
|
|
4722
|
+
return undefined;
|
|
4723
|
+
}
|
|
3886
4724
|
/** One request with the row's grant temporarily projected as the route's credential. */
|
|
3887
4725
|
async runOAuthRowTest(provider, row, model, window) {
|
|
3888
4726
|
const grant = row.oauth;
|
|
3889
4727
|
if (grant === null)
|
|
3890
4728
|
return { ok: false, latencyMs: 0, error: `Key "${row.id}" is not an OAuth account` };
|
|
4729
|
+
// Captured here, not read from the row later: a re-sign-in repoints the
|
|
4730
|
+
// row's generation in place, so the live object would answer with the NEW
|
|
4731
|
+
// ref and the borrow would look current.
|
|
4732
|
+
const grantRef = grant.grantRef;
|
|
3891
4733
|
const records = this.credentialRecords();
|
|
3892
4734
|
if (records === undefined)
|
|
3893
4735
|
return { ok: false, latencyMs: 0, error: 'The credential store does not support records' };
|
|
@@ -3897,23 +4739,6 @@ export class KeysManagerService {
|
|
|
3897
4739
|
// grant chain, so a rotation echo cannot observe or overwrite the
|
|
3898
4740
|
// temporary projection between them.
|
|
3899
4741
|
await this.scheduleGrantReconciliation(provider, async () => {
|
|
3900
|
-
const stored = await records.readRecord(grant.grantRef);
|
|
3901
|
-
if (window.cleaned) {
|
|
3902
|
-
// QC fix wave 2: the watchdog abandoned this window while the read was
|
|
3903
|
-
// in flight. Starting (or resuming) the borrow now would land it after
|
|
3904
|
-
// the cleanup that already ran, so the test stops here.
|
|
3905
|
-
result = this.abandonedTestResult();
|
|
3906
|
-
return;
|
|
3907
|
-
}
|
|
3908
|
-
if (stored === undefined || stored.kind !== 'grant' || !isOAuthGrantPayload(stored.payload)) {
|
|
3909
|
-
result = {
|
|
3910
|
-
ok: false,
|
|
3911
|
-
latencyMs: 0,
|
|
3912
|
-
error: `The grant payload at ${grant.grantRef} is missing or unusable; sign in to this account again`,
|
|
3913
|
-
};
|
|
3914
|
-
return;
|
|
3915
|
-
}
|
|
3916
|
-
const borrowed = structuredClone(stored.payload);
|
|
3917
4742
|
const captured = await records.readRecord(projectionKey);
|
|
3918
4743
|
// PR review round 14: the snapshot and its comparison key are frozen
|
|
3919
4744
|
// here. The seam hands back a live record, so a writer mutating that
|
|
@@ -3921,36 +4746,30 @@ export class KeysManagerService {
|
|
|
3921
4746
|
// the updater's `current` and make the borrow look unchanged.
|
|
3922
4747
|
const previous = captured === undefined ? undefined : structuredClone(captured);
|
|
3923
4748
|
const previousKey = previous === undefined ? undefined : JSON.stringify(previous);
|
|
3924
|
-
|
|
3925
|
-
|
|
4749
|
+
const blocked = this.borrowBarrier(window, provider, row, grantRef);
|
|
4750
|
+
if (blocked !== undefined) {
|
|
4751
|
+
result = blocked;
|
|
3926
4752
|
return;
|
|
3927
4753
|
}
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
4754
|
+
// The recovery writes a grant record, so it runs only once this test is
|
|
4755
|
+
// known to borrow: an abandoned or retiring test must not restore one.
|
|
4756
|
+
const recovered = await this.recoverGrantPayload(provider, row, records);
|
|
4757
|
+
if (recovered === undefined) {
|
|
4758
|
+
result = {
|
|
4759
|
+
ok: false,
|
|
4760
|
+
latencyMs: 0,
|
|
4761
|
+
error: `The grant payload at ${grantRef} is missing or unusable; sign in to this account again`,
|
|
4762
|
+
};
|
|
3934
4763
|
return;
|
|
3935
4764
|
}
|
|
3936
|
-
|
|
3937
|
-
//
|
|
3938
|
-
//
|
|
3939
|
-
//
|
|
3940
|
-
//
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
// The provider-wide marker is deliberately not read here: a removal that
|
|
3945
|
-
// began while this test is registered waits for it in
|
|
3946
|
-
// {@link settleTestBeforeRemoval} and is the designed handshake, while
|
|
3947
|
-
// the row mark is only set by a removal that already passed that check.
|
|
3948
|
-
const live = this.store.provider(provider);
|
|
3949
|
-
const removing = this.retiringAccounts.has(row);
|
|
3950
|
-
if (removing || live === undefined || !live.keys.some(candidate => candidate.id === row.id)) {
|
|
3951
|
-
result = removing
|
|
3952
|
-
? { ok: false, latencyMs: 0, error: `Provider "${provider}" is being removed; the test did not borrow its credential` }
|
|
3953
|
-
: { ok: false, latencyMs: 0, error: `Key "${row.id}" was removed from ${provider} before its test could borrow it` };
|
|
4765
|
+
const borrowed = structuredClone(recovered.payload);
|
|
4766
|
+
// PR #37 review (must-fix): the recovery is an untimed seam round trip
|
|
4767
|
+
// and can outlive the isolation deadline, after which the window's
|
|
4768
|
+
// cleanup has already run and released the window. Registering facts or
|
|
4769
|
+
// borrowing now would land them with no owner: re-run the barrier first.
|
|
4770
|
+
const lateBlocked = this.borrowBarrier(window, provider, row, grantRef);
|
|
4771
|
+
if (lateBlocked !== undefined) {
|
|
4772
|
+
result = lateBlocked;
|
|
3954
4773
|
return;
|
|
3955
4774
|
}
|
|
3956
4775
|
// QC fix wave 2: record the borrow before the write, so an abandoned
|
|
@@ -4597,9 +5416,11 @@ export class KeysManagerService {
|
|
|
4597
5416
|
...(typeof payload.expires === 'number' && Number.isFinite(payload.expires) ? { expiresAt: payload.expires } : {}),
|
|
4598
5417
|
};
|
|
4599
5418
|
if (previousRef !== rotatedRef) {
|
|
4600
|
-
//
|
|
4601
|
-
//
|
|
4602
|
-
//
|
|
5419
|
+
// Defence in depth, not a reachable window: no await separates the guard
|
|
5420
|
+
// above from the delete below, so neither a shutdown nor a takeover can
|
|
5421
|
+
// land in between. The re-check is kept because it is the condition the
|
|
5422
|
+
// delete depends on and a future await inserted here would otherwise
|
|
5423
|
+
// silently remove a record this attempt never owned.
|
|
4603
5424
|
if (this.persistenceRefused(capability) || (guard !== undefined && !guard())) {
|
|
4604
5425
|
this.store.touch();
|
|
4605
5426
|
return;
|
|
@@ -4608,6 +5429,11 @@ export class KeysManagerService {
|
|
|
4608
5429
|
try {
|
|
4609
5430
|
if (records === undefined)
|
|
4610
5431
|
throw new Error('The credential store does not support records');
|
|
5432
|
+
// Declared residual: this delete is an await with no expected-payload
|
|
5433
|
+
// CAS (`deleteRecord(key)` takes only the key), so a claim landing
|
|
5434
|
+
// inside it can still remove the same-ref record an activation just
|
|
5435
|
+
// wrote. Post-delete compensation is out of scope here; closing it
|
|
5436
|
+
// needs a seam-level compare-and-delete.
|
|
4611
5437
|
await records.deleteRecord(previousRef);
|
|
4612
5438
|
}
|
|
4613
5439
|
catch (error) {
|
|
@@ -4641,11 +5467,13 @@ export class KeysManagerService {
|
|
|
4641
5467
|
return { ok: false, error: failure?.message ?? failure?.code ?? 'The provider reported a failure' };
|
|
4642
5468
|
}
|
|
4643
5469
|
// PR review SF3 + round 8: only a real completion is a pass. The ping
|
|
4644
|
-
// asks for one token, so `
|
|
5470
|
+
// asks for one token, so `max-tokens` is the expected completion; a
|
|
4645
5471
|
// finish chunk with NO reason, `aborted`, or any kind this broker
|
|
4646
5472
|
// does not know means the provider did not answer, so reporting a
|
|
4647
|
-
// pass would claim a credential it never served.
|
|
4648
|
-
|
|
5473
|
+
// pass would claim a credential it never served. The accepted kinds
|
|
5474
|
+
// are the installed `@deepseek-ai/dsh-llm` union (`FinishReasonMap`),
|
|
5475
|
+
// shared with the tests so the two cannot drift again.
|
|
5476
|
+
if (kind !== undefined && TEST_SUCCESS_FINISH_KINDS.includes(kind)) {
|
|
4649
5477
|
// PR review round 42: a successful ping only proves the route
|
|
4650
5478
|
// answered. An api-key row test must also prove the pinned literal
|
|
4651
5479
|
// was the credential that served it — a takeover during the stream
|
|
@@ -5165,6 +5993,15 @@ export class KeysManagerService {
|
|
|
5165
5993
|
this.store.pruneBindings(now);
|
|
5166
5994
|
this.pruneAttempts(now);
|
|
5167
5995
|
}
|
|
5996
|
+
/**
|
|
5997
|
+
* Project one provider's unattributed-request count into its row. Additive
|
|
5998
|
+
* and optional (`I-000120`): a row with no count spreads nothing, so an
|
|
5999
|
+
* older snapshot and a healthy store render exactly as before.
|
|
6000
|
+
*/
|
|
6001
|
+
unattributedView(provider) {
|
|
6002
|
+
const count = this.unattributedRequests.get(provider) ?? 0;
|
|
6003
|
+
return count > 0 ? { unattributedRequests: count } : {};
|
|
6004
|
+
}
|
|
5168
6005
|
/** Build the complete UI state. */
|
|
5169
6006
|
buildState() {
|
|
5170
6007
|
const now = Date.now();
|
|
@@ -5181,6 +6018,11 @@ export class KeysManagerService {
|
|
|
5181
6018
|
const profile = namespace === undefined ? undefined : asProfile(getPath(namespace.value, entry.settingsPath));
|
|
5182
6019
|
const managed = this.store.provider(entry.provider);
|
|
5183
6020
|
const extra = EXTRA_PROVIDER_MAP.get(entry.provider);
|
|
6021
|
+
// A transport route names its own credential record and registers no
|
|
6022
|
+
// settings namespace, so the profile/capability read below can only
|
|
6023
|
+
// describe the route wrongly: it is neither a DSH route nor key-pool
|
|
6024
|
+
// capable, and it is never adoptable.
|
|
6025
|
+
const transport = entry.settingsNs === TRANSPORT_CREDENTIAL_SCOPE;
|
|
5184
6026
|
const oauthFlow = this.oauthFlowAvailable(entry.provider);
|
|
5185
6027
|
const caps = capabilityOf(entry, profile, oauthFlow);
|
|
5186
6028
|
const support = { supported: caps.keys, ...(caps.reason === undefined ? {} : { reason: caps.reason }) };
|
|
@@ -5198,14 +6040,17 @@ export class KeysManagerService {
|
|
|
5198
6040
|
provider: entry.provider,
|
|
5199
6041
|
displayName: extra?.name ?? entry.displayName ?? entry.provider,
|
|
5200
6042
|
source: extra !== undefined ? 'extra' : 'dsh',
|
|
5201
|
-
origin: extra !== undefined ? 'plugin' : entry.declared === true ? 'custom' : 'dsh',
|
|
6043
|
+
origin: extra !== undefined || transport ? 'plugin' : entry.declared === true ? 'custom' : 'dsh',
|
|
5202
6044
|
managed: managed !== undefined,
|
|
5203
6045
|
createdProfile: managed?.createdProfile ?? false,
|
|
5204
6046
|
configured: active.has(entry.provider) || profile !== undefined,
|
|
5205
6047
|
active: active.has(entry.provider),
|
|
5206
|
-
supported: support.supported,
|
|
5207
|
-
...(
|
|
5208
|
-
|
|
6048
|
+
supported: transport ? false : support.supported,
|
|
6049
|
+
...(transport
|
|
6050
|
+
? { reason: 'This route is served by a transport plugin; manage its account from this provider card.' }
|
|
6051
|
+
: support.reason === undefined ? {} : { reason: support.reason }),
|
|
6052
|
+
accountSupported: transport ? false : accountSupport.supported,
|
|
6053
|
+
...(transport ? { transport: true } : {}),
|
|
5209
6054
|
settingsNs: entry.settingsNs,
|
|
5210
6055
|
settingsPath: [...entry.settingsPath],
|
|
5211
6056
|
apiKeyEnv,
|
|
@@ -5224,6 +6069,7 @@ export class KeysManagerService {
|
|
|
5224
6069
|
status: managed !== undefined ? statusOfProvider(managed.keys, now) : 'unmanaged',
|
|
5225
6070
|
keys,
|
|
5226
6071
|
...(warning === undefined ? {} : { warning }),
|
|
6072
|
+
...this.unattributedView(entry.provider),
|
|
5227
6073
|
});
|
|
5228
6074
|
seen.add(entry.provider);
|
|
5229
6075
|
}
|
|
@@ -5264,6 +6110,7 @@ export class KeysManagerService {
|
|
|
5264
6110
|
status: managed !== undefined ? statusOfProvider(managed.keys, now) : 'unmanaged',
|
|
5265
6111
|
keys,
|
|
5266
6112
|
...(managed === undefined ? {} : { warning: 'This extra provider is managed but its pi-ai route is not registered. Check the provider profile in Models.' }),
|
|
6113
|
+
...this.unattributedView(extra.id),
|
|
5267
6114
|
});
|
|
5268
6115
|
seen.add(extra.id);
|
|
5269
6116
|
}
|
|
@@ -5302,6 +6149,7 @@ export class KeysManagerService {
|
|
|
5302
6149
|
status: statusOfProvider(managed.keys, now),
|
|
5303
6150
|
keys,
|
|
5304
6151
|
warning: 'This provider route is not currently registered. Check its profile in Models.',
|
|
6152
|
+
...this.unattributedView(managed.provider),
|
|
5305
6153
|
});
|
|
5306
6154
|
seen.add(managed.provider);
|
|
5307
6155
|
}
|
|
@@ -5325,6 +6173,8 @@ export class KeysManagerService {
|
|
|
5325
6173
|
writable: descriptor.writable && !this.store.readOnly,
|
|
5326
6174
|
settings: { ...normalizeSettings(this.store.settings) },
|
|
5327
6175
|
providers: views,
|
|
6176
|
+
// Managed only: a transport route renders as a card but stays out of
|
|
6177
|
+
// this published count, the key counts and every key-pool write path.
|
|
5328
6178
|
managedCount: views.filter(view => view.managed).length,
|
|
5329
6179
|
keyCount: allKeys.length,
|
|
5330
6180
|
healthyKeyCount: allKeys.filter(key => key.status === 'active').length,
|
|
@@ -5494,14 +6344,38 @@ export class KeysManagerService {
|
|
|
5494
6344
|
// PR review round 35: the last point before the settings write. Only
|
|
5495
6345
|
// `closing` is read: shutdown's own pre-close restoration writes here by
|
|
5496
6346
|
// design and runs before that flag goes up.
|
|
5497
|
-
|
|
6347
|
+
//
|
|
6348
|
+
// Reaching this point means the route genuinely needs a write: `current ===
|
|
6349
|
+
// desired` already returned true above, and the preserved / releasing /
|
|
6350
|
+
// foreign-value branches declined for their own reasons. So shutdown is
|
|
6351
|
+
// what stops it, and this is the one place that can say so — the callers
|
|
6352
|
+
// cannot infer it (`pushWarning` caps its list, and a silent `false` has
|
|
6353
|
+
// several non-refusal causes upstream).
|
|
6354
|
+
if (this.closing) {
|
|
6355
|
+
this.bindingWriteRefused(provider, 'Keys Manager is shutting down');
|
|
6356
|
+
return false;
|
|
6357
|
+
}
|
|
6358
|
+
// A caller that stamped the ownership generation it entered with forbids
|
|
6359
|
+
// this write once a newer claim landed: the binding it would commit was
|
|
6360
|
+
// derived from the classification that claim replaced.
|
|
6361
|
+
if (options.ownershipGeneration !== undefined
|
|
6362
|
+
&& this.ownershipSuperseded(provider, options.ownershipGeneration))
|
|
5498
6363
|
return false;
|
|
5499
6364
|
try {
|
|
5500
|
-
// The entry gate above read a snapshot
|
|
5501
|
-
// this write re-reads its descriptor (PR review round 25). Binding
|
|
5502
|
-
// guess then, so the gate closes again at the last point before the
|
|
6365
|
+
// The entry gate above read a snapshot, and ownership can turn unknown
|
|
6366
|
+
// while this write re-reads its descriptor (PR review round 25). Binding
|
|
6367
|
+
// is a guess then, so the gate closes again at the last point before the
|
|
5503
6368
|
// mutation. Unbinding stays allowed.
|
|
5504
|
-
|
|
6369
|
+
// That last point is `mutateSettings`' `stillWrites` predicate: it is
|
|
6370
|
+
// evaluated synchronously between the descriptor re-read and
|
|
6371
|
+
// `settings.mutate` (no await between them), and the mutate itself passes
|
|
6372
|
+
// the descriptor's revision as an expected-revision CAS, so a takeover
|
|
6373
|
+
// that changed the namespace in between makes the write fail instead of
|
|
6374
|
+
// landing stale state. A claim the predicate sees must not leave this
|
|
6375
|
+
// binding behind — it describes the classification that claim replaced.
|
|
6376
|
+
const stamp = options.ownershipGeneration;
|
|
6377
|
+
const written = await this.mutateSettings(record.settingsNs, [desired === undefined ? { op: 'unset', path } : { op: 'set', path, value: desired }], () => (desired === undefined || this.projectionOwnershipKnown(provider))
|
|
6378
|
+
&& (stamp === undefined || !this.ownershipSuperseded(provider, stamp)));
|
|
5505
6379
|
if (!written)
|
|
5506
6380
|
return false;
|
|
5507
6381
|
}
|
|
@@ -5575,8 +6449,15 @@ export class KeysManagerService {
|
|
|
5575
6449
|
const takeover = record?.takeoverPending === true
|
|
5576
6450
|
&& !this.foreignProjections.has(provider)
|
|
5577
6451
|
&& this.oauthOwnerFor(provider) === null;
|
|
5578
|
-
|
|
5579
|
-
|
|
6452
|
+
// The awaited sync can be superseded (an activation claims the route while
|
|
6453
|
+
// it runs). It would then commit a binding derived from the pre-claim
|
|
6454
|
+
// classification, and settling the debt would record a takeover reconciled
|
|
6455
|
+
// for a generation that no longer owns the route. The generation is
|
|
6456
|
+
// captured here, passed to the sync's own last-mile gate, and re-read
|
|
6457
|
+
// immediately before the clear.
|
|
6458
|
+
const generation = this.ownershipGenerations.get(provider) ?? 0;
|
|
6459
|
+
const bound = await this.syncBrokerBinding(provider, { write: false, takeover, ownershipGeneration: generation });
|
|
6460
|
+
if (takeover && bound && record !== undefined && !this.ownershipSuperseded(provider, generation)) {
|
|
5580
6461
|
record.takeoverPending = false;
|
|
5581
6462
|
this.store.touch();
|
|
5582
6463
|
}
|
|
@@ -5599,6 +6480,14 @@ export class KeysManagerService {
|
|
|
5599
6480
|
// is persistence, so shutdown refuses it.
|
|
5600
6481
|
if (this.closing)
|
|
5601
6482
|
return;
|
|
6483
|
+
// The own-generation stamp: this sync must not commit a binding derived
|
|
6484
|
+
// from a classification a newer claim replaced. It travels into
|
|
6485
|
+
// `syncBrokerBinding`, whose `stillWrites` predicate is the last point
|
|
6486
|
+
// before the settings write and runs synchronously right before a
|
|
6487
|
+
// revision-CAS'd mutate; the only window left is that write call itself,
|
|
6488
|
+
// which fails on a revision mismatch rather than committing stale state.
|
|
6489
|
+
// Every caller is covered here rather than at the call sites.
|
|
6490
|
+
const generation = this.ownershipGenerations.get(provider) ?? 0;
|
|
5602
6491
|
try {
|
|
5603
6492
|
// PR review round 40: adoption keeps the pre-adoption reference while a
|
|
5604
6493
|
// foreign projection serves the route and records that it still owes the
|
|
@@ -5607,8 +6496,8 @@ export class KeysManagerService {
|
|
|
5607
6496
|
// once the bind lands; `originalApiKeyEnv` alone proves nothing.
|
|
5608
6497
|
const record = this.store.provider(provider);
|
|
5609
6498
|
const takeover = record?.takeoverPending === true;
|
|
5610
|
-
const bound = await this.syncBrokerBinding(provider, { takeover });
|
|
5611
|
-
if (takeover && bound && record !== undefined) {
|
|
6499
|
+
const bound = await this.syncBrokerBinding(provider, { takeover, ownershipGeneration: generation });
|
|
6500
|
+
if (takeover && bound && record !== undefined && !this.ownershipSuperseded(provider, generation)) {
|
|
5612
6501
|
record.takeoverPending = false;
|
|
5613
6502
|
this.store.touch();
|
|
5614
6503
|
}
|
|
@@ -5690,6 +6579,12 @@ export class KeysManagerService {
|
|
|
5690
6579
|
throw new Error('This extra provider needs a key; add it from the extra-provider section');
|
|
5691
6580
|
throw new Error(`Provider "${provider}" is not registered by any adapter`);
|
|
5692
6581
|
}
|
|
6582
|
+
// A transport route's credential is the record its own adapter reads, so
|
|
6583
|
+
// adopting it would bind a profile nothing serves (and its namespace is
|
|
6584
|
+
// not even mounted). Refuse before any settings read or write.
|
|
6585
|
+
if (entry.settingsNs === TRANSPORT_CREDENTIAL_SCOPE) {
|
|
6586
|
+
throw new Error(`Provider "${provider}" is served by a transport plugin; manage its account from its provider card`);
|
|
6587
|
+
}
|
|
5693
6588
|
const descriptor = this.describeSettings();
|
|
5694
6589
|
const namespace = this.namespace(descriptor, entry.settingsNs);
|
|
5695
6590
|
if (namespace === undefined)
|
|
@@ -5848,6 +6743,7 @@ export class KeysManagerService {
|
|
|
5848
6743
|
return 'superseded';
|
|
5849
6744
|
}
|
|
5850
6745
|
this.ownershipReadFailures.delete(provider);
|
|
6746
|
+
this.unattributedRequests.delete(provider);
|
|
5851
6747
|
this.noteProviderHydrated(provider);
|
|
5852
6748
|
if (record === undefined)
|
|
5853
6749
|
this.foreignProjections.delete(provider);
|
|
@@ -6810,6 +7706,12 @@ export class KeysManagerService {
|
|
|
6810
7706
|
// branches, so it is constructed after the isolation wait — a mismatch
|
|
6811
7707
|
// profile still resolves pi-ai's stored grant, so the borrowed
|
|
6812
7708
|
// projection reaches it too.
|
|
7709
|
+
//
|
|
7710
|
+
// No ownership read and no unattributed count here: the store already
|
|
7711
|
+
// said this route's profile disagrees with its binding, so this manager
|
|
7712
|
+
// cannot describe what serves it and hands the request over untouched.
|
|
7713
|
+
// The counter is defined over routes whose ownership it did try to read
|
|
7714
|
+
// (see `unattributedRequests`).
|
|
6813
7715
|
return this.isolateFromTest(providerId, () => next());
|
|
6814
7716
|
}
|
|
6815
7717
|
// An ordinary request must never be served by the grant a row test has
|
|
@@ -7046,6 +7948,17 @@ export class KeysManagerService {
|
|
|
7046
7948
|
});
|
|
7047
7949
|
}
|
|
7048
7950
|
iterator = await service.als.run(store, () => next()[Symbol.asyncIterator]());
|
|
7951
|
+
// I-000120 / D8 + I-000201: this request is served without an
|
|
7952
|
+
// attribution, and this is the only site that counts one — but
|
|
7953
|
+
// only once downstream actually has an iterator. Both causes (the
|
|
7954
|
+
// bounded wait expired, the read had failed) still arrive here as
|
|
7955
|
+
// the same `false`, and the hydration pass never counts. A routing
|
|
7956
|
+
// or adapter failure that throws before dispatch served nothing, so
|
|
7957
|
+
// it must not inflate the count; `started` keeps one attempt to one
|
|
7958
|
+
// increment, and a later attempt that does dispatch counts then.
|
|
7959
|
+
if (!hydrated) {
|
|
7960
|
+
service.unattributedRequests.set(providerId, (service.unattributedRequests.get(providerId) ?? 0) + 1);
|
|
7961
|
+
}
|
|
7049
7962
|
}
|
|
7050
7963
|
if (iterator === undefined)
|
|
7051
7964
|
throw new Error('managed stream has no iterator');
|