@cortexkit/claustrum-client 0.1.0 → 0.2.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 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,124 @@ 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<readonly ScopedInventoryRow[]>;
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
+ /** Exchange a live token for its successor. The old token dies when the new one is issued. */
125
+ enrollRotate(input: {
126
+ token: string;
127
+ }): Promise<{
128
+ token: string;
129
+ tokenGeneration: number;
130
+ }>;
60
131
  close(): void;
61
132
  }
133
+ /**
134
+ * A row from the caller's own grant-covered inventory.
135
+ *
136
+ * `serves` is the ROUTING AXIS, not the id spelling. `apikey:openrouter` and
137
+ * `antigravity:google` both serve Anthropic models and contain no "anthropic"
138
+ * anywhere in their ids, so a consumer that filters on the id segment silently
139
+ * drops working accounts. Measured on a live vault: 8 Anthropic-capable rows, 3 of
140
+ * them invisible to an id-substring filter.
141
+ */
142
+ export interface ScopedInventoryRow {
143
+ readonly id: string;
144
+ readonly categories: readonly string[];
145
+ readonly credentialType: string;
146
+ readonly serves: readonly string[];
147
+ /**
148
+ * Which provider protocol this credential speaks. Absent for static keys.
149
+ *
150
+ * SELECT ON THIS, NOT ON `serves`, WHEN THE TOKEN GOES TO A PROVIDER'S OWN ENDPOINTS.
151
+ * Measured on a live vault: eight rows serve Anthropic models and only five are Claude
152
+ * OAuth -- openrouter, antigravity and cursor reach Anthropic models through their own
153
+ * APIs. The id spelling does not separate them either: two contain no "anthropic" at
154
+ * all, and `credentialType === 'oauth'` catches antigravity and cursor too.
155
+ */
156
+ readonly refreshAdapter?: string;
157
+ readonly state: string;
158
+ readonly recordVersion: number;
159
+ readonly operations: readonly string[];
160
+ /**
161
+ * NOT SERVED TODAY. The field is absent from `ListScopedCredential`, so this is always
162
+ * null; it is declared because a consumer's discovery high-water mark needs it and the
163
+ * absence should be visible in the type rather than discovered at runtime.
164
+ */
165
+ readonly createdAtMs: number | null;
166
+ readonly accountId?: string;
167
+ readonly email?: string;
168
+ readonly orgName?: string;
169
+ }
170
+ /** What a poll found. `pending` is not an error: the operator has not decided yet. */
171
+ export type EnrollmentPollOutcome = {
172
+ readonly status: 'pending';
173
+ } | {
174
+ readonly status: 'approved';
175
+ readonly name: string;
176
+ readonly token: string;
177
+ readonly tokenGeneration: number;
178
+ } | {
179
+ readonly status: 'denied';
180
+ };
package/dist/wire.js CHANGED
@@ -134,6 +134,103 @@ 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
+ /** Exchange a live token for its successor. The old token dies when the new one is issued. */
224
+ async enrollRotate(input) {
225
+ const response = await this.#call('auth.enroll_rotate', { token: input.token });
226
+ const result = decodeEnrollmentResult(response, this.#logger);
227
+ const token = result.token;
228
+ const generation = result.token_generation;
229
+ if (typeof token !== 'string' || token.length === 0 || typeof generation !== 'number') {
230
+ throw asCredentialError(response, 'invalid_response', this.#logger);
231
+ }
232
+ return { token, tokenGeneration: generation };
233
+ }
137
234
  close() {
138
235
  this.#closed = true;
139
236
  this.#client.close();
@@ -209,3 +306,95 @@ export class ClaustrumClient {
209
306
  await this.#reconnecting;
210
307
  }
211
308
  }
309
+ function decodeScopedInventory(response, logUnknownClass) {
310
+ if (hasCredentialError(response))
311
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
312
+ const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
313
+ const rows = result?.credentials;
314
+ if (!Array.isArray(rows))
315
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
316
+ return rows.map((row) => {
317
+ if (!isRecord(row))
318
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
319
+ const recordVersion = asRecordVersion(row.record_version);
320
+ if (recordVersion === undefined) {
321
+ throw asCredentialError(response, 'invalid_record_version', logUnknownClass);
322
+ }
323
+ const strings = (value) => {
324
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string')) {
325
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
326
+ }
327
+ return value;
328
+ };
329
+ // THE FIELD IS `id`, NOT `credential_id`. `get` and `status` echo `credential_id`
330
+ // for binding verification; an inventory row IS the credential, so it does not name
331
+ // the concept twice. Reading the wrong key yields undefined for every row and says
332
+ // nothing about why -- which is exactly what it did to my own probe.
333
+ // THE WIRE KEY IS `type`, NOT `credential_type`. The Rust field is named
334
+ // `credential_type` (because `type` is a keyword) and carries
335
+ // `#[serde(rename = "type")]`, so the FIELD name and the WIRE name differ and reading
336
+ // the field name gets undefined for every row.
337
+ //
338
+ // Second instance of this exact mistake in one evening: my probe read
339
+ // `credential_id` where the row says `id` and printed "?" for all seventeen. Both
340
+ // times I wrote the decoder from the Rust struct rather than from a served payload,
341
+ // which is the habit this test exists to break.
342
+ if (typeof row.id !== 'string' || typeof row.type !== 'string' || typeof row.state !== 'string') {
343
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
344
+ }
345
+ const createdAtMs = row.created_at_ms === undefined || row.created_at_ms === null
346
+ ? null
347
+ : typeof row.created_at_ms === 'number' && Number.isFinite(row.created_at_ms)
348
+ ? row.created_at_ms
349
+ : undefined;
350
+ if (createdAtMs === undefined)
351
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
352
+ if (!isOptionalString(row.refresh_adapter)) {
353
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
354
+ }
355
+ if (!isOptionalString(row.account_id) || !isOptionalString(row.email) || !isOptionalString(row.org_name)) {
356
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
357
+ }
358
+ return {
359
+ id: row.id,
360
+ categories: strings(row.categories),
361
+ credentialType: row.type,
362
+ serves: strings(row.serves),
363
+ state: row.state,
364
+ refreshAdapter: row.refresh_adapter,
365
+ recordVersion,
366
+ operations: strings(row.operations),
367
+ createdAtMs,
368
+ accountId: row.account_id,
369
+ email: row.email,
370
+ orgName: row.org_name,
371
+ };
372
+ });
373
+ }
374
+ function decodeEnrollmentResult(response, logUnknownClass) {
375
+ if (hasCredentialError(response))
376
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
377
+ const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
378
+ if (result === undefined)
379
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
380
+ return result;
381
+ }
382
+ function decodeEnrollmentPoll(response, logUnknownClass) {
383
+ const result = decodeEnrollmentResult(response, logUnknownClass);
384
+ const status = result.status;
385
+ if (status === 'pending')
386
+ return { status: 'pending' };
387
+ if (status === 'denied')
388
+ return { status: 'denied' };
389
+ if (status !== 'approved')
390
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
391
+ const { name, token, token_generation: generation } = result;
392
+ if (typeof name !== 'string' ||
393
+ typeof token !== 'string' ||
394
+ token.length === 0 ||
395
+ typeof generation !== 'number' ||
396
+ !Number.isInteger(generation)) {
397
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
398
+ }
399
+ return { status: 'approved', name, token, tokenGeneration: generation };
400
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/claustrum-client",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",