@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 +5 -3
- package/approved-extension-releases/0.0.0.1.json +2 -1
- package/approved-extension-releases/0.0.0.2.json +2 -1
- package/approved-extension.js +1 -1
- package/browser-connector.js +7 -2
- package/browser-provenance.js +17 -0
- package/browser-use-host.d.ts +11 -0
- package/browser-use-host.js +75 -18
- package/host-adapter.d.ts +5 -0
- package/host-adapter.js +12 -0
- package/index.d.ts +15 -1
- package/index.js +10 -2
- package/managed-mcp.js +5 -1
- package/package.json +21 -2
- package/partner-starter/README.md +4 -4
- package/partner-starter/package.json +1 -1
- package/partner-starter/synthetic.mjs +1 -1
- package/postgres-host.d.ts +7 -3
- package/postgres-whatsapp-journal.d.ts +8 -0
- package/postgres-whatsapp-journal.js +95 -0
- package/private-ingress.d.ts +4 -0
- package/private-ingress.js +22 -9
- package/starter/package.json +1 -1
- package/whatsapp-journal.d.ts +11 -0
- package/whatsapp-journal.js +78 -0
- package/whatsapp-messaging.d.ts +64 -0
- package/whatsapp-messaging.js +182 -0
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
# @weaccelerateinc/agent-sdk 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.
|
|
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.
|
|
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
|
|
package/approved-extension.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
|
|
3
|
-
const SDK_VERSION = '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';
|
package/browser-connector.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/browser-use-host.d.ts
CHANGED
|
@@ -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;
|
package/browser-use-host.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
73
|
-
const
|
|
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
|
-
|
|
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 ===
|
|
99
|
-
value.extensions
|
|
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:
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -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' },
|
package/postgres-host.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
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<
|
|
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
|
+
}
|
package/private-ingress.d.ts
CHANGED
|
@@ -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>;
|
package/private-ingress.js
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
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 {
|
|
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
|
}
|
package/starter/package.json
CHANGED
|
@@ -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
|
+
}
|