@andco/sdk 0.0.2 → 0.0.4

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 (83) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +96 -54
  3. package/dist/auth.d.ts +8 -3
  4. package/dist/auth.d.ts.map +1 -1
  5. package/dist/auth.js +32 -23
  6. package/dist/browser/controller.d.ts +55 -1
  7. package/dist/browser/controller.d.ts.map +1 -1
  8. package/dist/browser/controller.js +166 -9
  9. package/dist/browser/frame.d.ts +4 -3
  10. package/dist/browser/frame.d.ts.map +1 -1
  11. package/dist/browser/frame.js +12 -20
  12. package/dist/browser/index.d.ts +18 -6
  13. package/dist/browser/index.d.ts.map +1 -1
  14. package/dist/browser/index.js +64 -29
  15. package/dist/browser/popup.d.ts +6 -1
  16. package/dist/browser/popup.d.ts.map +1 -1
  17. package/dist/browser/popup.js +37 -8
  18. package/dist/cli/index.d.ts +4 -2
  19. package/dist/cli/index.d.ts.map +1 -1
  20. package/dist/cli/index.js +10 -3
  21. package/dist/cli/server.d.ts +5 -0
  22. package/dist/cli/server.d.ts.map +1 -1
  23. package/dist/cli/server.js +5 -0
  24. package/dist/client.d.ts +66 -19
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +125 -74
  27. package/dist/config.d.ts +2 -1
  28. package/dist/config.d.ts.map +1 -1
  29. package/dist/config.js +1 -0
  30. package/dist/credentials.d.ts +58 -4
  31. package/dist/credentials.d.ts.map +1 -1
  32. package/dist/credentials.js +0 -0
  33. package/dist/errors.d.ts +23 -21
  34. package/dist/errors.d.ts.map +1 -1
  35. package/dist/errors.js +18 -20
  36. package/dist/globals.d.ts +25 -0
  37. package/dist/globals.d.ts.map +1 -0
  38. package/dist/globals.js +15 -0
  39. package/dist/grants-api.d.ts +34 -0
  40. package/dist/grants-api.d.ts.map +1 -0
  41. package/dist/grants-api.js +48 -0
  42. package/dist/grants.d.ts +16 -0
  43. package/dist/grants.d.ts.map +1 -0
  44. package/dist/grants.js +13 -0
  45. package/dist/index.d.ts +12 -6
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +8 -2
  48. package/dist/inflight.d.ts +31 -0
  49. package/dist/inflight.d.ts.map +1 -0
  50. package/dist/inflight.js +26 -0
  51. package/dist/intents.d.ts +386 -40
  52. package/dist/intents.d.ts.map +1 -1
  53. package/dist/intents.js +712 -53
  54. package/dist/oauth.d.ts +55 -6
  55. package/dist/oauth.d.ts.map +1 -1
  56. package/dist/oauth.js +86 -57
  57. package/dist/presenter.d.ts +59 -11
  58. package/dist/presenter.d.ts.map +1 -1
  59. package/dist/presenter.js +40 -1
  60. package/dist/resource.d.ts +109 -0
  61. package/dist/resource.d.ts.map +1 -0
  62. package/dist/resource.js +151 -0
  63. package/dist/rest.d.ts +23 -4
  64. package/dist/rest.d.ts.map +1 -1
  65. package/dist/rest.js +46 -5
  66. package/dist/server/index.d.ts +3 -0
  67. package/dist/server/index.d.ts.map +1 -1
  68. package/dist/server/index.js +1 -0
  69. package/dist/server-metadata.generated.d.ts.map +1 -1
  70. package/dist/server-metadata.generated.js +8 -4
  71. package/dist/service.d.ts +109 -0
  72. package/dist/service.d.ts.map +1 -0
  73. package/dist/service.js +241 -0
  74. package/dist/session-store.d.ts +12 -4
  75. package/dist/session-store.d.ts.map +1 -1
  76. package/dist/session-store.js +64 -14
  77. package/dist/storage.d.ts +16 -23
  78. package/dist/storage.d.ts.map +1 -1
  79. package/dist/storage.js +27 -25
  80. package/dist/tokens.d.ts +46 -0
  81. package/dist/tokens.d.ts.map +1 -0
  82. package/dist/tokens.js +148 -0
  83. package/package.json +13 -3
package/dist/intents.js CHANGED
@@ -1,27 +1,64 @@
1
- import { RANDOM_UUID } from "@andco/protocol";
2
- import { subscribeFinancialEvents, } from "@andco/protocol/transport";
1
+ import { ANDCO_INTENT_PROPOSAL_MEDIA_TYPE, ANDCO_SIGNED_INSTRUCTION_MEDIA_TYPE, CALLBACK_STATE_FROM, callbackStateCreate, RANDOM_UUID, SafeURL, safeParseIntentCancellation, safeParseIntentRejection, } from "@andco/protocol";
2
+ import { ANDCO_INTENT_AUTHORIZATION_URL, INTENT_ID_SYNTAX, subscribeFinancialEvents, } from "@andco/protocol/transport";
3
3
  import { ANDCO_ERROR_CODES, AndcoError, Result } from "./errors.js";
4
- const INTENT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
+ import { resolveGlobals } from "./globals.js";
5
+ /** Derived from the transport's strict check so this layer cannot accept what it would reject. */
6
+ const INTENT_ID_PATTERN = /* @__PURE__ */ new RegExp(INTENT_ID_SYNTAX, "i");
5
7
  const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
