@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.
- package/LICENSE +1 -1
- package/README.md +93 -49
- package/dist/auth.d.ts +5 -3
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +10 -17
- package/dist/browser/controller.d.ts +46 -0
- package/dist/browser/controller.d.ts.map +1 -1
- package/dist/browser/controller.js +102 -12
- package/dist/browser/frame.d.ts.map +1 -1
- package/dist/browser/frame.js +6 -15
- package/dist/browser/index.d.ts +8 -2
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +48 -33
- package/dist/browser/popup.d.ts +4 -0
- package/dist/browser/popup.d.ts.map +1 -1
- package/dist/browser/popup.js +23 -7
- package/dist/cli/index.d.ts +4 -6
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +7 -13
- package/dist/cli/server.d.ts +5 -0
- package/dist/cli/server.d.ts.map +1 -1
- package/dist/cli/server.js +5 -0
- package/dist/client.d.ts +66 -19
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +117 -78
- package/dist/config.d.ts +2 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/credentials.d.ts +58 -4
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +0 -0
- package/dist/errors.d.ts +23 -21
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +18 -20
- package/dist/globals.d.ts +25 -0
- package/dist/globals.d.ts.map +1 -0
- package/dist/globals.js +15 -0
- package/dist/grants-api.d.ts +34 -0
- package/dist/grants-api.d.ts.map +1 -0
- package/dist/grants-api.js +48 -0
- package/dist/grants.d.ts +16 -0
- package/dist/grants.d.ts.map +1 -0
- package/dist/grants.js +13 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/inflight.d.ts +31 -0
- package/dist/inflight.d.ts.map +1 -0
- package/dist/inflight.js +26 -0
- package/dist/intents.d.ts +346 -70
- package/dist/intents.d.ts.map +1 -1
- package/dist/intents.js +629 -112
- package/dist/oauth.d.ts +55 -6
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +86 -57
- package/dist/presenter.d.ts +25 -11
- package/dist/presenter.d.ts.map +1 -1
- package/dist/presenter.js +39 -6
- package/dist/resource.d.ts +109 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/resource.js +151 -0
- package/dist/rest.d.ts +23 -4
- package/dist/rest.d.ts.map +1 -1
- package/dist/rest.js +46 -5
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server-metadata.generated.d.ts.map +1 -1
- package/dist/server-metadata.generated.js +8 -4
- package/dist/service.d.ts +109 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +241 -0
- package/dist/session-store.d.ts +12 -4
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +64 -14
- package/dist/storage.d.ts +16 -23
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +27 -25
- package/dist/tokens.d.ts +46 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +148 -0
- 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
|
-
|
|
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
|
|
22
|
+
* Creates, presents, reads and follows Intents under the current Grant.
|
|
8
23
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
#
|
|
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.#
|
|
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
|
-
*
|
|
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
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
-
|
|
148
|
+
presentationURL(intent, options) {
|
|
74
149
|
if (!this.#config)
|
|
75
150
|
return Result.fail(presentationUnsupported());
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
return Result.fail(
|
|
79
|
-
|
|
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(
|
|
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",
|
|
96
|
-
url.searchParams.set("error_return_uri",
|
|
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",
|
|
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
|
-
*
|
|
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
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
|
116
|
-
*
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
286
|
+
const urlOptions = { presentation, returnTo, errorReturnTo, state };
|
|
136
287
|
let intentId;
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
313
|
+
resolved = typeof source === "string" ? source : await source();
|
|
143
314
|
}
|
|
144
315
|
catch (cause) {
|
|
145
|
-
|
|
316
|
+
throw AndcoError.from(cause, ANDCO_ERROR_CODES.INVALID_CONFIGURATION);
|
|
146
317
|
}
|
|
147
|
-
|
|
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
|
-
|
|
339
|
+
signal,
|
|
156
340
|
});
|
|
157
|
-
if (presented.error)
|
|
158
|
-
|
|
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
|
|
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
|
|
166
|
-
|
|
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
|
|
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
|
-
/**
|
|
174
|
-
|
|
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
|
-
/**
|
|
189
|
-
|
|
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}/
|
|
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(
|
|
606
|
+
return Result.ok(data);
|
|
198
607
|
}
|
|
199
608
|
catch (cause) {
|
|
200
609
|
return Result.fail(cause);
|
|
201
610
|
}
|
|
202
611
|
}
|
|
203
|
-
/**
|
|
204
|
-
|
|
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
|
-
.
|
|
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
|
-
*
|
|
223
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
},
|
|
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)
|