@weaccelerateinc/agent-sdk 0.10.3 → 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 +11 -7
- package/approved-extension-releases/0.0.0.1.json +4 -1
- package/approved-extension-releases/0.0.0.2.json +4 -1
- package/approved-extension.js +1 -1
- package/browser-connector.d.ts +22 -0
- package/browser-connector.js +48 -4
- package/browser-provenance.js +17 -0
- package/browser-use-host.d.ts +14 -0
- package/browser-use-host.js +77 -18
- package/checkout-diagnostics.d.ts +13 -2
- package/checkout-diagnostics.js +18 -4
- package/cli.js +4 -1
- package/host-adapter.d.ts +5 -0
- package/host-adapter.js +21 -5
- package/index.d.ts +63 -5
- package/index.js +84 -13
- package/managed-mcp.d.ts +9 -3
- package/managed-mcp.js +56 -7
- package/package.json +21 -2
- package/partner-starter/.env.example +4 -2
- package/partner-starter/README.md +19 -6
- package/partner-starter/package.json +1 -1
- package/partner-starter/runtime.mjs +3 -2
- package/partner-starter/smoke.mjs +10 -5
- package/partner-starter/synthetic.mjs +37 -7
- package/partner-starter/tests/starter.test.mjs +58 -8
- package/partner-starter/trusted-context.example.mjs +3 -0
- package/partner.js +4 -0
- package/postgres-host.d.ts +7 -3
- package/postgres-whatsapp-journal.d.ts +8 -0
- package/postgres-whatsapp-journal.js +95 -0
- package/private-chat.js +10 -3
- package/private-ingress.d.ts +4 -0
- package/private-ingress.js +22 -9
- package/starter/README.md +1 -7
- package/starter/package.json +1 -1
- package/starter/runtime.mjs +7 -2
- package/starter/synthetic.mjs +1 -1
- package/starter/tests/starter.test.mjs +7 -39
- 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
|
@@ -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-chat.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AccelerateClient, AccelerateError } from './index.js';
|
|
2
|
+
import { validateCheckoutReconciliation } from './checkout-diagnostics.js';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
4
|
|
|
4
5
|
const idPattern = /^mcs_[A-Za-z0-9_-]{43}$/;
|
|
@@ -7,7 +8,7 @@ const identityPattern = /^[A-Za-z0-9_.:-]{1,500}$/;
|
|
|
7
8
|
// This limit applies only to the private review image, not JSON responses.
|
|
8
9
|
const maximumReviewImageBytes = 8 * 1024 * 1024;
|
|
9
10
|
const stages = new Set(['not_started', 'identity_required', 'otp_required', 'selection_required', 'preparing_checkout', 'approval_required', 'cvv_required', 'verified', 'review_delivery_required', 'review_delivered', 'approved_for_submit', 'closed']);
|
|
10
|
-
const statuses = new Set(['wallet_required', 'selection_ready', 'details_ready', 'approval_required', 'approved', 'ready_for_submit', 'cancelled', 'expired', 'failed', 'submit_attempted', 'outcome_unknown']);
|
|
11
|
+
const statuses = new Set(['wallet_required', 'selection_ready', 'details_ready', 'approval_required', 'approved', 'ready_for_submit', 'final_click_armed', 'cancelled', 'expired', 'failed', 'submit_attempted', 'outcome_unknown']);
|
|
11
12
|
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
12
13
|
const check = (value, code = 'invalid_input') => { if (!value) throw new AccelerateError(code); };
|
|
13
14
|
function keys(value, allowed, required = allowed, code = 'invalid_input') {
|
|
@@ -37,14 +38,20 @@ function quote(value, revision = false, afterFill = false) {
|
|
|
37
38
|
}
|
|
38
39
|
function state(value, expectedId) {
|
|
39
40
|
keys(value, ['checkout', 'stage', 'handled', 'duplicate', 'cards', 'addresses', 'identity_field', 'approval', 'selection_error', 'review'], ['checkout', 'stage', 'handled', 'duplicate', 'cards', 'addresses'], 'invalid_response');
|
|
40
|
-
keys(value.checkout, ['id', 'status', 'revision', 'expires_at', 'card', 'address', 'quote', 'submit_expires_at', 'purpose', 'submit_allowed', 'approval_policy', 'purchase_approved', 'fill_after_verification', 'retry_safe', 'payment_confirmed'],
|
|
41
|
+
keys(value.checkout, ['id', 'status', 'revision', 'expires_at', 'card', 'address', 'quote', 'submit_expires_at', 'purpose', 'submit_allowed', 'approval_policy', 'purchase_approved', 'fill_after_verification', 'reconciliation', 'final_click_actor', 'submit_actor', 'retry_safe', 'payment_confirmed'],
|
|
41
42
|
['id', 'status', 'revision', 'expires_at', 'retry_safe', 'payment_confirmed'], 'invalid_response');
|
|
42
43
|
const checkout = value.checkout;
|
|
44
|
+
if (checkout.reconciliation !== undefined) {
|
|
45
|
+
try { validateCheckoutReconciliation(checkout.reconciliation, checkout.status); } catch { check(false, 'invalid_response'); }
|
|
46
|
+
}
|
|
43
47
|
if (checkout.purpose !== undefined || checkout.submit_allowed !== undefined) check(
|
|
44
48
|
['purchase', 'sandbox_source_comparison', 'sandbox_fill_only'].includes(checkout.purpose) &&
|
|
45
49
|
checkout.submit_allowed === (checkout.purpose === 'purchase'), 'invalid_response');
|
|
46
50
|
check(checkout.id === expectedId && statuses.has(checkout.status) && checkout.retry_safe === false && checkout.payment_confirmed === false, 'invalid_response');
|
|
47
|
-
if (checkout.approval_policy !== undefined || checkout.purchase_approved !== undefined) check(checkout.approval_policy === 'after_fill' && typeof checkout.purchase_approved === 'boolean' && (!checkout.purchase_approved || checkout.status
|
|
51
|
+
if (checkout.approval_policy !== undefined || checkout.purchase_approved !== undefined) check(checkout.approval_policy === 'after_fill' && typeof checkout.purchase_approved === 'boolean' && (!checkout.purchase_approved || ['ready_for_submit', 'final_click_armed'].includes(checkout.status) && checkout.purpose === 'purchase'), 'invalid_response');
|
|
52
|
+
if (checkout.final_click_actor !== undefined) check(checkout.final_click_actor === 'customer' && checkout.purpose === 'purchase', 'invalid_response');
|
|
53
|
+
if (checkout.submit_actor !== undefined) check(checkout.submit_actor === 'customer' && checkout.final_click_actor === 'customer' && ['submit_attempted', 'outcome_unknown'].includes(checkout.status), 'invalid_response');
|
|
54
|
+
if (checkout.status === 'submit_attempted' && checkout.purpose === 'purchase') check(checkout.submit_actor === 'customer', 'invalid_response');
|
|
48
55
|
if (checkout.fill_after_verification !== undefined) check(checkout.fill_after_verification === true && checkout.approval_policy === 'after_fill' &&
|
|
49
56
|
['purchase', 'sandbox_fill_only'].includes(checkout.purpose), 'invalid_response');
|
|
50
57
|
integer(checkout.revision, 0); date(checkout.expires_at); if (checkout.submit_expires_at !== undefined) date(checkout.submit_expires_at);
|
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/README.md
CHANGED
|
@@ -72,14 +72,8 @@ The current safe capture is qualified only for the configured sandbox merchant's
|
|
|
72
72
|
5. Fill the verified revision once. The private handler delivers the actual safe form image and exact total: PAN stars plus last four, visible expiry and CVV stars.
|
|
73
73
|
6. For the default fill-only checkout, require successful informational image delivery, then cancel the same checkout. Submit is absent and forbidden. Do not inspect the populated browser.
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
This managed-browser starter is fill-only. Accelerate never sends the final payment click, and its private Kernel browser cannot hand control to the customer without breaking custody, so `purpose:purchase` and the submit flag are rejected. For purchases use the existing-browser partner starter: the customer's own backend browser performs the single physical click.
|
|
76
76
|
|
|
77
|
-
```dotenv
|
|
78
|
-
ACCELERATE_STARTER_PURPOSE=purchase
|
|
79
|
-
ACCELERATE_ENABLE_PURCHASE_SUBMIT=true
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
The model cannot enable these settings. `purpose:purchase` without the submit flag still exposes no submit tool.
|
|
83
77
|
|
|
84
78
|
## Explicit legacy compatibility
|
|
85
79
|
|
package/starter/package.json
CHANGED
package/starter/runtime.mjs
CHANGED
|
@@ -9,10 +9,15 @@ const requiredHooks = ['resolveContext', 'withOwnedCheckout', 'withCheckoutCreat
|
|
|
9
9
|
export async function configureStarter(server, env = process.env) {
|
|
10
10
|
const mode = env.ACCELERATE_STARTER_MODE ?? 'synthetic';
|
|
11
11
|
const purpose = env.ACCELERATE_STARTER_PURPOSE ?? 'sandbox_fill_only';
|
|
12
|
-
const enableSubmit = env.ACCELERATE_ENABLE_PURCHASE_SUBMIT === 'true';
|
|
13
12
|
if (!['sandbox_fill_only', 'purchase'].includes(purpose) ||
|
|
14
13
|
env.ACCELERATE_ENABLE_PURCHASE_SUBMIT !== undefined && !['true', 'false'].includes(env.ACCELERATE_ENABLE_PURCHASE_SUBMIT) ||
|
|
15
|
-
|
|
14
|
+
env.ACCELERATE_ENABLE_PURCHASE_SUBMIT === 'true' && purpose !== 'purchase') throw Error('Unsupported server purchase policy.');
|
|
15
|
+
// Accelerate never sends the final payment click, and its private managed browser cannot hand control to the
|
|
16
|
+
// customer without breaking custody. This starter is therefore fill-only; purchases use the existing-browser partner starter.
|
|
17
|
+
if (purpose !== 'sandbox_fill_only' || env.ACCELERATE_ENABLE_PURCHASE_SUBMIT === 'true') {
|
|
18
|
+
throw Error('The managed-browser starter is fill-only. Use the existing-browser partner starter for purchases.');
|
|
19
|
+
}
|
|
20
|
+
const enableSubmit = false;
|
|
16
21
|
let host, privateChat;
|
|
17
22
|
const matches = (state, event) => state.checkout.id === event.checkout.id && state.checkout.revision === event.checkout.revision &&
|
|
18
23
|
state.checkout.purpose === purpose && state.checkout.approval_policy === 'after_fill' &&
|
package/starter/synthetic.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const png = () => Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3]);
|
|
|
13
13
|
/** In-process fixtures only: no network, source, browser, raw OTP or security code.
|
|
14
14
|
* Private verification is simulated; safe image delivery uses the real SDK. */
|
|
15
15
|
export function createSyntheticHost({ purpose = 'sandbox_fill_only', deliverReview } = {}) {
|
|
16
|
-
if (
|
|
16
|
+
if (purpose !== 'sandbox_fill_only') invalid();
|
|
17
17
|
const records = new Map(), owners = new Map(), events = new Set(), messages = new Map();
|
|
18
18
|
const calls = { create: 0, browser: 0, payment_requested: 0, approval_requested: 0,
|
|
19
19
|
private_verification: 0, fill: 0, review: 0, review_sent: 0, review_acknowledged: 0,
|
|
@@ -74,41 +74,6 @@ test('review delivery failure retains the same event and retries image delivery
|
|
|
74
74
|
assert.equal(host.counters().submit, 0);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
test('purchase profile waits the full five-minute receipt-bound window and requires a distinct final submit', async t => {
|
|
78
|
-
const originalNow = Date.now; let now = originalNow(); Date.now = () => now;
|
|
79
|
-
t.after(() => { Date.now = originalNow; });
|
|
80
|
-
const { host, checkout, dispatch, binding, reachFill } = await prepared('purchase');
|
|
81
|
-
const filled = await reachFill();
|
|
82
|
-
await assert.rejects(host.privateChat.chat.reply(checkout.id, { ...binding, message_id: 'too-early', text: 'approve', sent_at: new Date(now).toISOString() }));
|
|
83
|
-
await assert.rejects(host.client.checkouts.submit(checkout.id, { revision: filled.revision }), { code: 'submit_unavailable' });
|
|
84
|
-
now += 45000; await dispatch('review_ready', filled);
|
|
85
|
-
const reviewed = await host.privateChat.chat.get(checkout.id, binding);
|
|
86
|
-
assert.equal(reviewed.stage, 'approval_required'); assert.equal(Date.parse(reviewed.review.expires_at), now + 300000);
|
|
87
|
-
now += 299999;
|
|
88
|
-
const approved = await host.privateChat.chat.reply(checkout.id, { ...binding, message_id: 'private-decision', text: 'approve', sent_at: new Date(now).toISOString() });
|
|
89
|
-
assert.equal(approved.stage, 'approved_for_submit'); assert.equal(approved.checkout.purchase_approved, true);
|
|
90
|
-
assert.equal(host.counters().submit, 1); // Only the deliberately denied early request.
|
|
91
|
-
assert.equal((await host.client.checkouts.get(checkout.id)).status, 'ready_for_submit');
|
|
92
|
-
const submitted = await host.client.checkouts.submit(checkout.id, { revision: filled.revision });
|
|
93
|
-
assert.equal(submitted.status, 'submit_attempted'); assert.equal(submitted.payment_confirmed, false);
|
|
94
|
-
await assert.rejects(host.client.checkouts.submit(checkout.id, { revision: filled.revision }), { code: 'submit_unavailable' });
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test('purchase decision at five minutes is expired, and a private decline cancels without submission', async t => {
|
|
98
|
-
const originalNow = Date.now; let now = originalNow(); Date.now = () => now;
|
|
99
|
-
t.after(() => { Date.now = originalNow; });
|
|
100
|
-
const expired = await prepared('purchase'), filled = await expired.reachFill();
|
|
101
|
-
await expired.dispatch('review_ready', filled); now += 300000;
|
|
102
|
-
await assert.rejects(expired.host.privateChat.chat.reply(expired.checkout.id, { ...expired.binding,
|
|
103
|
-
message_id: 'late-decision', text: 'approve', sent_at: new Date(now).toISOString() }));
|
|
104
|
-
assert.equal(expired.host.counters().purchase_decision, 0); assert.equal(expired.host.counters().submit, 0);
|
|
105
|
-
const declined = await prepared('purchase'), next = await declined.reachFill();
|
|
106
|
-
await declined.dispatch('review_ready', next); now++;
|
|
107
|
-
const closed = await declined.host.privateChat.chat.reply(declined.checkout.id, { ...declined.binding,
|
|
108
|
-
message_id: 'private-decline', text: 'decline', sent_at: new Date(now).toISOString() });
|
|
109
|
-
assert.equal(closed.checkout.status, 'cancelled'); assert.equal(declined.host.counters().submit, 0);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
77
|
test('starter registers the supported managed review profile and never accepts model purchase policy', async () => {
|
|
113
78
|
const tools = new Map();
|
|
114
79
|
const configured = await configureStarter({ registerTool(name, config, callback) { tools.set(name, { config, callback }); } }, {});
|
|
@@ -118,10 +83,13 @@ test('starter registers the supported managed review profile and never accepts m
|
|
|
118
83
|
assert.equal(created.ok, true); assert.equal(created.checkout.approval_policy, 'after_fill');
|
|
119
84
|
const rejected = await call('accelerate_checkout_start', { start_url: syntheticCheckoutUrl, purpose: 'purchase' });
|
|
120
85
|
assert.equal(rejected.ok, false); assert.equal(rejected.error.code, 'invalid_input');
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
{ ACCELERATE_STARTER_PURPOSE: 'purchase', ACCELERATE_ENABLE_PURCHASE_SUBMIT: '
|
|
124
|
-
|
|
86
|
+
// Kernel custody is fill-only: Accelerate never clicks and cannot hand its private browser to the customer.
|
|
87
|
+
for (const env of [{ ACCELERATE_STARTER_PURPOSE: 'purchase', ACCELERATE_ENABLE_PURCHASE_SUBMIT: 'true' }, { ACCELERATE_STARTER_PURPOSE: 'purchase' },
|
|
88
|
+
{ ACCELERATE_STARTER_PURPOSE: 'purchase', ACCELERATE_ENABLE_PURCHASE_SUBMIT: 'false' }]) {
|
|
89
|
+
await assert.rejects(configureStarter({ registerTool() { assert.fail('Kernel purchase exposed tools.'); } }, env), /fill-only/);
|
|
90
|
+
await assert.rejects(configureStarter({ registerTool() { assert.fail('Kernel purchase exposed tools.'); } }, { ...env, ACCELERATE_STARTER_MODE: 'live' }), /fill-only/);
|
|
91
|
+
}
|
|
92
|
+
assert.throws(() => createSyntheticHost({ purpose: 'purchase' }));
|
|
125
93
|
for (const env of [{ ACCELERATE_STARTER_PURPOSE: 'unknown' }, { ACCELERATE_ENABLE_PURCHASE_SUBMIT: 'true' },
|
|
126
94
|
{ ACCELERATE_ENABLE_PURCHASE_SUBMIT: '1' }]) await assert.rejects(configureStarter({ registerTool() { assert.fail('Invalid policy exposed tools.'); } }, env));
|
|
127
95
|
});
|
|
@@ -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 }>;
|