@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
package/managed-mcp.js CHANGED
@@ -1,6 +1,7 @@
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
- import { checkoutDiagnosticsJsonSchema, validateCheckoutDiagnostics } from './checkout-diagnostics.js';
4
+ import { checkoutDiagnosticsJsonSchema, validateCheckoutDiagnostics, validateCheckoutReconciliation } from './checkout-diagnostics.js';
4
5
  import { createHash } from 'node:crypto';
5
6
 
6
7
  const ID = /^mcs_[A-Za-z0-9_-]{43}$/;
@@ -8,9 +9,12 @@ const OWNER = /^[A-Za-z0-9_.:-]{1,500}$/;
8
9
  const PHONE = /^\+[1-9]\d{6,14}$/;
9
10
  const CONTEXT_KEYS = ['customer_id', 'workspace_id', 'conversation_id', 'phone_number'];
10
11
  const STATUSES = ['wallet_required', 'selection_ready', 'details_ready', 'approval_required', 'approved',
11
- 'ready_for_submit', 'cancelled', 'expired', 'failed', 'submit_attempted', 'outcome_unknown'];
12
+ 'ready_for_submit', 'final_click_armed', 'cancelled', 'expired', 'failed', 'submit_attempted', 'outcome_unknown'];
12
13
  const NEXT_ACTIONS = ['check_configuration', 'check_request', 'check_status', 'complete_wallet',
13
14
  'complete_verification', 'refresh_quote', 'refresh_revision', 'reconcile_order', 'stop'];
15
+ // Errors that prove the final-click arm was not created. Everything else after an arm request is an unknown outcome.
16
+ const PRE_ARM_REFUSALS = new Set(['approval_required', 'revision_conflict', 'checkout_expired', 'checkout_closed', 'customer_click_required',
17
+ 'permission_denied', 'submit_unavailable', 'capability_disabled']);
14
18
  const object = value => value !== null && typeof value === 'object' && !Array.isArray(value) &&
15
19
  [Object.prototype, null].includes(Object.getPrototypeOf(value));
16
20
  const check = (value, code = 'invalid_input') => { if (!value) throw new AccelerateError(code); };
