@andco/sdk 0.0.3 → 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 (82) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +93 -49
  3. package/dist/auth.d.ts +5 -3
  4. package/dist/auth.d.ts.map +1 -1
  5. package/dist/auth.js +10 -17
  6. package/dist/browser/controller.d.ts +46 -0
  7. package/dist/browser/controller.d.ts.map +1 -1
  8. package/dist/browser/controller.js +102 -12
  9. package/dist/browser/frame.d.ts.map +1 -1
  10. package/dist/browser/frame.js +6 -15
  11. package/dist/browser/index.d.ts +8 -2
  12. package/dist/browser/index.d.ts.map +1 -1
  13. package/dist/browser/index.js +48 -33
  14. package/dist/browser/popup.d.ts +4 -0
  15. package/dist/browser/popup.d.ts.map +1 -1
  16. package/dist/browser/popup.js +23 -7
  17. package/dist/cli/index.d.ts +4 -6
  18. package/dist/cli/index.d.ts.map +1 -1
  19. package/dist/cli/index.js +7 -13
  20. package/dist/cli/server.d.ts +5 -0
  21. package/dist/cli/server.d.ts.map +1 -1
  22. package/dist/cli/server.js +5 -0
  23. package/dist/client.d.ts +66 -19
  24. package/dist/client.d.ts.map +1 -1
  25. package/dist/client.js +117 -78
  26. package/dist/config.d.ts +2 -1
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +1 -0
  29. package/dist/credentials.d.ts +58 -4
  30. package/dist/credentials.d.ts.map +1 -1
  31. package/dist/credentials.js +0 -0
  32. package/dist/errors.d.ts +23 -21
  33. package/dist/errors.d.ts.map +1 -1
  34. package/dist/errors.js +18 -20
  35. package/dist/globals.d.ts +25 -0
  36. package/dist/globals.d.ts.map +1 -0
  37. package/dist/globals.js +15 -0
  38. package/dist/grants-api.d.ts +34 -0
  39. package/dist/grants-api.d.ts.map +1 -0
  40. package/dist/grants-api.js +48 -0
  41. package/dist/grants.d.ts +16 -0
  42. package/dist/grants.d.ts.map +1 -0
  43. package/dist/grants.js +13 -0
  44. package/dist/index.d.ts +12 -6
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +8 -3
  47. package/dist/inflight.d.ts +31 -0
  48. package/dist/inflight.d.ts.map +1 -0
  49. package/dist/inflight.js +26 -0
  50. package/dist/intents.d.ts +346 -70
  51. package/dist/intents.d.ts.map +1 -1
  52. package/dist/intents.js +629 -112
  53. package/dist/oauth.d.ts +55 -6
  54. package/dist/oauth.d.ts.map +1 -1
  55. package/dist/oauth.js +86 -57
  56. package/dist/presenter.d.ts +25 -11
  57. package/dist/presenter.d.ts.map +1 -1
  58. package/dist/presenter.js +39 -6
  59. package/dist/resource.d.ts +109 -0
  60. package/dist/resource.d.ts.map +1 -0
  61. package/dist/resource.js +151 -0
  62. package/dist/rest.d.ts +23 -4
  63. package/dist/rest.d.ts.map +1 -1
  64. package/dist/rest.js +46 -5
  65. package/dist/server/index.d.ts +3 -0
  66. package/dist/server/index.d.ts.map +1 -1
  67. package/dist/server/index.js +1 -0
  68. package/dist/server-metadata.generated.d.ts.map +1 -1
  69. package/dist/server-metadata.generated.js +8 -4
  70. package/dist/service.d.ts +109 -0
  71. package/dist/service.d.ts.map +1 -0
  72. package/dist/service.js +241 -0
  73. package/dist/session-store.d.ts +12 -4
  74. package/dist/session-store.d.ts.map +1 -1
  75. package/dist/session-store.js +64 -14
  76. package/dist/storage.d.ts +16 -23
  77. package/dist/storage.d.ts.map +1 -1
  78. package/dist/storage.js +27 -25
  79. package/dist/tokens.d.ts +46 -0
  80. package/dist/tokens.d.ts.map +1 -0
  81. package/dist/tokens.js +148 -0
  82. package/package.json +13 -3
