@cortexkit/claustrum-client 0.1.0 → 0.3.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.
@@ -0,0 +1,12 @@
1
+ export type EnrollmentTokenFile = {
2
+ token: string;
3
+ token_generation: number;
4
+ };
5
+ /**
6
+ * Atomically replace a consumer's enrollment credential.
7
+ *
8
+ * The file is always mode 0600 and every canonical ancestor is rejected when it is
9
+ * group- or world-writable without sticky. This is the same rule implemented by the
10
+ * shipped `refuse_writable_ancestor` symbol in `opencode_files.rs`.
11
+ */
12
+ export declare function writeEnrollmentTokenFile(path: string, value: EnrollmentTokenFile): Promise<void>;
@@ -0,0 +1,67 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { chmod, mkdir, open, realpath, rename, stat, unlink } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+ const TOKEN_RE = /^[0-9a-f]{64}$/;
5
+ function validateTokenFile(value) {
6
+ if (!TOKEN_RE.test(value.token))
7
+ throw new Error('enrollment token must be exactly 64 lowercase hex characters');
8
+ if (!Number.isSafeInteger(value.token_generation) || value.token_generation < 1) {
9
+ throw new Error('enrollment token generation must be a positive safe integer');
10
+ }
11
+ }
12
+ async function refuseWritableAncestor(parent) {
13
+ let component;
14
+ try {
15
+ component = await realpath(parent);
16
+ }
17
+ catch {
18
+ // The following file operation reports the precise filesystem error. A permissions
19
+ // verdict about a path that could not be resolved would hide that useful failure.
20
+ return;
21
+ }
22
+ for (;;) {
23
+ const metadata = await stat(component).catch(() => undefined);
24
+ // Canonicalisation already traversed this component. If a later stat races with a
25
+ // rename, let the create/rename operation below report the concrete failure.
26
+ if (metadata && (metadata.mode & 0o022) !== 0 && (metadata.mode & 0o1000) === 0) {
27
+ throw new Error(`enrollment token ancestor ${component} is group- or world-writable without sticky bit`);
28
+ }
29
+ const next = dirname(component);
30
+ if (next === component)
31
+ return;
32
+ component = next;
33
+ }
34
+ }
35
+ /**
36
+ * Atomically replace a consumer's enrollment credential.
37
+ *
38
+ * The file is always mode 0600 and every canonical ancestor is rejected when it is
39
+ * group- or world-writable without sticky. This is the same rule implemented by the
40
+ * shipped `refuse_writable_ancestor` symbol in `opencode_files.rs`.
41
+ */
42
+ export async function writeEnrollmentTokenFile(path, value) {
43
+ validateTokenFile(value);
44
+ const parent = dirname(path);
45
+ await mkdir(parent, { recursive: true, mode: 0o700 });
46
+ await refuseWritableAncestor(parent);
47
+ const temporary = join(parent, `.${basename(path)}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`);
48
+ let created = false;
49
+ try {
50
+ const descriptor = await open(temporary, 'wx', 0o600);
51
+ created = true;
52
+ try {
53
+ await descriptor.writeFile(`${JSON.stringify(value)}\n`, 'utf8');
54
+ await descriptor.sync();
55
+ await chmod(temporary, 0o600);
56
+ }
57
+ finally {
58
+ await descriptor.close();
59
+ }
60
+ await rename(temporary, path);
61
+ created = false;
62
+ }
63
+ finally {
64
+ if (created)
65
+ await unlink(temporary).catch(() => undefined);
66
+ }
67
+ }
package/dist/handles.d.ts CHANGED
@@ -12,6 +12,7 @@ export type HandleAccount = {
12
12
  handle: string;
13
13
  credential_id: string;
14
14
  superseded?: string[];
15
+ minTtlMs?: number;
15
16
  };
