@weaccelerateinc/agent-sdk 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
@@ -1,11 +1,13 @@
1
- # @weaccelerateinc/agent-sdk 0.11.0
1
+ # @weaccelerateinc/agent-sdk 0.12.0
2
+
3
+ SDK 0.12.0 adds opt-in Browser Use run-created fill-only checkout, exact additional-extension policies, and private WhatsApp adapters with durable sent-receipt reconciliation. The platform must implement durable owner/session/viewer fences and qualify its provider. Existing connector defaults and the 0.11.0 customer-final-click purchase contract remain. The approved extension ZIP is unchanged; its compatibility metadata includes 0.12.0.
2
4
 
3
5
  Version 0.11.0 is a breaking release: **Accelerate never sends the final payment click.** A purchase arms one short click, the customer's agent performs it once through its own browser connection (`BrowserConnectorHost.performFinalClick`) in the customer's existing browser, then Accelerate observes the outcome. Purchase requires `final_click_actor:'customer'` and that host; the managed Kernel browser is fill-only. `client.checkouts.submit` rejects locally with `customer_click_required`. See [customer-owned final click](../../docs/customer-final-click.md). Release 0.10.3 keeps its existing behavior. Merchant layouts remain limited to the qualified sandbox page; this SDK release does not by itself qualify any merchant layout, a service deployment or production payments.
4
6
 
5
- Server-only ESM SDK with TypeScript declarations, Node 22+, zero runtime dependencies. New partners use their existing browser and explicitly select `managed_review_v2`, which continues an armed fill immediately after private CVV verification. Existing callers keep `managed_review_v1` unless they opt in. Public npm releases use `@weaccelerateinc/agent-sdk`; install an exact available version with `npm install @weaccelerateinc/agent-sdk@0.11.0`. Check `npm view @weaccelerateinc/agent-sdk@0.11.0 version` to confirm registry availability. The package remains `UNLICENSED`.
7
+ Server-only ESM SDK with TypeScript declarations, Node 22+, zero runtime dependencies. New partners use their existing browser and explicitly select `managed_review_v2`, which continues an armed fill immediately after private CVV verification. Existing callers keep `managed_review_v1` unless they opt in. Public npm releases use `@weaccelerateinc/agent-sdk`; install an exact available version with `npm install @weaccelerateinc/agent-sdk@0.12.0`. Check `npm view @weaccelerateinc/agent-sdk@0.12.0 version` to confirm registry availability. The package remains `UNLICENSED`.
6
8
 
7
9
  ```sh
8
- npm install --ignore-scripts @weaccelerateinc/agent-sdk@0.11.0
10
+ npm install --ignore-scripts @weaccelerateinc/agent-sdk@0.12.0
9
11
  npx --no-install accelerate-agent init ./partner-demo --browser existing
10
12
  ```
11
13
 
@@ -27,7 +27,8 @@
27
27
  "0.10.2",
28
28
  "0.10.3",
29
29
  "0.10.4",
30
- "0.11.0"
30
+ "0.11.0",
31
+ "0.12.0"
31
32
  ],
32
33
  "executor_contract": "accelerate_manual_checkout_v1"
33
34
  },
@@ -26,7 +26,8 @@
26
26
  "0.10.2",
27
27
  "0.10.3",
28
28
  "0.10.4",
29
- "0.11.0"
29
+ "0.11.0",
30
+ "0.12.0"
30
31
  ],
31
32
  "executor_contract": "accelerate_manual_checkout_v1"
32
33
  },
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
 
3
- const SDK_VERSION = '0.11.0';
3
+ const SDK_VERSION = '0.12.0';
4
4
  const EXECUTOR_CONTRACT = 'accelerate_manual_checkout_v1';
5
5
  const BRIDGE_PROTOCOL = 'accelerate_extension_bridge_v1';
6
6
  const LEGACY_SHA256 = '65574cbb27ab344687963f72a136f2653551b57458b284e486946b27699099c8';
@@ -1,4 +1,5 @@
1
1
  import { AccelerateError } from './index.js';
2
+ import { browserUseRunProvenance, sameBrowserUseProvenance } from './browser-provenance.js';
2
3
  import { normalizeBrowserOperation } from './browser-operations.js';
3
4
  import { validateApprovedExtensionRelease, verifyApprovedExtensionRuntime } from './approved-extension.js';
4
5
 
@@ -40,11 +41,14 @@ function options(value = {}) {
40
41
  return Object.freeze(value.signal ? { signal: value.signal } : {});
41
42
  }
42
43
  function browser(value, provider, connectorId) {
43
- shape(value, ['provider', 'browser_id', 'custody_mode'], provider === 'connector' ? ['connector_id'] : [], 'permission_denied');
44
+ shape(value, ['provider', 'browser_id', 'custody_mode'], provider === 'connector' ? ['connector_id'] : ['provenance'], 'permission_denied');
44
45
  check(value.provider === provider && value.custody_mode === 'customer_backend_trusted' &&
45
46
  typeof value.browser_id === 'string' && (provider === 'browser_use' ? UUID : OPAQUE).test(value.browser_id) &&
46
47
  (provider !== 'connector' || value.connector_id === connectorId), 'permission_denied');
47
- return { ...value };
48
+ let provenance;
49
+ try { if (value.provenance !== undefined) provenance = browserUseRunProvenance(value.provenance); }
50
+ catch { throw new AccelerateError('permission_denied'); }
51
+ return { ...value, ...(provenance ? { provenance } : {}) };
48
52
  }
