@kottera/tera-web 0.1.0-rc.2 → 0.1.0-rc.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/README.md +3 -2
- package/dist/client.d.ts +59 -5
- package/dist/client.js +203 -16
- package/dist/feed/connection.d.ts +2 -0
- package/dist/feed/connection.js +2 -2
- package/dist/generated/kit.d.ts +61 -6
- package/dist/generated/kit.js +24 -4
- package/dist/generated/shares.d.ts +196 -0
- package/dist/generated/shares.js +17 -0
- package/dist/generated/workorder-events.d.ts +1 -1
- package/dist/generated/workorder.d.ts +91 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/routes.d.ts +15 -0
- package/dist/routes.js +28 -0
- package/dist/stepup.d.ts +87 -0
- package/dist/stepup.js +66 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,9 +10,10 @@ author's — and has no runtime dependencies. It is not yet published.
|
|
|
10
10
|
| Path | What it holds |
|
|
11
11
|
|------|---------------|
|
|
12
12
|
| `src/index.ts` | The package's one entry point. Everything public is re-exported here. |
|
|
13
|
-
| `src/routes.ts` | The route prefix and `
|
|
13
|
+
| `src/routes.ts` | The route prefix, `routePath`, and the app's base (`appBase`, `underBase`) every route and the sign-out are reached under. |
|
|
14
14
|
| `src/client.ts` | `TeraClient`, the typed client over D3's routes (D4 items 1, 3 and 4): `me()`, `invoke(capability, params)`, `feed(options)` (a `FeedConnection` read with the client's fetch), `upload(file, options)` with progress, and `signOut()`. A kit refusal throws `KitRequestError`; a verb's refusal answers as its verdict. |
|
|
15
|
-
| `src/
|
|
15
|
+
| `src/stepup.ts` | A person's step-up from the page's side (ADR-173 D11): the step-up header, the `return_to` the navigation fallback carries, and `StepUpError`. Every `TeraClient` request — `me()`, `invoke()`, `upload()` and `fetch(path)` to the plugin's own routes — has the person confirm a `step_up_required` and sends the request once more; `stepUp(id)` reads `GET /tera/v1/step-ups/{id}`. |
|
|
16
|
+
| `src/generated/` | The contract, generated from the committed schemas under `sdk/plugin/webui/schema/`, `sdk/workorder/schema/`, `sdk/feed/schema/` and `sdk/shares/schema/` (D4 item 1; the share types, ADR-174 D14): one type per schema, the kit's route table as `KIT_ROUTE_TABLE`, and the maps from a route or a feed event to the type it carries. Never hand-edited, and never hand-write a type it declares: `make web-types` regenerates it, and `TestWebTypes_MatchCommitted` (`cmd/internal/genwebtypes`) fails on a hand edit or a stale generation. |
|
|
16
17
|
| `src/feed/` | The stream connection (D4 item 2). |
|
|
17
18
|
| `src/**/*.test.ts` | The suite, beside what it tests. |
|
|
18
19
|
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FeedConnection, type FeedConnectionOptions } from "./feed/connection.ts";
|
|
2
|
-
import type { KitError, KitInvokeResponse, KitMeResponse, KitUploadResponse } from "./generated/kit.ts";
|
|
2
|
+
import type { KitError, KitInvokeResponse, KitMeResponse, KitStepUpResponse, KitUploadResponse } from "./generated/kit.ts";
|
|
3
|
+
import { type StepUpChallenge, type StepUpOptions } from "./stepup.ts";
|
|
3
4
|
/** The body of a kit failure: the envelope's `error` member. */
|
|
4
5
|
export type KitErrorBody = KitError["error"];
|
|
5
6
|
/** A verb's refusal, as the kit passed it through: the reply envelope's `error` member. */
|
|
@@ -89,19 +90,34 @@ export interface UploadRequest {
|
|
|
89
90
|
};
|
|
90
91
|
}
|
|
91
92
|
export interface TeraClientOptions {
|
|
93
|
+
/**
|
|
94
|
+
* The path the app is served under on its origin: `/`, the default, on a
|
|
95
|
+
* plugin's own origin, and `/plugins/<name>/` on an installation that serves
|
|
96
|
+
* plugins by path. Every route, and the sign-out, is reached under it.
|
|
97
|
+
*/
|
|
98
|
+
base?: string;
|
|
92
99
|
/** The fetch every route but the upload is read with; the page's own when absent. */
|
|
93
100
|
fetch?: typeof fetch;
|
|
94
101
|
/** Makes the upload's request; `new XMLHttpRequest()` when absent. */
|
|
95
102
|
uploadRequest?: () => UploadRequest;
|
|
96
|
-
/** Sends the page to a path; `location.assign` when absent. */
|
|
103
|
+
/** Sends the page to a path or URL; `location.assign` when absent. */
|
|
97
104
|
navigate?: (path: string) => void;
|
|
105
|
+
/** How a step-up is confirmed: the window, the page's URL and the polling. */
|
|
106
|
+
stepUp?: StepUpOptions;
|
|
98
107
|
}
|
|
99
108
|
/** The options of {@link TeraClient.feed}: a {@link FeedConnection}'s, with the opener the client's own. */
|
|
100
|
-
export type FeedOptions = Omit<FeedConnectionOptions, "open">;
|
|
109
|
+
export type FeedOptions = Omit<FeedConnectionOptions, "open" | "base">;
|
|
101
110
|
export declare class TeraClient {
|
|
111
|
+
private readonly base;
|
|
102
112
|
private readonly fetchFn;
|
|
103
113
|
private readonly uploadRequest;
|
|
104
114
|
private readonly navigate;
|
|
115
|
+
private readonly openWindow;
|
|
116
|
+
private readonly pageURL;
|
|
117
|
+
private readonly sleep;
|
|
118
|
+
private readonly pollMs;
|
|
119
|
+
/** Whether the step-up the page was loaded back with has been taken up. */
|
|
120
|
+
private resumeTaken;
|
|
105
121
|
constructor(options?: TeraClientOptions);
|
|
106
122
|
/** `GET /tera/v1/me`: who is signed in, and where to send them to sign out. */
|
|
107
123
|
me(): Promise<KitMeResponse>;
|
|
@@ -115,6 +131,7 @@ export declare class TeraClient {
|
|
|
115
131
|
* if (got.status === "success") show(got.result);
|
|
116
132
|
*/
|
|
117
133
|
invoke<Result = unknown, Params = unknown>(capability: string, params?: Params): Promise<InvokeVerdict<Result>>;
|
|
134
|
+
private invokeOnce;
|
|
118
135
|
/**
|
|
119
136
|
* A {@link FeedConnection} over `GET /tera/v1/feeds/{feed}`, read with this
|
|
120
137
|
* client's fetch. It is not started: call `start()` on it, and `stop()` when
|
|
@@ -128,11 +145,48 @@ export declare class TeraClient {
|
|
|
128
145
|
* route's headers, and `onProgress` reports the body as it is sent.
|
|
129
146
|
*/
|
|
130
147
|
upload(file: Blob, options?: UploadOptions): Promise<KitUploadResponse>;
|
|
148
|
+
private uploadOnce;
|
|
149
|
+
/**
|
|
150
|
+
* `GET /tera/v1/step-ups/{id}`: where one of the person's own step-ups is in
|
|
151
|
+
* its life. A step-up that is not theirs is thrown as tera's 404, as an id
|
|
152
|
+
* no step-up carries is.
|
|
153
|
+
*/
|
|
154
|
+
stepUp(id: string): Promise<KitStepUpResponse>;
|
|
155
|
+
/**
|
|
156
|
+
* A request to one of the plugin's own routes, `path` relative to the app's
|
|
157
|
+
* base (`reviews/7/finish`), same-origin. A `step_up_required` answer in the
|
|
158
|
+
* kit's envelope — what `webui.RequireStepUp` writes — is confirmed by the
|
|
159
|
+
* person and the request sent once more with `Tera-Step-Up`; any other
|
|
160
|
+
* answer, a success or a refusal, is returned as it arrived. A body sent
|
|
161
|
+
* again must be one that can be: a string, a `Blob`, `FormData` or a
|
|
162
|
+
* buffer, never a stream.
|
|
163
|
+
*/
|
|
164
|
+
fetch(path: string, init?: RequestInit): Promise<Response>;
|
|
165
|
+
/**
|
|
166
|
+
* Has the person confirm the step-up `challenge` names, and answers its id
|
|
167
|
+
* once it is satisfied, to send the act again with. It opens the step-up's
|
|
168
|
+
* page in a new window, or navigates to it with a `return_to` naming this
|
|
169
|
+
* page when the browser opens none, and reads its state every poll interval
|
|
170
|
+
* until it is satisfied, declined or expired, closing the window it opened.
|
|
171
|
+
*
|
|
172
|
+
* Rejects with a {@link StepUpError} when the step-up ends unconfirmed, the
|
|
173
|
+
* person closes its window while it still waits, or the page navigated away
|
|
174
|
+
* to confirm it. A page loaded back from that navigation carries the id, and
|
|
175
|
+
* the first step-up it is asked for resumes that one when it is satisfied.
|
|
176
|
+
*/
|
|
177
|
+
confirmStepUp(challenge: StepUpChallenge): Promise<string>;
|
|
178
|
+
/** The step-up this page was loaded back with, once. */
|
|
179
|
+
private takeResumed;
|
|
180
|
+
private fetchSameOrigin;
|
|
131
181
|
/**
|
|
132
182
|
* Signs the person out: navigates to the `signout_path` `me()` answers,
|
|
133
|
-
* tera's own
|
|
134
|
-
* is not
|
|
183
|
+
* tera's own sign-out, resolved against the app's base. A `signout_path`
|
|
184
|
+
* that is not a path relative to the base — a root-absolute path, or one
|
|
185
|
+
* naming another origin — is refused rather than followed.
|
|
135
186
|
*/
|
|
136
187
|
signOut(): Promise<void>;
|
|
188
|
+
/** A kit route's path under the app's base. */
|
|
189
|
+
private route;
|
|
137
190
|
private json;
|
|
191
|
+
private jsonOnce;
|
|
138
192
|
}
|
package/dist/client.js
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
*
|
|
24
24
|
* # Same origin
|
|
25
25
|
*
|
|
26
|
-
* Every request is to a path under the kit's prefix
|
|
26
|
+
* Every request is to a path under the kit's prefix, below the app's base
|
|
27
|
+
* ({@link TeraClientOptions.base}), on the page's own origin,
|
|
27
28
|
* with `credentials: "same-origin"`; a fetch is also `mode: "same-origin"`, so
|
|
28
29
|
* a page that somehow aimed one elsewhere is refused by the browser rather
|
|
29
30
|
* than sent. The browser then marks it `Sec-Fetch-Site: same-origin`, which is
|
|
@@ -31,12 +32,25 @@
|
|
|
31
32
|
* claims it: a page cannot set `Sec-Fetch-*` or `Origin`, and the rule rests
|
|
32
33
|
* on that.
|
|
33
34
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
35
|
+
* # A step-up
|
|
36
|
+
*
|
|
37
|
+
* Every request it makes handles a `step_up_required` refusal that names a
|
|
38
|
+
* page to confirm the act on (ADR-173 D11): the kit's envelope from a kit
|
|
39
|
+
* route or from the plugin's own route ({@link TeraClient.fetch}), and a Work
|
|
40
|
+
* Order verb's refusal relayed by {@link TeraClient.invoke}. It has the person
|
|
41
|
+
* confirm it ({@link TeraClient.confirmStepUp}) and sends the identical request
|
|
42
|
+
* once more, with `Tera-Step-Up: <id>`, or with `step_up` in an invoke's
|
|
43
|
+
* params. A step-up the person declines, lets expire or walks away from
|
|
44
|
+
* rejects with a {@link StepUpError}, and the request is not sent again.
|
|
45
|
+
*
|
|
46
|
+
* It is a plain class with an injected fetch, request factory, navigation,
|
|
47
|
+
* window opener and timer, so its tests drive it against a fake and no
|
|
48
|
+
* network.
|
|
36
49
|
*/
|
|
37
|
-
import { routePath } from "./routes.js";
|
|
50
|
+
import { appBase, routePath, underBase } from "./routes.js";
|
|
38
51
|
import { FeedConnection } from "./feed/connection.js";
|
|
39
52
|
import { fetchOpener } from "./feed/sse.js";
|
|
53
|
+
import { STEP_UP_HEADER, STEP_UP_POLL_MS, StepUpError, defaultOpenWindow, defaultSleep, resumedStepUp, stepUpChallengeOf, stepUpPageURL, } from "./stepup.js";
|
|
40
54
|
/**
|
|
41
55
|
* The `code` of a {@link KitRequestError} whose answer was not the kit's
|
|
42
56
|
* envelope — a proxy's page, a dropped body, a success that is not the
|
|
@@ -78,17 +92,30 @@ export class KitRequestError extends Error {
|
|
|
78
92
|
}
|
|
79
93
|
const JSON_TYPE = "application/json";
|
|
80
94
|
export class TeraClient {
|
|
95
|
+
base;
|
|
81
96
|
fetchFn;
|
|
82
97
|
uploadRequest;
|
|
83
98
|
navigate;
|
|
99
|
+
openWindow;
|
|
100
|
+
pageURL;
|
|
101
|
+
sleep;
|
|
102
|
+
pollMs;
|
|
103
|
+
/** Whether the step-up the page was loaded back with has been taken up. */
|
|
104
|
+
resumeTaken = false;
|
|
84
105
|
constructor(options = {}) {
|
|
106
|
+
this.base = appBase(options.base);
|
|
85
107
|
this.fetchFn = options.fetch ?? ((...args) => globalThis.fetch(...args));
|
|
86
108
|
this.uploadRequest = options.uploadRequest ?? (() => new XMLHttpRequest());
|
|
87
109
|
this.navigate = options.navigate ?? ((path) => globalThis.location.assign(path));
|
|
110
|
+
const stepUp = options.stepUp ?? {};
|
|
111
|
+
this.openWindow = stepUp.openWindow ?? defaultOpenWindow;
|
|
112
|
+
this.pageURL = stepUp.pageURL ?? (() => globalThis.location.href);
|
|
113
|
+
this.sleep = stepUp.sleep ?? defaultSleep;
|
|
114
|
+
this.pollMs = stepUp.pollIntervalMs ?? STEP_UP_POLL_MS;
|
|
88
115
|
}
|
|
89
116
|
/** `GET /tera/v1/me`: who is signed in, and where to send them to sign out. */
|
|
90
117
|
async me() {
|
|
91
|
-
return this.json(
|
|
118
|
+
return this.json(this.route("me"), { method: "GET" });
|
|
92
119
|
}
|
|
93
120
|
/**
|
|
94
121
|
* `POST /tera/v1/invoke/{capability}`: invokes one capability as the
|
|
@@ -100,7 +127,17 @@ export class TeraClient {
|
|
|
100
127
|
* if (got.status === "success") show(got.result);
|
|
101
128
|
*/
|
|
102
129
|
async invoke(capability, params) {
|
|
103
|
-
const
|
|
130
|
+
const verdict = await this.invokeOnce(capability, params);
|
|
131
|
+
if (verdict.status !== "error")
|
|
132
|
+
return verdict;
|
|
133
|
+
const challenge = stepUpChallengeOf(verdict.error.code, verdict.error.data?.step_up);
|
|
134
|
+
if (challenge === null || !isParamsObject(params))
|
|
135
|
+
return verdict;
|
|
136
|
+
const id = await this.confirmStepUp(challenge);
|
|
137
|
+
return this.invokeOnce(capability, { ...(params ?? {}), step_up: id });
|
|
138
|
+
}
|
|
139
|
+
async invokeOnce(capability, params) {
|
|
140
|
+
const url = this.route("invoke", capability);
|
|
104
141
|
const reply = await this.json(url, {
|
|
105
142
|
method: "POST",
|
|
106
143
|
headers: { "Content-Type": JSON_TYPE },
|
|
@@ -120,7 +157,7 @@ export class TeraClient {
|
|
|
120
157
|
* the page no longer wants it.
|
|
121
158
|
*/
|
|
122
159
|
feed(options) {
|
|
123
|
-
return new FeedConnection({ ...options, open: fetchOpener(this.fetchFn) });
|
|
160
|
+
return new FeedConnection({ ...options, base: this.base, open: fetchOpener(this.fetchFn) });
|
|
124
161
|
}
|
|
125
162
|
/**
|
|
126
163
|
* `POST /tera/v1/uploads`: sends one file as the signed-in person and
|
|
@@ -128,13 +165,24 @@ export class TeraClient {
|
|
|
128
165
|
* name. Its name and description travel percent-encoded (UTF-8) in the
|
|
129
166
|
* route's headers, and `onProgress` reports the body as it is sent.
|
|
130
167
|
*/
|
|
131
|
-
upload(file, options = {}) {
|
|
168
|
+
async upload(file, options = {}) {
|
|
169
|
+
try {
|
|
170
|
+
return await this.uploadOnce(file, options, null);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
const challenge = err instanceof KitRequestError ? stepUpChallengeOf(err.code, err.envelope?.step_up) : null;
|
|
174
|
+
if (challenge === null)
|
|
175
|
+
throw err;
|
|
176
|
+
return this.uploadOnce(file, options, await this.confirmStepUp(challenge));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
uploadOnce(file, options, stepUpId) {
|
|
132
180
|
const filename = options.filename ?? file.name ?? "";
|
|
133
181
|
if (filename === "") {
|
|
134
182
|
return Promise.reject(new TypeError("upload: a filename is required: pass a File, or options.filename"));
|
|
135
183
|
}
|
|
136
184
|
const contentType = options.contentType || file.type || "application/octet-stream";
|
|
137
|
-
const url =
|
|
185
|
+
const url = this.route("uploads");
|
|
138
186
|
const { signal, onProgress } = options;
|
|
139
187
|
if (signal?.aborted)
|
|
140
188
|
return Promise.reject(abortError());
|
|
@@ -151,6 +199,8 @@ export class TeraClient {
|
|
|
151
199
|
xhr.setRequestHeader("Tera-Upload-Description", encodeURIComponent(options.description));
|
|
152
200
|
}
|
|
153
201
|
xhr.setRequestHeader("Accept", JSON_TYPE);
|
|
202
|
+
if (stepUpId !== null)
|
|
203
|
+
xhr.setRequestHeader(STEP_UP_HEADER, stepUpId);
|
|
154
204
|
if (onProgress !== undefined) {
|
|
155
205
|
xhr.upload.onprogress = (ev) => onProgress({ loaded: ev.loaded, total: ev.lengthComputable ? ev.total : file.size });
|
|
156
206
|
}
|
|
@@ -181,22 +231,138 @@ export class TeraClient {
|
|
|
181
231
|
}
|
|
182
232
|
});
|
|
183
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* `GET /tera/v1/step-ups/{id}`: where one of the person's own step-ups is in
|
|
236
|
+
* its life. A step-up that is not theirs is thrown as tera's 404, as an id
|
|
237
|
+
* no step-up carries is.
|
|
238
|
+
*/
|
|
239
|
+
async stepUp(id) {
|
|
240
|
+
return this.json(this.route("step-ups", id), { method: "GET" }, false);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* A request to one of the plugin's own routes, `path` relative to the app's
|
|
244
|
+
* base (`reviews/7/finish`), same-origin. A `step_up_required` answer in the
|
|
245
|
+
* kit's envelope — what `webui.RequireStepUp` writes — is confirmed by the
|
|
246
|
+
* person and the request sent once more with `Tera-Step-Up`; any other
|
|
247
|
+
* answer, a success or a refusal, is returned as it arrived. A body sent
|
|
248
|
+
* again must be one that can be: a string, a `Blob`, `FormData` or a
|
|
249
|
+
* buffer, never a stream.
|
|
250
|
+
*/
|
|
251
|
+
async fetch(path, init = {}) {
|
|
252
|
+
const url = underBase(this.base, path);
|
|
253
|
+
const first = await this.fetchSameOrigin(url, init, null);
|
|
254
|
+
const challenge = await stepUpRefusalOf(first);
|
|
255
|
+
if (challenge === null)
|
|
256
|
+
return first;
|
|
257
|
+
return this.fetchSameOrigin(url, init, await this.confirmStepUp(challenge));
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Has the person confirm the step-up `challenge` names, and answers its id
|
|
261
|
+
* once it is satisfied, to send the act again with. It opens the step-up's
|
|
262
|
+
* page in a new window, or navigates to it with a `return_to` naming this
|
|
263
|
+
* page when the browser opens none, and reads its state every poll interval
|
|
264
|
+
* until it is satisfied, declined or expired, closing the window it opened.
|
|
265
|
+
*
|
|
266
|
+
* Rejects with a {@link StepUpError} when the step-up ends unconfirmed, the
|
|
267
|
+
* person closes its window while it still waits, or the page navigated away
|
|
268
|
+
* to confirm it. A page loaded back from that navigation carries the id, and
|
|
269
|
+
* the first step-up it is asked for resumes that one when it is satisfied.
|
|
270
|
+
*/
|
|
271
|
+
async confirmStepUp(challenge) {
|
|
272
|
+
const resumed = this.takeResumed();
|
|
273
|
+
if (resumed !== null) {
|
|
274
|
+
const state = await this.stepUp(resumed).then((s) => s.state, () => null);
|
|
275
|
+
if (state === "satisfied")
|
|
276
|
+
return resumed;
|
|
277
|
+
}
|
|
278
|
+
const win = this.openWindow(challenge.url);
|
|
279
|
+
if (win === null) {
|
|
280
|
+
this.navigate(stepUpPageURL(challenge.url, challenge.id, this.pageURL()));
|
|
281
|
+
throw new StepUpError(challenge.id, "navigated", "the browser opened no window, so the page went to tera's to confirm the act");
|
|
282
|
+
}
|
|
283
|
+
let closedSeen = false;
|
|
284
|
+
for (;;) {
|
|
285
|
+
await this.sleep(this.pollMs);
|
|
286
|
+
let state;
|
|
287
|
+
try {
|
|
288
|
+
state = (await this.stepUp(challenge.id)).state;
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
win.close();
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
if (state === "satisfied") {
|
|
295
|
+
win.close();
|
|
296
|
+
return challenge.id;
|
|
297
|
+
}
|
|
298
|
+
if (state !== "pending") {
|
|
299
|
+
win.close();
|
|
300
|
+
throw new StepUpError(challenge.id, state, `the step-up was ${state === "spent" ? "spent by another request" : state}, and the act was not sent again`);
|
|
301
|
+
}
|
|
302
|
+
// One more read after the person closes the window, in case they
|
|
303
|
+
// confirmed it just before; then it is abandoned.
|
|
304
|
+
if (win.closed) {
|
|
305
|
+
if (closedSeen) {
|
|
306
|
+
throw new StepUpError(challenge.id, "abandoned", "the step-up's window was closed before it was confirmed");
|
|
307
|
+
}
|
|
308
|
+
closedSeen = true;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** The step-up this page was loaded back with, once. */
|
|
313
|
+
takeResumed() {
|
|
314
|
+
if (this.resumeTaken)
|
|
315
|
+
return null;
|
|
316
|
+
this.resumeTaken = true;
|
|
317
|
+
try {
|
|
318
|
+
return resumedStepUp(this.pageURL());
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
fetchSameOrigin(url, init, stepUpId) {
|
|
325
|
+
const headers = new Headers(init.headers);
|
|
326
|
+
if (stepUpId !== null)
|
|
327
|
+
headers.set(STEP_UP_HEADER, stepUpId);
|
|
328
|
+
return this.fetchFn(url, { ...init, headers, credentials: "same-origin", mode: "same-origin" });
|
|
329
|
+
}
|
|
184
330
|
/**
|
|
185
331
|
* Signs the person out: navigates to the `signout_path` `me()` answers,
|
|
186
|
-
* tera's own
|
|
187
|
-
* is not
|
|
332
|
+
* tera's own sign-out, resolved against the app's base. A `signout_path`
|
|
333
|
+
* that is not a path relative to the base — a root-absolute path, or one
|
|
334
|
+
* naming another origin — is refused rather than followed.
|
|
188
335
|
*/
|
|
189
336
|
async signOut() {
|
|
190
337
|
const { signout_path: path } = await this.me();
|
|
191
|
-
if (typeof path !== "string" ||
|
|
192
|
-
throw new KitRequestError(200, null, `${
|
|
338
|
+
if (typeof path !== "string" || path === "" || path.startsWith("/") || /^[^/?#]*:/.test(path) || /[\\]/.test(path)) {
|
|
339
|
+
throw new KitRequestError(200, null, `${this.route("me")} answered a signout_path that is not a path relative to the app's base: ${JSON.stringify(path)}`);
|
|
193
340
|
}
|
|
194
|
-
this.navigate(path);
|
|
341
|
+
this.navigate(underBase(this.base, path));
|
|
342
|
+
}
|
|
343
|
+
/** A kit route's path under the app's base. */
|
|
344
|
+
route(...segments) {
|
|
345
|
+
return underBase(this.base, routePath(...segments));
|
|
195
346
|
}
|
|
196
|
-
async json(url, init) {
|
|
347
|
+
async json(url, init, stepUp = true) {
|
|
348
|
+
try {
|
|
349
|
+
return await this.jsonOnce(url, init, null);
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
const challenge = stepUp && err instanceof KitRequestError ? stepUpChallengeOf(err.code, err.envelope?.step_up) : null;
|
|
353
|
+
if (challenge === null)
|
|
354
|
+
throw err;
|
|
355
|
+
return this.jsonOnce(url, init, await this.confirmStepUp(challenge));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async jsonOnce(url, init, stepUpId) {
|
|
197
359
|
const response = await this.fetchFn(url, {
|
|
198
360
|
...init,
|
|
199
|
-
headers: {
|
|
361
|
+
headers: {
|
|
362
|
+
Accept: JSON_TYPE,
|
|
363
|
+
...init.headers,
|
|
364
|
+
...(stepUpId === null ? {} : { [STEP_UP_HEADER]: stepUpId }),
|
|
365
|
+
},
|
|
200
366
|
credentials: "same-origin",
|
|
201
367
|
mode: "same-origin",
|
|
202
368
|
cache: "no-store",
|
|
@@ -233,6 +399,27 @@ function answer(url, status, contentType, text) {
|
|
|
233
399
|
}
|
|
234
400
|
throw new KitRequestError(status, envelopeOf(body), `${url} answered HTTP ${status} (${contentType || "no content type"})`);
|
|
235
401
|
}
|
|
402
|
+
/** Whether `params` can carry `step_up` beside what it holds: absent, or a plain object. */
|
|
403
|
+
function isParamsObject(params) {
|
|
404
|
+
return params === undefined || params === null || (typeof params === "object" && !Array.isArray(params));
|
|
405
|
+
}
|
|
406
|
+
/** The step-up a response to the plugin's own route needs confirmed, read from a copy of it. */
|
|
407
|
+
async function stepUpRefusalOf(response) {
|
|
408
|
+
if (response.status !== 403)
|
|
409
|
+
return null;
|
|
410
|
+
const type = (response.headers.get("content-type") ?? "").toLowerCase().split(";")[0].trim();
|
|
411
|
+
if (type !== JSON_TYPE)
|
|
412
|
+
return null;
|
|
413
|
+
let body;
|
|
414
|
+
try {
|
|
415
|
+
body = JSON.parse(await response.clone().text());
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
const e = envelopeOf(body);
|
|
421
|
+
return e === null ? null : stepUpChallengeOf(e.code, e.step_up);
|
|
422
|
+
}
|
|
236
423
|
/** The envelope's `error` member, when `body` is a kit envelope; null otherwise. */
|
|
237
424
|
function envelopeOf(body) {
|
|
238
425
|
if (typeof body !== "object" || body === null)
|
|
@@ -79,6 +79,8 @@ export declare function defaultOrderKey(record: FeedFrameRecord): number | undef
|
|
|
79
79
|
export interface FeedConnectionOptions {
|
|
80
80
|
/** The feed's name, as the plugin's manifest declares it. */
|
|
81
81
|
feed: string;
|
|
82
|
+
/** The path the app is served under on its origin, as a client's `base` option names it; `/` when absent. */
|
|
83
|
+
base?: string;
|
|
82
84
|
/** Narrows the feed to these of its topic filters (the feed's `topics` parameter). */
|
|
83
85
|
topics?: readonly string[];
|
|
84
86
|
/** Each record not already delivered, in order. */
|
package/dist/feed/connection.js
CHANGED
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
* It is a plain class with an injected clock, opener and signals, so its tests
|
|
64
64
|
* drive it with fake timers and a scripted stream and no network.
|
|
65
65
|
*/
|
|
66
|
-
import { routePath } from "../routes.js";
|
|
66
|
+
import { routePath, underBase, ROOT_BASE } from "../routes.js";
|
|
67
67
|
import { fetchOpener, isRefusal } from "./sse.js";
|
|
68
68
|
export const BASE_DELAY_MS = 1_000;
|
|
69
69
|
export const MAX_DELAY_MS = 30_000;
|
|
@@ -164,7 +164,7 @@ export class FeedConnection {
|
|
|
164
164
|
for (const topic of this.options.topics ?? [])
|
|
165
165
|
query.append("topics", topic);
|
|
166
166
|
query.set("backfill", "all");
|
|
167
|
-
return routePath("feeds", this.options.feed) + "?" + query.toString();
|
|
167
|
+
return underBase(this.options.base ?? ROOT_BASE, routePath("feeds", this.options.feed)) + "?" + query.toString();
|
|
168
168
|
}
|
|
169
169
|
/** Opens the first stream. Nothing delivered before is remembered. */
|
|
170
170
|
start() {
|
package/dist/generated/kit.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { FeedFrameRecord, FeedFrameStatus } from "./feed.ts";
|
|
|
6
6
|
*
|
|
7
7
|
* Schema: sdk/plugin/webui/schema/v1/error-code.json
|
|
8
8
|
*/
|
|
9
|
-
export type KitErrorCode = "unauthenticated" | "cross_origin" | "route_not_found" | "method_not_allowed" | "route_not_served" | "invalid_request" | "exchange_refused" | "tera_unavailable";
|
|
9
|
+
export type KitErrorCode = "unauthenticated" | "cross_origin" | "route_not_found" | "method_not_allowed" | "route_not_served" | "invalid_request" | "exchange_refused" | "tera_unavailable" | "step_up_required";
|
|
10
10
|
/**
|
|
11
11
|
* Kit error envelope.
|
|
12
12
|
*
|
|
@@ -27,6 +27,14 @@ export type KitError = {
|
|
|
27
27
|
retry_after_ms?: number;
|
|
28
28
|
/** Structured detail the refusing verb attached, whose shape the verb decides. The kit's own refusals attach none. */
|
|
29
29
|
data?: unknown;
|
|
30
|
+
/** The step-up a step_up_required, step_up_pending or step_up_mismatch refusal of the kit's is about. On a step-up just begun it carries the id, the page (url) the person confirms it on, its expiry and the methods the act accepts: open the page, poll the step-up route until it is satisfied, declined or expired, and send the identical request again with the Tera-Step-Up header set to the id. */
|
|
31
|
+
step_up?: {
|
|
32
|
+
id: string;
|
|
33
|
+
url?: string;
|
|
34
|
+
methods?: string[];
|
|
35
|
+
expires_at?: string;
|
|
36
|
+
state?: string;
|
|
37
|
+
};
|
|
30
38
|
};
|
|
31
39
|
};
|
|
32
40
|
/**
|
|
@@ -75,8 +83,37 @@ export type KitInvokeResponse = {
|
|
|
75
83
|
export type KitMeResponse = {
|
|
76
84
|
/** The signed-in person, as tera's caller assertion names them: spelled as a Work Order that person filed would name them. */
|
|
77
85
|
subject: string;
|
|
78
|
-
/** Where to send the person to sign out: tera's own path, which
|
|
86
|
+
/** Where to send the person to sign out: tera's own sign-out, as a path relative to the app's base, which a page resolves against that base (/ on the plugin's own origin, /plugins/<name>/ on a path-based installation). It is never root-absolute. */
|
|
79
87
|
signout_path: string;
|
|
88
|
+
/** What to call the signed-in person, as tera's record of them holds it; absent when it holds none. Presentation only: subject is the identity, and nothing is decided by this. */
|
|
89
|
+
display_name?: string;
|
|
90
|
+
/** The address tera's record of the signed-in person holds; absent when it holds none. Presentation only: subject is the identity, and nothing is decided by this. */
|
|
91
|
+
email?: string;
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* GET /tera/v1/step-ups/{id} response.
|
|
95
|
+
*
|
|
96
|
+
* Where one of the person's own step-ups is in its life (ADR-173 D11).
|
|
97
|
+
*
|
|
98
|
+
* Schema: sdk/plugin/webui/schema/v1/step-up.response.json
|
|
99
|
+
*/
|
|
100
|
+
export type KitStepUpResponse = {
|
|
101
|
+
/** The step-up's id. */
|
|
102
|
+
id: string;
|
|
103
|
+
/** Where the step-up is in its life: pending until the person confirms or declines it, satisfied once confirmed, spent once the act used it, declined, or expired. A reader polls until it is satisfied, declined or expired. */
|
|
104
|
+
state: "pending" | "satisfied" | "spent" | "declined" | "expired";
|
|
105
|
+
/** tera's own text for the act the step-up confirms. */
|
|
106
|
+
display: string;
|
|
107
|
+
/** The methods the act accepts: passkey, and email_code when the act accepts it. */
|
|
108
|
+
methods: string[] | null;
|
|
109
|
+
/** When the step-up stops waiting, in RFC 3339. */
|
|
110
|
+
expires_at: string;
|
|
111
|
+
/** What satisfied it, passkey or email_code; absent until it is satisfied. */
|
|
112
|
+
method?: string;
|
|
113
|
+
/** For a passkey, the kind of sign-in that registered it; a passkey anchored on email_code is worth an email code. Absent otherwise. */
|
|
114
|
+
anchor?: string;
|
|
115
|
+
/** When the person confirmed it, in RFC 3339; absent until then. */
|
|
116
|
+
satisfied_at?: string;
|
|
80
117
|
};
|
|
81
118
|
/**
|
|
82
119
|
* POST /tera/v1/uploads response.
|
|
@@ -111,16 +148,17 @@ export declare const KIT_SCHEMAS: {
|
|
|
111
148
|
readonly KitFeedEventReauthenticate: "sdk/plugin/webui/schema/v1/feed.event.reauthenticate.json";
|
|
112
149
|
readonly KitInvokeResponse: "sdk/plugin/webui/schema/v1/invoke.response.json";
|
|
113
150
|
readonly KitMeResponse: "sdk/plugin/webui/schema/v1/me.response.json";
|
|
151
|
+
readonly KitStepUpResponse: "sdk/plugin/webui/schema/v1/step-up.response.json";
|
|
114
152
|
readonly KitUploadResponse: "sdk/plugin/webui/schema/v1/upload.response.json";
|
|
115
153
|
};
|
|
116
|
-
/** The prefix every kit route is served under (ADR-162 D3). */
|
|
154
|
+
/** The prefix every kit route is served under (ADR-162 D3), below the app's base. */
|
|
117
155
|
export declare const KIT_ROUTE_PREFIX = "/tera/v1/";
|
|
118
|
-
/** tera's own sign-out path, which `GET /tera/v1/me` answers as signout_path. */
|
|
119
|
-
export declare const KIT_SIGNOUT_PATH = "
|
|
156
|
+
/** tera's own sign-out path, which `GET /tera/v1/me` answers as signout_path: relative, resolved against the app's base. */
|
|
157
|
+
export declare const KIT_SIGNOUT_PATH = "_tera/signout";
|
|
120
158
|
/** The kit's route table (ADR-162 D3), as sdk/plugin/webui declares it. */
|
|
121
159
|
export declare const KIT_ROUTE_TABLE: {
|
|
122
160
|
readonly prefix: "/tera/v1/";
|
|
123
|
-
readonly signout_path: "
|
|
161
|
+
readonly signout_path: "_tera/signout";
|
|
124
162
|
readonly same_origin: "A route marked same_origin refuses a request unless it is same-origin: when the request carries Sec-Fetch-Site, that header must be same-origin; when it does not, its Origin must equal the request's own (scheme http or https, and the Host the request was sent to). The refusal is 403 cross_origin.";
|
|
125
163
|
readonly public: "None. Every route under the prefix refuses a request with no verified caller, 401 unauthenticated, before anything else is decided. Only the app's assets, outside the prefix, are served to an anonymous request.";
|
|
126
164
|
readonly error: {
|
|
@@ -223,6 +261,22 @@ export declare const KIT_ROUTE_TABLE: {
|
|
|
223
261
|
readonly schema: "https://kottera.dev/schemas/plugin/webui/upload.response/v1";
|
|
224
262
|
readonly description: "The staged artifact.";
|
|
225
263
|
};
|
|
264
|
+
}, {
|
|
265
|
+
readonly name: "step_up";
|
|
266
|
+
readonly method: "GET";
|
|
267
|
+
readonly path: "/tera/v1/step-ups/{id}";
|
|
268
|
+
readonly summary: "Read one of the person's own step-ups as the person (ADR-173 D11): what a page polls, after opening the step-up's url, until it is satisfied, declined or expired. A step-up that is not the person's is tera's 404, the same as an id no step-up carries.";
|
|
269
|
+
readonly same_origin: false;
|
|
270
|
+
readonly path_params: readonly [{
|
|
271
|
+
readonly name: "id";
|
|
272
|
+
readonly description: "The step-up's id, as a step_up_required refusal's step_up names it.";
|
|
273
|
+
readonly required: true;
|
|
274
|
+
}];
|
|
275
|
+
readonly response: {
|
|
276
|
+
readonly media_type: "application/json";
|
|
277
|
+
readonly schema: "https://kottera.dev/schemas/plugin/webui/step-up.response/v1";
|
|
278
|
+
readonly description: "The step-up's state.";
|
|
279
|
+
};
|
|
226
280
|
}];
|
|
227
281
|
};
|
|
228
282
|
/** The name of a route in KIT_ROUTE_TABLE. */
|
|
@@ -235,6 +289,7 @@ export type KitRouteResponses = {
|
|
|
235
289
|
me: KitMeResponse;
|
|
236
290
|
invoke: KitInvokeResponse;
|
|
237
291
|
upload: KitUploadResponse;
|
|
292
|
+
step_up: KitStepUpResponse;
|
|
238
293
|
};
|
|
239
294
|
/** The data of each server-sent event a feed stream carries, by event name. */
|
|
240
295
|
export type KitFeedEvents = {
|
package/dist/generated/kit.js
CHANGED
|
@@ -10,16 +10,17 @@ export const KIT_SCHEMAS = {
|
|
|
10
10
|
KitFeedEventReauthenticate: "sdk/plugin/webui/schema/v1/feed.event.reauthenticate.json",
|
|
11
11
|
KitInvokeResponse: "sdk/plugin/webui/schema/v1/invoke.response.json",
|
|
12
12
|
KitMeResponse: "sdk/plugin/webui/schema/v1/me.response.json",
|
|
13
|
+
KitStepUpResponse: "sdk/plugin/webui/schema/v1/step-up.response.json",
|
|
13
14
|
KitUploadResponse: "sdk/plugin/webui/schema/v1/upload.response.json",
|
|
14
15
|
};
|
|
15
|
-
/** The prefix every kit route is served under (ADR-162 D3). */
|
|
16
|
+
/** The prefix every kit route is served under (ADR-162 D3), below the app's base. */
|
|
16
17
|
export const KIT_ROUTE_PREFIX = "/tera/v1/";
|
|
17
|
-
/** tera's own sign-out path, which `GET /tera/v1/me` answers as signout_path. */
|
|
18
|
-
export const KIT_SIGNOUT_PATH = "
|
|
18
|
+
/** tera's own sign-out path, which `GET /tera/v1/me` answers as signout_path: relative, resolved against the app's base. */
|
|
19
|
+
export const KIT_SIGNOUT_PATH = "_tera/signout";
|
|
19
20
|
/** The kit's route table (ADR-162 D3), as sdk/plugin/webui declares it. */
|
|
20
21
|
export const KIT_ROUTE_TABLE = {
|
|
21
22
|
prefix: "/tera/v1/",
|
|
22
|
-
signout_path: "
|
|
23
|
+
signout_path: "_tera/signout",
|
|
23
24
|
same_origin: "A route marked same_origin refuses a request unless it is same-origin: when the request carries Sec-Fetch-Site, that header must be same-origin; when it does not, its Origin must equal the request's own (scheme http or https, and the Host the request was sent to). The refusal is 403 cross_origin.",
|
|
24
25
|
public: "None. Every route under the prefix refuses a request with no verified caller, 401 unauthenticated, before anything else is decided. Only the app's assets, outside the prefix, are served to an anonymous request.",
|
|
25
26
|
error: {
|
|
@@ -144,5 +145,24 @@ export const KIT_ROUTE_TABLE = {
|
|
|
144
145
|
description: "The staged artifact.",
|
|
145
146
|
},
|
|
146
147
|
},
|
|
148
|
+
{
|
|
149
|
+
name: "step_up",
|
|
150
|
+
method: "GET",
|
|
151
|
+
path: "/tera/v1/step-ups/{id}",
|
|
152
|
+
summary: "Read one of the person's own step-ups as the person (ADR-173 D11): what a page polls, after opening the step-up's url, until it is satisfied, declined or expired. A step-up that is not the person's is tera's 404, the same as an id no step-up carries.",
|
|
153
|
+
same_origin: false,
|
|
154
|
+
path_params: [
|
|
155
|
+
{
|
|
156
|
+
name: "id",
|
|
157
|
+
description: "The step-up's id, as a step_up_required refusal's step_up names it.",
|
|
158
|
+
required: true,
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
response: {
|
|
162
|
+
media_type: "application/json",
|
|
163
|
+
schema: "https://kottera.dev/schemas/plugin/webui/step-up.response/v1",
|
|
164
|
+
description: "The step-up's state.",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
147
167
|
],
|
|
148
168
|
};
|