16
17
  export type HandleProvider = {
17
18
  provider: string;
@@ -24,6 +25,7 @@ export type OpenCodeHandleFileV1 = {
24
25
  providers: HandleProvider[];
25
26
  };
26
27
  export declare function identifierIsValid(value: unknown): value is string;
28
+ export declare function credentialIdMatchesProvider(value: unknown, provider: string): value is string;
27
29
  export declare function parseHandleFile(value: unknown): OpenCodeHandleFileV1;
28
30
  type HandleFileStat = {
29
31
  isFile(): boolean;
package/dist/handles.js CHANGED
@@ -30,6 +30,12 @@ function handleIsValid(handle) {
30
30
  export function identifierIsValid(value) {
31
31
  return typeof value === 'string' && HANDLE_FILE_CONTRACT.labelRe.test(value) && !FORBIDDEN_IDENTIFIERS.has(value);
32
32
  }
33
+ export function credentialIdMatchesProvider(value, provider) {
34
+ if (typeof value !== 'string')
35
+ return false;
36
+ const segments = value.split(':');
37
+ return value.length > 0 && segments[1] === provider && segments.every((segment) => segment.length > 0);
38
+ }
33
39
  function invalid(message) {
34
40
  throw new HandleFileValidationError(message);
35
41
  }
@@ -66,6 +72,9 @@ export function parseHandleFile(value) {
66
72
  labels.add(account.label);
67
73
  if (!handleIsValid(account.handle))
68
74
  invalid(`provider ${index} account ${account.label} has invalid handle`);
75
+ if (account.minTtlMs !== undefined && !(Number.isSafeInteger(account.minTtlMs) && account.minTtlMs >= 0)) {
76
+ invalid(`provider ${index} account ${account.label} has invalid minTtlMs`);
77
+ }
69
78
  // Segment 2 of the credential id must BE the provider block it sits in. Without
70
79
  // this the check was non-empty-string only, so an `oauth:openai` binding parsed
71
80
  // cleanly inside an `anthropic` block -- a cross-provider smuggle that every
@@ -100,9 +109,8 @@ export function parseHandleFile(value) {
100
109
  // the emptiness guard. NO TEST DISTINGUISHES THAT ONE (empty rejects with or
101
110
  // without it, verified by removal), so it is kept for a future reader who loosens
102
111
  // the comparison, not for an arm it could never redden. The non-empty-SEGMENT
103
- // rule below is different: it reddens, and is pinned.
104
- const segments = account.credential_id.split(':');
105
- if (!account.credential_id || segments[1] !== item.provider || segments.some((segment) => segment.length === 0)) {
112
+ // rule in credentialIdMatchesProvider is different: it reddens, and is pinned.
113
+ if (!credentialIdMatchesProvider(account.credential_id, item.provider)) {
106
114
  invalid(`provider ${index} account ${account.label} has invalid credential id`);
107
115
  }
108
116
  if (account.superseded?.some((handle) => !handleIsValid(handle))) {
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { detectClaustrumConnection, getDefaultClaustrumConnectionPath, resolveClaustrumConnectionPath, type ClaustrumDetection, type ClaustrumEndpoint, } from './detect.js';
2
2
  export { storeIdentity, storageFingerprint } from './identity.js';
3
+ export { writeEnrollmentTokenFile, type EnrollmentTokenFile, } from './enrollment.js';
3
4
  export { MANIFEST_LOCK, manifestLockQuarantinePrefix, withManifestLock, writeHandleFileLocked, type ManifestHandleAccount, type ManifestHandleFile, type ManifestHandleProvider, type ManifestLockError, type ManifestLockErrorCode, } from './manifest-lock.js';
4
5
  export { ClaustrumCredentialError, credentialErrorAction, ERROR_CLASS_WIRE_SET, type ClaustrumCredentialErrorAction, type ClaustrumCredentialErrorClass, } from './errors.js';
5
- export { ClaustrumClient, type ClaustrumClientOptions, type ClaustrumConnector, type ClaustrumReporterSource, type CredentialStatus, type ServedCredential, } from './wire.js';
6
- export { HANDLE_FILE_CONTRACT, HandleFileValidationError, defaultHandleFilePath, handleFileRevision, identifierIsValid, parseHandleFile, readHandleFile, type HandleAccount, type HandleFileIo, type HandleProvider, type OpenCodeHandleFileV1, } from './handles.js';
6
+ export { ClaustrumClient, type ClaustrumClientOptions, type ClaustrumConnector, type ClaustrumReporterSource, type CredentialStatus, type EnrollmentPollOutcome, type ScopedInventory, type ScopedInventoryRow, type ServedCredential, } from './wire.js';
7
+ export { HANDLE_FILE_CONTRACT, HandleFileValidationError, credentialIdMatchesProvider, defaultHandleFilePath, handleFileRevision, identifierIsValid, parseHandleFile, readHandleFile, type HandleAccount, type HandleFileIo, type HandleProvider, type OpenCodeHandleFileV1, } from './handles.js';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { detectClaustrumConnection, getDefaultClaustrumConnectionPath, resolveClaustrumConnectionPath, } from './detect.js';
2
2
  export { storeIdentity, storageFingerprint } from './identity.js';
3
+ export { writeEnrollmentTokenFile, } from './enrollment.js';
3
4
  export { MANIFEST_LOCK, manifestLockQuarantinePrefix, withManifestLock, writeHandleFileLocked, } from './manifest-lock.js';
4
5
  export { ClaustrumCredentialError, credentialErrorAction, ERROR_CLASS_WIRE_SET, } from './errors.js';
5
6
  export { ClaustrumClient, } from './wire.js';
6
- export { HANDLE_FILE_CONTRACT, HandleFileValidationError, defaultHandleFilePath, handleFileRevision, identifierIsValid, parseHandleFile, readHandleFile, } from './handles.js';
7
+ export { HANDLE_FILE_CONTRACT, HandleFileValidationError, credentialIdMatchesProvider, defaultHandleFilePath, handleFileRevision, identifierIsValid, parseHandleFile, readHandleFile, } from './handles.js';
package/dist/wire.d.ts CHANGED
@@ -57,5 +57,155 @@ export declare class ClaustrumClient {
57
57
  recordVersion: number;
58
58
  reporterSource: ClaustrumReporterSource;
59
59
  }): Promise<void>;
60
+ /**
61
+ * Enumerate what this caller's grants cover.
62
+ *
63
+ * `params` is `{}` and must be PRESENT: the vault rejects an absent or null params
64
+ * object, so a caller cannot ask a different question by omitting it.
65
+ */
66
+ listScoped(enrollmentToken?: string): Promise<ScopedInventory>;
67
+ /**
68
+ * Fetch by credential id, authorized by a grant rather than a bearer handle.
69
+ *
70
+ * `minTtlMs` is a DEMAND, not a hint: if a real upstream exchange still cannot satisfy
71
+ * it, the vault refuses with `ttl_unsatisfiable` (class `context_overflow`) rather than
72
+ * serving a token that will die inside the caller's own margin. That class means
73
+ * reduce-and-retry, never wait-and-retry.
74
+ */
75
+ getScoped(input: {
76
+ credentialId: string;
77
+ enrollmentToken?: string;
78
+ minTtlMs?: number;
79
+ }): Promise<ServedCredential>;
80
+ /**
81
+ * Report a served credential dead, addressed by id rather than by handle.
82
+ *
83
+ * VERSION-FENCED: `recordVersion` must be the one this caller was SERVED. A report
84
+ * against a since-refreshed version is accepted and does nothing, which is the point --
85
+ * a stale report must not kill a fresh token. `accepted: true` is the receipt that the
86
+ * report was taken, never evidence that it applied.
87
+ */
88
+ reportAuthFailureScoped(input: {
89
+ credentialId: string;
90
+ enrollmentToken?: string;
91
+ providerStatus: number;
92
+ recordVersion: number;
93
+ reporterSource: ClaustrumReporterSource;
94
+ }): Promise<void>;
95
+ /**
96
+ * Propose an enrollment. The consumer mints `requestSecret` itself and sends only its
97
+ * hash; the raw secret NEVER leaves the process, and it is what authorizes the poll.
98
+ *
99
+ * IDEMPOTENT ON (name, secret): re-proposing with the same secret returns the SAME
100
+ * `requestId`. That exists because `requestId` is minted server-side, so a consumer
101
+ * cannot persist it before proposing -- a crash in that window would otherwise leave it
102
+ * holding a secret it cannot poll with and a name held until TTL. Persist the secret
103
+ * BEFORE calling this, and a crash costs nothing.
104
+ *
105
+ * A different secret on the same name is a different caller and is refused.
106
+ */
107
+ enrollPropose(input: {
108
+ name: string;
109
+ requestSecretHash: string;
110
+ }): Promise<{
111
+ requestId: string;
112
+ }>;
113
+ /**
114
+ * Poll a proposal. Returns `pending` until an operator decides, then exactly once
115
+ * returns the token.
116
+ *
117
+ * THE TOKEN IS RETURNED ONCE. Persist it before doing anything else with it; a lost
118
+ * token needs a fresh ceremony, not a second poll.
119
+ */
120
+ enrollPoll(input: {
121
+ requestId: string;
122
+ requestSecret: string;
123
+ }): Promise<EnrollmentPollOutcome>;
124
+ /**
125
+ * Exchange a live token for its successor. The old token dies when the new one is issued.
126
+ *
127
+ * `expectedTokenGeneration` IS REQUIRED AND IS A FENCE, not bookkeeping: it must be the
128
+ * generation this caller currently holds. Two rotations racing — a retry after an
129
+ * uncertain reply, or two instances of the same consumer — would otherwise each mint a
130
+ * successor and each invalidate the other's, leaving a consumer holding a token the
131
+ * vault has already replaced. The vault refuses a rotation whose expectation does not
132
+ * match, so the loser learns it lost instead of discovering it on a later spend.
133
+ */
134
+ enrollRotate(input: {
135
+ token: string;
136
+ expectedTokenGeneration: number;
137
+ }): Promise<{
138
+ token: string;
139
+ tokenGeneration: number;
140
+ }>;
60
141
  close(): void;
61
142
  }
143
+ /**
144
+ * A row from the caller's own grant-covered inventory.
145
+ *
146
+ * TWO SELECTION AXES, AND THEY ANSWER DIFFERENT QUESTIONS. Pick by what you are about
147
+ * to do with the token:
148
+ *
149
+ * - `refreshAdapter` — whose PROTOCOL the credential speaks. Use it when the token
150
+ * goes to a provider's own endpoints. Anything else is invalid there.
151
+ * - `serves` — which model VENDORS are reachable through it. Use it when choosing
152
+ * where a model can be served from, and the protocol is not yours to care about.
153
+ *
154
+ * Never the id spelling, in either case: on a live vault 8 rows serve Anthropic models
155
+ * and 3 of them contain no "anthropic" anywhere in their id.
156
+ *
157
+ * An earlier version of this comment called `serves` "the routing axis" full stop. That
158
+ * was written before `refreshAdapter` existed and is wrong for the protocol-native case:
159
+ * it would send a Claude OAuth request to `apikey:openrouter`.
160
+ */
161
+ export interface ScopedInventoryRow {
162
+ readonly id: string;
163
+ readonly categories: readonly string[];
164
+ readonly credentialType: string;
165
+ readonly serves: readonly string[];
166
+ /**
167
+ * Which provider protocol this credential speaks. Absent for static keys.
168
+ *
169
+ * SELECT ON THIS, NOT ON `serves`, WHEN THE TOKEN GOES TO A PROVIDER'S OWN ENDPOINTS.
170
+ * Measured on a live vault: eight rows serve Anthropic models and only five are Claude
171
+ * OAuth -- openrouter, antigravity and cursor reach Anthropic models through their own
172
+ * APIs. The id spelling does not separate them either: two contain no "anthropic" at
173
+ * all, and `credentialType === 'oauth'` catches antigravity and cursor too.
174
+ */
175
+ readonly refreshAdapter?: string;
176
+ readonly state: string;
177
+ readonly recordVersion: number;
178
+ readonly operations: readonly string[];
179
+ /**
180
+ * NOT SERVED TODAY. The field is absent from `ListScopedCredential`, so this is always
181
+ * null; it is declared because a consumer's discovery high-water mark needs it and the
182
+ * absence should be visible in the type rather than discovered at runtime.
183
+ */
184
+ readonly createdAtMs: number | null;
185
+ readonly accountId?: string;
186
+ readonly email?: string;
187
+ readonly orgName?: string;
188
+ }
189
+ /**
190
+ * An inventory reply: the covered rows, plus the cursor that says whether they moved.
191
+ *
192
+ * `view` is a DIGEST OVER WHAT THIS CALLER CAN SEE — ids, categories, state, operations —
193
+ * and deliberately NOT over `record_version`, so a routine token refresh does not move it.
194
+ * Compare it against the previous call's to decide whether to reconcile at all; it is an
195
+ * opaque string and nothing but equality is defined on it.
196
+ */
197
+ export interface ScopedInventory {
198
+ readonly rows: readonly ScopedInventoryRow[];
199
+ readonly view: string;
200
+ }
201
+ /** What a poll found. `pending` is not an error: the operator has not decided yet. */
202
+ export type EnrollmentPollOutcome = {
203
+ readonly status: 'pending';
204
+ } | {
205
+ readonly status: 'approved';
206
+ readonly name: string;
207
+ readonly token: string;
208
+ readonly tokenGeneration: number;
209
+ } | {
210
+ readonly status: 'denied';
211
+ };
package/dist/wire.js CHANGED
@@ -134,6 +134,115 @@ export class ClaustrumClient {
134
134
  if (result?.accepted !== true)
135
135
  throw asCredentialError(response, 'invalid_response', this.#logger);
136
136
  }
137
+ /**
138
+ * Enumerate what this caller's grants cover.
139
+ *
140
+ * `params` is `{}` and must be PRESENT: the vault rejects an absent or null params
141
+ * object, so a caller cannot ask a different question by omitting it.
142
+ */
143
+ async listScoped(enrollmentToken) {
144
+ const response = await this.#call('credential.list_scoped', enrollmentToken === undefined ? {} : { enrollment_token: enrollmentToken });
145
+ return decodeScopedInventory(response, this.#logger);
146
+ }
147
+ /**
148
+ * Fetch by credential id, authorized by a grant rather than a bearer handle.
149
+ *
150
+ * `minTtlMs` is a DEMAND, not a hint: if a real upstream exchange still cannot satisfy
151
+ * it, the vault refuses with `ttl_unsatisfiable` (class `context_overflow`) rather than
152
+ * serving a token that will die inside the caller's own margin. That class means
153
+ * reduce-and-retry, never wait-and-retry.
154
+ */
155
+ async getScoped(input) {
156
+ const response = await this.#call('credential.get_scoped', {
157
+ credential_id: input.credentialId,
158
+ enrollment_token: input.enrollmentToken,
159
+ min_ttl_ms: input.minTtlMs,
160
+ });
161
+ return decodeCredential(response, this.#logger);
162
+ }
163
+ /**
164
+ * Report a served credential dead, addressed by id rather than by handle.
165
+ *
166
+ * VERSION-FENCED: `recordVersion` must be the one this caller was SERVED. A report
167
+ * against a since-refreshed version is accepted and does nothing, which is the point --
168
+ * a stale report must not kill a fresh token. `accepted: true` is the receipt that the
169
+ * report was taken, never evidence that it applied.
170
+ */
171
+ async reportAuthFailureScoped(input) {
172
+ const response = await this.#call('credential.report_auth_failure', {
173
+ credential_id: input.credentialId,
174
+ enrollment_token: input.enrollmentToken,
175
+ provider_status: input.providerStatus,
176
+ record_version: input.recordVersion,
177
+ reporter_source: input.reporterSource,
178
+ });
179
+ if (hasCredentialError(response))
180
+ throw asCredentialError(response, 'invalid_response', this.#logger);
181
+ const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
182
+ if (result?.accepted !== true)
183
+ throw asCredentialError(response, 'invalid_response', this.#logger);
184
+ }
185
+ /**
186
+ * Propose an enrollment. The consumer mints `requestSecret` itself and sends only its
187
+ * hash; the raw secret NEVER leaves the process, and it is what authorizes the poll.
188
+ *
189
+ * IDEMPOTENT ON (name, secret): re-proposing with the same secret returns the SAME
190
+ * `requestId`. That exists because `requestId` is minted server-side, so a consumer
191
+ * cannot persist it before proposing -- a crash in that window would otherwise leave it
192
+ * holding a secret it cannot poll with and a name held until TTL. Persist the secret
193
+ * BEFORE calling this, and a crash costs nothing.
194
+ *
195
+ * A different secret on the same name is a different caller and is refused.
196
+ */
197
+ async enrollPropose(input) {
198
+ const response = await this.#call('auth.enroll_propose', {
199
+ proposed_name: input.name,
200
+ request_secret_hash: input.requestSecretHash,
201
+ });
202
+ const result = decodeEnrollmentResult(response, this.#logger);
203
+ const requestId = result.request_id;
204
+ if (typeof requestId !== 'string' || requestId.length === 0) {
205
+ throw asCredentialError(response, 'invalid_response', this.#logger);
206
+ }
207
+ return { requestId };
208
+ }
209
+ /**
210
+ * Poll a proposal. Returns `pending` until an operator decides, then exactly once
211
+ * returns the token.
212
+ *
213
+ * THE TOKEN IS RETURNED ONCE. Persist it before doing anything else with it; a lost
214
+ * token needs a fresh ceremony, not a second poll.
215
+ */
216
+ async enrollPoll(input) {
217
+ const response = await this.#call('auth.enroll_poll', {
218
+ request_id: input.requestId,
219
+ request_secret: input.requestSecret,
220
+ });
221
+ return decodeEnrollmentPoll(response, this.#logger);
222
+ }
223
+ /**
224
+ * Exchange a live token for its successor. The old token dies when the new one is issued.
225
+ *
226
+ * `expectedTokenGeneration` IS REQUIRED AND IS A FENCE, not bookkeeping: it must be the
227
+ * generation this caller currently holds. Two rotations racing — a retry after an
228
+ * uncertain reply, or two instances of the same consumer — would otherwise each mint a
229
+ * successor and each invalidate the other's, leaving a consumer holding a token the
230
+ * vault has already replaced. The vault refuses a rotation whose expectation does not
231
+ * match, so the loser learns it lost instead of discovering it on a later spend.
232
+ */
233
+ async enrollRotate(input) {
234
+ const response = await this.#call('auth.enroll_rotate', {
235
+ token: input.token,
236
+ expected_token_generation: input.expectedTokenGeneration,
237
+ });
238
+ const result = decodeEnrollmentResult(response, this.#logger);
239
+ const token = result.token;
240
+ const generation = result.token_generation;
241
+ if (typeof token !== 'string' || token.length === 0 || typeof generation !== 'number') {
242
+ throw asCredentialError(response, 'invalid_response', this.#logger);
243
+ }
244
+ return { token, tokenGeneration: generation };
245
+ }
137
246
  close() {
138
247
  this.#closed = true;
139
248
  this.#client.close();
@@ -209,3 +318,103 @@ export class ClaustrumClient {
209
318
  await this.#reconnecting;
210
319
  }
211
320
  }
321
+ function decodeScopedInventory(response, logUnknownClass) {
322
+ if (hasCredentialError(response))
323
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
324
+ const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
325
+ const rows = result?.credentials;
326
+ if (!Array.isArray(rows))
327
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
328
+ // THE CURSOR IS NOT OPTIONAL. Dropping it silently, as this decoder did in 0.2.0, leaves
329
+ // a consumer unable to tell "nothing changed" from "I did not ask", so it reconciles
330
+ // every poll or not at all.
331
+ const view = result?.view;
332
+ if (typeof view !== 'string' || view.length === 0) {
333
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
334
+ }
335
+ const decoded = rows.map((row) => {
336
+ if (!isRecord(row))
337
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
338
+ const recordVersion = asRecordVersion(row.record_version);
339
+ if (recordVersion === undefined) {
340
+ throw asCredentialError(response, 'invalid_record_version', logUnknownClass);
341
+ }
342
+ const strings = (value) => {
343
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) {
344
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
345
+ }
346
+ return value;
347
+ };
348
+ // THE FIELD IS `id`, NOT `credential_id`. `get` and `status` echo `credential_id`
349
+ // for binding verification; an inventory row IS the credential, so it does not name
350
+ // the concept twice. Reading the wrong key yields undefined for every row and says
351
+ // nothing about why -- which is exactly what it did to my own probe.
352
+ // THE WIRE KEY IS `type`, NOT `credential_type`. The Rust field is named
353
+ // `credential_type` (because `type` is a keyword) and carries
354
+ // `#[serde(rename = "type")]`, so the FIELD name and the WIRE name differ and reading
355
+ // the field name gets undefined for every row.
356
+ //
357
+ // Second instance of this exact mistake in one evening: my probe read
358
+ // `credential_id` where the row says `id` and printed "?" for all seventeen. Both
359
+ // times I wrote the decoder from the Rust struct rather than from a served payload,
360
+ // which is the habit this test exists to break.
361
+ if (typeof row.id !== 'string' || typeof row.type !== 'string' || typeof row.state !== 'string') {
362
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
363
+ }
364
+ const createdAtMs = row.created_at_ms === undefined || row.created_at_ms === null
365
+ ? null
366
+ : typeof row.created_at_ms === 'number' && Number.isFinite(row.created_at_ms)
367
+ ? row.created_at_ms
368
+ : undefined;
369
+ if (createdAtMs === undefined)
370
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
371
+ if (!isOptionalString(row.refresh_adapter)) {
372
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
373
+ }
374
+ if (!isOptionalString(row.account_id) || !isOptionalString(row.email) || !isOptionalString(row.org_name)) {
375
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
376
+ }
377
+ return {
378
+ id: row.id,
379
+ categories: strings(row.categories),
380
+ credentialType: row.type,
381
+ serves: strings(row.serves),
382
+ state: row.state,
383
+ refreshAdapter: row.refresh_adapter,
384
+ recordVersion,
385
+ operations: strings(row.operations),
386
+ createdAtMs,
387
+ accountId: row.account_id,
388
+ email: row.email,
389
+ orgName: row.org_name,
390
+ };
391
+ });
392
+ return { rows: decoded, view };
393
+ }
394
+ function decodeEnrollmentResult(response, logUnknownClass) {
395
+ if (hasCredentialError(response))
396
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
397
+ const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
398
+ if (result === undefined)
399
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
400
+ return result;
401
+ }
402
+ function decodeEnrollmentPoll(response, logUnknownClass) {
403
+ const result = decodeEnrollmentResult(response, logUnknownClass);
404
+ const status = result.status;
405
+ if (status === 'pending')
406
+ return { status: 'pending' };
407
+ if (status === 'denied')
408
+ return { status: 'denied' };
409
+ if (status !== 'approved')
410
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
411
+ const { name, token, token_generation: generation } = result;
412
+ if (typeof name !== 'string' ||
413
+ typeof token !== 'string' ||
414
+ token.length === 0 ||
415
+ typeof generation !== 'number' ||
416
+ !Number.isInteger(generation)) {
417
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
418
+ }
419
+ return { status: 'approved', name, token, tokenGeneration: generation };
420
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/claustrum-client",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",