@@ -59,6 +63,7 @@ const checkoutSchema = schemaObject({
59
63
  expires_at: timestampSchema, purpose: { type: 'string', enum: ['purchase', 'sandbox_source_comparison', 'sandbox_fill_only'] },
60
64
  approval_policy: { const: 'after_fill' }, purchase_approved: { type: 'boolean' },
61
65
  fill_after_verification: { const: true },
66
+ final_click_actor: { const: 'customer' }, submit_actor: { const: 'customer' },
62
67
  submit_allowed: { type: 'boolean' }, retry_safe: { const: false }, payment_confirmed: { const: false },
63
68
  card: schemaObject({ last4: { anyOf: [{ type: 'string', pattern: '^\\d{4}$' }, { type: 'null' }] }, brand: nullableText(40) }),
64
69
  address: schemaObject({ label: nullableText(400), city: nullableText(128), state: nullableText(128), postal_code_mask: nullableText(128) }, ['label']),
@@ -107,7 +112,7 @@ export const managedCheckoutToolDefinitions = freeze([
107
112
  description: 'Cancel this exact owned checkout and request private browser cleanup. Cancellation does not reverse an attempted order or prove that no purchase occurred. Keep uncertain-payment fences until independently reconciled.',
108
113
  inputSchema: idInput, outputSchema: outputSchema('checkout'), destructive: true },
109
114
  { name: 'accelerate_checkout_submit', title: 'Explicitly submit approved purchase',
110
- description: 'Explicit final purchase decision for this exact owned revision while its returned ready claim remains valid. Calls the one-use private final-submit operation once. Never automatically invoke after fill or retry after uncertainty. The default review profile requires the customer’s private post-fill approval first. submit_attempted is not order or payment confirmation. Available only when the server explicitly enables purchase submission.',
115
+ description: 'Explicit final purchase decision for this exact owned revision while its returned ready claim remains valid. Accelerate never clicks: this arms one short-lived click, your own backend performs the single physical final click in the customer’s existing browser, then Accelerate observes the outcome once. Never automatically invoke after fill or retry after uncertainty. The default review profile requires the customer’s private post-fill approval first. submit_attempted is not order or payment confirmation. Available only when the server explicitly enables purchase submission.',
111
116
  inputSchema: revisionInput, outputSchema: outputSchema('checkout'), destructive: true, submitOnly: true },
112
117
  ].map(value => ({ ...value, annotations: { readOnlyHint: value.readOnly === true, idempotentHint: value.readOnly === true,
113
118
  destructiveHint: value.destructive === true, openWorldHint: true } })));
@@ -126,7 +131,7 @@ function safeText(value, max = 1000, multiline = false) {
126
131
  function timestamp(value) { check(typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/u.test(value) && Number.isFinite(Date.parse(value)), 'invalid_response'); return value; }
127
132
  function projectCheckout(value) {
128
133
  shape(value, ['id', 'status', 'revision', 'expires_at', 'retry_safe', 'payment_confirmed'],
129
- ['purpose', 'submit_allowed', 'approval_policy', 'purchase_approved', 'fill_after_verification', 'card', 'address', 'quote', 'submit_expires_at', 'diagnostics'], 'invalid_response');
134
+ ['purpose', 'submit_allowed', 'approval_policy', 'purchase_approved', 'fill_after_verification', 'card', 'address', 'quote', 'submit_expires_at', 'diagnostics', 'reconciliation', 'final_click_actor', 'submit_actor'], 'invalid_response');
130
135
  check(typeof value.id === 'string' && ID.test(value.id) && STATUSES.includes(value.status) && Number.isSafeInteger(value.revision) &&
131
136
  value.revision >= 0 && value.revision <= 2147483647 && value.retry_safe === false && value.payment_confirmed === false, 'invalid_response');
132
137
  const result = { id: value.id, status: value.status, revision: value.revision, expires_at: timestamp(value.expires_at), retry_safe: false, payment_confirmed: false };
@@ -135,7 +140,7 @@ function projectCheckout(value) {
135
140
  result.purpose = value.purpose; result.submit_allowed = value.submit_allowed;
136
141
  }
137
142
  if (value.approval_policy !== undefined || value.purchase_approved !== undefined) {
138
- check(value.approval_policy === 'after_fill' && typeof value.purchase_approved === 'boolean' && (!value.purchase_approved || value.status === 'ready_for_submit' && value.purpose === 'purchase'), 'invalid_response');
143
+ check(value.approval_policy === 'after_fill' && typeof value.purchase_approved === 'boolean' && (!value.purchase_approved || ['ready_for_submit', 'final_click_armed'].includes(value.status) && value.purpose === 'purchase'), 'invalid_response');
139
144
  result.approval_policy = value.approval_policy; result.purchase_approved = value.purchase_approved;
140
145
  }
141
146
  if (value.fill_after_verification !== undefined) {
@@ -143,6 +148,17 @@ function projectCheckout(value) {
143
148
  ['purchase', 'sandbox_fill_only'].includes(value.purpose), 'invalid_response');
144
149
  result.fill_after_verification = true;
145
150
  }
151
+ // The customer's own backend/browser performs any final click; Accelerate never does.
152
+ if (value.final_click_actor !== undefined) {
153
+ check(value.final_click_actor === 'customer' && value.purpose === 'purchase', 'invalid_response');
154
+ result.final_click_actor = 'customer';
155
+ }
156
+ if (value.submit_actor !== undefined) {
157
+ check(value.submit_actor === 'customer' && value.final_click_actor === 'customer' && ['submit_attempted', 'outcome_unknown'].includes(value.status), 'invalid_response');
158
+ result.submit_actor = 'customer';
159
+ }
160
+ // An attested attempt names the customer; an unknown outcome cannot assert that a click happened.
161
+ if (value.status === 'submit_attempted' && value.purpose === 'purchase') check(value.submit_actor === 'customer', 'invalid_response');
146
162
  if (value.card !== undefined) {
147
163
  shape(value.card, ['last4', 'brand'], [], 'invalid_response');
148
164
  check(value.card.last4 === null || typeof value.card.last4 === 'string' && /^\d{4}$/u.test(value.card.last4), 'invalid_response');
@@ -164,6 +180,8 @@ function projectCheckout(value) {
164
180
  result.quote = { checkout_origin: value.quote.checkout_origin, amount_cents: value.quote.amount_cents, currency: 'USD' };
165
181
  }
166
182
  if (value.submit_expires_at !== undefined) result.submit_expires_at = timestamp(value.submit_expires_at);
183
+ // A backend record: checked here so a reconciled checkout stays readable, and never projected to the agent.
184
+ if (value.reconciliation !== undefined) { try { validateCheckoutReconciliation(value.reconciliation, value.status); } catch { check(false, 'invalid_response'); } }
167
185
  if (value.diagnostics !== undefined) result.diagnostics = JSON.parse(JSON.stringify(validateCheckoutDiagnostics(value.diagnostics, result)));
168
186
  return result;
169
187
  }
@@ -243,6 +261,9 @@ export function registerManagedCheckoutTools(server, { client = AccelerateClient
243
261
  const { verificationTiming, approvalPolicy, fillAfterVerification = false } = flowProfile({ profile, verificationTiming: requestedTiming, approvalPolicy: requestedApproval });
244
262
  check(server && typeof server.registerTool === 'function' && client?.checkouts &&
245
263
  ['create', 'get', 'browser', 'applyDetails', 'requestApproval', 'fill', 'submit', 'cancel'].every(key => typeof client.checkouts[key] === 'function') &&
264
+ (!enableSubmit || ['armFinalClick', 'completeFinalClick'].every(key => typeof client.checkouts[key] === 'function')) &&
265
+ // Purchases are never clicked by Accelerate or by Accelerate's private Kernel browser: only the customer's own browser host can click.
266
+ (purpose !== 'purchase' || browserMode === 'customer_backend_trusted' && ['prepareFinalClick', 'performFinalClick'].every(key => typeof browserConnector?.[key] === 'function')) &&
246
267
  [resolveContext, withOwnedCheckout, withCheckoutCreation, dispatchCheckoutEvent].every(value => typeof value === 'function') &&
247
268
  ['purchase', 'sandbox_fill_only'].includes(purpose) && typeof enableSubmit === 'boolean' && (!enableSubmit || purpose === 'purchase') &&
248
269
  ['on_start', 'on_payment'].includes(verificationTiming) && ['before_fill', 'after_fill'].includes(approvalPolicy) &&
@@ -251,6 +272,7 @@ export function registerManagedCheckoutTools(server, { client = AccelerateClient
251
272
  (browserConnector === undefined || browserMode === 'customer_backend_trusted' && browserConnector?.contract === 'accelerate_existing_browser_v1' &&
252
273
  ['prepareAttachment', 'commitAttachment', 'assertPaymentReady', 'confirmCleanup'].every(key => typeof browserConnector[key] === 'function')));
253
274
  const requirePolicy = checkout => {
275
+ check(purpose !== 'purchase' || checkout.final_click_actor === 'customer', 'invalid_response');
254
276
  check(approvalPolicy === 'after_fill' ? checkout.approval_policy === 'after_fill' && typeof checkout.purchase_approved === 'boolean'
255
277
  : checkout.approval_policy === undefined && checkout.purchase_approved === undefined, 'invalid_response');
256
278
  return checkout;
@@ -312,7 +334,10 @@ export function registerManagedCheckoutTools(server, { client = AccelerateClient
312
334
  requireAdmission();
313
335
  const browser = attachment ? attachment.browser : scope.browser;
314
336
  if (browserMode === 'customer_backend_trusted') {
315
- 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
+ }
316
341
  const valid = browser.provider === 'browser_use'
317
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)
318
343
  : browser.provider === 'connector' && typeof browser.connector_id === 'string' && /^[a-z][a-z0-9_-]{0,63}$/u.test(browser.connector_id) &&
@@ -322,6 +347,7 @@ export function registerManagedCheckoutTools(server, { client = AccelerateClient
322
347
  creationStarted = true;
323
348
  const created = await client.checkouts.create({ ...context, start_url: input.start_url,
324
349
  idempotency_key: scope.idempotency_key, interaction_mode: 'chat', purpose,
350
+ ...(purpose === 'purchase' ? { final_click_actor: 'customer' } : {}),
325
351
  ...(browserMode === 'customer_backend_trusted' ? { browser: { ...browser } } : {}),
326
352
  ...(attachment?.browser_resource ?? scope.browser_resource ? { browser_resource: attachment?.browser_resource ?? scope.browser_resource } : {}),
327
353
  ...(approvalPolicy === 'after_fill' ? { approval_policy: approvalPolicy } : {}),
@@ -350,7 +376,30 @@ export function registerManagedCheckoutTools(server, { client = AccelerateClient
350
376
  value = await client.checkouts.requestApproval(id, { ...approval, ...(fillAfterVerification ? { fill_after_verification: true } : {}) }, options);
351
377
  check(!fillAfterVerification || value.fill_after_verification === true, 'invalid_response');
352
378
  } else if (definition.name === 'accelerate_checkout_fill') value = await client.checkouts.fill(id, { revision: input.revision }, options);
353
- else if (definition.name === 'accelerate_checkout_submit') { check(enableSubmit && purpose === 'purchase', 'permission_denied'); value = await client.checkouts.submit(id, { revision: input.revision }, options); }
379
+ else if (definition.name === 'accelerate_checkout_submit') {
380
+ check(enableSubmit && purpose === 'purchase' && typeof browserConnector?.performFinalClick === 'function' && typeof browserConnector.prepareFinalClick === 'function', 'permission_denied');
381
+ // prepare -> arm -> customer-owned host click -> complete, exactly once. The arm token and target stay in this scope.
382
+ // Slow provider/extension readiness runs BEFORE the arm so it cannot consume the short click window.
383
+ const preparation = await browserConnector.prepareFinalClick(context, { checkout_id: id }, options); requireAdmission();
384
+ let armed;
385
+ try { armed = await client.checkouts.armFinalClick(id, { revision: input.revision }, options); }
386
+ catch (error) {
387
+ // Only an explicit refusal proves nothing was armed. Transport, schema, timeout and upstream failures may have burned the arm.
388
+ if (error instanceof AccelerateError && PRE_ARM_REFUSALS.has(error.code)) throw error;
389
+ throw new AccelerateError('outcome_unknown');
390
+ }
391
+ try {
392
+ await browserConnector.performFinalClick(context, { checkout_id: id, preparation,
393
+ checkout_url: armed.target.checkout_url, submit_selector: armed.target.submit_selector,
394
+ armed_expires_at: armed.armed_expires_at }, options);
395
+ } catch { /* Host failure is not retried; the service observation below is the only truth. */ }
396
+ try { value = await client.checkouts.completeFinalClick(id, { revision: input.revision, arm_id: armed.arm_id }, options); }
397
+ catch { throw new AccelerateError('outcome_unknown'); }
398
+ // After a click attempt any unusable observation is an uncertain outcome, never a retryable failure.
399
+ try { requirePolicy(projectCheckout(value)); } catch { throw new AccelerateError('outcome_unknown'); }
400
+ check(['submit_attempted', 'outcome_unknown'].includes(value?.status) && value.final_click_actor === 'customer' &&
401
+ (value.status === 'outcome_unknown' || value.submit_actor === 'customer'), 'outcome_unknown');
402
+ }
354
403
  else value = await client.checkouts.cancel(id, options);
355
404
  requireAdmission();
356
405
  const projected = kind === 'browser' ? projectBrowser(value) : requirePolicy(projectCheckout(value));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weaccelerateinc/agent-sdk",
3
- "version": "0.10.3",
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"
@@ -2,8 +2,10 @@
2
2
  ACCELERATE_STARTER_MODE=synthetic
3
3
  # Explicit new profile: private CVV continues the armed fill in the backend.
4
4
  ACCELERATE_CHECKOUT_PROFILE=managed_review_v2
5
- ACCELERATE_STARTER_PURPOSE=sandbox_fill_only
6
- ACCELERATE_ENABLE_PURCHASE_SUBMIT=false
5
+ ACCELERATE_STARTER_PURPOSE=purchase
6
+ ACCELERATE_ENABLE_PURCHASE_SUBMIT=true
7
+ # For a fill-only test, set purpose to sandbox_fill_only and submit to false.
8
+ # This file is a template; export values or load it with your server environment.
7
9
  # Live: operator-controlled absolute file exporting createPartnerDependencies().
8
10
  # ACCELERATE_CONTEXT_MODULE=/absolute/protected/partner-host.mjs
9
11
  # ACCELERATE_API_KEY=<tenant server key>
@@ -1,20 +1,20 @@
1
1
  # Existing-browser payment starter
2
2
 
3
- SDK source 0.10.3, explicit `managed_review_v2`, `customer_backend_trusted`. The matching service must support this profile; source changes do not establish an archive publication or live deployment. 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.
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.10.3 archive, install it, then:
7
+ Once Accelerate supplies the matching verified 0.12.0 archive, install it, then:
8
8
 
9
9
  ```sh
10
10
  npx --no-install accelerate-agent init ./partner-demo --browser existing
11
11
  cd partner-demo
12
- npm install /secure/downloads/weaccelerateinc-agent-sdk-0.10.3.tgz
12
+ npm install /secure/downloads/weaccelerateinc-agent-sdk-0.12.0.tgz
13
13
  npm test
14
14
  npm run smoke
15
15
  ```
16
16
 
17
- The synthetic fixture uses the actual connector, durable-host interface, private ingress/outbox and MCP registration. It simulates verification/provider operations; no real browser, message, credential or order is created. Its in-memory persistence and fake extension metadata are test fixtures only.
17
+ The synthetic fixture uses the actual connector, durable-host interface, private ingress/outbox and MCP registration. It simulates verification/provider operations; no real browser, message, credential or order is created. The smoke exercises default purchase mode, confirms submission is rejected before private approval, then cancels without approving or submitting. Its in-memory persistence and fake extension metadata are test fixtures only.
18
18
 
19
19
  ## Connect your platform
20
20
 
@@ -41,8 +41,21 @@ At create, v2 declares `fill_after_verification:true` and requires the service t
41
41
 
42
42
  Before attachment, use ordinary customer details for required non-secret fields or ask for missing details. Leave card number/PAN, expiry and CVV empty; never obtain them through ordinary browser tools.
43
43
 
44
- PAN displays stars + last four, CVV stars, expiry visible. Filled-page inspection is blocked for the agent. Provider-owner backend access remains trusted. The published release baseline qualifies `https://demos.weaccelerate.com/normal/payment`. The new universal-card source path probes compatible public HTTPS card forms instead of keeping a merchant hostname allowlist. It requires private `universal_card_v1` admission bound to the exact existing document and immutable purpose before OTP/CVV; ambiguous/unsupported forms fail without a replacement browser. That capability requires coordinated service/executor deployment and does not promise every checkout works. A candidate artifact or generic interface alone does not qualify a browser.
44
+ PAN displays stars + last four, CVV stars, expiry visible. Filled-page inspection is blocked for the agent. Provider-owner backend access remains trusted. `universal_card_v1` is private runtime admission for the exact HTTPS checkout document and requested purpose, including both purchase and fill-only. It detects compatible payment fields and safe review support across merchants; unsupported or ambiguous forms fail before OTP/CVV without a replacement browser. It requires a matching service/executor deployment. The approved extension's original manifest records demo fill/review/cancel evidence; the [2 November sandbox report](https://github.com/weaccelerateinc/accelerate/blob/main/products/agent-sdk/qualification/REPORT.md) includes additional real merchant fill/review runs, with no real-store order submission or production qualification. The universal path reuses the approved 0.0.0.1 extension; it does not require candidate 0.0.0.2.
45
45
 
46
- The default is `sandbox_fill_only`, with no submit tool. After its informational image, call cancel and verify cleanup. Enable purchase only through explicit backend `purpose:'purchase'` and `enableSubmit:true` after qualification. Customer approval never automatically submits. Uncertain outcomes retain fences and require reconciliation; status/cleanup never authorize a replacement by themselves.
46
+ ## Purchase and fill-only configuration
47
+
48
+ The new starter defaults are:
49
+
50
+ ```dotenv
51
+ ACCELERATE_STARTER_PURPOSE=purchase
52
+ ACCELERATE_ENABLE_PURCHASE_SUBMIT=true
53
+ ```
54
+
55
+ Purchase mode exposes `accelerate_checkout_submit`. Accelerate never sends the final click: that tool arms one short click, your `createPartnerDependencies` browser connector host must implement `performFinalClick` to click once in the customer's existing browser, then Accelerate observes the outcome. Registration fails without that host. The synthetic fixture simulates the arm, host click and completion; its smoke still cancels without a purchase decision, and the starter tests run the approved path. Private CVV, a delivered masked review, a fresh customer decision and a separate explicit submit for the matching revision are still required. A ready form or customer approval never submits automatically. The default `ACCELERATE_STARTER_MODE=synthetic` makes no external requests. `live` selects real backend dependencies, not the production wallet environment; the approved key, Merchant ID and service configuration select that environment.
56
+
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
+
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.
47
60
 
48
61
  Before live testing, confirm the session is executing the deployed v2 tool bundle. Updating a durable agent app does not necessarily replace an active session’s tools; settle its old work and use the framework’s supported idle deployment handoff or create a new authenticated session. Verify the quote response acknowledges `fill_after_verification:true`; a successful deployment alone is insufficient.
@@ -12,7 +12,7 @@
12
12
  "test": "node --test tests/*.test.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@weaccelerateinc/agent-sdk": "0.10.3",
15
+ "@weaccelerateinc/agent-sdk": "0.12.0",
16
16
  "@modelcontextprotocol/server": "2.3.1"
17
17
  },
18
18
  "devDependencies": {
@@ -9,8 +9,9 @@ export async function configureStarter(server, env = process.env) {
9
9
  const mode = env.ACCELERATE_STARTER_MODE ?? 'synthetic';
10
10
  const profile = env.ACCELERATE_CHECKOUT_PROFILE ?? 'managed_review_v2';
11
11
  if (!['managed_review_v1', 'managed_review_v2'].includes(profile)) throw Error('Unsupported partner checkout profile.');
12
- const purpose = env.ACCELERATE_STARTER_PURPOSE ?? 'sandbox_fill_only';
13
- const enableSubmit = env.ACCELERATE_ENABLE_PURCHASE_SUBMIT === 'true';
12
+ const purpose = env.ACCELERATE_STARTER_PURPOSE ?? 'purchase';
13
+ const enableSubmit = env.ACCELERATE_ENABLE_PURCHASE_SUBMIT === undefined
14
+ ? purpose === 'purchase' : env.ACCELERATE_ENABLE_PURCHASE_SUBMIT === 'true';
14
15
  if (!['sandbox_fill_only', 'purchase'].includes(purpose) ||
15
16
  env.ACCELERATE_ENABLE_PURCHASE_SUBMIT !== undefined && !['true', 'false'].includes(env.ACCELERATE_ENABLE_PURCHASE_SUBMIT) ||
16
17
  enableSubmit && purpose !== 'purchase') throw Error('Unsupported server purchase policy.');
@@ -12,9 +12,9 @@ const timer = setTimeout(() => { void transport.close(); }, 15000);
12
12
  try {
13
13
  await client.connect(transport);
14
14
  const tools = await client.listTools();
15
- assert.equal(tools.tools.length, 7);
15
+ assert.equal(tools.tools.length, 8);
16
16
  assert.equal(tools.tools.some(tool => tool.name === 'accelerate_checkout_request_payment'), true);
17
- assert.equal(tools.tools.some(tool => tool.name === 'accelerate_checkout_submit'), false);
17
+ assert.equal(tools.tools.some(tool => tool.name === 'accelerate_checkout_submit'), true);
18
18
  assert.equal(tools.tools.some(tool => tool.name === 'accelerate_checkout_fill'), false);
19
19
  const call = async (name, args) => {
20
20
  const result = await client.callTool({ name, arguments: args });
@@ -23,8 +23,9 @@ try {
23
23
  };
24
24
  const checkoutUrl = 'https://demos.weaccelerate.com/normal/payment';
25
25
  const checkout = (await call('accelerate_checkout_start', { start_url: checkoutUrl })).checkout;
26
- assert.equal(checkout.purpose, 'sandbox_fill_only'); assert.equal(checkout.submit_allowed, false);
26
+ assert.equal(checkout.purpose, 'purchase'); assert.equal(checkout.submit_allowed, true);
27
27
  assert.equal(checkout.approval_policy, 'after_fill'); assert.equal(checkout.purchase_approved, false);
28
+ assert.equal(checkout.final_click_actor, 'customer');
28
29
  assert.equal(checkout.status, 'wallet_required'); assert.equal(checkout.card, undefined);
29
30
  const id = checkout.id;
30
31
  assert.equal((await call('accelerate_checkout_status', { checkout_id: id })).checkout.status, 'wallet_required');
@@ -42,12 +43,16 @@ try {
42
43
  assert.equal(reviewed.purchase_approved, false);
43
44
  const remaining = Date.parse(reviewed.submit_expires_at) - Date.now();
44
45
  assert.ok(remaining > 290000 && remaining <= 300000);
46
+ const rejected = await client.callTool({ name: 'accelerate_checkout_submit', arguments: { checkout_id: id, revision: reviewed.revision } });
47
+ const rejection = rejected.structuredContent ?? JSON.parse(rejected.content.find(part => part.type === 'text').text);
48
+ assert.equal(rejection.ok, false); assert.equal(rejection.error.code, 'submit_unavailable');
45
49
  assert.equal((await call('accelerate_checkout_cancel', { checkout_id: id })).checkout.status, 'cancelled');
46
50
  complete = true;
47
51
  process.stdout.write(JSON.stringify({ ok: true, synthetic_only: true, mcp_protocol: true,
48
52
  profile: 'managed_review_v2', existing_browser_connector: true, browser_mode: 'customer_backend_trusted', verification_deferred: true, private_verification_before_fill: true,
49
- private_review_receipt_acknowledged: true, approval_window_ms: 300000, purchase_approval_requested: false,
50
- submit_tool_absent: true, manual_fill_tool_absent: true, fill_ready: true, cancelled: true, live_checkout_requests: 0 }) + '\n');
53
+ private_review_receipt_acknowledged: true, approval_window_ms: 300000, purchase_approval_requested: true,
54
+ purpose: 'purchase', submit_tool_present: true, unapproved_submit_blocked: true, purchase_decision_received: false,
55
+ submit_attempted: false, final_click_actor: 'customer', manual_fill_tool_absent: true, fill_ready: true, cancelled: true, live_checkout_requests: 0 }) + '\n');
51
56
  } catch {
52
57
  process.stderr.write(JSON.stringify({ ok: false, code: 'synthetic_smoke_failed' }) + '\n');
53
58
  } finally {
@@ -1,4 +1,4 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
2
  import { createExistingBrowserConnector } from '@weaccelerateinc/agent-sdk/browser-connector';
3
3
  import { AccelerateClient } from '@weaccelerateinc/agent-sdk';
4
4
  import { AcceleratePrivateChatClient, deliverPrivateCheckoutReview } from '@weaccelerateinc/agent-sdk/private-chat';
@@ -18,7 +18,7 @@ function createSyntheticHost({ purpose = 'sandbox_fill_only', profile = 'managed
18
18
  const records = new Map(), owners = new Map(), events = new Set(), messages = new Map();
19
19
  const calls = { create: 0, browser: 0, payment_requested: 0, approval_requested: 0,
20
20
  private_verification: 0, fill: 0, review: 0, review_sent: 0, review_acknowledged: 0,
21
- purchase_decision: 0, submit: 0, cancel: 0, external: 0 };
21
+ purchase_decision: 0, submit: 0, final_click_arm: 0, final_click_host: 0, final_click_complete: 0, cancel: 0, external: 0 };
22
22
  let tail = Promise.resolve();
23
23
  const locked = async work => {
24
24
  const previous = tail; let release;
@@ -29,6 +29,8 @@ function createSyntheticHost({ purpose = 'sandbox_fill_only', profile = 'managed
29
29
  expires_at: record.expires_at, purpose, submit_allowed: purpose === 'purchase',
30
30
  approval_policy: 'after_fill', purchase_approved: record.purchaseApproved === true,
31
31
  retry_safe: false, payment_confirmed: false,
32
+ ...(purpose === 'purchase' ? { final_click_actor: 'customer' } : {}),
33
+ ...(record.submitActor ? { submit_actor: 'customer' } : {}),
32
34
  ...(record.fillAfterVerificationProfile ? { fill_after_verification: true } : {}),
33
35
  ...(record.selected ? { card: { brand: 'Visa', last4: '4242' }, address: { label: 'Saved address ***23' } } : {}),
34
36
  ...(record.quote ? { quote: record.quote } : {}),
@@ -55,6 +57,7 @@ function createSyntheticHost({ purpose = 'sandbox_fill_only', profile = 'managed
55
57
  const body = request.body ? JSON.parse(request.body) : undefined;
56
58
  if (parsed.pathname === '/v1/checkouts' && request.method === 'POST') {
57
59
  if (body.interaction_mode !== 'chat' || body.purpose !== purpose || body.approval_policy !== 'after_fill' ||
60
+ (purpose === 'purchase') !== (body.final_click_actor === 'customer') || body.final_click_actor !== undefined && purpose !== 'purchase' ||
58
61
  Object.keys(context).some(key => body[key] !== context[key]) || body.start_url !== syntheticCheckoutUrl ||
59
62
  JSON.stringify(body.browser) !== JSON.stringify({ provider: 'connector', connector_id: 'synthetic_cdp', browser_id: 'existing:browser:1', custody_mode: 'customer_backend_trusted' })) invalid();
60
63
  if ((profile === 'managed_review_v2') !== (body.fill_after_verification === true)) invalid();
@@ -63,7 +66,7 @@ function createSyntheticHost({ purpose = 'sandbox_fill_only', profile = 'managed
63
66
  expires_at: new Date(Date.now() + 900000).toISOString(), selected: false, fillAfterVerificationProfile: body.fill_after_verification === true }); }
64
67
  return Response.json(view(records.get(id)), { status: 201 });
65
68
  }
66
- const match = /^\/v1\/checkouts\/(mcs_[A-Za-z0-9_-]{43})(?:\/(browser|details|approval|fill|submit|cancel|private-chat\/(?:start|state|review|review-delivered|reply)))?$/.exec(parsed.pathname);
69
+ const match = /^\/v1\/checkouts\/(mcs_[A-Za-z0-9_-]{43})(?:\/(browser|details|approval|fill|submit|cancel|final-click\/(?:arm|complete)|private-chat\/(?:start|state|review|review-delivered|reply)))?$/.exec(parsed.pathname);
67
70
  if (!match || !records.has(match[1])) invalid();
68
71
  const record = records.get(match[1]), action = match[2];
69
72
  if (!action && request.method === 'GET') return Response.json(view(record));
@@ -116,6 +119,7 @@ function createSyntheticHost({ purpose = 'sandbox_fill_only', profile = 'managed
116
119
  body.amount_cents === 100 && body.currency === 'USD') {
117
120
  if ((profile === 'managed_review_v2') !== (body.fill_after_verification === true)) invalid();
118
121
  record.fillAfterVerification = body.fill_after_verification === true;
122
+ record.submitSelector = body.submit_selector;
119
123
  record.status = 'approval_required'; record.stage = 'cvv_required'; record.revision++;
120
124
  record.quote = { checkout_origin: 'https://demos.weaccelerate.com', amount_cents: 100, currency: 'USD' };
121
125
  } else if (action === 'fill' && record.status === 'approved' && body.revision === record.revision) {
@@ -123,10 +127,24 @@ function createSyntheticHost({ purpose = 'sandbox_fill_only', profile = 'managed
123
127
  fill(record);
124
128
  } else if (action === 'cancel') { calls.cancel++; record.status = 'cancelled'; record.stage = 'closed'; record.purchaseApproved = false; delete record.review; }
125
129
  else if (action === 'submit') {
130
+ // The executor click path is closed for every purchase. Counting it proves the fixture never uses it.
126
131
  calls.submit++;
127
- if (purpose !== 'purchase' || !record.purchaseApproved || record.stage !== 'approved_for_submit' ||
128
- body.revision !== record.revision || Date.now() >= Date.parse(record.review.expires_at)) return Response.json({ error: { code: 'submit_unavailable' } }, { status: 409 });
129
- record.status = 'submit_attempted'; record.stage = 'closed'; record.purchaseApproved = false; delete record.review;
132
+ return Response.json({ error: { code: 'customer_click_required' } }, { status: 403 });
133
+ } else if (action === 'final-click/arm') {
134
+ if (purpose !== 'purchase' || !record.purchaseApproved || record.stage !== 'approved_for_submit' || record.status !== 'ready_for_submit' ||
135
+ body.revision !== record.revision || Date.now() >= Date.parse(record.review.expires_at) || record.arm) return Response.json({ error: { code: 'submit_unavailable' } }, { status: 409 });
136
+ calls.final_click_arm++;
137
+ record.arm = { id: 'sealed.' + randomBytes(48).toString('base64url'), expires_at: new Date(Date.now() + 10000).toISOString() };
138
+ record.status = 'final_click_armed';
139
+ return Response.json({ arm_id: record.arm.id, armed_expires_at: record.arm.expires_at,
140
+ target: { checkout_url: syntheticCheckoutUrl, submit_selector: record.submitSelector }, retry_safe: false, payment_confirmed: false });
141
+ } else if (action === 'final-click/complete') {
142
+ if (record.status !== 'final_click_armed' || body.revision !== record.revision || body.arm_id !== record.arm?.id || Object.keys(body).length !== 2)
143
+ return Response.json({ error: { code: 'submit_unavailable' } }, { status: 409 });
144
+ calls.final_click_complete++;
145
+ // Observation only: the fixture saw a click only when the customer host performed one.
146
+ record.status = record.clicked ? 'submit_attempted' : 'outcome_unknown'; record.submitActor = record.clicked === true;
147
+ record.stage = 'closed'; record.purchaseApproved = false; delete record.review; delete record.arm;
130
148
  } else invalid();
131
149
  return Response.json(view(record));
132
150
  };
@@ -180,6 +198,13 @@ function createSyntheticHost({ purpose = 'sandbox_fill_only', profile = 'managed
180
198
  // this exact event for redelivery, without replaying private filling.
181
199
  events.add(event.idempotency_key);
182
200
  },
201
+ /** The customer's browser host: the only place a physical click is ever simulated. */
202
+ hostClick: request => {
203
+ const record = records.get(request.checkout_id);
204
+ if (!record?.arm || Date.now() >= Date.parse(record.arm.expires_at) || Date.parse(request.armed_expires_at) !== Date.parse(record.arm.expires_at) || record.clicked ||
205
+ request.submit_selector !== record.submitSelector || request.checkout_url !== syntheticCheckoutUrl) invalid();
206
+ calls.final_click_host++; record.clicked = true; return { dispatched: true };
207
+ },
183
208
  counters: () => ({ ...calls }),
184
209
  };
185
210
  return host;
@@ -200,7 +225,7 @@ export function createSyntheticDependencies({ purpose = 'sandbox_fill_only', pro
200
225
  artifact: { format: 'zip', sha256: 'a'.repeat(64), bytes: 1234 },
201
226
  bridge: { protocol: 'accelerate_extension_bridge_v1', sha256: 'b'.repeat(64) },
202
227
  capabilities: { manual_card_fill: true, document_bound_single_use: true, autosave_disabled: true, automatic_submit: false, maximum_retention_ms: 480000 },
203
- compatibility: { sdk_versions: ['0.10.1', '0.10.2', '0.10.3'], 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' },
204
229
  qualifications: [{ provider: 'connector', scope: 'synthetic_only', evidence: 'docs/evidence/synthetic-starter.json' }],
205
230
  }; // Fake metadata for this in-process fixture only; never load it in live mode.
206
231
  const browser = { owner: context, browser: { provider: 'connector', connector_id: 'synthetic_cdp', browser_id: 'existing:browser:1', custody_mode: 'customer_backend_trusted' },
@@ -215,6 +240,11 @@ export function createSyntheticDependencies({ purpose = 'sandbox_fill_only', pro
215
240
  bindCheckout: async (_context, value) => { saved.set(value.checkout_id, value); },
216
241
  resolveCheckoutBrowser: async (_context, { checkout_id }) => { const { checkout_id: _id, attempt_key: _key, ...value } = saved.get(checkout_id); return value; },
217
242
  confirmCleanup: async () => ({ provider_browser_stopped: true, credentials_cleared: true, fence_retained: true }),
243
+ // Purchase only. The synthetic page is clicked by this customer-side host, never by Accelerate.
244
+ ...(purpose === 'purchase' ? { performFinalClick: async (_context, request) => {
245
+ if (request.fence_id !== 'synthetic:fence:1' || request.browser.browser_id !== 'existing:browser:1') throw Error('Synthetic click rejected.');
246
+ return fixture.hostClick(request);
247
+ } } : {}),
218
248
  } });
219
249
  return { client: fixture.client, privateChat: fixture.privateChat, resolveContext: fixture.resolveContext,
220
250
  persistence, browserConnector,
@@ -1,16 +1,18 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { configureStarter } from '../runtime.mjs';
4
- import { syntheticCheckoutUrl } from '../synthetic.mjs';
4
+ import { createSyntheticDependencies, syntheticCheckoutUrl } from '../synthetic.mjs';
5
+ import { registerPartnerCheckoutTools } from '@weaccelerateinc/agent-sdk/partner';
5
6
  async function fixture(env = {}) {
6
7
  const tools = new Map(); const integration = await configureStarter({ registerTool(name, _config, callback) { tools.set(name, callback); } }, env);
7
8
  const call = async (name, args) => { const result = (await tools.get(name)(args, {})).structuredContent; assert.equal(result.ok, true, JSON.stringify(result)); return result; };
8
9
  return { ...integration, tools, call };
9
10
  }
10
- test('explicit v2 starter verifies privately then fills and acknowledges review without a model fill tool', async t => {
11
+ test('explicit fill-only v2 verifies privately then fills and acknowledges review without a submit tool', async t => {
11
12
  const old = globalThis.fetch; globalThis.fetch = () => assert.fail('No network in synthetic starter'); t.after(() => { globalThis.fetch = old; });
12
- const f = await fixture(), checkout = (await f.call('accelerate_checkout_start', { start_url: syntheticCheckoutUrl })).checkout;
13
+ const f = await fixture({ ACCELERATE_STARTER_PURPOSE: 'sandbox_fill_only' }), checkout = (await f.call('accelerate_checkout_start', { start_url: syntheticCheckoutUrl })).checkout;
13
14
  const id = checkout.id; assert.equal(checkout.fill_after_verification, true); assert.equal(f.counters().fill, 0); assert.equal(checkout.status, 'wallet_required'); assert.equal(f.counters().payment_requested, 0);
15
+ assert.equal(checkout.purpose, 'sandbox_fill_only'); assert.equal(checkout.submit_allowed, false);
14
16
  assert.equal(f.tools.has('accelerate_checkout_submit'), false); assert.equal(f.tools.size, 7); assert.equal(f.tools.has('accelerate_checkout_fill'), false);
15
17
  assert.equal(f.profile, 'managed_review_v2');
16
18
  await f.call('accelerate_checkout_request_payment', { checkout_id: id });
@@ -28,7 +30,7 @@ test('explicit v2 starter verifies privately then fills and acknowledges review
28
30
  assert.equal(f.counters().submit, 0); assert.equal(f.counters().cancel, 1); assert.equal(f.counters().external, 0);
29
31
  });
30
32
  test('legacy v1 remains an explicit starter profile with one manual fill and no silent upgrade', async () => {
31
- const f = await fixture({ ACCELERATE_CHECKOUT_PROFILE: 'managed_review_v1' });
33
+ const f = await fixture({ ACCELERATE_CHECKOUT_PROFILE: 'managed_review_v1', ACCELERATE_STARTER_PURPOSE: 'sandbox_fill_only' });
32
34
  assert.equal(f.tools.has('accelerate_checkout_fill'), true); assert.equal(f.tools.size, 8);
33
35
  const id = (await f.call('accelerate_checkout_start', { start_url: syntheticCheckoutUrl })).checkout.id;
34
36
  await f.call('accelerate_checkout_request_payment', { checkout_id: id });
@@ -43,10 +45,15 @@ test('legacy v1 remains an explicit starter profile with one manual fill and no
43
45
  assert.equal(f.counters().review_acknowledged, 1); assert.equal(f.counters().submit, 0);
44
46
  await f.call('accelerate_checkout_cancel', { checkout_id: id });
45
47
  });
46
- test('v2 purchase still requires a fresh private decision and explicit submit after automatic fill', async () => {
47
- const f = await fixture({ ACCELERATE_STARTER_PURPOSE: 'purchase', ACCELERATE_ENABLE_PURCHASE_SUBMIT: 'true' });
48
+ test('default v2 purchase still requires a fresh private decision and explicit submit after automatic fill', async t => {
49
+ const old = globalThis.fetch; globalThis.fetch = () => assert.fail('No network in synthetic starter'); t.after(() => { globalThis.fetch = old; });
50
+ const f = await fixture();
48
51
  assert.equal(f.tools.has('accelerate_checkout_fill'), false); assert.equal(f.tools.has('accelerate_checkout_submit'), true);
49
- const id = (await f.call('accelerate_checkout_start', { start_url: syntheticCheckoutUrl })).checkout.id;
52
+ assert.equal(f.tools.size, 8);
53
+ const created = (await f.call('accelerate_checkout_start', { start_url: syntheticCheckoutUrl })).checkout;
54
+ assert.equal(created.purpose, 'purchase'); assert.equal(created.submit_allowed, true);
55
+ assert.equal(f.counters().payment_requested, 0);
56
+ const id = created.id;
50
57
  await f.call('accelerate_checkout_request_payment', { checkout_id: id });
51
58
  assert.equal(f.counters().fill, 0);
52
59
  await f.call('accelerate_checkout_apply_details', { checkout_id: id, checkout_url: syntheticCheckoutUrl });
@@ -60,10 +67,53 @@ test('v2 purchase still requires a fresh private decision and explicit submit af
60
67
  const rejected = (await f.tools.get('accelerate_checkout_submit')({ checkout_id: id, revision: quote.revision }, {})).structuredContent;
61
68
  assert.equal(rejected.ok, false); assert.equal(f.counters().submit, 0);
62
69
  await f.call('accelerate_checkout_cancel', { checkout_id: id });
70
+ assert.equal(f.counters().external, 0);
71
+ });
72
+ test('purchase submission can be explicitly disabled without changing purchase purpose', async () => {
73
+ const f = await fixture({ ACCELERATE_ENABLE_PURCHASE_SUBMIT: 'false' });
74
+ const created = (await f.call('accelerate_checkout_start', { start_url: syntheticCheckoutUrl })).checkout;
75
+ assert.equal(created.purpose, 'purchase'); assert.equal(created.submit_allowed, true);
76
+ assert.equal(f.tools.has('accelerate_checkout_submit'), false);
77
+ await f.call('accelerate_checkout_cancel', { checkout_id: created.id });
78
+ assert.equal(f.counters().submit, 0);
63
79
  });
64
80
  test('live setup requires a trusted backend module and preserves explicit custody selection', async () => {
65
81
  await assert.rejects(configureStarter({ registerTool() {} }, { ACCELERATE_STARTER_MODE: 'live' }), /trusted absolute/);
66
82
  await assert.rejects(configureStarter({ registerTool() {} }, { ACCELERATE_STARTER_MODE: 'unknown' }), /Unsupported/);
67
83
  await assert.rejects(configureStarter({ registerTool() {} }, { ACCELERATE_CHECKOUT_PROFILE: 'unknown' }), /profile/);
68
- await assert.rejects(configureStarter({ registerTool() {} }, { ACCELERATE_ENABLE_PURCHASE_SUBMIT: 'true' }), /purchase policy/);
84
+ await assert.rejects(configureStarter({ registerTool() {} }, { ACCELERATE_STARTER_PURPOSE: 'sandbox_fill_only', ACCELERATE_ENABLE_PURCHASE_SUBMIT: 'true' }), /purchase policy/);
85
+ for (const value of ['', 'TRUE', '1'])
86
+ await assert.rejects(configureStarter({ registerTool() {} }, { ACCELERATE_ENABLE_PURCHASE_SUBMIT: value }), /purchase policy/);
87
+ });
88
+
89
+ test('approved synthetic purchase: arm, one customer-host click, complete; the old executor submit is never used', async t => {
90
+ const clock = Date.now; let now = clock(); Date.now = () => now; t.after(() => { Date.now = clock; });
91
+ const tools = new Map(), deps = createSyntheticDependencies({ purpose: 'purchase', profile: 'managed_review_v2' });
92
+ const context = await deps.resolveContext(); let reply;
93
+ deps.authenticate = async () => ({ authenticated: true, direct: true, context, ...reply });
94
+ const integration = registerPartnerCheckoutTools({ registerTool(name, _config, callback) { tools.set(name, callback); } },
95
+ { ...deps, purpose: 'purchase', enableSubmit: true, profile: 'managed_review_v2' });
96
+ const invoke = async (name, args) => (await tools.get(name)(args, {})).structuredContent;
97
+ const call = async (name, args) => { const result = await invoke(name, args); assert.equal(result.ok, true, JSON.stringify(result)); return result; };
98
+ const created = (await call('accelerate_checkout_start', { start_url: syntheticCheckoutUrl })).checkout, id = created.id;
99
+ assert.equal(created.final_click_actor, 'customer');
100
+ await call('accelerate_checkout_request_payment', { checkout_id: id });
101
+ await call('accelerate_checkout_apply_details', { checkout_id: id, checkout_url: syntheticCheckoutUrl });
102
+ const quote = (await call('accelerate_checkout_request_approval', { checkout_id: id, checkout_url: syntheticCheckoutUrl, amount_cents: 100, currency: 'USD',
103
+ checkout_total_selector: '#checkout-total', submit_selector: '#place-order' })).checkout;
104
+ await call('accelerate_checkout_status', { checkout_id: id });
105
+ assert.equal((await invoke('accelerate_checkout_submit', { checkout_id: id, revision: quote.revision })).error.code, 'submit_unavailable');
106
+ assert.equal(deps.counters().final_click_arm, 0);
107
+ now++; // The decision must follow the delivered review receipt.
108
+ reply = { message_id: 'synthetic-private-decision', text: 'approve', sent_at: new Date(now).toISOString() };
109
+ assert.deepEqual(await integration.privateIngress.handle({}, Buffer.from('synthetic provider envelope')), { handled: true });
110
+ const done = (await call('accelerate_checkout_submit', { checkout_id: id, revision: quote.revision })).checkout;
111
+ assert.equal(done.status, 'submit_attempted'); assert.equal(done.submit_actor, 'customer'); assert.equal(done.payment_confirmed, false);
112
+ const counters = deps.counters();
113
+ assert.deepEqual([counters.final_click_arm, counters.final_click_host, counters.final_click_complete, counters.submit, counters.external], [1, 1, 1, 0, 0]);
114
+ });
115
+ test('purchase registration needs a host that performs the customer click; fill-only does not', () => {
116
+ const fillOnly = createSyntheticDependencies({ purpose: 'sandbox_fill_only' });
117
+ assert.throws(() => registerPartnerCheckoutTools({ registerTool() {} }, { ...fillOnly, purpose: 'purchase', enableSubmit: true }), /performFinalClick/);
118
+ registerPartnerCheckoutTools({ registerTool() {} }, { ...fillOnly, purpose: 'sandbox_fill_only', enableSubmit: false });
69
119
  });
@@ -11,6 +11,9 @@ export async function createPartnerDependencies({ client, privateChat }) {
11
11
  // browserConnector: createBrowserUseConnector({ extensionRelease,
12
12
  // host: createBrowserUseHost({apiKey,merchantId,extensionId,extensionRelease,host}) }) OR
13
13
  // createExistingBrowserConnector({ connectorId, extensionRelease, host });
14
+ // For purchase, host.performFinalClick(context, request, options) must perform exactly ONE trusted pointer
15
+ // click on request.submit_selector in the exact fenced browser/document/URL (no retry, no lifting the fence)
16
+ // and return { dispatched }. Accelerate never sends the final click.
14
17
  // authenticate(request, rawBody): verify webhook + established direct identity;
15
18
  // messaging: createLinqMessagingAdapter({ client: linq, sendingPhone, resolveDestination });
16
19
  // admitOrdinaryMessage(event), dispatchSafeProgress(event): durable dedupe + AI queue.
package/partner.js CHANGED
@@ -12,6 +12,10 @@ export function registerPartnerCheckoutTools(server, options = {}) {
12
12
  if (browserConnector?.contract !== 'accelerate_existing_browser_v1' || !privateChat?.chat) {
13
13
  throw Error('An existing-browser connector and private chat client are required.');
14
14
  }
15
+ // Accelerate never sends the final click. A purchase needs the customer's own browser host to do it once.
16
+ if (purpose === 'purchase' && (typeof browserConnector.performFinalClick !== 'function' || typeof browserConnector.prepareFinalClick !== 'function')) {
17
+ throw Error('Purchase requires a connector whose host implements performFinalClick: the customer\u2019s own browser performs the final click.');
18
+ }
15
19
  const host = createDurableCheckoutHost({ persistence, resolveContext });
16
20
  // Keep the filled browser until explicit cancel or separately authorized
17
21
  // submit. Review delivery can be recovered without repeating the fill.
@@ -1,4 +1,4 @@
1
- import type { HostPersistence } from './host-adapter.js';
1
+ import type { HostCheckoutRecord } from './host-adapter.js';
2
2
  export const checkoutHostMigrationSql: string;
3
3
  export interface CheckoutHostPostgresClient {
4
4
  query(sql: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[]; rowCount?: number | null }>;
@@ -6,9 +6,13 @@ export interface CheckoutHostPostgresClient {
6
6
  on?(event: 'error', listener: () => void): unknown;
7
7
  removeListener?(event: 'error', listener: () => void): unknown;
8
8
  }
9
- export function createPostgresCheckoutPersistence(options: {
9
+ /** Use a separate namespace for each private record type, including WhatsApp journals. */
10
+ export function createPostgresCheckoutPersistence<Record = HostCheckoutRecord>(options: {
10
11
  pool: { connect(): Promise<CheckoutHostPostgresClient> };
11
12
  encryptionKey: Buffer | string;
12
13
  namespace?: string;
13
14
  lockTimeoutMs?: number;
14
- }): Readonly<HostPersistence & { initialize(): Promise<void> }>;
15
+ }): Readonly<{
16
+ withLock<T>(ownerKey: string, work: (access: { load(): Promise<Record | undefined>; commit(record: Record): Promise<void> }) => Promise<T>): Promise<T>;
17
+ initialize(): Promise<void>;
18
+ }>;