@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.
Files changed (43) hide show
  1. package/README.md +11 -7
  2. package/approved-extension-releases/0.0.0.1.json +4 -1
  3. package/approved-extension-releases/0.0.0.2.json +4 -1
  4. package/approved-extension.js +1 -1
  5. package/browser-connector.d.ts +22 -0
  6. package/browser-connector.js +48 -4
  7. package/browser-provenance.js +17 -0
  8. package/browser-use-host.d.ts +14 -0
  9. package/browser-use-host.js +77 -18
  10. package/checkout-diagnostics.d.ts +13 -2
  11. package/checkout-diagnostics.js +18 -4
  12. package/cli.js +4 -1
  13. package/host-adapter.d.ts +5 -0
  14. package/host-adapter.js +21 -5
  15. package/index.d.ts +63 -5
  16. package/index.js +84 -13
  17. package/managed-mcp.d.ts +9 -3
  18. package/managed-mcp.js +56 -7
  19. package/package.json +21 -2
  20. package/partner-starter/.env.example +4 -2
  21. package/partner-starter/README.md +19 -6
  22. package/partner-starter/package.json +1 -1
  23. package/partner-starter/runtime.mjs +3 -2
  24. package/partner-starter/smoke.mjs +10 -5
  25. package/partner-starter/synthetic.mjs +37 -7
  26. package/partner-starter/tests/starter.test.mjs +58 -8
  27. package/partner-starter/trusted-context.example.mjs +3 -0
  28. package/partner.js +4 -0
  29. package/postgres-host.d.ts +7 -3
  30. package/postgres-whatsapp-journal.d.ts +8 -0
  31. package/postgres-whatsapp-journal.js +95 -0
  32. package/private-chat.js +10 -3
  33. package/private-ingress.d.ts +4 -0
  34. package/private-ingress.js +22 -9
  35. package/starter/README.md +1 -7
  36. package/starter/package.json +1 -1
  37. package/starter/runtime.mjs +7 -2
  38. package/starter/synthetic.mjs +1 -1
  39. package/starter/tests/starter.test.mjs +7 -39
  40. package/whatsapp-journal.d.ts +11 -0
  41. package/whatsapp-journal.js +78 -0
  42. package/whatsapp-messaging.d.ts +64 -0
  43. 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 === 'ready_for_submit' && checkout.purpose === 'purchase'), 'invalid_response');
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);
@@ -23,6 +23,10 @@ export interface PrivateCheckoutIngress<Request = unknown> {
23
23
  * and transient parsed text. Ordinary traffic is admitted only through the
24
24
  * callback as a new minimal event, never by passing the original envelope. */
25
25
  handle(request: Request, rawBody: Uint8Array): Promise<Readonly<{ handled: boolean; retry?: true }>>;
26
+ /** Trusted server-only entrypoint for a provider-authenticated direct message.
27
+ * Never expose as a model tool. Caller must invoke before history/AI, and must
28
+ * retry the webhook when retry is true. Clears message.text on every outcome. */
29
+ handleAuthenticated(message: AuthenticatedPrivateMessage): Promise<Readonly<{ handled: boolean; retry?: true }>>;
26
30
  /** Safely recover pending lifecycle/prompt/progress deliveries only. Never
27
31
  * repeats fill or submit. Requires authenticated context from the host. */
28
32
  drain(context: ManagedCheckoutContext): Promise<number>;
@@ -5,6 +5,7 @@ import { deliverPrivateCheckoutReview } from './private-chat.js';
5
5
 
6
6
  const MESSAGE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}$/;
7
7
  const PENDING = new Set(['identity_required', 'otp_required', 'selection_required', 'cvv_required', 'approval_required', 'review_delivery_required']);
8
+ const BEFORE_FILL = new Set(['identity_required', 'otp_required', 'selection_required', 'cvv_required']);
8
9
  const TERMINAL = new Set(['cancelled', 'expired', 'failed', 'outcome_unknown', 'submit_attempted']);
9
10
  const check = condition => { if (!condition) throw new AccelerateError('invalid_input'); };
10
11
  function digest(value) { return createHash('sha256').update(value).digest('hex'); }
