@kottera/tera-web 0.1.0-rc.3 → 0.1.0-rc.5

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 CHANGED
@@ -10,9 +10,11 @@ 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 `routePath`. |
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/generated/` | The contract, generated from the committed schemas under `sdk/plugin/webui/schema/`, `sdk/workorder/schema/` and `sdk/feed/schema/` (D4 item 1): 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. |
15
+ | `src/phone.ts` | `phoneURL(me, location, base?)`, the "Open on your phone" link (ADR-177 D4): `me().phone_path` resolved against the app's base (`/` by default, `/plugins/<name>/` on a path-based installation), with `next` set to `location.href`, fragment included. tera's proxy sends it on to tera's phone page, which draws the page's URL as a QR code, so the page needs no knowledge of tera's URL. A `phone_path` that is not relative to the base throws. |
16
+ | `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}`. |
17
+ | `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
18
  | `src/feed/` | The stream connection (D4 item 2). |
17
19
  | `src/**/*.test.ts` | The suite, beside what it tests. |
18
20
 
@@ -36,3 +38,16 @@ is here for an author with one, `tsc -p sdk/web`. What the gate does run over
36
38
  the generated types is `src/contract.test.ts`: every committed schema has a
37
39
  generated type, the route table is the committed one, and a fixture of each
38
40
  D3 shape, annotated with its generated type, is valid against its schema.
