@weaccelerateinc/agent-sdk 0.0.0-stage → 0.10.3

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 (52) hide show
  1. package/README.md +149 -2
  2. package/approved-extension-releases/0.0.0.1.json +39 -0
  3. package/approved-extension-releases/0.0.0.2.json +32 -0
  4. package/approved-extension.d.ts +24 -0
  5. package/approved-extension.js +90 -0
  6. package/browser-connector.d.ts +87 -0
  7. package/browser-connector.js +179 -0
  8. package/browser-operations.d.ts +56 -0
  9. package/browser-operations.js +144 -0
  10. package/browser-use-host.d.ts +43 -0
  11. package/browser-use-host.js +147 -0
  12. package/checkout-diagnostics.d.ts +23 -0
  13. package/checkout-diagnostics.js +67 -0
  14. package/cli.js +102 -0
  15. package/customer.env.example +6 -0
  16. package/host-adapter.d.ts +90 -0
  17. package/host-adapter.js +353 -0
  18. package/index.d.ts +209 -0
  19. package/index.js +441 -0
  20. package/linq-messaging.d.ts +45 -0
  21. package/linq-messaging.js +123 -0
  22. package/managed-mcp.d.ts +150 -0
  23. package/managed-mcp.js +382 -0
  24. package/package.json +114 -4
  25. package/partner-starter/.env.example +11 -0
  26. package/partner-starter/README.md +48 -0
  27. package/partner-starter/gitignore.template +5 -0
  28. package/partner-starter/package.json +21 -0
  29. package/partner-starter/runtime.mjs +32 -0
  30. package/partner-starter/server.mjs +13 -0
  31. package/partner-starter/smoke.mjs +56 -0
  32. package/partner-starter/synthetic.mjs +226 -0
  33. package/partner-starter/tests/starter.test.mjs +69 -0
  34. package/partner-starter/trusted-context.example.mjs +20 -0
  35. package/partner.d.ts +27 -0
  36. package/partner.js +49 -0
  37. package/postgres-host.d.ts +14 -0
  38. package/postgres-host.js +102 -0
  39. package/private-chat.d.ts +104 -0
  40. package/private-chat.js +309 -0
  41. package/private-ingress.d.ts +46 -0
  42. package/private-ingress.js +132 -0
  43. package/starter/.env.example +19 -0
  44. package/starter/README.md +88 -0
  45. package/starter/gitignore.template +5 -0
  46. package/starter/package.json +21 -0
  47. package/starter/runtime.mjs +61 -0
  48. package/starter/server.mjs +13 -0
  49. package/starter/smoke.mjs +55 -0
  50. package/starter/synthetic.mjs +163 -0
  51. package/starter/tests/starter.test.mjs +256 -0
  52. package/starter/trusted-context.example.mjs +49 -0
