@matuto-ai/claude-code-broker-connector 0.4.0 → 0.6.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -10
- package/.mcp.json +1 -2
- package/README.md +84 -46
- package/bin/cli.mjs +9 -5
- package/bin/plugin-hook.mjs +2 -1
- package/bin/plugin-mcp.mjs +101 -25
- package/bin/plugin-monitor.mjs +2 -2
- package/docs/automatic-delivery.md +14 -3
- package/docs/candidate-verification.md +25 -0
- package/docs/installation-selection.md +52 -35
- package/docs/integration.md +15 -2
- package/docs/message-lifecycle.md +24 -0
- package/docs/publishing.md +2 -2
- package/docs/verification.md +30 -0
- package/package.json +1 -1
- package/scripts/audit-native.mjs +6 -5
- package/skills/connect/SKILL.md +34 -2
- package/skills/psst/SKILL.md +4 -4
- package/src/approval-attachment.mjs +31 -0
- package/src/approval-session.mjs +58 -0
- package/src/approval-support.mjs +6 -0
- package/src/cooperative-inbox.mjs +11 -7
- package/src/cooperative-stdio.mjs +16 -8
- package/src/installation-selection.mjs +11 -6
- package/src/message-format.mjs +26 -0
- package/src/plugin-state.mjs +22 -3
- package/src/preflight.mjs +83 -0
- package/src/reference-pin.mjs +6 -6
- package/src/setup-context.mjs +24 -0
- package/src/tool-scope.mjs +26 -0
- package/vendor/approval.mjs +181 -0
- package/vendor/psst/NOTICE.md +10 -9
- package/vendor/psst/reference/client.mjs +90 -15
- package/vendor/psst/reference/inbox.mjs +55 -10
- package/vendor/psst/reference/mcp-stdio.mjs +5 -1
- package/vendor/psst/reference/tools.mjs +38 -14
- package/vendor/psst/src/protocol.mjs +19 -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,
|
|
238
|
-
|
|
239
|
-
|
|
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(
|
|
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
|
|
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;
|
|
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'
|
|
12
|
-
let 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,
|
|
29
|
-
return await client.sendPrepared(key);
|
|
30
|
-
} catch (error) { return
|
|
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
|
+
}
|