41
+
42
+ ## Open on your phone
43
+
44
+ ```ts
45
+ const me = await client.me();
46
+ link.href = phoneURL(me, window.location, "/plugins/wb/"); // omit the base at the root of the plugin's own origin
47
+ ```
48
+
49
+ The link is the plugin's own `_tera/phone`, under its base. tera's proxy
50
+ redirects it to tera's `/oauth/phone` only for a page under this
51
+ installation's own base, and to the app's base for any other. The fragment
52
+ rides along, but survives on the phone only when no sign-in intervenes, so a
53
+ deep link a phone must open on its first visit routes by path or query.
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 path, which ends their session for every plugin. A path that
134
- * is not one on this origin is refused rather than followed.
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 on the page's own origin,
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
- * It is a plain class with an injected fetch, request factory and navigation,
35
- * so its tests drive it against a fake and no network.
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, isBaseRelative, 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(routePath("me"), { method: "GET" });
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 url = routePath("invoke", capability);
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 = routePath("uploads");
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 path, which ends their session for every plugin. A path that
187
- * is not one on this origin is refused rather than followed.
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" || !path.startsWith("/") || path.startsWith("//") || path.startsWith("/\\")) {
192
- throw new KitRequestError(200, null, `${routePath("me")} answered a signout_path that is not a path on this origin: ${JSON.stringify(path)}`);
338
+ if (!isBaseRelative(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: { Accept: JSON_TYPE, ...init.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. */
@@ -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() {
@@ -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,39 @@ 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 ends their session for every plugin. */
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
+ /** Where to send the person to open a page on their phone: tera's proxy's redirect to tera's phone page, given the page's own URL as the next query parameter. A path relative to the app's base, resolved as signout_path is. It is never root-absolute. */
89
+ phone_path: string;
90
+ /** 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. */
91
+ display_name?: string;
92
+ /** 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. */
93
+ email?: string;
94
+ };
95
+ /**
96
+ * GET /tera/v1/step-ups/{id} response.
97
+ *
98
+ * Where one of the person's own step-ups is in its life (ADR-173 D11).
99
+ *
100
+ * Schema: sdk/plugin/webui/schema/v1/step-up.response.json
101
+ */
102
+ export type KitStepUpResponse = {
103
+ /** The step-up's id. */
104
+ id: string;
105
+ /** 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. */
106
+ state: "pending" | "satisfied" | "spent" | "declined" | "expired";
107
+ /** tera's own text for the act the step-up confirms. */
108
+ display: string;
109
+ /** The methods the act accepts: passkey, and email_code when the act accepts it. */
110
+ methods: string[] | null;
111
+ /** When the step-up stops waiting, in RFC 3339. */
112
+ expires_at: string;
113
+ /** What satisfied it, passkey or email_code; absent until it is satisfied. */
114
+ method?: string;
115
+ /** For a passkey, the kind of sign-in that registered it; a passkey anchored on email_code is worth an email code. Absent otherwise. */
116
+ anchor?: string;
117
+ /** When the person confirmed it, in RFC 3339; absent until then. */
118
+ satisfied_at?: string;
80
119
  };
81
120
  /**
82
121
  * POST /tera/v1/uploads response.
@@ -111,16 +150,20 @@ export declare const KIT_SCHEMAS: {
111
150
  readonly KitFeedEventReauthenticate: "sdk/plugin/webui/schema/v1/feed.event.reauthenticate.json";
112
151
  readonly KitInvokeResponse: "sdk/plugin/webui/schema/v1/invoke.response.json";
113
152
  readonly KitMeResponse: "sdk/plugin/webui/schema/v1/me.response.json";
153
+ readonly KitStepUpResponse: "sdk/plugin/webui/schema/v1/step-up.response.json";
114
154
  readonly KitUploadResponse: "sdk/plugin/webui/schema/v1/upload.response.json";
115
155
  };
116
- /** The prefix every kit route is served under (ADR-162 D3). */
156
+ /** The prefix every kit route is served under (ADR-162 D3), below the app's base. */
117
157
  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 = "/_tera/signout";
158
+ /** tera's own sign-out path, which `GET /tera/v1/me` answers as signout_path: relative, resolved against the app's base. */
159
+ export declare const KIT_SIGNOUT_PATH = "_tera/signout";
160
+ /** tera's proxy's phone path, which `GET /tera/v1/me` answers as phone_path: relative, resolved against the app's base. */
161
+ export declare const KIT_PHONE_PATH = "_tera/phone";
120
162
  /** The kit's route table (ADR-162 D3), as sdk/plugin/webui declares it. */
121
163
  export declare const KIT_ROUTE_TABLE: {
122
164
  readonly prefix: "/tera/v1/";
123
- readonly signout_path: "/_tera/signout";
165
+ readonly signout_path: "_tera/signout";
166
+ readonly phone_path: "_tera/phone";
124
167
  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
168
  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
169
  readonly error: {
@@ -132,7 +175,7 @@ export declare const KIT_ROUTE_TABLE: {
132
175
  readonly name: "me";
133
176
  readonly method: "GET";
134
177
  readonly path: "/tera/v1/me";
135
- readonly summary: "Who is signed in, and where to send them to sign out.";
178
+ readonly summary: "Who is signed in, where to send them to sign out, and where to send them to open a page on their phone.";
136
179
  readonly same_origin: false;
137
180
  readonly response: {
138
181
  readonly media_type: "application/json";
@@ -223,6 +266,22 @@ export declare const KIT_ROUTE_TABLE: {
223
266
  readonly schema: "https://kottera.dev/schemas/plugin/webui/upload.response/v1";
224
267
  readonly description: "The staged artifact.";
225
268
  };
269
+ }, {
270
+ readonly name: "step_up";
271
+ readonly method: "GET";
272
+ readonly path: "/tera/v1/step-ups/{id}";
273
+ 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.";
274
+ readonly same_origin: false;
275
+ readonly path_params: readonly [{
276
+ readonly name: "id";
277
+ readonly description: "The step-up's id, as a step_up_required refusal's step_up names it.";
278
+ readonly required: true;
279
+ }];
280
+ readonly response: {
281
+ readonly media_type: "application/json";
282
+ readonly schema: "https://kottera.dev/schemas/plugin/webui/step-up.response/v1";
283
+ readonly description: "The step-up's state.";
284
+ };
226
285
  }];
227
286
  };
228
287
  /** The name of a route in KIT_ROUTE_TABLE. */
@@ -235,6 +294,7 @@ export type KitRouteResponses = {
235
294
  me: KitMeResponse;
236
295
  invoke: KitInvokeResponse;
237
296
  upload: KitUploadResponse;
297
+ step_up: KitStepUpResponse;
238
298
  };
239
299
  /** The data of each server-sent event a feed stream carries, by event name. */
240
300
  export type KitFeedEvents = {