8
+ /** A compact JWS envelope. Whether it verifies, and what it says, are the server's answers to give. */
9
+ const SIGNED_INSTRUCTION_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
10
+ /** Where a presentation that leaves this document waits for {@link AndcoIntents.fromCallback}. */
11
+ const PENDING_PRESENTATION_KEY = "andco.intent_presentation";
12
+ /** Statuses after which an Intent never changes again. `failed.*` sub-statuses are terminal too. */
13
+ const TERMINAL_INTENT_STATUSES = /* @__PURE__ */ new Set([
14
+ "succeeded",
15
+ "completed",
16
+ "closed",
17
+ "cancelled",
18
+ "rejected",
19
+ "expired",
20
+ ]);
6
21
  /**
7
- * Creates and reads Intents under the current Grant, and observes the durable facts they produce.
22
+ * Creates, presents, reads and follows Intents under the current Grant.
8
23
  *
9
- * This is the domain-neutral Intent lifecycle only. Deposit, withdrawal, automatic charge, and the
10
- * four typed event subscriptions that used to live here now belong to the Bank Resource Server
11
- * Definition, which composes `create`, `events`, and `subscribe` exactly as a third party would —
12
- * a defect in this extension point is meant to surface in Andco's own code first.
24
+ * Every method that names an existing Intent takes the Intent or its id. Presentation is one
25
+ * controller for three shapes — popup, new tab, redirect — and the SDK always generates and checks
26
+ * the callback `state`, so an application never correlates callbacks itself.
13
27
  *
14
28
  * @example
15
29
  * ```ts
16
- * const { data: deposit } = await andco.intents.create("deposit", depositInput);
30
+ * const { data: deposit } = await bank.intents.createDeposit(input);
31
+ * const presentation = andco.intents.present(deposit, { presentation: "popup" });
32
+ * const { data } = await presentation.result;
33
+ * if (data?.outcome === "complete") refreshBalance();
17
34
  * ```
18
35
  */
