@modelprofile.com/authswitch 8.0.0 → 8.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-import-contract.d.ts +207 -0
- package/dist_ts/authority-import-contract.js +18 -0
- package/dist_ts/classes.authoritycli.d.ts +4 -0
- package/dist_ts/classes.authoritycli.js +148 -11
- package/dist_ts/classes.authorityclient.d.ts +7 -0
- package/dist_ts/classes.authorityclient.js +13 -1
- package/dist_ts/classes.authoritydaemon.d.ts +4 -0
- package/dist_ts/classes.authoritydaemon.js +72 -1
- package/dist_ts/classes.authoritydatabase.d.ts +5 -0
- package/dist_ts/classes.authoritydatabase.js +20 -5
- package/dist_ts/classes.authorityimport.d.ts +161 -0
- package/dist_ts/classes.authorityimport.js +638 -0
- package/dist_ts/classes.authoritymodels.d.ts +12 -0
- package/dist_ts/classes.authoritymodels.js +17 -1
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +6 -3
- package/dist_ts/ts_migration/legacysources/authswitchstores.d.ts +2 -0
- package/dist_ts/ts_migration/legacysources/authswitchstores.js +244 -0
- package/dist_ts/ts_migration/legacysources/index.d.ts +18 -0
- package/dist_ts/ts_migration/legacysources/index.js +24 -0
- package/dist_ts/ts_migration/legacysources/material.d.ts +37 -0
- package/dist_ts/ts_migration/legacysources/material.js +120 -0
- package/dist_ts/ts_migration/legacysources/nativestores.d.ts +2 -0
- package/dist_ts/ts_migration/legacysources/nativestores.js +207 -0
- package/dist_ts/ts_migration/legacysources/shared.d.ts +124 -0
- package/dist_ts/ts_migration/legacysources/shared.js +142 -0
- package/package.json +5 -1
- package/readme.md +46 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-import-contract.ts +216 -0
- package/ts/classes.authoritycli.ts +164 -10
- package/ts/classes.authorityclient.ts +22 -0
- package/ts/classes.authoritydaemon.ts +72 -0
- package/ts/classes.authoritydatabase.ts +20 -4
- package/ts/classes.authorityimport.ts +718 -0
- package/ts/classes.authoritymodels.ts +21 -0
- package/ts/index.ts +5 -2
- package/ts/ts_migration/legacysources/authswitchstores.ts +211 -0
- package/ts/ts_migration/legacysources/index.ts +35 -0
- package/ts/ts_migration/legacysources/material.ts +134 -0
- package/ts/ts_migration/legacysources/nativestores.ts +204 -0
- package/ts/ts_migration/legacysources/shared.ts +220 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { ITypedRequest } from '@api.global/typedrequest-interfaces';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one-time account import: reading the legacy stores that existed before the authority, and the
|
|
5
|
+
* credential-free report an owner reads before anything is imported.
|
|
6
|
+
*
|
|
7
|
+
* Importing a refresh token is a transfer of ownership, not a copy. The provider rotates a refresh token on
|
|
8
|
+
* use, so the moment one holder refreshes, every other copy of that token is dead. This contract therefore
|
|
9
|
+
* separates the classes of legacy source by who refreshes them after the import, and names the proof each
|
|
10
|
+
* class can offer without rotating anything.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Mirrors the migration ledger's source kinds, which are the only sources a ledger row can describe. */
|
|
14
|
+
export type TAuthSwitchImportSourceKind = 'authswitch_stash' | 'authswitch_backup' | 'agl_flex'
|
|
15
|
+
| 'codex_native' | 'opencode_native' | 'claude_native';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The ledger statuses a source can already hold when an inventory runs.
|
|
19
|
+
*
|
|
20
|
+
* - `prepared`: the source is named in the authority and nothing has been sent for it yet.
|
|
21
|
+
* - `pending_native_owner`: the account is registered and its native tool keeps the refresher, but the
|
|
22
|
+
* store could not be proven live. Nothing rotating was ever sent, so it is proven by submitting the
|
|
23
|
+
* source again once that tool has refreshed its own store.
|
|
24
|
+
* - `verified` and `complete`: the import settled; `complete` is the tombstone for that source.
|
|
25
|
+
* - `quarantined`: a rotating refresh may already have been consumed for this source, so it is never
|
|
26
|
+
* submitted again and its account is repaired by a device sign-in.
|
|
27
|
+
*/
|
|
28
|
+
export type TAuthSwitchImportLedgerStatus = 'pending_native_owner' | 'prepared' | 'verified' | 'complete'
|
|
29
|
+
| 'quarantined';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What the ownership rule allows for a source.
|
|
33
|
+
*
|
|
34
|
+
* - `import`: the authority becomes the single refresher. Nothing else owns this copy, so taking it over
|
|
35
|
+
* strands no other holder.
|
|
36
|
+
* - `project`: a native tool keeps refreshing its own store; the authority registers the account and holds
|
|
37
|
+
* the grant as a projection it must never refresh. Two refreshers on one refresh token is the failure this
|
|
38
|
+
* avoids.
|
|
39
|
+
* - `refuse`: the source cannot become an authority grant at all, and the owner signs in again instead.
|
|
40
|
+
*/
|
|
41
|
+
export type TAuthSwitchImportTreatment = 'import' | 'project' | 'refuse';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What can make this source's ledger row `verified`.
|
|
45
|
+
*
|
|
46
|
+
* - `rotating_refresh`: one real refresh through the authority. It proves the refresh token was live and
|
|
47
|
+
* makes the authority its holder in the same step -- and it is the point of no return, because the legacy
|
|
48
|
+
* copy is stale from that moment.
|
|
49
|
+
* - `access_token_read`: a provider read with the stored access token alone, matched against the identity
|
|
50
|
+
* derived locally from the same source bytes. It rotates nothing, so a native refresher keeps its grant.
|
|
51
|
+
* It is available only while that access token is still valid.
|
|
52
|
+
* - `none`: no proof exists for this source; it cannot be verified and cannot be imported.
|
|
53
|
+
*/
|
|
54
|
+
export type TAuthSwitchImportProof = 'rotating_refresh' | 'access_token_read' | 'none';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One legacy source as the inventory found it. Credential-free by construction: identity claims and a digest
|
|
58
|
+
* of the source bytes, never a token, a token digest, or the source path.
|
|
59
|
+
*/
|
|
60
|
+
export interface IAuthSwitchImportSource {
|
|
61
|
+
/** The migration ledger row this source would use. Derived from its kind and path, so it is stable. */
|
|
62
|
+
id: string;
|
|
63
|
+
sourceKind: TAuthSwitchImportSourceKind;
|
|
64
|
+
/** The source location as a hash. The ledger never stores a path and this DTO never exposes one. */
|
|
65
|
+
sourcePathHash: string;
|
|
66
|
+
/** Binds a later import to exactly these bytes. Changes whenever the source changes. */
|
|
67
|
+
sourceDigest: string;
|
|
68
|
+
treatment: TAuthSwitchImportTreatment;
|
|
69
|
+
proof: TAuthSwitchImportProof;
|
|
70
|
+
/** The authority account this source resolves to, derived locally from its identity claims. */
|
|
71
|
+
accountId: string | null;
|
|
72
|
+
providerId: string | null;
|
|
73
|
+
email: string | null;
|
|
74
|
+
plan: string | null;
|
|
75
|
+
label: string | null;
|
|
76
|
+
/** Expiry claim of the stored access token. It decides whether a non-rotating proof is available now. */
|
|
77
|
+
accessExpiresAt: string | null;
|
|
78
|
+
/** Remote-control enrollments carried by this source; `null` when they could not be counted. */
|
|
79
|
+
enrollmentCount: number | null;
|
|
80
|
+
/** The ledger row for this source, when one already exists. */
|
|
81
|
+
ledgerStatus: TAuthSwitchImportLedgerStatus | null;
|
|
82
|
+
/** Why this source cannot be submitted as it stands. An empty list means it can. */
|
|
83
|
+
problems: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The complete credential-free report. `notice` is what the owner must know before importing anything. */
|
|
87
|
+
export interface IAuthSwitchImportInventory {
|
|
88
|
+
generatedAt: string;
|
|
89
|
+
/** Identifies the credential locations this inventory read, as `authSwitchEnvironmentId` computes them. */
|
|
90
|
+
environmentId: string;
|
|
91
|
+
notice: string[];
|
|
92
|
+
sources: IAuthSwitchImportSource[];
|
|
93
|
+
/** Locations that could not be read at all, so an empty result never looks like an empty store. */
|
|
94
|
+
problems: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Management-only. Reads legacy stores and the ledger; changes nothing, and carries no credential. */
|
|
98
|
+
export interface IReq_AuthSwitchImportInventory extends ITypedRequest {
|
|
99
|
+
method: 'authswitch.authority.import.inventory';
|
|
100
|
+
request: Record<string, never>;
|
|
101
|
+
response: { inventory: IAuthSwitchImportInventory };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A ChatGPT login held by a store outside this package, submitted by the backend that owns its format. */
|
|
105
|
+
export interface IAuthSwitchImportOpenAiCredential {
|
|
106
|
+
providerId: 'openai';
|
|
107
|
+
accessToken: string;
|
|
108
|
+
refreshToken: string;
|
|
109
|
+
idToken: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What is being imported.
|
|
114
|
+
*
|
|
115
|
+
* A source this daemon can read itself is named only by its id: the daemon re-runs its readers, resolves the
|
|
116
|
+
* location from the hash and re-checks the digest, so no credential crosses the socket and no caller can
|
|
117
|
+
* substitute bytes the owner never approved. Only a store this package cannot read -- AGL's sealed Flex
|
|
118
|
+
* records -- submits material, and that is the whole reason submit lives on the backend-only runtime socket.
|
|
119
|
+
*/
|
|
120
|
+
export type TAuthSwitchImportSubmission =
|
|
121
|
+
| { kind: 'local'; sourceId: string; sourceDigest: string }
|
|
122
|
+
| { kind: 'external'; sourceKind: 'agl_flex'; sourcePathHash: string; sourceDigest: string;
|
|
123
|
+
credential: IAuthSwitchImportOpenAiCredential };
|
|
124
|
+
|
|
125
|
+
/** The outcome of one source. `quarantined` always names what to do instead, never a silent failure. */
|
|
126
|
+
export interface IAuthSwitchImportResult {
|
|
127
|
+
sourceId: string;
|
|
128
|
+
sourceKind: TAuthSwitchImportSourceKind;
|
|
129
|
+
treatment: TAuthSwitchImportTreatment;
|
|
130
|
+
status: TAuthSwitchImportLedgerStatus;
|
|
131
|
+
accountId: string | null;
|
|
132
|
+
loginId: string | null;
|
|
133
|
+
problems: string[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What the owner does next with one source.
|
|
138
|
+
*
|
|
139
|
+
* - `none`: the import settled; nothing is outstanding.
|
|
140
|
+
* - `resume`: submit the same source again. Nothing rotating has been sent for this source, so a second
|
|
141
|
+
* attempt is safe: an interrupted adopt continues from its durable record, and a native store that could
|
|
142
|
+
* not be proven live is proven the moment its own tool has refreshed it.
|
|
143
|
+
* - `device_login`: this source is finished as a source. Its account needs a fresh device sign-in, because a
|
|
144
|
+
* rotating refresh token whose outcome is unknown is never sent a second time.
|
|
145
|
+
*/
|
|
146
|
+
export type TAuthSwitchImportAction = 'none' | 'resume' | 'device_login';
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Marks an error answer whose text is an instruction for the owner, not a report of a fault.
|
|
150
|
+
*
|
|
151
|
+
* The daemon sets it on the refusals the importer authors and on nothing else. Every other failure answers
|
|
152
|
+
* with the transport's own sanitised text, which a caller must never present as if it said what to do next --
|
|
153
|
+
* for a submit in particular, an unmarked failure means the outcome is unknown and the source must be read
|
|
154
|
+
* with `authswitch.authority.import.status` rather than submitted again.
|
|
155
|
+
*/
|
|
156
|
+
export const authSwitchImportRefusalReason = 'authswitch_import_refusal';
|
|
157
|
+
|
|
158
|
+
/** True when the daemon answered with an importer refusal, so `error.message` is the instruction to show. */
|
|
159
|
+
export const isAuthSwitchImportRefusal = (error: unknown): boolean => {
|
|
160
|
+
if (typeof error !== 'object' || error === null || !('errorData' in error)) return false;
|
|
161
|
+
const data = error.errorData;
|
|
162
|
+
return typeof data === 'object' && data !== null && 'reason' in data
|
|
163
|
+
&& data.reason === authSwitchImportRefusalReason;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Where one source stands, read from the migration ledger, the grant it produced and its handoff.
|
|
168
|
+
*
|
|
169
|
+
* Only sources the authority has been told about appear here. A source that was never submitted has no
|
|
170
|
+
* ledger row; `authority import inventory` is the read that looks at the host and lists those.
|
|
171
|
+
*/
|
|
172
|
+
export interface IAuthSwitchImportStatusEntry {
|
|
173
|
+
sourceId: string;
|
|
174
|
+
sourceKind: TAuthSwitchImportSourceKind;
|
|
175
|
+
status: TAuthSwitchImportLedgerStatus;
|
|
176
|
+
accountId: string | null;
|
|
177
|
+
loginId: string | null;
|
|
178
|
+
/**
|
|
179
|
+
* Who refreshes that login now, or `null` when the ledger row names no grant yet. `none` is the absence of
|
|
180
|
+
* a refresher: the grant needs a device sign-in before anything can use it again.
|
|
181
|
+
*/
|
|
182
|
+
owner: 'daemon' | 'claude_native' | 'legacy_native' | 'none' | null;
|
|
183
|
+
action: TAuthSwitchImportAction;
|
|
184
|
+
statusObservedAt: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface IAuthSwitchImportStatus {
|
|
188
|
+
generatedAt: string;
|
|
189
|
+
entries: IAuthSwitchImportStatusEntry[];
|
|
190
|
+
nextCursor: string | null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Management-only. Reads durable records only; it opens no legacy store and carries no credential. */
|
|
194
|
+
export interface IReq_AuthSwitchImportStatus extends ITypedRequest {
|
|
195
|
+
method: 'authswitch.authority.import.status';
|
|
196
|
+
request: { after?: string; limit?: number };
|
|
197
|
+
response: { status: IAuthSwitchImportStatus };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Backend-only. One source, one ownership decision.
|
|
202
|
+
*
|
|
203
|
+
* Both flags are required and both must be `true`. `callerQuiescent` is the caller's own assertion, because
|
|
204
|
+
* this daemon can observe a watch, a native process and Codex's app-server but cannot observe a host that
|
|
205
|
+
* uses the legacy library in its own process. `acknowledgeRunOrder` is the owner's acknowledgement that an
|
|
206
|
+
* imported source's legacy copy is dead and that the everyday commands must already run on the authority.
|
|
207
|
+
*/
|
|
208
|
+
export interface IReq_AuthSwitchImportSubmit extends ITypedRequest {
|
|
209
|
+
method: 'authswitch.authority.import.submit';
|
|
210
|
+
request: {
|
|
211
|
+
submission: TAuthSwitchImportSubmission;
|
|
212
|
+
callerQuiescent: true;
|
|
213
|
+
acknowledgeRunOrder: true;
|
|
214
|
+
};
|
|
215
|
+
response: { result: IAuthSwitchImportResult };
|
|
216
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { IAuthSwitchDoctorPage, IAuthSwitchOperation, IAuthSwitchPreuseOperation,
|
|
2
2
|
IAuthSwitchSnapshot, IReq_AuthSwitchDoctor, IReq_AuthSwitchStartPreuse } from './authority-contract.js';
|
|
3
|
+
import { isAuthSwitchImportRefusal } from './authority-import-contract.js';
|
|
4
|
+
import type { IAuthSwitchImportInventory, IAuthSwitchImportResult, IAuthSwitchImportStatus,
|
|
5
|
+
IReq_AuthSwitchImportStatus, TAuthSwitchImportAction } from './authority-import-contract.js';
|
|
3
6
|
import { AuthSwitchClient } from './classes.authorityclient.js';
|
|
4
7
|
import { resolveAuthSwitchAuthorityPaths } from './classes.authorityservice.js';
|
|
5
8
|
import { defaultPreusePrompt, validatePreuseOptions } from './preuse.js';
|
|
@@ -9,6 +12,9 @@ import * as plugins from './plugins.js';
|
|
|
9
12
|
export interface IAuthSwitchAuthorityCliClient {
|
|
10
13
|
snapshotAll(): Promise<IAuthSwitchSnapshot>;
|
|
11
14
|
doctorPage(request: IReq_AuthSwitchDoctor['request']): Promise<IAuthSwitchDoctorPage>;
|
|
15
|
+
importInventory(): Promise<IAuthSwitchImportInventory>;
|
|
16
|
+
importStatus(request: IReq_AuthSwitchImportStatus['request']): Promise<IAuthSwitchImportStatus>;
|
|
17
|
+
submitImport(sourceId: string, sourceDigest: string): Promise<IAuthSwitchImportResult>;
|
|
12
18
|
beginAddOpenAi(operationId: string): Promise<IAuthSwitchOperation>;
|
|
13
19
|
beginReauthOpenAi(operationId: string, accountId: string, loginId: string,
|
|
14
20
|
purpose: 'openai_managed'): Promise<IAuthSwitchOperation>;
|
|
@@ -33,6 +39,9 @@ export interface IAuthSwitchAuthorityCliOptions {
|
|
|
33
39
|
type TAuthorityCliCommand =
|
|
34
40
|
| { kind: 'list'; json: boolean }
|
|
35
41
|
| { kind: 'doctor'; json: boolean }
|
|
42
|
+
| { kind: 'importInventory'; json: boolean }
|
|
43
|
+
| { kind: 'importStatus'; json: boolean }
|
|
44
|
+
| { kind: 'importSubmit'; sourceId: string; sourceDigest: string; json: boolean }
|
|
36
45
|
| { kind: 'add'; json: boolean }
|
|
37
46
|
| { kind: 'reauth'; accountId: string; loginId: string; json: boolean }
|
|
38
47
|
| { kind: 'get'; operationId: string; json: boolean }
|
|
@@ -51,9 +60,11 @@ class AuthorityCliFailure extends Error {
|
|
|
51
60
|
}
|
|
52
61
|
}
|
|
53
62
|
|
|
54
|
-
const usage = 'Usage: authswitch account list [--json] | authswitch account add openai [--json] | authswitch account reauth <account-id> <login-id> [--json] | authswitch account operation get|cancel|resume <operation-id> [--json] | authswitch account preuse <account-id> <login-id>|--all [--prompt <text>] [--model <id>] [--json] | authswitch account preuse operation get|cancel|resume <operation-id> [--json] | authswitch authority doctor [--json]';
|
|
63
|
+
const usage = 'Usage: authswitch account list [--json] | authswitch account add openai [--json] | authswitch account reauth <account-id> <login-id> [--json] | authswitch account operation get|cancel|resume <operation-id> [--json] | authswitch account preuse <account-id> <login-id>|--all [--prompt <text>] [--model <id>] [--json] | authswitch account preuse operation get|cancel|resume <operation-id> [--json] | authswitch authority doctor [--json] | authswitch authority import inventory [--json] | authswitch authority import status [--json] | authswitch authority import submit <source-id> --digest <source-digest> [--json]';
|
|
55
64
|
const safe = (value: string | number | null): string => plainText(value === null ? 'none' : String(value));
|
|
56
65
|
const isId = (value: string | undefined): value is string => Boolean(value && !value.startsWith('-'));
|
|
66
|
+
const isHash = (value: string | undefined): value is string =>
|
|
67
|
+
typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
57
68
|
|
|
58
69
|
const parsePreuse = (argv: readonly string[]): TAuthorityCliCommand | null => {
|
|
59
70
|
if (argv[2] === 'operation') {
|
|
@@ -109,6 +120,21 @@ const parse = (argv: readonly string[]): TAuthorityCliCommand | null => {
|
|
|
109
120
|
&& (argv.length === 2 || (argv.length === 3 && argv[2] === '--json'))) {
|
|
110
121
|
return { kind: 'doctor', json: argv[2] === '--json' };
|
|
111
122
|
}
|
|
123
|
+
if (argv[0] === 'authority' && argv[1] === 'import' && argv[2] === 'inventory'
|
|
124
|
+
&& (argv.length === 3 || (argv.length === 4 && argv[3] === '--json'))) {
|
|
125
|
+
return { kind: 'importInventory', json: argv[3] === '--json' };
|
|
126
|
+
}
|
|
127
|
+
if (argv[0] === 'authority' && argv[1] === 'import' && argv[2] === 'status'
|
|
128
|
+
&& (argv.length === 3 || (argv.length === 4 && argv[3] === '--json'))) {
|
|
129
|
+
return { kind: 'importStatus', json: argv[3] === '--json' };
|
|
130
|
+
}
|
|
131
|
+
if (argv[0] === 'authority' && argv[1] === 'import' && argv[2] === 'submit') {
|
|
132
|
+
// The digest the owner read in the inventory is part of the command: the daemon refuses a source whose
|
|
133
|
+
// bytes have changed since, so approving one source can never import a different one.
|
|
134
|
+
if (!isHash(argv[3]) || argv[4] !== '--digest' || !isHash(argv[5])
|
|
135
|
+
|| (argv.length !== 6 && (argv.length !== 7 || argv[6] !== '--json'))) return null;
|
|
136
|
+
return { kind: 'importSubmit', sourceId: argv[3], sourceDigest: argv[5], json: argv[6] === '--json' };
|
|
137
|
+
}
|
|
112
138
|
if (argv[0] === 'account' && argv[1] === 'add' && argv[2] === 'openai'
|
|
113
139
|
&& (argv.length === 3 || (argv.length === 4 && argv[3] === '--json'))) {
|
|
114
140
|
return { kind: 'add', json: argv[3] === '--json' };
|
|
@@ -143,6 +169,18 @@ class ScopedAuthorityCliClient implements IAuthSwitchAuthorityCliClient {
|
|
|
143
169
|
return this.client.doctorPage(request, this.controller.signal);
|
|
144
170
|
}
|
|
145
171
|
|
|
172
|
+
public importInventory(): Promise<IAuthSwitchImportInventory> {
|
|
173
|
+
return this.client.importInventory(this.controller.signal);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
public importStatus(request: IReq_AuthSwitchImportStatus['request']): Promise<IAuthSwitchImportStatus> {
|
|
177
|
+
return this.client.importStatus(request, this.controller.signal);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
public submitImport(sourceId: string, sourceDigest: string): Promise<IAuthSwitchImportResult> {
|
|
181
|
+
return this.client.submitImport({ kind: 'local', sourceId, sourceDigest }, this.controller.signal);
|
|
182
|
+
}
|
|
183
|
+
|
|
146
184
|
public beginAddOpenAi(operationId: string): Promise<IAuthSwitchOperation> {
|
|
147
185
|
return this.client.beginAddOpenAi(operationId, this.controller.signal);
|
|
148
186
|
}
|
|
@@ -201,6 +239,91 @@ const renderList = (snapshot: IAuthSwitchSnapshot): string => {
|
|
|
201
239
|
return lines.join('\n') + '\n';
|
|
202
240
|
};
|
|
203
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The legacy-source report.
|
|
244
|
+
*
|
|
245
|
+
* The notice comes first and is never abbreviated: an import is a one-way transfer of ownership, and this is
|
|
246
|
+
* the only place an owner reads what that costs before approving it.
|
|
247
|
+
*/
|
|
248
|
+
const renderInventory = (inventory: IAuthSwitchImportInventory): string => {
|
|
249
|
+
const lines = [`Legacy account sources — generated ${safe(inventory.generatedAt)}, environment ${safe(inventory.environmentId)}`,
|
|
250
|
+
'Before you import:'];
|
|
251
|
+
for (const notice of inventory.notice) lines.push(` - ${safe(notice)}`);
|
|
252
|
+
lines.push('');
|
|
253
|
+
if (inventory.sources.length === 0) lines.push('No legacy credential source was found.');
|
|
254
|
+
for (const source of inventory.sources) {
|
|
255
|
+
lines.push(`${safe(source.label ?? source.email ?? source.sourceKind)} [${safe(source.providerId)}]`,
|
|
256
|
+
` source ${safe(source.id)}; kind ${safe(source.sourceKind)}; treatment ${safe(source.treatment)}; proof ${safe(source.proof)}`,
|
|
257
|
+
` account ${safe(source.accountId)}; plan ${safe(source.plan)}; access token expires ${safe(source.accessExpiresAt)}`,
|
|
258
|
+
` enrollments ${source.enrollmentCount === null ? 'unknown' : source.enrollmentCount}; ledger ${safe(source.ledgerStatus)}; digest ${safe(source.sourceDigest)}`);
|
|
259
|
+
for (const problem of source.problems) lines.push(` problem: ${safe(problem)}`);
|
|
260
|
+
}
|
|
261
|
+
if (inventory.problems.length) {
|
|
262
|
+
lines.push('', 'Locations that could not be read:');
|
|
263
|
+
for (const problem of inventory.problems) lines.push(` - ${safe(problem)}`);
|
|
264
|
+
}
|
|
265
|
+
return lines.join('\n') + '\n';
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* What the owner does next with a source, in words rather than a contract value. An action this version does
|
|
270
|
+
* not recognise is read as the careful one: never invite a second submission on a guess.
|
|
271
|
+
*/
|
|
272
|
+
const actionText = (action: TAuthSwitchImportAction): string =>
|
|
273
|
+
action === 'none' ? 'nothing to do'
|
|
274
|
+
: action === 'resume' ? 'submit this source again to continue where it stopped'
|
|
275
|
+
: 'sign in to this account again; this source cannot be imported a second time';
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Every page of the ledger, as one report.
|
|
279
|
+
*
|
|
280
|
+
* The owner acts on this read, so it must not stop at a page boundary and leave a quarantined source
|
|
281
|
+
* unmentioned. A cursor that does not advance is a broken answer rather than a reason to keep asking.
|
|
282
|
+
*/
|
|
283
|
+
const collectImportStatus = async (client: IAuthSwitchAuthorityCliClient): Promise<IAuthSwitchImportStatus> => {
|
|
284
|
+
const first = await client.importStatus({ limit: 128 });
|
|
285
|
+
const entries = [...first.entries];
|
|
286
|
+
let cursor = first.nextCursor;
|
|
287
|
+
while (cursor !== null) {
|
|
288
|
+
const page = await client.importStatus({ after: cursor, limit: 128 });
|
|
289
|
+
if (page.entries.length === 0 || page.nextCursor === cursor) throw new AuthorityCliFailure('response');
|
|
290
|
+
entries.push(...page.entries);
|
|
291
|
+
cursor = page.nextCursor;
|
|
292
|
+
}
|
|
293
|
+
return { ...first, entries, nextCursor: null };
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const renderImportStatus = (status: IAuthSwitchImportStatus): string => {
|
|
297
|
+
const lines = [`Import status — generated ${safe(status.generatedAt)}`];
|
|
298
|
+
if (status.entries.length === 0) {
|
|
299
|
+
lines.push('No source has been submitted yet. Run `authswitch authority import inventory` to see what this host holds.');
|
|
300
|
+
}
|
|
301
|
+
for (const entry of status.entries) {
|
|
302
|
+
lines.push(`${safe(entry.sourceKind)} ${safe(entry.sourceId)}`,
|
|
303
|
+
` state ${safe(entry.status)}; refresher ${safe(entry.owner)}; recorded ${safe(entry.statusObservedAt)}`,
|
|
304
|
+
` account ${safe(entry.accountId)}; login ${safe(entry.loginId)}`,
|
|
305
|
+
` next: ${actionText(entry.action)}`);
|
|
306
|
+
}
|
|
307
|
+
// A source that was never submitted has no ledger row, so it is the inventory's subject, not this read's.
|
|
308
|
+
lines.push('Sources that were never submitted are listed by `authswitch authority import inventory`.');
|
|
309
|
+
return lines.join('\n') + '\n';
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const renderImportResult = (result: IAuthSwitchImportResult): string => {
|
|
313
|
+
const settled = result.status === 'complete';
|
|
314
|
+
const lines = [`${settled ? 'Imported' : 'Import did not settle'}: ${safe(result.sourceKind)} ${safe(result.sourceId)}`,
|
|
315
|
+
` treatment ${safe(result.treatment)}; state ${safe(result.status)}`,
|
|
316
|
+
` account ${safe(result.accountId)}; login ${safe(result.loginId)}`];
|
|
317
|
+
for (const problem of result.problems) lines.push(` problem: ${safe(problem)}`);
|
|
318
|
+
if (settled && result.treatment === 'import') {
|
|
319
|
+
lines.push(' The authority now refreshes this login. Its legacy copy holds a token the provider has rotated away.');
|
|
320
|
+
}
|
|
321
|
+
if (settled && result.treatment === 'project') {
|
|
322
|
+
lines.push(' The native tool still refreshes this login. The authority holds no copy of it.');
|
|
323
|
+
}
|
|
324
|
+
return lines.join('\n') + '\n';
|
|
325
|
+
};
|
|
326
|
+
|
|
204
327
|
const terminalOperation = (operation: IAuthSwitchOperation): boolean =>
|
|
205
328
|
operation.state === 'complete' || operation.state === 'failed' || operation.state === 'cancelled'
|
|
206
329
|
|| operation.state === 'interrupted' || operation.state === 'outcome_unknown';
|
|
@@ -443,6 +566,7 @@ export const runAuthSwitchAuthorityCli = async (argv: readonly string[],
|
|
|
443
566
|
let preuseHint = false;
|
|
444
567
|
let beginAttempted = false;
|
|
445
568
|
let beginResponded = false;
|
|
569
|
+
let submitAttempted = false;
|
|
446
570
|
const operationController = new AbortController();
|
|
447
571
|
const closeClient = (reason?: Error): void => {
|
|
448
572
|
if (clientClosed || !client) return;
|
|
@@ -579,6 +703,24 @@ export const runAuthSwitchAuthorityCli = async (argv: readonly string[],
|
|
|
579
703
|
await runDoctor(activeClient, command.json, writeOutput);
|
|
580
704
|
return 0;
|
|
581
705
|
}
|
|
706
|
+
if (command.kind === 'importInventory') {
|
|
707
|
+
const inventory = await activeClient.importInventory();
|
|
708
|
+
await writeOutput(command.json ? JSON.stringify(inventory) + '\n' : renderInventory(inventory));
|
|
709
|
+
return 0;
|
|
710
|
+
}
|
|
711
|
+
if (command.kind === 'importStatus') {
|
|
712
|
+
const status = await collectImportStatus(activeClient);
|
|
713
|
+
await writeOutput(command.json ? JSON.stringify(status) + '\n' : renderImportStatus(status));
|
|
714
|
+
return 0;
|
|
715
|
+
}
|
|
716
|
+
if (command.kind === 'importSubmit') {
|
|
717
|
+
// A lost response leaves the outcome unknown to this process but never to the authority, so the
|
|
718
|
+
// failure path below points at the status read rather than inviting a second submission.
|
|
719
|
+
submitAttempted = true;
|
|
720
|
+
const result = await activeClient.submitImport(command.sourceId, command.sourceDigest);
|
|
721
|
+
await writeOutput(command.json ? JSON.stringify(result) + '\n' : renderImportResult(result));
|
|
722
|
+
return result.status === 'complete' ? 0 : 1;
|
|
723
|
+
}
|
|
582
724
|
if (command.kind === 'preuseGet' || command.kind === 'preuseCancel') {
|
|
583
725
|
const operation = validatePreuseOperation(command.kind === 'preuseGet'
|
|
584
726
|
? await activeClient.getPreuse(command.operationId)
|
|
@@ -692,15 +834,27 @@ export const runAuthSwitchAuthorityCli = async (argv: readonly string[],
|
|
|
692
834
|
}
|
|
693
835
|
return 0;
|
|
694
836
|
} catch (error) {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
837
|
+
// A marked refusal is the daemon's answer to the command, not a fault: it names what the owner does
|
|
838
|
+
// instead and nothing was left half-done, so it is shown as written rather than replaced by a generic
|
|
839
|
+
// line. Any other failure -- including the transport's own sanitised text -- stays a diagnostic.
|
|
840
|
+
const refusal = error instanceof Error && isAuthSwitchImportRefusal(error) && !cancelled && !timedOut
|
|
841
|
+
? safe(error.message) : null;
|
|
842
|
+
let message = refusal !== null ? `${refusal}\n`
|
|
843
|
+
: cancelled ? 'Authswitch authority request was cancelled.\n'
|
|
844
|
+
: timedOut ? 'Authswitch authority operation timed out.\n'
|
|
845
|
+
: beginAttempted && !beginResponded
|
|
846
|
+
? 'Authswitch authority operation start response was lost.\n'
|
|
847
|
+
: error instanceof AuthorityCliFailure && error.kind === 'output'
|
|
848
|
+
? 'Authswitch authority output failed.\n'
|
|
849
|
+
: error instanceof AuthorityCliFailure && error.kind === 'response'
|
|
850
|
+
? 'Authswitch authority daemon returned an invalid response.\n'
|
|
851
|
+
: 'Authswitch authority daemon is unavailable.\n';
|
|
852
|
+
if (submitAttempted && refusal === null) {
|
|
853
|
+
// The authority may have settled this source even though this process never learned the answer; never
|
|
854
|
+
// submit it again blindly, because a rotating refresh token is sent at most once.
|
|
855
|
+
message += 'The outcome of this import is unknown to this command. Run `authswitch authority import '
|
|
856
|
+
+ 'status` before doing anything else with this source.\n';
|
|
857
|
+
}
|
|
704
858
|
if (operationIdForHint !== null) {
|
|
705
859
|
const resume = preuseHint ? preuseResumeCommand(operationIdForHint, command.json)
|
|
706
860
|
: resumeCommand(operationIdForHint, command.json);
|
|
@@ -11,6 +11,8 @@ import type {
|
|
|
11
11
|
} from './authority-contract.js';
|
|
12
12
|
import type { IReq_AuthSwitchBindAccount, IReq_AuthSwitchReleaseBinding,
|
|
13
13
|
IReq_AuthSwitchResolveAccess } from './authority-runtime-contract.js';
|
|
14
|
+
import type { IReq_AuthSwitchImportInventory, IReq_AuthSwitchImportStatus,
|
|
15
|
+
IReq_AuthSwitchImportSubmit } from './authority-import-contract.js';
|
|
14
16
|
import { AuthSwitchAuthorityFrameReader, authSwitchAuthorityFrameBytes,
|
|
15
17
|
maxAuthSwitchAuthorityFrameBytes } from './classes.authorityframing.js';
|
|
16
18
|
|
|
@@ -97,6 +99,26 @@ export class AuthSwitchClient {
|
|
|
97
99
|
return (await this.request<IReq_AuthSwitchDoctor>('authswitch.authority.doctor', options, 35_000, false, signal)).page;
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
/** The credential-free legacy-source report of the one-time import. It changes nothing. */
|
|
103
|
+
public async importInventory(signal?: AbortSignal): Promise<IReq_AuthSwitchImportInventory['response']['inventory']> {
|
|
104
|
+
return (await this.request<IReq_AuthSwitchImportInventory>('authswitch.authority.import.inventory',
|
|
105
|
+
{}, 35_000, false, signal)).inventory;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Where every source the authority knows about stands. Durable records only; no store is opened. */
|
|
109
|
+
public async importStatus(request: IReq_AuthSwitchImportStatus['request'] = {},
|
|
110
|
+
signal?: AbortSignal): Promise<IReq_AuthSwitchImportStatus['response']['status']> {
|
|
111
|
+
return (await this.request<IReq_AuthSwitchImportStatus>('authswitch.authority.import.status',
|
|
112
|
+
request, 35_000, false, signal)).status;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Backend-only. One source, one ownership decision; both acknowledgements are required. */
|
|
116
|
+
public async submitImport(submission: IReq_AuthSwitchImportSubmit['request']['submission'],
|
|
117
|
+
signal?: AbortSignal): Promise<IReq_AuthSwitchImportSubmit['response']['result']> {
|
|
118
|
+
return (await this.request<IReq_AuthSwitchImportSubmit>('authswitch.authority.import.submit',
|
|
119
|
+
{ submission, callerQuiescent: true, acknowledgeRunOrder: true }, 120_000, true, signal)).result;
|
|
120
|
+
}
|
|
121
|
+
|
|
100
122
|
public async getUsage(accountId: string, loginId: string, force = false): Promise<IReq_AuthSwitchUsage['response']['usage']> {
|
|
101
123
|
return (await this.request<IReq_AuthSwitchUsage>('authswitch.authority.usage',
|
|
102
124
|
{ accountId, loginId, force })).usage;
|
|
@@ -10,6 +10,11 @@ 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 { authSwitchImportRefusalReason } from './authority-import-contract.js';
|
|
14
|
+
import type { IReq_AuthSwitchImportInventory, IReq_AuthSwitchImportStatus, IReq_AuthSwitchImportSubmit,
|
|
15
|
+
TAuthSwitchImportSubmission } from './authority-import-contract.js';
|
|
16
|
+
import { AuthSwitchAuthorityImport, AuthSwitchImportRefusal,
|
|
17
|
+
type IAuthSwitchAuthorityImportOptions } from './classes.authorityimport.js';
|
|
13
18
|
import { authSwitchBindingCapabilityHash, AuthSwitchAuthorityBroker,
|
|
14
19
|
type IAuthSwitchAuthorityBrokerOptions } from './classes.authoritybroker.js';
|
|
15
20
|
import { AuthSwitchAuthorityDatabase, type IAuthSwitchAuthorityDatabaseOptions } from './classes.authoritydatabase.js';
|
|
@@ -32,6 +37,8 @@ export interface IAuthSwitchAuthorityDaemonOptions extends IAuthSwitchAuthorityD
|
|
|
32
37
|
claudeStatus?: ClaudeAccountStatus;
|
|
33
38
|
/** Activated only after a verified backend source receipt has registered the native home. */
|
|
34
39
|
claudeNative?: IClaudeNativeAuthorityOptions;
|
|
40
|
+
/** Where the one-time import reads the legacy stores. Defaults to this user's own locations. */
|
|
41
|
+
legacyImport?: IAuthSwitchAuthorityImportOptions;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
const hasKeys = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
|
|
@@ -41,6 +48,28 @@ const invalid = (): never => { throw new plugins.typedrequest.TypedResponseError
|
|
|
41
48
|
const validBindingId = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
42
49
|
const validBindingCapability = (value: unknown): value is string => typeof value === 'string'
|
|
43
50
|
&& /^[A-Za-z0-9_-]{43}$/.test(value);
|
|
51
|
+
const validHash = (value: unknown): value is string => typeof value === 'string'
|
|
52
|
+
&& /^[a-f0-9]{64}$/.test(value);
|
|
53
|
+
const boundedToken = (value: unknown): value is string => typeof value === 'string'
|
|
54
|
+
&& value.length > 0 && value.length <= 8192;
|
|
55
|
+
|
|
56
|
+
/** A submission names a local source by id and digest, or carries one external store's own login. */
|
|
57
|
+
const validImportSubmission = (value: unknown): value is TAuthSwitchImportSubmission => {
|
|
58
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
59
|
+
const submission = value as Record<string, unknown>;
|
|
60
|
+
if (submission.kind === 'local') {
|
|
61
|
+
return hasKeys(submission, ['kind', 'sourceId', 'sourceDigest'])
|
|
62
|
+
&& validHash(submission.sourceId) && validHash(submission.sourceDigest);
|
|
63
|
+
}
|
|
64
|
+
if (submission.kind !== 'external'
|
|
65
|
+
|| !hasKeys(submission, ['kind', 'sourceKind', 'sourcePathHash', 'sourceDigest', 'credential'])
|
|
66
|
+
|| submission.sourceKind !== 'agl_flex' || !validHash(submission.sourcePathHash)
|
|
67
|
+
|| !validHash(submission.sourceDigest)) return false;
|
|
68
|
+
const credential = submission.credential;
|
|
69
|
+
return hasKeys(credential, ['providerId', 'accessToken', 'refreshToken', 'idToken'])
|
|
70
|
+
&& credential.providerId === 'openai' && boundedToken(credential.accessToken)
|
|
71
|
+
&& boundedToken(credential.refreshToken) && boundedToken(credential.idToken);
|
|
72
|
+
};
|
|
44
73
|
|
|
45
74
|
interface IPreuseAccountReservation {
|
|
46
75
|
operationId: string;
|
|
@@ -52,6 +81,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
52
81
|
public readonly broker: AuthSwitchAuthorityBroker;
|
|
53
82
|
public readonly usage: AuthSwitchAuthorityUsage;
|
|
54
83
|
public readonly preuse: AuthSwitchAuthorityPreuse;
|
|
84
|
+
public readonly legacyImport: AuthSwitchAuthorityImport;
|
|
55
85
|
public readonly claudeNative?: ClaudeNativeAuthority;
|
|
56
86
|
private readonly managementRouter = new plugins.typedrequest.TypedRouter();
|
|
57
87
|
private readonly runtimeRouter = new plugins.typedrequest.TypedRouter();
|
|
@@ -102,6 +132,7 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
102
132
|
provider,
|
|
103
133
|
});
|
|
104
134
|
this.preuse = new AuthSwitchAuthorityPreuse(this.database, this.broker, options.preuse);
|
|
135
|
+
this.legacyImport = new AuthSwitchAuthorityImport(this.database, options.legacyImport);
|
|
105
136
|
if (options.claudeNative) {
|
|
106
137
|
this.claudeNative = new ClaudeNativeAuthority(this.database, {
|
|
107
138
|
...options.claudeNative, onChanged: () => this.broker.notifyChanged(),
|
|
@@ -248,6 +279,25 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
248
279
|
|| request.limit < 1 || request.limit > 128))) invalid();
|
|
249
280
|
return { page: await this.doctorPage(request) };
|
|
250
281
|
}));
|
|
282
|
+
this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchImportInventory>(
|
|
283
|
+
'authswitch.authority.import.inventory', async request => {
|
|
284
|
+
// A read with no parameters: the inventory is the whole host, and a caller cannot narrow it into
|
|
285
|
+
// believing a source does not exist.
|
|
286
|
+
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
287
|
+
|| Object.keys(request).length !== 0) invalid();
|
|
288
|
+
if (this.closing) throw new Error('Authswitch authority is shutting down.');
|
|
289
|
+
return { inventory: await this.legacyImport.inventory() };
|
|
290
|
+
}));
|
|
291
|
+
this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchImportStatus>(
|
|
292
|
+
'authswitch.authority.import.status', async request => {
|
|
293
|
+
if (request === null || typeof request !== 'object' || Array.isArray(request)
|
|
294
|
+
|| Object.keys(request).some(key => !['after', 'limit'].includes(key))
|
|
295
|
+
|| (request.after !== undefined && !validHash(request.after))
|
|
296
|
+
|| (request.limit !== undefined && (!Number.isSafeInteger(request.limit)
|
|
297
|
+
|| request.limit < 1 || request.limit > 128))) invalid();
|
|
298
|
+
if (this.closing) throw new Error('Authswitch authority is shutting down.');
|
|
299
|
+
return { status: await this.legacyImport.status(request) };
|
|
300
|
+
}));
|
|
251
301
|
this.managementRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchEvents>(
|
|
252
302
|
'authswitch.authority.events', async (request, tools) => {
|
|
253
303
|
if (!hasKeys(request, ['epoch', 'afterRevision', 'waitMs'])) invalid();
|
|
@@ -381,6 +431,28 @@ export class AuthSwitchAuthorityDaemon {
|
|
|
381
431
|
if (this.closing) throw new Error('Authswitch authority is closing.');
|
|
382
432
|
return this.releaseRuntimeBinding(request.bindingId, request.capability);
|
|
383
433
|
}));
|
|
434
|
+
this.runtimeRouter.addTypedHandler(new plugins.typedrequest.TypedHandler<IReq_AuthSwitchImportSubmit>(
|
|
435
|
+
'authswitch.authority.import.submit', async request => {
|
|
436
|
+
// Backend-only: an external submission carries the material of a store this package cannot read.
|
|
437
|
+
if (!hasKeys(request, ['submission', 'callerQuiescent', 'acknowledgeRunOrder'])
|
|
438
|
+
|| request.callerQuiescent !== true || request.acknowledgeRunOrder !== true
|
|
439
|
+
|| !validImportSubmission(request.submission)) invalid();
|
|
440
|
+
if (this.closing) throw new Error('Authswitch authority is closing.');
|
|
441
|
+
try {
|
|
442
|
+
return { result: await this.legacyImport.submit({ submission: request.submission,
|
|
443
|
+
callerQuiescent: true, acknowledgeRunOrder: true }) };
|
|
444
|
+
} catch (error) {
|
|
445
|
+
// A refusal is an answer, not a fault: it names what the owner does instead, and only a
|
|
446
|
+
// TypedResponseError survives the wire with its text. The marker is what lets the caller tell
|
|
447
|
+
// that instruction apart from the transport's sanitised text for an unexpected failure, whose
|
|
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
|
+
}
|
|
455
|
+
}));
|
|
384
456
|
}
|
|
385
457
|
|
|
386
458
|
private async doctorPage(request: IReq_AuthSwitchDoctor['request']): Promise<IAuthSwitchDoctorPage> {
|