@@ -75,15 +76,9 @@ export function createPrivateCheckoutIngress({ authenticate, host, privateChat,
75
76
  await saveAndDeliver(trusted, owned, response, `event-${digest(item.id)}`);
76
77
  });
77
78
  });
78
- return Object.freeze({ drain,
79
- async handle(request, rawBody) {
80
- let verified, context, messageId;
79
+ async function handleAuthenticated(verified) {
80
+ let context, messageId;
81
81
  try {
82
- check(rawBody instanceof Uint8Array);
83
- verified = await authenticate(request, rawBody);
84
- // Withhold the original envelope even for admitted ordinary messages.
85
- // Only a new minimal event can cross the AI/history boundary below.
86
- clearBody(rawBody);
87
82
  if (verified?.kind === 'ignore') return Object.freeze({ handled: true });
88
83
  check(verified?.authenticated === true && verified.direct === true);
89
84
  context = authenticatedCheckoutContext(verified.context); messageId = verified.message_id;
@@ -106,6 +101,13 @@ export function createPrivateCheckoutIngress({ authenticate, host, privateChat,
106
101
  if (isStatus) response = await privateChat.chat.get(owned.checkout_id, binding);
107
102
  else if (isCancel) response = await privateChat.chat.cancel(owned.checkout_id, binding);
108
103
  else {
104
+ // Private verification can fill in the same service call. Recheck
105
+ // after the wait while the original reply ID is durably pending.
106
+ // Same-ID recovery must pass this fresh check again; status and
107
+ // cancel remain available without inspecting the payment page.
108
+ if (browserConnector && (BEFORE_FILL.has(owned.previous_stage) ||
109
+ owned.previous_stage === 'approval_required' && owned.checkout?.approval_policy !== 'after_fill'))
110
+ await browserConnector.assertPaymentReady(context, { checkout_id: owned.checkout_id });
109
111
  const input = { ...binding, message_id: messageId, text: text || '[non-text reply]', ...metadata };
110
112
  try { response = await privateChat.chat.reply(owned.checkout_id, input); } finally { input.text = ''; }
111
113
  }
@@ -126,7 +128,18 @@ export function createPrivateCheckoutIngress({ authenticate, host, privateChat,
126
128
  }); } catch {}
127
129
  }
128
130
  return Object.freeze({ handled: true, retry: true });
129
- } finally { clearBody(rawBody); clearReply(verified); }
131
+ } finally { clearReply(verified); }
132
+ }
133
+ return Object.freeze({ drain, handleAuthenticated,
134
+ async handle(request, rawBody) {
135
+ let verified;
136
+ try {
137
+ check(rawBody instanceof Uint8Array);
138
+ verified = await authenticate(request, rawBody);
139
+ clearBody(rawBody);
140
+ return await handleAuthenticated(verified);
141
+ } catch { return Object.freeze({ handled: true, retry: true }); }
142
+ finally { clearBody(rawBody); clearReply(verified); }
130
143
  },
131
144
  });
132
145
  }
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
- For an explicitly enabled purchase integration, the same private image asks for simple approve/decline and grants a full five-minute decision window from the actual provider receipt. `ready_for_submit` is insufficient: wait for `purchase_approved:true`, then use a separate explicit owner-authorized submit operation before the returned deadline. After qualification, purchase mode requires both of these backend settings:
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
 
@@ -12,7 +12,7 @@
12
12
  "test": "node --test tests/*.test.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@weaccelerateinc/agent-sdk": "0.10.3",
15
+ "@weaccelerateinc/agent-sdk": "0.12.0",
16
16
  "@modelcontextprotocol/server": "2.3.1"
17
17
  },
18
18
  "devDependencies": {
@@ -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
- enableSubmit && purpose !== 'purchase') throw Error('Unsupported server purchase policy.');
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' &&
@@ -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 (!['sandbox_fill_only', 'purchase'].includes(purpose)) invalid();
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
- const purchaseTools = new Map();
122
- await configureStarter({ registerTool(name, _config, callback) { purchaseTools.set(name, callback); } },
123
- { ACCELERATE_STARTER_PURPOSE: 'purchase', ACCELERATE_ENABLE_PURCHASE_SUBMIT: 'true' });
124
- assert.equal(purchaseTools.has('accelerate_checkout_submit'), true);
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 }>;