@matuto-ai/opencode-broker-connector 0.1.1 → 0.4.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +61 -43
  3. package/bin/doctor.mjs +5 -0
  4. package/docs/candidate-verification.md +25 -0
  5. package/docs/integration.md +8 -6
  6. package/docs/legacy-connection.md +77 -0
  7. package/docs/message-lifecycle.md +24 -0
  8. package/docs/native-automation.md +155 -0
  9. package/docs/native-v2.md +121 -0
  10. package/docs/owned-run.md +4 -2
  11. package/docs/publishing.md +5 -5
  12. package/docs/reference-snapshot.sha256 +5 -5
  13. package/docs/sandbox-persistence.md +43 -0
  14. package/docs/verification.md +62 -0
  15. package/package.json +15 -4
  16. package/plugin/index.mjs +14 -2
  17. package/plugin/tui.mjs +17 -0
  18. package/plugin/v2.mjs +18 -0
  19. package/src/approval-helper.mjs +11 -0
  20. package/src/capabilities.mjs +13 -0
  21. package/src/config.mjs +5 -2
  22. package/src/cooperative-inbox.mjs +13 -8
  23. package/src/enrollment.mjs +52 -0
  24. package/src/installation.mjs +123 -0
  25. package/src/message-format.mjs +26 -0
  26. package/src/native-context.mjs +57 -0
  27. package/src/native-delivery.mjs +151 -0
  28. package/src/native-plugin.mjs +135 -13
  29. package/src/opencode-adapter.mjs +31 -15
  30. package/src/pending-resume.mjs +38 -0
  31. package/src/reference-pin.mjs +6 -6
  32. package/src/reference.mjs +1 -1
  33. package/src/setup-ui.mjs +100 -0
  34. package/src/tool-catalog.mjs +7 -2
  35. package/src/tool-scope.mjs +26 -0
  36. package/src/v2-delivery.mjs +149 -0
  37. package/src/v2-inbox.mjs +33 -0
  38. package/src/v2-lock.mjs +17 -0
  39. package/src/v2-plugin.mjs +192 -0
  40. package/src/v2-setup.mjs +133 -0
  41. package/v2/LICENSE +21 -0
  42. package/v2/README.md +17 -0
  43. package/v2/index.mjs +2 -0
  44. package/v2/package-lock.json +420 -0
  45. package/v2/package.json +13 -0
  46. package/vendor/broker/ORIGIN.md +11 -4
  47. package/vendor/broker/reference/client.mjs +90 -15
  48. package/vendor/broker/reference/inbox.mjs +55 -10
  49. package/vendor/broker/reference/mcp-stdio.mjs +5 -1
  50. package/vendor/broker/reference/tools.mjs +38 -14
  51. package/vendor/broker/src/protocol.mjs +19 -0
  52. package/vendor/enrollment/LICENSE +21 -0
  53. package/vendor/enrollment/ORIGIN.md +20 -0
  54. package/vendor/enrollment/approval.mjs +181 -0
  55. package/vendor/enrollment/enrollment.mjs +137 -0
@@ -2,7 +2,7 @@ import { DatabaseSync } from 'node:sqlite';
2
2
  import { mkdirSync, lstatSync, statfsSync, readdirSync, chmodSync, realpathSync } from 'node:fs';
