@cortexkit/claustrum-client 0.11.0 → 0.12.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/README.md CHANGED
@@ -47,6 +47,76 @@ copy. One behaviour reaches callers: while the vault is restarting or refusing r
47
47
  opens as retryable, a call now keeps retrying for up to 90 s (paced, with jitter) instead
48
48
  of 30 s. Give every vault call on a latency-sensitive path its own timeout.
49
49
 
50
+ ## Watching for changes
51
+
52
+ Since 0.12.0, `client.watchScoped(onNotice, { enrollmentToken? })` holds an
53
+ `auth.watch_scoped` request on a dedicated route. It returns `{ stop(), closed }`.
54
+ `stop()` is idempotent and resolves `closed` as `{ kind: 'stopped' }`. Notices have
55
+ exactly `{ kind: 'changed', epoch, seq, view, keys }`; malformed notices reject
56
+ `closed` with `ScopedWatchError` (`code: 'invalid_notice'`). Callback exceptions
57
+ also reject it (`callback_failed`); async callbacks must handle their own rejection.
58
+ Sequence gaps and new epochs are delivered, not silently filtered.
59
+
60
+ **The caller owns reconnecting and re-subscribing.** Read `listScoped` on connect.
61
+ On every notice, re-read if `view` differs from the last listing actually read,
62
+ `epoch` is new, or `seq` skipped. Notices are sheddable hints, not an event journal.
63
+ `keys` matters only to consumers that announce vault-held public keys: re-read
64
+ those keys when that digest changes, independently of the inventory view.
65
+
66
+ `@cortexkit/subc-client` 0.22 reports Response (including refusal) and STREAM_END
67
+ frames as the same successful subscription close, without the Response body.
68
+ Every accepted vault watch immediately sends notice seq 1, so the client uses
69
+ whether any notices arrived to interpret that ambiguous close:
70
+
71
+ | `closed` outcome | Caller action |
72
+ | --- | --- |
73
+ | `ended` (closed after notices) | Re-read and re-subscribe once for this close. A mid-watch revocation may take this extra round trip to discover. |
74
+ | `refused`, with the existing typed credential `error` | Stop. Never reconnect for that refusal. |
75
+ | `not_accepting` | Zero notices, then one successful `listScoped` confirmation. The vault may be draining: wait for reconnect or a caller-chosen delay, not a tight re-subscribe loop. |
76
+ | `transport_lost`, with the original SDK `error` (including `route_closed`) | Reconnect, re-read, then re-watch. |
77
+ | `unsupported` (`unknown_method`) | Fall back to polling `listScoped`. |
78
+
79
+ A zero-notice close performs exactly one `listScoped` using the same enrollment
80
+ token: its refusal becomes `refused`, its success becomes `not_accepting`. There is
81
+ no automatic retry or reconnection in that confirmation read. Malformed replies
82
+ reject `closed` with `ScopedWatchError('invalid_response')`.
83
+
84
+ ```ts
85
+ let client = await ClaustrumClient.connect(connectionOptions)
86
+ for (;;) {
87
+ let lastView = (await client.listScoped(token)).view // read on every connect/reopen
88
+ let epoch: string | undefined
89
+ let seq = 0
90
+ let reads = Promise.resolve()
91
+ let readError: unknown
92
+ const watch = client.watchScoped((notice) => {
93
+ const discontinuity = notice.epoch !== epoch || notice.seq !== seq + 1
94
+ epoch = notice.epoch
95
+ seq = notice.seq
96
+ reads = reads.then(async () => {
97
+ if (discontinuity || notice.view !== lastView) {
98
+ const listing = await client.listScoped(token)
99
+ lastView = listing.view // acknowledge the digest actually read, not the hint
100
+ useInventory(listing)
101
+ }
102
+ }).catch((error) => { readError = error; watch.stop() })
103
+ // Key-announcing callers separately compare notice.keys and re-read held keys.
104
+ }, { enrollmentToken: token })
105
+ let outcome
106
+ try { outcome = await watch.closed; await reads; if (readError) throw readError }
107
+ catch (error) { watch.stop(); throw error } // malformed notice/read/callback failure
108
+ if (outcome.kind === 'ended') continue // one new watch per close, never hidden retries
109
+ if (outcome.kind === 'transport_lost') {
110
+ client.close()
111
+ client = await ClaustrumClient.connect(connectionOptions)
112
+ continue
113
+ }
114
+ if (outcome.kind === 'not_accepting') { await callerBackoff(); continue }
115
+ if (outcome.kind === 'unsupported') await pollScopedInventory(client, token)
116
+ break // refused or explicitly stopped
117
+ }
118
+ ```
119
+
50
120
  ## OpenCode handle-file exports