19
36
  export class AndcoIntents {
20
37
  #rest;
21
- #crypto;
22
- constructor(rest, crypto) {
23
- this.#rest = rest;
24
- this.#crypto = crypto;
38
+ #config;
39
+ #presenter;
40
+ #globals;
41
+ #storage;
42
+ /** Window presentations still open, by Intent id: what makes `present` idempotent per Intent. */
43
+ #presenting = new Map();
44
+ constructor(options) {
45
+ this.#rest = options.rest;
46
+ this.#config = options.config;
47
+ this.#presenter = options.presenter;
48
+ this.#globals = options.globals ?? resolveGlobals();
49
+ this.#storage = options.presentationStorage ?? defaultPresentationStorage();
50
+ }
51
+ /**
52
+ * Whether an Intent has reached a status it never leaves: succeeded, completed, closed, cancelled,
53
+ * rejected, expired, or any `failed` status.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * if (AndcoIntents.isTerminal(intent)) stopPolling();
58
+ * ```
59
+ */
60
+ static isTerminal(intent) {
61
+ return TERMINAL_INTENT_STATUSES.has(intent.status) || intent.status.startsWith("failed");
25
62
  }
26
63
  /**
27
64
  * Creates an Intent of the given type. The exact Grant determines what it may do and where.
@@ -35,7 +72,7 @@ export class AndcoIntents {
35
72
  * Definition names its own response type here.
36
73
  */
37
74
  async create(type, input, options = {}) {
38
- const idempotencyKey = options.idempotencyKey ?? RANDOM_UUID(this.#crypto);
75
+ const idempotencyKey = options.idempotencyKey ?? RANDOM_UUID(this.#globals.crypto);
39
76
  if (!IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
40
77
  return Result.fail(ANDCO_ERROR_CODES.INVALID_CONFIGURATION, { message: "malformed Idempotency-Key" });
41
78
  }
@@ -44,6 +81,9 @@ export class AndcoIntents {
44
81
  ({ data } = await this.#rest.http
45
82
  // The generated body type is a bank-specific union; this method is neutral over `type`.
46
83
  .POST("/intents", {
84
+ // The endpoint answers to two content types. This is the proposal half, and saying so is
85
+ // what keeps it from depending on which one the transport happens to default to.
86
+ headers: { "Content-Type": ANDCO_INTENT_PROPOSAL_MEDIA_TYPE },
47
87
  params: { header: { "Idempotency-Key": idempotencyKey } },
48
88
  body: { ...input, type },
49
89
  })
@@ -57,8 +97,370 @@ export class AndcoIntents {
57
97
  }
58
98
  return Result.ok(data);
59
99
  }
60
- /** Reads the authoritative state of one Intent. This, not a callback, is the source of truth. */
61
- async get(intentId) {
100
+ /**
101
+ * Sends one already-signed Instruction: the `application/jwt` half of the same creation.
102
+ *
103
+ * The body is the compact JWS itself, and every value the operation uses comes from its verified
104
+ * claims — nothing is read from the envelope carrying them, so `type` is only checked against the
105
+ * response here and never written into a request. No `Idempotency-Key` accompanies it either: the
106
+ * key is the `jti` inside the signature, and the server answers `400 idempotency_key_not_allowed`
107
+ * to a second candidate rather than quietly picking one.
108
+ */
109
+ async createSigned(type, instruction) {
110
+ if (!SIGNED_INSTRUCTION_PATTERN.test(instruction)) {
111
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CONFIGURATION, { message: "malformed Signed Instruction" });
112
+ }
113
+ let data;
114
+ try {
115
+ ({ data } = await this.#rest.http
116
+ .POST("/intents", {
117
+ headers: { "Content-Type": ANDCO_SIGNED_INSTRUCTION_MEDIA_TYPE },
118
+ // A compact JWS is already serialized; the default serializer would JSON-quote it.
119
+ bodySerializer: (body) => body,
120
+ body: instruction,
121
+ })
122
+ .throwOnError());
123
+ }
124
+ catch (cause) {
125
+ return Result.fail(cause);
126
+ }
127
+ if (data?.type !== type) {
128
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_RESPONSE, { message: `expected a ${type} intent` });
129
+ }
130
+ return Result.ok(data);
131
+ }
132
+ /**
133
+ * The Andco-owned URL that presents one Intent, with its callbacks bound. Synchronous: it reads
134
+ * the Intent's own `authorization_url`, checks it belongs to the instance's interaction origin,
135
+ * and adds the four parameters Platform requires (`return_uri`, `error_return_uri`,
136
+ * `presentation`, `state`) — a bare `authorization_url` is an input, not a destination.
137
+ *
138
+ * Never intercepted by a native Host: it is a URL, not a presentation. Use it to redirect a payer
139
+ * from a server, or to put the link somewhere the SDK does not open itself. When only the id is at
140
+ * hand, {@link AndcoIntents.presentationURLFor} reads the Intent first.
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * const { data: url } = andco.intents.presentationURL(deposit, { presentation: "redirect", returnTo });
145
+ * return Response.redirect(url);
146
+ * ```
147
+ */
148
+ presentationURL(intent, options) {
149
+ if (!this.#config)
150
+ return Result.fail(presentationUnsupported());
151
+ const callbacks = this.#callbacks(options);
152
+ if (callbacks.error)
153
+ return Result.fail(callbacks.error);
154
+ const { returnTo, errorReturnTo } = callbacks.data;
155
+ let url;
156
+ let state;
157
+ try {
158
+ url = ANDCO_INTENT_AUTHORIZATION_URL(intent, this.#config.endpoints.api);
159
+ state = options.state ?? callbackStateCreate(returnTo.origin, RANDOM_UUID(this.#globals.crypto));
160
+ }
161
+ catch (cause) {
162
+ console.error("[AndcoIntents.presentationURL] invalid authorization_url %o", cause);
163
+ return Result.fail(AndcoError.from(cause, ANDCO_ERROR_CODES.INVALID_RESPONSE));
164
+ }
165
+ url.searchParams.set("return_uri", returnTo.href);
166
+ url.searchParams.set("error_return_uri", errorReturnTo.href);
167
+ url.searchParams.set("presentation", options.presentation);
168
+ url.searchParams.set("state", state);
169
+ console.debug("[AndcoIntents.presentationURL] built %s for %s", options.presentation, url.origin + url.pathname);
170
+ return Result.ok(url);
171
+ }
172
+ /**
173
+ * {@link AndcoIntents.presentationURL} for a caller holding only the id: reads the Intent, then
174
+ * builds the same URL. The one asynchronous way to a presentation URL.
175
+ *
176
+ * @example
177
+ * ```ts
178
+ * const { data: url } = await andco.intents.presentationURLFor(intentId, { presentation: "popup" });
179
+ * ```
180
+ */
181
+ async presentationURLFor(intentId, options) {
182
+ if (!this.#config)
183
+ return Result.fail(presentationUnsupported());
184
+ const read = await this.get(intentId);
185
+ if (read.error)
186
+ return Result.fail(read.error);
187
+ return this.presentationURL(read.data, options);
188
+ }
189
+ /**
190
+ * Presents one Intent and hands back a controller for it at once.
191
+ *
192
+ * `"popup"` and `"newtab"` open a window during the current user activation — call this
193
+ * synchronously from the click handler — and `result` settles when it closes, with the Intent read
194
+ * again: a callback reports that a window finished, which is not the same fact as a completed
195
+ * deposit, and a dismissal is read too because the person may have closed the window on an
196
+ * operation the bank had already accepted. Inside the first-party native Host both are presented
197
+ * by the Host instead of a window.
198
+ *
199
+ * `"redirect"` navigates this document away; `status` becomes `"navigated"` and `result` never
200
+ * settles. The return page reads the outcome with {@link AndcoIntents.fromCallback}.
201
+ *
202
+ * `state` is always generated here and checked on the way back. The source may be a factory,
203
+ * resolved only after the window is open, so creating the Intent on the click does not get the
204
+ * popup blocked.
205
+ *
206
+ * Idempotent per Intent within one Andco Instance: while a presentation of the same Intent id is
207
+ * still `"presenting"`, another call focuses the window already open and returns the SAME
208
+ * controller — never a second popup or tab, whatever options the second call carried. Once that
209
+ * presentation settles, a new call opens a new one. A `"redirect"` is unaffected, since it replaces
210
+ * the document, and a factory cannot be matched before it resolves, so it always presents.
211
+ *
212
+ * That is the window's idempotency. The Intent's own is server-side: a fixed-amount deposit or a
213
+ * transfer cannot be paid or confirmed twice however many windows reach it, whereas an open
214
+ * collection (no amount) accepts several contributions by design.
215
+ *
216
+ * @example
217
+ * ```ts
218
+ * const presentation = andco.intents.present(() => createDeposit(), { presentation: "popup" });
219
+ * cancelButton.onclick = () => presentation.close();
220
+ * const { data, error } = await presentation.result;
221
+ * if (data?.outcome === "complete") refreshBalance();
222
+ * ```
223
+ */
224
+ present(source, options = {}) {
225
+ const presentation = options.presentation ?? "popup";
226
+ const intentId = typeof source === "string" ? source : typeof source === "object" ? source.id : undefined;
227
+ if (intentId !== undefined && presentation !== "redirect") {
228
+ const existing = this.#presenting.get(intentId);
229
+ if (existing && existing.controller.status === "presenting") {
230
+ console.debug("[AndcoIntents.present] %s is already presenting; focusing %s", intentId, existing.presentationId);
231
+ this.#presenter?.focus?.(existing.presentationId);
232
+ return existing.controller;
233
+ }
234
+ }
235
+ let status = "presenting";
236
+ const abort = new AbortController();
237
+ if (options.signal?.aborted)
238
+ abort.abort();
239
+ options.signal?.addEventListener("abort", () => abort.abort(), { once: true });
240
+ const presentationId = RANDOM_UUID(this.#globals.crypto);
241
+ // Called synchronously: everything up to the presenter runs inside the caller's user activation.
242
+ const result = this.#present(source, options, presentationId, abort.signal, (next) => {
243
+ status = next;
244
+ });
245
+ const controller = {
246
+ get status() {
247
+ return status;
248
+ },
249
+ result,
250
+ close: () => {
251
+ console.debug("[AndcoIntents.present] close requested while %s", status);
252
+ abort.abort();
253
+ },
254
+ };
255
+ if (intentId !== undefined && presentation !== "redirect") {
256
+ this.#presenting.set(intentId, {
257
+ controller: controller,
258
+ presentationId,
259
+ });
260
+ const release = () => {
261
+ if (this.#presenting.get(intentId)?.controller === controller)
262
+ this.#presenting.delete(intentId);
263
+ };
264
+ void result.then(release, release);
265
+ }
266
+ return controller;
267
+ }
268
+ async #present(source, options, presentationId, signal, setStatus) {
269
+ const fail = (error) => {
270
+ console.error("[AndcoIntents.present] %o", error);
271
+ setStatus("error");
272
+ return Result.fail(error);
273
+ };
274
+ if (!this.#config || !this.#presenter) {
275
+ return fail(presentationUnsupported());
276
+ }
277
+ const presentation = options.presentation ?? "popup";
278
+ const callbacks = this.#callbacks(options);
279
+ if (callbacks.error) {
280
+ return fail(callbacks.error);
281
+ }
282
+ const { returnTo, errorReturnTo } = callbacks.data;
283
+ // The same encoding OAuth uses: it binds the opener origin the callback must post back to, and
284
+ // its alphabet is the one Platform accepts for `state`.
285
+ const state = callbackStateCreate(returnTo.origin, presentationId);
286
+ const urlOptions = { presentation, returnTo, errorReturnTo, state };
287
+ let intentId;
288
+ const destination = (intent) => {
289
+ const built = this.presentationURL(intent, urlOptions);
290
+ if (built.error)
291
+ throw built.error;
292
+ intentId = intent.id;
293
+ // A presentation that leaves this document is read back on another load; remember it there.
294
+ if (presentation !== "popup")
295
+ this.#remember(presentationId, { intentId: intent.id, presentation });
296
+ return built.data;
297
+ };
298
+ // An Intent in hand builds its URL with no round trip; an id or a factory resolves after the
299
+ // presenter has its window.
300
+ let url;
301
+ if (typeof source === "object") {
302
+ try {
303
+ url = destination(source);
304
+ }
305
+ catch (cause) {
306
+ return fail(AndcoError.from(cause, ANDCO_ERROR_CODES.INVALID_RESPONSE));
307
+ }
308
+ }
309
+ else {
310
+ url = async () => {
311
+ let resolved;
312
+ try {
313
+ resolved = typeof source === "string" ? source : await source();
314
+ }
315
+ catch (cause) {
316
+ throw AndcoError.from(cause, ANDCO_ERROR_CODES.INVALID_CONFIGURATION);
317
+ }
318
+ if (typeof resolved === "object" && "authorization_url" in resolved) {
319
+ return destination(resolved);
320
+ }
321
+ const read = await this.get(typeof resolved === "string" ? resolved : resolved.id);
322
+ if (read.error) {
323
+ throw read.error;
324
+ }
325
+ return destination(read.data);
326
+ };
327
+ }
328
+ console.debug("[AndcoIntents.present] presenting %s presentationId=%s", presentation, presentationId);
329
+ const presented = await this.#presenter.present({
330
+ kind: "intent",
331
+ presentation,
332
+ url,
333
+ get intentId() {
334
+ return intentId ?? "";
335
+ },
336
+ returnTo,
337
+ errorReturnTo,
338
+ presentationId,
339
+ signal,
340
+ });
341
+ if (presented.error) {
342
+ this.#forget(presentationId);
343
+ return fail(presented.error);
344
+ }
345
+ if (!intentId) {
346
+ // The presenter settled without ever asking for a destination, so nothing was presented.
347
+ return fail(presentationUnsupported());
348
+ }
349
+ else if (presentation === "redirect" && presented.data === null) {
350
+ // The document is being replaced; the return page owns the result now.
351
+ console.debug("[AndcoIntents.present] navigated away intentId=%s", intentId);
352
+ setStatus("navigated");
353
+ return new Promise(() => { });
354
+ }
355
+ // Settled in this document after all, so nothing is left for a return page to read.
356
+ this.#forget(presentationId);
357
+ const outcome = INTENT_RESULT_FROM(presented.data, { presentationId });
358
+ if (outcome.error) {
359
+ return fail(outcome.error);
360
+ }
361
+ console.debug("[AndcoIntents.present] settled: %s intentId=%s presentationId=%s", outcome.data, intentId, presentationId);
362
+ // Authoritative, and read on a dismissal too: a closed window says nothing about the money.
363
+ const intent = await this.get(intentId);
364
+ if (intent.error) {
365
+ return fail(intent.error);
366
+ }
367
+ setStatus(outcome.data === "complete" ? "complete" : "dismissed");
368
+ return Result.ok({ outcome: outcome.data, intent: intent.data });
369
+ }
370
+ /**
371
+ * Reads an Intent Presentation's outcome on the page its callback landed on — the return page of
372
+ * a `"redirect"`, or of a `"newtab"` whose opener is gone — then reads the Intent again.
373
+ *
374
+ * The callback's `state` must belong to a presentation this browser started with
375
+ * {@link AndcoIntents.present}; anything else is refused with `invalid_callback`, and each
376
+ * presentation is consumed once. A server that built the URL with
377
+ * {@link AndcoIntents.presentationURL} passes the `state` it kept instead.
378
+ *
379
+ * @example
380
+ * ```ts
381
+ * const { data, error } = await andco.intents.fromCallback(window.location.href);
382
+ * if (data?.outcome === "complete") showReceipt(data.intent);
383
+ * ```
384
+ */
385
+ async fromCallback(callbackUrl, options = {}) {
386
+ const mismatch = (message) => {
387
+ console.error("[AndcoIntents.fromCallback] %s", message);
388
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CALLBACK, { message });
389
+ };
390
+ let url;
391
+ try {
392
+ url = new URL(callbackUrl);
393
+ }
394
+ catch {
395
+ return mismatch("the callback is not a URL");
396
+ }
397
+ const state = url.searchParams.get("state");
398
+ const parsed = state ? CALLBACK_STATE_FROM(state) : null;
399
+ if (!state || !parsed)
400
+ return mismatch("the callback carries no valid state");
401
+ const returnedId = url.searchParams.get("intent_id");
402
+ let intentId;
403
+ if (options.state !== undefined) {
404
+ if (state !== options.state)
405
+ return mismatch("the callback does not belong to this presentation");
406
+ intentId = returnedId;
407
+ }
408
+ else {
409
+ const pending = this.#recall(parsed.presentationId);
410
+ if (!pending)
411
+ return mismatch("the callback does not belong to a presentation this browser started");
412
+ this.#forget(parsed.presentationId);
413
+ if (returnedId && returnedId !== pending.intentId) {
414
+ return mismatch("the callback names a different Intent than the one presented");
415
+ }
416
+ intentId = pending.intentId;
417
+ }
418
+ if (!intentId) {
419
+ return mismatch("the callback names no Intent");
420
+ }
421
+ const outcome = url.searchParams.get("andco_intent_result");
422
+ if (outcome !== "complete" && outcome !== "dismiss") {
423
+ return mismatch("the callback carries no Intent result");
424
+ }
425
+ console.debug("[AndcoIntents.fromCallback] %s intentId=%s", outcome, intentId);
426
+ const intent = await this.get(intentId);
427
+ if (intent.error) {
428
+ return Result.fail(intent.error);
429
+ }
430
+ return Result.ok({ outcome, intent: intent.data });
431
+ }
432
+ /**
433
+ * Reads the authoritative state of one Intent, as the Resource Server has it right now.
434
+ *
435
+ * This, never a callback, a window message or a webhook alone, is the source of truth: a callback
436
+ * reports that a window finished, and only this read says whether money moved. Accepts the Intent
437
+ * or just its id.
438
+ *
439
+ * Typical uses: re-reading after a page reload (keep the id, call `get`, or `watch` to follow it
440
+ * on); reading once `present` resolves, which {@link AndcoIntents.present} already does for
441
+ * its own `intent`; and, on your server, confirming `status` before marking an order paid or
442
+ * shipping goods — never trust the browser's word for it.
443
+ *
444
+ * Never throws; failures arrive as `error.code`:
445
+ * - `invalid_intent_id`: the id is not a well-formed UUID; no request was made.
446
+ * - `not_found`: no such Intent for this Project, or it belongs to another one. The API answers
447
+ * the same either way so ids cannot be probed.
448
+ * - `insufficient_authorization`: the Grant does not cover reading Intents of this family.
449
+ * - network and server failures carry their own code and are safe to retry.
450
+ *
451
+ * @example
452
+ * ```ts
453
+ * const { data: intent, error } = await andco.intents.get(intentOrId);
454
+ * if (error?.code === "not_found") return showUnknownOrder();
455
+ * if (intent && AndcoIntents.isTerminal(intent)) settleOrder(intent.status);
456
+ *
457
+ * // On the server, before marking the order paid:
458
+ * const { data } = await andco.intents.get(order.intentId);
459
+ * if (data?.status === "completed") await markPaid(order);
460
+ * ```
461
+ */
462
+ async get(intent) {
463
+ const intentId = idOf(intent);
62
464
  const invalid = assertIntentId(intentId);
63
465
  if (invalid)
64
466
  return Result.fail(invalid);
@@ -72,32 +474,166 @@ export class AndcoIntents {
72
474
  return Result.fail(cause);
73
475
  }
74
476
  }
75
- /** Executes an intent whose Grant permits it. Completion must still be confirmed with `get`. */
76
- async execute(intentId) {
477
+ /**
478
+ * Follows one Intent until it is terminal and answers it. The one call to re-attach to an Intent
479
+ * after a reload: keep the id, `watch` it, and the page is back where it was.
480
+ *
481
+ * Terminal means succeeded, completed, closed, cancelled, rejected, expired, or any `failed`
482
+ * status ({@link AndcoIntents.isTerminal}); the `result` then holds the Intent as the Resource
483
+ * Server has it. Built on {@link AndcoIntents.get}, which it repeats every `pollIntervalMs` — the
484
+ * authority, and the only way to notice an expiry — and on {@link AndcoIntents.subscribe}, whose
485
+ * events wake it up sooner. A read that fails is retried, so a dropped connection does not end
486
+ * the wait; a malformed id fails at once with `invalid_intent_id`.
487
+ *
488
+ * `stop()` or `signal` end it with `watch_stopped`; `intent` keeps the last read. Terminal means
489
+ * the Intent stopped changing, not that it succeeded: look at `status` before acting on money.
490
+ *
491
+ * @example
492
+ * ```ts
493
+ * const watch = andco.intents.watch(intentId);
494
+ * onUnmount(() => watch.stop());
495
+ * const { data: intent, error } = await watch.result;
496
+ * if (intent?.status === "completed") showReceipt(intent);
497
+ *
498
+ * // A milestone before the terminal status, with a deadline:
499
+ * andco.intents.watch(transfer, { types: ["transfer.accepted"], signal: AbortSignal.timeout(60_000) });
500
+ * ```
501
+ */
502
+ watch(intent, options = {}) {
503
+ const intentId = idOf(intent);
504
+ const pollIntervalMs = Math.max(options.pollIntervalMs ?? 1_000, 100);
505
+ let status = "watching";
506
+ let latest = null;
507
+ let timer;
508
+ let unsubscribe = () => { };
509
+ let finish = () => { };
510
+ const result = new Promise((resolve) => {
511
+ finish = (outcome, next) => {
512
+ if (status !== "watching")
513
+ return;
514
+ status = next;
515
+ clearTimeout(timer);
516
+ unsubscribe();
517
+ options.signal?.removeEventListener("abort", stop);
518
+ console.debug("[AndcoIntents.watch] %s ended %s %o", intentId, next, outcome.error ?? outcome.data?.status);
519
+ resolve(outcome);
520
+ };
521
+ });
522
+ const stop = () => finish(Result.fail(ANDCO_ERROR_CODES.WATCH_STOPPED), "stopped");
523
+ const controller = {
524
+ get status() {
525
+ return status;
526
+ },
527
+ get intent() {
528
+ return latest;
529
+ },
530
+ result,
531
+ stop,
532
+ };
533
+ const invalid = assertIntentId(intentId);
534
+ if (invalid) {
535
+ finish(Result.fail(invalid), "settled");
536
+ return controller;
537
+ }
538
+ if (options.signal?.aborted) {
539
+ stop();
540
+ return controller;
541
+ }
542
+ options.signal?.addEventListener("abort", stop, { once: true });
543
+ let reading = false;
544
+ let milestone = false;
545
+ const read = async () => {
546
+ // One read in flight at a time: an event feed replaying history must not fan out into N reads.
547
+ if (reading)
548
+ return;
549
+ reading = true;
550
+ clearTimeout(timer);
551
+ const current = await this.get(intentId);
552
+ reading = false;
553
+ if (status !== "watching")
554
+ return;
555
+ if (current.error) {
556
+ console.error("[AndcoIntents.watch] read of %s failed, retrying: %o", intentId, current.error);
557
+ options.onError?.(current.error);
558
+ }
559
+ else {
560
+ latest = current.data;
561
+ options.onChange?.(current.data);
562
+ if (milestone || AndcoIntents.isTerminal(current.data)) {
563
+ return finish(Result.ok(current.data), "settled");
564
+ }
565
+ }
566
+ timer = setTimeout(() => void read(), pollIntervalMs);
567
+ };
568
+ const types = options.types ? new Set(options.types) : null;
569
+ console.debug("[AndcoIntents.watch] %s until=%s types=%o", intentId, options.until ?? "terminal", options.types);
570
+ // Events wake the read up; whether the Intent is terminal is still decided by the read itself.
571
+ unsubscribe = this.subscribe(intentId, { pollIntervalMs, onError: () => { } }, (event) => {
572
+ if (types?.has(event.type))
573
+ milestone = true;
574
+ void read();
575
+ });
576
+ void read();
577
+ return controller;
578
+ }
579
+ /**
580
+ * Approves an Intent with this credential's Grant, which must hold `approve` on the family that
581
+ * releases it (`bank_transfer` for a transfer) for the same subject, within its maximum. The
582
+ * approver may be another app than the creator: dual control is two Grants. Answers the Intent;
583
+ * accepted is not moved, so wait for `transfer.succeeded` or `transfer.failed`.
584
+ *
585
+ * Fails with `amount_out_of_grant` above the approver's maximum (the Intent stays pending for
586
+ * another approver), `interaction_required` when a person still has to pick the source, and
587
+ * `intent_expired` past its deadline.
588
+ *
589
+ * @example
590
+ * ```ts
591
+ * const approver = andco.with(approverCredentials).intents;
592
+ * await approver.approve(transfer);
593
+ * await approver.approve(transferId, { grant: grantId }); // pin the approving Grant
594
+ * ```
595
+ */
596
+ async approve(intent, options = {}) {
597
+ const intentId = idOf(intent);
77
598
  const invalid = assertIntentId(intentId);
78
599
  if (invalid)
79
600
  return Result.fail(invalid);
601
+ console.debug("[AndcoIntents.approve] approving %s with grant %o", intentId, options.grant ?? "selected");
80
602
  try {
81
- await this.#rest.http
82
- .POST("/intents/{intent_id}/execute", { params: { path: { intent_id: intentId } } })
603
+ const { data } = await this.#rest.http
604
+ .POST("/intents/{intent_id}/approve", { params: { path: { intent_id: intentId } } }, { grant: options.grant })
83
605
  .throwOnError();
84
- return Result.ok(undefined);
606
+ return Result.ok(data);
85
607
  }
86
608
  catch (cause) {
87
609
  return Result.fail(cause);
88
610
  }
89
611
  }
90
- /** Reads one page of durable Intent facts, replaying after an opaque checkpoint. */
91
- async events(intentId, after, signal) {
612
+ /**
613
+ * Rejects an Intent as an approver, naming why. This credential's Grant must hold `reject` on the
614
+ * family that releases it for the same subject; no maximum applies. Recorded as a Rejection, which
615
+ * the audit keeps apart from the creator's Cancellation.
616
+ *
617
+ * @example
618
+ * ```ts
619
+ * await andco.with(approverCredentials).intents.reject(transferId, { reason: "duplicated_invoice" });
620
+ * ```
621
+ */
622
+ async reject(intent, options) {
623
+ const intentId = idOf(intent);
92
624
  const invalid = assertIntentId(intentId);
93
625
  if (invalid)
94
626
  return Result.fail(invalid);
627
+ const parsed = safeParseIntentRejection({ reason: options.reason });
628
+ if (!parsed.success) {
629
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CONFIGURATION, {
630
+ message: "rejection reason must be 1-280 characters",
631
+ });
632
+ }
633
+ console.debug("[AndcoIntents.reject] rejecting %s", intentId);
95
634
  try {
96
635
  const { data } = await this.#rest.http
97
- .GET("/intents/{intent_id}/events", {
98
- params: { path: { intent_id: intentId }, query: { after, limit: 100 } },
99
- signal,
100
- })
636
+ .POST("/intents/{intent_id}/reject", { params: { path: { intent_id: intentId } }, body: { reason: parsed.output.reason } }, { grant: options.grant })
101
637
  .throwOnError();
102
638
  return Result.ok(data);
103
639
  }
@@ -106,48 +642,171 @@ export class AndcoIntents {
106
642
  }
107
643
  }
108
644
  /**
109
- * Subscribes to a durable, paged feed of facts, reusing the Protocol Contract's paging, cursor,
110
- * checkpoint, and poll-interval reconciliation.
111
- *
112
- * Domain-neutral by design: the caller supplies both the page fetcher (`read`) and the `subject`
113
- * that scopes its cursor namespace, so nothing here assumes an Intent, an account, or any other
114
- * kind of subject. That is what lets a Resource Server Definition — the Bank one included —
115
- * compose this loop for its own event feed instead of rebuilding paging and checkpointing from
116
- * scratch, which is the one genuinely difficult piece of this surface.
117
- *
118
- * Every subscription returns its own unsubscribe function, so binding one to a framework effect —
119
- * `useEffect`, Svelte's `$effect`, Vue's `watchEffect` — is a single line.
645
+ * Withdraws an Intent this app created, naming why. Only the creator cancels; an approver
646
+ * declining it uses {@link AndcoIntents.reject}, and the audit keeps the two apart. Answers
647
+ * `intent_expired` when the Intent's deadline has already passed.
120
648
  *
121
649
  * @example
122
650
  * ```ts
123
- * const unsubscribe = andco.intents.subscribe(
124
- * { kind: "intent", id: intentId },
125
- * (after, signal) => andco.intents.events(intentId, after, signal),
126
- * (event) => (event.type.startsWith("deposit.") ? event : null),
127
- * (event) => setDeposit(event),
128
- * { onError },
129
- * );
651
+ * await andco.intents.cancel(transfer, { reason: "rejected_by_erp" });
130
652
  * ```
131
653
  */
132
- subscribe(subject, read, decode, handler, options) {
654
+ async cancel(intent, options) {
655
+ const intentId = idOf(intent);
656
+ const invalid = assertIntentId(intentId);
657
+ if (invalid)
658
+ return Result.fail(invalid);
659
+ const parsed = safeParseIntentCancellation({ reason: options.reason });
660
+ if (!parsed.success) {
661
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CONFIGURATION, {
662
+ message: "cancellation reason must be 1-280 characters",
663
+ });
664
+ }
665
+ console.debug("[AndcoIntents.cancel] cancelling %s", intentId);
666
+ try {
667
+ const { data } = await this.#rest.http
668
+ .POST("/intents/{intent_id}/cancel", { params: { path: { intent_id: intentId } }, body: { reason: parsed.output.reason } }, { grant: options.grant })
669
+ .throwOnError();
670
+ return Result.ok(data);
671
+ }
672
+ catch (cause) {
673
+ return Result.fail(cause);
674
+ }
675
+ }
676
+ /** Reads one page of durable Intent facts, replaying after an opaque checkpoint. */
677
+ async events(intent, after, signal) {
678
+ const intentId = idOf(intent);
679
+ const invalid = assertIntentId(intentId);
680
+ if (invalid)
681
+ return Result.fail(invalid);
682
+ try {
683
+ const { data } = await this.#rest.http
684
+ .GET("/intents/{intent_id}/events", {
685
+ params: { path: { intent_id: intentId }, query: { after, limit: 100 } },
686
+ signal,
687
+ })
688
+ .throwOnError();
689
+ return Result.ok(data);
690
+ }
691
+ catch (cause) {
692
+ return Result.fail(cause);
693
+ }
694
+ }
695
+ subscribe(intent, optionsOrHandler, maybeHandler) {
696
+ const options = typeof optionsOrHandler === "function" ? {} : optionsOrHandler;
697
+ const handler = typeof optionsOrHandler === "function" ? optionsOrHandler : maybeHandler;
698
+ const intentId = idOf(intent);
699
+ const onError = options.onError ?? ((error) => console.error("[AndcoIntents.subscribe] %s %o", intentId, error));
700
+ const invalid = assertIntentId(intentId);
701
+ if (invalid) {
702
+ queueMicrotask(() => onError(invalid));
703
+ return () => { };
704
+ }
705
+ const types = options.types ? new Set(options.types) : null;
706
+ console.debug("[AndcoIntents.subscribe] %s types=%o after=%s", intentId, options.types, options.after);
133
707
  const subscription = subscribeFinancialEvents(async (after, signal) => {
134
- const page = await read(after, signal);
708
+ const page = await this.events(intentId, after, signal);
135
709
  if (page.error)
136
710
  throw page.error;
137
711
  return page.data;
138
- }, decode,
712
+ }, (event) => (types === null || types.has(event.type) ? event : null),
139
713
  // The checkpoint is pushed only once a handler has succeeded, so persisting it can never
140
714
  // acknowledge an event the application failed to process.
141
715
  async (event) => {
142
716
  await handler(event);
143
717
  options.onCheckpoint?.(event.cursor);
144
- }, {
145
- ...(options.after === undefined ? {} : { after: options.after }),
146
- onError: options.onError,
147
- ...(options.pollIntervalMs === undefined ? {} : { pollIntervalMs: options.pollIntervalMs }),
148
- }, subject);
718
+ }, { after: options.after, onError, pollIntervalMs: options.pollIntervalMs }, { kind: "intent", id: intentId });
149
719
  return () => subscription.unsubscribe();
150
720
  }
721
+ /** Resolves and checks the two callbacks a presentation returns through. */
722
+ #callbacks(options) {
723
+ let returnTo;
724
+ let errorReturnTo;
725
+ try {
726
+ returnTo = options.returnTo ? new SafeURL(options.returnTo) : this.#config?.redirectTo;
727
+ if (!returnTo) {
728
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CONFIGURATION, {
729
+ message: "an Intent Presentation needs a returnTo, and this instance has no redirectTo",
730
+ });
731
+ }
732
+ errorReturnTo = options.errorReturnTo ? new SafeURL(options.errorReturnTo) : returnTo;
733
+ }
734
+ catch (cause) {
735
+ return Result.fail(AndcoError.from(cause, ANDCO_ERROR_CODES.INVALID_CONFIGURATION));
736
+ }
737
+ if (returnTo.origin !== errorReturnTo.origin) {
738
+ // Platform refuses a mismatch by rendering a 404. Failing here says why instead.
739
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CONFIGURATION, {
740
+ message: "returnTo and errorReturnTo must share one origin",
741
+ });
742
+ }
743
+ return Result.ok({ returnTo, errorReturnTo });
744
+ }
745
+ #remember(presentationId, pending) {
746
+ if (!this.#storage) {
747
+ console.warn("[AndcoIntents.present] no presentationStorage; fromCallback will refuse this return");
748
+ return;
749
+ }
750
+ this.#storage.setItem(`${PENDING_PRESENTATION_KEY}.${presentationId}`, JSON.stringify(pending));
751
+ }
752
+ #recall(presentationId) {
753
+ const stored = this.#storage?.getItem(`${PENDING_PRESENTATION_KEY}.${presentationId}`);
754
+ if (!stored)
755
+ return null;
756
+ try {
757
+ const parsed = JSON.parse(stored);
758
+ return typeof parsed.intentId === "string" && typeof parsed.presentation === "string"
759
+ ? parsed
760
+ : null;
761
+ }
762
+ catch {
763
+ return null;
764
+ }
765
+ }
766
+ #forget(presentationId) {
767
+ this.#storage?.removeItem(`${PENDING_PRESENTATION_KEY}.${presentationId}`);
768
+ }
769
+ }
770
+ /**
771
+ * Reads the lifecycle outcome out of the callback a presentation came back with.
772
+ *
773
+ * A closed window with no callback is a dismissal, never a failure. A callback whose `state` does
774
+ * not carry this presentation is not ours and is refused rather than guessed at.
775
+ */
776
+ function INTENT_RESULT_FROM(callbackUrl, presentation) {
777
+ if (!callbackUrl)
778
+ return Result.ok("dismiss");
779
+ const state = callbackUrl.searchParams.get("state");
780
+ const parsed = state ? CALLBACK_STATE_FROM(state) : null;
781
+ if (!parsed || parsed.presentationId !== presentation.presentationId) {
782
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CALLBACK, {
783
+ message: "the callback does not belong to this presentation",
784
+ });
785
+ }
786
+ const result = callbackUrl.searchParams.get("andco_intent_result");
787
+ if (result === "complete")
788
+ return Result.ok("complete");
789
+ if (result === "dismiss")
790
+ return Result.ok("dismiss");
791
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CALLBACK, { message: "the callback carries no Intent result" });
792
+ }
793
+ /** `sessionStorage` when the runtime has one and allows it; nothing on a server. */
794
+ function defaultPresentationStorage() {
795
+ try {
796
+ return typeof sessionStorage === "undefined" ? undefined : sessionStorage;
797
+ }
798
+ catch {
799
+ return undefined;
800
+ }
801
+ }
802
+ /** One message for every surface that has the lifecycle but cannot put a window in front of anyone. */
803
+ function presentationUnsupported() {
804
+ return new AndcoError(ANDCO_ERROR_CODES.PRESENTATION_UNSUPPORTED, {
805
+ message: "this Intent surface was composed without a presenter; present through the Andco Instance instead",
806
+ });
807
+ }
808
+ function idOf(intent) {
809
+ return typeof intent === "string" ? intent : intent.id;
151
810
  }
152
811
  /** Intent identifiers are UUIDs; anything else never reaches the network. */
153
812
  function assertIntentId(value) {