3
3
  import { resolve, join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { canonical, digest, validateEnvelope, validateUuid, validateSend, validateIdempotencyKey, TYPES } from '../src/protocol.mjs';
5
+ import { canonical, digest, validateEnvelope, validateUuid, validateSend, validateIdempotencyKey, validateResend, TYPES } from '../src/protocol.mjs';
6
6
  import { LocalError } from './errors.mjs';
7
7
 
8
8
  export function newSessionIdentity(clientType) {
@@ -68,6 +68,12 @@ export class Inbox {
68
68
  key TEXT PRIMARY KEY, digest TEXT NOT NULL, request TEXT NOT NULL,
69
69
  created_at INTEGER NOT NULL, retry_until INTEGER NOT NULL, result TEXT);
70
70
  PRAGMA user_version=1;`);
71
+ if(!this.db.prepare('PRAGMA table_info(outgoing)').all().some(c=>c.name==='kind'))this.db.exec("ALTER TABLE outgoing ADD COLUMN kind TEXT NOT NULL DEFAULT 'send'");
72
+ for(const [name,type] of [['failure','TEXT'],['uncertain','INTEGER NOT NULL DEFAULT 0'],['operation_id','TEXT']]){
73
+ if(!this.db.prepare('PRAGMA table_info(outgoing)').all().some(c=>c.name===name))this.db.exec(`ALTER TABLE outgoing ADD COLUMN ${name} ${type}`);
74
+ }
75
+ this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS outgoing_operation ON outgoing(operation_id) WHERE operation_id IS NOT NULL');
76
+ this.db.exec('CREATE INDEX IF NOT EXISTS outgoing_recovery ON outgoing(retry_until,created_at,key) WHERE result IS NULL AND failure IS NULL');
71
77
  const stored = this.getState('identity');
72
78
  if (stored && canonical(stored) !== canonical(this.identity)) throw new LocalError('session_mismatch');
73
79
  if (!stored) this.setState('identity', this.identity);
@@ -234,33 +240,62 @@ export class Inbox {
234
240
  });
235
241
  }
236
242
 
237
- prepareSend(rawRequest, { key = randomUUID(), retryWindowSeconds = 86400 } = {}) {
238
- key = validateIdempotencyKey(key);
239
- const request = validateSend(rawRequest);
243
+ prepareSend(rawRequest, options = {}) {
244
+ return this.prepareOperation('send',validateSend(rawRequest),options);
245
+ }
246
+
247
+ prepareResend(messageId, input = {}, options = {}) {
248
+ const validated=validateResend(input);
249
+ return this.prepareOperation('resend',{message_id:validateUuid(messageId),...(input.ttl_seconds===undefined?{}:validated)},options);
250
+ }
251
+
252
+ prepareOperation(kind, request, { key, operationId, issuedAt=this.now(), retryUntil, retryWindowSeconds = 86400 } = {}) {
253
+ if(operationId!==undefined&&(typeof operationId!=='string'||!/^[a-f0-9]{64}$/.test(operationId)))throw new LocalError('invalid_configuration');
254
+ const mapped=operationId?this.outgoingForOperation(operationId):null;
255
+ key = validateIdempotencyKey(key??mapped?.key??`op_${issuedAt}_${randomUUID().replaceAll('-','')}`);
240
256
  const encoded = canonical(request);
241
257
  const hash = digest(request);
242
258
  const existing = this.outgoing(key);
243
259
  if (existing) {
244
- if (existing.digest !== hash) throw new LocalError('send_conflict');
260
+ if (existing.digest !== hash || existing.kind !== kind) throw new LocalError('send_conflict');
245
261
  return key;
246
262
  }
263
+ const started=/^op_([0-9]{13})_/.exec(key);
264
+ if(started&&issuedAt-Number(started[1])>=retryWindowSeconds*1000)throw new LocalError('retry_window_elapsed',{effect:'unknown'});
265
+ if(retryUntil!==undefined&&(!Number.isSafeInteger(retryUntil)||retryUntil<=this.now()))throw new LocalError('retry_window_elapsed',{effect:'unknown'});
247
266
  if (!Number.isSafeInteger(retryWindowSeconds) || retryWindowSeconds < 1) throw new LocalError('invalid_configuration');
248
267
  this.transaction(() => {
268
+ this.db.prepare('DELETE FROM outgoing WHERE key IN (SELECT key FROM outgoing WHERE (result IS NOT NULL OR failure IS NOT NULL) AND retry_until<? LIMIT 100)').run(this.now());
249
269
  if (this.db.prepare('SELECT COUNT(*) AS n FROM outgoing').get().n >= this.limits.maxOutgoingCount) throw new LocalError('inbox_full');
250
- this.db.prepare('INSERT INTO outgoing(key,digest,request,created_at,retry_until) VALUES(?,?,?,?,?)').run(key, hash, encoded, this.now(), this.now() + retryWindowSeconds * 1000);
270
+ this.db.prepare('INSERT INTO outgoing(key,digest,request,created_at,retry_until,kind,operation_id) VALUES(?,?,?,?,?,?,?)').run(key, hash, encoded, this.now(), Math.min(retryUntil??Infinity,this.now()+retryWindowSeconds*1000),kind,operationId??null);
251
271
  }, { growth: Buffer.byteLength(encoded) * 3 + 8192 });
252
272
  return key;
253
273
  }
254
274
 
275
+ outgoingForOperation(operationId) {
276
+ const row=this.db.prepare('SELECT key FROM outgoing WHERE operation_id=?').get(operationId);
277
+ return row?this.outgoing(row.key):null;
278
+ }
279
+
255
280
  outgoing(key) {
256
281
  this.assertUsable();
257
282
  const row = this.db.prepare('SELECT * FROM outgoing WHERE key=?').get(key);
258
- return row ? { ...row, request: JSON.parse(row.request), result: row.result ? JSON.parse(row.result) : null } : null;
283
+ return row ? { ...row, request: JSON.parse(row.request), result: row.result ? JSON.parse(row.result) : null, failure: row.failure ? JSON.parse(row.failure) : null } : null;
259
284
  }
260
285
 
261
286
  unresolvedSends() {
262
287
  this.assertUsable();
263
- return this.db.prepare('SELECT key,created_at,retry_until FROM outgoing WHERE result IS NULL ORDER BY created_at LIMIT 100').all().map(row => ({ ...row }));
288
+ return this.db.prepare('SELECT key,created_at,retry_until FROM outgoing WHERE result IS NULL AND failure IS NULL ORDER BY created_at LIMIT 100').all().map(row => ({ ...row }));
289
+ }
290
+
291
+ outgoingRecovery(limit = 10, now = this.now()) {
292
+ this.assertUsable();
293
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(now)) throw new LocalError('invalid_configuration');
294
+ // Filter before LIMIT: retained, expired unknowns must never starve live retries.
295
+ // Earliest deadline first uses the partial index without sorting/scanning old rows.
296
+ const operations = this.db.prepare('SELECT key,created_at,retry_until FROM outgoing WHERE result IS NULL AND failure IS NULL AND retry_until>? ORDER BY retry_until,created_at,key LIMIT ?').all(now, limit).map(row => ({ ...row }));
297
+ const expiredCount = this.db.prepare('SELECT count(*) AS n FROM outgoing WHERE result IS NULL AND failure IS NULL AND retry_until<=?').get(now).n;
298
+ return { operations, expiredCount };
264
299
  }
265
300
 
266
301
  recordSendResult(key, result) {
@@ -268,15 +303,25 @@ export class Inbox {
268
303
  if (Buffer.byteLength(encoded) > 16384) throw new LocalError('invalid_response', { effect: 'unknown' });
269
304
  this.transaction(() => {
270
305
  if (!this.outgoing(key)) throw new LocalError('invalid_configuration');
271
- this.db.prepare('UPDATE outgoing SET result=? WHERE key=?').run(encoded, key);
306
+ this.db.prepare('UPDATE outgoing SET result=?,failure=NULL,uncertain=0 WHERE key=?').run(encoded, key);
272
307
  }, { growth: Buffer.byteLength(encoded) * 2 + 8192 });
273
308
  }
274
309
 
310
+ markOutgoingAttempt(key) {
311
+ this.transaction(()=>this.db.prepare('UPDATE outgoing SET uncertain=1 WHERE key=?').run(key));
312
+ }
313
+
314
+ recordSendFailure(key, failure) {
315
+ const encoded=canonical(failure);
316
+ if(Buffer.byteLength(encoded)>16384)throw new LocalError('invalid_response',{effect:'unknown'});
317
+ this.transaction(()=>this.db.prepare('UPDATE outgoing SET failure=?,uncertain=0 WHERE key=? AND result IS NULL').run(encoded,key),{growth:Buffer.byteLength(encoded)*2+8192});
318
+ }
319
+
275
320
  collect(limit = 100) {
276
321
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) throw new LocalError('invalid_configuration');
277
322
  return this.transaction(() => {
278
323
  const removedInbox = this.db.prepare("DELETE FROM inbox WHERE message_id IN (SELECT message_id FROM inbox WHERE state='consumed' AND retain_until<? ORDER BY retain_until LIMIT ?)").run(this.now(), limit).changes;
279
- const removedSends = this.db.prepare('DELETE FROM outgoing WHERE key IN (SELECT key FROM outgoing WHERE result IS NOT NULL AND retry_until<? ORDER BY retry_until LIMIT ?)').run(this.now(), limit).changes;
324
+ const removedSends = this.db.prepare('DELETE FROM outgoing WHERE key IN (SELECT key FROM outgoing WHERE (result IS NOT NULL OR failure IS NOT NULL) AND retry_until<? ORDER BY retry_until LIMIT ?)').run(this.now(), limit).changes;
280
325
  return { removed_inbox: removedInbox, removed_sends: removedSends };
281
326
  });
282
327
  }
@@ -13,8 +13,12 @@ const metadata = object({ name: text(128), model, project: { type: ['string', 'n
13
13
 
14
14
  export const TOOL_DEFINITIONS = Object.freeze([
15
15
  { name: 'broker_list_clients', description: 'List authorized exact session addresses. Names and models are descriptive, not routing authority.', inputSchema: object({ connected: { type: 'boolean' }, client_type: { enum: ['pi', 'antigravity-cli', 'claude-code', 'opencode'] }, name: text(128), project: text(256), cursor: text(256), limit: { type: 'integer', minimum: 1, maximum: 100 } }) },
16
- { name: 'broker_send', description: 'Send opaque untrusted AGENT text to one exact client_id. Acceptance means broker storage only; preserve send_key on uncertain results.', inputSchema: object({ recipient_id: uuid, text: text(65536), ttl_seconds: { type: 'integer', minimum: 60, maximum: 604800 }, correlation_id: text(128), reply_to_message_id: uuid, send_key: text(128) }, ['recipient_id', 'text']) },
16
+ { name: 'broker_send', description: 'Send opaque untrusted AGENT text to one exact client_id. Acceptance means broker storage only; internal operation identity handles technical retries.', inputSchema: object({ recipient_id: uuid, text: text(65536), ttl_seconds: { type: 'integer', minimum: 60, maximum: 604800 }, correlation_id: text(128), reply_to_message_id: uuid }, ['recipient_id', 'text']) },
17
17
  { name: 'broker_update_metadata', description: 'CAS update of descriptive name/model/project only. Metadata does not grant access or change identity.', inputSchema: object({ metadata_patch: metadata, expected_metadata_revision: { type: 'integer', minimum: 1 } }, ['metadata_patch', 'expected_metadata_revision']) },
18
+ { name: 'broker_sent_history', description: 'Read only this exact session’s outgoing metadata, newest first. Default 10, maximum 100, 32768-byte cap. No content. Preserve filters on continuation; restart on history_changed.', inputSchema: object({ recipient_id: uuid, status: { enum: ['queued','claimed','delivered','expired','failed','cancelled'] }, accepted_after: text(24), accepted_before: text(24), cursor: text(1024), limit: { type: 'integer', minimum: 1, maximum: 100 } }) },
19
+ { name: 'broker_message_content', description: 'Explicitly retrieve the original content and current transport receipt of one owned outgoing message.', inputSchema: object({ message_id: uuid }, ['message_id']) },
20
+ { name: 'broker_resend', description: 'Explicit new delivery of an owned retained message, with a new message ID. May duplicate a prior delivery; never an automatic technical retry.', inputSchema: object({ message_id: uuid, ttl_seconds: { type: 'integer', minimum: 60, maximum: 604800 } }, ['message_id']) },
21
+ { name: 'broker_cancel', description: 'Stop further pending delivery of one owned message. Cannot undo inbox content; inspect uncertainty and cancel_result.', inputSchema: object({ message_id: uuid }, ['message_id']) },
18
22
  { name: 'broker_message_status', description: 'Read broker delivery state, never model progress or task completion.', inputSchema: object({ message_id: uuid }, ['message_id']) },
19
23
  { name: 'broker_receive', description: 'Cooperatively peek at the oldest durable AGENT inbox item during this tool call. No unsolicited wakeup or native-consumption receipt; the item remains pending.', inputSchema: object() },
20
24
  ]);
@@ -1,15 +1,25 @@
1
+ import { digest } from '../src/protocol.mjs';
1
2
  import { LocalError, safeError } from './errors.mjs';
2
3
  import { provenance } from './runtime.mjs';
3
4
 
4
5
  /** Native host tool facade, not an MCP transport or a generic TUI injection API. */
5
6
  export function createTools({ client, inbox = client?.inbox, observeMetadata = null, cooperativeBoundary = null }) {
6
7
  if (!client || !inbox) throw new LocalError('invalid_configuration');
8
+ const internalOperation=(kind,context)=>{
9
+ if(context?.operationId!==undefined){
10
+ if(typeof context.operationId!=='string'||!context.operationId||context.operationId.length>1024)throw new LocalError('invalid_configuration');
11
+ const operationId=digest({kind,operation_id:context.operationId}),existing=inbox.outgoingForOperation(operationId);
12
+ return {operationId,...(existing?{key:existing.key}:{}),...(context.operationStartedAt===undefined?{}:{operationStartedAt:context.operationStartedAt})};
13
+ }
14
+ return {};
15
+ };
16
+ const publicResult=value=>{const {send_key,...result}=value;return result;};
7
17
  return {
8
- async broker_list_clients(arguments_ = {}) { return client.listClients(arguments_); },
9
- async broker_send(arguments_) {
18
+ async broker_list_clients(arguments_ = {}, context = {}) { return client.listClients(arguments_,{signal:context.signal}); },
19
+ async broker_send(arguments_, context = {}) {
10
20
  if (!arguments_ || typeof arguments_ !== 'object' || Array.isArray(arguments_)
11
- || Object.keys(arguments_).some(key => !['recipient_id', 'text', 'ttl_seconds', 'correlation_id', 'reply_to_message_id', 'send_key'].includes(key))) throw new LocalError('invalid_configuration');
12
- let key = arguments_.send_key;
21
+ || Object.keys(arguments_).some(key => !['recipient_id', 'text', 'ttl_seconds', 'correlation_id', 'reply_to_message_id'].includes(key))) throw new LocalError('invalid_configuration');
22
+ const operation=internalOperation('send',context);let key=operation.key;
13
23
  try {
14
24
  const request = { recipient_id: arguments_.recipient_id, content: { type: 'text/plain', text: arguments_.text } };
15
25
  for (const field of ['ttl_seconds', 'correlation_id', 'reply_to_message_id']) if (Object.hasOwn(arguments_, field)) request[field] = arguments_[field];
@@ -21,21 +31,35 @@ export function createTools({ client, inbox = client?.inbox, observeMetadata = n
21
31
  } else if (!original && observeMetadata) {
22
32
  const observed = await observeMetadata();
23
33
  if (observed) {
24
- await client.refreshOwnedMetadata();
34
+ await client.refreshOwnedMetadata({signal:context.signal});
25
35
  request.metadata_patch = observed; request.expected_metadata_revision = client.attachment.metadata_revision;
26
36
  }
27
37
  }
28
- key = client.prepareSend(request, key ? { key } : {});
29
- return await client.sendPrepared(key);
30
- } catch (error) { return { ...safeError(error), send_key: key ?? null }; }
38
+ key = client.prepareSend(request,operation);
39
+ return publicResult(await client.sendPrepared(key,{signal:context.signal}));
40
+ } catch (error) { return safeError(error); }
31
41
  },
32
- async broker_update_metadata(arguments_) {
42
+ async broker_update_metadata(arguments_, context = {}) {
33
43
  if (!arguments_ || Object.keys(arguments_).some(key => !['metadata_patch', 'expected_metadata_revision'].includes(key))) throw new LocalError('invalid_configuration');
34
- return client.updateMetadata(arguments_.metadata_patch, { expectedRevision: arguments_.expected_metadata_revision });
44
+ return client.updateMetadata(arguments_.metadata_patch, { expectedRevision: arguments_.expected_metadata_revision, signal:context.signal });
45
+ },
46
+ async broker_sent_history(arguments_ = {}, context = {}) { return client.sentHistory(arguments_,{signal:context.signal}); },
47
+ async broker_resend(arguments_, context = {}) {
48
+ if(!arguments_||Object.keys(arguments_).some(key=>!['message_id','ttl_seconds'].includes(key)))throw new LocalError('invalid_configuration');
49
+ const operation=internalOperation('resend',context),{message_id,...input}=arguments_;
50
+ return publicResult(await client.resendPrepared(client.prepareResend(message_id,input,operation),{signal:context.signal}));
51
+ },
52
+ async broker_cancel(arguments_, context = {}) {
53
+ if(!arguments_||Object.keys(arguments_).join(',')!=='message_id')throw new LocalError('invalid_configuration');
54
+ return client.cancelMessage(arguments_.message_id,{signal:context.signal});
55
+ },
56
+ async broker_message_content(arguments_, context = {}) {
57
+ if(!arguments_||Object.keys(arguments_).join(',')!=='message_id') throw new LocalError('invalid_configuration');
58
+ return client.messageContent(arguments_.message_id,{signal:context.signal});
35
59
  },
36
- async broker_message_status(arguments_) {
60
+ async broker_message_status(arguments_, context = {}) {
37
61
  if (!arguments_ || Object.keys(arguments_).join(',') !== 'message_id') throw new LocalError('invalid_configuration');
38
- return client.messageStatus(arguments_.message_id);
62
+ return client.messageStatus(arguments_.message_id,{signal:context.signal});
39
63
  },
40
64
  async broker_receive(arguments_ = {}) {
41
65
  if (Object.keys(arguments_).length || typeof cooperativeBoundary !== 'function') throw new LocalError('unsafe_boundary');
@@ -52,8 +76,8 @@ export function createTools({ client, inbox = client?.inbox, observeMetadata = n
52
76
  };
53
77
  }
54
78
 
55
- export async function invokeTool(tools, name, arguments_) {
79
+ export async function invokeTool(tools, name, arguments_, context = {}) {
56
80
  if (!Object.hasOwn(tools, name) || typeof tools[name] !== 'function') return new LocalError('unknown_tool').toJSON();
57
- try { return await tools[name](arguments_); }
81
+ try { return await tools[name](arguments_,context); }
58
82
  catch (error) { return safeError(error); }
59
83
  }
@@ -17,6 +17,9 @@ const ERROR_TEXT = Object.freeze({
17
17
  forbidden: ['This operation is not authorized.', 'Check the enrolled adapter permissions.'],
18
18
  recipient_not_found: ['The recipient is not available.', 'Select an exact authorized client ID from discovery.'],
19
19
  message_not_found: ['The message is not available.', 'Use a message ID owned by this principal.'],
20
+ retry_window_elapsed: ['The operation recovery record has expired.', 'Reconcile the existing receipt; only an explicit new send or resend may create another delivery.'],
21
+ history_changed: ['History changed while paging.', 'Restart the listing without a cursor; no continuation was silently skipped.'],
22
+ content_unavailable: ['The original content is no longer retained.', 'Use the receipt; unavailable content cannot be reconstructed.'],
20
23
  identity_conflict: ['The session identity conflicts with an existing registration.', 'Do not adopt another session; verify the local session identity.'],
21
24
  stale_lease_epoch: ['Reconnect this session before retrying.', 'Read owned registration state and reconnect; do not blindly replay a mutation.'],
22
25
  idempotency_conflict: ['This key was already used for a different request.', 'Reconcile the original request; do not change an uncertain request.'],
@@ -297,3 +300,19 @@ export function canonical(value) {
297
300
  export function digest(value) {
298
301
  return createHash('sha256').update(canonical(value), 'utf8').digest('hex');
299
302
  }
303
+
304
+ export function validateResend(value, { limits = {} } = {}) {
305
+ object(value, ['ttl_seconds'], [], 'body');
306
+ const merged = { ...DEFAULTS, ...limits };
307
+ return { ttl_seconds: value.ttl_seconds === undefined ? merged.default_ttl_seconds : integer(value.ttl_seconds, Math.max(60, merged.min_ttl_seconds), Math.min(604800, merged.max_ttl_seconds), 'body.ttl_seconds') };
308
+ }
309
+
310
+ /** New internal keys carry a timestamp; legacy keys retain their bounded v1 guarantee. */
311
+ export function validateOperationTime(key, now, retryWindowSeconds) {
312
+ if(!key.startsWith('op_'))return;
313
+ const match=/^op_([0-9]{13})_([A-Za-z0-9_-]{16,96})$/.exec(key);
314
+ if(!match)throw new ApiError(400,'invalid_request','headers.Idempotency-Key');
315
+ const started=Number(match[1]);
316
+ if(started>now+300000)throw new ApiError(400,'invalid_request','headers.Idempotency-Key');
317
+ if(now-started>=retryWindowSeconds*1000)throw new ApiError(410,'retry_window_elapsed','headers.Idempotency-Key');
318
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bruno Orsolon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,20 @@
1
+ # Shared enrollment helper
2
+
3
+ Unmodified `reference/enrollment.mjs` from `matuto-ai/psst` commit `3e908e79966d886a02d372c103e31dec6e900c1a`, independently compared byte-for-byte with the remote committed source. Upstream PR #18 is pending review/merge; this commit pin is not a deployment or release claim.
4
+
5
+ SHA-256: `afeab00d7c2d6768d3537bd7c350e249768897bf81eff9b4ae9796f7c3f9dbe9`.
6
+
7
+ Runtime hash checked by `src/enrollment.mjs` before import. MIT license copied alongside. The existing pinned broker transport/runtime remains unchanged at its separate origin.
8
+
9
+ Source: https://github.com/matuto-ai/psst/blob/3e908e79966d886a02d372c103e31dec6e900c1a/reference/enrollment.mjs
10
+ Operator guide at that commit: https://github.com/matuto-ai/psst/blob/3e908e79966d886a02d372c103e31dec6e900c1a/docs/pairing-operator.md
11
+
12
+ ## URL-only approval helper
13
+
14
+ `approval.mjs` is vendored byte-for-byte from `matuto-ai/psst` commit
15
+ `72f5d2d1b834e02cfb6b2f7dc11c6f1a55729344`, path `reference/approval.mjs`,
16
+ Git blob `8e6128c22835e66c0ab50aa15c27ff16d33e1169`, SHA-256
17
+ `a60c3a0ccd7deb50556586cd67841bb89a300ea3c7f1af3a91d437f856b7a2d5`.
18
+ This is the explicit broker PR #22 dependency, not a claim of a deployed release.
19
+ The connector checks the digest before importing it. The old enrollment helper
20
+ remains unchanged for legacy bundles/receipts.
@@ -0,0 +1,181 @@
1
+ // Single-file helper deliberately vendorable by native connectors. Never emits secrets.
2
+ import { randomBytes, randomUUID, createHash } from 'node:crypto';
3
+ import { openSync, closeSync, fstatSync, readFileSync, writeFileSync, fsyncSync, renameSync, unlinkSync, mkdirSync, lstatSync, realpathSync, readdirSync, constants } from 'node:fs';
4
+ import { resolve, dirname, join, isAbsolute } from 'node:path';
5
+ import https from 'node:https';
6
+ import { DatabaseSync } from 'node:sqlite';
7
+
8
+ const TYPES = ['pi','claude-code','opencode','antigravity-cli'];
9
+ const fail = (code, effect = 'not_applied', retryable=false, retryAfterMs=2000) => { const e = new Error(code); Object.assign(e,{code,effect,retryable,...(retryable?{retryAfterMs:Math.max(1000,Math.min(300000,Math.ceil(retryAfterMs)))}:{})}); throw e; };
10
+ const hash = value => createHash('sha256').update(value).digest('hex');
11
+ function privateDir(path) {
12
+ if (!isAbsolute(path)) fail('private_directory_required');
13
+ mkdirSync(path, {recursive:true, mode:0o700});
14
+ const s = lstatSync(path);
15
+ if (!s.isDirectory() || s.isSymbolicLink() || realpathSync(path) !== resolve(path) || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid())) fail('private_directory_required');
16
+ }
17
+ function privateRead(path) {
18
+ if (!isAbsolute(path) || realpathSync(dirname(path)) !== resolve(dirname(path))) fail('private_file_required');
19
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
20
+ try {
21
+ const s = fstatSync(fd);
22
+ if (!s.isFile() || s.size > 65536 || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid())) fail('private_file_required');
23
+ return readFileSync(fd,'utf8');
24
+ } finally { closeSync(fd); }
25
+ }
26
+ function save(path, text) {
27
+ // Reject unsafe existing destinations rather than silently repairing their mode.
28
+ try { privateRead(path); } catch(e) { if(e.code !== 'ENOENT') throw e; }
29
+ const temp = `${path}.${randomUUID()}.tmp`;
30
+ const fd = openSync(temp, constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,0o600);
31
+ try { writeFileSync(fd,text); fsyncSync(fd); } finally { closeSync(fd); }
32
+ renameSync(temp,path);
33
+ const d = openSync(dirname(path),constants.O_RDONLY);try { fsyncSync(d); } finally { closeSync(d); }
34
+ }
35
+ function origin(value) {
36
+ let u; try { u = new URL(value); } catch { fail('https_origin_required'); }
37
+ if (typeof value !== 'string' || /[\u0000-\u0020\u007f]/u.test(value) || u.protocol !== 'https:' || u.username || u.password || u.pathname !== '/' || u.search || u.hash) fail('https_origin_required');
38
+ return u.origin;
39
+ }
40
+ const stateNames=['pending','approved','denied','expired','revoked'];
41
+ const uuid=/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
42
+ function locations({brokerUrl,directory,clientType}) {
43
+ if(!TYPES.includes(clientType))fail('invalid_client_type');
44
+ if(!isAbsolute(directory))fail('private_directory_required');
45
+ const broker=origin(brokerUrl),root=join(directory,'approvals',hash(broker+'\0'+clientType));
46
+ return {broker,root,current:join(root,'current.json'),clientType};
47
+ }
48
+ function load(loc) {
49
+ let pointer;try{pointer=JSON.parse(privateRead(loc.current));}catch(e){if(e.code==='ENOENT')return null;throw e;}
50
+ if(!pointer||!uuid.test(pointer.requestId))fail('invalid_approval_state');
51
+ const dir=join(loc.root,pointer.requestId),state=JSON.parse(privateRead(join(dir,'attempt.json')));
52
+ if(state.requestId!==pointer.requestId||state.brokerUrl!==loc.broker||state.clientType!==loc.clientType
53
+ ||!/^[A-Za-z0-9_-]{43}$/.test(state.token)||!stateNames.includes(state.state)
54
+ ||typeof state.label!=='string'||state.label.length>80)fail('invalid_approval_state');
55
+ return {state,dir};
56
+ }
57
+ function visible({state,dir}) {
58
+ const result={requestId:state.requestId,approvalUrl:state.brokerUrl+'/admin',state:state.state,requestExpiresAt:state.requestExpiresAt??null};
59
+ if(state.state==='approved'){
60
+ if(!state.credential||Date.parse(state.credential.expires_at)<=Date.now())return {...result,state:'expired'};
61
+ result.connectionFile=join(dir,'connection.json');
62
+ Object.assign(result,readEnrollmentReceipt({connectionFile:result.connectionFile,clientType:state.clientType}));
63
+ }
64
+ return result;
65
+ }
66
+ function sanitized(e) {
67
+ if(e.effect&&/^[a-z_]+$/.test(e.code))throw e;
68
+ fail('approval_local_error','unknown');
69
+ }
70
+ export function readApprovalState(options) {
71
+ try{const item=load(locations(options));return item?visible(item):null;}catch(e){sanitized(e);}
72
+ }
73
+ function request(broker,method,path,token,input) {
74
+ return new Promise((resolvePromise,reject)=>{
75
+ const body=input===undefined?undefined:JSON.stringify(input);
76
+ const transportError=()=>Object.assign(new Error('approval_transport_error'),{code:'approval_transport_error',effect:'unknown',retryable:true,retryAfterMs:2000});
77
+ const req=https.request(new URL(path,broker),{method,headers:{Authorization:`Enrollment ${token}`,...(body?{'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}:{})}},res=>{
78
+ let size=0;const chunks=[];
79
+ res.on('data',chunk=>{size+=chunk.length;if(size>16384)res.destroy();else chunks.push(chunk);});
80
+ res.on('error',()=>reject(transportError()));
81
+ res.on('end',()=>{try{resolvePromise({status:res.statusCode,retryAfter:res.headers['retry-after'],body:JSON.parse(Buffer.concat(chunks).toString('utf8'))});}catch{reject(Object.assign(new Error('invalid_response'),{code:'invalid_response',effect:'unknown',retryable:false}));}});
82
+ });
83
+ const timer=setTimeout(()=>req.destroy(),10000);req.on('close',()=>clearTimeout(timer));
84
+ req.on('error',()=>reject(transportError()));req.end(body);
85
+ });
86
+ }
87
+ function publish(item,result,broker,clientType){
88
+ const {state,dir}=item;
89
+ if(!result||result.request_id!==state.requestId||!stateNames.includes(result.state)
90
+ ||!Number.isFinite(Date.parse(result.request_expires_at)))fail('invalid_response','unknown');
91
+ if(result.state==='approved'){
92
+ const c=result.credential;
93
+ if(!c||c.protocol_version!=='1'||c.client_type!==clientType||!/^enrolled-[0-9a-f-]{36}$/.test(c.credential_id)
94
+ ||!/^installation-[0-9a-f-]{36}$/.test(c.adapter_id)||!Array.isArray(c.scopes)||!c.scopes.length||c.scopes.length>32
95
+ ||c.scopes.some(s=>typeof s!=='string'||!/^[-a-zA-Z0-9_.]{1,128}$/.test(s))||!Number.isFinite(Date.parse(c.expires_at)))fail('invalid_response','unknown');
96
+ if(Date.parse(c.expires_at)<=Date.now())fail('credential_expired');
97
+ save(join(dir,'token'),state.token+'\n');
98
+ save(join(dir,'enrollment.json'),JSON.stringify({format:'psst-enrollment-receipt-v1',broker_url:broker,...c,token_sha256:hash(state.token)})+'\n');
99
+ save(join(dir,'connection.json'),JSON.stringify({format:'broker-client-bundle-v1',protocol_version:'1',broker_url:broker,token_file:'token',enrollment_receipt_file:'enrollment.json',file_paths_relative_to:'connection_file_directory',authorization_header:'Authorization',authorization_scheme:'Bearer',insecure_development:false,allowed_client_types:[clientType],scope:c.scopes[0]})+'\n');
100
+ state.credential=c;
101
+ }
102
+ state.state=result.state;state.requestExpiresAt=result.request_expires_at;
103
+ save(join(dir,'attempt.json'),JSON.stringify(state)+'\n');return visible(item);
104
+ }
105
+ async function operate(options,begin,{transport=request,pollIntervalMs=2000}={}) {
106
+ let lock;
107
+ try {
108
+ const loc=locations(options);
109
+ if(!begin&&!load(loc))fail('approval_setup_required');
110
+ privateDir(options.directory);privateDir(join(options.directory,'approvals'));privateDir(loc.root);
111
+ const lockPath=join(loc.root,'.owner.sqlite');
112
+ try{privateRead(lockPath);}catch(e){if(e.code!=='ENOENT')throw e;}
113
+ const fd=openSync(lockPath,constants.O_WRONLY|constants.O_CREAT|constants.O_NOFOLLOW,0o600);closeSync(fd);
114
+ try{lock=new DatabaseSync(lockPath);lock.exec('PRAGMA journal_mode=DELETE; PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE; CREATE TABLE IF NOT EXISTS owner(id INTEGER);');}
115
+ catch{fail('approval_busy','not_applied',true);}
116
+ let item=load(loc);
117
+ if(begin){
118
+ const replacement=options.replaceRequestId;
119
+ if(replacement!==undefined&&(!uuid.test(replacement)||!item))fail('selection_conflict');
120
+ if(replacement!==undefined&&item.state.requestId!==replacement&&item.state.parentRequestId!==replacement)fail('selection_conflict');
121
+ if(replacement!==undefined&&item.state.requestId===replacement&&item.state.state==='pending')fail('selection_conflict');
122
+ if(!item||(replacement!==undefined&&item.state.requestId===replacement)){
123
+ if(readdirSync(loc.root).length>=130)fail('approval_capacity');
124
+ const label=options.label??options.clientType;
125
+ if(typeof label!=='string'||label.length>80||/[\u0000-\u001f\u007f]/u.test(label))fail('invalid_request');
126
+ const state={requestId:randomUUID(),parentRequestId:replacement??null,brokerUrl:loc.broker,clientType:options.clientType,label,token:randomBytes(32).toString('base64url'),state:'pending'};
127
+ const dir=join(loc.root,state.requestId);privateDir(dir);
128
+ save(join(dir,'attempt.json'),JSON.stringify(state)+'\n');
129
+ save(loc.current,JSON.stringify({requestId:state.requestId})+'\n');item={state,dir};
130
+ }
131
+ }
132
+ if(!item)fail('approval_setup_required');
133
+ if(item.state.state!=='pending')return visible(item);
134
+ const s=item.state;
135
+ if(Number.isFinite(s.retryAt)&&s.retryAt>Date.now()&&s.retryAt-Date.now()<=300000)fail(s.retryCode??'rate_limited',s.retryEffect??'unknown',true,s.retryAt-Date.now());
136
+ if(!begin){
137
+ const now=Date.now();
138
+ if(Number.isFinite(s.lastPollAt)&&now>=s.lastPollAt&&now-s.lastPollAt<pollIntervalMs)return visible(item);
139
+ s.lastPollAt=now;save(join(item.dir,'attempt.json'),JSON.stringify(s)+'\n');
140
+ }
141
+ const path=begin?'/v1/enrollment-requests':`/v1/enrollment-requests/${s.requestId}`;
142
+ const response=await transport(loc.broker,begin?'POST':'GET',path,s.token,begin?{request_id:s.requestId,client_type:s.clientType,label:s.label}:undefined);
143
+ if(![200,201].includes(response.status)){
144
+ const code=response.body?.error?.code;
145
+ let retryAfterMs=response.status===429?60000:5000;
146
+ if(typeof response.retryAfter==='string'){
147
+ const seconds=/^[0-9]{1,6}$/.test(response.retryAfter)?Number(response.retryAfter):NaN;
148
+ const parsed=Number.isFinite(seconds)?seconds*1000:Date.parse(response.retryAfter)-Date.now();
149
+ if(Number.isFinite(parsed))retryAfterMs=Math.max(1000,Math.min(300000,Math.ceil(parsed)));
150
+ }
151
+ if([429,503].includes(response.status)){s.retryAt=Date.now()+retryAfterMs;s.retryCode=code==='not_ready'||code==='storage_unavailable'?code:'rate_limited';s.retryEffect=response.body?.error?.effect==='not_applied'?'not_applied':'unknown';save(join(item.dir,'attempt.json'),JSON.stringify(s)+'\n');}
152
+ fail(['unauthenticated','forbidden','invalid_request','idempotency_conflict','rate_limited','not_ready','storage_unavailable'].includes(code)?code:'invalid_response',response.body?.error?.effect==='not_applied'?'not_applied':'unknown',[429,503].includes(response.status),retryAfterMs);
153
+ }
154
+ delete s.retryAt;delete s.retryCode;delete s.retryEffect;
155
+ return publish(item,response.body,loc.broker,options.clientType);
156
+ }catch(e){sanitized(e);}finally{try{lock?.close();}catch{}}
157
+ }
158
+ export const beginApproval=(options,testOptions)=>operate(options,true,testOptions);
159
+ export const pollApproval=(options,testOptions)=>operate(options,false,testOptions);
160
+
161
+ /** Read only for a helper-enrolled bundle. Missing/corrupt receipt is not a legacy fallback. */
162
+ export function readEnrollmentReceipt({connectionFile,clientType}) {
163
+ try {
164
+ if(!TYPES.includes(clientType))fail('invalid_client_type');
165
+ const bundle=JSON.parse(privateRead(connectionFile));
166
+ const receipt=JSON.parse(privateRead(join(dirname(connectionFile),'enrollment.json')));
167
+ const token=privateRead(join(dirname(connectionFile),'token')).trim();
168
+ if(bundle?.format!=='broker-client-bundle-v1'||bundle.protocol_version!=='1'||bundle.token_file!=='token'||bundle.enrollment_receipt_file!=='enrollment.json'
169
+ ||bundle.authorization_scheme!=='Bearer'||bundle.authorization_header!=='Authorization'||bundle.insecure_development!==false
170
+ ||!Array.isArray(bundle.allowed_client_types)||bundle.allowed_client_types.length!==1||bundle.allowed_client_types[0]!==clientType
171
+ ||receipt?.format!=='psst-enrollment-receipt-v1'||receipt.protocol_version!=='1'||receipt.client_type!==clientType
172
+ ||receipt.broker_url!==origin(bundle.broker_url)||!/^[A-Za-z0-9_-]{43}$/.test(token)||receipt.token_sha256!==hash(token)
173
+ ||!/^enrolled-[0-9a-f-]{36}$/.test(receipt.credential_id)||!/^installation-[0-9a-f-]{36}$/.test(receipt.adapter_id)
174
+ ||!Array.isArray(receipt.scopes)||!receipt.scopes.length||receipt.scopes[0]!==bundle.scope||!Number.isFinite(Date.parse(receipt.expires_at)))fail('invalid_enrollment_receipt');
175
+ if(Date.parse(receipt.expires_at)<=Date.now())fail('credential_expired');
176
+ return {credentialId:receipt.credential_id,expiresAt:receipt.expires_at};
177
+ } catch(e) {
178
+ if(e.effect&&/^[a-z_]+$/.test(e.code))throw e;
179
+ fail('invalid_enrollment_receipt');
180
+ }
181
+ }
@@ -0,0 +1,137 @@
1
+ // Single-file helper deliberately vendorable by native connectors. Never emits secrets.
2
+ import { randomBytes, randomUUID, createHash } from 'node:crypto';
3
+ import { openSync, closeSync, fstatSync, readFileSync, writeFileSync, fsyncSync, renameSync, unlinkSync, mkdirSync, lstatSync, realpathSync, readdirSync, constants } from 'node:fs';
4
+ import { resolve, dirname, join, isAbsolute } from 'node:path';
5
+ import https from 'node:https';
6
+ import { DatabaseSync } from 'node:sqlite';
7
+
8
+ const TYPES = ['pi','claude-code','opencode','antigravity-cli'];
9
+ const fail = (code, effect = 'not_applied') => { const e = new Error(code); e.code = code; e.effect = effect; throw e; };
10
+ const hash = value => createHash('sha256').update(value).digest('hex');
11
+ function privateDir(path) {
12
+ if (!isAbsolute(path)) fail('private_directory_required');
13
+ mkdirSync(path, {recursive:true, mode:0o700});
14
+ const s = lstatSync(path);
15
+ if (!s.isDirectory() || s.isSymbolicLink() || realpathSync(path) !== resolve(path) || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid())) fail('private_directory_required');
16
+ }
17
+ function privateRead(path) {
18
+ if (!isAbsolute(path) || realpathSync(dirname(path)) !== resolve(dirname(path))) fail('private_file_required');
19
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
20
+ try {
21
+ const s = fstatSync(fd);
22
+ if (!s.isFile() || s.size > 65536 || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid())) fail('private_file_required');
23
+ return readFileSync(fd,'utf8');
24
+ } finally { closeSync(fd); }
25
+ }
26
+ function save(path, text) {
27
+ // Reject unsafe existing destinations rather than silently repairing their mode.
28
+ try { privateRead(path); } catch(e) { if(e.code !== 'ENOENT') throw e; }
29
+ const temp = `${path}.${randomUUID()}.tmp`;
30
+ const fd = openSync(temp, constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,0o600);
31
+ try { writeFileSync(fd,text); fsyncSync(fd); } finally { closeSync(fd); }
32
+ renameSync(temp,path);
33
+ const d = openSync(dirname(path),constants.O_RDONLY);try { fsyncSync(d); } finally { closeSync(d); }
34
+ }
35
+ function origin(value) {
36
+ let u; try { u = new URL(value); } catch { fail('https_origin_required'); }
37
+ if (typeof value !== 'string' || /[\u0000-\u0020\u007f]/u.test(value) || u.protocol !== 'https:' || u.username || u.password || u.pathname !== '/' || u.search || u.hash) fail('https_origin_required');
38
+ return u.origin;
39
+ }
40
+ function post(url, secret, body) {
41
+ return new Promise((resolvePromise,reject) => {
42
+ const request = https.request(new URL('/v1/enrollments',url), {method:'POST',headers:{Authorization:`Pairing ${secret}`,'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}, timeout:10000}, response => {
43
+ let size=0;const chunks=[];
44
+ response.on('data',chunk=>{size+=chunk.length;if(size>16384)response.destroy(new Error('invalid_response'));else chunks.push(chunk);});
45
+ response.on('error',()=>reject(Object.assign(new Error('enrollment_transport_error'),{code:'enrollment_transport_error',effect:'unknown'})));
46
+ response.on('end',()=>{
47
+ try { resolvePromise({status:response.statusCode,body:JSON.parse(Buffer.concat(chunks).toString('utf8'))}); }
48
+ catch { reject(Object.assign(new Error('invalid_response'),{code:'invalid_response',effect:'unknown'})); }
49
+ });
50
+ });
51
+ const deadline=setTimeout(()=>request.destroy(),10000);
52
+ request.on('close',()=>clearTimeout(deadline));
53
+ request.on('timeout',()=>request.destroy());
54
+ request.on('error',()=>reject(Object.assign(new Error('enrollment_transport_error'),{code:'enrollment_transport_error',effect:'unknown'})));
55
+ request.end(body);
56
+ });
57
+ }
58
+ /** Explicit connect only. The optional transport is a test seam, never a user configuration field. */
59
+ export async function connectPairingFile({pairingFile,directory,clientType,brokerUrl}, {transport = post} = {}) {
60
+ let lock, lockPath;
61
+ try {
62
+ if (!TYPES.includes(clientType)) fail('invalid_client_type');
63
+ const pair = JSON.parse(privateRead(pairingFile));
64
+ if (!pair || pair.format !== 'psst-pairing-v1' || pair.client_type !== clientType || typeof pair.pairing_secret !== 'string'
65
+ || !/^[A-Za-z0-9_-]{43}$/.test(pair.pairing_secret) || !Number.isFinite(Date.parse(pair.expires_at))) fail('invalid_pairing_file');
66
+ const broker = origin(pair.broker_url);
67
+ if (brokerUrl !== undefined && origin(brokerUrl) !== broker) fail('broker_origin_mismatch');
68
+ privateDir(directory);
69
+ lockPath = join(directory,'.enrollment-owner.sqlite');
70
+ // Kernel-held SQLite lock survives no process crash; no stale PID or lock deletion.
71
+ try { privateRead(lockPath); } catch(e) { if(e.code!=='ENOENT')throw e; }
72
+ const ownerFd=openSync(lockPath,constants.O_WRONLY|constants.O_CREAT|constants.O_NOFOLLOW,0o600);closeSync(ownerFd);
73
+ try {
74
+ lock = new DatabaseSync(lockPath);
75
+ lock.exec('PRAGMA journal_mode=DELETE; PRAGMA locking_mode=EXCLUSIVE; BEGIN EXCLUSIVE; CREATE TABLE IF NOT EXISTS owner (id INTEGER);');
76
+ } catch { try{lock?.close();}catch{}lock=undefined;fail('enrollment_busy'); }
77
+ // Per-grant attempts preserve old credentials for uncertain recovery and explicit replacement.
78
+ const attempts = join(directory,'enrollments');privateDir(attempts);
79
+ const key = hash(pair.pairing_secret), attemptDir = join(attempts,key);
80
+ if (!readdirSync(attempts).includes(key) && readdirSync(attempts).length >= 64) fail('enrollment_capacity');
81
+ privateDir(attemptDir);
82
+ const stateFile = join(attemptDir,'attempt.json');
83
+ let state;
84
+ try { state = JSON.parse(privateRead(stateFile)); }
85
+ catch(e) {
86
+ if(e.code !== 'ENOENT')throw e;
87
+ state = {broker_url:broker,client_type:clientType,enrollment_id:randomUUID(),token:randomBytes(32).toString('base64url')};
88
+ save(stateFile,JSON.stringify(state)+'\n'); // fsync before any issuance can happen
89
+ }
90
+ if (state.broker_url !== broker || state.client_type !== clientType || !/^[A-Za-z0-9_-]{43}$/.test(state.token)
91
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(state.enrollment_id)) fail('invalid_enrollment_state');
92
+ const response = await transport(broker,pair.pairing_secret,JSON.stringify({enrollment_id:state.enrollment_id,client_type:clientType,token_sha256:hash(state.token)}));
93
+ if (![200,201].includes(response.status)) {
94
+ const code = response.body?.error?.code;
95
+ fail(['unauthenticated','forbidden','invalid_request','idempotency_conflict','rate_limited','not_ready','storage_unavailable'].includes(code)?code:'invalid_response',response.body?.error?.effect==='not_applied'?'not_applied':'unknown');
96
+ }
97
+ const result = response.body;
98
+ if (!result || result.protocol_version !== '1' || result.client_type !== clientType || !/^enrolled-[0-9a-f-]{36}$/.test(result.credential_id)
99
+ || !/^installation-[0-9a-f-]{36}$/.test(result.adapter_id) || !Array.isArray(result.scopes) || !result.scopes.length || result.scopes.length>32
100
+ || result.scopes.some(s=>typeof s!=='string'||!/^[-a-zA-Z0-9_.]{1,128}$/.test(s)) || !Number.isFinite(Date.parse(result.expires_at))) fail('invalid_response','unknown');
101
+ if(Date.parse(result.expires_at)<=Date.now())fail('credential_expired','unknown');
102
+ save(join(attemptDir,'token'),state.token+'\n');
103
+ const bundle = {format:'broker-client-bundle-v1',protocol_version:'1',broker_url:broker,token_file:'token',enrollment_receipt_file:'enrollment.json',file_paths_relative_to:'connection_file_directory',authorization_header:'Authorization',authorization_scheme:'Bearer',insecure_development:false,allowed_client_types:[clientType],scope:result.scopes[0]};
104
+ const connectionFile = join(attemptDir,'connection.json');
105
+ // Publish receipt before bundle: the returned bundle always has its token-bound receipt.
106
+ save(join(attemptDir,'enrollment.json'),JSON.stringify({format:'psst-enrollment-receipt-v1',broker_url:broker,...result,token_sha256:hash(state.token)})+'\n');
107
+ save(connectionFile,JSON.stringify(bundle,null,2)+'\n');
108
+ return {connectionFile,credentialId:result.credential_id,expiresAt:result.expires_at};
109
+ } catch(e) {
110
+ if (e.effect && /^[a-z_]+$/.test(e.code)) throw e;
111
+ fail('enrollment_local_error','unknown');
112
+ } finally {
113
+ if (lock !== undefined) { try { lock.close(); } catch { /* Never replace a sanitized result with raw filesystem details. */ } }
114
+ }
115
+ }
116
+
117
+ /** Read only for a helper-enrolled bundle. Missing/corrupt receipt is not a legacy fallback. */
118
+ export function readEnrollmentReceipt({connectionFile,clientType}) {
119
+ try {
120
+ if(!TYPES.includes(clientType))fail('invalid_client_type');
121
+ const bundle=JSON.parse(privateRead(connectionFile));
122
+ const receipt=JSON.parse(privateRead(join(dirname(connectionFile),'enrollment.json')));
123
+ const token=privateRead(join(dirname(connectionFile),'token')).trim();
124
+ if(bundle?.format!=='broker-client-bundle-v1'||bundle.protocol_version!=='1'||bundle.token_file!=='token'||bundle.enrollment_receipt_file!=='enrollment.json'
125
+ ||bundle.authorization_scheme!=='Bearer'||bundle.authorization_header!=='Authorization'||bundle.insecure_development!==false
126
+ ||!Array.isArray(bundle.allowed_client_types)||bundle.allowed_client_types.length!==1||bundle.allowed_client_types[0]!==clientType
127
+ ||receipt?.format!=='psst-enrollment-receipt-v1'||receipt.protocol_version!=='1'||receipt.client_type!==clientType
128
+ ||receipt.broker_url!==origin(bundle.broker_url)||!/^[A-Za-z0-9_-]{43}$/.test(token)||receipt.token_sha256!==hash(token)
129
+ ||!/^enrolled-[0-9a-f-]{36}$/.test(receipt.credential_id)||!/^installation-[0-9a-f-]{36}$/.test(receipt.adapter_id)
130
+ ||!Array.isArray(receipt.scopes)||!receipt.scopes.length||receipt.scopes[0]!==bundle.scope||!Number.isFinite(Date.parse(receipt.expires_at)))fail('invalid_enrollment_receipt');
131
+ if(Date.parse(receipt.expires_at)<=Date.now())fail('credential_expired');
132
+ return {credentialId:receipt.credential_id,expiresAt:receipt.expires_at};
133
+ } catch(e) {
134
+ if(e.effect&&/^[a-z_]+$/.test(e.code))throw e;
135
+ fail('invalid_enrollment_receipt');
136
+ }
137
+ }