package/dist/intents.js CHANGED
@@ -1,31 +1,64 @@
1
- import { CALLBACK_STATE_FROM, callbackStateCreate, RANDOM_UUID, SafeURL, } from "@andco/protocol";
2
- import { ANDCO_INTENT_AUTHORIZATION_URL, 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
38
  #config;
22
39
  #presenter;
23
- #crypto;
40
+ #globals;
41
+ #storage;
42
+ /** Window presentations still open, by Intent id: what makes `present` idempotent per Intent. */
43
+ #presenting = new Map();
24
44
  constructor(options) {
25
45
  this.#rest = options.rest;
26
46
  this.#config = options.config;
27
47
  this.#presenter = options.presenter;
28
- this.#crypto = options.crypto;
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");
29
62
  }
30
63
  /**
31
64
  * Creates an Intent of the given type. The exact Grant determines what it may do and where.
@@ -39,7 +72,7 @@ export class AndcoIntents {
39
72
  * Definition names its own response type here.
40
73
  */
41
74
  async create(type, input, options = {}) {
42
- const idempotencyKey = options.idempotencyKey ?? RANDOM_UUID(this.#crypto);
75
+ const idempotencyKey = options.idempotencyKey ?? RANDOM_UUID(this.#globals.crypto);
43
76
  if (!IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
44
77
  return Result.fail(ANDCO_ERROR_CODES.INVALID_CONFIGURATION, { message: "malformed Idempotency-Key" });
45
78
  }
@@ -48,6 +81,9 @@ export class AndcoIntents {
48
81
  ({ data } = await this.#rest.http
49
82
  // The generated body type is a bank-specific union; this method is neutral over `type`.
50
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 },
51
87
  params: { header: { "Idempotency-Key": idempotencyKey } },
52
88
  body: { ...input, type },
53
89
  })
@@ -62,116 +98,369 @@ export class AndcoIntents {
62
98
  return Result.ok(data);
63
99
  }
64
100
  /**
65
- * The Andco-owned URL that presents one Intent, with its callbacks bound.
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.
66
137
  *
67
- * Published rather than folded into `present` because the Hosted Intent Button needs the same URL
68
- * with a different `state`: its callback returns to the widget that opened the window, not to
69
- * this document. Two constructions of one URL is precisely the drift ADR-0014 records, and a bare
70
- * `authorization_url` is an input to presentation, not a destination — Platform renders a 404 for
71
- * any of the four parameters below that is missing.
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
+ * ```
72
147
  */
73
- async presentationURL(options) {
148
+ presentationURL(intent, options) {
74
149
  if (!this.#config)
75
150
  return Result.fail(presentationUnsupported());
76
- const invalid = assertIntentId(options.intentId);
77
- if (invalid)
78
- return Result.fail(invalid);
79
- if (options.returnTo.origin !== options.errorReturnTo.origin) {
80
- // Platform refuses a mismatch by rendering a 404. Failing here says why instead.
81
- return Result.fail(ANDCO_ERROR_CODES.INVALID_CONFIGURATION, {
82
- message: "returnTo and errorReturnTo must share one origin",
83
- });
84
- }
85
- const read = await this.get(options.intentId);
86
- if (read.error)
87
- return Result.fail(read.error);
151
+ const callbacks = this.#callbacks(options);
152
+ if (callbacks.error)
153
+ return Result.fail(callbacks.error);
154
+ const { returnTo, errorReturnTo } = callbacks.data;
88
155
  let url;
156
+ let state;
89
157
  try {
90
- url = ANDCO_INTENT_AUTHORIZATION_URL(read.data, this.#config.endpoints.api);
158
+ url = ANDCO_INTENT_AUTHORIZATION_URL(intent, this.#config.endpoints.api);
159
+ state = options.state ?? callbackStateCreate(returnTo.origin, RANDOM_UUID(this.#globals.crypto));
91
160
  }
92
161
  catch (cause) {
162
+ console.error("[AndcoIntents.presentationURL] invalid authorization_url %o", cause);
93
163
  return Result.fail(AndcoError.from(cause, ANDCO_ERROR_CODES.INVALID_RESPONSE));
94
164
  }
95
- url.searchParams.set("return_uri", options.returnTo.href);
96
- url.searchParams.set("error_return_uri", options.errorReturnTo.href);
165
+ url.searchParams.set("return_uri", returnTo.href);
166
+ url.searchParams.set("error_return_uri", errorReturnTo.href);
97
167
  url.searchParams.set("presentation", options.presentation);
98
- url.searchParams.set("state", options.state);
168
+ url.searchParams.set("state", state);
169
+ console.debug("[AndcoIntents.presentationURL] built %s for %s", options.presentation, url.origin + url.pathname);
99
170
  return Result.ok(url);
100
171
  }
101
172
  /**
102
- * Presents one Intent and answers with its authoritative outcome.
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}.
103
201
  *
104
- * This is the Direct Popup Presentation: the programmatic path for UI that cannot mount a Hosted
105
- * Intent Button. It owns the whole interaction — the window, the four callback parameters, the
106
- * correlation, and the read afterwards — so that no Project has to reach for a window primitive
107
- * to complete a financial operation.
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.
108
205
  *
109
- * The Intent is read again once the window settles, because a callback reports that a window
110
- * finished and that is not the same fact as a completed deposit. A dismissal is read too: the
111
- * user may have closed the window on an operation the bank had already accepted.
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.
112
215
  *
113
216
  * @example
114
217
  * ```ts
115
- * const { data, error } = await andco.intents.present<AndCoDepositIntent>({
116
- * intentId: async () => (await bank.intents.createDeposit(input)).data.id,
117
- * });
218
+ * const presentation = andco.intents.present(() => createDeposit(), { presentation: "popup" });
219
+ * cancelButton.onclick = () => presentation.close();
220
+ * const { data, error } = await presentation.result;
118
221
  * if (data?.outcome === "complete") refreshBalance();
119
222
  * ```
120
223
  */
121
- async present(options) {
122
- if (!this.#config || !this.#presenter)
123
- return Result.fail(presentationUnsupported());
124
- const returnTo = options.returnTo ? new SafeURL(options.returnTo) : this.#config.redirectTo;
125
- if (!returnTo) {
126
- return Result.fail(ANDCO_ERROR_CODES.INVALID_CONFIGURATION, {
127
- message: "an Intent Presentation needs a returnTo, and this instance has no redirectTo",
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,
128
259
  });
260
+ const release = () => {
261
+ if (this.#presenting.get(intentId)?.controller === controller)
262
+ this.#presenting.delete(intentId);
263
+ };
264
+ void result.then(release, release);
129
265
  }
130
- const errorReturnTo = options.errorReturnTo ? new SafeURL(options.errorReturnTo) : returnTo;
131
- const presentationId = RANDOM_UUID(this.#crypto);
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;
132
283
  // The same encoding OAuth uses: it binds the opener origin the callback must post back to, and
133
284
  // its alphabet is the one Platform accepts for `state`.
134
285
  const state = callbackStateCreate(returnTo.origin, presentationId);
135
- // Resolved inside the thunk, so the window is already open when the Intent is created.
286
+ const urlOptions = { presentation, returnTo, errorReturnTo, state };
136
287
  let intentId;
137
- const presented = await this.#presenter.present({
138
- kind: "intent",
139
- presentation: "popup",
140
- url: async () => {
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;
141
312
  try {
142
- intentId = typeof options.intentId === "string" ? options.intentId : await options.intentId();
313
+ resolved = typeof source === "string" ? source : await source();
143
314
  }
144
315
  catch (cause) {
145
- return Result.fail(AndcoError.from(cause, ANDCO_ERROR_CODES.INVALID_CONFIGURATION));
316
+ throw AndcoError.from(cause, ANDCO_ERROR_CODES.INVALID_CONFIGURATION);
146
317
  }
147
- return this.presentationURL({ intentId, presentation: "popup", returnTo, errorReturnTo, state });
148
- },
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,
149
333
  get intentId() {
150
334
  return intentId ?? "";
151
335
  },
152
336
  returnTo,
153
337
  errorReturnTo,
154
338
  presentationId,
155
- ...(options.signal === undefined ? {} : { signal: options.signal }),
339
+ signal,
156
340
  });
157
- if (presented.error)
158
- return Result.fail(presented.error);
341
+ if (presented.error) {
342
+ this.#forget(presentationId);
343
+ return fail(presented.error);
344
+ }
159
345
  if (!intentId) {
160
346
  // The presenter settled without ever asking for a destination, so nothing was presented.
161
- return Result.fail(presentationUnsupported());
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(() => { });
162
354
  }
355
+ // Settled in this document after all, so nothing is left for a return page to read.
356
+ this.#forget(presentationId);
163
357
  const outcome = INTENT_RESULT_FROM(presented.data, { presentationId });
164
- if (outcome.error)
165
- return Result.fail(outcome.error);
166
- console.debug("[AndcoIntents.present] settled: %s %o", outcome.data, { intentId, 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);
167
362
  // Authoritative, and read on a dismissal too: a closed window says nothing about the money.
168
363
  const intent = await this.get(intentId);
169
- if (intent.error)
170
- return Result.fail(intent.error);
364
+ if (intent.error) {
365
+ return fail(intent.error);
366
+ }
367
+ setStatus(outcome.data === "complete" ? "complete" : "dismissed");
171
368
  return Result.ok({ outcome: outcome.data, intent: intent.data });
172
369
  }
173
- /** Reads the authoritative state of one Intent. This, not a callback, is the source of truth. */
174
- async get(intentId) {
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);
175
464
  const invalid = assertIntentId(intentId);
176
465
  if (invalid)
177
466
  return Result.fail(invalid);
@@ -185,32 +474,166 @@ export class AndcoIntents {
185
474
  return Result.fail(cause);
186
475
  }
187
476
  }
188
- /** Executes an intent whose Grant permits it. Completion must still be confirmed with `get`. */
189
- 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);
190
598
  const invalid = assertIntentId(intentId);
191
599
  if (invalid)
192
600
  return Result.fail(invalid);
601
+ console.debug("[AndcoIntents.approve] approving %s with grant %o", intentId, options.grant ?? "selected");
193
602
  try {
194
- await this.#rest.http
195
- .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 })
196
605
  .throwOnError();
197
- return Result.ok(undefined);
606
+ return Result.ok(data);
198
607
  }
199
608
  catch (cause) {
200
609
  return Result.fail(cause);
201
610
  }
202
611
  }
203
- /** Reads one page of durable Intent facts, replaying after an opaque checkpoint. */
204
- 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);
205
624
  const invalid = assertIntentId(intentId);
206
625
  if (invalid)
207
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);
208
634
  try {
209
635
  const { data } = await this.#rest.http
210
- .GET("/intents/{intent_id}/events", {
211
- params: { path: { intent_id: intentId }, query: { after, limit: 100 } },
212
- signal,
213
- })
636
+ .POST("/intents/{intent_id}/reject", { params: { path: { intent_id: intentId } }, body: { reason: parsed.output.reason } }, { grant: options.grant })
214
637
  .throwOnError();
215
638
  return Result.ok(data);
216
639
  }
@@ -219,48 +642,130 @@ export class AndcoIntents {
219
642
  }
220
643
  }
221
644
  /**
222
- * Subscribes to a durable, paged feed of facts, reusing the Protocol Contract's paging, cursor,
223
- * checkpoint, and poll-interval reconciliation.
224
- *
225
- * Domain-neutral by design: the caller supplies both the page fetcher (`read`) and the `subject`
226
- * that scopes its cursor namespace, so nothing here assumes an Intent, an account, or any other
227
- * kind of subject. That is what lets a Resource Server Definition — the Bank one included —
228
- * compose this loop for its own event feed instead of rebuilding paging and checkpointing from
229
- * scratch, which is the one genuinely difficult piece of this surface.
230
- *
231
- * Every subscription returns its own unsubscribe function, so binding one to a framework effect —
232
- * `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.
233
648
  *
234
649
  * @example
235
650
  * ```ts
236
- * const unsubscribe = andco.intents.subscribe(
237
- * { kind: "intent", id: intentId },
238
- * (after, signal) => andco.intents.events(intentId, after, signal),
239
- * (event) => (event.type.startsWith("deposit.") ? event : null),
240
- * (event) => setDeposit(event),
241
- * { onError },
242
- * );
651
+ * await andco.intents.cancel(transfer, { reason: "rejected_by_erp" });
243
652
  * ```
244
653
  */
245
- 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);
246
707
  const subscription = subscribeFinancialEvents(async (after, signal) => {
247
- const page = await read(after, signal);
708
+ const page = await this.events(intentId, after, signal);
248
709
  if (page.error)
249
710
  throw page.error;
250
711
  return page.data;
251
- }, decode,
712
+ }, (event) => (types === null || types.has(event.type) ? event : null),
252
713
  // The checkpoint is pushed only once a handler has succeeded, so persisting it can never
253
714
  // acknowledge an event the application failed to process.
254
715
  async (event) => {
255
716
  await handler(event);
256
717
  options.onCheckpoint?.(event.cursor);
257
- }, {
258
- ...(options.after === undefined ? {} : { after: options.after }),
259
- onError: options.onError,
260
- ...(options.pollIntervalMs === undefined ? {} : { pollIntervalMs: options.pollIntervalMs }),
261
- }, subject);
718
+ }, { after: options.after, onError, pollIntervalMs: options.pollIntervalMs }, { kind: "intent", id: intentId });
262
719
  return () => subscription.unsubscribe();
263
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
+ }
264
769
  }
265
770
  /**
266
771
  * Reads the lifecycle outcome out of the callback a presentation came back with.
@@ -285,12 +790,24 @@ function INTENT_RESULT_FROM(callbackUrl, presentation) {
285
790
  return Result.ok("dismiss");
286
791
  return Result.fail(ANDCO_ERROR_CODES.INVALID_CALLBACK, { message: "the callback carries no Intent result" });
287
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
+ }
288
802
  /** One message for every surface that has the lifecycle but cannot put a window in front of anyone. */
289
803
  function presentationUnsupported() {
290
804
  return new AndcoError(ANDCO_ERROR_CODES.PRESENTATION_UNSUPPORTED, {
291
805
  message: "this Intent surface was composed without a presenter; present through the Andco Instance instead",
292
806
  });
293
807
  }
808
+ function idOf(intent) {
809
+ return typeof intent === "string" ? intent : intent.id;
810
+ }
294
811
  /** Intent identifiers are UUIDs; anything else never reaches the network. */
295
812
  function assertIntentId(value) {
296
813
  return INTENT_ID_PATTERN.test(value)