@modelprofile.com/authswitch 8.1.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/authority-contract.d.ts +73 -0
- package/dist_ts/authority-contract.js +55 -2
- package/dist_ts/authority-import-contract.d.ts +8 -4
- package/dist_ts/authority-import-contract.js +12 -13
- package/dist_ts/authority-paths.d.ts +37 -0
- package/dist_ts/authority-paths.js +46 -0
- package/dist_ts/authority-runtime-contract.d.ts +11 -1
- package/dist_ts/classes.authoritybroker.d.ts +9 -0
- package/dist_ts/classes.authoritybroker.js +72 -11
- package/dist_ts/classes.authorityclient.d.ts +17 -11
- package/dist_ts/classes.authorityclient.js +47 -22
- package/dist_ts/classes.authoritydaemon.d.ts +22 -3
- package/dist_ts/classes.authoritydaemon.js +106 -59
- package/dist_ts/classes.authoritydatabase.d.ts +9 -1
- package/dist_ts/classes.authoritydatabase.js +39 -13
- package/dist_ts/classes.authorityimport.d.ts +33 -6
- package/dist_ts/classes.authorityimport.js +102 -25
- package/dist_ts/classes.authoritymodels.d.ts +4 -1
- package/dist_ts/classes.authoritymodels.js +15 -3
- package/dist_ts/classes.authoritypreuse.js +8 -3
- package/dist_ts/classes.authorityservice.d.ts +20 -11
- package/dist_ts/classes.authorityservice.js +37 -23
- package/dist_ts/classes.claudeauthority.js +19 -11
- package/dist_ts/classes.claudenative.d.ts +63 -3
- package/dist_ts/classes.claudenative.js +68 -8
- package/dist_ts/classes.cli.d.ts +10 -2
- package/dist_ts/classes.cli.js +12 -4
- package/dist_ts/classes.codexmanaged.d.ts +8 -0
- package/dist_ts/classes.codexmanaged.js +19 -12
- package/dist_ts/classes.legacyfence.d.ts +53 -0
- package/dist_ts/classes.legacyfence.js +189 -0
- package/dist_ts/classes.operations.d.ts +15 -3
- package/dist_ts/classes.operations.js +22 -4
- package/dist_ts/classes.service.d.ts +21 -2
- package/dist_ts/classes.service.js +35 -8
- package/dist_ts/classes.tui.d.ts +2 -1
- package/dist_ts/classes.tui.js +3 -2
- package/dist_ts/ts_migration/0003_claude_handoff_proof.d.ts +13 -0
- package/dist_ts/ts_migration/0003_claude_handoff_proof.js +20 -0
- package/dist_ts/ts_migration/index.js +3 -1
- package/dist_ts/ts_migration/legacysources/authswitchstores.js +5 -2
- package/dist_ts/ts_migration/legacysources/nativestores.js +15 -8
- package/dist_ts/ts_migration/legacysources/shared.d.ts +3 -2
- package/dist_ts/ts_migration/legacysources/shared.js +3 -5
- package/package.json +11 -7
- package/readme.md +188 -13
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +125 -0
- package/ts/authority-import-contract.ts +12 -11
- package/ts/authority-paths.ts +69 -0
- package/ts/authority-runtime-contract.ts +11 -1
- package/ts/classes.authoritybroker.ts +70 -11
- package/ts/classes.authorityclient.ts +59 -21
- package/ts/classes.authoritydaemon.ts +116 -60
- package/ts/classes.authoritydatabase.ts +39 -12
- package/ts/classes.authorityimport.ts +131 -26
- package/ts/classes.authoritymodels.ts +11 -2
- package/ts/classes.authoritypreuse.ts +7 -1
- package/ts/classes.authorityservice.ts +42 -30
- package/ts/classes.claudeauthority.ts +22 -10
- package/ts/classes.claudenative.ts +104 -10
- package/ts/classes.cli.ts +14 -3
- package/ts/classes.codexmanaged.ts +18 -7
- package/ts/classes.legacyfence.ts +219 -0
- package/ts/classes.operations.ts +22 -3
- package/ts/classes.service.ts +45 -8
- package/ts/classes.tui.ts +3 -1
- package/ts/ts_migration/0003_claude_handoff_proof.ts +19 -0
- package/ts/ts_migration/index.ts +2 -0
- package/ts/ts_migration/legacysources/authswitchstores.ts +4 -1
- package/ts/ts_migration/legacysources/nativestores.ts +15 -7
- package/ts/ts_migration/legacysources/shared.ts +4 -6
|
@@ -10,10 +10,10 @@ import type {
|
|
|
10
10
|
} from './authority-contract.js';
|
|
11
11
|
import type { IReq_AuthSwitchBindAccount, IReq_AuthSwitchReleaseBinding,
|
|
12
12
|
IReq_AuthSwitchResolveAccess } from './authority-runtime-contract.js';
|
|
13
|
-
import {
|
|
13
|
+
import { AuthSwitchRefusal } from './authority-contract.js';
|
|
14
14
|
import type { IReq_AuthSwitchImportInventory, IReq_AuthSwitchImportStatus, IReq_AuthSwitchImportSubmit,
|
|
15
15
|
TAuthSwitchImportSubmission } from './authority-import-contract.js';
|
|
16
|
-
import { AuthSwitchAuthorityImport,
|
|
16
|
+
import { AuthSwitchAuthorityImport,
|
|
17
17
|
type IAuthSwitchAuthorityImportOptions } from './classes.authorityimport.js';
|
|
18
18
|
import { authSwitchBindingCapabilityHash, AuthSwitchAuthorityBroker,
|
|
19
19
|
type IAuthSwitchAuthorityBrokerOptions } from './classes.authoritybroker.js';
|
|
@@ -37,13 +37,46 @@ export interface IAuthSwitchAuthorityDaemonOptions extends IAuthSwitchAuthorityD
|
|
|
37
37
|
claudeStatus?: ClaudeAccountStatus;
|
|
38
38
|
/** Activated only after a verified backend source receipt has registered the native home. */
|
|
39
39
|
claudeNative?: IClaudeNativeAuthorityOptions;
|
|
40
|
-
/** Where the one-time import reads the legacy stores
|
|
41
|
-
legacyImport
|
|
40
|
+
/** Where the one-time import reads the legacy stores, or `'none'`. Never derived from this host here. */
|
|
41
|
+
legacyImport: TAuthSwitchAuthorityLegacyImport;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where a daemon's one-time import reads, stated by whoever builds it.
|
|
46
|
+
*
|
|
47
|
+
* The legacy stores are this user's real credentials, so which host they are read from is never something a
|
|
48
|
+
* daemon decides for its caller: it is named, or the daemon is built with no import surface at all.
|
|
49
|
+
* `AuthSwitchAuthorityService`'s entry point is the one place that may name this host's own locations,
|
|
50
|
+
* because that is the daemon that exists to serve this host. The importer requires those two locations of
|
|
51
|
+
* every caller, so naming its options here is already the whole statement.
|
|
52
|
+
*/
|
|
53
|
+
export type TAuthSwitchAuthorityLegacyImport = IAuthSwitchAuthorityImportOptions | 'none';
|
|
54
|
+
|
|
55
|
+
/** What a caller that named neither is told. It names the two answers and who may take the third. */
|
|
56
|
+
const legacyImportRequired = 'Authswitch authority requires explicit legacy source locations: pass '
|
|
57
|
+
+ 'legacyImport with an env and an absolute homeDirectory, or legacyImport: "none" for a daemon with no '
|
|
58
|
+
+ 'account import. Only the authswitch service entry point may take them from this host.';
|
|
59
|
+
|
|
60
|
+
/** What the import routes answer on a daemon that was built with no import surface. */
|
|
61
|
+
const legacyImportAbsent = 'This authority was started without legacy source locations, so it serves no '
|
|
62
|
+
+ 'account import.';
|
|
63
|
+
|
|
64
|
+
/** The same check for a caller with types and one without: stated locations, or stated absence. */
|
|
65
|
+
function assertStatedLegacyImport(value: unknown): asserts value is TAuthSwitchAuthorityLegacyImport {
|
|
66
|
+
if (value === 'none') return;
|
|
67
|
+
if (value === null || typeof value !== 'object'
|
|
68
|
+
|| typeof (value as { env?: unknown }).env !== 'object' || (value as { env?: unknown }).env === null
|
|
69
|
+
|| typeof (value as { homeDirectory?: unknown }).homeDirectory !== 'string'
|
|
70
|
+
|| !plugins.path.isAbsolute((value as { homeDirectory: string }).homeDirectory)) {
|
|
71
|
+
throw new Error(legacyImportRequired);
|
|
72
|
+
}
|
|
42
73
|
}
|
|
43
74
|
|
|
44
75
|
const hasKeys = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
|
|
45
76
|
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
46
77
|
&& Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
78
|
+
// A malformed request is a caller's own bug, not an instruction for an owner, so it stays what it was:
|
|
79
|
+
// its own text, no refusal marker. A client must not present it as something the owner can act on.
|
|
47
80
|
const invalid = (): never => { throw new plugins.typedrequest.TypedResponseError('Invalid authswitch authority request.'); };
|
|
48
81
|
const validBindingId = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
49
82
|
const validBindingCapability = (value: unknown): value is string => typeof value === 'string'
|
|
@@ -81,7 +114,8 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
81
114
|
public readonly broker: AuthSwitchAuthorityBroker;
|
|
82
115
|
public readonly usage: AuthSwitchAuthorityUsage;
|
|
83
116
|
public readonly preuse: AuthSwitchAuthorityPreuse;
|
|
84
|
-
|
|
117
|
+
/** Absent on a daemon built with `legacyImport: 'none'`; its import routes then refuse. */
|
|
118
|
+
public readonly legacyImport?: AuthSwitchAuthorityImport;
|
|
85
119
|
public readonly claudeNative?: ClaudeNativeAuthority;
|
|
86
120
|
private readonly managementRouter = new plugins.typedrequest.TypedRouter();
|
|
87
121
|
private readonly runtimeRouter = new plugins.typedrequest.TypedRouter();
|
|
@@ -104,6 +138,10 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
104
138
|
private closing = false;
|
|
105
139
|
|
|
106
140
|
constructor(private readonly options: IAuthSwitchAuthorityDaemonOptions) {
|
|
141
|
+
// First, before anything is constructed: a daemon that was going to read the wrong host's credentials
|
|
142
|
+
// must not come into existence at all. The type states it and this states it again, because a caller
|
|
143
|
+
// without types is exactly the caller that would have defaulted here.
|
|
144
|
+
assertStatedLegacyImport(options.legacyImport);
|
|
107
145
|
this.database = new AuthSwitchAuthorityDatabase(options);
|
|
108
146
|
const provider = options.broker?.provider ?? new plugins.flexAccounts.OpenAiProviderAdapter();
|
|
109
147
|
this.broker = new AuthSwitchAuthorityBroker(this.database, { ...options.broker, provider });
|
|
@@ -132,7 +170,9 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
132
170
|
provider,
|
|
133
171
|
});
|
|
134
172
|
this.preuse = new AuthSwitchAuthorityPreuse(this.database, this.broker, options.preuse);
|
|
135
|
-
|
|
173
|
+
if (options.legacyImport !== 'none') {
|
|
174
|
+
this.legacyImport = new AuthSwitchAuthorityImport(this.database, options.legacyImport);
|
|
175
|
+
}
|
|
136
176
|
if (options.claudeNative) {
|
|
137
177
|
this.claudeNative = new ClaudeNativeAuthority(this.database, {
|
|
138
178
|
...options.claudeNative, onChanged: () => this.broker.notifyChanged(),
|
|
@@ -202,7 +242,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
202
242
|
const prepare = async (): Promise<void> => {
|
|
203
243
|
if (this.closing || this.drainingAccounts.has(request.accountId)
|
|
204
244
|
|| this.preuseAccounts.has(request.accountId)) {
|
|
205
|
-
throw new
|
|
245
|
+
throw new AuthSwitchRefusal('account_busy', 'Account runtime drain is already in progress.');
|
|
206
246
|
}
|
|
207
247
|
this.drainingAccounts.add(request.accountId);
|
|
208
248
|
this.deviceDrainOwners.set(request.accountId, request.operationId);
|
|
@@ -229,46 +269,67 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
229
269
|
});
|
|
230
270
|
}
|
|
231
271
|
|
|
272
|
+
/**
|
|
273
|
+
* One handler, with the only place a refusal becomes a marked answer.
|
|
274
|
+
*
|
|
275
|
+
* Both routers register through here, so a handler states its refusal by throwing `AuthSwitchRefusal`
|
|
276
|
+
* and never touches the wire shape; anything else it throws stays the transport's sanitised fault,
|
|
277
|
+
* which is what keeps "the outcome is unknown" distinguishable from "here is what to do instead".
|
|
278
|
+
*/
|
|
279
|
+
private handler<T extends ITypedRequest>(method: T['method'],
|
|
280
|
+
run: plugins.typedrequest.THandlerFunction<T>): plugins.typedrequest.TypedHandler<T> {
|
|
281
|
+
return new plugins.typedrequest.TypedHandler<T>(method, async (request, tools) => {
|
|
282
|
+
try { return await run(request, tools); }
|
|
283
|
+
catch (error) {
|
|
284
|
+
if (error instanceof AuthSwitchRefusal) {
|
|
285
|
+
throw new plugins.typedrequest.TypedResponseError(error.message, error.data);
|
|
286
|
+
}
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
232
292
|
private register(): void {
|
|
233
|
-
this.managementRouter.addTypedHandler(
|
|
293
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchUsage>(
|
|
234
294
|
'authswitch.authority.usage', async request => {
|
|
235
295
|
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
236
296
|
|| !Object.hasOwn(request, 'accountId') || !Object.hasOwn(request, 'loginId')
|
|
237
297
|
|| Object.keys(request).some(key => !['accountId', 'loginId', 'force'].includes(key))
|
|
238
298
|
|| typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
|
|
239
299
|
|| (request.force !== undefined && typeof request.force !== 'boolean')) invalid();
|
|
240
|
-
if (this.closing) throw new
|
|
300
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
241
301
|
return { usage: await this.usage.get(request.accountId, request.loginId, request.force ?? false) };
|
|
242
302
|
}));
|
|
243
|
-
this.managementRouter.addTypedHandler(
|
|
303
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchSwitchClaudeNative>(
|
|
244
304
|
'authswitch.authority.claude.switch', async request => {
|
|
245
305
|
if (!hasKeys(request, ['accountId', 'loginId', 'purpose'])
|
|
246
306
|
|| typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
|
|
247
307
|
|| request.purpose !== 'claude_host_native') invalid();
|
|
248
|
-
if (this.closing
|
|
308
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
309
|
+
if (!this.claudeNative) throw new AuthSwitchRefusal('claude_home_unregistered', 'Claude native handoff is unavailable.');
|
|
249
310
|
return { handoff: await this.claudeNative.switchTo(request.accountId, request.loginId) };
|
|
250
311
|
}));
|
|
251
|
-
this.managementRouter.addTypedHandler(
|
|
312
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchClaudeNativeHandoff>(
|
|
252
313
|
'authswitch.authority.claude.handoff', async request => {
|
|
253
314
|
if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
|
|
254
|
-
if (!this.claudeNative) throw new
|
|
315
|
+
if (!this.claudeNative) throw new AuthSwitchRefusal('claude_home_unregistered', 'Claude native handoff is unavailable.');
|
|
255
316
|
return { handoff: await this.claudeNative.getHandoff(request.operationId) };
|
|
256
317
|
}));
|
|
257
|
-
this.managementRouter.addTypedHandler(
|
|
318
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchClaudeNativeHandoffs>(
|
|
258
319
|
'authswitch.authority.claude.handoffs', async request => {
|
|
259
320
|
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
260
321
|
|| Object.keys(request).some(key => !['after', 'limit'].includes(key))) invalid();
|
|
261
|
-
if (!this.claudeNative) throw new
|
|
322
|
+
if (!this.claudeNative) throw new AuthSwitchRefusal('claude_home_unregistered', 'Claude native handoff is unavailable.');
|
|
262
323
|
return this.claudeNative.listHandoffs(request.after ?? null, request.limit ?? 128);
|
|
263
324
|
}));
|
|
264
|
-
this.managementRouter.addTypedHandler(
|
|
325
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchSnapshot>(
|
|
265
326
|
'authswitch.authority.snapshot', async request => {
|
|
266
327
|
if (request === null || typeof request !== 'object' || Array.isArray(request)) invalid();
|
|
267
328
|
const keys = Object.keys(request);
|
|
268
329
|
if (keys.some(key => !['accountAfter', 'loginAfter', 'bindingAfter', 'limit'].includes(key))) invalid();
|
|
269
330
|
return { snapshot: await this.broker.snapshot(request) };
|
|
270
331
|
}));
|
|
271
|
-
this.managementRouter.addTypedHandler(
|
|
332
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchDoctor>(
|
|
272
333
|
'authswitch.authority.doctor', async request => {
|
|
273
334
|
if (request === null || typeof request !== 'object' || Array.isArray(request)) invalid();
|
|
274
335
|
const cursorKeys = ['accountAfter', 'loginAfter', 'codexHomeAfter', 'claudeHomeAfter',
|
|
@@ -279,63 +340,65 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
279
340
|
|| request.limit < 1 || request.limit > 128))) invalid();
|
|
280
341
|
return { page: await this.doctorPage(request) };
|
|
281
342
|
}));
|
|
282
|
-
this.managementRouter.addTypedHandler(
|
|
343
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportInventory>(
|
|
283
344
|
'authswitch.authority.import.inventory', async request => {
|
|
284
345
|
// A read with no parameters: the inventory is the whole host, and a caller cannot narrow it into
|
|
285
346
|
// believing a source does not exist.
|
|
286
347
|
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
287
348
|
|| Object.keys(request).length !== 0) invalid();
|
|
288
|
-
if (this.closing) throw new
|
|
349
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
350
|
+
if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
|
|
289
351
|
return { inventory: await this.legacyImport.inventory() };
|
|
290
352
|
}));
|
|
291
|
-
this.managementRouter.addTypedHandler(
|
|
353
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportStatus>(
|
|
292
354
|
'authswitch.authority.import.status', async request => {
|
|
293
355
|
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
294
356
|
|| Object.keys(request).some(key => !['after', 'limit'].includes(key))
|
|
295
357
|
|| (request.after !== undefined && !validHash(request.after))
|
|
296
358
|
|| (request.limit !== undefined && (!Number.isSafeInteger(request.limit)
|
|
297
359
|
|| request.limit < 1 || request.limit > 128))) invalid();
|
|
298
|
-
if (this.closing) throw new
|
|
360
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
361
|
+
if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
|
|
299
362
|
return { status: await this.legacyImport.status(request) };
|
|
300
363
|
}));
|
|
301
|
-
this.managementRouter.addTypedHandler(
|
|
364
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchEvents>(
|
|
302
365
|
'authswitch.authority.events', async (request, tools) => {
|
|
303
366
|
if (!hasKeys(request, ['epoch', 'afterRevision', 'waitMs'])) invalid();
|
|
304
367
|
return this.broker.events(request.epoch, request.afterRevision, request.waitMs, tools?.abortSignal);
|
|
305
368
|
}));
|
|
306
|
-
this.managementRouter.addTypedHandler(
|
|
369
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBeginAdd>(
|
|
307
370
|
'authswitch.authority.add', async request => {
|
|
308
371
|
if (!hasKeys(request, ['operationId', 'providerId', 'flow']) || typeof request.operationId !== 'string'
|
|
309
372
|
|| request.providerId !== 'openai' || request.flow !== 'device') invalid();
|
|
310
|
-
if (this.closing) throw new
|
|
373
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
311
374
|
return { operation: await this.broker.beginAddOpenAi(request.operationId) };
|
|
312
375
|
}));
|
|
313
|
-
this.managementRouter.addTypedHandler(
|
|
376
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBeginReauth>(
|
|
314
377
|
'authswitch.authority.reauth', async request => {
|
|
315
378
|
if (!hasKeys(request, ['operationId', 'accountId', 'loginId', 'purpose', 'flow']) || request.flow !== 'device'
|
|
316
379
|
|| typeof request.operationId !== 'string'
|
|
317
380
|
|| typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
|
|
318
381
|
|| request.purpose !== 'openai_managed') invalid();
|
|
319
|
-
if (this.closing) throw new
|
|
382
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
320
383
|
return { operation: await this.beginReauthOpenAi(request) };
|
|
321
384
|
}));
|
|
322
|
-
this.managementRouter.addTypedHandler(
|
|
385
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchGetOperation>(
|
|
323
386
|
'authswitch.authority.operation', async request => {
|
|
324
387
|
if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
|
|
325
388
|
return { operation: await this.broker.getOperation(request.operationId) };
|
|
326
389
|
}));
|
|
327
|
-
this.managementRouter.addTypedHandler(
|
|
390
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchListOperations>(
|
|
328
391
|
'authswitch.authority.operations', async request => {
|
|
329
392
|
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
330
393
|
|| Object.keys(request).some(key => !['after', 'limit'].includes(key))) invalid();
|
|
331
394
|
return this.broker.listOperations(request.after ?? null, request.limit ?? 128);
|
|
332
395
|
}));
|
|
333
|
-
this.managementRouter.addTypedHandler(
|
|
396
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchCancelOperation>(
|
|
334
397
|
'authswitch.authority.cancel', async request => {
|
|
335
398
|
if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
|
|
336
399
|
return { operation: await this.broker.cancelOperation(request.operationId) };
|
|
337
400
|
}));
|
|
338
|
-
this.managementRouter.addTypedHandler(
|
|
401
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchStartPreuse>(
|
|
339
402
|
'authswitch.authority.preuse.start', async request => {
|
|
340
403
|
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
341
404
|
|| !['operationId', 'accountId', 'loginId', 'purpose', 'prompt'].every(key => Object.hasOwn(request, key))
|
|
@@ -344,7 +407,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
344
407
|
|| typeof request.loginId !== 'string' || request.purpose !== 'openai_managed'
|
|
345
408
|
|| typeof request.prompt !== 'string'
|
|
346
409
|
|| (request.model !== undefined && typeof request.model !== 'string')) invalid();
|
|
347
|
-
if (this.closing) throw new
|
|
410
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
348
411
|
const reservation: IPreuseAccountReservation = { operationId: request.operationId };
|
|
349
412
|
let ownsReservation = false;
|
|
350
413
|
const release = (): void => {
|
|
@@ -371,28 +434,28 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
371
434
|
throw error;
|
|
372
435
|
}
|
|
373
436
|
}));
|
|
374
|
-
this.managementRouter.addTypedHandler(
|
|
437
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchGetPreuse>(
|
|
375
438
|
'authswitch.authority.preuse.operation', async request => {
|
|
376
439
|
if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
|
|
377
440
|
return { operation: await this.preuse.get(request.operationId) };
|
|
378
441
|
}));
|
|
379
|
-
this.managementRouter.addTypedHandler(
|
|
442
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchCancelPreuse>(
|
|
380
443
|
'authswitch.authority.preuse.cancel', async request => {
|
|
381
444
|
if (!hasKeys(request, ['operationId']) || typeof request.operationId !== 'string') invalid();
|
|
382
445
|
return { operation: await this.preuse.cancel(request.operationId) };
|
|
383
446
|
}));
|
|
384
|
-
this.managementRouter.addTypedHandler(
|
|
447
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRenameAccount>(
|
|
385
448
|
'authswitch.authority.rename', async request => {
|
|
386
449
|
if (!hasKeys(request, ['accountId', 'expectedRevision', 'label']) || typeof request.accountId !== 'string'
|
|
387
450
|
|| typeof request.expectedRevision !== 'number' || typeof request.label !== 'string') invalid();
|
|
388
451
|
return { account: await this.broker.renameAccount(request.accountId, request.expectedRevision, request.label) };
|
|
389
452
|
}));
|
|
390
|
-
this.managementRouter.addTypedHandler(
|
|
453
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchRemoveAccount>(
|
|
391
454
|
'authswitch.authority.remove', async request => {
|
|
392
455
|
if (!hasKeys(request, ['accountId', 'expectedRevision']) || typeof request.accountId !== 'string'
|
|
393
456
|
|| typeof request.expectedRevision !== 'number') invalid();
|
|
394
457
|
if (this.drainingAccounts.has(request.accountId) || this.preuseAccounts.has(request.accountId)) {
|
|
395
|
-
throw new
|
|
458
|
+
throw new AuthSwitchRefusal('account_busy', 'Account runtime drain is already in progress.');
|
|
396
459
|
}
|
|
397
460
|
this.drainingAccounts.add(request.accountId);
|
|
398
461
|
try {
|
|
@@ -400,7 +463,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
400
463
|
return { account: await this.broker.removeAccount(request.accountId, request.expectedRevision) };
|
|
401
464
|
} finally { this.drainingAccounts.delete(request.accountId); }
|
|
402
465
|
}));
|
|
403
|
-
this.managementRouter.addTypedHandler(
|
|
466
|
+
this.managementRouter.addTypedHandler(this.handler<IReq_AuthSwitchBindAccount>(
|
|
404
467
|
'authswitch.authority.bind', async request => {
|
|
405
468
|
if (!hasKeys(request, ['accountId', 'loginId', 'purpose', 'runtime', 'scopeId', 'incarnationId'])
|
|
406
469
|
|| typeof request.accountId !== 'string' || typeof request.loginId !== 'string'
|
|
@@ -408,7 +471,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
408
471
|
|| typeof request.incarnationId !== 'string') invalid();
|
|
409
472
|
return this.broker.bindAccount(request);
|
|
410
473
|
}));
|
|
411
|
-
this.runtimeRouter.addTypedHandler(
|
|
474
|
+
this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchResolveAccess>(
|
|
412
475
|
'authswitch.authority.resolveAccess', async request => {
|
|
413
476
|
if ((typeof request !== 'object' || request === null || Array.isArray(request))
|
|
414
477
|
|| !['bindingId', 'capability', 'minValidityMs'].every(key => Object.hasOwn(request, key))
|
|
@@ -417,41 +480,34 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
417
480
|
|| !validBindingId(request.bindingId) || !validBindingCapability(request.capability)
|
|
418
481
|
|| typeof request.minValidityMs !== 'number'
|
|
419
482
|
|| (request.rejectedGrantGeneration !== undefined && typeof request.rejectedGrantGeneration !== 'number')) invalid();
|
|
420
|
-
if (this.closing) throw new
|
|
483
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
421
484
|
const finish = this.beginRuntimeBindingAccess(this.runtimeBindingKey(request.bindingId, request.capability));
|
|
422
485
|
try {
|
|
423
486
|
return await this.broker.resolveAccess(request.bindingId, request.capability, request.minValidityMs,
|
|
424
487
|
request.rejectedGrantGeneration);
|
|
425
488
|
} finally { finish(); }
|
|
426
489
|
}));
|
|
427
|
-
this.runtimeRouter.addTypedHandler(
|
|
490
|
+
this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchReleaseBinding>(
|
|
428
491
|
'authswitch.authority.release', async request => {
|
|
429
492
|
if (!hasKeys(request, ['bindingId', 'capability'])
|
|
430
493
|
|| !validBindingId(request.bindingId) || !validBindingCapability(request.capability)) invalid();
|
|
431
|
-
if (this.closing) throw new
|
|
494
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
432
495
|
return this.releaseRuntimeBinding(request.bindingId, request.capability);
|
|
433
496
|
}));
|
|
434
|
-
this.runtimeRouter.addTypedHandler(
|
|
497
|
+
this.runtimeRouter.addTypedHandler(this.handler<IReq_AuthSwitchImportSubmit>(
|
|
435
498
|
'authswitch.authority.import.submit', async request => {
|
|
436
499
|
// Backend-only: an external submission carries the material of a store this package cannot read.
|
|
437
500
|
if (!hasKeys(request, ['submission', 'callerQuiescent', 'acknowledgeRunOrder'])
|
|
438
501
|
|| request.callerQuiescent !== true || request.acknowledgeRunOrder !== true
|
|
439
502
|
|| !validImportSubmission(request.submission)) invalid();
|
|
440
|
-
if (this.closing) throw new
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
// import outcome is unknown. Everything else stays sanitised and unmarked.
|
|
449
|
-
if (error instanceof AuthSwitchImportRefusal) {
|
|
450
|
-
throw new plugins.typedrequest.TypedResponseError(error.message,
|
|
451
|
-
{ reason: authSwitchImportRefusalReason });
|
|
452
|
-
}
|
|
453
|
-
throw error;
|
|
454
|
-
}
|
|
503
|
+
if (this.closing) throw new AuthSwitchRefusal('authority_closing', 'Authswitch authority is closing.');
|
|
504
|
+
if (!this.legacyImport) throw new AuthSwitchRefusal('import_refusal', legacyImportAbsent);
|
|
505
|
+
// An importer refusal is an `AuthSwitchRefusal` like every other, so the one conversion in
|
|
506
|
+
// `handler()` marks it. What stays special is its meaning: an UNMARKED failure of this route
|
|
507
|
+
// leaves the import outcome unknown, and the source must be read with `import.status` rather
|
|
508
|
+
// than submitted again.
|
|
509
|
+
return { result: await this.legacyImport.submit({ submission: request.submission,
|
|
510
|
+
callerQuiescent: true, acknowledgeRunOrder: true }) };
|
|
455
511
|
}));
|
|
456
512
|
}
|
|
457
513
|
|
|
@@ -593,13 +649,13 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
593
649
|
const run = home.activeRun;
|
|
594
650
|
if (!run) continue;
|
|
595
651
|
const survivor = await findManagedCodexProcess(run.socketPath);
|
|
596
|
-
if (survivor) throw new
|
|
652
|
+
if (survivor) throw new AuthSwitchRefusal('account_busy', 'A managed Codex app-server still owns this account; stop its work before changing the account.');
|
|
597
653
|
if (run.pid === null || run.startedAt === null) {
|
|
598
|
-
throw new
|
|
654
|
+
throw new AuthSwitchRefusal('account_busy', 'A managed Codex launch has uncertain process ownership; reconcile it before changing the account.');
|
|
599
655
|
}
|
|
600
656
|
try {
|
|
601
657
|
process.kill(run.pid, 0);
|
|
602
|
-
throw new
|
|
658
|
+
throw new AuthSwitchRefusal('account_busy', 'A managed Codex process identity is still present; reconcile it before changing the account.');
|
|
603
659
|
} catch (error) {
|
|
604
660
|
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH')) throw error;
|
|
605
661
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
+
import { AuthSwitchRefusal } from './authority-contract.js';
|
|
2
3
|
import {
|
|
3
4
|
AuthSwitchAuthorityAccountModel, AuthSwitchAuthorityBindingModel, AuthSwitchAuthorityClaudeHandoffModel,
|
|
4
5
|
AuthSwitchAuthorityClaudeHomeModel, AuthSwitchAuthorityCodexHomeModel, AuthSwitchAuthorityEnrollmentModel,
|
|
@@ -358,7 +359,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
358
359
|
|| account.primaryGrantId !== intent.grantId || !grant || grant.accountId !== intent.accountId
|
|
359
360
|
|| grant.providerId !== 'openai' || grant.purpose !== 'openai_managed'
|
|
360
361
|
|| grant.owner !== 'daemon' || ['removed', 'exchange_may_have_been_sent'].includes(grant.state)) {
|
|
361
|
-
throw new
|
|
362
|
+
throw new AuthSwitchRefusal('login_unavailable', 'Selected OpenAI login is unavailable for reauthentication.');
|
|
362
363
|
}
|
|
363
364
|
expectedGrantGeneration = grant.grantGeneration;
|
|
364
365
|
}
|
|
@@ -1038,9 +1039,9 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1038
1039
|
if (accountStored && accountStored.primaryGrantId !== grantId) {
|
|
1039
1040
|
throw new Error('Primary grant identity cannot be replaced.');
|
|
1040
1041
|
}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
throw new Error(
|
|
1042
|
+
// A device login commits daemon ownership; it is no proof that a native owner has stopped refreshing.
|
|
1043
|
+
if (grantStored && transfersGrantRefresher(grantStored.owner, draft.grant.owner)) {
|
|
1044
|
+
throw new Error(grantRefresherTransferRefusal(grantStored.owner, draft.grant.owner));
|
|
1044
1045
|
}
|
|
1045
1046
|
const completed: IStoredAuthorityDeviceOperation = {
|
|
1046
1047
|
...operation, state: 'complete', prompt: null,
|
|
@@ -1093,10 +1094,14 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1093
1094
|
if (draft.id !== grantId || draft.accountId !== account.id || draft.updateId !== updateId) {
|
|
1094
1095
|
throw new Error('Grant change has an invalid identity.');
|
|
1095
1096
|
}
|
|
1097
|
+
// This writer carries no handoff proof, so it may only move a grant within the ownership it already has.
|
|
1098
|
+
if (transfersGrantRefresher(grant.owner, draft.owner)) {
|
|
1099
|
+
throw new Error(grantRefresherTransferRefusal(grant.owner, draft.owner));
|
|
1100
|
+
}
|
|
1096
1101
|
if (grant.purpose === 'claude_host_native' && draft.state === 'exchange_may_have_been_sent'
|
|
1097
1102
|
&& grant.state !== 'exchange_may_have_been_sent'
|
|
1098
1103
|
&& !(await this.hasVerifiedClaudeGrantReceipt(account.id, grant.id, session))) {
|
|
1099
|
-
throw new
|
|
1104
|
+
throw new AuthSwitchRefusal('claude_receipt_missing', 'Claude refresh requires a verified import receipt.');
|
|
1100
1105
|
}
|
|
1101
1106
|
const accountDraft: IStoredAuthorityAccount = { ...account, revision: account.revision + 1,
|
|
1102
1107
|
statusObservedAt: draft.statusObservedAt, updateId };
|
|
@@ -1166,7 +1171,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1166
1171
|
const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
|
|
1167
1172
|
const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: accountId }, { session });
|
|
1168
1173
|
if (!metaStored || !accountStored || accountStored.removed || accountStored.revision !== expectedRevision) {
|
|
1169
|
-
throw new
|
|
1174
|
+
throw new AuthSwitchRefusal('account_changed', 'Account changed; refresh before removing.');
|
|
1170
1175
|
}
|
|
1171
1176
|
const account = AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored);
|
|
1172
1177
|
const scan = async (visit: (grant: IStoredAuthorityGrant,
|
|
@@ -1185,13 +1190,13 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1185
1190
|
await scan(async grant => {
|
|
1186
1191
|
if (grant.state === 'removed') return;
|
|
1187
1192
|
if (grant.owner === 'claude_native' || grant.owner === 'legacy_native') {
|
|
1188
|
-
throw new
|
|
1193
|
+
throw new AuthSwitchRefusal('native_owner_holds_login', 'Stop the native owner and complete its handoff before removing this account.');
|
|
1189
1194
|
}
|
|
1190
1195
|
if (grant.state === 'exchange_may_have_been_sent') {
|
|
1191
|
-
throw new
|
|
1196
|
+
throw new AuthSwitchRefusal('account_busy', 'Wait for the account refresh to finish before removing it.');
|
|
1192
1197
|
}
|
|
1193
1198
|
if (['handoff_pending', 'handoff_quarantined'].includes(grant.state)) {
|
|
1194
|
-
throw new
|
|
1199
|
+
throw new AuthSwitchRefusal('claude_handoff_pending', 'Resolve the Claude native handoff before removing this account.');
|
|
1195
1200
|
}
|
|
1196
1201
|
});
|
|
1197
1202
|
await scan(async (grant, stored) => {
|
|
@@ -1530,7 +1535,15 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1530
1535
|
return { handoffs, nextCursor: stored.length > limit ? handoffs.at(-1)!.id : null };
|
|
1531
1536
|
}
|
|
1532
1537
|
|
|
1533
|
-
/**
|
|
1538
|
+
/**
|
|
1539
|
+
* Only a verified backend import receipt may adopt a native home. No startup autodiscovery exists.
|
|
1540
|
+
*
|
|
1541
|
+
* `import.submit` is the one route that reaches this, so its two decided outcomes -- a home another grant
|
|
1542
|
+
* holds, and a login another home already holds -- answer with the importer's published code: the
|
|
1543
|
+
* transaction refused, the ledger row stays `verified` and nothing rotating was sent, while an unmarked
|
|
1544
|
+
* failure of that route means the outcome is unknown and the source must be read with `import.status`
|
|
1545
|
+
* instead. The ambiguous write below keeps that unmarked meaning, because it genuinely is unknown.
|
|
1546
|
+
*/
|
|
1534
1547
|
public async registerClaudeHomeFromReceipt(input: { homeId: string; accountId: string; grantId: string;
|
|
1535
1548
|
receiptId: string; sourceDigest: string; accountUuid: string; organizationUuid: string;
|
|
1536
1549
|
updateId: string; now: string }): Promise<IStoredAuthorityClaudeHome> {
|
|
@@ -1558,7 +1571,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1558
1571
|
}
|
|
1559
1572
|
if (current) {
|
|
1560
1573
|
if (current.activeAccountId !== accountId || current.activeGrantId !== grantId) {
|
|
1561
|
-
throw new
|
|
1574
|
+
throw new AuthSwitchRefusal('import_refusal', 'Claude native home belongs to another grant.');
|
|
1562
1575
|
}
|
|
1563
1576
|
return AuthSwitchAuthorityClaudeHomeModel.exact.toPersisted(current);
|
|
1564
1577
|
}
|
|
@@ -1575,6 +1588,11 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1575
1588
|
|| !['ambiguous_write', 'unique_conflict'].includes(error.code)) throw error;
|
|
1576
1589
|
const home = await this.readClaudeHome(homeId);
|
|
1577
1590
|
if (home?.updateId === updateId && home.activeAccountId === accountId && home.activeGrantId === grantId) return home;
|
|
1591
|
+
// One grant, one home: the unique index refused a second home for a login another one already holds.
|
|
1592
|
+
if (error.code === 'unique_conflict') {
|
|
1593
|
+
throw new AuthSwitchRefusal('import_refusal',
|
|
1594
|
+
'This Claude login is already registered to another native home.');
|
|
1595
|
+
}
|
|
1578
1596
|
throw new Error('Claude native home registration outcome is unknown.');
|
|
1579
1597
|
}
|
|
1580
1598
|
}
|
|
@@ -1797,9 +1815,18 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
1797
1815
|
throw new Error('Migration completion requires a verified import of the same account and grant.');
|
|
1798
1816
|
}
|
|
1799
1817
|
if (current) {
|
|
1818
|
+
// A row may follow its source's current bytes in exactly one state. `pending_native_owner` is a
|
|
1819
|
+
// projection whose own tool still refreshes it: nothing rotating was ever sent for such a row and
|
|
1820
|
+
// the authority holds no copy of its credential, so the file that tool has since rewritten is the
|
|
1821
|
+
// same login and the row must be able to reach it -- otherwise the retry its own refusal asks for
|
|
1822
|
+
// is impossible. It may never change the account or grant it named while doing so, and no other
|
|
1823
|
+
// status may move its digest at all.
|
|
1824
|
+
const followsSource = current.sourceDigest !== draft.sourceDigest;
|
|
1800
1825
|
if (current.status === 'complete' || current.version !== draft.version
|
|
1801
1826
|
|| current.sourceKind !== draft.sourceKind || current.sourcePathHash !== draft.sourcePathHash
|
|
1802
|
-
|| current.
|
|
1827
|
+
|| (followsSource && (current.status !== 'pending_native_owner' || current.accountId === null
|
|
1828
|
+
|| current.accountId !== draft.accountId || current.grantId !== draft.grantId))
|
|
1829
|
+
|| draft.revision !== current.revision + 1) {
|
|
1803
1830
|
throw new Error('Migration source identity or terminal status cannot change.');
|
|
1804
1831
|
}
|
|
1805
1832
|
const result = await AuthSwitchAuthorityMigrationLedgerModel.exact.transition({
|