@modelprofile.com/authswitch 6.5.0 → 8.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 +300 -6
- package/dist_ts/authority-contract.js +1 -1
- package/dist_ts/authority-runtime-contract.d.ts +13 -0
- package/dist_ts/classes.accountlist.js +4 -3
- package/dist_ts/classes.authoritybroker.d.ts +25 -5
- package/dist_ts/classes.authoritybroker.js +388 -123
- package/dist_ts/classes.authoritycli.d.ts +24 -0
- package/dist_ts/classes.authoritycli.js +675 -0
- package/dist_ts/classes.authorityclient.d.ts +19 -7
- package/dist_ts/classes.authorityclient.js +51 -14
- package/dist_ts/classes.authoritydaemon.d.ts +28 -1
- package/dist_ts/classes.authoritydaemon.js +477 -36
- package/dist_ts/classes.authoritydatabase.d.ts +155 -11
- package/dist_ts/classes.authoritydatabase.js +1037 -69
- package/dist_ts/classes.authoritymodels.d.ts +229 -23
- package/dist_ts/classes.authoritymodels.js +867 -128
- package/dist_ts/classes.authoritypreuse.d.ts +47 -0
- package/dist_ts/classes.authoritypreuse.js +277 -0
- package/dist_ts/classes.authorityregistry.d.ts +9 -0
- package/dist_ts/classes.authorityregistry.js +32 -0
- package/dist_ts/classes.authoritysecrets.d.ts +1 -1
- package/dist_ts/classes.authoritysecrets.js +2 -2
- package/dist_ts/classes.authorityusage.d.ts +160 -0
- package/dist_ts/classes.authorityusage.js +429 -0
- package/dist_ts/classes.claudeauthority.d.ts +66 -0
- package/dist_ts/classes.claudeauthority.js +528 -0
- package/dist_ts/classes.claudecodeharness.js +3 -2
- package/dist_ts/classes.claudecodelocks.d.ts +13 -0
- package/dist_ts/classes.claudecodelocks.js +75 -3
- package/dist_ts/classes.claudenative.d.ts +130 -0
- package/dist_ts/classes.claudenative.js +708 -0
- package/dist_ts/classes.claudestatus.d.ts +9 -0
- package/dist_ts/classes.claudestatus.js +26 -9
- package/dist_ts/classes.claudetokenrefresh.d.ts +15 -1
- package/dist_ts/classes.claudetokenrefresh.js +74 -21
- package/dist_ts/classes.codexharness.js +4 -2
- package/dist_ts/classes.codexmanaged.d.ts +12 -1
- package/dist_ts/classes.codexmanaged.js +75 -14
- package/dist_ts/classes.codexpreuse.d.ts +8 -0
- package/dist_ts/classes.codexpreuse.js +24 -6
- package/dist_ts/classes.fileharness.d.ts +2 -0
- package/dist_ts/classes.fileharness.js +6 -4
- package/dist_ts/classes.limits.js +12 -7
- package/dist_ts/classes.opencodeharness.js +2 -2
- package/dist_ts/claudehttp.d.ts +10 -0
- package/dist_ts/claudehttp.js +29 -3
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +7 -1
- package/dist_ts/interfaces.harness.d.ts +6 -0
- package/dist_ts/interfaces.list.d.ts +9 -3
- package/dist_ts/plugins.d.ts +6 -3
- package/dist_ts/plugins.js +8 -4
- package/dist_ts/ts_migration/0001_authority_meta.d.ts +13 -0
- package/dist_ts/ts_migration/0001_authority_meta.js +44 -0
- package/dist_ts/ts_migration/0002_remove_backup_records.d.ts +17 -0
- package/dist_ts/ts_migration/0002_remove_backup_records.js +48 -0
- package/dist_ts/ts_migration/index.d.ts +6 -0
- package/dist_ts/ts_migration/index.js +12 -0
- package/package.json +3 -3
- package/readme.md +177 -14
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +262 -9
- package/ts/authority-runtime-contract.ts +14 -0
- package/ts/classes.accountlist.ts +3 -2
- package/ts/classes.authoritybroker.ts +385 -121
- package/ts/classes.authoritycli.ts +719 -0
- package/ts/classes.authorityclient.ts +75 -18
- package/ts/classes.authoritydaemon.ts +444 -36
- package/ts/classes.authoritydatabase.ts +1076 -75
- package/ts/classes.authoritymodels.ts +642 -144
- package/ts/classes.authoritypreuse.ts +296 -0
- package/ts/classes.authorityregistry.ts +47 -0
- package/ts/classes.authoritysecrets.ts +1 -1
- package/ts/classes.authorityusage.ts +527 -0
- package/ts/classes.claudeauthority.ts +537 -0
- package/ts/classes.claudecodeharness.ts +2 -1
- package/ts/classes.claudecodelocks.ts +70 -1
- package/ts/classes.claudenative.ts +785 -0
- package/ts/classes.claudestatus.ts +38 -9
- package/ts/classes.claudetokenrefresh.ts +82 -19
- package/ts/classes.codexharness.ts +3 -1
- package/ts/classes.codexmanaged.ts +76 -13
- package/ts/classes.codexpreuse.ts +33 -5
- package/ts/classes.fileharness.ts +7 -3
- package/ts/classes.limits.ts +11 -6
- package/ts/classes.opencodeharness.ts +1 -1
- package/ts/claudehttp.ts +31 -2
- package/ts/index.ts +6 -0
- package/ts/interfaces.harness.ts +6 -0
- package/ts/interfaces.list.ts +9 -3
- package/ts/plugins.ts +8 -3
- package/ts/ts_migration/0001_authority_meta.ts +45 -0
- package/ts/ts_migration/0002_remove_backup_records.ts +49 -0
- package/ts/ts_migration/index.ts +12 -0
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import {
|
|
3
|
-
AuthSwitchAuthorityAccountModel, AuthSwitchAuthorityBindingModel,
|
|
3
|
+
AuthSwitchAuthorityAccountModel, AuthSwitchAuthorityBindingModel, AuthSwitchAuthorityClaudeHandoffModel,
|
|
4
|
+
AuthSwitchAuthorityClaudeHomeModel, AuthSwitchAuthorityCodexHomeModel, AuthSwitchAuthorityEnrollmentModel,
|
|
4
5
|
AuthSwitchAuthorityEventModel, AuthSwitchAuthorityGrantModel, AuthSwitchAuthorityHandoffModel,
|
|
5
|
-
AuthSwitchAuthorityMetaModel, AuthSwitchAuthorityMigrationLedgerModel,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
AuthSwitchAuthorityMetaModel, AuthSwitchAuthorityMigrationLedgerModel, AuthSwitchAuthorityOperationModel,
|
|
7
|
+
AuthSwitchAuthorityUsageModel, authorityUsageId, assertStoredAuthorityAccount,
|
|
8
|
+
assertStoredAuthorityBinding, assertStoredAuthorityClaudeHandoff, assertStoredAuthorityClaudeHome,
|
|
9
|
+
assertStoredAuthorityEnrollment, assertStoredAuthorityGrant,
|
|
10
|
+
assertStoredAuthorityHandoff, assertStoredAuthorityMigrationLedger, assertStoredAuthorityOperation,
|
|
11
|
+
assertStoredAuthorityUsage,
|
|
12
|
+
type IStoredAuthorityAccount, type IStoredAuthorityBinding, type IStoredAuthorityClaudeHandoff,
|
|
13
|
+
type IStoredAuthorityClaudeHome, type IStoredAuthorityCodexHome, type IStoredAuthorityEnrollment,
|
|
9
14
|
type IStoredAuthorityEvent, type IStoredAuthorityGrant, type IStoredAuthorityHandoff,
|
|
10
|
-
type IStoredAuthorityMeta, type IStoredAuthorityMigrationLedger,
|
|
15
|
+
type IStoredAuthorityDeviceOperation, type IStoredAuthorityMeta, type IStoredAuthorityMigrationLedger,
|
|
16
|
+
type IStoredAuthorityOperation, type IStoredAuthorityPreuseOperation, type IStoredAuthorityUsage,
|
|
11
17
|
} from './classes.authoritymodels.js';
|
|
18
|
+
import type { IAuthSwitchStoredUsage, IAuthSwitchUsageContext } from './classes.authorityusage.js';
|
|
19
|
+
import { authorityModelRegistry } from './classes.authorityregistry.js';
|
|
20
|
+
import { runAuthorityMigrations } from './ts_migration/index.js';
|
|
12
21
|
|
|
13
22
|
export interface IAuthSwitchAuthorityDatabaseOptions {
|
|
14
23
|
dataDirectory: string;
|
|
@@ -16,12 +25,46 @@ export interface IAuthSwitchAuthorityDatabaseOptions {
|
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
type TEventKind = IStoredAuthorityEvent['kind'];
|
|
19
|
-
type TSession = plugins.
|
|
28
|
+
type TSession = plugins.nosqldb.SmartdataSession;
|
|
29
|
+
type TStoredAuthorityDeviceOperationAdmission = Omit<IStoredAuthorityDeviceOperation, 'expectedGrantGeneration'>;
|
|
30
|
+
type TClaudeHandoffDraft = {
|
|
31
|
+
home: IStoredAuthorityClaudeHome;
|
|
32
|
+
handoff: IStoredAuthorityClaudeHandoff;
|
|
33
|
+
outgoing: IStoredAuthorityGrant;
|
|
34
|
+
incoming: IStoredAuthorityGrant;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const usageContext = (meta: IStoredAuthorityMeta, account: IStoredAuthorityAccount,
|
|
38
|
+
grant: IStoredAuthorityGrant): IAuthSwitchUsageContext => ({
|
|
39
|
+
epoch: meta.epoch, accountId: account.id, loginId: grant.id, subjectId: account.subject,
|
|
40
|
+
workspaceId: account.workspaceId, providerId: account.providerId,
|
|
41
|
+
purpose: grant.purpose, owner: grant.owner, grantState: grant.state,
|
|
42
|
+
removed: account.removed, authorizationGeneration: grant.authorizationGeneration,
|
|
43
|
+
grantGeneration: grant.grantGeneration, nativeSource: null,
|
|
44
|
+
});
|
|
45
|
+
const sameUsageContext = (left: IAuthSwitchUsageContext, right: IAuthSwitchUsageContext): boolean =>
|
|
46
|
+
left.epoch === right.epoch && left.accountId === right.accountId && left.loginId === right.loginId
|
|
47
|
+
&& left.subjectId === right.subjectId && left.workspaceId === right.workspaceId
|
|
48
|
+
&& left.providerId === right.providerId
|
|
49
|
+
&& left.purpose === right.purpose && left.owner === right.owner && left.grantState === right.grantState
|
|
50
|
+
&& left.removed === right.removed && left.authorizationGeneration === right.authorizationGeneration
|
|
51
|
+
&& left.grantGeneration === right.grantGeneration;
|
|
52
|
+
const sameUsageSource = (left: IAuthSwitchUsageContext, right: IAuthSwitchUsageContext): boolean =>
|
|
53
|
+
sameUsageContext(left, right) && (left.nativeSource === null ? right.nativeSource === null
|
|
54
|
+
: right.nativeSource !== null && left.nativeSource.homeId === right.nativeSource.homeId
|
|
55
|
+
&& left.nativeSource.credentialDigest === right.nativeSource.credentialDigest
|
|
56
|
+
&& left.nativeSource.configDigest === right.nativeSource.configDigest
|
|
57
|
+
&& left.nativeSource.accessTokenDigest === right.nativeSource.accessTokenDigest);
|
|
58
|
+
const storedOperation = (stored: Parameters<typeof AuthSwitchAuthorityOperationModel.exact.toPersisted>[0]): IStoredAuthorityOperation => {
|
|
59
|
+
const operation = AuthSwitchAuthorityOperationModel.exact.toPersisted(stored);
|
|
60
|
+
assertStoredAuthorityOperation(operation);
|
|
61
|
+
return operation;
|
|
62
|
+
};
|
|
20
63
|
|
|
21
64
|
/** Separate account, grant, binding and journal records; the only global record is a revision counter. */
|
|
22
65
|
export class AuthSwitchAuthorityDatabase {
|
|
23
|
-
private engine?: plugins.
|
|
24
|
-
private database?: plugins.
|
|
66
|
+
private engine?: plugins.nosqldbEngine.LocalNoSqlDb;
|
|
67
|
+
private database?: plugins.nosqldb.SmartdataDb;
|
|
25
68
|
private started = false;
|
|
26
69
|
|
|
27
70
|
constructor(private readonly options: IAuthSwitchAuthorityDatabaseOptions) {}
|
|
@@ -39,32 +82,41 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
39
82
|
if (this.started || this.engine || this.database) throw new Error('Authswitch database is already starting or started.');
|
|
40
83
|
await this.privateDirectory(this.options.dataDirectory);
|
|
41
84
|
await this.privateDirectory(plugins.path.dirname(this.options.socketPath));
|
|
42
|
-
const engine = new plugins.
|
|
85
|
+
const engine = new plugins.nosqldbEngine.LocalNoSqlDb({ folderPath: this.options.dataDirectory, socketPath: this.options.socketPath });
|
|
43
86
|
this.engine = engine;
|
|
44
87
|
try {
|
|
45
88
|
const connection = await engine.start();
|
|
46
|
-
const database = new plugins.
|
|
89
|
+
const database = new plugins.nosqldb.SmartdataDb({ mongoDbUrl: connection.connectionUri, mongoDbName: 'authswitch' });
|
|
47
90
|
this.database = database;
|
|
48
|
-
for (const model of [AuthSwitchAuthorityMetaModel, AuthSwitchAuthorityAccountModel,
|
|
49
|
-
AuthSwitchAuthorityGrantModel, AuthSwitchAuthorityBindingModel, AuthSwitchAuthorityCodexHomeModel, AuthSwitchAuthorityEnrollmentModel,
|
|
50
|
-
AuthSwitchAuthorityHandoffModel, AuthSwitchAuthorityMigrationLedgerModel, AuthSwitchAuthorityEventModel]) {
|
|
51
|
-
plugins.smartdata.setDefaultManagerForDoc({ db: database }, model);
|
|
52
|
-
}
|
|
53
91
|
await database.init();
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
92
|
+
// The LocalNoSqlDb root lease excludes another engine during this catalog admission.
|
|
93
|
+
const inventory = await database.inspectNamespace({ maxCollections: 1024,
|
|
94
|
+
documentCountUpTo: 0, timeoutMs: 30_000 });
|
|
95
|
+
const names = new Set(authorityModelRegistry.map(entry => entry.name));
|
|
96
|
+
for (const collection of inventory.collections) {
|
|
97
|
+
if (collection.namespaceType !== 'collection' || !names.has(collection.collectionName)) {
|
|
98
|
+
throw new Error('Authswitch authority database contains an unknown collection or namespace.');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const fresh = inventory.collections.length === 0;
|
|
102
|
+
if (!fresh) {
|
|
103
|
+
if (!inventory.collections.some(item => item.collectionName === 'authswitch_authority_meta')) {
|
|
104
|
+
throw new Error('Existing authswitch authority database has no metadata collection.');
|
|
105
|
+
}
|
|
106
|
+
await runAuthorityMigrations(database);
|
|
107
|
+
}
|
|
108
|
+
await Promise.all(authorityModelRegistry.map(entry => entry.initialize(database)));
|
|
109
|
+
await Promise.all(authorityModelRegistry.map(entry => entry.prepare()));
|
|
110
|
+
if (fresh) {
|
|
111
|
+
const initial: IStoredAuthorityMeta = {
|
|
112
|
+
id: 'authswitch-authority',
|
|
113
|
+
epoch: plugins.crypto.randomUUID(), revision: 0, updateId: plugins.crypto.randomUUID(),
|
|
114
|
+
};
|
|
115
|
+
const result = await AuthSwitchAuthorityMetaModel.exact.insert(initial);
|
|
116
|
+
if (result.status !== 'inserted') throw new Error('Authswitch authority could not initialize.');
|
|
117
|
+
} else if (!await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' })) {
|
|
118
|
+
throw new Error('Existing authswitch authority metadata is missing.');
|
|
119
|
+
}
|
|
68
120
|
this.started = true;
|
|
69
121
|
} catch (error) {
|
|
70
122
|
try { await this.stop(); } catch { /* Preserve the original startup error. */ }
|
|
@@ -72,7 +124,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
72
124
|
}
|
|
73
125
|
}
|
|
74
126
|
|
|
75
|
-
private db(): plugins.
|
|
127
|
+
private db(): plugins.nosqldb.SmartdataDb {
|
|
76
128
|
if (!this.started || !this.database) throw new Error('Authswitch database is unavailable.');
|
|
77
129
|
return this.database;
|
|
78
130
|
}
|
|
@@ -111,38 +163,50 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
111
163
|
return stored ? AuthSwitchAuthorityCodexHomeModel.exact.toPersisted(stored) : null;
|
|
112
164
|
}
|
|
113
165
|
|
|
114
|
-
/**
|
|
115
|
-
public async
|
|
166
|
+
/** Durable home claims must be inspected even when the daemon has lost its process map. */
|
|
167
|
+
public async codexHomesForAccount(accountId: string, after: string | null): Promise<IStoredAuthorityCodexHome[]> {
|
|
168
|
+
this.db();
|
|
169
|
+
if (!/^[a-f0-9]{64}$/.test(accountId) || (after !== null && !/^[a-f0-9]{64}$/.test(after))) {
|
|
170
|
+
throw new Error('Invalid managed Codex home selector.');
|
|
171
|
+
}
|
|
172
|
+
const stored = await AuthSwitchAuthorityCodexHomeModel.exact.findStored({
|
|
173
|
+
filter: after ? { accountId, id: { $gt: after } } : { accountId }, sort: { id: 1 }, limit: 128,
|
|
174
|
+
});
|
|
175
|
+
return stored.map(item => AuthSwitchAuthorityCodexHomeModel.exact.toPersisted(item));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Home path identity is hashed by the caller; no vendor files or raw paths enter NoSQLDB. */
|
|
179
|
+
public async reserveCodexHome(accountId: string, grantId: string, scopeId: string, homeId: string,
|
|
116
180
|
updateId: string): Promise<{ newlyRegistered: boolean }> {
|
|
117
181
|
try {
|
|
118
182
|
return await this.transaction(async session => {
|
|
119
183
|
const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: accountId }, { session });
|
|
120
184
|
const account = accountStored ? AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored) : null;
|
|
121
|
-
const grantStored =
|
|
122
|
-
? await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: account.primaryGrantId }, { session }) : null;
|
|
185
|
+
const grantStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: grantId }, { session });
|
|
123
186
|
const grant = grantStored ? AuthSwitchAuthorityGrantModel.exact.toPersisted(grantStored) : null;
|
|
124
187
|
if (!account || account.removed || account.providerId !== 'openai' || grant?.owner !== 'daemon'
|
|
125
|
-
|| grant.purpose !== 'openai_managed' || grant.accountId !== accountId
|
|
188
|
+
|| grant.purpose !== 'openai_managed' || grant.accountId !== accountId
|
|
189
|
+
|| grant.id !== account.primaryGrantId || grant.state === 'removed') {
|
|
126
190
|
throw new Error('Managed Codex requires a daemon-owned OpenAI account.');
|
|
127
191
|
}
|
|
128
192
|
const used = await AuthSwitchAuthorityCodexHomeModel.exact.findStoredOne({ id: homeId }, { session });
|
|
129
193
|
if (used) {
|
|
130
|
-
if (used.accountId !== accountId || used.scopeId !== scopeId) {
|
|
194
|
+
if (used.accountId !== accountId || used.grantId !== grantId || used.scopeId !== scopeId) {
|
|
131
195
|
throw new Error('Managed Codex home already belongs to another account or scope.');
|
|
132
196
|
}
|
|
133
197
|
return { newlyRegistered: false };
|
|
134
198
|
}
|
|
135
|
-
const record: IStoredAuthorityCodexHome = { id: homeId, accountId, scopeId, activeRun: null,
|
|
199
|
+
const record: IStoredAuthorityCodexHome = { id: homeId, accountId, grantId, scopeId, activeRun: null,
|
|
136
200
|
createdAt: new Date().toISOString(), updateId };
|
|
137
201
|
const inserted = await AuthSwitchAuthorityCodexHomeModel.exact.insert(record, { session });
|
|
138
202
|
if (inserted.status !== 'inserted') throw new Error('Managed Codex home changed during admission.');
|
|
139
203
|
return { newlyRegistered: true };
|
|
140
204
|
});
|
|
141
205
|
} catch (error) {
|
|
142
|
-
if (error instanceof plugins.
|
|
206
|
+
if (error instanceof plugins.nosqldb.SmartdataExactPersistenceError
|
|
143
207
|
&& (error.code === 'ambiguous_write' || error.code === 'unique_conflict')) {
|
|
144
208
|
const assigned = await AuthSwitchAuthorityCodexHomeModel.exact.findStoredOne({ id: homeId });
|
|
145
|
-
if (assigned?.accountId === accountId && assigned.scopeId === scopeId
|
|
209
|
+
if (assigned?.accountId === accountId && assigned.grantId === grantId && assigned.scopeId === scopeId
|
|
146
210
|
&& assigned.updateId === updateId) return { newlyRegistered: true };
|
|
147
211
|
throw new Error('Managed Codex home registration changed concurrently; refresh before retrying.');
|
|
148
212
|
}
|
|
@@ -169,7 +233,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
169
233
|
return true;
|
|
170
234
|
});
|
|
171
235
|
} catch (error) {
|
|
172
|
-
if (!(error instanceof plugins.
|
|
236
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
173
237
|
const current = await this.readCodexHome(homeId);
|
|
174
238
|
if (current?.updateId === updateId && JSON.stringify(current.activeRun) === JSON.stringify(activeRun)) return;
|
|
175
239
|
throw new Error('Managed Codex home run outcome is unknown.');
|
|
@@ -188,10 +252,10 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
188
252
|
const accountStored = binding
|
|
189
253
|
? await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: binding.accountId }, { session }) : null;
|
|
190
254
|
const account = accountStored ? AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored) : null;
|
|
191
|
-
const grantStored = binding && account
|
|
255
|
+
const grantStored = binding && account && !account.removed
|
|
192
256
|
? await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: binding.grantId }, { session }) : null;
|
|
193
257
|
const grant = grantStored ? AuthSwitchAuthorityGrantModel.exact.toPersisted(grantStored) : null;
|
|
194
|
-
return { binding, account, grant };
|
|
258
|
+
return { binding, account, grant: grant?.accountId === account?.id ? grant : null };
|
|
195
259
|
});
|
|
196
260
|
}
|
|
197
261
|
|
|
@@ -219,6 +283,286 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
219
283
|
return stored ? AuthSwitchAuthorityMigrationLedgerModel.exact.toPersisted(stored) : null;
|
|
220
284
|
}
|
|
221
285
|
|
|
286
|
+
public async readOperation(id: string): Promise<IStoredAuthorityOperation | null> {
|
|
287
|
+
this.db();
|
|
288
|
+
const stored = await AuthSwitchAuthorityOperationModel.exact.findStoredOne({ id });
|
|
289
|
+
return stored ? storedOperation(stored) : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
public async operationsPage(after: string | null, limit = 128): Promise<{ operations: IStoredAuthorityDeviceOperation[]; nextCursor: string | null }> {
|
|
293
|
+
this.db();
|
|
294
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256) throw new Error('Invalid operation page size.');
|
|
295
|
+
const stored = await AuthSwitchAuthorityOperationModel.exact.findStored({
|
|
296
|
+
filter: after
|
|
297
|
+
? { kind: { $in: ['add_openai', 'reauth_openai'] }, id: { $gt: after } }
|
|
298
|
+
: { kind: { $in: ['add_openai', 'reauth_openai'] } },
|
|
299
|
+
sort: { id: 1 }, limit: limit + 1,
|
|
300
|
+
});
|
|
301
|
+
const operations = stored.slice(0, limit).map(storedOperation)
|
|
302
|
+
.filter((item): item is IStoredAuthorityDeviceOperation => item.kind !== 'preuse_openai');
|
|
303
|
+
return { operations, nextCursor: stored.length > limit ? operations.at(-1)!.id : null };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
public async operationsByState(state: IStoredAuthorityDeviceOperation['state'], after: string | null,
|
|
307
|
+
limit = 128): Promise<IStoredAuthorityDeviceOperation[]> {
|
|
308
|
+
this.db();
|
|
309
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256) throw new Error('Invalid operation page size.');
|
|
310
|
+
const stored = await AuthSwitchAuthorityOperationModel.exact.findStored({
|
|
311
|
+
filter: after ? { state, id: { $gt: after } } : { state }, sort: { id: 1 }, limit,
|
|
312
|
+
});
|
|
313
|
+
return stored.map(storedOperation)
|
|
314
|
+
.filter((item): item is IStoredAuthorityDeviceOperation => item.kind !== 'preuse_openai');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
public async preuseOperationsByState(state: IStoredAuthorityPreuseOperation['state'], after: string | null,
|
|
318
|
+
limit = 128): Promise<IStoredAuthorityPreuseOperation[]> {
|
|
319
|
+
this.db();
|
|
320
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256) throw new Error('Invalid operation page size.');
|
|
321
|
+
const stored = await AuthSwitchAuthorityOperationModel.exact.findStored({
|
|
322
|
+
filter: after ? { kind: 'preuse_openai', state, id: { $gt: after } } : { kind: 'preuse_openai', state },
|
|
323
|
+
sort: { id: 1 }, limit,
|
|
324
|
+
});
|
|
325
|
+
return stored.map(storedOperation)
|
|
326
|
+
.filter((item): item is IStoredAuthorityPreuseOperation => item.kind === 'preuse_openai');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Insert-or-read admission for one exact device-login intent. Existing receipts are never replayed. */
|
|
330
|
+
public async createOperation(intent: TStoredAuthorityDeviceOperationAdmission): Promise<{
|
|
331
|
+
operation: IStoredAuthorityDeviceOperation; created: boolean;
|
|
332
|
+
}> {
|
|
333
|
+
this.db();
|
|
334
|
+
assertStoredAuthorityOperation({ ...intent,
|
|
335
|
+
expectedGrantGeneration: intent.kind === 'reauth_openai' ? 1 : null });
|
|
336
|
+
const sameRequest = (current: IStoredAuthorityOperation): current is IStoredAuthorityDeviceOperation =>
|
|
337
|
+
current.kind !== 'preuse_openai' && current.kind === intent.kind
|
|
338
|
+
&& current.accountId === intent.accountId && current.grantId === intent.grantId
|
|
339
|
+
&& current.purpose === intent.purpose;
|
|
340
|
+
try {
|
|
341
|
+
return await this.transaction(async session => {
|
|
342
|
+
const existingStored = await AuthSwitchAuthorityOperationModel.exact.findStoredOne(
|
|
343
|
+
{ id: intent.id }, { session });
|
|
344
|
+
if (existingStored) {
|
|
345
|
+
const existing = storedOperation(existingStored);
|
|
346
|
+
if (!sameRequest(existing)) throw new Error('Account operation ID belongs to a different request.');
|
|
347
|
+
return { operation: existing, created: false };
|
|
348
|
+
}
|
|
349
|
+
let expectedGrantGeneration: number | null = null;
|
|
350
|
+
if (intent.kind === 'reauth_openai') {
|
|
351
|
+
const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne(
|
|
352
|
+
{ id: intent.accountId! }, { session });
|
|
353
|
+
const grantStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne(
|
|
354
|
+
{ id: intent.grantId! }, { session });
|
|
355
|
+
const account = accountStored ? AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored) : null;
|
|
356
|
+
const grant = grantStored ? AuthSwitchAuthorityGrantModel.exact.toPersisted(grantStored) : null;
|
|
357
|
+
if (!account || account.removed || account.providerId !== 'openai'
|
|
358
|
+
|| account.primaryGrantId !== intent.grantId || !grant || grant.accountId !== intent.accountId
|
|
359
|
+
|| grant.providerId !== 'openai' || grant.purpose !== 'openai_managed'
|
|
360
|
+
|| grant.owner !== 'daemon' || ['removed', 'exchange_may_have_been_sent'].includes(grant.state)) {
|
|
361
|
+
throw new Error('Selected OpenAI login is unavailable for reauthentication.');
|
|
362
|
+
}
|
|
363
|
+
expectedGrantGeneration = grant.grantGeneration;
|
|
364
|
+
}
|
|
365
|
+
const operation: IStoredAuthorityDeviceOperation = { ...intent, expectedGrantGeneration };
|
|
366
|
+
assertStoredAuthorityOperation(operation);
|
|
367
|
+
const result = await AuthSwitchAuthorityOperationModel.exact.insert(operation, { session });
|
|
368
|
+
if (result.status !== 'inserted') throw new Error('Account operation ID already exists.');
|
|
369
|
+
return { operation, created: true };
|
|
370
|
+
});
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
373
|
+
const stored = await this.readOperation(intent.id);
|
|
374
|
+
if (stored && sameRequest(stored)) {
|
|
375
|
+
return { operation: stored, created: stored.updateId === intent.updateId };
|
|
376
|
+
}
|
|
377
|
+
throw new Error('Account operation creation outcome is unknown; no provider login was started.');
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Revalidate the durable owner and exact reauthentication generation after mutable runtime preparation. */
|
|
382
|
+
public async confirmOperationMayStart(operationId: string): Promise<IStoredAuthorityDeviceOperation> {
|
|
383
|
+
return this.transaction(async session => {
|
|
384
|
+
const operationStored = await AuthSwitchAuthorityOperationModel.exact.findStoredOne(
|
|
385
|
+
{ id: operationId }, { session });
|
|
386
|
+
if (!operationStored) throw new Error('Account operation was not found.');
|
|
387
|
+
const operation = storedOperation(operationStored);
|
|
388
|
+
if (operation.kind === 'preuse_openai' || operation.state !== 'starting') {
|
|
389
|
+
throw new Error('Account operation is no longer starting.');
|
|
390
|
+
}
|
|
391
|
+
if (operation.kind === 'reauth_openai') {
|
|
392
|
+
const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne(
|
|
393
|
+
{ id: operation.accountId! }, { session });
|
|
394
|
+
const grantStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne(
|
|
395
|
+
{ id: operation.grantId! }, { session });
|
|
396
|
+
const account = accountStored ? AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored) : null;
|
|
397
|
+
const grant = grantStored ? AuthSwitchAuthorityGrantModel.exact.toPersisted(grantStored) : null;
|
|
398
|
+
if (!account || account.removed || account.providerId !== 'openai'
|
|
399
|
+
|| account.primaryGrantId !== operation.grantId || !grant || grant.accountId !== operation.accountId
|
|
400
|
+
|| grant.providerId !== 'openai' || grant.purpose !== 'openai_managed'
|
|
401
|
+
|| grant.owner !== 'daemon' || ['removed', 'exchange_may_have_been_sent'].includes(grant.state)
|
|
402
|
+
|| grant.grantGeneration !== operation.expectedGrantGeneration) {
|
|
403
|
+
throw new Error('Selected OpenAI login changed before reauthentication could start.');
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return operation;
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
public async changeOperation(updateId: string, operationId: string,
|
|
411
|
+
change: (operation: IStoredAuthorityDeviceOperation) => IStoredAuthorityDeviceOperation): Promise<IStoredAuthorityDeviceOperation> {
|
|
412
|
+
this.db();
|
|
413
|
+
try {
|
|
414
|
+
const stored = await AuthSwitchAuthorityOperationModel.exact.findStoredOne({ id: operationId });
|
|
415
|
+
if (!stored) throw new Error('Account operation was not found.');
|
|
416
|
+
const current = storedOperation(stored);
|
|
417
|
+
if (current.kind === 'preuse_openai') throw new Error('Account operation is not a device login.');
|
|
418
|
+
const draft = change(structuredClone(current));
|
|
419
|
+
const allowed: Record<IStoredAuthorityDeviceOperation['state'], IStoredAuthorityDeviceOperation['state'][]> = {
|
|
420
|
+
starting: ['pending', 'failed', 'cancelled', 'interrupted'],
|
|
421
|
+
pending: ['committing', 'failed', 'cancelled', 'interrupted'],
|
|
422
|
+
committing: ['complete', 'outcome_unknown'],
|
|
423
|
+
complete: [], failed: [], cancelled: [], interrupted: [], outcome_unknown: [],
|
|
424
|
+
};
|
|
425
|
+
if (draft.id !== current.id || draft.kind !== current.kind || draft.purpose !== current.purpose
|
|
426
|
+
|| draft.accountId !== current.accountId || draft.grantId !== current.grantId
|
|
427
|
+
|| draft.expectedGrantGeneration !== current.expectedGrantGeneration
|
|
428
|
+
|| draft.createdAt !== current.createdAt || draft.updateId !== updateId
|
|
429
|
+
|| draft.revision !== current.revision + 1 || !allowed[current.state].includes(draft.state)) {
|
|
430
|
+
throw new Error('Account operation transition is invalid.');
|
|
431
|
+
}
|
|
432
|
+
assertStoredAuthorityOperation(draft);
|
|
433
|
+
const result = await AuthSwitchAuthorityOperationModel.exact.transition({
|
|
434
|
+
current: stored, change: model => Object.assign(model, draft),
|
|
435
|
+
});
|
|
436
|
+
if (result.status !== 'transitioned') throw new Error('Account operation changed concurrently.');
|
|
437
|
+
return draft;
|
|
438
|
+
} catch (error) {
|
|
439
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
440
|
+
const stored = await this.readOperation(operationId);
|
|
441
|
+
if (stored?.kind !== 'preuse_openai' && stored?.updateId === updateId) return stored;
|
|
442
|
+
throw new Error('Account operation transition outcome is unknown.');
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Insert-or-read idempotency for one exact preuse request. Existing records are never resumed here. */
|
|
447
|
+
public async createPreuseOperation(operation: IStoredAuthorityPreuseOperation): Promise<{
|
|
448
|
+
operation: IStoredAuthorityPreuseOperation; created: boolean;
|
|
449
|
+
}> {
|
|
450
|
+
this.db();
|
|
451
|
+
assertStoredAuthorityOperation(operation);
|
|
452
|
+
const sameRequest = (current: IStoredAuthorityOperation): current is IStoredAuthorityPreuseOperation =>
|
|
453
|
+
current.kind === 'preuse_openai' && current.accountId === operation.accountId
|
|
454
|
+
&& current.grantId === operation.grantId && current.purpose === operation.purpose
|
|
455
|
+
&& current.requestHash === operation.requestHash && current.requestedModel === operation.requestedModel;
|
|
456
|
+
try {
|
|
457
|
+
return await this.transaction(async session => {
|
|
458
|
+
const existingStored = await AuthSwitchAuthorityOperationModel.exact.findStoredOne({ id: operation.id }, { session });
|
|
459
|
+
if (existingStored) {
|
|
460
|
+
const existing = storedOperation(existingStored);
|
|
461
|
+
if (!sameRequest(existing)) throw new Error('Preuse operation ID belongs to a different request.');
|
|
462
|
+
return { operation: existing, created: false };
|
|
463
|
+
}
|
|
464
|
+
const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: operation.accountId }, { session });
|
|
465
|
+
const grantStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: operation.grantId }, { session });
|
|
466
|
+
const account = accountStored ? AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored) : null;
|
|
467
|
+
const grant = grantStored ? AuthSwitchAuthorityGrantModel.exact.toPersisted(grantStored) : null;
|
|
468
|
+
if (!account || account.removed || account.providerId !== 'openai' || account.primaryGrantId !== operation.grantId
|
|
469
|
+
|| !grant || grant.accountId !== operation.accountId || grant.providerId !== 'openai'
|
|
470
|
+
|| grant.purpose !== 'openai_managed' || grant.owner !== 'daemon'
|
|
471
|
+
|| !['ready', 'retry_wait'].includes(grant.state)
|
|
472
|
+
|| grant.authorizationGeneration !== operation.authorizationGeneration) {
|
|
473
|
+
throw new Error('Selected OpenAI login is unavailable for preuse.');
|
|
474
|
+
}
|
|
475
|
+
const inserted = await AuthSwitchAuthorityOperationModel.exact.insert(operation, { session });
|
|
476
|
+
if (inserted.status !== 'inserted') throw new Error('Preuse operation ID already exists.');
|
|
477
|
+
return { operation, created: true };
|
|
478
|
+
});
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
481
|
+
const stored = await this.readOperation(operation.id);
|
|
482
|
+
if (stored && sameRequest(stored) && stored.updateId === operation.updateId) {
|
|
483
|
+
return { operation: stored, created: true };
|
|
484
|
+
}
|
|
485
|
+
throw new Error('Preuse operation creation outcome is unknown; no provider request was started.');
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The committed marker precedes the only quota-consuming provider call. */
|
|
490
|
+
public async markPreuseRequestMayHaveStarted(operationId: string, updateId: string, model: string,
|
|
491
|
+
grantGeneration: number, now: string): Promise<IStoredAuthorityPreuseOperation> {
|
|
492
|
+
try {
|
|
493
|
+
return await this.transaction(async session => {
|
|
494
|
+
const operationStored = await AuthSwitchAuthorityOperationModel.exact.findStoredOne({ id: operationId }, { session });
|
|
495
|
+
if (!operationStored) throw new Error('Preuse operation was not found.');
|
|
496
|
+
const current = storedOperation(operationStored);
|
|
497
|
+
if (current.kind !== 'preuse_openai' || current.state !== 'preparing') {
|
|
498
|
+
throw new Error('Preuse operation is no longer preparing.');
|
|
499
|
+
}
|
|
500
|
+
const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: current.accountId }, { session });
|
|
501
|
+
const grantStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: current.grantId }, { session });
|
|
502
|
+
const account = accountStored ? AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored) : null;
|
|
503
|
+
const grant = grantStored ? AuthSwitchAuthorityGrantModel.exact.toPersisted(grantStored) : null;
|
|
504
|
+
if (!account || account.removed || account.providerId !== 'openai' || account.primaryGrantId !== current.grantId
|
|
505
|
+
|| !grant || grant.accountId !== current.accountId || grant.providerId !== 'openai'
|
|
506
|
+
|| grant.purpose !== current.purpose || grant.owner !== 'daemon'
|
|
507
|
+
|| !['ready', 'retry_wait'].includes(grant.state)
|
|
508
|
+
|| grant.authorizationGeneration !== current.authorizationGeneration
|
|
509
|
+
|| grant.grantGeneration !== grantGeneration) {
|
|
510
|
+
throw new Error('Preuse login changed before its request marker was committed.');
|
|
511
|
+
}
|
|
512
|
+
const draft: IStoredAuthorityPreuseOperation = { ...current, model, grantGeneration,
|
|
513
|
+
state: 'request_may_have_started', updatedAt: now, revision: current.revision + 1, updateId };
|
|
514
|
+
assertStoredAuthorityOperation(draft);
|
|
515
|
+
const transitioned = await AuthSwitchAuthorityOperationModel.exact.transition({
|
|
516
|
+
current: operationStored, change: stored => Object.assign(stored, draft),
|
|
517
|
+
}, { session });
|
|
518
|
+
if (transitioned.status !== 'transitioned') throw new Error('Preuse operation changed concurrently.');
|
|
519
|
+
return draft;
|
|
520
|
+
});
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
523
|
+
const stored = await this.readOperation(operationId);
|
|
524
|
+
if (stored?.kind === 'preuse_openai' && stored.updateId === updateId
|
|
525
|
+
&& stored.state === 'request_may_have_started') return stored;
|
|
526
|
+
throw new Error('Preuse request-marker outcome is unknown; no provider request was started.');
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
public async changePreuseOperation(updateId: string, operationId: string,
|
|
531
|
+
change: (operation: IStoredAuthorityPreuseOperation) => IStoredAuthorityPreuseOperation): Promise<IStoredAuthorityPreuseOperation> {
|
|
532
|
+
this.db();
|
|
533
|
+
try {
|
|
534
|
+
const stored = await AuthSwitchAuthorityOperationModel.exact.findStoredOne({ id: operationId });
|
|
535
|
+
if (!stored) throw new Error('Preuse operation was not found.');
|
|
536
|
+
const current = storedOperation(stored);
|
|
537
|
+
if (current.kind !== 'preuse_openai') throw new Error('Operation is not a preuse request.');
|
|
538
|
+
const draft = change(structuredClone(current));
|
|
539
|
+
const allowed: Record<IStoredAuthorityPreuseOperation['state'], IStoredAuthorityPreuseOperation['state'][]> = {
|
|
540
|
+
preparing: ['request_may_have_started', 'not_sent'],
|
|
541
|
+
request_may_have_started: ['complete', 'outcome_unknown'],
|
|
542
|
+
complete: [], not_sent: [], outcome_unknown: [],
|
|
543
|
+
};
|
|
544
|
+
if (draft.id !== current.id || draft.kind !== current.kind || draft.purpose !== current.purpose
|
|
545
|
+
|| draft.accountId !== current.accountId || draft.grantId !== current.grantId
|
|
546
|
+
|| draft.requestHash !== current.requestHash || draft.requestedModel !== current.requestedModel
|
|
547
|
+
|| draft.authorizationGeneration !== current.authorizationGeneration
|
|
548
|
+
|| draft.createdAt !== current.createdAt || draft.updateId !== updateId
|
|
549
|
+
|| draft.revision !== current.revision + 1 || !allowed[current.state].includes(draft.state)) {
|
|
550
|
+
throw new Error('Preuse operation transition is invalid.');
|
|
551
|
+
}
|
|
552
|
+
assertStoredAuthorityOperation(draft);
|
|
553
|
+
const result = await AuthSwitchAuthorityOperationModel.exact.transition({
|
|
554
|
+
current: stored, change: model => Object.assign(model, draft),
|
|
555
|
+
});
|
|
556
|
+
if (result.status !== 'transitioned') throw new Error('Preuse operation changed concurrently.');
|
|
557
|
+
return draft;
|
|
558
|
+
} catch (error) {
|
|
559
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
560
|
+
const stored = await this.readOperation(operationId);
|
|
561
|
+
if (stored?.kind === 'preuse_openai' && stored.updateId === updateId) return stored;
|
|
562
|
+
throw new Error('Preuse operation transition outcome is unknown.');
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
222
566
|
public async migrationLedgerByStatus(status: IStoredAuthorityMigrationLedger['status'],
|
|
223
567
|
after: string | null, limit = 128): Promise<IStoredAuthorityMigrationLedger[]> {
|
|
224
568
|
this.db();
|
|
@@ -241,10 +585,153 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
241
585
|
});
|
|
242
586
|
}
|
|
243
587
|
|
|
244
|
-
public async
|
|
245
|
-
|
|
588
|
+
public async readAccountAndGrant(accountId: string, grantId: string): Promise<{
|
|
589
|
+
account: IStoredAuthorityAccount | null; grant: IStoredAuthorityGrant | null;
|
|
590
|
+
}> {
|
|
591
|
+
return this.transaction(async session => {
|
|
592
|
+
const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: accountId }, { session });
|
|
593
|
+
const grantStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: grantId }, { session });
|
|
594
|
+
const account = accountStored ? AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored) : null;
|
|
595
|
+
const grant = grantStored ? AuthSwitchAuthorityGrantModel.exact.toPersisted(grantStored) : null;
|
|
596
|
+
return { account, grant: grant?.accountId === accountId ? grant : null };
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Epoch, account and exact login are read from one authority snapshot. */
|
|
601
|
+
public async readUsageContext(accountId: string, loginId: string): Promise<IAuthSwitchUsageContext | null> {
|
|
602
|
+
return (await this.readUsageAccessView(accountId, loginId))?.context ?? null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Backend-only view used to validate a usage token before and after unsealing. */
|
|
606
|
+
public async readUsageAccessView(accountId: string, loginId: string): Promise<{
|
|
607
|
+
context: IAuthSwitchUsageContext; account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
|
|
608
|
+
} | null> {
|
|
609
|
+
if (!/^[a-f0-9]{64}$/.test(accountId) || !/^[a-f0-9]{64}$/.test(loginId)) {
|
|
610
|
+
throw new Error('Invalid authority usage identity.');
|
|
611
|
+
}
|
|
612
|
+
return this.transaction(async session => {
|
|
613
|
+
const meta = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
|
|
614
|
+
const account = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: accountId }, { session });
|
|
615
|
+
const grant = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: loginId }, { session });
|
|
616
|
+
if (!meta || !account || !grant || grant.accountId !== accountId) return null;
|
|
617
|
+
const accountRecord = AuthSwitchAuthorityAccountModel.exact.toPersisted(account);
|
|
618
|
+
const grantRecord = AuthSwitchAuthorityGrantModel.exact.toPersisted(grant);
|
|
619
|
+
return { context: usageContext(AuthSwitchAuthorityMetaModel.exact.toPersisted(meta), accountRecord, grantRecord),
|
|
620
|
+
account: accountRecord, grant: grantRecord };
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Coherent native source ownership, including the home handoff fence. */
|
|
625
|
+
public async readClaudeNativeUsageView(homeId: string, accountId: string, loginId: string): Promise<{
|
|
626
|
+
context: IAuthSwitchUsageContext; account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
|
|
627
|
+
home: IStoredAuthorityClaudeHome;
|
|
628
|
+
} | null> {
|
|
629
|
+
if (![homeId, accountId, loginId].every(id => /^[a-f0-9]{64}$/.test(id))) {
|
|
630
|
+
throw new Error('Invalid Claude native usage selector.');
|
|
631
|
+
}
|
|
632
|
+
return this.transaction(async session => {
|
|
633
|
+
const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
|
|
634
|
+
const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: accountId }, { session });
|
|
635
|
+
const grantStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: loginId }, { session });
|
|
636
|
+
const homeStored = await AuthSwitchAuthorityClaudeHomeModel.exact.findStoredOne({ id: homeId }, { session });
|
|
637
|
+
if (!metaStored || !accountStored || !grantStored || !homeStored
|
|
638
|
+
|| grantStored.accountId !== accountId || accountStored.removed
|
|
639
|
+
|| accountStored.providerId !== 'anthropic' || grantStored.providerId !== 'anthropic'
|
|
640
|
+
|| grantStored.purpose !== 'claude_host_native' || grantStored.owner !== 'claude_native'
|
|
641
|
+
|| grantStored.state !== 'native' || homeStored.status !== 'ready'
|
|
642
|
+
|| homeStored.pendingOperationId !== null || homeStored.activeAccountId !== accountId
|
|
643
|
+
|| homeStored.activeGrantId !== loginId) return null;
|
|
644
|
+
const account = AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored);
|
|
645
|
+
const grant = AuthSwitchAuthorityGrantModel.exact.toPersisted(grantStored);
|
|
646
|
+
return { context: usageContext(AuthSwitchAuthorityMetaModel.exact.toPersisted(metaStored), account, grant),
|
|
647
|
+
account, grant, home: AuthSwitchAuthorityClaudeHomeModel.exact.toPersisted(homeStored) };
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
public async readUsage(accountId: string, loginId: string): Promise<IAuthSwitchStoredUsage | null> {
|
|
652
|
+
this.db();
|
|
653
|
+
if (!/^[a-f0-9]{64}$/.test(accountId) || !/^[a-f0-9]{64}$/.test(loginId)) {
|
|
654
|
+
throw new Error('Invalid authority usage identity.');
|
|
655
|
+
}
|
|
656
|
+
const stored = await AuthSwitchAuthorityUsageModel.exact.findStoredOne({ id: authorityUsageId(accountId, loginId) });
|
|
657
|
+
if (!stored) return null;
|
|
658
|
+
const { id: _id, accountId: _accountId, loginId: _loginId, ...record } =
|
|
659
|
+
AuthSwitchAuthorityUsageModel.exact.toPersisted(stored);
|
|
660
|
+
return record;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Publish one credential-free usage result only if its authority and predecessor still match. */
|
|
664
|
+
public async publishUsageIfCurrent(context: IAuthSwitchUsageContext,
|
|
665
|
+
previousUpdateId: string | null, next: IAuthSwitchStoredUsage): Promise<boolean> {
|
|
666
|
+
const id = authorityUsageId(context.accountId, context.loginId);
|
|
667
|
+
const draft: IStoredAuthorityUsage = { id, accountId: context.accountId,
|
|
668
|
+
loginId: context.loginId, ...next };
|
|
669
|
+
assertStoredAuthorityUsage(draft);
|
|
670
|
+
if (!sameUsageSource(context, next.context)
|
|
671
|
+
|| (previousUpdateId !== null && !/^[a-f0-9-]{36}$/.test(previousUpdateId))) {
|
|
672
|
+
throw new Error('Authority usage publication has an invalid context or predecessor.');
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
return await this.transaction(async session => {
|
|
676
|
+
const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
|
|
677
|
+
const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: context.accountId }, { session });
|
|
678
|
+
const grantStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: context.loginId }, { session });
|
|
679
|
+
if (!metaStored || !accountStored || !grantStored || grantStored.accountId !== context.accountId) return false;
|
|
680
|
+
const actual = usageContext(AuthSwitchAuthorityMetaModel.exact.toPersisted(metaStored),
|
|
681
|
+
AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored),
|
|
682
|
+
AuthSwitchAuthorityGrantModel.exact.toPersisted(grantStored));
|
|
683
|
+
if (!sameUsageContext(context, actual) || actual.removed) return false;
|
|
684
|
+
if (actual.providerId === 'openai') {
|
|
685
|
+
if (actual.owner !== 'daemon' || actual.purpose !== 'openai_managed'
|
|
686
|
+
|| context.nativeSource !== null || !['ready', 'retry_wait'].includes(actual.grantState)) return false;
|
|
687
|
+
} else if (actual.providerId === 'anthropic' && actual.purpose === 'claude_host_native') {
|
|
688
|
+
if (actual.owner === 'daemon') {
|
|
689
|
+
if (context.nativeSource !== null || !['ready', 'retry_wait'].includes(actual.grantState)) return false;
|
|
690
|
+
} else if (actual.owner === 'claude_native' && actual.grantState === 'native'
|
|
691
|
+
&& context.nativeSource !== null) {
|
|
692
|
+
const homeStored = await AuthSwitchAuthorityClaudeHomeModel.exact.findStoredOne({
|
|
693
|
+
id: context.nativeSource.homeId }, { session });
|
|
694
|
+
if (!homeStored || homeStored.status !== 'ready' || homeStored.pendingOperationId !== null
|
|
695
|
+
|| homeStored.activeAccountId !== context.accountId
|
|
696
|
+
|| homeStored.activeGrantId !== context.loginId) return false;
|
|
697
|
+
} else return false;
|
|
698
|
+
} else return false;
|
|
699
|
+
const current = await AuthSwitchAuthorityUsageModel.exact.findStoredOne({ id }, { session });
|
|
700
|
+
if (current) {
|
|
701
|
+
const prior = AuthSwitchAuthorityUsageModel.exact.toPersisted(current);
|
|
702
|
+
if (sameUsageSource(prior.context, context)
|
|
703
|
+
? prior.updateId !== previousUpdateId : previousUpdateId !== null) return false;
|
|
704
|
+
const changed = await AuthSwitchAuthorityUsageModel.exact.transition({ current,
|
|
705
|
+
change: model => Object.assign(model, draft) }, { session });
|
|
706
|
+
if (changed.status !== 'transitioned') return false;
|
|
707
|
+
} else {
|
|
708
|
+
if (previousUpdateId !== null) return false;
|
|
709
|
+
const inserted = await AuthSwitchAuthorityUsageModel.exact.insert(draft, { session });
|
|
710
|
+
if (inserted.status !== 'inserted') return false;
|
|
711
|
+
}
|
|
712
|
+
await this.appendEvent(session, AuthSwitchAuthorityMetaModel.exact.toPersisted(metaStored),
|
|
713
|
+
next.updateId, 'account', context.accountId);
|
|
714
|
+
return true;
|
|
715
|
+
});
|
|
716
|
+
} catch (error) {
|
|
717
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError)) throw error;
|
|
718
|
+
if (error.code === 'unique_conflict') return false;
|
|
719
|
+
if (error.code !== 'ambiguous_write') throw error;
|
|
720
|
+
const [stored, event] = await Promise.all([
|
|
721
|
+
AuthSwitchAuthorityUsageModel.exact.findStoredOne({ id }),
|
|
722
|
+
AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: next.updateId }),
|
|
723
|
+
]);
|
|
724
|
+
if (stored?.updateId === next.updateId && event?.epoch === context.epoch
|
|
725
|
+
&& event.kind === 'account' && event.accountId === context.accountId
|
|
726
|
+
&& sameUsageSource(stored.context, context)) return true;
|
|
727
|
+
throw new Error('Authority usage publication outcome is unknown; the provider query must not be replayed.');
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
public async page(accountAfter: string | null, grantAfter: string | null, bindingAfter: string | null, limit: number): Promise<{
|
|
732
|
+
meta: IStoredAuthorityMeta; accounts: IStoredAuthorityAccount[]; grants: IStoredAuthorityGrant[];
|
|
246
733
|
bindings: IStoredAuthorityBinding[];
|
|
247
|
-
nextAccountCursor: string | null; nextBindingCursor: string | null;
|
|
734
|
+
nextAccountCursor: string | null; nextGrantCursor: string | null; nextBindingCursor: string | null;
|
|
248
735
|
}> {
|
|
249
736
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256) throw new Error('Invalid authority page size.');
|
|
250
737
|
return this.transaction(async session => {
|
|
@@ -254,28 +741,128 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
254
741
|
filter: accountAfter ? { id: { $gt: accountAfter } } : {},
|
|
255
742
|
sort: { id: 1 }, limit: limit + 1, session,
|
|
256
743
|
});
|
|
744
|
+
const grants = await AuthSwitchAuthorityGrantModel.exact.findStored({
|
|
745
|
+
filter: grantAfter ? { id: { $gt: grantAfter } } : {},
|
|
746
|
+
sort: { id: 1 }, limit: limit + 1, session,
|
|
747
|
+
});
|
|
257
748
|
const bindings = await AuthSwitchAuthorityBindingModel.exact.findStored({
|
|
258
749
|
filter: bindingAfter ? { id: { $gt: bindingAfter } } : {},
|
|
259
750
|
sort: { id: 1 }, limit: limit + 1, session,
|
|
260
751
|
});
|
|
261
752
|
const accountPage = accounts.slice(0, limit).map(item => AuthSwitchAuthorityAccountModel.exact.toPersisted(item));
|
|
753
|
+
const grantPage = grants.slice(0, limit).map(item => AuthSwitchAuthorityGrantModel.exact.toPersisted(item));
|
|
262
754
|
const bindingPage = bindings.slice(0, limit).map(item => AuthSwitchAuthorityBindingModel.exact.toPersisted(item));
|
|
263
|
-
const withGrants: Array<{ account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant | null }> = [];
|
|
264
|
-
for (const account of accountPage) {
|
|
265
|
-
const stored = account.primaryGrantId
|
|
266
|
-
? await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: account.primaryGrantId }, { session }) : null;
|
|
267
|
-
if (account.primaryGrantId && !stored) throw new Error('Authority account primary grant is missing.');
|
|
268
|
-
withGrants.push({ account, grant: stored ? AuthSwitchAuthorityGrantModel.exact.toPersisted(stored) : null });
|
|
269
|
-
}
|
|
270
755
|
return {
|
|
271
756
|
meta: AuthSwitchAuthorityMetaModel.exact.toPersisted(meta),
|
|
272
|
-
accounts:
|
|
757
|
+
accounts: accountPage, grants: grantPage, bindings: bindingPage,
|
|
273
758
|
nextAccountCursor: accounts.length > limit ? accountPage.at(-1)!.id : null,
|
|
759
|
+
nextGrantCursor: grants.length > limit ? grantPage.at(-1)!.id : null,
|
|
274
760
|
nextBindingCursor: bindings.length > limit ? bindingPage.at(-1)!.id : null,
|
|
275
761
|
};
|
|
276
762
|
});
|
|
277
763
|
}
|
|
278
764
|
|
|
765
|
+
/**
|
|
766
|
+
* One bounded, repeatable-read diagnostic page. Its metadata revision is authority state,
|
|
767
|
+
* not a complete change clock for every native-home record.
|
|
768
|
+
*/
|
|
769
|
+
public async doctorPage(cursors: {
|
|
770
|
+
accountAfter: string | null;
|
|
771
|
+
loginAfter: string | null;
|
|
772
|
+
codexHomeAfter: string | null;
|
|
773
|
+
claudeHomeAfter: string | null;
|
|
774
|
+
claudeHandoffAfter: string | null;
|
|
775
|
+
nativeHandoffAfter: string | null;
|
|
776
|
+
migrationAfter: string | null;
|
|
777
|
+
}, limit: number): Promise<{
|
|
778
|
+
observedAt: string;
|
|
779
|
+
meta: IStoredAuthorityMeta;
|
|
780
|
+
accounts: IStoredAuthorityAccount[];
|
|
781
|
+
logins: IStoredAuthorityGrant[];
|
|
782
|
+
codexHomes: IStoredAuthorityCodexHome[];
|
|
783
|
+
claudeHomes: IStoredAuthorityClaudeHome[];
|
|
784
|
+
claudeHandoffs: IStoredAuthorityClaudeHandoff[];
|
|
785
|
+
nativeHandoffs: IStoredAuthorityHandoff[];
|
|
786
|
+
migrations: IStoredAuthorityMigrationLedger[];
|
|
787
|
+
nextAccountCursor: string | null;
|
|
788
|
+
nextLoginCursor: string | null;
|
|
789
|
+
nextCodexHomeCursor: string | null;
|
|
790
|
+
nextClaudeHomeCursor: string | null;
|
|
791
|
+
nextClaudeHandoffCursor: string | null;
|
|
792
|
+
nextNativeHandoffCursor: string | null;
|
|
793
|
+
nextMigrationCursor: string | null;
|
|
794
|
+
}> {
|
|
795
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 128) {
|
|
796
|
+
throw new Error('Invalid authority doctor page size.');
|
|
797
|
+
}
|
|
798
|
+
const hashCursor = (value: string | null): boolean => value === null || /^[a-f0-9]{64}$/.test(value);
|
|
799
|
+
if (![cursors.accountAfter, cursors.loginAfter, cursors.codexHomeAfter, cursors.claudeHomeAfter,
|
|
800
|
+
cursors.nativeHandoffAfter, cursors.migrationAfter].every(hashCursor)
|
|
801
|
+
|| (cursors.claudeHandoffAfter !== null && !/^[a-f0-9-]{36}$/.test(cursors.claudeHandoffAfter))) {
|
|
802
|
+
throw new Error('Invalid authority doctor cursor.');
|
|
803
|
+
}
|
|
804
|
+
return this.transaction(async session => {
|
|
805
|
+
const meta = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
|
|
806
|
+
if (!meta) throw new Error('Authswitch authority metadata is missing.');
|
|
807
|
+
const accounts = await AuthSwitchAuthorityAccountModel.exact.findStored({
|
|
808
|
+
filter: cursors.accountAfter ? { id: { $gt: cursors.accountAfter } } : {},
|
|
809
|
+
sort: { id: 1 }, limit: limit + 1, session,
|
|
810
|
+
});
|
|
811
|
+
const logins = await AuthSwitchAuthorityGrantModel.exact.findStored({
|
|
812
|
+
filter: cursors.loginAfter ? { id: { $gt: cursors.loginAfter } } : {},
|
|
813
|
+
sort: { id: 1 }, limit: limit + 1, session,
|
|
814
|
+
});
|
|
815
|
+
const codexHomes = await AuthSwitchAuthorityCodexHomeModel.exact.findStored({
|
|
816
|
+
filter: cursors.codexHomeAfter ? { id: { $gt: cursors.codexHomeAfter } } : {},
|
|
817
|
+
sort: { id: 1 }, limit: limit + 1, session,
|
|
818
|
+
});
|
|
819
|
+
const claudeHomes = await AuthSwitchAuthorityClaudeHomeModel.exact.findStored({
|
|
820
|
+
filter: cursors.claudeHomeAfter ? { id: { $gt: cursors.claudeHomeAfter } } : {},
|
|
821
|
+
sort: { id: 1 }, limit: limit + 1, session,
|
|
822
|
+
});
|
|
823
|
+
const claudeHandoffs = await AuthSwitchAuthorityClaudeHandoffModel.exact.findStored({
|
|
824
|
+
filter: cursors.claudeHandoffAfter ? { id: { $gt: cursors.claudeHandoffAfter } } : {},
|
|
825
|
+
sort: { id: 1 }, limit: limit + 1, session,
|
|
826
|
+
});
|
|
827
|
+
const nativeHandoffs = await AuthSwitchAuthorityHandoffModel.exact.findStored({
|
|
828
|
+
filter: cursors.nativeHandoffAfter ? { id: { $gt: cursors.nativeHandoffAfter } } : {},
|
|
829
|
+
sort: { id: 1 }, limit: limit + 1, session,
|
|
830
|
+
});
|
|
831
|
+
const migrations = await AuthSwitchAuthorityMigrationLedgerModel.exact.findStored({
|
|
832
|
+
filter: cursors.migrationAfter ? { id: { $gt: cursors.migrationAfter } } : {},
|
|
833
|
+
sort: { id: 1 }, limit: limit + 1, session,
|
|
834
|
+
});
|
|
835
|
+
const accountPage = accounts.slice(0, limit).map(item => AuthSwitchAuthorityAccountModel.exact.toPersisted(item));
|
|
836
|
+
const loginPage = logins.slice(0, limit).map(item => AuthSwitchAuthorityGrantModel.exact.toPersisted(item));
|
|
837
|
+
const codexHomePage = codexHomes.slice(0, limit).map(item => AuthSwitchAuthorityCodexHomeModel.exact.toPersisted(item));
|
|
838
|
+
const claudeHomePage = claudeHomes.slice(0, limit).map(item => AuthSwitchAuthorityClaudeHomeModel.exact.toPersisted(item));
|
|
839
|
+
const claudeHandoffPage = claudeHandoffs.slice(0, limit)
|
|
840
|
+
.map(item => AuthSwitchAuthorityClaudeHandoffModel.exact.toPersisted(item));
|
|
841
|
+
const nativeHandoffPage = nativeHandoffs.slice(0, limit)
|
|
842
|
+
.map(item => AuthSwitchAuthorityHandoffModel.exact.toPersisted(item));
|
|
843
|
+
const migrationPage = migrations.slice(0, limit)
|
|
844
|
+
.map(item => AuthSwitchAuthorityMigrationLedgerModel.exact.toPersisted(item));
|
|
845
|
+
return {
|
|
846
|
+
observedAt: new Date().toISOString(),
|
|
847
|
+
meta: AuthSwitchAuthorityMetaModel.exact.toPersisted(meta),
|
|
848
|
+
accounts: accountPage,
|
|
849
|
+
logins: loginPage,
|
|
850
|
+
codexHomes: codexHomePage,
|
|
851
|
+
claudeHomes: claudeHomePage,
|
|
852
|
+
claudeHandoffs: claudeHandoffPage,
|
|
853
|
+
nativeHandoffs: nativeHandoffPage,
|
|
854
|
+
migrations: migrationPage,
|
|
855
|
+
nextAccountCursor: accounts.length > limit ? accountPage.at(-1)!.id : null,
|
|
856
|
+
nextLoginCursor: logins.length > limit ? loginPage.at(-1)!.id : null,
|
|
857
|
+
nextCodexHomeCursor: codexHomes.length > limit ? codexHomePage.at(-1)!.id : null,
|
|
858
|
+
nextClaudeHomeCursor: claudeHomes.length > limit ? claudeHomePage.at(-1)!.id : null,
|
|
859
|
+
nextClaudeHandoffCursor: claudeHandoffs.length > limit ? claudeHandoffPage.at(-1)!.id : null,
|
|
860
|
+
nextNativeHandoffCursor: nativeHandoffs.length > limit ? nativeHandoffPage.at(-1)!.id : null,
|
|
861
|
+
nextMigrationCursor: migrations.length > limit ? migrationPage.at(-1)!.id : null,
|
|
862
|
+
};
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
279
866
|
public async grantsByState(state: IStoredAuthorityGrant['state'], after: string | null, limit = 128): Promise<IStoredAuthorityGrant[]> {
|
|
280
867
|
this.db();
|
|
281
868
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256) throw new Error('Invalid authority scan size.');
|
|
@@ -338,18 +925,27 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
338
925
|
}
|
|
339
926
|
|
|
340
927
|
private async persistGrant(session: TSession, current: Awaited<ReturnType<typeof AuthSwitchAuthorityGrantModel.exact.findStoredOne>>,
|
|
341
|
-
draft: IStoredAuthorityGrant): Promise<void> {
|
|
928
|
+
draft: IStoredAuthorityGrant, allowClaudeHandoff = false): Promise<void> {
|
|
342
929
|
assertStoredAuthorityGrant(draft);
|
|
343
930
|
if (current) {
|
|
344
931
|
const prior = AuthSwitchAuthorityGrantModel.exact.toPersisted(current);
|
|
345
932
|
if (draft.id !== prior.id || draft.accountId !== prior.accountId
|
|
346
933
|
|| draft.providerId !== prior.providerId || draft.purpose !== prior.purpose
|
|
347
934
|
|| draft.audience !== prior.audience) throw new Error('Grant identity cannot change.');
|
|
935
|
+
if (!allowClaudeHandoff && draft.purpose === 'claude_host_native'
|
|
936
|
+
&& (['handoff_pending', 'handoff_quarantined'].includes(draft.state)
|
|
937
|
+
|| ['handoff_pending', 'handoff_quarantined'].includes(prior.state)
|
|
938
|
+
|| draft.owner !== prior.owner)) {
|
|
939
|
+
throw new Error('Claude native ownership requires the two-grant handoff transaction.');
|
|
940
|
+
}
|
|
348
941
|
const result = await AuthSwitchAuthorityGrantModel.exact.transition({
|
|
349
942
|
current, change: model => Object.assign(model, draft),
|
|
350
943
|
}, { session });
|
|
351
944
|
if (result.status !== 'transitioned') throw new Error('Grant changed concurrently.');
|
|
352
945
|
} else {
|
|
946
|
+
if (!allowClaudeHandoff && ['handoff_pending', 'handoff_quarantined'].includes(draft.state)) {
|
|
947
|
+
throw new Error('Claude handoff cannot begin with an unassigned grant.');
|
|
948
|
+
}
|
|
353
949
|
const result = await AuthSwitchAuthorityGrantModel.exact.insert(draft, { session });
|
|
354
950
|
if (result.status !== 'inserted') throw new Error('Grant was added concurrently.');
|
|
355
951
|
}
|
|
@@ -388,7 +984,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
388
984
|
return { meta, ...draft };
|
|
389
985
|
});
|
|
390
986
|
} catch (error) {
|
|
391
|
-
if (!(error instanceof plugins.
|
|
987
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
392
988
|
const event = await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId });
|
|
393
989
|
const { account, grant } = await this.readAccountAndPrimaryGrant(accountId);
|
|
394
990
|
if (event && account?.updateId === updateId && grant?.updateId === updateId) {
|
|
@@ -398,6 +994,75 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
398
994
|
}
|
|
399
995
|
}
|
|
400
996
|
|
|
997
|
+
/** Device-login result, account and grant are one commit; a lost RPC response remains discoverable by operation ID. */
|
|
998
|
+
public async commitLoginOperation(updateId: string, operationId: string, accountId: string, grantId: string,
|
|
999
|
+
change: (account: IStoredAuthorityAccount | null, grant: IStoredAuthorityGrant | null) => {
|
|
1000
|
+
account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
|
|
1001
|
+
}): Promise<{ account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant; operation: IStoredAuthorityDeviceOperation }> {
|
|
1002
|
+
try {
|
|
1003
|
+
return await this.transaction(async session => {
|
|
1004
|
+
const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
|
|
1005
|
+
const operationStored = await AuthSwitchAuthorityOperationModel.exact.findStoredOne({ id: operationId }, { session });
|
|
1006
|
+
if (!metaStored || !operationStored) throw new Error('Device operation is unavailable.');
|
|
1007
|
+
const operation = storedOperation(operationStored);
|
|
1008
|
+
if (operation.kind === 'preuse_openai' || operation.state !== 'committing' || operation.commitId !== updateId
|
|
1009
|
+
|| (operation.kind === 'reauth_openai' && (operation.accountId !== accountId || operation.grantId !== grantId))) {
|
|
1010
|
+
throw new Error('Device operation is not admitted for this login.');
|
|
1011
|
+
}
|
|
1012
|
+
const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: accountId }, { session });
|
|
1013
|
+
const grantStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: grantId }, { session });
|
|
1014
|
+
const oldGrant = grantStored ? AuthSwitchAuthorityGrantModel.exact.toPersisted(grantStored) : null;
|
|
1015
|
+
if (operation.kind === 'reauth_openai' && oldGrant?.grantGeneration !== operation.expectedGrantGeneration) {
|
|
1016
|
+
throw new Error('Login changed while device reauthentication was pending.');
|
|
1017
|
+
}
|
|
1018
|
+
const draft = change(
|
|
1019
|
+
accountStored ? structuredClone(AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored)) : null,
|
|
1020
|
+
oldGrant ? structuredClone(oldGrant) : null);
|
|
1021
|
+
if (draft.account.id !== accountId || draft.grant.id !== grantId || draft.grant.accountId !== accountId
|
|
1022
|
+
|| draft.account.primaryGrantId !== grantId || draft.account.providerId !== draft.grant.providerId
|
|
1023
|
+
|| draft.account.updateId !== updateId || draft.grant.updateId !== updateId) {
|
|
1024
|
+
throw new Error('Device login account/grant change has an invalid identity.');
|
|
1025
|
+
}
|
|
1026
|
+
if (accountStored && accountStored.primaryGrantId !== grantId) {
|
|
1027
|
+
throw new Error('Primary grant identity cannot be replaced.');
|
|
1028
|
+
}
|
|
1029
|
+
if (grantStored && ['legacy_native', 'claude_native'].includes(grantStored.owner)
|
|
1030
|
+
&& draft.grant.owner === 'daemon') {
|
|
1031
|
+
throw new Error('Native ownership requires a verified handoff.');
|
|
1032
|
+
}
|
|
1033
|
+
const completed: IStoredAuthorityDeviceOperation = {
|
|
1034
|
+
...operation, state: 'complete', prompt: null,
|
|
1035
|
+
result: { accountId, grantId, accountRevision: draft.account.revision,
|
|
1036
|
+
grantGeneration: draft.grant.grantGeneration },
|
|
1037
|
+
error: null, updatedAt: draft.account.statusObservedAt, finishedAt: draft.account.statusObservedAt,
|
|
1038
|
+
revision: operation.revision + 1, updateId,
|
|
1039
|
+
};
|
|
1040
|
+
assertStoredAuthorityOperation(completed);
|
|
1041
|
+
await this.persistAccount(session, accountStored, draft.account);
|
|
1042
|
+
await this.persistGrant(session, grantStored, draft.grant);
|
|
1043
|
+
const transitioned = await AuthSwitchAuthorityOperationModel.exact.transition({
|
|
1044
|
+
current: operationStored, change: model => Object.assign(model, completed),
|
|
1045
|
+
}, { session });
|
|
1046
|
+
if (transitioned.status !== 'transitioned') throw new Error('Device operation changed concurrently.');
|
|
1047
|
+
await this.appendEvent(session, AuthSwitchAuthorityMetaModel.exact.toPersisted(metaStored),
|
|
1048
|
+
updateId, 'account', accountId);
|
|
1049
|
+
return { ...draft, operation: completed };
|
|
1050
|
+
});
|
|
1051
|
+
} catch (error) {
|
|
1052
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
1053
|
+
const [event, operation, pair] = await Promise.all([
|
|
1054
|
+
AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId }),
|
|
1055
|
+
this.readOperation(operationId), this.readAccountAndPrimaryGrant(accountId),
|
|
1056
|
+
]);
|
|
1057
|
+
if (event && operation?.kind !== 'preuse_openai' && operation?.state === 'complete' && operation.updateId === updateId
|
|
1058
|
+
&& pair.account?.updateId === updateId && pair.grant?.updateId === updateId
|
|
1059
|
+
&& operation.result?.accountId === accountId && operation.result.grantId === grantId) {
|
|
1060
|
+
return { account: pair.account, grant: pair.grant, operation };
|
|
1061
|
+
}
|
|
1062
|
+
throw new Error('Device login commit outcome is unknown; inspect the durable operation before retrying.');
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
401
1066
|
/** Grant transition and public account revision share a transaction and one event. */
|
|
402
1067
|
public async changeGrant(updateId: string, grantId: string,
|
|
403
1068
|
change: (grant: IStoredAuthorityGrant, account: IStoredAuthorityAccount) => IStoredAuthorityGrant): Promise<{
|
|
@@ -416,6 +1081,11 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
416
1081
|
if (draft.id !== grantId || draft.accountId !== account.id || draft.updateId !== updateId) {
|
|
417
1082
|
throw new Error('Grant change has an invalid identity.');
|
|
418
1083
|
}
|
|
1084
|
+
if (grant.purpose === 'claude_host_native' && draft.state === 'exchange_may_have_been_sent'
|
|
1085
|
+
&& grant.state !== 'exchange_may_have_been_sent'
|
|
1086
|
+
&& !(await this.hasVerifiedClaudeGrantReceipt(account.id, grant.id, session))) {
|
|
1087
|
+
throw new Error('Claude refresh requires a verified import receipt.');
|
|
1088
|
+
}
|
|
419
1089
|
const accountDraft: IStoredAuthorityAccount = { ...account, revision: account.revision + 1,
|
|
420
1090
|
statusObservedAt: draft.statusObservedAt, updateId };
|
|
421
1091
|
await this.persistGrant(session, grantStored, draft);
|
|
@@ -425,7 +1095,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
425
1095
|
return { meta, account: accountDraft, grant: draft };
|
|
426
1096
|
});
|
|
427
1097
|
} catch (error) {
|
|
428
|
-
if (!(error instanceof plugins.
|
|
1098
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
429
1099
|
const event = await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId });
|
|
430
1100
|
const grant = await this.readGrant(grantId);
|
|
431
1101
|
const account = grant ? await this.readAccount(grant.accountId) : null;
|
|
@@ -463,7 +1133,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
463
1133
|
return { account: updatedAccount, grant: draft };
|
|
464
1134
|
});
|
|
465
1135
|
} catch (error) {
|
|
466
|
-
if (!(error instanceof plugins.
|
|
1136
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
467
1137
|
const event = await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId });
|
|
468
1138
|
const account = await this.readAccount(accountId);
|
|
469
1139
|
const grant = await this.readGrant(grantId);
|
|
@@ -504,6 +1174,9 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
504
1174
|
if (grant.state === 'exchange_may_have_been_sent') {
|
|
505
1175
|
throw new Error('Wait for the account refresh to finish before removing it.');
|
|
506
1176
|
}
|
|
1177
|
+
if (['handoff_pending', 'handoff_quarantined'].includes(grant.state)) {
|
|
1178
|
+
throw new Error('Resolve the Claude native handoff before removing this account.');
|
|
1179
|
+
}
|
|
507
1180
|
});
|
|
508
1181
|
await scan(async (grant, stored) => {
|
|
509
1182
|
if (grant.state === 'removed') return;
|
|
@@ -550,7 +1223,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
550
1223
|
return removed;
|
|
551
1224
|
});
|
|
552
1225
|
} catch (error) {
|
|
553
|
-
if (!(error instanceof plugins.
|
|
1226
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
554
1227
|
const event = await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId });
|
|
555
1228
|
const account = await this.readAccount(accountId);
|
|
556
1229
|
if (event && account?.removed && account.updateId === updateId) return account;
|
|
@@ -576,7 +1249,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
576
1249
|
return { meta, account: draft };
|
|
577
1250
|
});
|
|
578
1251
|
} catch (error) {
|
|
579
|
-
if (!(error instanceof plugins.
|
|
1252
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
580
1253
|
const event = await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId });
|
|
581
1254
|
const account = await this.readAccount(accountId);
|
|
582
1255
|
if (event && account?.updateId === updateId) return { meta: await this.readMeta(), account };
|
|
@@ -584,7 +1257,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
584
1257
|
}
|
|
585
1258
|
}
|
|
586
1259
|
|
|
587
|
-
public async changeBinding(updateId: string, bindingId: string, accountId: string,
|
|
1260
|
+
public async changeBinding(updateId: string, bindingId: string, accountId: string, grantId: string,
|
|
588
1261
|
change: (draft: IStoredAuthorityBinding | null, account: IStoredAuthorityAccount,
|
|
589
1262
|
grant: IStoredAuthorityGrant | null) => IStoredAuthorityBinding): Promise<{
|
|
590
1263
|
meta: IStoredAuthorityMeta; binding: IStoredAuthorityBinding;
|
|
@@ -595,13 +1268,14 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
595
1268
|
const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: accountId }, { session });
|
|
596
1269
|
if (!metaStored || !accountStored) throw new Error('Account is not available for this runtime.');
|
|
597
1270
|
const account = AuthSwitchAuthorityAccountModel.exact.toPersisted(accountStored);
|
|
598
|
-
const grantStored =
|
|
599
|
-
? await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: account.primaryGrantId }, { session }) : null;
|
|
1271
|
+
const grantStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: grantId }, { session });
|
|
600
1272
|
const grant = grantStored ? AuthSwitchAuthorityGrantModel.exact.toPersisted(grantStored) : null;
|
|
601
1273
|
const current = await AuthSwitchAuthorityBindingModel.exact.findStoredOne({ id: bindingId }, { session });
|
|
602
1274
|
const draft = change(current ? structuredClone(AuthSwitchAuthorityBindingModel.exact.toPersisted(current)) : null, account, grant);
|
|
603
1275
|
if (draft.id !== bindingId || draft.accountId !== accountId || draft.updateId !== updateId) throw new Error('Binding change has an invalid identity.');
|
|
604
|
-
if (draft.grantId !==
|
|
1276
|
+
if (draft.grantId !== grantId || grant?.accountId !== accountId) {
|
|
1277
|
+
throw new Error('Binding login does not belong to the account.');
|
|
1278
|
+
}
|
|
605
1279
|
if (current && (draft.runtime !== current.runtime || draft.scopeId !== current.scopeId)) {
|
|
606
1280
|
throw new Error('Runtime binding identity cannot change.');
|
|
607
1281
|
}
|
|
@@ -620,7 +1294,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
620
1294
|
return { meta, binding: draft };
|
|
621
1295
|
});
|
|
622
1296
|
} catch (error) {
|
|
623
|
-
if (!(error instanceof plugins.
|
|
1297
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
624
1298
|
const event = await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId });
|
|
625
1299
|
const binding = await this.readBinding(bindingId);
|
|
626
1300
|
if (event && binding?.updateId === updateId) return { meta: await this.readMeta(), binding };
|
|
@@ -628,14 +1302,15 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
628
1302
|
}
|
|
629
1303
|
}
|
|
630
1304
|
|
|
631
|
-
|
|
632
|
-
|
|
1305
|
+
private async revokeBindingExact(updateId: string, bindingId: string, capabilityHash: string,
|
|
1306
|
+
externalOnly: boolean): Promise<boolean> {
|
|
633
1307
|
try {
|
|
634
1308
|
return await this.transaction(async session => {
|
|
635
1309
|
const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
|
|
636
1310
|
if (!metaStored) throw new Error('Authswitch authority metadata is missing.');
|
|
637
1311
|
const current = await AuthSwitchAuthorityBindingModel.exact.findStoredOne({ id: bindingId }, { session });
|
|
638
|
-
if (!current || current.capabilityHash !== capabilityHash
|
|
1312
|
+
if (!current || current.capabilityHash !== capabilityHash
|
|
1313
|
+
|| (externalOnly && !['flex', 'opencode'].includes(current.runtime))) return false;
|
|
639
1314
|
const result = await AuthSwitchAuthorityBindingModel.exact.delete({ current }, { session });
|
|
640
1315
|
if (result.status !== 'deleted') throw new Error('Runtime binding changed during revocation.');
|
|
641
1316
|
await this.appendEvent(session, AuthSwitchAuthorityMetaModel.exact.toPersisted(metaStored),
|
|
@@ -643,13 +1318,26 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
643
1318
|
return true;
|
|
644
1319
|
});
|
|
645
1320
|
} catch (error) {
|
|
646
|
-
if (!(error instanceof plugins.
|
|
1321
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
647
1322
|
const event = await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId });
|
|
648
|
-
|
|
1323
|
+
const binding = await this.readBinding(bindingId);
|
|
1324
|
+
// Every bind mints a fresh random capability. An event plus absence or a different
|
|
1325
|
+
// capability therefore proves that this exact deletion committed before any successor.
|
|
1326
|
+
if (event && (!binding || binding.capabilityHash !== capabilityHash)) return true;
|
|
649
1327
|
throw new Error('Authswitch binding revocation outcome is unknown.');
|
|
650
1328
|
}
|
|
651
1329
|
}
|
|
652
1330
|
|
|
1331
|
+
/** Closing an authority-owned runtime revokes its exact capability after owned cleanup. */
|
|
1332
|
+
public revokeBinding(updateId: string, bindingId: string, capabilityHash: string): Promise<boolean> {
|
|
1333
|
+
return this.revokeBindingExact(updateId, bindingId, capabilityHash, false);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/** External callers can release only Flex/OpenCode bindings; managed Codex cleanup stays daemon-owned. */
|
|
1337
|
+
public releaseExternalBinding(updateId: string, bindingId: string, capabilityHash: string): Promise<boolean> {
|
|
1338
|
+
return this.revokeBindingExact(updateId, bindingId, capabilityHash, true);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
653
1341
|
/** Enrollment metadata is sealed by the caller and cannot become detached from an account. */
|
|
654
1342
|
public async changeEnrollment(updateId: string, enrollmentId: string, accountId: string,
|
|
655
1343
|
change: (current: IStoredAuthorityEnrollment | null) => IStoredAuthorityEnrollment): Promise<IStoredAuthorityEnrollment> {
|
|
@@ -678,7 +1366,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
678
1366
|
return draft;
|
|
679
1367
|
});
|
|
680
1368
|
} catch (error) {
|
|
681
|
-
if (!(error instanceof plugins.
|
|
1369
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
682
1370
|
const event = await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId });
|
|
683
1371
|
const enrollment = await this.readEnrollment(enrollmentId);
|
|
684
1372
|
if (event && enrollment?.updateId === updateId) return enrollment;
|
|
@@ -760,7 +1448,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
760
1448
|
return draft;
|
|
761
1449
|
});
|
|
762
1450
|
} catch (error) {
|
|
763
|
-
if (!(error instanceof plugins.
|
|
1451
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
764
1452
|
const event = await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: updateId });
|
|
765
1453
|
const handoff = await this.readHandoff(handoffId);
|
|
766
1454
|
const grant = await this.readGrant(grantId);
|
|
@@ -769,7 +1457,315 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
769
1457
|
}
|
|
770
1458
|
}
|
|
771
1459
|
|
|
772
|
-
|
|
1460
|
+
public async readClaudeHome(homeId: string): Promise<IStoredAuthorityClaudeHome | null> {
|
|
1461
|
+
this.db();
|
|
1462
|
+
if (!/^[a-f0-9]{64}$/.test(homeId)) throw new Error('Invalid Claude home selector.');
|
|
1463
|
+
const stored = await AuthSwitchAuthorityClaudeHomeModel.exact.findStoredOne({ id: homeId });
|
|
1464
|
+
return stored ? AuthSwitchAuthorityClaudeHomeModel.exact.toPersisted(stored) : null;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
public async readClaudeHandoff(operationId: string): Promise<IStoredAuthorityClaudeHandoff | null> {
|
|
1468
|
+
this.db();
|
|
1469
|
+
if (!/^[a-f0-9-]{36}$/.test(operationId)) throw new Error('Invalid Claude handoff selector.');
|
|
1470
|
+
const stored = await AuthSwitchAuthorityClaudeHandoffModel.exact.findStoredOne({ id: operationId });
|
|
1471
|
+
return stored ? AuthSwitchAuthorityClaudeHandoffModel.exact.toPersisted(stored) : null;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/** A verified backend import receipt, not an account snapshot, authorizes an inactive Claude grant. */
|
|
1475
|
+
public async hasVerifiedClaudeGrantReceipt(accountId: string, grantId: string,
|
|
1476
|
+
session?: TSession): Promise<boolean> {
|
|
1477
|
+
this.db();
|
|
1478
|
+
if (!/^[a-f0-9]{64}$/.test(accountId) || !/^[a-f0-9]{64}$/.test(grantId)) {
|
|
1479
|
+
throw new Error('Invalid Claude grant receipt selector.');
|
|
1480
|
+
}
|
|
1481
|
+
for (const status of ['verified', 'complete'] as const) {
|
|
1482
|
+
const stored = await AuthSwitchAuthorityMigrationLedgerModel.exact.findStored({
|
|
1483
|
+
filter: { grantId, status }, sort: { id: 1 }, limit: 128, session,
|
|
1484
|
+
});
|
|
1485
|
+
if (stored.some(item => item.accountId === accountId
|
|
1486
|
+
&& ['authswitch_stash', 'authswitch_backup', 'claude_native'].includes(item.sourceKind))) return true;
|
|
1487
|
+
if (stored.length === 128) throw new Error('Claude grant has too many migration receipts to verify.');
|
|
1488
|
+
}
|
|
1489
|
+
return false;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
public async claudeHandoffsByPhase(phase: IStoredAuthorityClaudeHandoff['phase'],
|
|
1493
|
+
after: string | null): Promise<IStoredAuthorityClaudeHandoff[]> {
|
|
1494
|
+
this.db();
|
|
1495
|
+
if (!['reserved', 'prepared', 'quarantined'].includes(phase)
|
|
1496
|
+
|| (after !== null && !/^[a-f0-9-]{36}$/.test(after))) throw new Error('Invalid Claude handoff scan.');
|
|
1497
|
+
const stored = await AuthSwitchAuthorityClaudeHandoffModel.exact.findStored({
|
|
1498
|
+
filter: after === null ? { phase } : { phase, id: { $gt: after } },
|
|
1499
|
+
sort: { id: 1 }, limit: 128,
|
|
1500
|
+
});
|
|
1501
|
+
return stored.map(item => AuthSwitchAuthorityClaudeHandoffModel.exact.toPersisted(item));
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
public async claudeHandoffsPage(after: string | null, limit = 128): Promise<{
|
|
1505
|
+
handoffs: IStoredAuthorityClaudeHandoff[]; nextCursor: string | null;
|
|
1506
|
+
}> {
|
|
1507
|
+
this.db();
|
|
1508
|
+
if ((after !== null && !/^[a-f0-9-]{36}$/.test(after))
|
|
1509
|
+
|| !Number.isSafeInteger(limit) || limit < 1 || limit > 256) throw new Error('Invalid Claude handoff page.');
|
|
1510
|
+
const stored = await AuthSwitchAuthorityClaudeHandoffModel.exact.findStored({
|
|
1511
|
+
filter: after === null ? {} : { id: { $gt: after } }, sort: { id: 1 }, limit: limit + 1,
|
|
1512
|
+
});
|
|
1513
|
+
const handoffs = stored.slice(0, limit).map(item => AuthSwitchAuthorityClaudeHandoffModel.exact.toPersisted(item));
|
|
1514
|
+
return { handoffs, nextCursor: stored.length > limit ? handoffs.at(-1)!.id : null };
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/** Only a verified backend import receipt may adopt a native home. No startup autodiscovery exists. */
|
|
1518
|
+
public async registerClaudeHomeFromReceipt(input: { homeId: string; accountId: string; grantId: string;
|
|
1519
|
+
receiptId: string; sourceDigest: string; accountUuid: string; organizationUuid: string;
|
|
1520
|
+
updateId: string; now: string }): Promise<IStoredAuthorityClaudeHome> {
|
|
1521
|
+
const { homeId, accountId, grantId, receiptId, sourceDigest, updateId } = input;
|
|
1522
|
+
for (const value of [homeId, accountId, grantId, receiptId, sourceDigest]) {
|
|
1523
|
+
if (!/^[a-f0-9]{64}$/.test(value)) throw new Error('Invalid verified Claude home identity.');
|
|
1524
|
+
}
|
|
1525
|
+
if (!/^[a-f0-9-]{36}$/.test(updateId)) throw new Error('Invalid Claude home update identity.');
|
|
1526
|
+
try {
|
|
1527
|
+
return await this.transaction(async session => {
|
|
1528
|
+
const receiptStored = await AuthSwitchAuthorityMigrationLedgerModel.exact.findStoredOne({ id: receiptId }, { session });
|
|
1529
|
+
const accountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: accountId }, { session });
|
|
1530
|
+
const grantStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: grantId }, { session });
|
|
1531
|
+
const current = await AuthSwitchAuthorityClaudeHomeModel.exact.findStoredOne({ id: homeId }, { session });
|
|
1532
|
+
if (!receiptStored || !['verified', 'complete'].includes(receiptStored.status)
|
|
1533
|
+
|| receiptStored.sourceKind !== 'claude_native' || receiptStored.sourcePathHash !== homeId
|
|
1534
|
+
|| receiptStored.sourceDigest !== sourceDigest || receiptStored.accountId !== accountId
|
|
1535
|
+
|| receiptStored.grantId !== grantId || !accountStored || accountStored.removed
|
|
1536
|
+
|| accountStored.providerId !== 'anthropic' || accountStored.subject !== input.accountUuid
|
|
1537
|
+
|| accountStored.workspaceId !== input.organizationUuid || !grantStored
|
|
1538
|
+
|| grantStored.accountId !== accountId || grantStored.purpose !== 'claude_host_native'
|
|
1539
|
+
|| grantStored.owner !== 'claude_native' || grantStored.state !== 'native'
|
|
1540
|
+
|| grantStored.ciphertext !== null) {
|
|
1541
|
+
throw new Error('Verified Claude native source mapping is unavailable.');
|
|
1542
|
+
}
|
|
1543
|
+
if (current) {
|
|
1544
|
+
if (current.activeAccountId !== accountId || current.activeGrantId !== grantId) {
|
|
1545
|
+
throw new Error('Claude native home belongs to another grant.');
|
|
1546
|
+
}
|
|
1547
|
+
return AuthSwitchAuthorityClaudeHomeModel.exact.toPersisted(current);
|
|
1548
|
+
}
|
|
1549
|
+
const home: IStoredAuthorityClaudeHome = { id: homeId, activeAccountId: accountId,
|
|
1550
|
+
activeGrantId: grantId, pendingOperationId: null, status: 'ready', revision: 1,
|
|
1551
|
+
createdAt: input.now, updateId };
|
|
1552
|
+
assertStoredAuthorityClaudeHome(home);
|
|
1553
|
+
const inserted = await AuthSwitchAuthorityClaudeHomeModel.exact.insert(home, { session });
|
|
1554
|
+
if (inserted.status !== 'inserted') throw new Error('Claude native home changed concurrently.');
|
|
1555
|
+
return home;
|
|
1556
|
+
});
|
|
1557
|
+
} catch (error) {
|
|
1558
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError)
|
|
1559
|
+
|| !['ambiguous_write', 'unique_conflict'].includes(error.code)) throw error;
|
|
1560
|
+
const home = await this.readClaudeHome(homeId);
|
|
1561
|
+
if (home?.updateId === updateId && home.activeAccountId === accountId && home.activeGrantId === grantId) return home;
|
|
1562
|
+
throw new Error('Claude native home registration outcome is unknown.');
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
public async readClaudeHandoffContext(operationId: string): Promise<{
|
|
1567
|
+
handoff: IStoredAuthorityClaudeHandoff; home: IStoredAuthorityClaudeHome;
|
|
1568
|
+
outgoing: IStoredAuthorityGrant; incoming: IStoredAuthorityGrant;
|
|
1569
|
+
outgoingAccount: IStoredAuthorityAccount; incomingAccount: IStoredAuthorityAccount;
|
|
1570
|
+
} | null> {
|
|
1571
|
+
if (!/^[a-f0-9-]{36}$/.test(operationId)) throw new Error('Invalid Claude handoff selector.');
|
|
1572
|
+
return this.transaction(async session => {
|
|
1573
|
+
const stored = await AuthSwitchAuthorityClaudeHandoffModel.exact.findStoredOne({ id: operationId }, { session });
|
|
1574
|
+
if (!stored) return null;
|
|
1575
|
+
const handoff = AuthSwitchAuthorityClaudeHandoffModel.exact.toPersisted(stored);
|
|
1576
|
+
const homeStored = await AuthSwitchAuthorityClaudeHomeModel.exact.findStoredOne({ id: handoff.homeId }, { session });
|
|
1577
|
+
const outgoingStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: handoff.outgoingGrantId }, { session });
|
|
1578
|
+
const incomingStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: handoff.incomingGrantId }, { session });
|
|
1579
|
+
const outgoingAccountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: handoff.outgoingAccountId }, { session });
|
|
1580
|
+
const incomingAccountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: handoff.incomingAccountId }, { session });
|
|
1581
|
+
if (!homeStored || !outgoingStored || !incomingStored || !outgoingAccountStored || !incomingAccountStored) {
|
|
1582
|
+
throw new Error('Claude handoff references a missing authority record.');
|
|
1583
|
+
}
|
|
1584
|
+
return { handoff,
|
|
1585
|
+
home: AuthSwitchAuthorityClaudeHomeModel.exact.toPersisted(homeStored),
|
|
1586
|
+
outgoing: AuthSwitchAuthorityGrantModel.exact.toPersisted(outgoingStored),
|
|
1587
|
+
incoming: AuthSwitchAuthorityGrantModel.exact.toPersisted(incomingStored),
|
|
1588
|
+
outgoingAccount: AuthSwitchAuthorityAccountModel.exact.toPersisted(outgoingAccountStored),
|
|
1589
|
+
incomingAccount: AuthSwitchAuthorityAccountModel.exact.toPersisted(incomingAccountStored) };
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/** One phase advances both grants, home, journal and both account events atomically. */
|
|
1594
|
+
public async changeClaudeHandoff(input: { operationId: string; homeId: string; incomingGrantId: string;
|
|
1595
|
+
updateId: string; eventIds: readonly [string, string];
|
|
1596
|
+
change: (current: {
|
|
1597
|
+
home: IStoredAuthorityClaudeHome; handoff: IStoredAuthorityClaudeHandoff | null;
|
|
1598
|
+
outgoing: IStoredAuthorityGrant; incoming: IStoredAuthorityGrant;
|
|
1599
|
+
outgoingAccount: IStoredAuthorityAccount; incomingAccount: IStoredAuthorityAccount;
|
|
1600
|
+
}) => TClaudeHandoffDraft }): Promise<TClaudeHandoffDraft> {
|
|
1601
|
+
const { operationId, homeId, incomingGrantId, updateId, eventIds } = input;
|
|
1602
|
+
if (!/^[a-f0-9-]{36}$/.test(operationId) || !/^[a-f0-9]{64}$/.test(homeId)
|
|
1603
|
+
|| !/^[a-f0-9]{64}$/.test(incomingGrantId)
|
|
1604
|
+
|| ![updateId, ...eventIds].every(value => /^[a-f0-9-]{36}$/.test(value))
|
|
1605
|
+
|| new Set([updateId, ...eventIds]).size !== 3) throw new Error('Invalid Claude handoff identity.');
|
|
1606
|
+
let expectedPhase: IStoredAuthorityClaudeHandoff['phase'] | null = null;
|
|
1607
|
+
try {
|
|
1608
|
+
return await this.transaction(async session => {
|
|
1609
|
+
const metaStored = await AuthSwitchAuthorityMetaModel.exact.findStoredOne({ id: 'authswitch-authority' }, { session });
|
|
1610
|
+
const homeStored = await AuthSwitchAuthorityClaudeHomeModel.exact.findStoredOne({ id: homeId }, { session });
|
|
1611
|
+
const handoffStored = await AuthSwitchAuthorityClaudeHandoffModel.exact.findStoredOne({ id: operationId }, { session });
|
|
1612
|
+
if (!metaStored || !homeStored) throw new Error('Verified Claude home is unavailable.');
|
|
1613
|
+
const home = AuthSwitchAuthorityClaudeHomeModel.exact.toPersisted(homeStored);
|
|
1614
|
+
const handoff = handoffStored ? AuthSwitchAuthorityClaudeHandoffModel.exact.toPersisted(handoffStored) : null;
|
|
1615
|
+
if (home.status !== 'ready' || (handoff === null ? home.pendingOperationId !== null
|
|
1616
|
+
: home.pendingOperationId !== operationId || handoff.homeId !== homeId
|
|
1617
|
+
|| handoff.incomingGrantId !== incomingGrantId)) {
|
|
1618
|
+
throw new Error('Claude home has another pending or quarantined operation.');
|
|
1619
|
+
}
|
|
1620
|
+
const outgoingId = handoff?.outgoingGrantId ?? home.activeGrantId;
|
|
1621
|
+
if (outgoingId === incomingGrantId) throw new Error('Claude home already owns the selected grant.');
|
|
1622
|
+
const outgoingStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: outgoingId }, { session });
|
|
1623
|
+
const incomingStored = await AuthSwitchAuthorityGrantModel.exact.findStoredOne({ id: incomingGrantId }, { session });
|
|
1624
|
+
if (!outgoingStored || !incomingStored) throw new Error('Claude handoff grant is unavailable.');
|
|
1625
|
+
const outgoing = AuthSwitchAuthorityGrantModel.exact.toPersisted(outgoingStored);
|
|
1626
|
+
const incoming = AuthSwitchAuthorityGrantModel.exact.toPersisted(incomingStored);
|
|
1627
|
+
const incomingHome = await AuthSwitchAuthorityClaudeHomeModel.exact.findStoredOne({
|
|
1628
|
+
activeGrantId: incomingGrantId,
|
|
1629
|
+
}, { session });
|
|
1630
|
+
if (incomingHome && incomingHome.id !== homeId) {
|
|
1631
|
+
throw new Error('Claude grant is already active in another native home.');
|
|
1632
|
+
}
|
|
1633
|
+
const outgoingAccountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: outgoing.accountId }, { session });
|
|
1634
|
+
const incomingAccountStored = await AuthSwitchAuthorityAccountModel.exact.findStoredOne({ id: incoming.accountId }, { session });
|
|
1635
|
+
if (!outgoingAccountStored || !incomingAccountStored || outgoingAccountStored.removed || incomingAccountStored.removed
|
|
1636
|
+
|| outgoing.accountId === incoming.accountId || outgoing.providerId !== 'anthropic'
|
|
1637
|
+
|| incoming.providerId !== 'anthropic' || outgoing.purpose !== 'claude_host_native'
|
|
1638
|
+
|| incoming.purpose !== 'claude_host_native' || home.activeGrantId !== outgoing.id
|
|
1639
|
+
|| home.activeAccountId !== outgoing.accountId) throw new Error('Claude handoff account identity is invalid.');
|
|
1640
|
+
const outgoingAccount = AuthSwitchAuthorityAccountModel.exact.toPersisted(outgoingAccountStored);
|
|
1641
|
+
const incomingAccount = AuthSwitchAuthorityAccountModel.exact.toPersisted(incomingAccountStored);
|
|
1642
|
+
const draft = input.change({ home: structuredClone(home), handoff: handoff ? structuredClone(handoff) : null,
|
|
1643
|
+
outgoing: structuredClone(outgoing), incoming: structuredClone(incoming),
|
|
1644
|
+
outgoingAccount: structuredClone(outgoingAccount), incomingAccount: structuredClone(incomingAccount) });
|
|
1645
|
+
expectedPhase = draft.handoff.phase;
|
|
1646
|
+
assertStoredAuthorityClaudeHome(draft.home);
|
|
1647
|
+
assertStoredAuthorityClaudeHandoff(draft.handoff);
|
|
1648
|
+
if (draft.home.id !== homeId || draft.home.createdAt !== home.createdAt
|
|
1649
|
+
|| draft.home.revision !== home.revision + 1 || draft.home.updateId !== updateId
|
|
1650
|
+
|| draft.handoff.id !== operationId || draft.handoff.homeId !== homeId
|
|
1651
|
+
|| draft.handoff.outgoingAccountId !== outgoing.accountId || draft.handoff.outgoingGrantId !== outgoing.id
|
|
1652
|
+
|| draft.handoff.incomingAccountId !== incoming.accountId || draft.handoff.incomingGrantId !== incoming.id
|
|
1653
|
+
|| draft.handoff.sourceIdentity.accountUuid !== outgoingAccount.subject
|
|
1654
|
+
|| draft.handoff.sourceIdentity.organizationUuid !== outgoingAccount.workspaceId
|
|
1655
|
+
|| draft.handoff.targetIdentity.accountUuid !== incomingAccount.subject
|
|
1656
|
+
|| draft.handoff.targetIdentity.organizationUuid !== incomingAccount.workspaceId
|
|
1657
|
+
|| draft.handoff.revision !== (handoff?.revision ?? 0) + 1 || draft.handoff.updateId !== updateId
|
|
1658
|
+
|| draft.outgoing.id !== outgoing.id || draft.incoming.id !== incoming.id
|
|
1659
|
+
|| draft.outgoing.accountId !== outgoing.accountId || draft.incoming.accountId !== incoming.accountId
|
|
1660
|
+
|| draft.outgoing.updateId !== updateId || draft.incoming.updateId !== updateId
|
|
1661
|
+
|| draft.outgoing.revision !== outgoing.revision + 1 || draft.incoming.revision !== incoming.revision + 1) {
|
|
1662
|
+
throw new Error('Claude handoff changed an immutable identity or revision.');
|
|
1663
|
+
}
|
|
1664
|
+
if (handoff && (draft.handoff.startedAt !== handoff.startedAt
|
|
1665
|
+
|| JSON.stringify(draft.handoff.sourceIdentity) !== JSON.stringify(handoff.sourceIdentity)
|
|
1666
|
+
|| JSON.stringify(draft.handoff.targetIdentity) !== JSON.stringify(handoff.targetIdentity)
|
|
1667
|
+
|| (handoff.phase !== 'reserved' && (JSON.stringify(draft.handoff.before) !== JSON.stringify(handoff.before)
|
|
1668
|
+
|| JSON.stringify(draft.handoff.after) !== JSON.stringify(handoff.after)
|
|
1669
|
+
|| draft.handoff.sourceAccessDigest !== handoff.sourceAccessDigest
|
|
1670
|
+
|| draft.handoff.targetAccessDigest !== handoff.targetAccessDigest)))) {
|
|
1671
|
+
throw new Error('Claude handoff proof or identity changed.');
|
|
1672
|
+
}
|
|
1673
|
+
if (!handoff) {
|
|
1674
|
+
let verifiedIncoming = false;
|
|
1675
|
+
for (const status of ['verified', 'complete'] as const) {
|
|
1676
|
+
const receipts = await AuthSwitchAuthorityMigrationLedgerModel.exact.findStored({
|
|
1677
|
+
filter: { grantId: incoming.id, status }, sort: { id: 1 }, limit: 128, session,
|
|
1678
|
+
});
|
|
1679
|
+
if (receipts.length === 128) throw new Error('Claude grant receipt set is unbounded.');
|
|
1680
|
+
verifiedIncoming ||= receipts.some(item => item.accountId === incoming.accountId
|
|
1681
|
+
&& ['authswitch_stash', 'authswitch_backup', 'claude_native'].includes(item.sourceKind));
|
|
1682
|
+
}
|
|
1683
|
+
if (draft.handoff.phase !== 'reserved' || home.pendingOperationId !== null
|
|
1684
|
+
|| !verifiedIncoming
|
|
1685
|
+
|| outgoing.state !== 'native' || outgoing.owner !== 'claude_native' || outgoing.ciphertext !== null
|
|
1686
|
+
|| incoming.state !== 'ready' || incoming.owner !== 'daemon' || incoming.ciphertext === null
|
|
1687
|
+
|| draft.home.activeGrantId !== outgoing.id || draft.home.pendingOperationId !== operationId
|
|
1688
|
+
|| draft.home.status !== 'ready' || draft.outgoing.state !== 'native'
|
|
1689
|
+
|| draft.outgoing.owner !== 'claude_native' || draft.outgoing.ciphertext !== null
|
|
1690
|
+
|| draft.incoming.state !== 'handoff_pending' || draft.incoming.owner !== 'daemon'
|
|
1691
|
+
|| draft.incoming.ciphertext !== incoming.ciphertext) throw new Error('Claude handoff reservation is invalid.');
|
|
1692
|
+
} else if (handoff.phase === 'reserved' && draft.handoff.phase === 'prepared') {
|
|
1693
|
+
if (outgoing.state !== 'native' || incoming.state !== 'handoff_pending'
|
|
1694
|
+
|| draft.outgoing.state !== 'handoff_pending' || draft.outgoing.owner !== 'claude_native'
|
|
1695
|
+
|| draft.outgoing.ciphertext !== null || draft.incoming.state !== 'handoff_pending'
|
|
1696
|
+
|| draft.incoming.owner !== 'daemon' || draft.incoming.ciphertext !== incoming.ciphertext
|
|
1697
|
+
|| draft.home.activeGrantId !== outgoing.id || draft.home.pendingOperationId !== operationId
|
|
1698
|
+
|| draft.home.status !== 'ready') throw new Error('Claude handoff preparation is invalid.');
|
|
1699
|
+
} else if (handoff.phase === 'prepared' && draft.handoff.phase === 'committed') {
|
|
1700
|
+
if (outgoing.state !== 'handoff_pending' || incoming.state !== 'handoff_pending'
|
|
1701
|
+
|| draft.outgoing.state !== 'ready' || draft.outgoing.owner !== 'daemon'
|
|
1702
|
+
|| draft.outgoing.ciphertext !== handoff.sealedOutgoing
|
|
1703
|
+
|| draft.incoming.state !== 'native' || draft.incoming.owner !== 'claude_native'
|
|
1704
|
+
|| draft.incoming.ciphertext !== null || draft.home.activeAccountId !== incoming.accountId
|
|
1705
|
+
|| draft.home.activeGrantId !== incoming.id || draft.home.pendingOperationId !== null
|
|
1706
|
+
|| draft.home.status !== 'ready' || draft.handoff.sealedOutgoing !== null) {
|
|
1707
|
+
throw new Error('Claude handoff completion is invalid.');
|
|
1708
|
+
}
|
|
1709
|
+
} else if (['reserved', 'prepared'].includes(handoff.phase)
|
|
1710
|
+
&& draft.handoff.phase === 'aborted') {
|
|
1711
|
+
if (incoming.state !== 'handoff_pending' || draft.incoming.state !== 'ready'
|
|
1712
|
+
|| draft.incoming.owner !== 'daemon' || draft.incoming.ciphertext !== incoming.ciphertext
|
|
1713
|
+
|| draft.outgoing.state !== 'native' || draft.outgoing.owner !== 'claude_native'
|
|
1714
|
+
|| draft.outgoing.ciphertext !== null || draft.home.activeGrantId !== outgoing.id
|
|
1715
|
+
|| draft.home.pendingOperationId !== null || draft.home.status !== 'ready'
|
|
1716
|
+
|| draft.handoff.sealedOutgoing !== null) throw new Error('Claude handoff abort is invalid.');
|
|
1717
|
+
} else if (['reserved', 'prepared'].includes(handoff.phase)
|
|
1718
|
+
&& draft.handoff.phase === 'quarantined') {
|
|
1719
|
+
if (draft.outgoing.state !== 'handoff_quarantined' || draft.outgoing.owner !== 'claude_native'
|
|
1720
|
+
|| draft.incoming.state !== 'handoff_quarantined' || draft.incoming.owner !== 'daemon'
|
|
1721
|
+
|| draft.home.activeGrantId !== outgoing.id || draft.home.pendingOperationId !== operationId
|
|
1722
|
+
|| draft.home.status !== 'quarantined' || draft.handoff.problem === 'none') {
|
|
1723
|
+
throw new Error('Claude handoff quarantine is invalid.');
|
|
1724
|
+
}
|
|
1725
|
+
} else throw new Error('Claude handoff phase transition is invalid.');
|
|
1726
|
+
await this.persistGrant(session, outgoingStored, draft.outgoing, true);
|
|
1727
|
+
await this.persistGrant(session, incomingStored, draft.incoming, true);
|
|
1728
|
+
const homeResult = await AuthSwitchAuthorityClaudeHomeModel.exact.transition({ current: homeStored,
|
|
1729
|
+
change: model => Object.assign(model, draft.home) }, { session });
|
|
1730
|
+
if (homeResult.status !== 'transitioned') throw new Error('Claude home changed concurrently.');
|
|
1731
|
+
if (handoffStored) {
|
|
1732
|
+
const result = await AuthSwitchAuthorityClaudeHandoffModel.exact.transition({ current: handoffStored,
|
|
1733
|
+
change: model => Object.assign(model, draft.handoff) }, { session });
|
|
1734
|
+
if (result.status !== 'transitioned') throw new Error('Claude handoff changed concurrently.');
|
|
1735
|
+
} else {
|
|
1736
|
+
const result = await AuthSwitchAuthorityClaudeHandoffModel.exact.insert(draft.handoff, { session });
|
|
1737
|
+
if (result.status !== 'inserted') throw new Error('Claude handoff operation already exists.');
|
|
1738
|
+
}
|
|
1739
|
+
const outgoingAccountDraft = { ...outgoingAccount, revision: outgoingAccount.revision + 1,
|
|
1740
|
+
statusObservedAt: draft.handoff.updatedAt, updateId };
|
|
1741
|
+
const incomingAccountDraft = { ...incomingAccount, revision: incomingAccount.revision + 1,
|
|
1742
|
+
statusObservedAt: draft.handoff.updatedAt, updateId };
|
|
1743
|
+
await this.persistAccount(session, outgoingAccountStored, outgoingAccountDraft);
|
|
1744
|
+
await this.persistAccount(session, incomingAccountStored, incomingAccountDraft);
|
|
1745
|
+
const first = await this.appendEvent(session, AuthSwitchAuthorityMetaModel.exact.toPersisted(metaStored),
|
|
1746
|
+
eventIds[0], 'account', outgoing.accountId);
|
|
1747
|
+
await this.appendEvent(session, first, eventIds[1], 'account', incoming.accountId);
|
|
1748
|
+
return draft;
|
|
1749
|
+
});
|
|
1750
|
+
} catch (error) {
|
|
1751
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
1752
|
+
const context = await this.readClaudeHandoffContext(operationId);
|
|
1753
|
+
const first = await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: eventIds[0] });
|
|
1754
|
+
const second = await AuthSwitchAuthorityEventModel.exact.findStoredOne({ id: eventIds[1] });
|
|
1755
|
+
if (context && context.handoff.phase === expectedPhase
|
|
1756
|
+
&& [context.handoff, context.home, context.outgoing, context.incoming,
|
|
1757
|
+
context.outgoingAccount, context.incomingAccount].every(item => item.updateId === updateId)
|
|
1758
|
+
&& first?.accountId === context.outgoing.accountId
|
|
1759
|
+
&& second?.accountId === context.incoming.accountId && first.epoch === second.epoch
|
|
1760
|
+
&& second.revision === first.revision + 1) {
|
|
1761
|
+
return { handoff: context.handoff, home: context.home,
|
|
1762
|
+
outgoing: context.outgoing, incoming: context.incoming };
|
|
1763
|
+
}
|
|
1764
|
+
throw new Error('Claude handoff database outcome is unknown; native ownership remains blocked.');
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/** Ledger transitions never silently replace an active credential or waive the verified import. */
|
|
773
1769
|
public async changeMigrationLedger(updateId: string, ledgerId: string,
|
|
774
1770
|
change: (current: IStoredAuthorityMigrationLedger | null) => IStoredAuthorityMigrationLedger): Promise<IStoredAuthorityMigrationLedger> {
|
|
775
1771
|
try {
|
|
@@ -778,7 +1774,12 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
778
1774
|
const draft = change(current ? structuredClone(AuthSwitchAuthorityMigrationLedgerModel.exact.toPersisted(current)) : null);
|
|
779
1775
|
if (draft.id !== ledgerId || draft.updateId !== updateId) throw new Error('Migration ledger identity is invalid.');
|
|
780
1776
|
assertStoredAuthorityMigrationLedger(draft);
|
|
781
|
-
|
|
1777
|
+
// A source may only be considered migrated after a verified import bound to an account, a grant and a
|
|
1778
|
+
// source digest. This deliberately tightens a flow that has no production caller and has never run.
|
|
1779
|
+
if (draft.status === 'complete' && (current?.status !== 'verified'
|
|
1780
|
+
|| current.accountId !== draft.accountId || current.grantId !== draft.grantId)) {
|
|
1781
|
+
throw new Error('Migration completion requires a verified import of the same account and grant.');
|
|
1782
|
+
}
|
|
782
1783
|
if (current) {
|
|
783
1784
|
if (current.status === 'complete' || current.version !== draft.version
|
|
784
1785
|
|| current.sourceKind !== draft.sourceKind || current.sourcePathHash !== draft.sourcePathHash
|
|
@@ -797,7 +1798,7 @@ export class AuthSwitchAuthorityDatabase {
|
|
|
797
1798
|
return draft;
|
|
798
1799
|
});
|
|
799
1800
|
} catch (error) {
|
|
800
|
-
if (!(error instanceof plugins.
|
|
1801
|
+
if (!(error instanceof plugins.nosqldb.SmartdataExactPersistenceError) || error.code !== 'ambiguous_write') throw error;
|
|
801
1802
|
const ledger = await this.readMigrationLedger(ledgerId);
|
|
802
1803
|
if (ledger?.updateId === updateId) return ledger;
|
|
803
1804
|
throw new Error('Authswitch migration ledger write outcome is unknown.');
|