@cortexkit/anthropic-auth-core 1.22.0 → 1.23.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/accounts.d.ts +59 -2
- package/dist/accounts.js +666 -115
- package/dist/auth.js +3 -0
- package/dist/cachekeep.d.ts +18 -1
- package/dist/cachekeep.js +108 -82
- package/dist/cch.d.ts +11 -1
- package/dist/cch.js +48 -3
- package/dist/claude-code.d.ts +7 -1
- package/dist/claude-code.js +18 -3
- package/dist/claustrum-enrollment-registry.d.ts +17 -0
- package/dist/claustrum-enrollment-registry.js +194 -0
- package/dist/claustrum-enrollment.d.ts +70 -0
- package/dist/claustrum-enrollment.js +485 -0
- package/dist/claustrum-scoped-roster.d.ts +23 -0
- package/dist/claustrum-scoped-roster.js +194 -0
- package/dist/claustrum-scoped-runtime.d.ts +28 -0
- package/dist/claustrum-scoped-runtime.js +230 -0
- package/dist/claustrum-scoped.d.ts +39 -0
- package/dist/claustrum-scoped.js +193 -0
- package/dist/claustrum.d.ts +149 -6
- package/dist/claustrum.js +990 -48
- package/dist/commands/account.d.ts +40 -12
- package/dist/commands/account.js +138 -9
- package/dist/constants.d.ts +3 -2
- package/dist/constants.js +3 -2
- package/dist/custom-headers.d.ts +3 -0
- package/dist/custom-headers.js +102 -0
- package/dist/fast.js +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/logger.js +3 -1
- package/dist/model-remap.d.ts +27 -0
- package/dist/model-remap.js +72 -0
- package/dist/models.d.ts +19 -0
- package/dist/models.js +48 -12
- package/dist/oauth-profile.d.ts +2 -1
- package/dist/oauth-profile.js +6 -0
- package/dist/prime.d.ts +11 -3
- package/dist/prime.js +25 -7
- package/dist/quota-header-feed.d.ts +13 -0
- package/dist/quota-header-feed.js +60 -8
- package/dist/quota-manager.d.ts +15 -1
- package/dist/quota-manager.js +73 -22
- package/dist/relay.d.ts +8 -0
- package/dist/relay.js +72 -12
- package/dist/sticky-routing.d.ts +2 -0
- package/dist/sticky-routing.js +19 -1
- package/package.json +3 -2
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type ClaustrumClientOptions as ClaustrumEnrollmentClientOptions, type EnrollmentPollOutcome, type EnrollmentTokenFile, writeEnrollmentTokenFile } from '@cortexkit/claustrum-client';
|
|
2
|
+
export declare const CLAUSTRUM_OPENCODE_ENROLLMENT_NAME = "anthropic-auth-opencode";
|
|
3
|
+
export declare const CLAUSTRUM_PI_ENROLLMENT_NAME = "anthropic-auth-pi";
|
|
4
|
+
export interface ClaustrumEnrollmentClient {
|
|
5
|
+
enrollPropose(input: {
|
|
6
|
+
name: string;
|
|
7
|
+
requestSecretHash: string;
|
|
8
|
+
}): Promise<{
|
|
9
|
+
requestId: string;
|
|
10
|
+
}>;
|
|
11
|
+
enrollPoll(input: {
|
|
12
|
+
requestId: string;
|
|
13
|
+
requestSecret: string;
|
|
14
|
+
}): Promise<EnrollmentPollOutcome>;
|
|
15
|
+
}
|
|
16
|
+
export interface ClaustrumEnrollmentConnection extends ClaustrumEnrollmentClient {
|
|
17
|
+
close(): void;
|
|
18
|
+
}
|
|
19
|
+
export declare function connectClaustrumEnrollmentClient(options?: ClaustrumEnrollmentClientOptions): Promise<ClaustrumEnrollmentConnection>;
|
|
20
|
+
export type ClaustrumEnrollmentStatus = {
|
|
21
|
+
state: 'idle';
|
|
22
|
+
} | {
|
|
23
|
+
state: 'pending';
|
|
24
|
+
proposedName: string;
|
|
25
|
+
requestId?: string;
|
|
26
|
+
retryCode?: string;
|
|
27
|
+
} | {
|
|
28
|
+
state: 'approved';
|
|
29
|
+
proposedName: string;
|
|
30
|
+
approvedName?: string;
|
|
31
|
+
tokenGeneration: number;
|
|
32
|
+
} | {
|
|
33
|
+
state: 'denied';
|
|
34
|
+
proposedName: string;
|
|
35
|
+
} | {
|
|
36
|
+
state: 'blocked';
|
|
37
|
+
proposedName: string;
|
|
38
|
+
code: string;
|
|
39
|
+
} | {
|
|
40
|
+
state: 'unavailable';
|
|
41
|
+
proposedName: string;
|
|
42
|
+
code: string;
|
|
43
|
+
} | {
|
|
44
|
+
state: 'busy';
|
|
45
|
+
};
|
|
46
|
+
export interface ClaustrumEnrollmentPaths {
|
|
47
|
+
statePath: string;
|
|
48
|
+
tokenPath: string;
|
|
49
|
+
}
|
|
50
|
+
export declare function getClaustrumEnrollmentPaths(tokenPath: string): ClaustrumEnrollmentPaths;
|
|
51
|
+
export declare function readClaustrumEnrollmentStatus(paths: ClaustrumEnrollmentPaths, proposedName?: string): Promise<ClaustrumEnrollmentStatus>;
|
|
52
|
+
/** Read fresh bearer material for a scoped operation; never publish this value. */
|
|
53
|
+
export declare function readClaustrumEnrollmentToken(tokenPath: string): Promise<EnrollmentTokenFile>;
|
|
54
|
+
export declare class ClaustrumEnrollmentManager {
|
|
55
|
+
#private;
|
|
56
|
+
constructor(options: {
|
|
57
|
+
client: ClaustrumEnrollmentClient;
|
|
58
|
+
paths: ClaustrumEnrollmentPaths;
|
|
59
|
+
proposedName: string;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
mintSecret?: () => string;
|
|
62
|
+
writeTokenFile?: typeof writeEnrollmentTokenFile;
|
|
63
|
+
});
|
|
64
|
+
status(): Promise<ClaustrumEnrollmentStatus>;
|
|
65
|
+
resetTerminal(): Promise<ClaustrumEnrollmentResetResult>;
|
|
66
|
+
reconcile(): Promise<ClaustrumEnrollmentStatus>;
|
|
67
|
+
}
|
|
68
|
+
export type ClaustrumEnrollmentResetResult = 'reset' | 'idle' | 'refused-pending' | 'refused-approved' | 'busy';
|
|
69
|
+
/** Reset local terminal metadata without connecting to the credential daemon. */
|
|
70
|
+
export declare function resetClaustrumEnrollmentState(paths: ClaustrumEnrollmentPaths, proposedName: string): Promise<ClaustrumEnrollmentResetResult>;
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants as fsConstants } from 'node:fs';
|
|
3
|
+
import { chmod, mkdir, open, realpath, rename, stat, unlink, } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
5
|
+
import { ClaustrumCredentialError, ClaustrumClient as ClaustrumWireClient, writeEnrollmentTokenFile, } from '@cortexkit/claustrum-client';
|
|
6
|
+
import { acquireRefreshFileLock } from './accounts.js';
|
|
7
|
+
import { parseJsonRedacted } from './json.js';
|
|
8
|
+
export const CLAUSTRUM_OPENCODE_ENROLLMENT_NAME = 'anthropic-auth-opencode';
|
|
9
|
+
export const CLAUSTRUM_PI_ENROLLMENT_NAME = 'anthropic-auth-pi';
|
|
10
|
+
const ENROLLMENT_SCHEMA = 1;
|
|
11
|
+
const ENROLLMENT_FILE_MAX_BYTES = 16 * 1024;
|
|
12
|
+
const ENROLLMENT_LOCK_TTL_MS = 30_000;
|
|
13
|
+
const TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
14
|
+
export function connectClaustrumEnrollmentClient(options = {}) {
|
|
15
|
+
return ClaustrumWireClient.connect(options);
|
|
16
|
+
}
|
|
17
|
+
export function getClaustrumEnrollmentPaths(tokenPath) {
|
|
18
|
+
const extension = extname(tokenPath) || '.json';
|
|
19
|
+
const stem = basename(tokenPath, extname(tokenPath));
|
|
20
|
+
return {
|
|
21
|
+
statePath: join(dirname(tokenPath), `${stem}-state${extension}`),
|
|
22
|
+
tokenPath,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function isRecord(value) {
|
|
26
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
27
|
+
}
|
|
28
|
+
function validTimestamp(value) {
|
|
29
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
30
|
+
}
|
|
31
|
+
function validGeneration(value) {
|
|
32
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1;
|
|
33
|
+
}
|
|
34
|
+
function decodeEnrollmentState(value) {
|
|
35
|
+
if (!isRecord(value) ||
|
|
36
|
+
value.version !== ENROLLMENT_SCHEMA ||
|
|
37
|
+
typeof value.proposedName !== 'string' ||
|
|
38
|
+
value.proposedName.length === 0) {
|
|
39
|
+
throw new Error('invalid Claustrum enrollment state');
|
|
40
|
+
}
|
|
41
|
+
if (value.phase === 'pending') {
|
|
42
|
+
if (!TOKEN_RE.test(String(value.requestSecret ?? '')) ||
|
|
43
|
+
(value.requestId !== undefined &&
|
|
44
|
+
(typeof value.requestId !== 'string' ||
|
|
45
|
+
value.requestId.length === 0)) ||
|
|
46
|
+
!validTimestamp(value.createdAt) ||
|
|
47
|
+
!validTimestamp(value.updatedAt)) {
|
|
48
|
+
throw new Error('invalid Claustrum enrollment state');
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
version: 1,
|
|
52
|
+
phase: 'pending',
|
|
53
|
+
proposedName: value.proposedName,
|
|
54
|
+
requestSecret: value.requestSecret,
|
|
55
|
+
...(value.requestId !== undefined && {
|
|
56
|
+
requestId: value.requestId,
|
|
57
|
+
}),
|
|
58
|
+
createdAt: value.createdAt,
|
|
59
|
+
updatedAt: value.updatedAt,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (value.phase === 'approved') {
|
|
63
|
+
if ((value.approvedName !== undefined &&
|
|
64
|
+
(typeof value.approvedName !== 'string' ||
|
|
65
|
+
value.approvedName.length === 0)) ||
|
|
66
|
+
!validGeneration(value.tokenGeneration) ||
|
|
67
|
+
!validTimestamp(value.updatedAt)) {
|
|
68
|
+
throw new Error('invalid Claustrum enrollment state');
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
version: 1,
|
|
72
|
+
phase: 'approved',
|
|
73
|
+
proposedName: value.proposedName,
|
|
74
|
+
...(value.approvedName !== undefined && {
|
|
75
|
+
approvedName: value.approvedName,
|
|
76
|
+
}),
|
|
77
|
+
tokenGeneration: value.tokenGeneration,
|
|
78
|
+
updatedAt: value.updatedAt,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (value.phase === 'denied' || value.phase === 'blocked') {
|
|
82
|
+
if ((value.errorCode !== undefined && typeof value.errorCode !== 'string') ||
|
|
83
|
+
!validTimestamp(value.updatedAt)) {
|
|
84
|
+
throw new Error('invalid Claustrum enrollment state');
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
version: 1,
|
|
88
|
+
phase: value.phase,
|
|
89
|
+
proposedName: value.proposedName,
|
|
90
|
+
...(value.errorCode !== undefined && {
|
|
91
|
+
errorCode: value.errorCode,
|
|
92
|
+
}),
|
|
93
|
+
updatedAt: value.updatedAt,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
throw new Error('invalid Claustrum enrollment state');
|
|
97
|
+
}
|
|
98
|
+
function decodeTokenFile(value) {
|
|
99
|
+
if (!isRecord(value) ||
|
|
100
|
+
!TOKEN_RE.test(String(value.token ?? '')) ||
|
|
101
|
+
!validGeneration(value.token_generation)) {
|
|
102
|
+
throw new Error('invalid Claustrum enrollment token file');
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
token: value.token,
|
|
106
|
+
token_generation: value.token_generation,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function validateReadableSecretFile(metadata) {
|
|
110
|
+
if (!metadata.isFile()) {
|
|
111
|
+
throw new Error('Claustrum enrollment file must be a regular file');
|
|
112
|
+
}
|
|
113
|
+
if ((metadata.mode & 0o077) !== 0) {
|
|
114
|
+
throw new Error('Claustrum enrollment file must be owner-only');
|
|
115
|
+
}
|
|
116
|
+
const expectedUid = process.getuid?.();
|
|
117
|
+
if (expectedUid !== undefined && metadata.uid !== expectedUid) {
|
|
118
|
+
throw new Error('Claustrum enrollment file must be owned by the current user');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async function readBoundedJson(path) {
|
|
122
|
+
let descriptor;
|
|
123
|
+
try {
|
|
124
|
+
descriptor = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error.code === 'ENOENT')
|
|
128
|
+
return undefined;
|
|
129
|
+
throw new Error('Claustrum enrollment file could not be opened safely');
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
validateReadableSecretFile(await descriptor.stat());
|
|
133
|
+
const source = Buffer.alloc(ENROLLMENT_FILE_MAX_BYTES + 1);
|
|
134
|
+
const { bytesRead } = await descriptor.read(source, 0, source.byteLength, 0);
|
|
135
|
+
if (bytesRead > ENROLLMENT_FILE_MAX_BYTES) {
|
|
136
|
+
throw new Error('Claustrum enrollment file is too large');
|
|
137
|
+
}
|
|
138
|
+
return parseJsonRedacted(source.subarray(0, bytesRead).toString('utf8'));
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
await descriptor.close();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function refuseWritableAncestor(parent) {
|
|
145
|
+
let component;
|
|
146
|
+
try {
|
|
147
|
+
component = await realpath(parent);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
for (;;) {
|
|
153
|
+
const metadata = await stat(component).catch(() => undefined);
|
|
154
|
+
if (metadata &&
|
|
155
|
+
(metadata.mode & 0o022) !== 0 &&
|
|
156
|
+
(metadata.mode & 0o1000) === 0) {
|
|
157
|
+
throw new Error('Claustrum enrollment path has an unsafe writable ancestor');
|
|
158
|
+
}
|
|
159
|
+
const next = dirname(component);
|
|
160
|
+
if (next === component)
|
|
161
|
+
return;
|
|
162
|
+
component = next;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function writeStateAtomic(path, state) {
|
|
166
|
+
const parent = dirname(path);
|
|
167
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
168
|
+
await refuseWritableAncestor(parent);
|
|
169
|
+
const bytes = `${JSON.stringify(state)}\n`;
|
|
170
|
+
if (Buffer.byteLength(bytes) > ENROLLMENT_FILE_MAX_BYTES) {
|
|
171
|
+
throw new Error('Claustrum enrollment state is too large');
|
|
172
|
+
}
|
|
173
|
+
const temporary = join(parent, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
174
|
+
let descriptor;
|
|
175
|
+
try {
|
|
176
|
+
descriptor = await open(temporary, 'wx', 0o600);
|
|
177
|
+
await descriptor.writeFile(bytes, 'utf8');
|
|
178
|
+
await descriptor.sync();
|
|
179
|
+
await descriptor.close();
|
|
180
|
+
descriptor = undefined;
|
|
181
|
+
await chmod(temporary, 0o600);
|
|
182
|
+
await rename(temporary, path);
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await descriptor?.close().catch(() => { });
|
|
186
|
+
await unlink(temporary).catch(() => { });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export async function readClaustrumEnrollmentStatus(paths, proposedName = CLAUSTRUM_OPENCODE_ENROLLMENT_NAME) {
|
|
190
|
+
const tokenValue = await readBoundedJson(paths.tokenPath);
|
|
191
|
+
const token = tokenValue === undefined ? undefined : decodeTokenFile(tokenValue);
|
|
192
|
+
const stateValue = await readBoundedJson(paths.statePath);
|
|
193
|
+
const state = stateValue === undefined ? undefined : decodeEnrollmentState(stateValue);
|
|
194
|
+
if (token) {
|
|
195
|
+
return {
|
|
196
|
+
state: 'approved',
|
|
197
|
+
proposedName: state?.proposedName ?? proposedName,
|
|
198
|
+
...(state?.phase === 'approved' &&
|
|
199
|
+
state.approvedName !== undefined && {
|
|
200
|
+
approvedName: state.approvedName,
|
|
201
|
+
}),
|
|
202
|
+
tokenGeneration: token.token_generation,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (!state)
|
|
206
|
+
return { state: 'idle' };
|
|
207
|
+
if (state.phase === 'pending') {
|
|
208
|
+
return {
|
|
209
|
+
state: 'pending',
|
|
210
|
+
proposedName: state.proposedName,
|
|
211
|
+
...(state.requestId !== undefined && { requestId: state.requestId }),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (state.phase === 'approved') {
|
|
215
|
+
return {
|
|
216
|
+
state: 'blocked',
|
|
217
|
+
proposedName: state.proposedName,
|
|
218
|
+
code: 'missing_token',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (state.phase === 'denied')
|
|
222
|
+
return { state: 'denied', proposedName: state.proposedName };
|
|
223
|
+
return {
|
|
224
|
+
state: 'blocked',
|
|
225
|
+
proposedName: state.proposedName,
|
|
226
|
+
code: state.errorCode ?? 'unknown',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** Read fresh bearer material for a scoped operation; never publish this value. */
|
|
230
|
+
export async function readClaustrumEnrollmentToken(tokenPath) {
|
|
231
|
+
await refuseWritableAncestor(tokenPath);
|
|
232
|
+
const value = await readBoundedJson(tokenPath);
|
|
233
|
+
if (value === undefined)
|
|
234
|
+
throw new Error('Claustrum enrollment is not configured');
|
|
235
|
+
return decodeTokenFile(value);
|
|
236
|
+
}
|
|
237
|
+
function statusFromState(state) {
|
|
238
|
+
if (state.phase === 'pending') {
|
|
239
|
+
return {
|
|
240
|
+
state: 'pending',
|
|
241
|
+
proposedName: state.proposedName,
|
|
242
|
+
...(state.requestId !== undefined && { requestId: state.requestId }),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (state.phase === 'approved') {
|
|
246
|
+
return {
|
|
247
|
+
state: 'approved',
|
|
248
|
+
proposedName: state.proposedName,
|
|
249
|
+
...(state.approvedName !== undefined && {
|
|
250
|
+
approvedName: state.approvedName,
|
|
251
|
+
}),
|
|
252
|
+
tokenGeneration: state.tokenGeneration,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (state.phase === 'denied')
|
|
256
|
+
return { state: 'denied', proposedName: state.proposedName };
|
|
257
|
+
return {
|
|
258
|
+
state: 'blocked',
|
|
259
|
+
proposedName: state.proposedName,
|
|
260
|
+
code: state.errorCode ?? 'unknown',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
export class ClaustrumEnrollmentManager {
|
|
264
|
+
#client;
|
|
265
|
+
#paths;
|
|
266
|
+
#proposedName;
|
|
267
|
+
#now;
|
|
268
|
+
#mintSecret;
|
|
269
|
+
#writeTokenFile;
|
|
270
|
+
constructor(options) {
|
|
271
|
+
this.#client = options.client;
|
|
272
|
+
this.#paths = options.paths;
|
|
273
|
+
this.#proposedName = options.proposedName;
|
|
274
|
+
this.#now = options.now ?? Date.now;
|
|
275
|
+
this.#mintSecret =
|
|
276
|
+
options.mintSecret ?? (() => randomBytes(32).toString('hex'));
|
|
277
|
+
this.#writeTokenFile = options.writeTokenFile ?? writeEnrollmentTokenFile;
|
|
278
|
+
}
|
|
279
|
+
async status() {
|
|
280
|
+
return readClaustrumEnrollmentStatus(this.#paths, this.#proposedName);
|
|
281
|
+
}
|
|
282
|
+
async resetTerminal() {
|
|
283
|
+
return resetClaustrumEnrollmentState(this.#paths, this.#proposedName);
|
|
284
|
+
}
|
|
285
|
+
async reconcile() {
|
|
286
|
+
await mkdir(dirname(this.#paths.statePath), {
|
|
287
|
+
recursive: true,
|
|
288
|
+
mode: 0o700,
|
|
289
|
+
});
|
|
290
|
+
const lock = await acquireRefreshFileLock({
|
|
291
|
+
name: 'ceremony',
|
|
292
|
+
path: this.#paths.statePath,
|
|
293
|
+
ttlMs: ENROLLMENT_LOCK_TTL_MS,
|
|
294
|
+
renew: true,
|
|
295
|
+
});
|
|
296
|
+
if (!lock)
|
|
297
|
+
return { state: 'busy' };
|
|
298
|
+
try {
|
|
299
|
+
const existingToken = await readBoundedJson(this.#paths.tokenPath);
|
|
300
|
+
const token = existingToken === undefined ? undefined : decodeTokenFile(existingToken);
|
|
301
|
+
const stateValue = await readBoundedJson(this.#paths.statePath);
|
|
302
|
+
let state = stateValue === undefined ? undefined : decodeEnrollmentState(stateValue);
|
|
303
|
+
if (state && state.proposedName !== this.#proposedName) {
|
|
304
|
+
throw new Error('Claustrum enrollment state belongs to a different consumer');
|
|
305
|
+
}
|
|
306
|
+
if (token) {
|
|
307
|
+
if (!state || state.phase === 'pending') {
|
|
308
|
+
const approved = {
|
|
309
|
+
version: 1,
|
|
310
|
+
phase: 'approved',
|
|
311
|
+
proposedName: state?.proposedName ?? this.#proposedName,
|
|
312
|
+
tokenGeneration: token.token_generation,
|
|
313
|
+
updatedAt: this.#now(),
|
|
314
|
+
};
|
|
315
|
+
await writeStateAtomic(this.#paths.statePath, approved);
|
|
316
|
+
state = approved;
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
state: 'approved',
|
|
320
|
+
proposedName: state.proposedName,
|
|
321
|
+
...(state.phase === 'approved' &&
|
|
322
|
+
state.approvedName !== undefined && {
|
|
323
|
+
approvedName: state.approvedName,
|
|
324
|
+
}),
|
|
325
|
+
tokenGeneration: token.token_generation,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (state?.phase === 'approved') {
|
|
329
|
+
const blocked = {
|
|
330
|
+
version: 1,
|
|
331
|
+
phase: 'blocked',
|
|
332
|
+
proposedName: state.proposedName,
|
|
333
|
+
errorCode: 'missing_token',
|
|
334
|
+
updatedAt: this.#now(),
|
|
335
|
+
};
|
|
336
|
+
await writeStateAtomic(this.#paths.statePath, blocked);
|
|
337
|
+
return statusFromState(blocked);
|
|
338
|
+
}
|
|
339
|
+
if (state && state.phase !== 'pending')
|
|
340
|
+
return statusFromState(state);
|
|
341
|
+
if (!state) {
|
|
342
|
+
const now = this.#now();
|
|
343
|
+
state = {
|
|
344
|
+
version: 1,
|
|
345
|
+
phase: 'pending',
|
|
346
|
+
proposedName: this.#proposedName,
|
|
347
|
+
requestSecret: this.#mintSecret(),
|
|
348
|
+
createdAt: now,
|
|
349
|
+
updatedAt: now,
|
|
350
|
+
};
|
|
351
|
+
if (!TOKEN_RE.test(state.requestSecret))
|
|
352
|
+
throw new Error('invalid minted Claustrum enrollment secret');
|
|
353
|
+
await writeStateAtomic(this.#paths.statePath, state);
|
|
354
|
+
}
|
|
355
|
+
if (!state.requestId) {
|
|
356
|
+
try {
|
|
357
|
+
const requestSecretHash = createHash('sha256')
|
|
358
|
+
.update(Buffer.from(state.requestSecret, 'hex'))
|
|
359
|
+
.digest('hex');
|
|
360
|
+
const proposed = await this.#client.enrollPropose({
|
|
361
|
+
name: state.proposedName,
|
|
362
|
+
requestSecretHash,
|
|
363
|
+
});
|
|
364
|
+
state = {
|
|
365
|
+
...state,
|
|
366
|
+
requestId: proposed.requestId,
|
|
367
|
+
updatedAt: this.#now(),
|
|
368
|
+
};
|
|
369
|
+
await writeStateAtomic(this.#paths.statePath, state);
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
if (error instanceof ClaustrumCredentialError) {
|
|
373
|
+
if (error.code === 'pending_queue_full' ||
|
|
374
|
+
error.action === 'retry') {
|
|
375
|
+
return {
|
|
376
|
+
state: 'pending',
|
|
377
|
+
proposedName: state.proposedName,
|
|
378
|
+
retryCode: error.code,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const blocked = {
|
|
382
|
+
version: 1,
|
|
383
|
+
phase: 'blocked',
|
|
384
|
+
proposedName: state.proposedName,
|
|
385
|
+
errorCode: error.code,
|
|
386
|
+
updatedAt: this.#now(),
|
|
387
|
+
};
|
|
388
|
+
await writeStateAtomic(this.#paths.statePath, blocked);
|
|
389
|
+
return statusFromState(blocked);
|
|
390
|
+
}
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const requestId = state.requestId;
|
|
395
|
+
if (!requestId)
|
|
396
|
+
return statusFromState(state);
|
|
397
|
+
try {
|
|
398
|
+
const outcome = await this.#client.enrollPoll({
|
|
399
|
+
requestId,
|
|
400
|
+
requestSecret: state.requestSecret,
|
|
401
|
+
});
|
|
402
|
+
if (outcome.status === 'pending')
|
|
403
|
+
return statusFromState(state);
|
|
404
|
+
if (outcome.status === 'denied') {
|
|
405
|
+
const denied = {
|
|
406
|
+
version: 1,
|
|
407
|
+
phase: 'denied',
|
|
408
|
+
proposedName: state.proposedName,
|
|
409
|
+
updatedAt: this.#now(),
|
|
410
|
+
};
|
|
411
|
+
await writeStateAtomic(this.#paths.statePath, denied);
|
|
412
|
+
return statusFromState(denied);
|
|
413
|
+
}
|
|
414
|
+
await this.#writeTokenFile(this.#paths.tokenPath, {
|
|
415
|
+
token: outcome.token,
|
|
416
|
+
token_generation: outcome.tokenGeneration,
|
|
417
|
+
});
|
|
418
|
+
const approved = {
|
|
419
|
+
version: 1,
|
|
420
|
+
phase: 'approved',
|
|
421
|
+
proposedName: state.proposedName,
|
|
422
|
+
approvedName: outcome.name,
|
|
423
|
+
tokenGeneration: outcome.tokenGeneration,
|
|
424
|
+
updatedAt: this.#now(),
|
|
425
|
+
};
|
|
426
|
+
await writeStateAtomic(this.#paths.statePath, approved);
|
|
427
|
+
return statusFromState(approved);
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
if (!(error instanceof ClaustrumCredentialError))
|
|
431
|
+
throw error;
|
|
432
|
+
if (error.action === 'retry') {
|
|
433
|
+
return {
|
|
434
|
+
state: 'pending',
|
|
435
|
+
proposedName: state.proposedName,
|
|
436
|
+
requestId,
|
|
437
|
+
retryCode: error.code,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
const blocked = {
|
|
441
|
+
version: 1,
|
|
442
|
+
phase: 'blocked',
|
|
443
|
+
proposedName: state.proposedName,
|
|
444
|
+
errorCode: error.code,
|
|
445
|
+
updatedAt: this.#now(),
|
|
446
|
+
};
|
|
447
|
+
await writeStateAtomic(this.#paths.statePath, blocked);
|
|
448
|
+
return statusFromState(blocked);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
await lock.release();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/** Reset local terminal metadata without connecting to the credential daemon. */
|
|
457
|
+
export async function resetClaustrumEnrollmentState(paths, proposedName) {
|
|
458
|
+
await mkdir(dirname(paths.statePath), { recursive: true, mode: 0o700 });
|
|
459
|
+
const lock = await acquireRefreshFileLock({
|
|
460
|
+
name: 'ceremony',
|
|
461
|
+
path: paths.statePath,
|
|
462
|
+
ttlMs: ENROLLMENT_LOCK_TTL_MS,
|
|
463
|
+
renew: true,
|
|
464
|
+
});
|
|
465
|
+
if (!lock)
|
|
466
|
+
return 'busy';
|
|
467
|
+
try {
|
|
468
|
+
if ((await readBoundedJson(paths.tokenPath)) !== undefined)
|
|
469
|
+
return 'refused-approved';
|
|
470
|
+
const value = await readBoundedJson(paths.statePath);
|
|
471
|
+
if (value === undefined)
|
|
472
|
+
return 'idle';
|
|
473
|
+
const state = decodeEnrollmentState(value);
|
|
474
|
+
if (state.proposedName !== proposedName)
|
|
475
|
+
throw new Error('Claustrum enrollment state belongs to a different consumer');
|
|
476
|
+
if (state.phase === 'pending')
|
|
477
|
+
return 'refused-pending';
|
|
478
|
+
await lock.assertOwned();
|
|
479
|
+
await unlink(paths.statePath);
|
|
480
|
+
return 'reset';
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
await lock.release();
|
|
484
|
+
}
|
|
485
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type AccountStorage, type OAuthAccount } from './accounts.js';
|
|
2
|
+
import type { ClaustrumScopedAccount, ClaustrumScopedCustody } from './claustrum-scoped.js';
|
|
3
|
+
export interface ClaustrumScopedRoster {
|
|
4
|
+
/** The vault's conventional default record; absent when only labeled accounts exist. */
|
|
5
|
+
primary?: ClaustrumScopedAccount;
|
|
6
|
+
accounts: readonly OAuthAccount[];
|
|
7
|
+
storage: AccountStorage;
|
|
8
|
+
view: string;
|
|
9
|
+
}
|
|
10
|
+
/** Pure identity projection; callers must serialize discovery before persisting it. */
|
|
11
|
+
export declare function projectClaustrumScopedRoster(storage: AccountStorage, rows: readonly ClaustrumScopedAccount[], view: string, now?: number): ClaustrumScopedRoster;
|
|
12
|
+
/**
|
|
13
|
+
* The lease covers LIST and commit, not just the file write: opaque view hashes
|
|
14
|
+
* cannot tell a delayed old response from a newer one. A lost lease is checked
|
|
15
|
+
* while holding the config lock, before any authoritative membership write.
|
|
16
|
+
* Neither local mode nor a failed list can publish an empty inventory.
|
|
17
|
+
*/
|
|
18
|
+
export declare function refreshClaustrumScopedRoster(options: {
|
|
19
|
+
path: string;
|
|
20
|
+
custody: Pick<ClaustrumScopedCustody, 'discover'>;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
now?: () => number;
|
|
23
|
+
}): Promise<ClaustrumScopedRoster | undefined>;
|