@cortexkit/common-auth 0.2.5 → 0.2.7
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/claustrum/consumer.d.ts +93 -0
- package/dist/claustrum/consumer.js +276 -0
- package/dist/claustrum/custody.d.ts +128 -0
- package/dist/claustrum/custody.js +321 -0
- package/dist/claustrum/enrollment.d.ts +121 -0
- package/dist/claustrum/enrollment.js +579 -0
- package/dist/claustrum/errors.d.ts +16 -0
- package/dist/claustrum/errors.js +8 -0
- package/dist/claustrum/host-slot.d.ts +39 -0
- package/dist/claustrum/host-slot.js +72 -0
- package/dist/claustrum/index.d.ts +18 -1
- package/dist/claustrum/index.js +22 -2
- package/dist/claustrum/interlock.d.ts +29 -0
- package/dist/claustrum/interlock.js +36 -0
- package/dist/claustrum/roster.d.ts +103 -0
- package/dist/claustrum/roster.js +334 -0
- package/dist/commands/builtins.d.ts +71 -0
- package/dist/commands/builtins.js +508 -0
- package/dist/commands/index.d.ts +10 -1
- package/dist/commands/index.js +5 -2
- package/dist/commands/menu.d.ts +39 -0
- package/dist/commands/menu.js +249 -0
- package/dist/commands/model.d.ts +188 -0
- package/dist/commands/model.js +16 -0
- package/dist/commands/pi.d.ts +22 -0
- package/dist/commands/pi.js +178 -0
- package/dist/commands/seam.d.ts +36 -0
- package/dist/commands/seam.js +185 -0
- package/dist/store/errors.d.ts +1 -1
- package/dist/store/index.d.ts +2 -0
- package/dist/store/index.js +1 -0
- package/dist/store/pool.d.ts +12 -0
- package/dist/store/pool.js +3 -0
- package/dist/store/settings.d.ts +63 -0
- package/dist/store/settings.js +120 -0
- package/package.json +1 -1
|
@@ -0,0 +1,579 @@
|
|
|
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, isAbsolute, join, resolve, } from 'node:path';
|
|
5
|
+
import { ClaustrumCredentialError, ClaustrumClient as ClaustrumWireClient, writeEnrollmentTokenFile, } from '@cortexkit/claustrum-client';
|
|
6
|
+
import { acquireRefreshFileLock } from '../fs/index.js';
|
|
7
|
+
import { ClaustrumConsumerError } from './errors.js';
|
|
8
|
+
const ENROLLMENT_SCHEMA = 1;
|
|
9
|
+
const ENROLLMENT_FILE_MAX_BYTES = 16 * 1024;
|
|
10
|
+
const ENROLLMENT_LOCK_TTL_MS = 30_000;
|
|
11
|
+
const TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
12
|
+
/**
|
|
13
|
+
* Codes the vault's closed enrollment-refusal vocabulary marks permanent. The
|
|
14
|
+
* client labels some module error frames transient/retry regardless, so the
|
|
15
|
+
* producer's code takes precedence over the client's action for these.
|
|
16
|
+
*/
|
|
17
|
+
export const TERMINAL_ENROLLMENT_CODES = new Set([
|
|
18
|
+
'invalid_params',
|
|
19
|
+
'pending_exists',
|
|
20
|
+
'not_found',
|
|
21
|
+
'already_consumed',
|
|
22
|
+
'superseded',
|
|
23
|
+
'stale_generation',
|
|
24
|
+
]);
|
|
25
|
+
/** Codes that are always worth another ceremony tick, whatever the action says. */
|
|
26
|
+
export const RETRYABLE_ENROLLMENT_CODES = new Set([
|
|
27
|
+
'pending_queue_full',
|
|
28
|
+
]);
|
|
29
|
+
/**
|
|
30
|
+
* Classify an enrollment transport refusal as `(code, disposition)`. The
|
|
31
|
+
* vault's own code wins: a terminal code is terminal even when the client
|
|
32
|
+
* says retry, queue saturation is retryable even when it says gone, and any
|
|
33
|
+
* other code follows the client's action. Errors that are not producer
|
|
34
|
+
* refusals return undefined and are the caller's to rethrow.
|
|
35
|
+
*/
|
|
36
|
+
export function classifyEnrollmentError(error) {
|
|
37
|
+
if (!(error instanceof ClaustrumCredentialError))
|
|
38
|
+
return undefined;
|
|
39
|
+
if (TERMINAL_ENROLLMENT_CODES.has(error.code))
|
|
40
|
+
return { code: error.code, disposition: 'terminal' };
|
|
41
|
+
if (RETRYABLE_ENROLLMENT_CODES.has(error.code))
|
|
42
|
+
return { code: error.code, disposition: 'retry' };
|
|
43
|
+
return {
|
|
44
|
+
code: error.code,
|
|
45
|
+
disposition: error.action === 'retry' ? 'retry' : 'terminal',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The name a plugin proposes for one host, for example `openai-auth-opencode`.
|
|
50
|
+
* Each host enrolls separately so the operator can revoke one without the other.
|
|
51
|
+
*/
|
|
52
|
+
export function enrollmentName(plugin, host) {
|
|
53
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(plugin) ||
|
|
54
|
+
!/^[a-z0-9][a-z0-9-]*$/.test(host))
|
|
55
|
+
throw new ClaustrumConsumerError('invalid-state', 'Claustrum enrollment names use lowercase letters, digits and dashes');
|
|
56
|
+
return `${plugin}-${host}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Connect the enrollment ceremony. Setup calls this; the request path never
|
|
60
|
+
* does. `connectionFile` is required because this library reads no
|
|
61
|
+
* environment and knows no host paths.
|
|
62
|
+
*/
|
|
63
|
+
export function connectClaustrumEnrollmentClient(options) {
|
|
64
|
+
return ClaustrumWireClient.connect(options);
|
|
65
|
+
}
|
|
66
|
+
/** The ceremony state file sits next to the token: `x.json` pairs with `x-state.json`. */
|
|
67
|
+
export function getClaustrumEnrollmentPaths(tokenPath) {
|
|
68
|
+
const extension = extname(tokenPath) || '.json';
|
|
69
|
+
const stem = basename(tokenPath, extname(tokenPath));
|
|
70
|
+
return {
|
|
71
|
+
statePath: join(dirname(tokenPath), `${stem}-state${extension}`),
|
|
72
|
+
tokenPath,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* One host's token and state paths. Every host gets its own pair under the
|
|
77
|
+
* plugin's state directory, so an OpenCode enrollment and a Pi enrollment can
|
|
78
|
+
* be approved and revoked independently. A plugin-resolved override replaces
|
|
79
|
+
* the default token path; a relative override resolves against `cwd`.
|
|
80
|
+
*/
|
|
81
|
+
export function hostEnrollmentPaths(input) {
|
|
82
|
+
const override = input.override?.trim();
|
|
83
|
+
if (override && !isAbsolute(override) && !input.cwd)
|
|
84
|
+
throw new ClaustrumConsumerError('invalid-state', 'A relative Claustrum enrollment override needs a base directory');
|
|
85
|
+
const tokenPath = override
|
|
86
|
+
? isAbsolute(override)
|
|
87
|
+
? override
|
|
88
|
+
: resolve(input.cwd, override)
|
|
89
|
+
: join(input.stateDir, `${input.host}-enrollment.json`);
|
|
90
|
+
return getClaustrumEnrollmentPaths(tokenPath);
|
|
91
|
+
}
|
|
92
|
+
function isRecord(value) {
|
|
93
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
94
|
+
}
|
|
95
|
+
function validTimestamp(value) {
|
|
96
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
97
|
+
}
|
|
98
|
+
function validGeneration(value) {
|
|
99
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1;
|
|
100
|
+
}
|
|
101
|
+
function invalidState() {
|
|
102
|
+
return new ClaustrumConsumerError('invalid-state', 'invalid Claustrum enrollment state');
|
|
103
|
+
}
|
|
104
|
+
function decodeEnrollmentState(value) {
|
|
105
|
+
if (!isRecord(value) ||
|
|
106
|
+
value.version !== ENROLLMENT_SCHEMA ||
|
|
107
|
+
typeof value.proposedName !== 'string' ||
|
|
108
|
+
value.proposedName.length === 0) {
|
|
109
|
+
throw invalidState();
|
|
110
|
+
}
|
|
111
|
+
if (value.phase === 'pending') {
|
|
112
|
+
if (!TOKEN_RE.test(String(value.requestSecret ?? '')) ||
|
|
113
|
+
(value.requestId !== undefined &&
|
|
114
|
+
(typeof value.requestId !== 'string' ||
|
|
115
|
+
value.requestId.length === 0)) ||
|
|
116
|
+
!validTimestamp(value.createdAt) ||
|
|
117
|
+
!validTimestamp(value.updatedAt)) {
|
|
118
|
+
throw invalidState();
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
version: 1,
|
|
122
|
+
phase: 'pending',
|
|
123
|
+
proposedName: value.proposedName,
|
|
124
|
+
requestSecret: value.requestSecret,
|
|
125
|
+
...(value.requestId !== undefined && {
|
|
126
|
+
requestId: value.requestId,
|
|
127
|
+
}),
|
|
128
|
+
createdAt: value.createdAt,
|
|
129
|
+
updatedAt: value.updatedAt,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (value.phase === 'approved') {
|
|
133
|
+
if ((value.approvedName !== undefined &&
|
|
134
|
+
(typeof value.approvedName !== 'string' ||
|
|
135
|
+
value.approvedName.length === 0)) ||
|
|
136
|
+
!validGeneration(value.tokenGeneration) ||
|
|
137
|
+
!validTimestamp(value.updatedAt)) {
|
|
138
|
+
throw invalidState();
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
version: 1,
|
|
142
|
+
phase: 'approved',
|
|
143
|
+
proposedName: value.proposedName,
|
|
144
|
+
...(value.approvedName !== undefined && {
|
|
145
|
+
approvedName: value.approvedName,
|
|
146
|
+
}),
|
|
147
|
+
tokenGeneration: value.tokenGeneration,
|
|
148
|
+
updatedAt: value.updatedAt,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (value.phase === 'denied' || value.phase === 'blocked') {
|
|
152
|
+
if ((value.errorCode !== undefined && typeof value.errorCode !== 'string') ||
|
|
153
|
+
!validTimestamp(value.updatedAt)) {
|
|
154
|
+
throw invalidState();
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
version: 1,
|
|
158
|
+
phase: value.phase,
|
|
159
|
+
proposedName: value.proposedName,
|
|
160
|
+
...(value.errorCode !== undefined && {
|
|
161
|
+
errorCode: value.errorCode,
|
|
162
|
+
}),
|
|
163
|
+
updatedAt: value.updatedAt,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
throw invalidState();
|
|
167
|
+
}
|
|
168
|
+
function decodeTokenFile(value) {
|
|
169
|
+
if (!isRecord(value) ||
|
|
170
|
+
!TOKEN_RE.test(String(value.token ?? '')) ||
|
|
171
|
+
!validGeneration(value.token_generation)) {
|
|
172
|
+
throw new ClaustrumConsumerError('invalid-token', 'invalid Claustrum enrollment token file');
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
token: value.token,
|
|
176
|
+
token_generation: value.token_generation,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function validateReadableSecretFile(metadata) {
|
|
180
|
+
if (!metadata.isFile()) {
|
|
181
|
+
throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file must be a regular file');
|
|
182
|
+
}
|
|
183
|
+
if ((metadata.mode & 0o077) !== 0) {
|
|
184
|
+
throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file must be owner-only');
|
|
185
|
+
}
|
|
186
|
+
const expectedUid = process.getuid?.();
|
|
187
|
+
if (expectedUid !== undefined && metadata.uid !== expectedUid) {
|
|
188
|
+
throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file must be owned by the current user');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** Parse secret-bearing JSON without letting a parser message echo its bytes. */
|
|
192
|
+
function parseSecretJson(text) {
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(text);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
throw new ClaustrumConsumerError('invalid-state', 'Claustrum enrollment file is not valid JSON');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function readBoundedJson(path) {
|
|
201
|
+
let descriptor;
|
|
202
|
+
try {
|
|
203
|
+
// O_NOFOLLOW: a symlink planted at the path must not redirect a secret read.
|
|
204
|
+
descriptor = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error.code === 'ENOENT')
|
|
208
|
+
return undefined;
|
|
209
|
+
throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file could not be opened safely');
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
validateReadableSecretFile(await descriptor.stat());
|
|
213
|
+
const source = Buffer.alloc(ENROLLMENT_FILE_MAX_BYTES + 1);
|
|
214
|
+
const { bytesRead } = await descriptor.read(source, 0, source.byteLength, 0);
|
|
215
|
+
if (bytesRead > ENROLLMENT_FILE_MAX_BYTES) {
|
|
216
|
+
throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file is too large');
|
|
217
|
+
}
|
|
218
|
+
return parseSecretJson(source.subarray(0, bytesRead).toString('utf8'));
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
await descriptor.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Refuse a path below any group- or world-writable directory without the
|
|
226
|
+
* sticky bit: another user could swap the file out from under us there.
|
|
227
|
+
*/
|
|
228
|
+
async function refuseWritableAncestor(parent) {
|
|
229
|
+
let component;
|
|
230
|
+
try {
|
|
231
|
+
component = await realpath(parent);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
for (;;) {
|
|
237
|
+
const metadata = await stat(component).catch(() => undefined);
|
|
238
|
+
if (metadata &&
|
|
239
|
+
(metadata.mode & 0o022) !== 0 &&
|
|
240
|
+
(metadata.mode & 0o1000) === 0) {
|
|
241
|
+
throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment path has an unsafe writable ancestor');
|
|
242
|
+
}
|
|
243
|
+
const next = dirname(component);
|
|
244
|
+
if (next === component)
|
|
245
|
+
return;
|
|
246
|
+
component = next;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Create the enrollment directory owner-only (0700), and tighten it to 0700
|
|
251
|
+
* when it already exists with group or other bits, so the token and the
|
|
252
|
+
* request secret never sit in a directory another account can list.
|
|
253
|
+
*/
|
|
254
|
+
async function ensurePrivateDirectory(directory) {
|
|
255
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
256
|
+
const metadata = await stat(directory);
|
|
257
|
+
const uid = process.getuid?.();
|
|
258
|
+
if (uid !== undefined && metadata.uid !== uid) {
|
|
259
|
+
throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment directory must be owned by the current user');
|
|
260
|
+
}
|
|
261
|
+
if ((metadata.mode & 0o077) !== 0)
|
|
262
|
+
await chmod(directory, 0o700);
|
|
263
|
+
}
|
|
264
|
+
async function writeStateAtomic(path, state) {
|
|
265
|
+
const parent = dirname(path);
|
|
266
|
+
await ensurePrivateDirectory(parent);
|
|
267
|
+
await refuseWritableAncestor(parent);
|
|
268
|
+
const bytes = `${JSON.stringify(state)}\n`;
|
|
269
|
+
if (Buffer.byteLength(bytes) > ENROLLMENT_FILE_MAX_BYTES) {
|
|
270
|
+
throw new ClaustrumConsumerError('invalid-state', 'Claustrum enrollment state is too large');
|
|
271
|
+
}
|
|
272
|
+
const temporary = join(parent, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
273
|
+
let descriptor;
|
|
274
|
+
try {
|
|
275
|
+
descriptor = await open(temporary, 'wx', 0o600);
|
|
276
|
+
await descriptor.writeFile(bytes, 'utf8');
|
|
277
|
+
await descriptor.sync();
|
|
278
|
+
await descriptor.close();
|
|
279
|
+
descriptor = undefined;
|
|
280
|
+
await chmod(temporary, 0o600);
|
|
281
|
+
await rename(temporary, path);
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
await descriptor?.close().catch(() => { });
|
|
285
|
+
await unlink(temporary).catch(() => { });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
export async function readClaustrumEnrollmentStatus(paths, proposedName) {
|
|
289
|
+
const tokenValue = await readBoundedJson(paths.tokenPath);
|
|
290
|
+
const token = tokenValue === undefined ? undefined : decodeTokenFile(tokenValue);
|
|
291
|
+
const stateValue = await readBoundedJson(paths.statePath);
|
|
292
|
+
const state = stateValue === undefined ? undefined : decodeEnrollmentState(stateValue);
|
|
293
|
+
if (token) {
|
|
294
|
+
return {
|
|
295
|
+
state: 'approved',
|
|
296
|
+
proposedName: state?.proposedName ?? proposedName,
|
|
297
|
+
...(state?.phase === 'approved' &&
|
|
298
|
+
state.approvedName !== undefined && {
|
|
299
|
+
approvedName: state.approvedName,
|
|
300
|
+
}),
|
|
301
|
+
tokenGeneration: token.token_generation,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
if (!state)
|
|
305
|
+
return { state: 'idle' };
|
|
306
|
+
if (state.phase === 'approved') {
|
|
307
|
+
return {
|
|
308
|
+
state: 'blocked',
|
|
309
|
+
proposedName: state.proposedName,
|
|
310
|
+
code: 'missing_token',
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
return statusFromState(state);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Read fresh bearer material for one scoped operation; never publish it.
|
|
317
|
+
* Re-reading per operation is what lets an operator reissue a token on disk.
|
|
318
|
+
*/
|
|
319
|
+
export async function readClaustrumEnrollmentToken(tokenPath) {
|
|
320
|
+
await refuseWritableAncestor(tokenPath);
|
|
321
|
+
const value = await readBoundedJson(tokenPath);
|
|
322
|
+
if (value === undefined)
|
|
323
|
+
throw new ClaustrumConsumerError('not-enrolled', 'Claustrum enrollment is not configured');
|
|
324
|
+
return decodeTokenFile(value);
|
|
325
|
+
}
|
|
326
|
+
function statusFromState(state) {
|
|
327
|
+
if (state.phase === 'pending') {
|
|
328
|
+
return {
|
|
329
|
+
state: 'pending',
|
|
330
|
+
proposedName: state.proposedName,
|
|
331
|
+
...(state.requestId !== undefined && { requestId: state.requestId }),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (state.phase === 'approved') {
|
|
335
|
+
return {
|
|
336
|
+
state: 'approved',
|
|
337
|
+
proposedName: state.proposedName,
|
|
338
|
+
...(state.approvedName !== undefined && {
|
|
339
|
+
approvedName: state.approvedName,
|
|
340
|
+
}),
|
|
341
|
+
tokenGeneration: state.tokenGeneration,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (state.phase === 'denied')
|
|
345
|
+
return { state: 'denied', proposedName: state.proposedName };
|
|
346
|
+
return {
|
|
347
|
+
state: 'blocked',
|
|
348
|
+
proposedName: state.proposedName,
|
|
349
|
+
code: state.errorCode ?? 'unknown',
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function wrongConsumer() {
|
|
353
|
+
return new ClaustrumConsumerError('wrong-consumer', 'Claustrum enrollment state belongs to a different consumer');
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The enrollment ceremony for one host. Run it from setup only: it proposes,
|
|
357
|
+
* polls and persists, and every step can wait on an operator. The request
|
|
358
|
+
* path reads the resulting token and never calls into this class.
|
|
359
|
+
*/
|
|
360
|
+
export class ClaustrumEnrollmentManager {
|
|
361
|
+
#client;
|
|
362
|
+
#paths;
|
|
363
|
+
#proposedName;
|
|
364
|
+
#now;
|
|
365
|
+
#mintSecret;
|
|
366
|
+
#writeTokenFile;
|
|
367
|
+
constructor(options) {
|
|
368
|
+
this.#client = options.client;
|
|
369
|
+
this.#paths = options.paths;
|
|
370
|
+
this.#proposedName = options.proposedName;
|
|
371
|
+
this.#now = options.now ?? Date.now;
|
|
372
|
+
this.#mintSecret =
|
|
373
|
+
options.mintSecret ?? (() => randomBytes(32).toString('hex'));
|
|
374
|
+
this.#writeTokenFile = options.writeTokenFile ?? writeEnrollmentTokenFile;
|
|
375
|
+
}
|
|
376
|
+
async status() {
|
|
377
|
+
return readClaustrumEnrollmentStatus(this.#paths, this.#proposedName);
|
|
378
|
+
}
|
|
379
|
+
async resetTerminal() {
|
|
380
|
+
return resetClaustrumEnrollmentState(this.#paths, this.#proposedName);
|
|
381
|
+
}
|
|
382
|
+
async #block(state, code) {
|
|
383
|
+
// Writing the terminal phase drops the request secret from disk.
|
|
384
|
+
const blocked = {
|
|
385
|
+
version: 1,
|
|
386
|
+
phase: 'blocked',
|
|
387
|
+
proposedName: state.proposedName,
|
|
388
|
+
errorCode: code,
|
|
389
|
+
updatedAt: this.#now(),
|
|
390
|
+
};
|
|
391
|
+
await writeStateAtomic(this.#paths.statePath, blocked);
|
|
392
|
+
return statusFromState(blocked);
|
|
393
|
+
}
|
|
394
|
+
async reconcile() {
|
|
395
|
+
await ensurePrivateDirectory(dirname(this.#paths.statePath));
|
|
396
|
+
const lock = await acquireRefreshFileLock({
|
|
397
|
+
name: 'ceremony',
|
|
398
|
+
path: this.#paths.statePath,
|
|
399
|
+
ttlMs: ENROLLMENT_LOCK_TTL_MS,
|
|
400
|
+
renew: true,
|
|
401
|
+
});
|
|
402
|
+
if (!lock)
|
|
403
|
+
return { state: 'busy' };
|
|
404
|
+
try {
|
|
405
|
+
const existingToken = await readBoundedJson(this.#paths.tokenPath);
|
|
406
|
+
const token = existingToken === undefined ? undefined : decodeTokenFile(existingToken);
|
|
407
|
+
const stateValue = await readBoundedJson(this.#paths.statePath);
|
|
408
|
+
let state = stateValue === undefined ? undefined : decodeEnrollmentState(stateValue);
|
|
409
|
+
if (state && state.proposedName !== this.#proposedName)
|
|
410
|
+
throw wrongConsumer();
|
|
411
|
+
if (token) {
|
|
412
|
+
if (!state || state.phase === 'pending') {
|
|
413
|
+
const approved = {
|
|
414
|
+
version: 1,
|
|
415
|
+
phase: 'approved',
|
|
416
|
+
proposedName: state?.proposedName ?? this.#proposedName,
|
|
417
|
+
tokenGeneration: token.token_generation,
|
|
418
|
+
updatedAt: this.#now(),
|
|
419
|
+
};
|
|
420
|
+
await writeStateAtomic(this.#paths.statePath, approved);
|
|
421
|
+
state = approved;
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
state: 'approved',
|
|
425
|
+
proposedName: state.proposedName,
|
|
426
|
+
...(state.phase === 'approved' &&
|
|
427
|
+
state.approvedName !== undefined && {
|
|
428
|
+
approvedName: state.approvedName,
|
|
429
|
+
}),
|
|
430
|
+
tokenGeneration: token.token_generation,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
if (state?.phase === 'approved') {
|
|
434
|
+
const blocked = {
|
|
435
|
+
version: 1,
|
|
436
|
+
phase: 'blocked',
|
|
437
|
+
proposedName: state.proposedName,
|
|
438
|
+
errorCode: 'missing_token',
|
|
439
|
+
updatedAt: this.#now(),
|
|
440
|
+
};
|
|
441
|
+
await writeStateAtomic(this.#paths.statePath, blocked);
|
|
442
|
+
return statusFromState(blocked);
|
|
443
|
+
}
|
|
444
|
+
if (state && state.phase !== 'pending')
|
|
445
|
+
return statusFromState(state);
|
|
446
|
+
if (!state) {
|
|
447
|
+
const now = this.#now();
|
|
448
|
+
state = {
|
|
449
|
+
version: 1,
|
|
450
|
+
phase: 'pending',
|
|
451
|
+
proposedName: this.#proposedName,
|
|
452
|
+
requestSecret: this.#mintSecret(),
|
|
453
|
+
createdAt: now,
|
|
454
|
+
updatedAt: now,
|
|
455
|
+
};
|
|
456
|
+
if (!TOKEN_RE.test(state.requestSecret))
|
|
457
|
+
throw new ClaustrumConsumerError('invalid-state', 'invalid minted Claustrum enrollment secret');
|
|
458
|
+
// The secret is on disk before the proposal leaves the process: the
|
|
459
|
+
// vault answers a repeated proposal with the same secret with the same
|
|
460
|
+
// request id, so a crash between propose and saving the id costs nothing.
|
|
461
|
+
await writeStateAtomic(this.#paths.statePath, state);
|
|
462
|
+
}
|
|
463
|
+
if (!state.requestId) {
|
|
464
|
+
try {
|
|
465
|
+
const requestSecretHash = createHash('sha256')
|
|
466
|
+
.update(Buffer.from(state.requestSecret, 'hex'))
|
|
467
|
+
.digest('hex');
|
|
468
|
+
const proposed = await this.#client.enrollPropose({
|
|
469
|
+
name: state.proposedName,
|
|
470
|
+
requestSecretHash,
|
|
471
|
+
});
|
|
472
|
+
state = {
|
|
473
|
+
...state,
|
|
474
|
+
requestId: proposed.requestId,
|
|
475
|
+
updatedAt: this.#now(),
|
|
476
|
+
};
|
|
477
|
+
await writeStateAtomic(this.#paths.statePath, state);
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
const refusal = classifyEnrollmentError(error);
|
|
481
|
+
if (!refusal)
|
|
482
|
+
throw error;
|
|
483
|
+
if (refusal.disposition === 'retry') {
|
|
484
|
+
return {
|
|
485
|
+
state: 'pending',
|
|
486
|
+
proposedName: state.proposedName,
|
|
487
|
+
retryCode: refusal.code,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
return this.#block(state, refusal.code);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const requestId = state.requestId;
|
|
494
|
+
if (!requestId)
|
|
495
|
+
return statusFromState(state);
|
|
496
|
+
try {
|
|
497
|
+
const outcome = await this.#client.enrollPoll({
|
|
498
|
+
requestId,
|
|
499
|
+
requestSecret: state.requestSecret,
|
|
500
|
+
});
|
|
501
|
+
if (outcome.status === 'pending')
|
|
502
|
+
return statusFromState(state);
|
|
503
|
+
if (outcome.status === 'denied') {
|
|
504
|
+
const denied = {
|
|
505
|
+
version: 1,
|
|
506
|
+
phase: 'denied',
|
|
507
|
+
proposedName: state.proposedName,
|
|
508
|
+
updatedAt: this.#now(),
|
|
509
|
+
};
|
|
510
|
+
await writeStateAtomic(this.#paths.statePath, denied);
|
|
511
|
+
return statusFromState(denied);
|
|
512
|
+
}
|
|
513
|
+
// The vault returns the token exactly once: it reaches disk before the
|
|
514
|
+
// pending metadata (and its secret) is replaced.
|
|
515
|
+
await this.#writeTokenFile(this.#paths.tokenPath, {
|
|
516
|
+
token: outcome.token,
|
|
517
|
+
token_generation: outcome.tokenGeneration,
|
|
518
|
+
});
|
|
519
|
+
const approved = {
|
|
520
|
+
version: 1,
|
|
521
|
+
phase: 'approved',
|
|
522
|
+
proposedName: state.proposedName,
|
|
523
|
+
approvedName: outcome.name,
|
|
524
|
+
tokenGeneration: outcome.tokenGeneration,
|
|
525
|
+
updatedAt: this.#now(),
|
|
526
|
+
};
|
|
527
|
+
await writeStateAtomic(this.#paths.statePath, approved);
|
|
528
|
+
return statusFromState(approved);
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
const refusal = classifyEnrollmentError(error);
|
|
532
|
+
if (!refusal)
|
|
533
|
+
throw error;
|
|
534
|
+
if (refusal.disposition === 'retry') {
|
|
535
|
+
return {
|
|
536
|
+
state: 'pending',
|
|
537
|
+
proposedName: state.proposedName,
|
|
538
|
+
requestId,
|
|
539
|
+
retryCode: refusal.code,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
return this.#block(state, refusal.code);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
finally {
|
|
546
|
+
await lock.release();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/** Reset local terminal metadata without connecting to the credential daemon. */
|
|
551
|
+
export async function resetClaustrumEnrollmentState(paths, proposedName) {
|
|
552
|
+
await ensurePrivateDirectory(dirname(paths.statePath));
|
|
553
|
+
const lock = await acquireRefreshFileLock({
|
|
554
|
+
name: 'ceremony',
|
|
555
|
+
path: paths.statePath,
|
|
556
|
+
ttlMs: ENROLLMENT_LOCK_TTL_MS,
|
|
557
|
+
renew: true,
|
|
558
|
+
});
|
|
559
|
+
if (!lock)
|
|
560
|
+
return 'busy';
|
|
561
|
+
try {
|
|
562
|
+
if ((await readBoundedJson(paths.tokenPath)) !== undefined)
|
|
563
|
+
return 'refused-approved';
|
|
564
|
+
const value = await readBoundedJson(paths.statePath);
|
|
565
|
+
if (value === undefined)
|
|
566
|
+
return 'idle';
|
|
567
|
+
const state = decodeEnrollmentState(value);
|
|
568
|
+
if (state.proposedName !== proposedName)
|
|
569
|
+
throw wrongConsumer();
|
|
570
|
+
if (state.phase === 'pending')
|
|
571
|
+
return 'refused-pending';
|
|
572
|
+
await lock.assertOwned();
|
|
573
|
+
await unlink(paths.statePath);
|
|
574
|
+
return 'reset';
|
|
575
|
+
}
|
|
576
|
+
finally {
|
|
577
|
+
await lock.release();
|
|
578
|
+
}
|
|
579
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every refusal the `/claustrum` code raises itself. Refusals sent by the
|
|
3
|
+
* vault keep arriving as the client's own `ClaustrumCredentialError` (a
|
|
4
|
+
* `code`, a `class` and an `action`), so callers can tell the vault's verdict
|
|
5
|
+
* apart from a check made on this side.
|
|
6
|
+
*/
|
|
7
|
+
export type ClaustrumConsumerFailureKind = 'closed' | 'not-enrolled' | 'invalid-token' | 'unavailable' | 'identity-changed' | 'insufficient-validity' | 'invalid-material' | 'not-active' | 'route-unavailable' | 'route-declined' | 'no-receipt' | 'unsafe-file' | 'invalid-state' | 'wrong-consumer' | 'roster-busy' | 'host-slot-login' | 'host-slot-placeholder' | 'placeholder-refresh';
|
|
8
|
+
export declare class ClaustrumConsumerError extends Error {
|
|
9
|
+
readonly kind: ClaustrumConsumerFailureKind;
|
|
10
|
+
constructor(kind: ClaustrumConsumerFailureKind, message: string);
|
|
11
|
+
}
|
|
12
|
+
/** The structural logger every class here accepts; `/logger`'s `createLogger` satisfies it. */
|
|
13
|
+
export interface ClaustrumLogger {
|
|
14
|
+
warn(message: string, data?: unknown): void;
|
|
15
|
+
debug(message: string, data?: unknown): void;
|
|
16
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's own auth slot (OpenCode's stored login for the provider, Pi's
|
|
3
|
+
* equivalent) must hold something, or the host drops the provider. Under
|
|
4
|
+
* custody it holds this placeholder: an OAuth-shaped value that is never a
|
|
5
|
+
* credential. Its empty access token also makes the vault's sealer refuse it,
|
|
6
|
+
* should it ever be offered for import.
|
|
7
|
+
*/
|
|
8
|
+
export declare const CUSTODY_PLACEHOLDER_PREFIX = "claustrum-tombstone:v1:";
|
|
9
|
+
export declare function custodyPlaceholderKey(provider: string): string;
|
|
10
|
+
export declare function custodyPlaceholder(provider: string): {
|
|
11
|
+
type: 'oauth';
|
|
12
|
+
access: '';
|
|
13
|
+
refresh: string;
|
|
14
|
+
expires: 0;
|
|
15
|
+
};
|
|
16
|
+
export declare function isCustodyPlaceholderValue(value: unknown): value is string;
|
|
17
|
+
export declare function isCustodyPlaceholder(auth: unknown, provider: string): boolean;
|
|
18
|
+
/** What the host slot holds: the custody placeholder, a real login, or nothing usable. */
|
|
19
|
+
export type HostSlotContent = 'placeholder' | 'login' | 'empty';
|
|
20
|
+
export declare function classifyHostSlot(auth: unknown, provider: string): HostSlotContent;
|
|
21
|
+
/**
|
|
22
|
+
* Check the host slot against the plugin's mode before serving anything.
|
|
23
|
+
*
|
|
24
|
+
* - Custody mode with a real login in the slot fails closed: someone signed in
|
|
25
|
+
* through the host while the vault owns the accounts, and serving either the
|
|
26
|
+
* login or the vault would silently pick one. The plugin surfaces the error
|
|
27
|
+
* and the user chooses (leave custody, or remove the login).
|
|
28
|
+
* - Local mode with the placeholder in the slot also fails: there is no local
|
|
29
|
+
* credential to serve, and the user has to sign in.
|
|
30
|
+
*
|
|
31
|
+
* Returns the slot content when the combination is consistent.
|
|
32
|
+
*/
|
|
33
|
+
export declare function assertHostSlotMatchesMode(input: {
|
|
34
|
+
mode: 'custody' | 'local';
|
|
35
|
+
auth: unknown;
|
|
36
|
+
provider: string;
|
|
37
|
+
}): HostSlotContent;
|
|
38
|
+
/** Refuse to run a local token refresh with the placeholder as the refresh token. */
|
|
39
|
+
export declare function assertNotCustodyPlaceholder(refreshToken: unknown, provider: string): void;
|