51
121
 
52
122
  `defaultHandleFilePath`, `readHandleFile`, `handleFileRevision`, and `parseHandleFile`
package/dist/index.d.ts CHANGED
@@ -4,4 +4,5 @@ export { writeEnrollmentTokenFile, type EnrollmentTokenFile, } from './enrollmen
4
4
  export { MANIFEST_LOCK, manifestLockQuarantinePrefix, withManifestLock, writeHandleFileLocked, type ManifestHandleAccount, type ManifestHandleFile, type ManifestHandleProvider, type ManifestLockError, type ManifestLockErrorCode, } from './manifest-lock.js';
5
5
  export { ClaustrumCredentialError, credentialErrorAction, ERROR_CLASS_WIRE_SET, type ClaustrumCredentialErrorAction, type ClaustrumCredentialErrorClass, } from './errors.js';
6
6
  export { ClaustrumClient, type ClaustrumClientOptions, type ClaustrumConnector, type ClaustrumReporterSource, type CredentialStatus, type EnrollmentPollOutcome, type ScopedGrantTuple, type ScopedInventory, type ScopedInventoryRow, type ServedCredential, } from './wire.js';
7
+ export { ScopedWatchError, type ScopedWatch, type ScopedWatchNotice, type ScopedWatchOptions, type ScopedWatchOutcome, } from './watch.js';
7
8
  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
@@ -4,4 +4,5 @@ export { writeEnrollmentTokenFile, } from './enrollment.js';
4
4
  export { MANIFEST_LOCK, manifestLockQuarantinePrefix, withManifestLock, writeHandleFileLocked, } from './manifest-lock.js';
5
5
  export { ClaustrumCredentialError, credentialErrorAction, ERROR_CLASS_WIRE_SET, } from './errors.js';
6
6
  export { ClaustrumClient, } from './wire.js';
7
+ export { ScopedWatchError, } from './watch.js';
7
8
  export { HANDLE_FILE_CONTRACT, HandleFileValidationError, credentialIdMatchesProvider, defaultHandleFilePath, handleFileRevision, identifierIsValid, parseHandleFile, readHandleFile, } from './handles.js';
