@weaccelerateinc/agent-sdk 0.10.3 → 0.11.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.
@@ -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'], 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.
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);
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.11.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
  });