@modelprofile.com/authswitch 8.2.0 → 9.1.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 +84 -1
- package/dist_ts/authority-contract.js +14 -2
- package/dist_ts/authority-import-contract.d.ts +6 -0
- 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 +23 -8
- package/dist_ts/classes.authoritybroker.js +155 -32
- package/dist_ts/classes.authorityclient.d.ts +22 -2
- package/dist_ts/classes.authorityclient.js +98 -13
- package/dist_ts/classes.authoritydaemon.d.ts +21 -3
- package/dist_ts/classes.authoritydaemon.js +77 -32
- package/dist_ts/classes.authoritydatabase.d.ts +21 -3
- package/dist_ts/classes.authoritydatabase.js +98 -12
- package/dist_ts/classes.authorityimport.d.ts +16 -4
- package/dist_ts/classes.authorityimport.js +75 -24
- package/dist_ts/classes.authoritymodels.js +5 -3
- package/dist_ts/classes.authoritypreuse.js +8 -3
- package/dist_ts/classes.authorityservice.d.ts +10 -11
- package/dist_ts/classes.authorityservice.js +14 -23
- package/dist_ts/classes.cli.d.ts +10 -2
- package/dist_ts/classes.cli.js +12 -4
- package/dist_ts/classes.codexmanaged.d.ts +0 -1
- package/dist_ts/classes.codexmanaged.js +13 -26
- 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/codexcontract.d.ts +30 -0
- package/dist_ts/codexcontract.js +174 -0
- package/dist_ts/ts_migration/0004_container_setup_owner.d.ts +12 -0
- package/dist_ts/ts_migration/0004_container_setup_owner.js +19 -0
- package/dist_ts/ts_migration/index.js +3 -1
- package/dist_ts/ts_migration/legacysources/authswitchstores.js +5 -2
- package/package.json +11 -11
- package/readme.md +194 -24
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +90 -4
- package/ts/authority-import-contract.ts +6 -0
- package/ts/authority-paths.ts +69 -0
- package/ts/authority-runtime-contract.ts +11 -1
- package/ts/classes.authoritybroker.ts +153 -33
- package/ts/classes.authorityclient.ts +102 -15
- package/ts/classes.authoritydaemon.ts +89 -27
- package/ts/classes.authoritydatabase.ts +98 -12
- package/ts/classes.authorityimport.ts +102 -25
- package/ts/classes.authoritymodels.ts +4 -1
- package/ts/classes.authoritypreuse.ts +7 -1
- package/ts/classes.authorityservice.ts +15 -30
- package/ts/classes.cli.ts +14 -3
- package/ts/classes.codexmanaged.ts +10 -19
- 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/codexcontract.ts +200 -0
- package/ts/ts_migration/0004_container_setup_owner.ts +19 -0
- package/ts/ts_migration/index.ts +2 -0
- package/ts/ts_migration/legacysources/authswitchstores.ts +4 -1
|
@@ -76,9 +76,15 @@ export interface IAuthSwitchImportClaudeTokens {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
export interface IAuthSwitchAuthorityImportOptions {
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Locates the legacy stores, exactly as each owning class locates its own -- and always stated.
|
|
81
|
+
*
|
|
82
|
+
* These are the two inputs that decide whose real credentials an import reads, so the importer never
|
|
83
|
+
* reaches for the host's own environment: a caller that could not name them has no business importing.
|
|
84
|
+
* `AuthSwitchAuthorityService`'s entry point is the one place that takes them from this host.
|
|
85
|
+
*/
|
|
86
|
+
env: NodeJS.ProcessEnv;
|
|
87
|
+
homeDirectory: string;
|
|
82
88
|
now?: () => number;
|
|
83
89
|
/** The one rotating refresh of an imported ChatGPT login, and the non-rotating read that proves one. */
|
|
84
90
|
openAi?: IAuthSwitchImportOpenAiOperations;
|
|
@@ -189,6 +195,58 @@ const RUN_ORDER_REFUSAL = 'Importing needs the run-order acknowledgement: an imp
|
|
|
189
195
|
+ 'exercise exactly that copy. Import only once those commands run on the authority and the native '
|
|
190
196
|
+ 'projections exist.';
|
|
191
197
|
|
|
198
|
+
/**
|
|
199
|
+
* A row keeps the login it named, permanently: no route forgets a row or re-points it at another account,
|
|
200
|
+
* and one would be a way to import a login the owner never approved under an approval given for a different
|
|
201
|
+
* one. The same is true of a settled row, which answers that it is already imported.
|
|
202
|
+
*/
|
|
203
|
+
const FOREIGN_SOURCE_REFUSAL = 'This source now holds a different account\'s login than the one this import '
|
|
204
|
+
+ 'registered for it. Restore the login this source was registered with, then import it again.';
|
|
205
|
+
const CHANGED_SOURCE_REFUSAL =
|
|
206
|
+
'The source changed after its migration ledger row was written; it is a different source now.';
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Whether a row may take the bytes its source holds now.
|
|
210
|
+
*
|
|
211
|
+
* A projection that could not be proven waits for its own tool, and that tool proves itself by refreshing
|
|
212
|
+
* its store -- which rewrites the file. The row has to be able to follow those bytes, or the retry its own
|
|
213
|
+
* refusal asks for could never be made at all. It follows them only this far: the row is unsettled, nothing
|
|
214
|
+
* rotating was ever sent for it, and the authority holds no copy of its credential. That the bytes are still
|
|
215
|
+
* the same login is a separate question, and `ledgerObjection` answers it first.
|
|
216
|
+
*/
|
|
217
|
+
const followsRefreshedSource = (ledger: IStoredAuthorityMigrationLedger,
|
|
218
|
+
record: Pick<ILegacySourceRecord, 'treatment'>): boolean =>
|
|
219
|
+
ledger.status === 'pending_native_owner' && record.treatment === 'project' && ledger.grantId !== null;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* What this source's own row objects to, asked once and answered for both the report and the submit.
|
|
223
|
+
*
|
|
224
|
+
* The runbook tells the owner not to submit a source the inventory lists a problem for, which is only true
|
|
225
|
+
* while the two agree, so they are the same four questions in one place. The identity question is the one
|
|
226
|
+
* the provider cannot answer: a row is found by the hash of its location, so the same row answers for
|
|
227
|
+
* whatever login that location holds later -- `codex login` under the same path, a replaced saved record --
|
|
228
|
+
* and the non-rotating proof asks the provider about the identity the newly read bytes themselves claim. It
|
|
229
|
+
* therefore proves those bytes are self-consistent and live, never that they are the login this row was
|
|
230
|
+
* approved for. A source with no derivable identity is left to its reader's own problem.
|
|
231
|
+
*/
|
|
232
|
+
type TLedgerObjection = 'imported' | 'quarantined' | 'foreign' | 'changed';
|
|
233
|
+
|
|
234
|
+
const ledgerObjection = (ledger: IStoredAuthorityMigrationLedger,
|
|
235
|
+
record: Pick<ILegacySourceRecord, 'treatment' | 'accountId' | 'sourceDigest'>,
|
|
236
|
+
grantId: string | null): TLedgerObjection | null => {
|
|
237
|
+
// A quarantined row is terminal whatever its location holds now, and its answer -- sign in to that
|
|
238
|
+
// account again -- is the next step either way. Everything after it is about the login that is there.
|
|
239
|
+
if (ledger.status === 'quarantined') return 'quarantined';
|
|
240
|
+
// Before "already imported": a settled row whose location now holds another account would otherwise
|
|
241
|
+
// answer that a login it never touched is imported.
|
|
242
|
+
if (record.accountId !== null && grantId !== null
|
|
243
|
+
&& ((ledger.accountId !== null && ledger.accountId !== record.accountId)
|
|
244
|
+
|| (ledger.grantId !== null && ledger.grantId !== grantId))) return 'foreign';
|
|
245
|
+
if (ledger.status === 'complete') return 'imported';
|
|
246
|
+
if (ledger.sourceDigest !== record.sourceDigest && !followsRefreshedSource(ledger, record)) return 'changed';
|
|
247
|
+
return null;
|
|
248
|
+
};
|
|
249
|
+
|
|
192
250
|
/** Strips the source location: a ledger row stores its hash, and no response may carry the path. */
|
|
193
251
|
const publicSource = (record: ILegacySourceRecord,
|
|
194
252
|
ledgerStatus: IAuthSwitchImportSource['ledgerStatus']): IAuthSwitchImportSource => ({
|
|
@@ -227,9 +285,9 @@ export class AuthSwitchAuthorityImport {
|
|
|
227
285
|
private readonly quiescence: IAuthSwitchImportQuiescence;
|
|
228
286
|
|
|
229
287
|
constructor(private readonly database: AuthSwitchAuthorityDatabase,
|
|
230
|
-
options: IAuthSwitchAuthorityImportOptions
|
|
231
|
-
this.env = options.env
|
|
232
|
-
this.homeDirectory = options.homeDirectory
|
|
288
|
+
options: IAuthSwitchAuthorityImportOptions) {
|
|
289
|
+
this.env = options.env;
|
|
290
|
+
this.homeDirectory = options.homeDirectory;
|
|
233
291
|
this.now = options.now ?? Date.now;
|
|
234
292
|
this.openAi = options.openAi ?? new plugins.flexAccounts.OpenAiProviderAdapter();
|
|
235
293
|
this.claudeStatus = options.claudeStatus ?? new ClaudeAccountStatus();
|
|
@@ -248,12 +306,20 @@ export class AuthSwitchAuthorityImport {
|
|
|
248
306
|
for (const record of report.sources) {
|
|
249
307
|
const ledger = await this.database.readMigrationLedger(record.id);
|
|
250
308
|
const source = publicSource(record, ledger?.status ?? null);
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
309
|
+
// The same four questions the submit decides on, so a source the report invites is one the submit
|
|
310
|
+
// takes. New bytes of the same login under a row that is still waiting for them are not an objection:
|
|
311
|
+
// they are the retry that row asks for, and no problem line tells the owner to stop.
|
|
312
|
+
const objection = ledger === null ? null : ledgerObjection(ledger, record,
|
|
313
|
+
record.accountId === null ? null : importGrantId(record.accountId, grantPlan(record)));
|
|
314
|
+
if (objection === 'foreign') {
|
|
315
|
+
source.problems.push('this source now holds a different account\'s login than the one it was registered with');
|
|
316
|
+
}
|
|
317
|
+
if (objection === 'changed') {
|
|
318
|
+
// A later import must not treat the approved bytes and the current bytes as the same source; the
|
|
319
|
+
// ledger deliberately refuses to move that row.
|
|
254
320
|
source.problems.push('the source changed after its migration ledger row was written');
|
|
255
321
|
}
|
|
256
|
-
if (
|
|
322
|
+
if (objection === 'imported') {
|
|
257
323
|
source.problems.push('this source is already imported; its legacy copy is retired');
|
|
258
324
|
}
|
|
259
325
|
sources.push(source);
|
|
@@ -281,7 +347,7 @@ export class AuthSwitchAuthorityImport {
|
|
|
281
347
|
const grant = row.grantId === null ? null : await this.database.readGrant(row.grantId);
|
|
282
348
|
const handoff = await this.database.readHandoff(importHandoffId(row.id));
|
|
283
349
|
entries.push({
|
|
284
|
-
sourceId: row.id, sourceKind: row.sourceKind, status: row.status,
|
|
350
|
+
sourceId: row.id, sourceKind: row.sourceKind, sourcePathHash: row.sourcePathHash, status: row.status,
|
|
285
351
|
accountId: row.accountId, loginId: row.grantId, owner: grant?.owner ?? null,
|
|
286
352
|
action: importAction(row.status, handoff), statusObservedAt: row.statusObservedAt,
|
|
287
353
|
});
|
|
@@ -308,17 +374,6 @@ export class AuthSwitchAuthorityImport {
|
|
|
308
374
|
const record = this.resolveSource(request.submission);
|
|
309
375
|
const plan = grantPlan(record);
|
|
310
376
|
this.assertQuiescent(record);
|
|
311
|
-
const ledger = await this.database.readMigrationLedger(record.id);
|
|
312
|
-
if (ledger !== null) {
|
|
313
|
-
if (ledger.sourceDigest !== record.sourceDigest) {
|
|
314
|
-
refuse('The source changed after its migration ledger row was written; it is a different source now.');
|
|
315
|
-
}
|
|
316
|
-
if (ledger.status === 'complete') refuse('This source is already imported.');
|
|
317
|
-
if (ledger.status === 'quarantined') {
|
|
318
|
-
refuse('This source is quarantined: its outcome could not be established, so it needs a device '
|
|
319
|
-
+ 'sign-in for that account rather than another import.');
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
377
|
const accountId = record.accountId;
|
|
323
378
|
const subject = record.subject;
|
|
324
379
|
const workspaceId = record.workspaceId;
|
|
@@ -326,6 +381,18 @@ export class AuthSwitchAuthorityImport {
|
|
|
326
381
|
refuse('The source carries no provider identity the authority can bind an account to.');
|
|
327
382
|
}
|
|
328
383
|
const grantId = importGrantId(accountId, plan);
|
|
384
|
+
const ledger = await this.database.readMigrationLedger(record.id);
|
|
385
|
+
// Every one of these is decided from the durable row alone, before a single write: a projection
|
|
386
|
+
// registers its account and grant before the provider is asked anything, so an objection that arrived
|
|
387
|
+
// later would arrive after the record it objects to had been written.
|
|
388
|
+
switch (ledger === null ? null : ledgerObjection(ledger, record, grantId)) {
|
|
389
|
+
case 'imported': refuse('This source is already imported.');
|
|
390
|
+
case 'quarantined':
|
|
391
|
+
refuse('This source is quarantined: its outcome could not be established, so it needs a device '
|
|
392
|
+
+ 'sign-in for that account rather than another import.');
|
|
393
|
+
case 'foreign': refuse(FOREIGN_SOURCE_REFUSAL);
|
|
394
|
+
case 'changed': refuse(CHANGED_SOURCE_REFUSAL);
|
|
395
|
+
}
|
|
329
396
|
if (ledger === null) await this.beginLedger(record);
|
|
330
397
|
const material = this.material(record, request.submission);
|
|
331
398
|
return record.treatment === 'project'
|
|
@@ -428,12 +495,20 @@ export class AuthSwitchAuthorityImport {
|
|
|
428
495
|
}));
|
|
429
496
|
}
|
|
430
497
|
|
|
498
|
+
/**
|
|
499
|
+
* Moves the row, and -- only where a caller states it -- the bytes that move with the status.
|
|
500
|
+
*
|
|
501
|
+
* The digest is part of the same write as the status it was proven under, so no row can record a proof of
|
|
502
|
+
* bytes it does not name. The database holds the rule about which row may carry a new digest at all.
|
|
503
|
+
*/
|
|
431
504
|
private async moveLedger(ledgerId: string, status: IStoredAuthorityMigrationLedger['status'],
|
|
432
|
-
accountId: string | null, grantId: string | null
|
|
505
|
+
accountId: string | null, grantId: string | null,
|
|
506
|
+
sourceDigest?: string): Promise<IStoredAuthorityMigrationLedger> {
|
|
433
507
|
const updateId = plugins.crypto.randomUUID();
|
|
434
508
|
return this.database.changeMigrationLedger(updateId, ledgerId, current => {
|
|
435
509
|
if (!current) throw new Error('The migration ledger row disappeared during the import.');
|
|
436
|
-
return { ...current, status, accountId, grantId,
|
|
510
|
+
return { ...current, status, accountId, grantId,
|
|
511
|
+
sourceDigest: sourceDigest ?? current.sourceDigest, revision: current.revision + 1,
|
|
437
512
|
statusObservedAt: this.stamp(), updateId };
|
|
438
513
|
});
|
|
439
514
|
}
|
|
@@ -513,7 +588,9 @@ export class AuthSwitchAuthorityImport {
|
|
|
513
588
|
await this.moveLedger(record.id, 'pending_native_owner', identity.accountId, identity.grantId);
|
|
514
589
|
throw error;
|
|
515
590
|
}
|
|
516
|
-
|
|
591
|
+
// The bytes that were just proven are the bytes this row records, written with the proof itself: a row
|
|
592
|
+
// that followed its tool's refresh names the store the provider answered for, never the one before it.
|
|
593
|
+
await this.moveLedger(record.id, 'verified', identity.accountId, identity.grantId, record.sourceDigest);
|
|
517
594
|
if (record.sourceKind === 'claude_native') await this.adoptClaudeHome(record, identity);
|
|
518
595
|
await this.moveLedger(record.id, 'complete', identity.accountId, identity.grantId);
|
|
519
596
|
return this.result(record, 'complete', identity, []);
|
|
@@ -444,7 +444,10 @@ export const assertStoredAuthorityGrant: (value: unknown) => asserts value is IS
|
|
|
444
444
|
throw new Error('Claude handoff grant has incompatible custody.');
|
|
445
445
|
}
|
|
446
446
|
if (value.purpose === 'openai_managed' && value.providerId !== 'openai') throw new Error('OpenAI grant provider mismatch.');
|
|
447
|
-
|
|
447
|
+
// No native tool refreshes a container setup token: the authority holds it, or nobody does.
|
|
448
|
+
if (value.purpose === 'claude_container_setup' && value.owner !== 'daemon' && value.owner !== 'none') {
|
|
449
|
+
throw new Error('Container setup grant has a native owner.');
|
|
450
|
+
}
|
|
448
451
|
};
|
|
449
452
|
|
|
450
453
|
export const assertStoredAuthorityBinding: (value: unknown) => asserts value is IStoredAuthorityBinding = value => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import type { IAuthSwitchPreuseOperation } from './authority-contract.js';
|
|
3
|
+
import { AuthSwitchRefusal } from './authority-contract.js';
|
|
3
4
|
import { AuthSwitchAuthorityBroker } from './classes.authoritybroker.js';
|
|
4
5
|
import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
|
|
5
6
|
import type { IStoredAuthorityPreuseOperation, TStoredAuthorityPreuseProblem } from './classes.authoritymodels.js';
|
|
@@ -250,7 +251,12 @@ export class AuthSwitchAuthorityPreuse {
|
|
|
250
251
|
private async readStored(operationId: string): Promise<IStoredAuthorityPreuseOperation> {
|
|
251
252
|
if (!uuid(operationId)) throw new Error('Invalid preuse operation ID.');
|
|
252
253
|
const operation = await this.database.readOperation(operationId);
|
|
253
|
-
|
|
254
|
+
// One site answers both routes that take a preuse operation id, so a read and a cancel of something
|
|
255
|
+
// this authority does not hold say the same thing in the same code.
|
|
256
|
+
if (!operation || operation.kind !== 'preuse_openai') {
|
|
257
|
+
throw new AuthSwitchRefusal('not_found', 'This authority knows no preuse by that operation ID. '
|
|
258
|
+
+ 'Start a new preuse and follow that one.');
|
|
259
|
+
}
|
|
254
260
|
return operation;
|
|
255
261
|
}
|
|
256
262
|
|
|
@@ -1,37 +1,12 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
+
import { authSwitchAuthorityUnitDirectory, authSwitchAuthorityUnitName,
|
|
3
|
+
resolveAuthSwitchAuthorityPaths } from './authority-paths.js';
|
|
2
4
|
import { AuthSwitchAuthorityDaemon } from './classes.authoritydaemon.js';
|
|
3
5
|
import type { IClaudeNativeAuthorityOptions } from './classes.claudeauthority.js';
|
|
4
6
|
import { ClaudeNativeAdapter, claudeNativeUserSettingsFiles,
|
|
5
7
|
resolveClaudeNativeHome } from './classes.claudenative.js';
|
|
6
8
|
|
|
7
|
-
export
|
|
8
|
-
runtimeDirectory: string;
|
|
9
|
-
dataDirectory: string;
|
|
10
|
-
databaseSocketPath: string;
|
|
11
|
-
authoritySocketPath: string;
|
|
12
|
-
runtimeSocketPath: string;
|
|
13
|
-
runtimeSocketDirectory: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Canonical per-user paths. An explicit runtime directory is also usable by a trusted container host. */
|
|
17
|
-
export const resolveAuthSwitchAuthorityPaths = (runtimeDirectory = process.env.XDG_RUNTIME_DIR): IAuthSwitchAuthorityPaths => {
|
|
18
|
-
if (!runtimeDirectory || !plugins.path.isAbsolute(runtimeDirectory)) {
|
|
19
|
-
throw new Error('A private XDG_RUNTIME_DIR is required for authswitch authority.');
|
|
20
|
-
}
|
|
21
|
-
const home = plugins.os.userInfo().homedir;
|
|
22
|
-
const dataHome = process.env.XDG_DATA_HOME || plugins.path.join(home, '.local/share');
|
|
23
|
-
if (!plugins.path.isAbsolute(dataHome)) throw new Error('XDG_DATA_HOME must be an absolute path.');
|
|
24
|
-
const socketDirectory = plugins.path.join(runtimeDirectory, 'authswitch');
|
|
25
|
-
const runtimeSocketDirectory = plugins.path.join(socketDirectory, 'runtime');
|
|
26
|
-
return {
|
|
27
|
-
runtimeDirectory,
|
|
28
|
-
dataDirectory: plugins.path.join(dataHome, 'authswitch', 'authority'),
|
|
29
|
-
databaseSocketPath: plugins.path.join(socketDirectory, 'internal', 'db.sock'),
|
|
30
|
-
authoritySocketPath: plugins.path.join(socketDirectory, 'management', 'authority.sock'),
|
|
31
|
-
runtimeSocketPath: plugins.path.join(runtimeSocketDirectory, 'runtime.sock'),
|
|
32
|
-
runtimeSocketDirectory,
|
|
33
|
-
};
|
|
34
|
-
};
|
|
9
|
+
export { resolveAuthSwitchAuthorityPaths, type IAuthSwitchAuthorityPaths } from './authority-paths.js';
|
|
35
10
|
|
|
36
11
|
/** Explicit installer and lifecycle operations for a user-scoped systemd service. */
|
|
37
12
|
export class AuthSwitchAuthorityService {
|
|
@@ -39,9 +14,11 @@ export class AuthSwitchAuthorityService {
|
|
|
39
14
|
public readonly unitFile: plugins.smartdaemon.SystemdUnitFile;
|
|
40
15
|
public readonly definition: plugins.smartdaemon.SystemdServiceDefinition;
|
|
41
16
|
|
|
42
|
-
|
|
17
|
+
/** The unit directory defaults to the one the legacy fence looks in for this user (`./authority-paths`). */
|
|
18
|
+
constructor(runtimeDirectory: string,
|
|
19
|
+
unitDirectory = authSwitchAuthorityUnitDirectory(process.env, plugins.os.userInfo().homedir)) {
|
|
43
20
|
const packageRoot = plugins.path.dirname(plugins.path.dirname(plugins.fileURLToPath(import.meta.url)));
|
|
44
|
-
const options = { unitName:
|
|
21
|
+
const options = { unitName: authSwitchAuthorityUnitName, scope: 'user' as const, runtimeDirectory };
|
|
45
22
|
this.unit = new plugins.smartdaemon.SystemdUnit(options);
|
|
46
23
|
this.unitFile = new plugins.smartdaemon.SystemdUnitFile({ ...options, unitDirectory });
|
|
47
24
|
this.definition = new plugins.smartdaemon.SystemdServiceDefinition({
|
|
@@ -86,6 +63,13 @@ export const resolveAuthSwitchClaudeNativeAuthority = (env: NodeJS.ProcessEnv =
|
|
|
86
63
|
settingsFiles: claudeNativeUserSettingsFiles(configDir) }) };
|
|
87
64
|
};
|
|
88
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The per-user daemon this package installs, and the one place that names this host's own legacy stores.
|
|
68
|
+
*
|
|
69
|
+
* Every other construction of `AuthSwitchAuthorityDaemon` -- a test, a consumer's harness, an embedding
|
|
70
|
+
* backend -- states its own locations or asks for no import surface, because a daemon that defaulted here
|
|
71
|
+
* would read the credentials of whichever user happened to start it.
|
|
72
|
+
*/
|
|
89
73
|
export const runAuthSwitchAuthorityDaemon = async (paths = resolveAuthSwitchAuthorityPaths()): Promise<void> => {
|
|
90
74
|
const daemon = new AuthSwitchAuthorityDaemon({
|
|
91
75
|
dataDirectory: paths.dataDirectory,
|
|
@@ -93,6 +77,7 @@ export const runAuthSwitchAuthorityDaemon = async (paths = resolveAuthSwitchAuth
|
|
|
93
77
|
authoritySocketPath: paths.authoritySocketPath,
|
|
94
78
|
runtimeSocketPath: paths.runtimeSocketPath,
|
|
95
79
|
claudeNative: resolveAuthSwitchClaudeNativeAuthority(),
|
|
80
|
+
legacyImport: { env: process.env, homeDirectory: plugins.os.homedir() },
|
|
96
81
|
});
|
|
97
82
|
await daemon.start();
|
|
98
83
|
await new Promise<void>((resolve, reject) => {
|
package/ts/classes.cli.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { CodexHarness } from './classes.codexharness.js';
|
|
|
4
4
|
import { OpenCodeHarness } from './classes.opencodeharness.js';
|
|
5
5
|
import { ClaudeCodeHarness } from './classes.claudecodeharness.js';
|
|
6
6
|
import { AuthSwitchTui } from './classes.tui.js';
|
|
7
|
-
import { AglAuthSwitchCoordinator, AuthSwitchOperations, authSwitchMutationReplacesLogin, type TAuthSwitchCoordinator, type TAuthSwitchMutation } from './classes.operations.js';
|
|
7
|
+
import { AglAuthSwitchCoordinator, AuthSwitchOperations, authSwitchHostLegacyFence, authSwitchMutationReplacesLogin, type TAuthSwitchCoordinator, type TAuthSwitchLegacyFence, type TAuthSwitchMutation } from './classes.operations.js';
|
|
8
8
|
import { readAccountList } from './classes.accountlist.js';
|
|
9
9
|
import { AccountListRenderer } from './classes.listrenderer.js';
|
|
10
10
|
import { accountLimits, activeAccounts, CondensedRenderer } from './classes.limits.js';
|
|
@@ -98,9 +98,20 @@ export class AuthSwitchCli {
|
|
|
98
98
|
private readonly operations: AuthSwitchOperations;
|
|
99
99
|
private get out(): plugins.smartconsole.SmartConsole { return this.console ??= new plugins.smartconsole.SmartConsole(); }
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
/**
|
|
102
|
+
* `legacyFenceArg` states which authority fences the mutations of injected harnesses.
|
|
103
|
+
*
|
|
104
|
+
* With no harnesses this command line is this host's, exactly as its default harnesses and its AGL
|
|
105
|
+
* coordinator are, and it is fenced by this host's authority. Injected harnesses write whatever stores
|
|
106
|
+
* their caller chose, so -- like the coordinator -- nothing of this host is assumed for them: they are
|
|
107
|
+
* fenced only by what the caller states, and by nothing when it states nothing.
|
|
108
|
+
*/
|
|
109
|
+
constructor(harnessesArg?: IAuthHarness[] | CodexSwitcher, coordinatorArg?: TAuthSwitchCoordinator,
|
|
110
|
+
legacyFenceArg?: TAuthSwitchLegacyFence) {
|
|
102
111
|
const coordinator = new AglAuthSwitchCoordinator();
|
|
103
|
-
this.operations = new AuthSwitchOperations(
|
|
112
|
+
this.operations = new AuthSwitchOperations(
|
|
113
|
+
coordinatorArg ?? (harnessesArg === undefined ? request => coordinator.coordinate(request) : undefined),
|
|
114
|
+
legacyFenceArg ?? (harnessesArg === undefined ? authSwitchHostLegacyFence() : 'none'));
|
|
104
115
|
harnessesArg ??= [new CodexHarness(), new OpenCodeHarness(), new ClaudeCodeHarness()];
|
|
105
116
|
// Preserve the published single-Codex constructor at the composition boundary.
|
|
106
117
|
const harnesses = Array.isArray(harnessesArg) ? harnessesArg : [new CodexHarness(harnessesArg)];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { commitinfo } from './00_commitinfo_data.js';
|
|
3
3
|
import type { AuthSwitchAuthorityBroker } from './classes.authoritybroker.js';
|
|
4
|
+
import { requireVerifiedCodexServer, verifyManagedCodexContract, type IManagedCodexContract } from './codexcontract.js';
|
|
4
5
|
|
|
5
6
|
export interface IAuthSwitchManagedCodexOptions {
|
|
6
7
|
/** Private runtime root; only the disposable control socket lives here. */
|
|
@@ -153,18 +154,6 @@ export class AuthSwitchManagedCodex {
|
|
|
153
154
|
return picked;
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
private async requireVersion(): Promise<void> {
|
|
157
|
-
const executable = this.options.executable ?? 'codex';
|
|
158
|
-
const output = await new Promise<string>((resolve, reject) => {
|
|
159
|
-
plugins.childProcess.execFile(executable, ['--version'], { timeout: 5_000, maxBuffer: 4096,
|
|
160
|
-
windowsHide: true, env: this.environment() }, (error, stdout) => {
|
|
161
|
-
if (error) reject(new Error('Managed Codex version probe failed.'));
|
|
162
|
-
else resolve(stdout.trim());
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
if (output !== 'codex-cli 0.155.1') throw new Error('Managed Codex requires verified codex-cli 0.155.1.');
|
|
166
|
-
}
|
|
167
|
-
|
|
168
157
|
public start(): Promise<void> {
|
|
169
158
|
if (this.closed || this.startPromise) throw new Error('Managed Codex can start only once.');
|
|
170
159
|
this.startPromise = this.performStart();
|
|
@@ -195,13 +184,18 @@ export class AuthSwitchManagedCodex {
|
|
|
195
184
|
} catch (error) {
|
|
196
185
|
if (!isRecord(error) || error.code !== 'ENOENT') throw error;
|
|
197
186
|
}
|
|
198
|
-
|
|
187
|
+
// Nothing is recorded or started for a Codex that is too old or no longer offers what this uses.
|
|
188
|
+
const contract: IManagedCodexContract = await verifyManagedCodexContract({
|
|
189
|
+
executable: this.options.executable ?? 'codex', env: this.environment(), scratchDirectory: this.runDirectory });
|
|
199
190
|
if (this.closed) throw new Error('Managed Codex startup was cancelled.');
|
|
200
191
|
const initial = await this.options.broker.resolveAccess(this.options.bindingId,
|
|
201
192
|
this.options.capability, 60_000);
|
|
202
193
|
if (this.closed) throw new Error('Managed Codex startup was cancelled.');
|
|
203
194
|
this.workspaceId = initial.accountId;
|
|
204
195
|
this.grantedGeneration = initial.grantGeneration;
|
|
196
|
+
// Codex binds the socket elsewhere and publishes this path as an alias; deriving where
|
|
197
|
+
// proves the host can verify that alias before any run is recorded or process started.
|
|
198
|
+
await plugins.crossharness.codexUnixSocketTarget(this.socketPath);
|
|
205
199
|
await this.options.onPreparedRun(this.socketPath);
|
|
206
200
|
if (this.closed) throw new Error('Managed Codex startup was cancelled.');
|
|
207
201
|
const child = plugins.childProcess.spawn(this.options.executable ?? 'codex', [
|
|
@@ -226,11 +220,7 @@ export class AuthSwitchManagedCodex {
|
|
|
226
220
|
while (true) {
|
|
227
221
|
if (this.closed) throw new Error('Managed Codex startup was cancelled.');
|
|
228
222
|
if (!isAlive(child)) throw new Error('Managed Codex app-server exited before accepting connections.');
|
|
229
|
-
|
|
230
|
-
if ((await plugins.fs.promises.lstat(this.socketPath)).isSocket()) break;
|
|
231
|
-
} catch (error) {
|
|
232
|
-
if (!isRecord(error) || error.code !== 'ENOENT') throw error;
|
|
233
|
-
}
|
|
223
|
+
if (await plugins.crossharness.observeCodexUnixSocket(this.socketPath) === 'published') break;
|
|
234
224
|
if (Date.now() >= deadline) throw new Error('Managed Codex app-server socket startup timed out.');
|
|
235
225
|
await wait(25);
|
|
236
226
|
}
|
|
@@ -248,7 +238,8 @@ export class AuthSwitchManagedCodex {
|
|
|
248
238
|
});
|
|
249
239
|
this.client = client;
|
|
250
240
|
await client.connect();
|
|
251
|
-
|
|
241
|
+
// The executable can be replaced between the check and the spawn (Codex updates itself).
|
|
242
|
+
requireVerifiedCodexServer(contract, client.serverVersion);
|
|
252
243
|
const login: unknown = await client.request('account/login/start', {
|
|
253
244
|
type: 'chatgptAuthTokens', accessToken: initial.accessToken, chatgptAccountId: initial.accountId,
|
|
254
245
|
}, 5_000);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { AuthSwitchRefusal } from './authority-contract.js';
|
|
3
|
+
import { authSwitchAuthoritySocketPaths, authSwitchAuthorityUnitDirectory,
|
|
4
|
+
authSwitchAuthorityUnitName } from './authority-paths.js';
|
|
5
|
+
import { AuthSwitchClient } from './classes.authorityclient.js';
|
|
6
|
+
import { StashStore } from './classes.stashstore.js';
|
|
7
|
+
import { legacyHash, legacySourceId,
|
|
8
|
+
resolveLegacySourceLocations } from './ts_migration/legacysources/shared.js';
|
|
9
|
+
import type { TAuthSwitchMutation } from './mutation.js';
|
|
10
|
+
import type { IAuthHarness } from './interfaces.harness.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The gate between the legacy credential commands and an account the authority has taken over.
|
|
14
|
+
*
|
|
15
|
+
* An import is a transfer of ownership, not a copy: the moment the authority refreshes an adopted login,
|
|
16
|
+
* the legacy saved copy holds a refresh token the provider has already rotated away. Writing that copy back
|
|
17
|
+
* into a native store -- which is what the legacy `save` and `switch` do -- would hand a tool a credential
|
|
18
|
+
* that is dead, and the owner would learn it from the tool rather than from here. This is the one place
|
|
19
|
+
* that stops it, and it stops it for the hosted service too, because `runAuthSwitchMutation` is the
|
|
20
|
+
* chokepoint both the command line and `AuthSwitchService` pass through.
|
|
21
|
+
*
|
|
22
|
+
* An authority that answers decides from its migration ledger alone. The ledger names a source by the hash
|
|
23
|
+
* of its own path, which is exactly what the legacy side can compute for the record it is about to touch, so
|
|
24
|
+
* no account identity has to be mapped between the two worlds: the legacy account id names a file, the file
|
|
25
|
+
* names a row, and the row says whether the authority took it. One that does not answer is decided by
|
|
26
|
+
* whether its service unit is installed (`authorityUnitInstalled`).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Where a fence looks: this host's legacy stores and this host's authority, both stated by the caller. */
|
|
30
|
+
export interface IAuthSwitchLegacyFenceLocations {
|
|
31
|
+
env: NodeJS.ProcessEnv;
|
|
32
|
+
homeDirectory: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What a caller states, exactly as a daemon states where it reads the legacy stores.
|
|
37
|
+
*
|
|
38
|
+
* `'none'` is for a caller that is not this host's own credential tooling -- a fixture, or a harness test
|
|
39
|
+
* that owns no authority -- and it is stated rather than defaulted, because a fence that silently pointed
|
|
40
|
+
* at `process.env` from inside a test would read whichever host ran it.
|
|
41
|
+
*/
|
|
42
|
+
export type TAuthSwitchLegacyFence = IAuthSwitchLegacyFenceLocations | 'none';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* This host's own locations, for a caller whose harnesses are this host's own.
|
|
46
|
+
*
|
|
47
|
+
* `AuthSwitchCli` and `AuthSwitchService` use it when they compose their harnesses from this process's
|
|
48
|
+
* environment, and a host that composes them the same way -- AGL -- states it explicitly. A fence that named
|
|
49
|
+
* anything else would be fencing other stores than the ones those harnesses write. Everything below them --
|
|
50
|
+
* `AuthSwitchOperations`, `runAuthSwitchMutation` -- takes it as an argument, so no library path can pick
|
|
51
|
+
* up a host it was never given.
|
|
52
|
+
*/
|
|
53
|
+
export const authSwitchHostLegacyFence = (): IAuthSwitchLegacyFenceLocations =>
|
|
54
|
+
({ env: process.env, homeDirectory: plugins.os.homedir() });
|
|
55
|
+
|
|
56
|
+
const fenceRequired = 'An authswitch account mutation requires stated legacy fence locations: pass an env '
|
|
57
|
+
+ 'and an absolute homeDirectory, or "none" for a caller that owns no authority.';
|
|
58
|
+
|
|
59
|
+
/** The same check for a caller with types and one without. */
|
|
60
|
+
export function assertStatedLegacyFence(value: unknown): asserts value is TAuthSwitchLegacyFence {
|
|
61
|
+
if (value === 'none') return;
|
|
62
|
+
if (value === null || typeof value !== 'object'
|
|
63
|
+
|| typeof (value as { env?: unknown }).env !== 'object' || (value as { env?: unknown }).env === null
|
|
64
|
+
|| typeof (value as { homeDirectory?: unknown }).homeDirectory !== 'string'
|
|
65
|
+
|| !plugins.path.isAbsolute((value as { homeDirectory: string }).homeDirectory)) {
|
|
66
|
+
throw new Error(fenceRequired);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What the owner is told. Neither names a path, an id or a host; each names the command that helps.
|
|
72
|
+
*
|
|
73
|
+
* There is no authority command that activates a login in a native store, and that is the point: a login
|
|
74
|
+
* the authority holds is used by binding a runtime to it, which is what AGL does, so the instruction names
|
|
75
|
+
* the read that shows it rather than inventing a replacement for `switch`.
|
|
76
|
+
*/
|
|
77
|
+
const authorityHoldsLogin = 'The account authority holds this login now: its legacy saved copy is stale, '
|
|
78
|
+
+ 'so the legacy save and switch no longer apply to it. "authswitch account list" shows the account; a '
|
|
79
|
+
+ 'runtime uses it by binding to it through the authority.';
|
|
80
|
+
const authorityUnavailable = 'This host has an account authority and it is not answering, so whether it '
|
|
81
|
+
+ 'holds this login cannot be known. Start it with "authswitch authority service start" and run this '
|
|
82
|
+
+ 'again.';
|
|
83
|
+
|
|
84
|
+
/** A settled row is one whose source was taken over. Both statuses mean the legacy copy is finished. */
|
|
85
|
+
const settled = new Set(['verified', 'complete']);
|
|
86
|
+
|
|
87
|
+
/** How long the gate may wait on the authority before a mutation is refused rather than delayed. */
|
|
88
|
+
const ASK_TIMEOUT_MS = 4_000;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The legacy store paths one mutation would read or write, as the migration readers name them.
|
|
92
|
+
*
|
|
93
|
+
* Codex keeps a stash directory per account, Claude and OpenCode a record file per account, and the reader
|
|
94
|
+
* of each derives its ledger row from that exact path (`ts_migration/legacysources/authswitchstores.ts`).
|
|
95
|
+
* Only the account the mutation names is looked up: a switch also re-saves the outgoing login, but writing
|
|
96
|
+
* a record rotates nothing, while activating one is the write this fence exists for.
|
|
97
|
+
*/
|
|
98
|
+
const mutationSourcePaths = (root: string, harnessId: string, accountId: string): string[] => {
|
|
99
|
+
if (harnessId === 'codex') {
|
|
100
|
+
try { return [new StashStore('codex', root).entryDir(accountId)]; }
|
|
101
|
+
// An account id no stash name can be derived from names no stash, and therefore no ledger row.
|
|
102
|
+
catch { return []; }
|
|
103
|
+
}
|
|
104
|
+
if (harnessId === 'claude' || harnessId === 'opencode') {
|
|
105
|
+
return /^[a-f0-9]{64}$/.test(accountId)
|
|
106
|
+
? [plugins.path.join(root, harnessId, `${accountId}.json`)] : [];
|
|
107
|
+
}
|
|
108
|
+
// A harness this package never wrote a legacy store for has no legacy copy to strand.
|
|
109
|
+
return [];
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Which account a mutation touches. A save without one saves whatever login is active right now. */
|
|
113
|
+
const mutationAccountId = async (harness: IAuthHarness,
|
|
114
|
+
mutation: TAuthSwitchMutation): Promise<string | null> => {
|
|
115
|
+
if (mutation.accountId !== undefined) return mutation.accountId;
|
|
116
|
+
if (mutation.action !== 'save') return null;
|
|
117
|
+
const state = await harness.readState();
|
|
118
|
+
return state.accounts.find(account => account.isActive)?.id ?? null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** What the legacy source a mutation names looks like to an authority that answers. */
|
|
122
|
+
type TLedgerAnswer = 'settled' | 'not_settled' | 'no_answer';
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One bounded, paged read of the ledger over the management socket: credential-free, and it reaches no
|
|
126
|
+
* legacy store. Anything but a complete answer -- no runtime directory, no socket, a socket that never
|
|
127
|
+
* answers within the bound, a daemon that serves no import status -- is `no_answer`, never "nothing held".
|
|
128
|
+
*/
|
|
129
|
+
const askLedger = async (env: NodeJS.ProcessEnv, sourceIds: ReadonlySet<string>): Promise<TLedgerAnswer> => {
|
|
130
|
+
// Only the stated environment names a socket, and nothing else decides which one: the full path resolver
|
|
131
|
+
// defaults an absent directory to this process's own and reads this process's home and XDG_DATA_HOME for
|
|
132
|
+
// the store, so a value there would silence an authority that answers.
|
|
133
|
+
if (!env.XDG_RUNTIME_DIR) return 'no_answer';
|
|
134
|
+
let client: AuthSwitchClient;
|
|
135
|
+
try {
|
|
136
|
+
const paths = authSwitchAuthoritySocketPaths(env.XDG_RUNTIME_DIR);
|
|
137
|
+
client = new AuthSwitchClient(paths.authoritySocketPath, paths.runtimeSocketPath);
|
|
138
|
+
} catch { return 'no_answer'; }
|
|
139
|
+
const deadline = AbortSignal.timeout(ASK_TIMEOUT_MS);
|
|
140
|
+
let after: string | undefined;
|
|
141
|
+
try {
|
|
142
|
+
while (true) {
|
|
143
|
+
const page = await client.importStatus({ after, limit: 128 }, deadline);
|
|
144
|
+
if (page.entries.some(entry => sourceIds.has(entry.sourceId) && settled.has(entry.status))) return 'settled';
|
|
145
|
+
if (!page.nextCursor) return 'not_settled';
|
|
146
|
+
after = page.nextCursor;
|
|
147
|
+
}
|
|
148
|
+
} catch { return 'no_answer'; }
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const unitUnreadable = 'The authswitch authority service unit could not be inspected, so whether an account '
|
|
152
|
+
+ 'authority is installed here cannot be known; the legacy save and switch refuse until it can.';
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Whether this user has the authority's service unit installed: the durable evidence that an authority
|
|
156
|
+
* exists here even while it is not running.
|
|
157
|
+
*
|
|
158
|
+
* The unit is what `authswitch authority service install` writes, in the directory
|
|
159
|
+
* `authSwitchAuthorityUnitDirectory` derives from the caller's stated locations, and it survives a reboot --
|
|
160
|
+
* the management socket does not: it lives on tmpfs and is absent after every boot until the service
|
|
161
|
+
* starts. It is read through smartdaemon's own `SystemdUnitFile.inspect()`, which checks the exact file the
|
|
162
|
+
* installer writes and reads nothing else; its content is a service definition, never a credential.
|
|
163
|
+
*
|
|
164
|
+
* The read needs no login session. `inspect()` reads only the unit directory, so the fence hands smartdaemon
|
|
165
|
+
* no runtime directory: the stated one belongs to the ledger ask above, and passing it here would let a value
|
|
166
|
+
* this read never uses refuse the command. The answer therefore depends on the stated unit directory alone,
|
|
167
|
+
* and a cron job or a plain ssh command -- no `XDG_RUNTIME_DIR` at all -- is decided exactly like a session.
|
|
168
|
+
*
|
|
169
|
+
* smartdaemon agrees: with an explicit unit directory it validates only that directory at construction, and
|
|
170
|
+
* `inspect()` never reads this process's `XDG_RUNTIME_DIR`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME` or home, so
|
|
171
|
+
* none of them can change the answer.
|
|
172
|
+
*
|
|
173
|
+
* systemd is the only installer this package has, so on another platform no unit can be installed. Any
|
|
174
|
+
* failure to inspect it -- an unsafe or changed file, a directory another user can write -- is not an answer,
|
|
175
|
+
* and refuses. So does a stated unit directory that cannot be derived or that smartdaemon refuses to
|
|
176
|
+
* construct the read from (a relative or malformed stated `XDG_DATA_HOME`): the stated locations then name no
|
|
177
|
+
* directory to look in, an installer running with well-formed locations may have written the unit elsewhere,
|
|
178
|
+
* and reading that as "no authority here" would be a guess.
|
|
179
|
+
*/
|
|
180
|
+
const authorityUnitInstalled = async (fence: IAuthSwitchLegacyFenceLocations): Promise<boolean> => {
|
|
181
|
+
if (process.platform !== 'linux') return false;
|
|
182
|
+
// One catch covers derivation, construction and inspection: each failure leaves the same question open.
|
|
183
|
+
try {
|
|
184
|
+
const unitFile = new plugins.smartdaemon.SystemdUnitFile({ unitName: authSwitchAuthorityUnitName,
|
|
185
|
+
scope: 'user', unitDirectory: authSwitchAuthorityUnitDirectory(fence.env, fence.homeDirectory) });
|
|
186
|
+
return await unitFile.inspect() !== null;
|
|
187
|
+
} catch { throw new Error(unitUnreadable); }
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The gate itself: allow, or refuse with the reason.
|
|
192
|
+
*
|
|
193
|
+
* An authority that answers decides, whether or not its unit is installed: its ledger is the only record
|
|
194
|
+
* of what it took over. One that does not answer is unknown -- and unknown is never idle -- but only where
|
|
195
|
+
* an authority is installed at all; a host without the unit is known to hold nothing, and it is every host
|
|
196
|
+
* before the cutover. The evidence cannot tell which stores an installed authority imports from, so while it
|
|
197
|
+
* is stopped it refuses the legacy save and switch of every store, until it runs again.
|
|
198
|
+
*/
|
|
199
|
+
export const assertAuthSwitchMutationAllowed = async (fence: TAuthSwitchLegacyFence,
|
|
200
|
+
harness: IAuthHarness, mutation: TAuthSwitchMutation): Promise<void> => {
|
|
201
|
+
assertStatedLegacyFence(fence);
|
|
202
|
+
if (fence === 'none') return;
|
|
203
|
+
// A remove deletes a saved copy and writes no credential, which is why the legacy `drop` command is
|
|
204
|
+
// deliberately unfenced as well: for an adopted account that copy already holds a rotated-away token.
|
|
205
|
+
if (mutation.action !== 'save' && mutation.action !== 'switch') return;
|
|
206
|
+
|
|
207
|
+
const root = resolveLegacySourceLocations(fence.env, fence.homeDirectory).authSwitchHome;
|
|
208
|
+
const accountId = await mutationAccountId(harness, mutation);
|
|
209
|
+
const paths = accountId === null ? [] : mutationSourcePaths(root, harness.id, accountId);
|
|
210
|
+
// A mutation that names no legacy record the importer reads can never have been imported, so there is
|
|
211
|
+
// nothing to ask about, and the answer must not depend on whether an authority is up.
|
|
212
|
+
if (paths.length === 0) return;
|
|
213
|
+
|
|
214
|
+
const answer = await askLedger(fence.env,
|
|
215
|
+
new Set(paths.map(path => legacySourceId('authswitch_stash', legacyHash(path)))));
|
|
216
|
+
if (answer === 'settled') throw new AuthSwitchRefusal('authority_holds_login', authorityHoldsLogin);
|
|
217
|
+
if (answer === 'not_settled') return;
|
|
218
|
+
if (await authorityUnitInstalled(fence)) throw new AuthSwitchRefusal('authority_unavailable', authorityUnavailable);
|
|
219
|
+
};
|