@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.
- package/LICENSE +1 -1
- package/README.md +96 -54
- package/dist/auth.d.ts +8 -3
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +32 -23
- package/dist/browser/controller.d.ts +55 -1
- package/dist/browser/controller.d.ts.map +1 -1
- package/dist/browser/controller.js +166 -9
- package/dist/browser/frame.d.ts +4 -3
- package/dist/browser/frame.d.ts.map +1 -1
- package/dist/browser/frame.js +12 -20
- package/dist/browser/index.d.ts +18 -6
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +64 -29
- package/dist/browser/popup.d.ts +6 -1
- package/dist/browser/popup.d.ts.map +1 -1
- package/dist/browser/popup.js +37 -8
- package/dist/cli/index.d.ts +4 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +10 -3
- 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 +125 -74
- 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 -2
- 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 +386 -40
- package/dist/intents.d.ts.map +1 -1
- package/dist/intents.js +712 -53
- 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 +59 -11
- package/dist/presenter.d.ts.map +1 -1
- package/dist/presenter.js +40 -1
- 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,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
|
-
|
|
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
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
/**
|
|
61
|
-
|
|
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
|
-
/**
|
|
76
|
-
|
|
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}/
|
|
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(
|
|
606
|
+
return Result.ok(data);
|
|
85
607
|
}
|
|
86
608
|
catch (cause) {
|
|
87
609
|
return Result.fail(cause);
|
|
88
610
|
}
|
|
89
611
|
}
|
|
90
|
-
/**
|
|
91
|
-
|
|
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
|
-
.
|
|
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
|
-
*
|
|
110
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
},
|
|
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) {
|