49
53
  function record(value, context, config, { fenced = false, readiness = false, checkoutUrl } = {}) {
50
54
  shape(value, DOC, [...(fenced ? ['fence_id'] : []), ...(readiness ? ['recording_disabled', 'extension', 'automation'] : [])], 'permission_denied');
@@ -66,6 +70,7 @@ function record(value, context, config, { fenced = false, readiness = false, che
66
70
  function sameDocument(actual, expected) {
67
71
  check(actual.document_id === expected.document_id && actual.checkout_url === expected.checkout_url &&
68
72
  ['provider', 'browser_id', 'connector_id', 'custody_mode'].every(key => actual.browser[key] === expected.browser[key]) &&
73
+ sameBrowserUseProvenance(actual.browser.provenance, expected.browser.provenance) &&
69
74
  actual.expires_at === expected.expires_at, 'permission_denied');
70
75
  }
71
76
  function makeConnector(configuration, provider) {
@@ -0,0 +1,17 @@
1
+ const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/u;
2
+ const fields = ['type', 'run_id', 'session_id', 'provider_workspace_id', 'binding_sha256'];
3
+ /** Private backend binding. Provider workspace membership is not shopper ownership. */
4
+ export function browserUseRunProvenance(value) {
5
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
6
+ ![Object.prototype, null].includes(Object.getPrototypeOf(value)) || Object.keys(value).length !== fields.length ||
7
+ !fields.every(key => Object.hasOwn(value, key)) || value.type !== 'browser_use_run_v1' ||
8
+ !uuid.test(value.run_id) || !uuid.test(value.session_id) ||
9
+ !(value.provider_workspace_id === null || uuid.test(value.provider_workspace_id)) ||
10
+ typeof value.binding_sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(value.binding_sha256)) throw Error('Invalid browser provenance.');
11
+ return Object.freeze(Object.fromEntries(fields.map(key => [key, value[key]])));
12
+ }
13
+ export function sameBrowserUseProvenance(a, b) {
14
+ if (a === undefined || b === undefined) return a === b;
15
+ try { return JSON.stringify(browserUseRunProvenance(a)) === JSON.stringify(browserUseRunProvenance(b)); }
16
+ catch { return false; }
17
+ }
@@ -10,6 +10,9 @@ export interface BrowserUseDocumentObservation {
10
10
  checkout_url: string;
11
11
  extension: Omit<ApprovedExtensionRuntime, 'artifact_sha256'>;
12
12
  automation: BrowserReadiness['automation'];
13
+ /** Required after a run-created browser is fenced. Derived from the durable session store,
14
+ * including queued/follow-up work, live-view issuance and human takeover routes. */
15
+ run_control?: { checkout_reserved: true; session_scheduling_fenced: true; live_view_blocked: true };
13
16
  }
14
17
  export interface BrowserUsePlatformHost {
15
18
  /** Resolve an EXISTING browser from authenticated durable ownership. SDK reads actual provider expiry. */
@@ -38,8 +41,16 @@ export function createBrowserUseHost(options: {
38
41
  apiKey: string;
39
42
  merchantId: string;
40
43
  extensionId: string;
44
+ /** Primary runtime ID pinned in durable operator installation configuration.
45
+ * Required for run-created or multi-extension mode; stable across host restarts. */
46
+ extensionRuntimeId?: string;
41
47
  extensionRelease: ApprovedExtensionRelease;
42
48
  host: BrowserUsePlatformHost;
49
+ /** Requires a durable owned run reservation and a fence covering all session runs and queued work.
50
+ * The executor must separately enable this merchant. Default false. */
51
+ allowRunCreatedBrowsers?: boolean;
52
+ /** Complete additional extension set approved for this merchant/provider. This is not qualification evidence. */
53
+ additionalExtensions?: ReadonlyArray<Readonly<{ id: string; runtimeId: string; version: string }>>;
43
54
  fetchImpl?: typeof fetch;
44
55
  /** Test clock only. */
45
56
  now?: () => number;
@@ -1,4 +1,5 @@
1
1
  import { AccelerateError } from './index.js';
2
+ import { browserUseRunProvenance } from './browser-provenance.js';
2
3
  import { validateApprovedExtensionRelease, verifyApprovedExtensionRuntime } from './approved-extension.js';
3
4
  import { normalizeBrowserOperation } from './browser-operations.js';
4
5
 
@@ -44,12 +45,23 @@ const freeze = value => {
44
45
  };
45
46
 
46
47
  export function createBrowserUseHost(configuration = {}) {
47
- exact(configuration, ['apiKey', 'merchantId', 'extensionId', 'extensionRelease', 'host'], ['fetchImpl', 'now']);
48
- const { apiKey, merchantId, extensionId, host, fetchImpl = fetch, now = Date.now } = configuration;
48
+ exact(configuration, ['apiKey', 'merchantId', 'extensionId', 'extensionRelease', 'host'], ['fetchImpl', 'now', 'allowRunCreatedBrowsers', 'additionalExtensions', 'extensionRuntimeId']);
49
+ const { apiKey, merchantId, extensionId, host, fetchImpl = fetch, now = Date.now,
50
+ allowRunCreatedBrowsers = false, additionalExtensions = [], extensionRuntimeId } = configuration;
49
51
  check(typeof apiKey === 'string' && apiKey.length >= 16 && apiKey.length <= 500 && !/[\u0000-\u0020\u007f]/u.test(apiKey) &&
50
52
  UUID.test(merchantId) && UUID.test(extensionId) && typeof fetchImpl === 'function' && typeof now === 'function' && host &&
51
53
  ['resolveOwnedBrowser', 'drainAndFence', 'bindCheckout', 'resolveCheckoutBrowser', 'inspectDocument', 'confirmLocalCleanup']
52
54
  .every(key => typeof host[key] === 'function'), 'configuration_invalid');
55
+ check(typeof allowRunCreatedBrowsers === 'boolean' && Array.isArray(additionalExtensions) && additionalExtensions.length <= 2, 'configuration_invalid');
56
+ check(extensionRuntimeId === undefined || RUNTIME.test(extensionRuntimeId), 'configuration_invalid');
57
+ check(!(allowRunCreatedBrowsers || additionalExtensions.length) || RUNTIME.test(extensionRuntimeId ?? ''), 'configuration_invalid');
58
+ const extensionIds = new Set([extensionId]), runtimes = new Set();
59
+ const extras = additionalExtensions.map(value => {
60
+ exact(value, ['id', 'runtimeId', 'version']);
61
+ check(UUID.test(value.id) && RUNTIME.test(value.runtimeId) && typeof value.version === 'string' &&
62
+ /^\d+(?:\.\d+){0,3}$/u.test(value.version) && !extensionIds.has(value.id) && !runtimes.has(value.runtimeId), 'configuration_invalid');
63
+ extensionIds.add(value.id); runtimes.add(value.runtimeId); return Object.freeze({ ...value });
64
+ });
53
65
  let release;
54
66
  try { release = validateApprovedExtensionRelease(configuration.extensionRelease); }
55
67
  catch { throw new AccelerateError('configuration_invalid'); }
@@ -58,21 +70,25 @@ export function createBrowserUseHost(configuration = {}) {
58
70
  try { return await host[method](...args); }
59
71
  catch { throw new AccelerateError('integration_unavailable'); }
60
72
  };
61
- const owned = (context, document) => {
73
+ const owned = (context, document, cleanup = false) => {
62
74
  exact(document, ['owner', 'browser', 'document_id', 'checkout_url'], ['expires_at', 'fence_id']);
63
75
  exact(document.owner, ['customer_id', 'workspace_id', 'conversation_id', 'phone_number']);
64
76
  check(['customer_id', 'workspace_id', 'conversation_id', 'phone_number'].every(key => document.owner[key] === context[key]), 'permission_denied');
65
- exact(document.browser, ['provider', 'browser_id', 'custody_mode']);
77
+ exact(document.browser, ['provider', 'browser_id', 'custody_mode'], ['provenance']);
66
78
  check(document.browser.provider === 'browser_use' && document.browser.custody_mode === 'customer_backend_trusted' && UUID.test(document.browser.browser_id) &&
67
79
  typeof document.document_id === 'string' && /^[A-Za-z0-9_.:-]{1,200}$/u.test(document.document_id), 'permission_denied');
68
80
  try { normalizeBrowserOperation('navigate', { url: document.checkout_url }); }
69
81
  catch { throw new AccelerateError('permission_denied'); }
70
- return { owner: { ...document.owner }, browser: { ...document.browser }, document_id: document.document_id, checkout_url: document.checkout_url };
82
+ let provenance;
83
+ if (Object.hasOwn(document.browser, 'provenance')) {
84
+ check(allowRunCreatedBrowsers || cleanup && typeof document.fence_id === 'string' && document.fence_id.length > 0, 'permission_denied');
85
+ try { provenance = browserUseRunProvenance(document.browser.provenance); }
86
+ catch { throw new AccelerateError('permission_denied'); }
87
+ }
88
+ return { owner: { ...document.owner }, browser: { ...document.browser, ...(provenance ? { provenance } : {}) }, document_id: document.document_id, checkout_url: document.checkout_url };
71
89
  };
72
- const resource = async (context, document, requiredStatus, options = {}) => {
73
- const binding = owned(context, document).browser;
74
- const timeout = AbortSignal.timeout(10000), signal = AbortSignal.any([timeout, ...(options.signal ? [options.signal] : [])]);
75
- const endpoint = `${BASE}/browsers/${binding.browser_id}`;
90
+ const providerRead = async (path, signal) => {
91
+ const endpoint = `${BASE}${path}`;
76
92
  let response;
77
93
  try {
78
94
  check(!signal.aborted);
@@ -85,26 +101,63 @@ export function createBrowserUseHost(configuration = {}) {
85
101
  /^application\/json(?:\s*;|$)/iu.test(response.headers.get('content-type') ?? '') && response.body);
86
102
  const length = response.headers.get('content-length');
87
103
  check(length === null || /^\d+$/u.test(length) && Number(length) <= 262144);
88
- const value = await json(response, signal);
104
+ return await json(response, signal);
105
+ } finally { void response?.body?.cancel().catch(() => {}); }
106
+ };
107
+ const resource = async (context, document, requiredStatus, options = {}) => {
108
+ const binding = owned(context, document, requiredStatus === 'stopped').browser;
109
+ const timeout = AbortSignal.timeout(10000), signal = AbortSignal.any([timeout, ...(options.signal ? [options.signal] : [])]);
110
+ try {
111
+ const value = await providerRead(`/browsers/${binding.browser_id}`, signal);
89
112
  const metadata = value?.metadata;
113
+ let runEvidence;
114
+ if (binding.provenance) {
115
+ const provenance = binding.provenance;
116
+ const run = await providerRead(`/runs/${provenance.run_id}`, signal);
117
+ check(object(run) && run.id === provenance.run_id && run.sessionId === provenance.session_id &&
118
+ run.workspaceId === provenance.provider_workspace_id && value.agentSessionId === provenance.session_id);
119
+ // A breached scheduling fence must block payment, but must not prevent
120
+ // confirmation that this exact original owned browser has stopped.
121
+ if (requiredStatus !== 'stopped') {
122
+ const session = await providerRead(`/sessions/${provenance.session_id}`, signal);
123
+ const queue = await providerRead(`/sessions/${provenance.session_id}/queue`, signal);
124
+ check(['completed', 'cancelled'].includes(run.status) &&
125
+ object(session) && session.sessionId === provenance.session_id && session.latestRunId === provenance.run_id &&
126
+ session.workspaceId === provenance.provider_workspace_id && ['completed', 'cancelled'].includes(session.status) &&
127
+ object(queue) && Array.isArray(queue.queue) && queue.queue.length === 0 && value.agentSessionId === provenance.session_id);
128
+ runEvidence = { agentSessionId: value.agentSessionId,
129
+ agentRun: { id: run.id, sessionId: run.sessionId, workspaceId: run.workspaceId, status: run.status },
130
+ agentSession: { sessionId: session.sessionId, workspaceId: session.workspaceId, latestRunId: session.latestRunId, status: session.status },
131
+ agentQueue: { queue: [] } };
132
+ }
133
+ }
90
134
  check(object(value) && value.id === binding.browser_id && value.status === requiredStatus &&
91
135
  value.recordingAvailable === false && (value.recordingUrl == null || value.recordingUrl === '') &&
92
- object(metadata) && metadata.accelerate_merchant_id === merchantId &&
136
+ (binding.provenance || object(metadata) && metadata.accelerate_merchant_id === merchantId &&
93
137
  metadata.accelerate_owner_user_id === context.customer_id && metadata.accelerate_workspace_id === context.workspace_id &&
94
138
  metadata.accelerate_custody_mode === 'customer_backend_trusted' && metadata.accelerate_agent_extension_id === extensionId &&
95
- metadata.accelerate_extension_sha256 === release.artifact.sha256 && metadata.accelerate_extension_version === release.extension_version &&
139
+ metadata.accelerate_extension_sha256 === release.artifact.sha256 && metadata.accelerate_extension_version === release.extension_version) &&
96
140
  typeof value.timeoutAt === 'string' && Number.isFinite(Date.parse(value.timeoutAt)) &&
97
141
  (requiredStatus === 'stopped' || Date.parse(value.timeoutAt) > now()) &&
98
- Array.isArray(value.extensions) && value.extensions.length === 1 && value.extensions[0]?.id === extensionId &&
99
- value.extensions[0]?.version === release.extension_version && RUNTIME.test(value.extensions[0]?.runtimeId ?? ''));
142
+ Array.isArray(value.extensions) && value.extensions.length === extras.length + 1 &&
143
+ new Set(value.extensions.map(item => item?.id)).size === value.extensions.length &&
144
+ new Set(value.extensions.map(item => item?.runtimeId)).size === value.extensions.length);
145
+ const primary = value.extensions.find(item => item?.id === extensionId);
146
+ check(primary?.version === release.extension_version && RUNTIME.test(primary?.runtimeId ?? '') &&
147
+ extras.every(expected => value.extensions.some(item => ['id', 'version', 'runtimeId'].every(key => item?.[key] === expected[key]))));
148
+ check(extensionRuntimeId === undefined || primary.runtimeId === extensionRuntimeId);
100
149
  // Deliberately project away provider CDP/viewer URLs and unknown response fields.
101
- const read = freeze({ expires_at: value.timeoutAt, runtime_id: value.extensions[0].runtimeId });
150
+ const read = freeze({ expires_at: value.timeoutAt, runtime_id: primary.runtimeId });
102
151
  if (!options.withConnection) return read;
103
152
  check(typeof value.cdpUrl === 'string' && value.cdpUrl.length <= 2048 && typeof value.startedAt === 'string');
104
153
  return freeze({ ...read, connection: freeze({ id: value.id, status: value.status, cdpUrl: value.cdpUrl, startedAt: value.startedAt,
105
- timeoutAt: value.timeoutAt, recordingAvailable: false, metadata, extensions: value.extensions }) });
154
+ timeoutAt: value.timeoutAt, recordingAvailable: false,
155
+ metadata: binding.provenance ? {} : Object.fromEntries(['accelerate_merchant_id', 'accelerate_owner_user_id',
156
+ 'accelerate_workspace_id', 'accelerate_custody_mode', 'accelerate_agent_extension_id',
157
+ 'accelerate_extension_sha256', 'accelerate_extension_version'].map(key => [key, metadata[key]])),
158
+ extensions: value.extensions.map(item => ({ id: item.id, runtimeId: item.runtimeId, version: item.version })),
159
+ ...runEvidence }) });
106
160
  } catch {
107
- void response?.body?.cancel().catch(() => {});
108
161
  throw new AccelerateError(options.signal?.aborted ? 'request_cancelled' : 'integration_unavailable');
109
162
  }
110
163
  };
@@ -121,7 +174,11 @@ export function createBrowserUseHost(configuration = {}) {
121
174
  async inspectBrowser(context, document, options) {
122
175
  const observed = await resource(context, document, 'active', options);
123
176
  const current = await callHost('inspectDocument', context, document, freeze({ runtime_id: observed.runtime_id }), options);
124
- exact(current, ['document_id', 'checkout_url', 'extension', 'automation']);
177
+ exact(current, ['document_id', 'checkout_url', 'extension', 'automation'], ['run_control']);
178
+ if (document.browser.provenance && current.automation?.agent_access_fenced === true) {
179
+ exact(current.run_control, ['checkout_reserved', 'session_scheduling_fenced', 'live_view_blocked']);
180
+ check(Object.values(current.run_control).every(value => value === true));
181
+ }
125
182
  exact(current.extension, ['extension_version', 'success', 'initialized', 'wasmInitialized', 'autosaveDisabled',
126
183
  'boundedRetentionSupported', 'maximumRetentionMs'], ['bridge_protocol']);
127
184
  const extension = { ...current.extension, artifact_sha256: release.artifact.sha256 };
package/host-adapter.d.ts CHANGED
@@ -70,6 +70,11 @@ export interface DurableCheckoutHost<Extra = unknown> {
70
70
  * without uploading/sending the same review again. */
71
71
  withPrivateDelivery(context: ManagedCheckoutContext, idempotencyKey: string,
72
72
  deliver: () => Promise<{ message_id: string; sent_at: string }>): Promise<Readonly<{ message_id: string; sent_at: string }>>;
73
+ /** Recover only an existing pending delivery under its owner lock. lookup must
74
+ * be a trusted read-only journal function binding exact context, destination,
75
+ * and prompt idempotency key; it must never upload or send. */
76
+ reconcilePrivateDelivery(context: ManagedCheckoutContext, idempotencyKey: string,
77
+ lookup: (context: Readonly<ManagedCheckoutContext>, idempotencyKey: string) => Promise<{ message_id: string; sent_at: string }>): Promise<Readonly<{ message_id: string; sent_at: string }>>;
73
78
  /** Call ONLY after connector independently verifies stopped/cleared and retained fence. */
74
79
  confirmBrowserCleanup(context: ManagedCheckoutContext, checkoutId: CheckoutId): Promise<void>;
75
80
  withPrivateCheckout<T>(context: ManagedCheckoutContext, metadata: PrivateHostOperation,
package/host-adapter.js CHANGED
@@ -300,6 +300,18 @@ export function createDurableCheckoutHost({ persistence, resolveContext } = {})
300
300
  check(MESSAGE.test(receipt.message_id) && validDate(receipt.sent_at) && Date.parse(receipt.sent_at) <= Date.now(), 'invalid_response');
301
301
  entry.receipt = clone(receipt); await commit(active); return Object.freeze(clone(receipt));
302
302
  }),
303
+ reconcilePrivateDelivery: (context, idempotencyKey, lookup) => locked(context, async (active, resolved) => {
304
+ check(/^accelerate-chat:[a-f0-9]{64}$/.test(idempotencyKey) && typeof lookup === 'function');
305
+ const entry = active.record.deliveries?.find(item => item.idempotency_key === idempotencyKey);
306
+ check(entry && active.record.attempt?.checkout_id, 'permission_denied');
307
+ if (entry.receipt) return Object.freeze(clone(entry.receipt));
308
+ // Only a trusted read-only journal lookup may be passed here. It must bind
309
+ // the exact owner, destination and prompt key; it must never send.
310
+ const receipt = await lookup(Object.freeze({ ...resolved }), idempotencyKey);
311
+ exact(receipt, ['message_id', 'sent_at'], [], 'invalid_response');
312
+ check(MESSAGE.test(receipt.message_id) && validDate(receipt.sent_at) && Date.parse(receipt.sent_at) <= Date.now(), 'invalid_response');
313
+ entry.receipt = clone(receipt); await commit(active); return Object.freeze(clone(receipt));
314
+ }),
303
315
  confirmBrowserCleanup: (context, checkoutId) => locked(context, async active => {
304
316
  check(active.record.attempt?.checkout_id === checkoutId && active.record.attempt.checkout?.diagnostics?.cleanup_status === 'confirmed', 'operation_conflict');
305
317
  active.record.attempt.cleanup_confirmed = true; await commit(active);
package/index.d.ts CHANGED
@@ -88,6 +88,16 @@ export interface BrowserUseBrowserBinding {
88
88
  provider: 'browser_use';
89
89
  browser_id: string;
90
90
  custody_mode: 'customer_backend_trusted';
91
+ /** Opt-in provider run binding from a durable authenticated owner mapping. Never model input. */
92
+ provenance?: BrowserUseRunProvenance;
93
+ }
94
+ export interface BrowserUseRunProvenance {
95
+ type: 'browser_use_run_v1';
96
+ run_id: string;
97
+ session_id: string;
98
+ provider_workspace_id: string | null;
99
+ /** SHA-256 of the host's immutable owner/run reservation, not raw shopper data. */
100
+ binding_sha256: string;
91
101
  }
92
102
  /** Registered server-side executor connector; IDs are resolved from owned backend records. */
93
103
  export interface RegisteredBrowserBinding {
@@ -164,8 +174,12 @@ export interface ClientBrowserResource {
164
174
  startedAt: string;
165
175
  timeoutAt: string;
166
176
  recordingAvailable: false;
177
+ agentSessionId?: string;
178
+ agentRun?: { id: string; sessionId: string; workspaceId: string | null; status: 'completed' | 'cancelled' };
179
+ agentSession?: { sessionId: string; workspaceId: string | null; latestRunId: string; status: 'completed' | 'cancelled' };
180
+ agentQueue?: { queue: never[] };
167
181
  metadata: Record<string, string>;
168
- extensions: [{ id: string; version: string; runtimeId: string }];
182
+ extensions: [{ id: string; version: string; runtimeId: string }, ...{ id: string; version: string; runtimeId: string }[]];
169
183
  }
170
184
  /** Hypothesis to inspect against the actual checkout; never a caller assertion of currency evidence. */
171
185
  export interface QuoteVerificationRequest {
package/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { env as processEnv, versions } from 'node:process';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { normalizeBrowserOperation } from './browser-operations.js';
4
+ import { browserUseRunProvenance } from './browser-provenance.js';
4
5
  import { validateCheckoutDiagnostics, validateCheckoutReconciliation } from './checkout-diagnostics.js';
5
6
  export { checkoutDiagnosticsJsonSchema, validateCheckoutDiagnostics } from './checkout-diagnostics.js';
6
7
 
@@ -242,7 +243,10 @@ function creationInspectionInput(input) {
242
243
  for (const name of ['customer_id', 'workspace_id', 'conversation_id'])
243
244
  check(typeof input[name] === 'string' && /^[A-Za-z0-9_.:-]{1,500}$/u.test(input[name]));
244
245
  string(input.idempotency_key, 200);
245
- keys(input.browser, ['provider', 'browser_id', 'custody_mode']);
246
+ keys(input.browser, ['provider', 'browser_id', 'custody_mode', 'provenance'], ['provider', 'browser_id', 'custody_mode']);
247
+ if (Object.hasOwn(input.browser, 'provenance')) {
248
+ try { browserUseRunProvenance(input.browser.provenance); } catch { check(false); }
249
+ }
246
250
  check(input.browser.provider === 'browser_use' && typeof input.browser.browser_id === 'string' &&
247
251
  UUID.test(input.browser.browser_id) && input.browser.browser_id === input.browser.browser_id.toLowerCase() &&
248
252
  input.browser.custody_mode === 'customer_backend_trusted');
@@ -450,7 +454,11 @@ export class AccelerateClient {
450
454
  input.approval_policy === 'after_fill' && input.browser?.custody_mode === 'customer_backend_trusted');
451
455
  check(input.interaction_mode === 'chat' ? typeof input.conversation_id === 'string' && /^[A-Za-z0-9_.:-]{1,500}$/u.test(input.conversation_id) : input.conversation_id === undefined);
452
456
  if (input.browser !== undefined) {
453
- keys(input.browser, ['provider', 'browser_id', 'custody_mode', ...(input.browser?.provider === 'connector' ? ['connector_id'] : [])]);
457
+ keys(input.browser, ['provider', 'browser_id', 'custody_mode', ...(input.browser?.provider === 'connector' ? ['connector_id'] : ['provenance'])],
458
+ ['provider', 'browser_id', 'custody_mode', ...(input.browser?.provider === 'connector' ? ['connector_id'] : [])]);
459
+ if (Object.hasOwn(input.browser, 'provenance')) {
460
+ try { browserUseRunProvenance(input.browser.provenance); } catch { check(false); }
461
+ }
454
462
  const supportedBinding = input.browser.provider === 'browser_use'
455
463
  ? typeof input.browser.browser_id === 'string' && UUID.test(input.browser.browser_id) && input.browser.browser_id === input.browser.browser_id.toLowerCase()
456
464
  : input.browser.provider === 'connector' && typeof input.browser.connector_id === 'string' && /^[a-z][a-z0-9_-]{0,63}$/u.test(input.browser.connector_id) &&
package/managed-mcp.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AccelerateClient, AccelerateError } from './index.js';
2
+ import { browserUseRunProvenance } from './browser-provenance.js';
2
3
  import { browserOperationJsonSchema, parseBrowserOperation, normalizeBrowserOperation } from './browser-operations.js';
3
4
  import { checkoutDiagnosticsJsonSchema, validateCheckoutDiagnostics, validateCheckoutReconciliation } from './checkout-diagnostics.js';
4
5
  import { createHash } from 'node:crypto';
@@ -333,7 +334,10 @@ export function registerManagedCheckoutTools(server, { client = AccelerateClient
333
334
  requireAdmission();
334
335
  const browser = attachment ? attachment.browser : scope.browser;
335
336
  if (browserMode === 'customer_backend_trusted') {
336
- shape(browser, ['provider', 'browser_id', 'custody_mode'], browser?.provider === 'connector' ? ['connector_id'] : [], 'permission_denied');
337
+ shape(browser, ['provider', 'browser_id', 'custody_mode'], browser?.provider === 'connector' ? ['connector_id'] : ['provenance'], 'permission_denied');
338
+ if (browser.provenance !== undefined) {
339
+ try { browserUseRunProvenance(browser.provenance); } catch { check(false, 'permission_denied'); }
340
+ }
337
341
  const valid = browser.provider === 'browser_use'
338
342
  ? typeof browser.browser_id === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/u.test(browser.browser_id)
339
343
  : browser.provider === 'connector' && typeof browser.connector_id === 'string' && /^[a-z][a-z0-9_-]{0,63}$/u.test(browser.connector_id) &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weaccelerateinc/agent-sdk",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Server-only payment SDK, existing-browser connectors and private host adapters for agent platforms",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -57,6 +57,18 @@
57
57
  "./browser-use-host": {
58
58
  "types": "./browser-use-host.d.ts",
59
59
  "import": "./browser-use-host.js"
60
+ },
61
+ "./whatsapp-messaging": {
62
+ "types": "./whatsapp-messaging.d.ts",
63
+ "import": "./whatsapp-messaging.js"
64
+ },
65
+ "./whatsapp-journal": {
66
+ "types": "./whatsapp-journal.d.ts",
67
+ "import": "./whatsapp-journal.js"
68
+ },
69
+ "./postgres-whatsapp-journal": {
70
+ "types": "./postgres-whatsapp-journal.d.ts",
71
+ "import": "./postgres-whatsapp-journal.js"
60
72
  }
61
73
  },
62
74
  "files": [
@@ -91,7 +103,14 @@
91
103
  "approved-extension-releases",
92
104
  "partner-starter",
93
105
  "browser-use-host.js",
94
- "browser-use-host.d.ts"
106
+ "browser-use-host.d.ts",
107
+ "whatsapp-messaging.js",
108
+ "whatsapp-messaging.d.ts",
109
+ "whatsapp-journal.js",
110
+ "whatsapp-journal.d.ts",
111
+ "browser-provenance.js",
112
+ "postgres-whatsapp-journal.js",
113
+ "postgres-whatsapp-journal.d.ts"
95
114
  ],
96
115
  "engines": {
97
116
  "node": ">=22"
@@ -1,15 +1,15 @@
1
1
  # Existing-browser payment starter
2
2
 
3
- SDK 0.11.0, explicit `managed_review_v2`, `customer_backend_trusted`. Newly generated existing-browser apps default to `purpose:'purchase'` with the submit tool enabled. The matching service must support this profile; publishing the SDK does not deploy the service or qualify a merchant layout. Your platform creates its browser and installs the approved extension. This starter attaches that same browser at payment; it never creates a replacement. The older `accelerate-agent init <dir>` starter still selects Accelerate-managed Kernel custody and its fill-only default.
3
+ SDK 0.12.0, explicit `managed_review_v2`, `customer_backend_trusted`. Newly generated existing-browser apps default to `purpose:'purchase'` with the submit tool enabled. The matching service must support this profile; publishing the SDK does not deploy the service or qualify a merchant layout. Your platform creates its browser and installs the approved extension. This starter attaches that same browser at payment; it never creates a replacement. The older `accelerate-agent init <dir>` starter still selects Accelerate-managed Kernel custody and its fill-only default.
4
4
 
5
5
  ## Run without credentials
6
6
 
7
- Once Accelerate supplies the matching verified 0.11.0 archive, install it, then:
7
+ Once Accelerate supplies the matching verified 0.12.0 archive, install it, then:
8
8
 
9
9
  ```sh
10
10
  npx --no-install accelerate-agent init ./partner-demo --browser existing
11
11
  cd partner-demo
12
- npm install /secure/downloads/weaccelerateinc-agent-sdk-0.11.0.tgz
12
+ npm install /secure/downloads/weaccelerateinc-agent-sdk-0.12.0.tgz
13
13
  npm test
14
14
  npm run smoke
15
15
  ```
@@ -56,6 +56,6 @@ Purchase mode exposes `accelerate_checkout_submit`. Accelerate never sends the f
56
56
 
57
57
  For a no-order test, set `ACCELERATE_STARTER_PURPOSE=sandbox_fill_only` and remove the submit override or set `ACCELERATE_ENABLE_PURCHASE_SUBMIT=false`. If you copied `.env.example`, change both lines. An explicit true submit setting with fill-only purpose is rejected. After its informational review, cancel and verify cleanup. Purchase purpose with explicit submit false is also supported.
58
58
 
59
- The starter does not load `.env` automatically; export the settings or load them through your server's environment configuration. This change affects future `init --browser existing` output after 0.11.0 is released. Published 0.10.3 and already generated apps retain their old files; configure their existing switches explicitly to enable purchase. Reusable SDK registration defaults remain fill-only. Existing checkout purpose is immutable, so an existing fill-only checkout cannot become a purchase. Uncertain outcomes retain fences and require reconciliation; status/cleanup never authorize a replacement by themselves.
59
+ The starter does not load `.env` automatically; export the settings or load them through your server's environment configuration. The purchase default introduced in 0.11.0 applies to newly generated `init --browser existing` projects. Published 0.10.3 and already generated apps retain their old files; configure their existing switches explicitly to enable purchase. Reusable SDK registration defaults remain fill-only. Existing checkout purpose is immutable, so an existing fill-only checkout cannot become a purchase. Uncertain outcomes retain fences and require reconciliation; status/cleanup never authorize a replacement by themselves.
60
60
 
61
61
  Before live testing, confirm the session is executing the deployed v2 tool bundle. Updating a durable agent app does not necessarily replace an active session’s tools; settle its old work and use the framework’s supported idle deployment handoff or create a new authenticated session. Verify the quote response acknowledges `fill_after_verification:true`; a successful deployment alone is insufficient.
@@ -12,7 +12,7 @@
12
12
  "test": "node --test tests/*.test.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@weaccelerateinc/agent-sdk": "0.11.0",
15
+ "@weaccelerateinc/agent-sdk": "0.12.0",
16
16
  "@modelcontextprotocol/server": "2.3.1"
17
17
  },
18
18
  "devDependencies": {
@@ -225,7 +225,7 @@ export function createSyntheticDependencies({ purpose = 'sandbox_fill_only', pro
225
225
  artifact: { format: 'zip', sha256: 'a'.repeat(64), bytes: 1234 },
226
226
  bridge: { protocol: 'accelerate_extension_bridge_v1', sha256: 'b'.repeat(64) },
227
227
  capabilities: { manual_card_fill: true, document_bound_single_use: true, autosave_disabled: true, automatic_submit: false, maximum_retention_ms: 480000 },
228
- compatibility: { sdk_versions: ['0.10.1', '0.10.2', '0.10.3', '0.10.4', '0.11.0'], executor_contract: 'accelerate_manual_checkout_v1' },
228
+ compatibility: { sdk_versions: ['0.10.1', '0.10.2', '0.10.3', '0.10.4', '0.11.0', '0.12.0'], executor_contract: 'accelerate_manual_checkout_v1' },
229
229
  qualifications: [{ provider: 'connector', scope: 'synthetic_only', evidence: 'docs/evidence/synthetic-starter.json' }],
230
230
  }; // Fake metadata for this in-process fixture only; never load it in live mode.
231
231
  const browser = { owner: context, browser: { provider: 'connector', connector_id: 'synthetic_cdp', browser_id: 'existing:browser:1', custody_mode: 'customer_backend_trusted' },
@@ -1,4 +1,4 @@
1
- import type { HostPersistence } from './host-adapter.js';
1
+ import type { HostCheckoutRecord } from './host-adapter.js';
2
2
  export const checkoutHostMigrationSql: string;
3
3
  export interface CheckoutHostPostgresClient {
4
4
  query(sql: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[]; rowCount?: number | null }>;
@@ -6,9 +6,13 @@ export interface CheckoutHostPostgresClient {
6
6
  on?(event: 'error', listener: () => void): unknown;
7
7
  removeListener?(event: 'error', listener: () => void): unknown;
8
8
  }
9
- export function createPostgresCheckoutPersistence(options: {
9
+ /** Use a separate namespace for each private record type, including WhatsApp journals. */
10
+ export function createPostgresCheckoutPersistence<Record = HostCheckoutRecord>(options: {
10
11
  pool: { connect(): Promise<CheckoutHostPostgresClient> };
11
12
  encryptionKey: Buffer | string;
12
13
  namespace?: string;
13
14
  lockTimeoutMs?: number;
14
- }): Readonly<HostPersistence & { initialize(): Promise<void> }>;
15
+ }): Readonly<{
16
+ withLock<T>(ownerKey: string, work: (access: { load(): Promise<Record | undefined>; commit(record: Record): Promise<void> }) => Promise<T>): Promise<T>;
17
+ initialize(): Promise<void>;
18
+ }>;
@@ -0,0 +1,8 @@
1
+ import type { WhatsAppDeliveryJournal } from './whatsapp-messaging.js';
2
+ export const whatsappJournalMigrationSql: string;
3
+ /** Short Postgres transactions with row locks; transaction poolers are supported.
4
+ * initialize() executes DDL only when called by an operator. Reservations and
5
+ * matched evidence never expire; unmatched statuses expire after 24 hours and
6
+ * are capped at 64 per sender/recipient. Run pruneExpired periodically. Only
7
+ * hashed identifiers/status/timestamps are stored. Requires real PG qualification. */
8
+ export function createPostgresWhatsAppDeliveryJournal(options: { pool: { query(sql: string, args?: unknown[]): Promise<{ rows: any[]; rowCount?: number | null }>; connect(): Promise<{ query(sql: string, args?: unknown[]): Promise<{ rows: any[]; rowCount?: number | null }>; release(destroy?: boolean): void }> } }): Readonly<WhatsAppDeliveryJournal & { initialize(): Promise<void>; pruneExpired(): Promise<number> }>;
@@ -0,0 +1,95 @@
1
+ import { AccelerateError } from './index.js';
2
+ const HASH = /^[a-f0-9]{64}$/, MESSAGE = /^wa:[a-f0-9]{64}$/;
3
+ const check = value => { if (!value) throw new AccelerateError('request_failed'); };
4
+ const exact = (obj, names) => obj && typeof obj === 'object' && !Array.isArray(obj) && Object.keys(obj).length === names.length && names.every(k => Object.hasOwn(obj, k));
5
+ const scope = s => exact(s, ['key', 'sender', 'recipient']) && HASH.test(s.key) && HASH.test(s.sender) && HASH.test(s.recipient);
6
+ const timestamp = x => typeof x === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) && Number.isFinite(Date.parse(x)) && Date.parse(x) <= Date.now();
7
+ /** Operator-only setup. Rows contain hashed IDs and timestamps only. */
8
+ export const whatsappJournalMigrationSql = `
9
+ CREATE TABLE IF NOT EXISTS accelerate_whatsapp_reservations (
10
+ sender text NOT NULL, delivery_key text NOT NULL, recipient text NOT NULL, message_id text,
11
+ PRIMARY KEY(sender, delivery_key), UNIQUE(sender, message_id));
12
+ CREATE INDEX IF NOT EXISTS accelerate_whatsapp_recipient ON accelerate_whatsapp_reservations(sender, recipient, delivery_key);
13
+ CREATE TABLE IF NOT EXISTS accelerate_whatsapp_statuses (
14
+ sender text NOT NULL, recipient text NOT NULL, message_id text NOT NULL, status text NOT NULL,
15
+ occurred_at timestamptz NOT NULL, expires_at timestamptz, conflicted boolean NOT NULL DEFAULT false,
16
+ PRIMARY KEY(sender,recipient,message_id,status));
17
+ CREATE INDEX IF NOT EXISTS accelerate_whatsapp_expiry ON accelerate_whatsapp_statuses(expires_at) WHERE expires_at IS NOT NULL;`;
18
+ /** Durable reservation rows never expire. Pre-bind event rows expire after 24h;
19
+ * at most 64 active unmatched events per sender+recipient are retained. */
20
+ export function createPostgresWhatsAppDeliveryJournal({ pool } = {}) {
21
+ check(typeof pool?.query === 'function' && typeof pool?.connect === 'function');
22
+ const query = async (sql, args = []) => { try { return await pool.query(sql, args); } catch { throw new AccelerateError('request_failed'); } };
23
+ const transaction = async work => {
24
+ let client, began = false, broken = false;
25
+ try {
26
+ client = await pool.connect();
27
+ await client.query('BEGIN'); began = true;
28
+ const output = await work(client);
29
+ await client.query('COMMIT'); began = false;
30
+ return output;
31
+ } catch {
32
+ broken = true;
33
+ if (began) try { await client.query('ROLLBACK'); } catch {}
34
+ throw new AccelerateError('request_failed');
35
+ } finally { if (client) client.release(broken); }
36
+ };
37
+ return Object.freeze({
38
+ async initialize() { await query(whatsappJournalMigrationSql); },
39
+ async reserve(s) {
40
+ check(scope(s));
41
+ const inserted = await query('INSERT INTO accelerate_whatsapp_reservations(sender,delivery_key,recipient) VALUES($1,$2,$3) ON CONFLICT DO NOTHING RETURNING delivery_key', [s.sender,s.key,s.recipient]);
42
+ if (inserted.rows.length) return { created: true };
43
+ const found = await query('SELECT recipient FROM accelerate_whatsapp_reservations WHERE sender=$1 AND delivery_key=$2', [s.sender,s.key]);
44
+ check(found.rows.length === 1 && found.rows[0].recipient === s.recipient); return { created: false };
45
+ },
46
+ async bind(s) {
47
+ check(exact(s, ['key','sender','recipient','message_id']) && scope({ key:s.key,sender:s.sender,recipient:s.recipient }) && MESSAGE.test(s.message_id));
48
+ await transaction(async client => {
49
+ // Lock first, then issue a fresh-snapshot promotion statement. A status
50
+ // writer that finished while this waited is visible to the second query.
51
+ const found = await client.query('SELECT message_id FROM accelerate_whatsapp_reservations WHERE sender=$1 AND delivery_key=$2 AND recipient=$3 FOR UPDATE', [s.sender,s.key,s.recipient]);
52
+ check(found.rows.length === 1 && (found.rows[0].message_id === null || found.rows[0].message_id === s.message_id));
53
+ await client.query('UPDATE accelerate_whatsapp_reservations SET message_id=$4 WHERE sender=$1 AND delivery_key=$2 AND recipient=$3', [s.sender,s.key,s.recipient,s.message_id]);
54
+ await client.query(`UPDATE accelerate_whatsapp_statuses SET expires_at=NULL
55
+ WHERE sender=$1 AND recipient=$2 AND message_id=$3 AND expires_at > clock_timestamp()`, [s.sender,s.recipient,s.message_id]);
56
+ });
57
+ },
58
+ async recordStatus(s) {
59
+ check(exact(s,['sender','recipient','message_id','status','timestamp']) && HASH.test(s.sender) && HASH.test(s.recipient) && MESSAGE.test(s.message_id) && ['sent','failed'].includes(s.status) && timestamp(s.timestamp));
60
+ await transaction(async client => {
61
+ // Select the same unbound candidate for a recipient in deterministic
62
+ // order. Separate post-lock statements see all preceding commits.
63
+ const locked = await client.query(`SELECT message_id FROM accelerate_whatsapp_reservations
64
+ WHERE sender=$1 AND recipient=$2 AND (message_id=$3 OR message_id IS NULL)
65
+ ORDER BY (message_id=$3) DESC NULLS LAST,delivery_key LIMIT 1 FOR UPDATE`, [s.sender,s.recipient,s.message_id]);
66
+ if (!locked.rows.length) return;
67
+ const bound = locked.rows[0].message_id === s.message_id;
68
+ if (!bound) {
69
+ const counted = await client.query(`SELECT count(*)::int AS count,
70
+ COALESCE(bool_or(message_id=$3 AND status=$4),false) AS existing FROM accelerate_whatsapp_statuses
71
+ WHERE sender=$1 AND recipient=$2 AND expires_at > clock_timestamp()`, [s.sender,s.recipient,s.message_id,s.status]);
72
+ if (counted.rows[0].count >= 64 && !counted.rows[0].existing) return;
73
+ }
74
+ await client.query(`INSERT INTO accelerate_whatsapp_statuses(sender,recipient,message_id,status,occurred_at,expires_at)
75
+ VALUES($1,$2,$3,$4,$5::timestamptz,CASE WHEN $6::boolean THEN NULL ELSE clock_timestamp()+interval '24 hours' END)
76
+ ON CONFLICT(sender,recipient,message_id,status) DO UPDATE SET
77
+ conflicted=accelerate_whatsapp_statuses.conflicted OR accelerate_whatsapp_statuses.occurred_at<>EXCLUDED.occurred_at,
78
+ expires_at=CASE WHEN accelerate_whatsapp_statuses.expires_at IS NULL THEN NULL ELSE EXCLUDED.expires_at END`,
79
+ [s.sender,s.recipient,s.message_id,s.status,s.timestamp,bound]);
80
+ });
81
+ },
82
+ async get(s) {
83
+ check(scope(s));
84
+ const result = await query(`SELECT r.recipient,r.message_id,
85
+ EXISTS(SELECT 1 FROM accelerate_whatsapp_statuses e WHERE e.sender=r.sender AND e.recipient=r.recipient AND e.message_id=r.message_id AND (e.expires_at IS NULL OR e.expires_at>clock_timestamp()) AND (e.status='failed' OR e.conflicted)) AS failed,
86
+ (SELECT e.occurred_at FROM accelerate_whatsapp_statuses e WHERE e.sender=r.sender AND e.recipient=r.recipient AND e.message_id=r.message_id AND (e.expires_at IS NULL OR e.expires_at>clock_timestamp()) AND e.status='sent' AND NOT e.conflicted) AS sent_at
87
+ FROM accelerate_whatsapp_reservations r WHERE r.sender=$1 AND r.delivery_key=$2`, [s.sender,s.key]);
88
+ check(result.rows.length === 1 && result.rows[0].recipient === s.recipient);
89
+ const row = result.rows[0], sent = row.sent_at == null ? undefined : new Date(row.sent_at).toISOString();
90
+ return { sender:s.sender, recipient:s.recipient, ...(row.message_id ? { message_id:row.message_id } : {}), status:row.failed ? 'failed' : sent ? 'sent' : 'pending', ...(sent && !row.failed ? { sent_at:sent } : {}) };
91
+ },
92
+ /** Only unmatched expired events; never reservation or confirmed evidence. */
93
+ async pruneExpired() { const result = await query(`DELETE FROM accelerate_whatsapp_statuses e WHERE e.expires_at IS NOT NULL AND e.expires_at<=clock_timestamp() AND NOT EXISTS (SELECT 1 FROM accelerate_whatsapp_reservations r WHERE r.sender=e.sender AND r.recipient=e.recipient AND r.message_id=e.message_id)`); return result.rowCount ?? 0; },
94
+ });
95
+ }
@@ -23,6 +23,10 @@ export interface PrivateCheckoutIngress<Request = unknown> {
23
23
  * and transient parsed text. Ordinary traffic is admitted only through the
24
24
  * callback as a new minimal event, never by passing the original envelope. */
25
25
  handle(request: Request, rawBody: Uint8Array): Promise<Readonly<{ handled: boolean; retry?: true }>>;
26
+ /** Trusted server-only entrypoint for a provider-authenticated direct message.
27
+ * Never expose as a model tool. Caller must invoke before history/AI, and must
28
+ * retry the webhook when retry is true. Clears message.text on every outcome. */
29
+ handleAuthenticated(message: AuthenticatedPrivateMessage): Promise<Readonly<{ handled: boolean; retry?: true }>>;
26
30
  /** Safely recover pending lifecycle/prompt/progress deliveries only. Never
27
31
  * repeats fill or submit. Requires authenticated context from the host. */
28
32
  drain(context: ManagedCheckoutContext): Promise<number>;
@@ -5,6 +5,7 @@ import { deliverPrivateCheckoutReview } from './private-chat.js';
5
5
 
6
6
  const MESSAGE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}$/;
7
7
  const PENDING = new Set(['identity_required', 'otp_required', 'selection_required', 'cvv_required', 'approval_required', 'review_delivery_required']);
8
+ const BEFORE_FILL = new Set(['identity_required', 'otp_required', 'selection_required', 'cvv_required']);
8
9
  const TERMINAL = new Set(['cancelled', 'expired', 'failed', 'outcome_unknown', 'submit_attempted']);
9
10
  const check = condition => { if (!condition) throw new AccelerateError('invalid_input'); };
10
11
  function digest(value) { return createHash('sha256').update(value).digest('hex'); }
@@ -75,15 +76,9 @@ export function createPrivateCheckoutIngress({ authenticate, host, privateChat,
75
76
  await saveAndDeliver(trusted, owned, response, `event-${digest(item.id)}`);
76
77
  });
77
78
  });
78
- return Object.freeze({ drain,
79
- async handle(request, rawBody) {
80
- let verified, context, messageId;
79
+ async function handleAuthenticated(verified) {
80
+ let context, messageId;
81
81
  try {
82
- check(rawBody instanceof Uint8Array);
83
- verified = await authenticate(request, rawBody);
84
- // Withhold the original envelope even for admitted ordinary messages.
85
- // Only a new minimal event can cross the AI/history boundary below.
86
- clearBody(rawBody);
87
82
  if (verified?.kind === 'ignore') return Object.freeze({ handled: true });
88
83
  check(verified?.authenticated === true && verified.direct === true);
89
84
  context = authenticatedCheckoutContext(verified.context); messageId = verified.message_id;
@@ -106,6 +101,13 @@ export function createPrivateCheckoutIngress({ authenticate, host, privateChat,
106
101
  if (isStatus) response = await privateChat.chat.get(owned.checkout_id, binding);
107
102
  else if (isCancel) response = await privateChat.chat.cancel(owned.checkout_id, binding);
108
103
  else {
104
+ // Private verification can fill in the same service call. Recheck
105
+ // after the wait while the original reply ID is durably pending.
106
+ // Same-ID recovery must pass this fresh check again; status and
107
+ // cancel remain available without inspecting the payment page.
108
+ if (browserConnector && (BEFORE_FILL.has(owned.previous_stage) ||
109
+ owned.previous_stage === 'approval_required' && owned.checkout?.approval_policy !== 'after_fill'))
110
+ await browserConnector.assertPaymentReady(context, { checkout_id: owned.checkout_id });
109
111
  const input = { ...binding, message_id: messageId, text: text || '[non-text reply]', ...metadata };
110
112
  try { response = await privateChat.chat.reply(owned.checkout_id, input); } finally { input.text = ''; }
111
113
  }
@@ -126,7 +128,18 @@ export function createPrivateCheckoutIngress({ authenticate, host, privateChat,
126
128
  }); } catch {}
127
129
  }
128
130
  return Object.freeze({ handled: true, retry: true });
129
- } finally { clearBody(rawBody); clearReply(verified); }
131
+ } finally { clearReply(verified); }
132
+ }
133
+ return Object.freeze({ drain, handleAuthenticated,
134
+ async handle(request, rawBody) {
135
+ let verified;
136
+ try {
137
+ check(rawBody instanceof Uint8Array);
138
+ verified = await authenticate(request, rawBody);
139
+ clearBody(rawBody);
140
+ return await handleAuthenticated(verified);
141
+ } catch { return Object.freeze({ handled: true, retry: true }); }
142
+ finally { clearBody(rawBody); clearReply(verified); }
130
143
  },
131
144
  });
132
145
  }
@@ -12,7 +12,7 @@
12
12
  "test": "node --test tests/*.test.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@weaccelerateinc/agent-sdk": "0.11.0",
15
+ "@weaccelerateinc/agent-sdk": "0.12.0",
16
16
  "@modelcontextprotocol/server": "2.3.1"
17
17
  },
18
18
  "devDependencies": {
@@ -0,0 +1,11 @@
1
+ import type { WhatsAppDeliveryJournal } from './whatsapp-messaging.js';
2
+ /** Persistence MUST use a dedicated namespace and an exclusive sender owner
3
+ * lock across processes. commit must be durable before it resolves and survive
4
+ * later remote failures. The included Postgres persistence can be used with a
5
+ * separate namespace and a session-affine connection. Records contain only
6
+ * hashes, statuses and timestamps. Capacity limits fail closed: 1000 permanent
7
+ * reservations and 3000 status records per sender. Unmatched events are limited to 64 per recipient and 1000 total, and ignored when full. No automatic deletion occurs;
8
+ * operators must plan safe archival/reconciliation before those limits. */
9
+ export function createWhatsAppDeliveryJournal(options: { persistence: {
10
+ withLock<T>(owner: string, work: (access: { load(): Promise<unknown>; commit(record: unknown): Promise<void> }) => Promise<T>): Promise<T>;
11
+ } }): Readonly<WhatsAppDeliveryJournal>;
@@ -0,0 +1,78 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { AccelerateError } from './index.js';
3
+
4
+ const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
5
+ const HASH = /^[a-f0-9]{64}$/;
6
+ const MESSAGE = /^wa:[a-f0-9]{64}$/;
7
+ const time = value => typeof value === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) && Number.isFinite(Date.parse(value)) && Date.parse(value) <= Date.now();
8
+ const check = value => { if (!value) throw new AccelerateError('request_failed'); };
9
+ const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every(k => Object.hasOwn(value, k));
10
+ const scopeOK = s => exact(s, ['key', 'sender', 'recipient']) && HASH.test(s.key) && HASH.test(s.sender) && HASH.test(s.recipient);
11
+ const statusOK = s => exact(s, ['sender', 'recipient', 'message_id', 'status', 'timestamp']) && HASH.test(s.sender) && HASH.test(s.recipient) && MESSAGE.test(s.message_id) && ['sent', 'failed'].includes(s.status) && time(s.timestamp);
12
+ function validate(record, sender) {
13
+ check(exact(record, ['version', 'sender', 'reservations', 'statuses']) && record.version === 1 && record.sender === sender && Array.isArray(record.reservations) && record.reservations.length <= 1000 && Array.isArray(record.statuses) && record.statuses.length <= 3000);
14
+ const reservations = new Set(), events = new Set();
15
+ for (const item of record.reservations) {
16
+ check((exact(item, ['key', 'recipient']) || exact(item, ['key', 'recipient', 'message_id'])) && HASH.test(item.key) && HASH.test(item.recipient) && (item.message_id === undefined || MESSAGE.test(item.message_id)) && !reservations.has(item.key)); reservations.add(item.key);
17
+ }
18
+ for (const item of record.statuses) {
19
+ check((exact(item, ['recipient', 'message_id', 'status', 'timestamp']) ||
20
+ exact(item, ['recipient', 'message_id', 'status', 'timestamp', 'conflicted']) && item.conflicted === true) &&
21
+ statusOK({ sender, recipient: item.recipient, message_id: item.message_id, status: item.status, timestamp: item.timestamp }));
22
+ const k = `${item.recipient}:${item.message_id}:${item.status}`; check(!events.has(k)); events.add(k);
23
+ }
24
+ }
25
+ /** Use a dedicated encrypted persistence namespace. Each sender has one SHA-256
26
+ * owner lock, shared by reservations and out-of-order provider status events. */
27
+ export function createWhatsAppDeliveryJournal({ persistence } = {}) {
28
+ check(typeof persistence?.withLock === 'function');
29
+ async function locked(sender, work) {
30
+ check(HASH.test(sender)); let calls = 0, complete = false, expected;
31
+ try {
32
+ const result = await persistence.withLock(`accelerate-partner-host:${digest(['whatsapp-journal-v1', sender])}`, async access => {
33
+ check(++calls === 1 && typeof access?.load === 'function' && typeof access?.commit === 'function');
34
+ const record = await access.load() ?? { version: 1, sender, reservations: [], statuses: [] };
35
+ validate(record, sender);
36
+ const commit = async () => { validate(record, sender); await access.commit(structuredClone(record)); };
37
+ expected = await work(record, commit); complete = true; return expected;
38
+ });
39
+ check(calls === 1 && complete && result === expected); return result;
40
+ } catch { throw new AccelerateError('request_failed'); }
41
+ }
42
+ const find = (record, s) => { const item = record.reservations.find(row => row.key === s.key); if (item) check(item.recipient === s.recipient); return item; };
43
+ return Object.freeze({
44
+ async reserve(s) { check(scopeOK(s)); return locked(s.sender, async (record, commit) => { if (find(record, s)) return { created: false }; check(record.reservations.length < 1000); record.reservations.push({ key: s.key, recipient: s.recipient }); await commit(); return { created: true }; }); },
45
+ async bind(s) {
46
+ check(exact(s, ['key', 'sender', 'recipient', 'message_id']) && scopeOK({ key: s.key, sender: s.sender, recipient: s.recipient }) && MESSAGE.test(s.message_id));
47
+ return locked(s.sender, async (record, commit) => { const item = find(record, s); check(item && (!item.message_id || item.message_id === s.message_id));
48
+ // One provider message cannot represent multiple reservations.
49
+ check(!record.reservations.some(other => other !== item && other.message_id === s.message_id));
50
+ if (!item.message_id) { item.message_id = s.message_id; await commit(); } });
51
+ },
52
+ async recordStatus(s) { check(statusOK(s)); return locked(s.sender, async (record, commit) => {
53
+ // Retain an unknown ID only while a reservation for this recipient has
54
+ // not yet been bound. Ignore unrelated business traffic.
55
+ const matched = record.reservations.some(x => x.recipient === s.recipient && x.message_id === s.message_id);
56
+ const pending = record.reservations.some(x => x.recipient === s.recipient && !x.message_id);
57
+ if (!matched && !pending) return;
58
+ const previous = record.statuses.find(x => x.recipient === s.recipient && x.message_id === s.message_id && x.status === s.status);
59
+ if (previous) {
60
+ if (previous.timestamp !== s.timestamp) {
61
+ if (!previous.conflicted) { previous.conflicted = true; await commit(); }
62
+ check(false);
63
+ }
64
+ return;
65
+ }
66
+ // Unmatched provider traffic cannot exhaust the bounded reference journal.
67
+ const unmatched = record.statuses.filter(x => x.recipient === s.recipient && !record.reservations.some(r => r.recipient === x.recipient && r.message_id === x.message_id));
68
+ const allUnmatched = record.statuses.filter(x => !record.reservations.some(r => r.recipient === x.recipient && r.message_id === x.message_id)).length;
69
+ if (!matched && (unmatched.length >= 64 || allUnmatched >= 1000 || record.statuses.length >= 3000)) return;
70
+ check(record.statuses.length < 3000); record.statuses.push({ recipient: s.recipient, message_id: s.message_id, status: s.status, timestamp: s.timestamp }); await commit();
71
+ }); },
72
+ async get(s) { check(scopeOK(s)); return locked(s.sender, async record => {
73
+ const item = find(record, s); check(item); const events = record.statuses.filter(x => x.recipient === s.recipient && x.message_id === item.message_id);
74
+ const sent = events.find(x => x.status === 'sent'), failed = events.find(x => x.status === 'failed' || x.conflicted === true);
75
+ return { sender: s.sender, recipient: s.recipient, ...(item.message_id ? { message_id: item.message_id } : {}), status: failed ? 'failed' : sent ? 'sent' : 'pending', ...(sent && !failed ? { sent_at: sent.timestamp } : {}) };
76
+ }); },
77
+ });
78
+ }
@@ -0,0 +1,64 @@
1
+ import type { ManagedCheckoutContext } from './managed-mcp.js';
2
+ import type { AuthenticatedPrivateMessage, PrivateMessagingAdapter } from './private-ingress.js';
3
+
4
+ type Scope = { key: string; sender: string; recipient: string };
5
+ type Destination = { phone_number_id: string; recipient_id: string; verified_wa_id?: string };
6
+ type Identity = { phone_number: string; wa_id: string; phone_number_id: string };
7
+ type Resolution = ManagedCheckoutContext | { context: ManagedCheckoutContext; verified_wa_id: string } | null;
8
+ type Status = { sender: string; recipient: string; message_id: string; status: 'sent' | 'failed'; timestamp: string };
9
+ /** Implement with a durable, atomic, linearizable shared database. reserve must
10
+ * create exactly once and check the same key's sender/recipient on every call.
11
+ * bind must bind exactly one hashed provider ID to that reservation and reject any
12
+ * conflict. recordStatus retains pre-bind events only for a matching recipient
13
+ * with an unbound reservation and ignores unrelated statuses. Match sender,
14
+ * recipient and hashed message ID when binding, tolerate duplicate/out-of-order events,
15
+ * and fail closed on conflicting sent/failed evidence. get returns pending until
16
+ * an exact matched sent event; its sent_at must be the provider event timestamp.
17
+ * Delivered/read timestamps never substitute for sent; missing proof stays pending.
18
+ * Provider IDs are deterministically hashed before journaling and SDK delivery.
19
+ * Never store raw envelopes, image bytes, message text, OTP or CVV. Apply bounded
20
+ * retention to unbound status events. Unknown reservations must not be cleared
21
+ * or retried without independently reconciling the provider outcome. */
22
+ export interface WhatsAppDeliveryJournal {
23
+ reserve(scope: Scope): Promise<{ created: boolean }>;
24
+ bind(input: Scope & { message_id: string }): Promise<void>;
25
+ recordStatus(input: Status): Promise<void>;
26
+ get(scope: Scope): Promise<{ sender: string; recipient: string; message_id?: string; status: 'pending' | 'sent' | 'failed'; sent_at?: string }>;
27
+ }
28
+ /** Provider wrapper must make one native request per call and disable retries.
29
+ * sendImage sends the image and caption together. Never expose provider URLs. */
30
+ export interface WhatsAppMessagingClient {
31
+ uploadImage(input: { bytes: Buffer; contentType: 'image/png'; signal: AbortSignal }): Promise<{ media_id: string }>;
32
+ sendImage(input: { phone_number_id: string; recipient_id: string; media_id: string; caption: string; signal: AbortSignal }): Promise<{ message_id: string }>;
33
+ sendText(input: { phone_number_id: string; recipient_id: string; text: string; signal: AbortSignal }): Promise<{ message_id: string }>;
34
+ }
35
+ export function createWhatsAppMessagingAdapter(options: {
36
+ client: WhatsAppMessagingClient; journal: WhatsAppDeliveryJournal; sendingPhoneId: string;
37
+ /** Resolve from authenticated backend state only. If the provider wa_id differs
38
+ * from the verified phone, verified_wa_id must equal the mapped recipient_id. */
39
+ resolveDestination(context: Readonly<ManagedCheckoutContext>): Promise<Destination> | Destination;
40
+ }): Readonly<PrivateMessagingAdapter & {
41
+ /** Read-only exact-context/key reconciliation. Never sends or uploads. */
42
+ findReviewReceipt(context: Readonly<ManagedCheckoutContext>, idempotencyKey: string, options?: { signal?: AbortSignal }): Promise<{ message_id: string; sent_at: string }>;
43
+ }>;
44
+ export function createWhatsAppWebhookAuthenticator<Request = unknown>(options: {
45
+ /** Verify provider signature over exact raw bytes. No callback may log those bytes. */
46
+ verifyWebhook(request: Request, rawBody: Uint8Array): Promise<boolean> | boolean;
47
+ /** Return null for unknown senders; throw only on transient resolution errors. */
48
+ resolveIdentity(identity: Identity): Promise<Resolution> | Resolution;
49
+ sendingPhoneId: string; journal: WhatsAppDeliveryJournal;
50
+ }): (request: Request, rawBody: Uint8Array) => Promise<AuthenticatedPrivateMessage | { kind: 'ignore' }>;
51
+ /** Verify the exact original raw bytes once, then process statuses and inbound
52
+ * text in provider array order. Meta's separate arrays do not encode relative
53
+ * ordering: statuses are recorded before messages in each change. onMessage
54
+ * runs transiently before any history or AI processing; it MUST durably
55
+ * deduplicate message_id. A thrown error or retry:true is an item failure. A failure rejects the whole
56
+ * webhook; provider retries can repeat earlier callbacks. No raw webhook is
57
+ * reserialized or retained, and rawBody is zeroed even on failure. */
58
+ export function createWhatsAppWebhookBatchHandler<Request = unknown>(options: {
59
+ verifyWebhook(request: Request, rawBody: Uint8Array): Promise<boolean> | boolean;
60
+ /** Return null for unknown senders; throw only on transient resolution errors. */
61
+ resolveIdentity(identity: Identity): Promise<Resolution> | Resolution;
62
+ sendingPhoneId: string; journal: WhatsAppDeliveryJournal;
63
+ onMessage(message: AuthenticatedPrivateMessage): Promise<void | { retry?: boolean }> | void | { retry?: boolean };
64
+ }): (request: Request, rawBody: Uint8Array) => Promise<{ handled: true }>;
@@ -0,0 +1,182 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { AccelerateError } from './index.js';
3
+ import { authenticatedCheckoutContext } from './host-adapter.js';
4
+
5
+ const id = /^[A-Za-z0-9][A-Za-z0-9_.:+/=-]{0,499}$/;
6
+ const safeId = value => `wa:${hash(value)}`;
7
+ const digits = /^[1-9]\d{5,19}$/;
8
+ const keyPattern = /^[A-Za-z0-9_.:-]{1,200}$/;
9
+ const fail = () => { throw new AccelerateError('request_failed'); };
10
+ const check = value => { if (!value) fail(); };
11
+ const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
12
+ const validTime = value => typeof value === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) && Number.isFinite(Date.parse(value)) && Date.parse(value) <= Date.now();
13
+ const fromEpoch = value => {
14
+ check(typeof value === 'string' && /^\d{10}$/.test(value));
15
+ const ms = Number(value) * 1000; check(Number.isSafeInteger(ms) && ms <= Date.now());
16
+ return new Date(ms).toISOString();
17
+ };
18
+ const phoneDigits = value => { check(typeof value === 'string' && /^\+[1-9]\d{6,14}$/.test(value)); return value.slice(1); };
19
+ function bounded(signal, deadline) {
20
+ const ms = deadline === undefined ? 45000 : Date.parse(deadline) - Date.now();
21
+ check(Number.isFinite(ms) && ms > 0);
22
+ return AbortSignal.any([AbortSignal.timeout(Math.min(ms, 45000)), ...(signal ? [signal] : [])]);
23
+ }
24
+ async function run(signal, task) {
25
+ signal.throwIfAborted();
26
+ return new Promise((resolve, reject) => {
27
+ const abort = () => reject(new AccelerateError('request_failed'));
28
+ signal.addEventListener('abort', abort, { once: true });
29
+ Promise.resolve().then(task).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
30
+ if (signal.aborted) abort();
31
+ });
32
+ }
33
+ const wait = signal => run(signal, () => new Promise(resolve => setTimeout(resolve, 250)));
34
+ function validateJournal(journal) { check(['reserve', 'bind', 'recordStatus', 'get'].every(k => typeof journal?.[k] === 'function')); }
35
+
36
+ /** The injected journal MUST be durable, atomic, and linearizable across all
37
+ * workers. No image, caption, reply or raw provider envelope may be stored. */
38
+ export function createWhatsAppMessagingAdapter({ client, journal, sendingPhoneId, resolveDestination } = {}) {
39
+ check(typeof client?.uploadImage === 'function' && typeof client?.sendImage === 'function' && typeof client?.sendText === 'function' && digits.test(sendingPhoneId) && typeof resolveDestination === 'function');
40
+ validateJournal(journal);
41
+ async function deliver(input, message, review, options = {}) {
42
+ let bytes;
43
+ try {
44
+ const context = authenticatedCheckoutContext(input);
45
+ check(message && typeof message.text === 'string' && message.text.length > 0 && message.text.length <= (review ? 1024 : 4096) && keyPattern.test(message.idempotency_key));
46
+ if (review) check(Buffer.isBuffer(message.bytes) && message.bytes.length > 8 && message.bytes.length <= 2 * 1024 * 1024 && message.bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])) && message.contentType === 'image/png' && message.signal instanceof AbortSignal);
47
+ const signal = bounded(review ? message.signal : options.signal, review ? message.delivery_expires_at : undefined);
48
+ const destination = await run(signal, () => resolveDestination(context));
49
+ check(destination && destination.phone_number_id === sendingPhoneId && (destination.recipient_id === phoneDigits(context.phone_number) || digits.test(destination.recipient_id) && destination.verified_wa_id === destination.recipient_id));
50
+ const scope = { key: hash([context.customer_id, context.workspace_id, context.conversation_id, message.idempotency_key]),
51
+ sender: hash(sendingPhoneId), recipient: hash(destination.recipient_id) };
52
+ const reserved = await run(signal, () => journal.reserve(scope));
53
+ check(reserved && typeof reserved.created === 'boolean');
54
+ if (reserved.created) {
55
+ let accepted;
56
+ if (review) {
57
+ bytes = Buffer.from(message.bytes);
58
+ const uploaded = await run(signal, () => client.uploadImage({ bytes, contentType: 'image/png', signal }));
59
+ check(typeof uploaded?.media_id === 'string' && id.test(uploaded.media_id));
60
+ accepted = await run(signal, () => client.sendImage({ phone_number_id: sendingPhoneId, recipient_id: destination.recipient_id, media_id: uploaded.media_id, caption: message.text, signal }));
61
+ } else accepted = await run(signal, () => client.sendText({ phone_number_id: sendingPhoneId, recipient_id: destination.recipient_id, text: message.text, signal }));
62
+ check(typeof accepted?.message_id === 'string' && id.test(accepted.message_id));
63
+ await run(signal, () => journal.bind({ ...scope, message_id: safeId(accepted.message_id) }));
64
+ }
65
+ for (;;) {
66
+ const value = await run(signal, () => journal.get(scope));
67
+ check(value && value.sender === scope.sender && value.recipient === scope.recipient);
68
+ // A reservation without an ID has an ambiguous provider outcome. Never send again.
69
+ check(typeof value.message_id === 'string' && id.test(value.message_id));
70
+ if (value.status === 'failed') fail();
71
+ if (value.status === 'sent') {
72
+ check(validTime(value.sent_at) && (!review || Date.parse(value.sent_at) < Date.parse(message.delivery_expires_at)));
73
+ return Object.freeze({ message_id: value.message_id, sent_at: value.sent_at });
74
+ }
75
+ check(value.status === 'pending');
76
+ await wait(signal);
77
+ }
78
+ } catch { throw new AccelerateError('request_failed'); }
79
+ finally { bytes?.fill(0); }
80
+ }
81
+ async function findReviewReceipt(input, idempotencyKey, options = {}) {
82
+ try {
83
+ const context = authenticatedCheckoutContext(input);
84
+ check(typeof idempotencyKey === 'string' && /^accelerate-chat:[a-f0-9]{64}$/.test(idempotencyKey));
85
+ const signal = bounded(options.signal);
86
+ const destination = await run(signal, () => resolveDestination(context));
87
+ check(destination && destination.phone_number_id === sendingPhoneId && (destination.recipient_id === phoneDigits(context.phone_number) || digits.test(destination.recipient_id) && destination.verified_wa_id === destination.recipient_id));
88
+ const scope = { key: hash([context.customer_id, context.workspace_id, context.conversation_id, idempotencyKey]), sender: hash(sendingPhoneId), recipient: hash(destination.recipient_id) };
89
+ const value = await run(signal, () => journal.get(scope));
90
+ check(value && value.sender === scope.sender && value.recipient === scope.recipient && value.status === 'sent' && typeof value.message_id === 'string' && id.test(value.message_id) && validTime(value.sent_at));
91
+ return Object.freeze({ message_id: value.message_id, sent_at: value.sent_at });
92
+ } catch { throw new AccelerateError('request_failed'); }
93
+ }
94
+ return Object.freeze({ sendText: (context, message, options) => deliver(context, message, false, options), sendReview: (context, message) => deliver(context, message, true), findReviewReceipt });
95
+ }
96
+
97
+ /** A batch handler verifies the original bytes once and invokes onMessage in
98
+ * provider array order. Callback processing must run before history or AI and
99
+ * must deduplicate provider message IDs across whole-webhook retries. */
100
+ export function createWhatsAppWebhookBatchHandler({ verifyWebhook, resolveIdentity, sendingPhoneId, journal, onMessage } = {}) {
101
+ check(typeof verifyWebhook === 'function' && typeof resolveIdentity === 'function' && digits.test(sendingPhoneId) && typeof onMessage === 'function'); validateJournal(journal);
102
+ return async (request, rawBody) => {
103
+ let body;
104
+ try {
105
+ check(rawBody instanceof Uint8Array && rawBody.byteLength <= 262144 && await verifyWebhook(request, rawBody) === true);
106
+ body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(rawBody));
107
+ check(body?.object === 'whatsapp_business_account' && Array.isArray(body.entry) && body.entry.length > 0);
108
+ let retry = false;
109
+ for (const entry of body.entry) {
110
+ if (!Array.isArray(entry?.changes)) { retry = true; continue; }
111
+ for (const change of entry.changes) {
112
+ if (change?.field !== 'messages') continue;
113
+ const value = change.value;
114
+ if (value?.messaging_product !== 'whatsapp' || value.metadata?.phone_number_id !== sendingPhoneId) { retry = true; continue; }
115
+ if ((value.statuses !== undefined && !Array.isArray(value.statuses)) || (value.messages !== undefined && !Array.isArray(value.messages))) { retry = true; continue; }
116
+ const statuses = value.statuses ?? [], messages = value.messages ?? [];
117
+ // Separate arrays have no shared provider ordering. Keep each array's order.
118
+ for (const status of statuses) {
119
+ try {
120
+ if (!['sent', 'failed'].includes(status?.status)) continue;
121
+ check(id.test(status.id) && digits.test(status.recipient_id));
122
+ const time = fromEpoch(status.timestamp);
123
+ await journal.recordStatus({ sender: hash(sendingPhoneId), recipient: hash(status.recipient_id), message_id: safeId(status.id), status: status.status, timestamp: time });
124
+ } catch { retry = true; }
125
+ }
126
+ for (const message of messages) {
127
+ let result;
128
+ try {
129
+ if (message?.type !== 'text') continue;
130
+ // Never treat explicitly shared/group traffic as direct.
131
+ if (value.group_id != null || value.is_group === true || message.group_id != null || message.groupId != null || message.group != null || message.is_group === true || message.isGroup === true || message.context?.group_id != null) continue;
132
+ check(message && id.test(message.id) && digits.test(message.from) && typeof message.text?.body === 'string' && message.text.body.length <= 2000);
133
+ const sent_at = fromEpoch(message.timestamp), phone_number = `+${message.from}`;
134
+ const resolved = await resolveIdentity({ phone_number, wa_id: message.from, phone_number_id: sendingPhoneId });
135
+ if (resolved === null) continue;
136
+ const mapped = resolved && Object.hasOwn(resolved, 'context') ? resolved : undefined;
137
+ const context = authenticatedCheckoutContext(mapped ? mapped.context : resolved);
138
+ check(context.phone_number === phone_number || mapped?.verified_wa_id === message.from);
139
+ result = { authenticated: true, direct: true, context, message_id: safeId(message.id), text: message.text.body, sent_at };
140
+ if (message.context?.id !== undefined) { check(id.test(message.context.id)); result.reply_to_message_id = safeId(message.context.id); }
141
+ const handled = await onMessage(result);
142
+ if (handled?.retry === true) retry = true;
143
+ } catch { retry = true; }
144
+ finally { if (result) result.text = ''; if (message?.text && typeof message.text === 'object') message.text.body = ''; }
145
+ }
146
+ }
147
+ }
148
+ check(!retry);
149
+ return Object.freeze({ handled: true });
150
+ } catch { throw new AccelerateError('permission_denied'); }
151
+ finally {
152
+ if (Array.isArray(body?.entry)) for (const entry of body.entry) if (Array.isArray(entry?.changes)) for (const change of entry.changes) if (Array.isArray(change?.value?.messages)) for (const message of change.value.messages) if (message?.text && typeof message.text === 'object') message.text.body = '';
153
+ if (rawBody instanceof Uint8Array) rawBody.fill(0);
154
+ body = undefined;
155
+ }
156
+ };
157
+ }
158
+
159
+ /** Compatibility authenticator accepts exactly one message or status event.
160
+ * Use the batch handler for provider endpoints that can deliver batches. */
161
+ export function createWhatsAppWebhookAuthenticator(options = {}) {
162
+ return async (request, rawBody) => {
163
+ let message, count = 0;
164
+ // Batch compatibility cannot safely return multiple messages to a caller
165
+ // that only consumes one; reject before any journal or callback side effect.
166
+ let parsed;
167
+ try {
168
+ check(rawBody instanceof Uint8Array && rawBody.byteLength <= 262144 && await options.verifyWebhook(request, rawBody) === true);
169
+ parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(rawBody));
170
+ check(Array.isArray(parsed?.entry) && parsed.entry.length === 1 && Array.isArray(parsed.entry[0]?.changes) && parsed.entry[0].changes.length === 1);
171
+ const value = parsed.entry[0].changes[0]?.value;
172
+ check((value?.messages?.length ?? 0) + (value?.statuses?.length ?? 0) === 1);
173
+ const handler = createWhatsAppWebhookBatchHandler({ ...options, verifyWebhook: () => true, onMessage: event => { count++; message = { ...event }; } });
174
+ await handler(request, rawBody);
175
+ return count ? message : { kind: 'ignore' };
176
+ } catch { throw new AccelerateError('permission_denied'); }
177
+ finally {
178
+ if (Array.isArray(parsed?.entry)) for (const entry of parsed.entry) if (Array.isArray(entry?.changes)) for (const change of entry.changes) if (Array.isArray(change?.value?.messages)) for (const item of change.value.messages) if (item?.text && typeof item.text === 'object') item.text.body = '';
179
+ if (rawBody instanceof Uint8Array) rawBody.fill(0); parsed = undefined;
180
+ }
181
+ };
182
+ }