@@ -0,0 +1,38 @@
1
+ import type { BindIdentity, SubcClient } from '@cortexkit/subc-client';
2
+ import { ClaustrumCredentialError } from './errors.js';
3
+ export type ScopedWatchNotice = {
4
+ kind: 'changed';
5
+ epoch: string;
6
+ seq: number;
7
+ view: string;
8
+ keys: string;
9
+ };
10
+ export type ScopedWatchOutcome = {
11
+ kind: 'refused';
12
+ error: ClaustrumCredentialError;
13
+ } | {
14
+ kind: 'ended';
15
+ } | {
16
+ kind: 'not_accepting';
17
+ } | {
18
+ kind: 'transport_lost';
19
+ error: unknown;
20
+ } | {
21
+ kind: 'unsupported';
22
+ } | {
23
+ kind: 'stopped';
24
+ };
25
+ export declare class ScopedWatchError extends Error {
26
+ readonly code: 'invalid_notice' | 'callback_failed' | 'invalid_response';
27
+ constructor(code: 'invalid_notice' | 'callback_failed' | 'invalid_response');
28
+ }
29
+ export type ScopedWatch = {
30
+ stop(): void;
31
+ closed: Promise<ScopedWatchOutcome>;
32
+ };
33
+ export type ScopedWatchOptions = {
34
+ enrollmentToken?: string;
35
+ };
36
+ /** Decode only the notice schema; sequence gaps and new epochs belong to the consumer. */
37
+ export declare function decodeScopedWatchNotice(bytes: Uint8Array): ScopedWatchNotice;
38
+ export declare function openScopedWatch(client: Pick<SubcClient, 'routeOpen' | 'subscribe' | 'closeRoute'>, identity: BindIdentity, onNotice: (notice: ScopedWatchNotice) => void, options: ScopedWatchOptions, readScoped: () => Promise<unknown>): ScopedWatch;
package/dist/watch.js ADDED
@@ -0,0 +1,104 @@
1
+ import { ClaustrumCredentialError } from './errors.js';
2
+ export class ScopedWatchError extends Error {
3
+ code;
4
+ constructor(code) {
5
+ super(`Scoped watch failed: ${code}`);
6
+ this.code = code;
7
+ this.name = 'ScopedWatchError';
8
+ }
9
+ }
10
+ /** Decode only the notice schema; sequence gaps and new epochs belong to the consumer. */
11
+ export function decodeScopedWatchNotice(bytes) {
12
+ try {
13
+ const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
14
+ if (value === null || typeof value !== 'object' || Array.isArray(value) ||
15
+ Object.keys(value).sort().join(',') !== 'epoch,keys,kind,seq,view' ||
16
+ value.kind !== 'changed' || typeof value.epoch !== 'string' || value.epoch.length === 0 ||
17
+ !Number.isSafeInteger(value.seq) || value.seq <= 0 ||
18
+ typeof value.view !== 'string' || typeof value.keys !== 'string')
19
+ throw new ScopedWatchError('invalid_notice');
20
+ return value;
21
+ }
22
+ catch {
23
+ throw new ScopedWatchError('invalid_notice');
24
+ }
25
+ }
26
+ export function openScopedWatch(client, identity, onNotice, options, readScoped) {
27
+ let settled = false;
28
+ let notices = 0;
29
+ let subscription;
30
+ let route;
31
+ let resolve;
32
+ let reject;
33
+ const closed = new Promise((yes, no) => { resolve = yes; reject = no; });
34
+ const finish = (outcome) => {
35
+ if (settled)
36
+ return;
37
+ settled = true;
38
+ subscription?.unsubscribe();
39
+ if (route)
40
+ void client.closeRoute(route).catch(() => { });
41
+ if (outcome instanceof ScopedWatchError)
42
+ reject(outcome);
43
+ else
44
+ resolve(outcome);
45
+ };
46
+ void (async () => {
47
+ try {
48
+ route = await client.routeOpen({ kind: 'management_surface', module_id: 'claustrum' }, identity, { consumerIdentity: null });
49
+ if (settled) {
50
+ await client.closeRoute(route);
51
+ return;
52
+ }
53
+ subscription = client.subscribe(route, {
54
+ method: 'auth.watch_scoped',
55
+ params: options.enrollmentToken === undefined ? {} : { enrollment_token: options.enrollmentToken },
56
+ }, (bytes) => {
57
+ if (settled)
58
+ return;
59
+ let notice;
60
+ try {
61
+ notice = decodeScopedWatchNotice(bytes);
62
+ }
63
+ catch {
64
+ finish(new ScopedWatchError('invalid_notice'));
65
+ return;
66
+ }
67
+ notices++;
68
+ try {
69
+ onNotice(notice);
70
+ }
71
+ catch {
72
+ finish(new ScopedWatchError('callback_failed'));
73
+ }
74
+ });
75
+ await subscription.closed;
76
+ if (settled)
77
+ return;
78
+ // The SDK erases Response bodies. Only an immediate close needs a confirming
79
+ // list read; after notices, the caller reopens once to discover a revocation.
80
+ if (notices > 0)
81
+ finish({ kind: 'ended' });
82
+ else {
83
+ try {
84
+ await readScoped();
85
+ finish({ kind: 'not_accepting' });
86
+ }
87
+ catch (error) {
88
+ if (error instanceof ClaustrumCredentialError && error.code !== 'invalid_response') {
89
+ finish({ kind: 'refused', error });
90
+ }
91
+ else if (error instanceof ClaustrumCredentialError)
92
+ finish(new ScopedWatchError('invalid_response'));
93
+ else
94
+ finish({ kind: 'transport_lost', error });
95
+ }
96
+ }
97
+ }
98
+ catch (error) {
99
+ const code = error !== null && typeof error === 'object' && 'code' in error ? error.code : undefined;
100
+ finish(code === 'unknown_method' ? { kind: 'unsupported' } : { kind: 'transport_lost', error });
101
+ }
102
+ })();
103
+ return { stop: () => finish({ kind: 'stopped' }), closed };
104
+ }
package/dist/wire.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SubcClient, type BindIdentity } from '@cortexkit/subc-client';
2
+ import { type ScopedWatch, type ScopedWatchNotice, type ScopedWatchOptions } from './watch.js';
2
3
  export type ClaustrumConnector = (options: {
3
4
  connectionFile: string;
4
5
  handshakeTimeoutMs?: number;
@@ -107,6 +108,8 @@ export declare class ClaustrumClient {
107
108
  * object, so a caller cannot ask a different question by omitting it.
108
109
  */
109
110
  listScoped(enrollmentToken?: string): Promise<ScopedInventory>;
111
+ /** One watch on a dedicated route. Reopen/reconnect policy belongs to the caller. */
112
+ watchScoped(onNotice: (notice: ScopedWatchNotice) => void, options?: ScopedWatchOptions): ScopedWatch;
110
113
  /**
111
114
  * Fetch by credential id, authorized by a grant rather than a bearer handle.
112
115
  *
package/dist/wire.js CHANGED
@@ -2,6 +2,7 @@ import { SubcCallError, SubcClient, } from '@cortexkit/subc-client';
2
2
  import { resolveClaustrumConnectionPath } from './detect.js';
3
3
  import { asCredentialError, ClaustrumCredentialError, hasCredentialError, } from './errors.js';
4
4
  import { storeIdentity } from './identity.js';
5
+ import { openScopedWatch } from './watch.js';
5
6
  const CLAUSTRUM_MODULE_ID = 'claustrum';
6
7
  const RECONNECT_BACKOFF_MS = 60_000;
7
8
  function isRecord(value) {
@@ -163,6 +164,14 @@ export class ClaustrumClient {
163
164
  const response = await this.#call('credential.list_scoped', enrollmentToken === undefined ? {} : { enrollment_token: enrollmentToken });
164
165
  return decodeScopedInventory(response, this.#logger);
165
166
  }
167
+ /** One watch on a dedicated route. Reopen/reconnect policy belongs to the caller. */
168
+ watchScoped(onNotice, options = {}) {
169
+ const client = this.#client;
170
+ return openScopedWatch(client, this.#identity, onNotice, options, async () => {
171
+ const response = await client.call('claustrum', 'credential.list_scoped', options.enrollmentToken === undefined ? {} : { enrollment_token: options.enrollmentToken }, { identity: this.#identity, consumerIdentity: null });
172
+ return decodeScopedInventory(response, this.#logger);
173
+ });
174
+ }
166
175
  /**
167
176
  * Fetch by credential id, authorized by a grant rather than a bearer handle.
168
177
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/claustrum-client",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",