@@ -0,0 +1,90 @@
1
+ import type { Checkout, CheckoutId, AccelerateClient } from './index.js';
2
+ import type { ManagedCheckoutContext, ManagedCheckoutEvent, ManagedCheckoutToolsOptions } from './managed-mcp.js';
3
+ import type { PrivateChatState, PrivateChatPrompt, PrivateChatProgress } from './private-chat.js';
4
+
5
+ export type HostOwner = Pick<ManagedCheckoutContext, 'customer_id' | 'workspace_id' | 'conversation_id'>;
6
+ export interface HostCheckoutAttempt {
7
+ idempotency_key: string;
8
+ checkout_id?: CheckoutId;
9
+ phase: 'creating' | 'active' | 'uncertain' | 'closed';
10
+ /** Sticky across successful status/cancel; requires separate operator reconciliation. */
11
+ uncertain: boolean;
12
+ /** Durable one-use fill/submit admissions survive a lost final commit ACK. */
13
+ /** Independent provider cleanup proof, separate from service ACK. */
14
+ cleanup_confirmed?: boolean;
15
+ effects?: Array<'accelerate_checkout_fill' | 'accelerate_checkout_submit'>;
16
+ pending?: { operation: string; message_id?: string; revision?: number };
17
+ private_stage?: PrivateChatState['stage'];
18
+ checkout?: Checkout;
19
+ }
20
+ export type HostOutboxItem = {
21
+ id: string;
22
+ delivered: boolean;
23
+ } & ({ kind: 'checkout_event'; payload: ManagedCheckoutEvent } |
24
+ { kind: 'private_prompt'; payload: PrivateChatPrompt & { checkout_id: CheckoutId } } |
25
+ { kind: 'safe_progress'; payload: PrivateChatProgress & { checkout_id: CheckoutId } });
26
+ /** No OTP, CVV, PAN, image bytes, browser connection credentials or private reply
27
+ * text may enter this record. Encrypt at rest according to host policy. */
28
+ export interface HostCheckoutRecord {
29
+ version: 'v1';
30
+ owner: HostOwner;
31
+ /** Authenticated original phone binding hash; never the raw phone number. */
32
+ phone_binding?: string;
33
+ attempt?: HostCheckoutAttempt;
34
+ history: Array<{ idempotency_key: string; checkout_id: CheckoutId; status: 'cancelled' | 'expired' | 'failed'; uncertain: false }>;
35
+ outbox: HostOutboxItem[];
36
+ /** Native-send receipts only, never image bytes/URLs or reply text. A pending
37
+ * send without receipt requires provider reconciliation, not another upload. */
38
+ deliveries?: Array<{ idempotency_key: string; receipt?: { message_id: string; sent_at: string } }>;
39
+ }
40
+ export interface HostPersistenceAccess {
41
+ load(): Promise<HostCheckoutRecord | undefined>;
42
+ /** Resolve only after a DURABLE independent commit. This write MUST survive a
43
+ * later callback failure; an enclosing rollback transaction is incompatible. */
44
+ commit(record: HostCheckoutRecord): Promise<void>;
45
+ }
46
+ export interface HostPersistence {
47
+ /** Hold a cross-process owner lock and call work exactly once. No automatic
48
+ * transaction/callback retries. Lost locks/commit acknowledgements must reject.
49
+ * A local Map/mutex does not implement this production durability contract. */
50
+ withLock<T>(ownerKey: string, work: (access: HostPersistenceAccess) => Promise<T>): Promise<T>;
51
+ }
52
+ export interface PrivateHostOperation {
53
+ operation: 'private_start' | 'private_reply' | 'private_state' | 'private_cancel' | 'review_delivery';
54
+ /** Original authenticated provider ID; required for recoverable reply replay. */
55
+ message_id?: string;
56
+ }
57
+ export interface OwnedPrivateCheckout extends ManagedCheckoutContext {
58
+ checkout_id: CheckoutId;
59
+ previous_stage?: PrivateChatState['stage'];
60
+ checkout?: Readonly<Checkout>;
61
+ /** Validates and commits only the safe state/progress/outgoing prompt. */
62
+ savePrivateState(state: PrivateChatState, options: { messageId: string; suppressPrompt?: boolean }): Promise<void>;
63
+ }
64
+ export interface DurableCheckoutHost<Extra = unknown> {
65
+ resolveContext: ManagedCheckoutToolsOptions<Extra>['resolveContext'];
66
+ withCheckoutCreation: ManagedCheckoutToolsOptions<Extra>['withCheckoutCreation'];
67
+ withOwnedCheckout: ManagedCheckoutToolsOptions<Extra>['withOwnedCheckout'];
68
+ dispatchCheckoutEvent: ManagedCheckoutToolsOptions<Extra>['dispatchCheckoutEvent'];
69
+ /** Durable actual native-send receipt cache; lost SDK ACK can reuse receipt
70
+ * without uploading/sending the same review again. */
71
+ withPrivateDelivery(context: ManagedCheckoutContext, idempotencyKey: string,
72
+ deliver: () => Promise<{ message_id: string; sent_at: string }>): Promise<Readonly<{ message_id: string; sent_at: string }>>;
73
+ /** Call ONLY after connector independently verifies stopped/cleared and retained fence. */
74
+ confirmBrowserCleanup(context: ManagedCheckoutContext, checkoutId: CheckoutId): Promise<void>;
75
+ withPrivateCheckout<T>(context: ManagedCheckoutContext, metadata: PrivateHostOperation,
76
+ work: (checkout: Readonly<OwnedPrivateCheckout>) => Promise<T>): Promise<T | undefined>;
77
+ /** Safe deliveries only. Recipient/queue must durably deduplicate item.id.
78
+ * Failure leaves the same pending delivery; never repeat fill or submit here. */
79
+ drainOutbox(context: ManagedCheckoutContext,
80
+ deliver: (item: Readonly<HostOutboxItem>, context: Readonly<ManagedCheckoutContext>) => Promise<void>): Promise<number>;
81
+ }
82
+ /** Strict runtime validation only; caller must actually authenticate upstream. */
83
+ export function authenticatedCheckoutContext(value: unknown): Readonly<ManagedCheckoutContext>;
84
+ /** Hash-only key scopes one active payment fence to customer+workspace. Exact
85
+ * authenticated conversation is additionally checked under the owner lock. */
86
+ export function checkoutHostOwnerKey(context: ManagedCheckoutContext): string;
87
+ export function createDurableCheckoutHost<Extra = unknown>(options: {
88
+ persistence: HostPersistence;
89
+ resolveContext: ManagedCheckoutToolsOptions<Extra>['resolveContext'];
90
+ }): Readonly<DurableCheckoutHost<Extra>>;
@@ -0,0 +1,353 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { AccelerateError } from './index.js';
4
+ import { validateCheckoutDiagnostics } from './checkout-diagnostics.js';
5
+ import { renderPrivateChatState, privateChatProgress } from './private-chat.js';
6
+
7
+ const ID = /^mcs_[A-Za-z0-9_-]{43}$/;
8
+ const OWNER = /^[A-Za-z0-9_.:-]{1,500}$/;
9
+ const MESSAGE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}$/;
10
+ const RECOVER = new Set(['accelerate_checkout_status', 'accelerate_checkout_cancel', 'private_state', 'private_cancel']);
11
+ const TERMINAL = new Set(['cancelled', 'expired', 'failed']);
12
+ const STATUS_ORDER = new Map(['wallet_required', 'selection_ready', 'details_ready', 'approval_required', 'approved', 'ready_for_submit'].map((status, index) => [status, index]));
13
+ 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']);
14
+ const STATUSES = new Set(['wallet_required', 'selection_ready', 'details_ready', 'approval_required', 'approved', 'ready_for_submit', 'cancelled', 'expired', 'failed', 'submit_attempted', 'outcome_unknown']);
15
+ const object = value => value && typeof value === 'object' && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
16
+ const check = (condition, code = 'invalid_input') => { if (!condition) throw new AccelerateError(code); };
17
+ const clone = value => structuredClone(value);
18
+ const validDate = value => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) && Number.isFinite(Date.parse(value));
19
+ function canonical(value) {
20
+ if (Array.isArray(value)) return value.map(canonical);
21
+ if (object(value)) return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
22
+ return value;
23
+ }
24
+ const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
25
+ function exact(value, required, optional = [], code = 'invalid_input') {
26
+ check(object(value) && required.every(key => Object.hasOwn(value, key)) && Object.keys(value).every(key => required.includes(key) || optional.includes(key)), code);
27
+ }
28
+ export function authenticatedCheckoutContext(value) {
29
+ exact(value, ['customer_id', 'workspace_id', 'conversation_id', 'phone_number'], [], 'permission_denied');
30
+ check(['customer_id', 'workspace_id', 'conversation_id'].every(key => typeof value[key] === 'string' && OWNER.test(value[key])) && /^\+[1-9]\d{6,14}$/.test(value.phone_number), 'permission_denied');
31
+ return Object.freeze({ ...value });
32
+ }
33
+ function owner(value) {
34
+ return Object.fromEntries(['customer_id', 'workspace_id', 'conversation_id'].map(key => [key, value[key]]));
35
+ }
36
+ export function checkoutHostOwnerKey(context) {
37
+ const resolved = authenticatedCheckoutContext(context);
38
+ return `accelerate-partner-host:${digest(['v1', resolved.customer_id, resolved.workspace_id])}`;
39
+ }
40
+ function sameOwner(left, right) { return ['customer_id', 'workspace_id', 'conversation_id'].every(key => left[key] === right[key]); }
41
+ function sameAccount(left, right) { return ['customer_id', 'workspace_id'].every(key => left[key] === right[key]); }
42
+ function samePhone(record, context) { return record.phone_binding === undefined || record.phone_binding === digest(['v1', context.customer_id, context.workspace_id, context.phone_number]); }
43
+ function requireCleanClosedAttempt(record) {
44
+ const old = record.attempt;
45
+ check(old?.phase === 'closed' && !old.uncertain && !old.pending && TERMINAL.has(old.checkout?.status) && old.checkout?.diagnostics?.cleanup_status === 'confirmed' && old.cleanup_confirmed === true, 'outcome_unknown');
46
+ check(record.outbox.every(item => item.delivered) && (record.deliveries ?? []).every(item => item.receipt !== undefined), 'operation_conflict');
47
+ }
48
+ function safeText(value, max = 10000) {
49
+ check(typeof value === 'string' && value.length > 0 && value.length <= max && !/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|(?:\d[ -]?){13,19}|(?:wss?|devtools|chrome-extension):\/\/|Bearer\s|\b(?:session_token|handoff_token|submit_token|api_key)\b/i.test(value), 'invalid_response');
50
+ return value;
51
+ }
52
+ function safeCheckout(value) {
53
+ exact(value, ['id', 'status', 'revision', 'expires_at', 'retry_safe', 'payment_confirmed'], ['purpose', 'submit_allowed', 'approval_policy', 'purchase_approved', 'fill_after_verification', 'card', 'address', 'quote', 'submit_expires_at', 'diagnostics'], 'invalid_response');
54
+ check(ID.test(value.id) && STATUSES.has(value.status) && Number.isSafeInteger(value.revision) && value.revision >= 0 && value.retry_safe === false && value.payment_confirmed === false && validDate(value.expires_at), 'invalid_response');
55
+ if (value.card !== undefined) {
56
+ exact(value.card, ['brand', 'last4'], [], 'invalid_response');
57
+ check(value.card.last4 === null || typeof value.card.last4 === 'string' && /^\d{4}$/.test(value.card.last4), 'invalid_response');
58
+ if (value.card.brand !== null) safeText(value.card.brand, 40);
59
+ }
60
+ if (value.address !== undefined) {
61
+ exact(value.address, ['label'], ['city', 'state', 'postal_code_mask'], 'invalid_response');
62
+ for (const key of ['label', 'city', 'state']) if (value.address[key] != null) safeText(value.address[key], 400);
63
+ check(value.address.postal_code_mask == null || /^\*{3}[A-Za-z0-9 -]{1,2}$/.test(value.address.postal_code_mask), 'invalid_response');
64
+ }
65
+ if (value.quote !== undefined) {
66
+ exact(value.quote, ['checkout_origin', 'amount_cents', 'currency'], [], 'invalid_response');
67
+ let url; try { url = new URL(value.quote.checkout_origin); } catch { check(false, 'invalid_response'); }
68
+ check(url.protocol === 'https:' && url.origin === value.quote.checkout_origin && !url.username && !url.password && !url.port && Number.isSafeInteger(value.quote.amount_cents) && value.quote.amount_cents > 0 && value.quote.currency === 'USD', 'invalid_response');
69
+ }
70
+ if (value.purpose !== undefined) check(['purchase', 'sandbox_fill_only', 'sandbox_source_comparison'].includes(value.purpose) && value.submit_allowed === (value.purpose === 'purchase'), 'invalid_response');
71
+ if (value.submit_expires_at !== undefined) check(validDate(value.submit_expires_at), 'invalid_response');
72
+ if (value.submit_allowed !== undefined) check(typeof value.submit_allowed === 'boolean', 'invalid_response');
73
+ if (value.fill_after_verification !== undefined) check(value.fill_after_verification === true && value.approval_policy === 'after_fill' && ['purchase', 'sandbox_fill_only'].includes(value.purpose), 'invalid_response');
74
+ if (value.purchase_approved !== undefined) check(typeof value.purchase_approved === 'boolean', 'invalid_response');
75
+ if (value.approval_policy !== undefined) check(value.approval_policy === 'after_fill' && typeof value.purchase_approved === 'boolean', 'invalid_response');
76
+ // The SDK owns detailed diagnostics validation. This projection keeps no URLs,
77
+ // provider capabilities or data that could accidentally become an effect input.
78
+ const result = clone(value);
79
+ if (result.diagnostics !== undefined) result.diagnostics = validateCheckoutDiagnostics(result.diagnostics, result);
80
+ return result;
81
+ }
82
+ function validateRecord(value, context) {
83
+ if (value === undefined || value === null) return { version: 'v1', owner: owner(context), phone_binding: digest(['v1', context.customer_id, context.workspace_id, context.phone_number]), outbox: [], history: [], deliveries: [] };
84
+ exact(value, ['version', 'owner', 'outbox', 'history'], ['attempt', 'deliveries', 'phone_binding'], 'operation_conflict');
85
+ check(value.version === 'v1' && sameOwner(value.owner, context), 'permission_denied');
86
+ if (value.phone_binding !== undefined) {
87
+ check(typeof value.phone_binding === 'string' && /^[a-f0-9]{64}$/.test(value.phone_binding), 'operation_conflict');
88
+ if (context.phone_number !== undefined) check(value.phone_binding === digest(['v1', context.customer_id, context.workspace_id, context.phone_number]), 'permission_denied');
89
+ }
90
+ exact(value.owner, ['customer_id', 'workspace_id', 'conversation_id'], [], 'operation_conflict');
91
+ check(['customer_id', 'workspace_id', 'conversation_id'].every(key => typeof value.owner[key] === 'string' && OWNER.test(value.owner[key])), 'operation_conflict');
92
+ check(Array.isArray(value.outbox) && value.outbox.length <= 500 && Array.isArray(value.history) && value.history.length <= 1000, 'operation_conflict');
93
+ if (value.attempt !== undefined) {
94
+ exact(value.attempt, ['idempotency_key', 'phase', 'uncertain'], ['checkout_id', 'pending', 'private_stage', 'checkout', 'effects', 'cleanup_confirmed'], 'operation_conflict');
95
+ check(/^accelerate-host:[a-f0-9-]{36}$/.test(value.attempt.idempotency_key) && ['creating', 'active', 'uncertain', 'closed'].includes(value.attempt.phase) && typeof value.attempt.uncertain === 'boolean', 'operation_conflict');
96
+ if (value.attempt.cleanup_confirmed !== undefined) check(typeof value.attempt.cleanup_confirmed === 'boolean', 'operation_conflict');
97
+ if (value.attempt.effects !== undefined) check(Array.isArray(value.attempt.effects) && value.attempt.effects.every(item => ['accelerate_checkout_fill', 'accelerate_checkout_submit'].includes(item)) && new Set(value.attempt.effects).size === value.attempt.effects.length, 'operation_conflict');
98
+ if (value.attempt.checkout_id !== undefined) check(ID.test(value.attempt.checkout_id), 'operation_conflict');
99
+ if (value.attempt.pending !== undefined) {
100
+ exact(value.attempt.pending, ['operation'], ['message_id', 'revision'], 'operation_conflict');
101
+ check(/^[a-z_]{1,100}$/.test(value.attempt.pending.operation) && (value.attempt.pending.message_id === undefined || MESSAGE.test(value.attempt.pending.message_id)), 'operation_conflict');
102
+ }
103
+ if (value.attempt.pending?.revision !== undefined) check(Number.isSafeInteger(value.attempt.pending.revision) && value.attempt.pending.revision > 0, 'operation_conflict');
104
+ if (value.attempt.private_stage !== undefined) check(STAGES.has(value.attempt.private_stage), 'operation_conflict');
105
+ if (value.attempt.checkout) check(safeCheckout(value.attempt.checkout).id === value.attempt.checkout_id, 'operation_conflict');
106
+ }
107
+ for (const item of value.history) {
108
+ exact(item, ['idempotency_key', 'checkout_id', 'status', 'uncertain'], [], 'operation_conflict');
109
+ check(ID.test(item.checkout_id) && TERMINAL.has(item.status) && item.uncertain === false, 'operation_conflict');
110
+ }
111
+ if (value.deliveries !== undefined) {
112
+ check(Array.isArray(value.deliveries) && value.deliveries.length <= 500, 'operation_conflict');
113
+ for (const item of value.deliveries) {
114
+ exact(item, ['idempotency_key'], ['receipt'], 'operation_conflict');
115
+ check(/^accelerate-chat:[a-f0-9]{64}$/.test(item.idempotency_key), 'operation_conflict');
116
+ if (item.receipt !== undefined) { exact(item.receipt, ['message_id', 'sent_at'], [], 'operation_conflict'); check(MESSAGE.test(item.receipt.message_id) && validDate(item.receipt.sent_at), 'operation_conflict'); }
117
+ }
118
+ }
119
+ for (const item of value.outbox) validateOutbox(item);
120
+ return clone(value);
121
+ }
122
+ function validateOutbox(item) {
123
+ exact(item, ['id', 'kind', 'payload', 'delivered'], [], 'invalid_response');
124
+ check(/^accelerate-(?:checkout-event|chat|progress):[a-f0-9]{64}$/.test(item.id) && ['checkout_event', 'private_prompt', 'safe_progress'].includes(item.kind) && typeof item.delivered === 'boolean', 'invalid_response');
125
+ if (item.kind === 'checkout_event') {
126
+ exact(item.payload, ['version', 'kind', 'idempotency_key', 'checkout'], [], 'invalid_response');
127
+ check(item.payload.version === 'v1' && ['created', 'payment_requested', 'approval_requested', 'review_ready'].includes(item.payload.kind) && item.payload.idempotency_key === item.id, 'invalid_response');
128
+ safeCheckout(item.payload.checkout);
129
+ } else {
130
+ exact(item.payload, ['checkout_id', 'text', 'idempotency_key'], item.kind === 'safe_progress' ? ['stage'] : [], 'invalid_response');
131
+ check(ID.test(item.payload.checkout_id) && item.payload.idempotency_key === item.id, 'invalid_response'); safeText(item.payload.text);
132
+ if (item.kind === 'safe_progress') check(['preparing_checkout', 'verified', 'approved_for_submit', 'closed'].includes(item.payload.stage), 'invalid_response');
133
+ }
134
+ }
135
+ function observe(record, checkout) {
136
+ const projection = safeCheckout(checkout);
137
+ check(projection.id === record.attempt.checkout_id, 'permission_denied');
138
+ const saved = record.attempt.checkout;
139
+ if (saved && !['outcome_unknown', 'submit_attempted'].includes(projection.status)) {
140
+ if (projection.revision < saved.revision) return;
141
+ if (projection.revision === saved.revision && STATUS_ORDER.has(saved.status) && STATUS_ORDER.has(projection.status) && STATUS_ORDER.get(saved.status) > STATUS_ORDER.get(projection.status)) return;
142
+ }
143
+ // Inline review delivery may have already closed this browser while the outer
144
+ // fill callback still holds its earlier ready_for_submit projection.
145
+ if (saved && TERMINAL.has(saved.status) && !TERMINAL.has(projection.status) && !['outcome_unknown', 'submit_attempted'].includes(projection.status) && projection.revision <= saved.revision) return;
146
+ record.attempt.checkout = projection;
147
+ if (['outcome_unknown', 'submit_attempted'].includes(projection.status)) record.attempt.uncertain = true;
148
+ record.attempt.phase = record.attempt.uncertain ? 'uncertain' : TERMINAL.has(projection.status) ? 'closed' : 'active';
149
+ }
150
+ function recoverable(record, metadata) {
151
+ const attempt = record.attempt;
152
+ if (!attempt?.pending && !attempt?.uncertain) return;
153
+ const duplicatePrivateReply = metadata.operation === 'private_reply' && metadata.message_id && attempt.pending?.operation === 'private_reply' && metadata.message_id === attempt.pending.message_id && !attempt.uncertain;
154
+ check(RECOVER.has(metadata.operation) || duplicatePrivateReply, 'outcome_unknown');
155
+ }
156
+
157
+ /** The host owns persistence. No memory store or timer can satisfy this contract. */
158
+ export function createDurableCheckoutHost({ persistence, resolveContext } = {}) {
159
+ check(typeof persistence?.withLock === 'function' && typeof resolveContext === 'function');
160
+ const scope = new AsyncLocalStorage();
161
+ const locked = async (input, work, { creationRebind = false } = {}) => {
162
+ const context = authenticatedCheckoutContext(input), key = checkoutHostOwnerKey(context), current = scope.getStore();
163
+ if (current?.active) {
164
+ // A live nested operation may not rebind even a closed record. Existing
165
+ // callbacks retain the original conversation/phone until they unwind.
166
+ check(current.key === key && sameOwner(current.record.owner, context) && samePhone(current.record, context), 'permission_denied');
167
+ return work(current, context);
168
+ }
169
+ let calls = 0, completed = false, expected;
170
+ const result = await persistence.withLock(key, async access => {
171
+ check(++calls === 1 && typeof access?.load === 'function' && typeof access?.commit === 'function', 'operation_conflict');
172
+ const saved = await access.load();
173
+ let record;
174
+ if (creationRebind && saved !== undefined && saved !== null) {
175
+ // Validate the persisted schema before considering a different verified
176
+ // conversation. Account identity still scopes this same owner lock.
177
+ record = validateRecord(saved, saved.owner);
178
+ check(sameAccount(record.owner, context), 'permission_denied');
179
+ if (!sameOwner(record.owner, context) || !samePhone(record, context)) requireCleanClosedAttempt(record);
180
+ } else record = validateRecord(saved, context);
181
+ const active = { key, access, record, active: true, effectDepth: 0 };
182
+ try { return await scope.run(active, async () => { expected = await work(active, context); completed = true; return expected; }); }
183
+ finally { active.active = false; }
184
+ });
185
+ check(calls === 1 && completed && result === expected, 'operation_conflict'); return result;
186
+ };
187
+ const commit = async active => {
188
+ check(active.active, 'operation_conflict');
189
+ // Never pass live mutable state to a persistence implementation.
190
+ validateRecord(active.record, active.record.owner);
191
+ await active.access.commit(clone(active.record));
192
+ check(active.active, 'operation_conflict');
193
+ };
194
+ const enqueue = async (active, item) => {
195
+ validateOutbox(item);
196
+ const existing = active.record.outbox.find(entry => entry.id === item.id);
197
+ if (existing) { check(JSON.stringify(canonical({ ...existing, delivered: false })) === JSON.stringify(canonical(item)), 'idempotency_conflict'); return; }
198
+ check(active.record.outbox.length < 500, 'operation_conflict');
199
+ active.record.outbox.push(clone(item)); await commit(active);
200
+ };
201
+ const withOwned = (context, checkoutId, work, metadata = { operation: 'unclassified' }) => locked(context, async (active, resolved) => {
202
+ check(typeof work === 'function' && ID.test(checkoutId) && active.record.attempt?.checkout_id === checkoutId, 'permission_denied');
203
+ exact(metadata, ['operation'], ['message_id', 'revision']);
204
+ check(metadata.revision === undefined || Number.isSafeInteger(metadata.revision) && metadata.revision > 0); check(/^[a-z_]{1,100}$/.test(metadata.operation));
205
+ if (!active.effectDepth || active.record.attempt.uncertain) recoverable(active.record, metadata);
206
+ // A lost previous effect acknowledgement remains sticky even if a safe read
207
+ // succeeds. Read-only failures do not create a new payment ambiguity.
208
+ if (active.record.attempt.effects?.includes(metadata.operation)) check(false, 'checkout_frozen');
209
+ if (metadata.operation === 'accelerate_checkout_fill') {
210
+ const checkout = active.record.attempt.checkout;
211
+ check(checkout?.status === 'approved' && active.record.attempt.private_stage === 'verified' && metadata.revision === checkout.revision, 'verification_required');
212
+ }
213
+ if (metadata.operation === 'accelerate_checkout_submit') {
214
+ const checkout = active.record.attempt.checkout;
215
+ check(checkout?.purpose === 'purchase' && checkout.submit_allowed === true && checkout.status === 'ready_for_submit' && checkout.purchase_approved === true && active.record.attempt.private_stage === 'approved_for_submit' && validDate(checkout.submit_expires_at) && Date.parse(checkout.submit_expires_at) > Date.now() && metadata.revision === checkout.revision, 'submit_unavailable');
216
+ }
217
+ const oneUse = ['accelerate_checkout_fill', 'accelerate_checkout_submit'].includes(metadata.operation);
218
+ if (oneUse) {
219
+ active.record.attempt.effects ??= [];
220
+ check(!active.record.attempt.effects.includes(metadata.operation), 'checkout_frozen');
221
+ active.record.attempt.effects.push(metadata.operation);
222
+ }
223
+ const recovery = RECOVER.has(metadata.operation), previousPending = active.record.attempt.pending;
224
+ if (!recovery) { active.record.attempt.pending = { ...metadata }; await commit(active); }
225
+ const binding = Object.freeze({ ...resolved, checkout_id: checkoutId });
226
+ try {
227
+ active.effectDepth++;
228
+ let result;
229
+ try { result = await work(binding); } finally { active.effectDepth--; }
230
+ if (result?.checkout) observe(active.record, result.checkout);
231
+ if (!recovery) delete active.record.attempt.pending;
232
+ else if (previousPending) active.record.attempt.pending = previousPending;
233
+ await commit(active); return result;
234
+ } catch (error) {
235
+ if (!recovery) {
236
+ // Preserve the already-committed pending marker if this acknowledgement
237
+ // also fails; a later process must still refuse another effect.
238
+ active.record.attempt.uncertain = true; active.record.attempt.phase = 'uncertain';
239
+ try { await commit(active); } catch {}
240
+ }
241
+ throw error instanceof AccelerateError ? error : new AccelerateError(recovery ? 'request_failed' : 'outcome_unknown');
242
+ }
243
+ });
244
+ return Object.freeze({
245
+ resolveContext: async extra => authenticatedCheckoutContext(await resolveContext(extra)),
246
+ withCheckoutCreation: (context, work) => locked(context, async (active, resolved) => {
247
+ check(typeof work === 'function');
248
+ if (active.record.attempt) {
249
+ const old = active.record.attempt;
250
+ requireCleanClosedAttempt(active.record);
251
+ // Ordinary closed history is bounded. Uncertain attempts are never
252
+ // admitted here, aged out or removed by this compaction.
253
+ active.record.history = active.record.history.slice(-999);
254
+ active.record.history.push({ idempotency_key: old.idempotency_key, checkout_id: old.checkout_id, status: old.checkout.status, uncertain: false });
255
+ }
256
+ if (active.record.attempt) { active.record.outbox = []; active.record.deliveries = []; }
257
+ // Rebind only in the same durable write as the new creation fence. A lost
258
+ // commit acknowledgement cannot leave an unfenced owner-only row.
259
+ active.record.owner = owner(resolved);
260
+ active.record.phone_binding = digest(['v1', resolved.customer_id, resolved.workspace_id, resolved.phone_number]);
261
+ active.record.attempt = { idempotency_key: `accelerate-host:${randomUUID()}`, phase: 'creating', uncertain: false, cleanup_confirmed: false, effects: [], pending: { operation: 'creation' } };
262
+ await commit(active);
263
+ let persisted = false;
264
+ try {
265
+ const result = await work({ idempotency_key: active.record.attempt.idempotency_key, persistCreated: async binding => {
266
+ check(!persisted && binding?.checkout_id && ID.test(binding.checkout_id) && sameOwner(binding, resolved) && binding.phone_number === resolved.phone_number, 'permission_denied');
267
+ check(!active.record.attempt.checkout_id || active.record.attempt.checkout_id === binding.checkout_id, 'idempotency_conflict');
268
+ active.record.attempt.checkout_id = binding.checkout_id; await commit(active); persisted = true;
269
+ } });
270
+ check(persisted, 'outcome_unknown');
271
+ if (result?.checkout) observe(active.record, result.checkout);
272
+ else active.record.attempt.phase = 'active';
273
+ delete active.record.attempt.pending; await commit(active); return result;
274
+ } catch (error) {
275
+ active.record.attempt.uncertain = true; active.record.attempt.phase = 'uncertain'; try { await commit(active); } catch {}
276
+ throw error instanceof AccelerateError ? error : new AccelerateError('outcome_unknown');
277
+ }
278
+ }, { creationRebind: true }),
279
+ withOwnedCheckout: withOwned,
280
+ dispatchCheckoutEvent: (context, event) => locked(context, async active => {
281
+ check(active.record.attempt?.checkout_id === event?.checkout?.id, 'permission_denied');
282
+ await enqueue(active, { id: event.idempotency_key, kind: 'checkout_event', payload: event, delivered: false });
283
+ }),
284
+ withPrivateDelivery: (context, idempotencyKey, deliver) => locked(context, async active => {
285
+ check(/^accelerate-chat:[a-f0-9]{64}$/.test(idempotencyKey) && typeof deliver === 'function');
286
+ active.record.deliveries ??= [];
287
+ const saved = active.record.deliveries.find(item => item.idempotency_key === idempotencyKey);
288
+ if (saved?.receipt) return Object.freeze(clone(saved.receipt));
289
+ // An unconfirmed native send requires provider reconciliation; do not
290
+ // create a different attachment under an already-used message key.
291
+ check(!saved, 'outcome_unknown');
292
+ check(active.record.deliveries.length < 500, 'operation_conflict');
293
+ const entry = { idempotency_key: idempotencyKey }; active.record.deliveries.push(entry); await commit(active);
294
+ const receipt = await deliver();
295
+ exact(receipt, ['message_id', 'sent_at'], [], 'invalid_response');
296
+ check(MESSAGE.test(receipt.message_id) && validDate(receipt.sent_at) && Date.parse(receipt.sent_at) <= Date.now(), 'invalid_response');
297
+ entry.receipt = clone(receipt); await commit(active); return Object.freeze(clone(receipt));
298
+ }),
299
+ confirmBrowserCleanup: (context, checkoutId) => locked(context, async active => {
300
+ check(active.record.attempt?.checkout_id === checkoutId && active.record.attempt.checkout?.diagnostics?.cleanup_status === 'confirmed', 'operation_conflict');
301
+ active.record.attempt.cleanup_confirmed = true; await commit(active);
302
+ }),
303
+ /** For private callbacks only. A provider message ID may recover its own
304
+ * idempotent reply; no reply text enters persistence or the outbox. */
305
+ withPrivateCheckout: (context, metadata, work) => locked(context, async (active, resolved) => {
306
+ exact(metadata, ['operation'], ['message_id']);
307
+ check(metadata.message_id === undefined || typeof metadata.message_id === 'string' && MESSAGE.test(metadata.message_id));
308
+ const attempt = active.record.attempt;
309
+ if (!attempt?.checkout_id) return undefined;
310
+ if (!active.effectDepth || attempt.uncertain) recoverable(active.record, metadata);
311
+ check(['private_start', 'private_reply', 'private_state', 'private_cancel', 'review_delivery'].includes(metadata.operation) && typeof work === 'function');
312
+ const recovery = RECOVER.has(metadata.operation), previousPending = attempt.pending, nested = active.effectDepth > 0;
313
+ if (!recovery) { attempt.pending = { ...metadata }; await commit(active); }
314
+ try {
315
+ active.effectDepth++;
316
+ let result;
317
+ try { result = await work(Object.freeze({ ...resolved, checkout_id: attempt.checkout_id,
318
+ previous_stage: attempt.private_stage, checkout: attempt.checkout ? Object.freeze(clone(attempt.checkout)) : undefined,
319
+ savePrivateState: async (state, { messageId, suppressPrompt = false } = {}) => {
320
+ check(state?.checkout?.id === attempt.checkout_id && MESSAGE.test(messageId), 'invalid_response');
321
+ // These renderers revalidate the complete private projection. Only
322
+ // safe outgoing wording is eligible for durable storage.
323
+ const prompt = renderPrivateChatState(state, { messageId }), progress = privateChatProgress(state, { messageId });
324
+ const previousStage = attempt.private_stage;
325
+ observe(active.record, state.checkout); attempt.private_stage = state.stage;
326
+ await commit(active);
327
+ if (prompt && !suppressPrompt && state.stage !== 'review_delivery_required') await enqueue(active, { id: prompt.idempotency_key, kind: 'private_prompt', payload: { checkout_id: attempt.checkout_id, ...prompt }, delivered: false });
328
+ if (progress && (previousStage !== state.stage || state.duplicate)) await enqueue(active, { id: progress.idempotency_key, kind: 'safe_progress', payload: { checkout_id: attempt.checkout_id, ...progress }, delivered: false });
329
+ },
330
+ })); } finally { active.effectDepth--; }
331
+ if (previousPending && (recovery || nested)) attempt.pending = previousPending; else if (!recovery) delete attempt.pending;
332
+ await commit(active); return result;
333
+ } catch (error) {
334
+ // A reply may be replayed ONLY under its original authenticated provider
335
+ // ID, relying on the private service's durable deduplication. Other
336
+ // uncertain effects block future effects while retaining safe recovery.
337
+ if (!recovery && metadata.operation !== 'private_reply') { attempt.uncertain = true; attempt.phase = 'uncertain'; try { await commit(active); } catch {} }
338
+ throw new AccelerateError('request_failed');
339
+ }
340
+ }),
341
+ /** At-least-once SAFE delivery only. The downstream provider/queue must
342
+ * deduplicate item.id. Never put a payment action in this handler. */
343
+ drainOutbox: (context, deliver) => locked(context, async (active, resolved) => {
344
+ check(typeof deliver === 'function'); let delivered = 0;
345
+ for (const item of active.record.outbox) {
346
+ if (item.delivered) continue;
347
+ await deliver(Object.freeze(clone(item)), resolved);
348
+ item.delivered = true; await commit(active); delivered++;
349
+ }
350
+ return delivered;
351
+ }),
352
+ });
353
+ }