@kottera/tera-web 0.1.0-rc.3 → 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 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 `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/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 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, 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 (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: { 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,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 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
+ /** 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 = "/_tera/signout";
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: "/_tera/signout";
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 = {
@@ -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 = "/_tera/signout";
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: "/_tera/signout",
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
  };
@@ -0,0 +1,196 @@
1
+ /**
2
+ * An ended share.
3
+ *
4
+ * The result of api.v1.shares.delete and api.v1.shares.leave: what the act ended. An ended share decides nothing and is not listed.
5
+ *
6
+ * Schema: sdk/shares/schema/v1/ended.json
7
+ */
8
+ export type SharesEnded = {
9
+ /** The ended share's id. */
10
+ id: string;
11
+ /** The installation whose resource was shared, <vault>/plugins/<installation>. */
12
+ installation: string;
13
+ /** The resource that was shared. */
14
+ resource: string;
15
+ /** The grantee person's id. */
16
+ grantee: string;
17
+ /** The sharer person's id. */
18
+ sharer: string;
19
+ /** How the share ended, as its record holds it: revoked or left. */
20
+ ended: "revoked" | "left";
21
+ };
22
+ /**
23
+ * Extend a share.
24
+ *
25
+ * The params of api.v1.shares.patch, PATCH /api/v1/shares/{id} (ADR-174 D11).
26
+ *
27
+ * Schema: sdk/shares/schema/v1/extend.request.json
28
+ */
29
+ export type SharesExtendRequest = {
30
+ /** The share's id, a UUIDv7. */
31
+ id: string;
32
+ /** The new expiry: later than the share's, at most the installation's max_expiry from now. Absent takes the kind's default_expiry from now, clamped to max_expiry. */
33
+ expires_at?: string;
34
+ };
35
+ /**
36
+ * Name one share.
37
+ *
38
+ * The params of api.v1.shares.get, api.v1.shares.delete and api.v1.shares.leave: the {id} of /api/v1/shares/{id}.
39
+ *
40
+ * Schema: sdk/shares/schema/v1/id.request.json
41
+ */
42
+ export type SharesIdRequest = {
43
+ /** The share's id, a UUIDv7. */
44
+ id: string;
45
+ };
46
+ /**
47
+ * A list of shares.
48
+ *
49
+ * The result of api.v1.shares.list.
50
+ *
51
+ * Schema: sdk/shares/schema/v1/list.json
52
+ */
53
+ export type SharesList = {
54
+ /** The shares, each with whether it decides now. */
55
+ shares: {
56
+ /** The share's id, a UUIDv7. */
57
+ id: string;
58
+ /** The installation whose resource is shared, <vault>/plugins/<installation>. */
59
+ installation: string;
60
+ /** The resource shared, plugins/<vault>/<installation>/<kind>/<id>. */
61
+ resource: string;
62
+ /** The shared verbs. */
63
+ verbs: string[] | null;
64
+ /** What the grantee is: person. */
65
+ grantee_kind: string;
66
+ /** The grantee person's id. */
67
+ grantee: string;
68
+ /** The id of the person who made the share. */
69
+ sharer: string;
70
+ /** The plugin principal that made the share for its sharer; absent when the sharer made it directly. */
71
+ created_via?: string;
72
+ /** When the share was made. */
73
+ created_at: string;
74
+ /** When the share stops deciding. */
75
+ expires_at: string;
76
+ /** When the share was last extended; absent if never. */
77
+ extended_at?: string;
78
+ /** Whether the share decides at least one of its verbs on the grantee's next request, computed from what decides now. */
79
+ deciding: boolean;
80
+ /** When deciding is false, the one reason why. Absent while the share decides. */
81
+ not_deciding?: "sharer_lost_access" | "sharing_disabled" | "installation_not_standing" | "verb_no_longer_shareable" | "pending_invitation";
82
+ }[] | null;
83
+ };
84
+ /**
85
+ * List shares.
86
+ *
87
+ * The params of api.v1.shares.list, the query of GET /api/v1/shares. At most one member is named; with none, the caller's own live shares as grantee and as sharer.
88
+ *
89
+ * Schema: sdk/shares/schema/v1/list.request.json
90
+ */
91
+ export type SharesListRequest = {
92
+ /** An installation, <vault>/plugins/<name>: every share on it. Needs the shares admin grant (api/v1/shares/admin). */
93
+ installation?: string;
94
+ /** One resource, plugins/<vault>/<installation>/<kind>/<id>: every share on it when the caller's own grants include its share-conferring verb, else the caller's own shares on it. */
95
+ resource?: string;
96
+ /** A grantee's person id; someone else's needs the shares admin grant. */
97
+ grantee?: string;
98
+ /** A sharer's person id; someone else's needs the shares admin grant. */
99
+ sharer?: string;
100
+ };
101
+ /**
102
+ * Make a share.
103
+ *
104
+ * The params of api.v1.shares.create, the body of POST /api/v1/shares (ADR-174 D6).
105
+ *
106
+ * Schema: sdk/shares/schema/v1/make.request.json
107
+ */
108
+ export type SharesMakeRequest = {
109
+ /** The one resource shared, plugins/<vault>/<installation>/<kind>/<id>. */
110
+ resource: string;
111
+ /** The shared verbs: a non-empty subset of the kind's sharing { verbs }, never its share-conferring verb. */
112
+ verbs: string[];
113
+ /** The grantee person's id. Exactly one of grantee and grantee_address names the grantee. */
114
+ grantee?: string;
115
+ /** An email address the directory resolves to the grantee. An address no person holds is invited, and the share is pending until they sign in, on a kind whose effective grantee class is addresses; otherwise it is refused share_grantee_not_admitted. */
116
+ grantee_address?: string;
117
+ /** The share's expiry, at most the installation's max_expiry from now. Absent takes the kind's default_expiry, clamped to max_expiry. */
118
+ expires_at?: string;
119
+ };
120
+ /**
121
+ * The vocabulary of the share acts' refusal codes (sdk/shares.Codes).
122
+ *
123
+ * Every stable code a share act is refused with. A code is never renamed; a new refusal is a new code, so a reader keeps a code it does not know as it arrived. share_not_found answers a share the caller may not see exactly as an id no share has. share_grantee_not_admitted answers an address no person holds on a kind whose effective grantee class is not addresses; on one that is, the address is invited and the share is pending until the invitee signs in.
124
+ *
125
+ * Schema: sdk/shares/schema/v1/refusal-code.json
126
+ */
127
+ export type SharesRefusalCode = "share_sharer_not_a_person" | "share_sharing_disabled" | "share_resource_not_shareable" | "share_resource_unknown" | "share_actor_other_installation" | "share_verb_not_shareable" | "share_verb_confers_sharing" | "share_verb_not_held" | "share_grantee_not_admitted" | "share_guest_cannot_invite" | "share_invitation_refused" | "share_grantee_is_sharer" | "share_expiry_beyond_max" | "share_cap_active" | "share_cap_pending_invitations" | "share_exists" | "share_caller_not_a_person" | "share_not_found" | "share_sharer_lost_access" | "share_expiry_not_later" | "share_admin_required" | "share_list_query" | "shares_unavailable";
128
+ /**
129
+ * A share act refused.
130
+ *
131
+ * A share act tera refused, as the shares client answers it: the route's stable code and its words, unchanged, and the detail the code declares.
132
+ *
133
+ * Schema: sdk/shares/schema/v1/refusal.json
134
+ */
135
+ export type SharesRefusal = {
136
+ /** The route's stable code. A code a reader does not know is kept as it arrived. */
137
+ code: string;
138
+ /** The route's words for a person, unchanged. */
139
+ message: string;
140
+ /** On share_verb_not_held, share_verb_not_shareable and share_verb_confers_sharing, the verb refused. */
141
+ verb?: string;
142
+ /** On share_exists, the id of the share already there. */
143
+ existing?: string;
144
+ /** On share_invitation_refused, why the address was not invited (inviter_limited, email_unavailable, ...). */
145
+ invitation?: string;
146
+ };
147
+ /**
148
+ * A share.
149
+ *
150
+ * One live share, with whether it decides now: the result of api.v1.shares.create, api.v1.shares.get and api.v1.shares.patch, and each member of a list (ADR-174 D14).
151
+ *
152
+ * Schema: sdk/shares/schema/v1/share.json
153
+ */
154
+ export type SharesShare = {
155
+ /** The share's id, a UUIDv7. */
156
+ id: string;
157
+ /** The installation whose resource is shared, <vault>/plugins/<installation>. */
158
+ installation: string;
159
+ /** The resource shared, plugins/<vault>/<installation>/<kind>/<id>. */
160
+ resource: string;
161
+ /** The shared verbs. */
162
+ verbs: string[] | null;
163
+ /** What the grantee is: person. */
164
+ grantee_kind: string;
165
+ /** The grantee person's id. */
166
+ grantee: string;
167
+ /** The id of the person who made the share. */
168
+ sharer: string;
169
+ /** The plugin principal that made the share for its sharer; absent when the sharer made it directly. */
170
+ created_via?: string;
171
+ /** When the share was made. */
172
+ created_at: string;
173
+ /** When the share stops deciding. */
174
+ expires_at: string;
175
+ /** When the share was last extended; absent if never. */
176
+ extended_at?: string;
177
+ /** Whether the share decides at least one of its verbs on the grantee's next request, computed from what decides now. */
178
+ deciding: boolean;
179
+ /** When deciding is false, the one reason why. Absent while the share decides. */
180
+ not_deciding?: "sharer_lost_access" | "sharing_disabled" | "installation_not_standing" | "verb_no_longer_shareable" | "pending_invitation";
181
+ };
182
+ /**
183
+ * Every type this module declares, mapped to the repository path of the schema
184
+ * it was generated from.
185
+ */
186
+ export declare const SHARES_SCHEMAS: {
187
+ readonly SharesEnded: "sdk/shares/schema/v1/ended.json";
188
+ readonly SharesExtendRequest: "sdk/shares/schema/v1/extend.request.json";
189
+ readonly SharesIdRequest: "sdk/shares/schema/v1/id.request.json";
190
+ readonly SharesList: "sdk/shares/schema/v1/list.json";
191
+ readonly SharesListRequest: "sdk/shares/schema/v1/list.request.json";
192
+ readonly SharesMakeRequest: "sdk/shares/schema/v1/make.request.json";
193
+ readonly SharesRefusalCode: "sdk/shares/schema/v1/refusal-code.json";
194
+ readonly SharesRefusal: "sdk/shares/schema/v1/refusal.json";
195
+ readonly SharesShare: "sdk/shares/schema/v1/share.json";
196
+ };
@@ -0,0 +1,17 @@
1
+ // Code generated by cmd/internal/genwebtypes from sdk/shares/schema/v1/; DO NOT EDIT.
2
+ // Regenerate with `make web-types` (ADR-162 D4 item 1).
3
+ /**
4
+ * Every type this module declares, mapped to the repository path of the schema
5
+ * it was generated from.
6
+ */
7
+ export const SHARES_SCHEMAS = {
8
+ SharesEnded: "sdk/shares/schema/v1/ended.json",
9
+ SharesExtendRequest: "sdk/shares/schema/v1/extend.request.json",
10
+ SharesIdRequest: "sdk/shares/schema/v1/id.request.json",
11
+ SharesList: "sdk/shares/schema/v1/list.json",
12
+ SharesListRequest: "sdk/shares/schema/v1/list.request.json",
13
+ SharesMakeRequest: "sdk/shares/schema/v1/make.request.json",
14
+ SharesRefusalCode: "sdk/shares/schema/v1/refusal-code.json",
15
+ SharesRefusal: "sdk/shares/schema/v1/refusal.json",
16
+ SharesShare: "sdk/shares/schema/v1/share.json",
17
+ };
@@ -48,6 +48,8 @@ export type WorkOrderAnswerParams = {
48
48
  replace?: boolean;
49
49
  /** Cross ONE refusal: a select site where this Work Order's live step binding is running a candidate the new pin no longer offers (ADR-130 D5, ratified crossable 2026-09-20). The binding row is RETAINED at the same chain generation and the resume RE-SELECTS from the new candidate set, running the candidate loop against live capacity exactly as a first admission would. It does NOT preserve the candidate the run is on — that candidate is gone, which is why the refusal exists. It lifts nothing else: a capability or operation switch at a fixed site has no candidate to re-select, and a withdrawn `yields_turn` offer is frozen for the life of a pinned conversation (ADR-129 D9). Over a site whose candidate IS still offered, or a fixed site, it is a no-op and says so in the manifest rather than refusing. Only meaningful with `repin`. Authorized against `api/v1/workorders/repin/reselect` IN ADDITION to `api/v1/workorders/repin`. Pass `dry_run` first: the manifest's `would_reselect` names the site, the candidate being given up and the set the resume will choose from. */
50
50
  reselect?: boolean;
51
+ /** The id of the step-up a person confirmed this act with, when the Work Order's type requires one for it (ADR-173 D10): a review verdict, an answer, or an off-tera `complete` / `failed`. Call WITHOUT it first: the call is refused `step_up_required` and its error data names the step-up's `id` and the `url` the person opens to confirm it — show them that url; you cannot confirm it yourself. Then retry the IDENTICAL call with step_up = that id. `step_up_pending` means they have not confirmed yet: wait and retry the same call. A step-up confirms exactly one act with exactly this content, once. */
52
+ step_up?: string;
51
53
  /** The operator's exact prose. Interactive answers become one durable ledger message; other answers are wrapped as {"answer": "<text>"}. Ignored when `payload` is supplied. */
52
54
  text?: string;
53
55
  };
@@ -965,6 +967,13 @@ export type WorkOrderGetResult = {
965
967
  authorized_for?: string;
966
968
  plugin?: string;
967
969
  evidence_class?: string;
970
+ step_up?: {
971
+ id: string;
972
+ method: string;
973
+ anchor?: string;
974
+ at: string;
975
+ };
976
+ display_name?: string;
968
977
  };
969
978
  };
970
979
  question?: {
@@ -1217,10 +1226,22 @@ export type WorkOrderGetResult = {
1217
1226
  describe?: string;
1218
1227
  caller_is_person?: boolean;
1219
1228
  outcome_provenance?: string;
1229
+ step_up?: {
1230
+ id: string;
1231
+ method: string;
1232
+ anchor?: string;
1233
+ at: string;
1234
+ };
1220
1235
  };
1221
1236
  outcome?: {
1222
1237
  outcome_provenance: string;
1223
1238
  attested_by?: string;
1239
+ step_up?: {
1240
+ id: string;
1241
+ method: string;
1242
+ anchor?: string;
1243
+ at: string;
1244
+ };
1224
1245
  };
1225
1246
  relations: {
1226
1247
  relation: string;
@@ -1785,6 +1806,12 @@ export type WorkOrderListResult = {
1785
1806
  describe?: string;
1786
1807
  caller_is_person?: boolean;
1787
1808
  outcome_provenance?: string;
1809
+ step_up?: {
1810
+ id: string;
1811
+ method: string;
1812
+ anchor?: string;
1813
+ at: string;
1814
+ };
1788
1815
  };
1789
1816
  }[] | null;
1790
1817
  };
@@ -1818,6 +1845,12 @@ export type WorkOrderListOffTeraResult = {
1818
1845
  describe?: string;
1819
1846
  caller_is_person?: boolean;
1820
1847
  outcome_provenance?: string;
1848
+ step_up?: {
1849
+ id: string;
1850
+ method: string;
1851
+ anchor?: string;
1852
+ at: string;
1853
+ };
1821
1854
  };
1822
1855
  take_age_seconds?: number;
1823
1856
  /** On a held entry: the JSON Schema this Work Order was pinned to at its first take, which submit_result grades the record against. Absent on posted and taken entries, and when the type declares none. */
@@ -1840,6 +1873,12 @@ export type WorkOrderListOffTeraResult = {
1840
1873
  describe?: string;
1841
1874
  caller_is_person?: boolean;
1842
1875
  outcome_provenance?: string;
1876
+ step_up?: {
1877
+ id: string;
1878
+ method: string;
1879
+ anchor?: string;
1880
+ at: string;
1881
+ };
1843
1882
  };
1844
1883
  take_age_seconds?: number;
1845
1884
  /** On a held entry: the JSON Schema this Work Order was pinned to at its first take, which submit_result grades the record against. Absent on posted and taken entries, and when the type declares none. */
@@ -1862,6 +1901,12 @@ export type WorkOrderListOffTeraResult = {
1862
1901
  describe?: string;
1863
1902
  caller_is_person?: boolean;
1864
1903
  outcome_provenance?: string;
1904
+ step_up?: {
1905
+ id: string;
1906
+ method: string;
1907
+ anchor?: string;
1908
+ at: string;
1909
+ };
1865
1910
  };
1866
1911
  take_age_seconds?: number;
1867
1912
  /** On a held entry: the JSON Schema this Work Order was pinned to at its first take, which submit_result grades the record against. Absent on posted and taken entries, and when the type declares none. */
@@ -1962,6 +2007,12 @@ export type WorkOrderProgressResult = {
1962
2007
  describe?: string;
1963
2008
  caller_is_person?: boolean;
1964
2009
  outcome_provenance?: string;
2010
+ step_up?: {
2011
+ id: string;
2012
+ method: string;
2013
+ anchor?: string;
2014
+ at: string;
2015
+ };
1965
2016
  } | null;
1966
2017
  addressed_events: {
1967
2018
  id: number;
@@ -2550,6 +2601,8 @@ export type WorkOrderSubmitResultParams = {
2550
2601
  };
2551
2602
  /** The id of the last event you have processed on this Work Order. The response carries the events addressed to you after it (a repost, a cancel, a wake, an answer, posted guidance), at most 32 with `more` set when there were more, plus `newest_event_id`. The server keeps no cursor: omit it, or pass 0, for everything addressed so far. */
2552
2603
  seen_through?: number;
2604
+ /** The id of the step-up a person confirmed this act with, when the Work Order's type requires one for it (ADR-173 D10): a review verdict, an answer, or an off-tera `complete` / `failed`. Call WITHOUT it first: the call is refused `step_up_required` and its error data names the step-up's `id` and the `url` the person opens to confirm it — show them that url; you cannot confirm it yourself. Then retry the IDENTICAL call with step_up = that id. `step_up_pending` means they have not confirmed yet: wait and retry the same call. A step-up confirms exactly one act with exactly this content, once. */
2605
+ step_up?: string;
2553
2606
  };
2554
2607
  /**
2555
2608
  * `work_order.submit_result` result (v1).
@@ -2565,6 +2618,12 @@ export type WorkOrderSubmitResultResult = {
2565
2618
  outcome?: {
2566
2619
  outcome_provenance: string;
2567
2620
  attested_by?: string;
2621
+ step_up?: {
2622
+ id: string;
2623
+ method: string;
2624
+ anchor?: string;
2625
+ at: string;
2626
+ };
2568
2627
  };
2569
2628
  checks?: string;
2570
2629
  addressed_events: {
@@ -2612,6 +2671,12 @@ export type WorkOrderTakeResult = {
2612
2671
  describe?: string;
2613
2672
  caller_is_person?: boolean;
2614
2673
  outcome_provenance?: string;
2674
+ step_up?: {
2675
+ id: string;
2676
+ method: string;
2677
+ anchor?: string;
2678
+ at: string;
2679
+ };
2615
2680
  } | null;
2616
2681
  instructions: string;
2617
2682
  requirements?: string;
@@ -2675,6 +2740,8 @@ export type WorkOrderTransitionParams = {
2675
2740
  replace?: boolean;
2676
2741
  /** Cross ONE refusal: a select site where this Work Order's live step binding is running a candidate the new pin no longer offers (ADR-130 D5, ratified crossable 2026-09-20). The binding row is RETAINED at the same chain generation and the resume RE-SELECTS from the new candidate set, running the candidate loop against live capacity exactly as a first admission would. It does NOT preserve the candidate the run is on — that candidate is gone, which is why the refusal exists. It lifts nothing else: a capability or operation switch at a fixed site has no candidate to re-select, and a withdrawn `yields_turn` offer is frozen for the life of a pinned conversation (ADR-129 D9). Over a site whose candidate IS still offered, or a fixed site, it is a no-op and says so in the manifest rather than refusing. Only meaningful with `repin`. Authorized against `api/v1/workorders/repin/reselect` IN ADDITION to `api/v1/workorders/repin`. Pass `dry_run` first: the manifest's `would_reselect` names the site, the candidate being given up and the set the resume will choose from. */
2677
2742
  reselect?: boolean;
2743
+ /** The id of the step-up a person confirmed this act with, when the Work Order's type requires one for it (ADR-173 D10): a review verdict, an answer, or an off-tera `complete` / `failed`. Call WITHOUT it first: the call is refused `step_up_required` and its error data names the step-up's `id` and the `url` the person opens to confirm it — show them that url; you cannot confirm it yourself. Then retry the IDENTICAL call with step_up = that id. `step_up_pending` means they have not confirmed yet: wait and retry the same call. A step-up confirms exactly one act with exactly this content, once. */
2744
+ step_up?: string;
2678
2745
  /** action=answer only: the operator's answer text. Read work_order.get(id).question first — that is what is being asked. */
2679
2746
  text?: string;
2680
2747
  };
@@ -3102,6 +3169,12 @@ export type WorkOrderTreeResult = {
3102
3169
  outcome?: {
3103
3170
  outcome_provenance: string;
3104
3171
  attested_by?: string;
3172
+ step_up?: {
3173
+ id: string;
3174
+ method: string;
3175
+ anchor?: string;
3176
+ at: string;
3177
+ };
3105
3178
  };
3106
3179
  replaced?: {
3107
3180
  relation: string;
@@ -3326,6 +3399,12 @@ export type WorkOrderTreeResult = {
3326
3399
  outcome?: {
3327
3400
  outcome_provenance: string;
3328
3401
  attested_by?: string;
3402
+ step_up?: {
3403
+ id: string;
3404
+ method: string;
3405
+ anchor?: string;
3406
+ at: string;
3407
+ };
3329
3408
  };
3330
3409
  replaced?: {
3331
3410
  relation: string;
package/dist/index.d.ts CHANGED
@@ -1,12 +1,15 @@
1
- export { ROUTE_PREFIX, routePath } from "./routes.ts";
1
+ export { ROOT_BASE, ROUTE_PREFIX, appBase, routePath, underBase } from "./routes.ts";
2
2
  export { TeraClient, KitRequestError, UNRECOGNIZED_RESPONSE, type FeedOptions, type InvokeVerdict, type KitErrorBody, type TeraClientOptions, type UploadOptions, type UploadProgress, type UploadRequest, type VerbRefusal, } from "./client.ts";
3
+ export { STEP_UP_HEADER, STEP_UP_POLL_MS, STEP_UP_REQUIRED, STEP_UP_RESUME_PARAM, StepUpError, resumedStepUp, stepUpChallengeOf, stepUpPageURL, type StepUpChallenge, type StepUpEnd, type StepUpOptions, type StepUpWindow, } from "./stepup.ts";
3
4
  export { BASE_DELAY_MS, MAX_DELAY_MS, REPLAY_LIMIT, REPLAY_WINDOW_MS, HEARTBEAT_GRACE_MS, REAUTHENTICATE_EVENT, FeedConnection, browserSignals, defaultOrderKey, type Clock, type FeedConnectionOptions, type FeedConnectionPhase, type FeedConnectionState, type FeedFrame, type FeedLoss, type FeedSignal, type FeedSignals, } from "./feed/connection.ts";
4
5
  export { FeedOpenError, SSEParser, fetchOpener, isRefusal, type FeedOpener, type SSEEvent } from "./feed/sse.ts";
5
6
  export type * from "./generated/feed.ts";
6
7
  export type * from "./generated/kit.ts";
8
+ export type * from "./generated/shares.ts";
7
9
  export type * from "./generated/workorder.ts";
8
10
  export type * from "./generated/workorder-events.ts";
9
11
  export { FEED_SCHEMAS } from "./generated/feed.ts";
10
12
  export { KIT_ROUTE_PREFIX, KIT_ROUTE_TABLE, KIT_SCHEMAS, KIT_SIGNOUT_PATH } from "./generated/kit.ts";
13
+ export { SHARES_SCHEMAS } from "./generated/shares.ts";
11
14
  export { WORK_ORDER_SCHEMAS } from "./generated/workorder.ts";
12
15
  export { WORK_ORDER_EVENT_SCHEMAS } from "./generated/workorder-events.ts";
package/dist/index.js CHANGED
@@ -2,11 +2,13 @@
2
2
  //
3
3
  // Framework-free, with no runtime dependencies: it carries no UI component,
4
4
  // because the framework is the author's.
5
- export { ROUTE_PREFIX, routePath } from "./routes.js";
5
+ export { ROOT_BASE, ROUTE_PREFIX, appBase, routePath, underBase } from "./routes.js";
6
6
  export { TeraClient, KitRequestError, UNRECOGNIZED_RESPONSE, } from "./client.js";
7
+ export { STEP_UP_HEADER, STEP_UP_POLL_MS, STEP_UP_REQUIRED, STEP_UP_RESUME_PARAM, StepUpError, resumedStepUp, stepUpChallengeOf, stepUpPageURL, } from "./stepup.js";
7
8
  export { BASE_DELAY_MS, MAX_DELAY_MS, REPLAY_LIMIT, REPLAY_WINDOW_MS, HEARTBEAT_GRACE_MS, REAUTHENTICATE_EVENT, FeedConnection, browserSignals, defaultOrderKey, } from "./feed/connection.js";
8
9
  export { FeedOpenError, SSEParser, fetchOpener, isRefusal } from "./feed/sse.js";
9
10
  export { FEED_SCHEMAS } from "./generated/feed.js";
10
11
  export { KIT_ROUTE_PREFIX, KIT_ROUTE_TABLE, KIT_SCHEMAS, KIT_SIGNOUT_PATH } from "./generated/kit.js";
12
+ export { SHARES_SCHEMAS } from "./generated/shares.js";
11
13
  export { WORK_ORDER_SCHEMAS } from "./generated/workorder.js";
12
14
  export { WORK_ORDER_EVENT_SCHEMAS } from "./generated/workorder-events.js";
package/dist/routes.d.ts CHANGED
@@ -8,3 +8,18 @@ export declare const ROUTE_PREFIX = "/tera/v1/";
8
8
  * collapses the dot segments and encoding does not change them.
9
9
  */
10
10
  export declare function routePath(...segments: string[]): string;
11
+ /** The base of an app served at the root of its origin. */
12
+ export declare const ROOT_BASE = "/";
13
+ /**
14
+ * appBase checks the path an app is served under on its origin and answers it
15
+ * ending in `/`: `/` on a plugin's own origin, `/plugins/<name>/` on a
16
+ * path-based installation. A base that is not a root-absolute path on the
17
+ * page's own origin is refused.
18
+ */
19
+ export declare function appBase(base?: string): string;
20
+ /**
21
+ * underBase places a path the kit names under an app's base: a kit path
22
+ * (`/tera/v1/me`, from {@link routePath}) or a path relative to the base
23
+ * (`_tera/signout`, the kit's `signout_path`) alike.
24
+ */
25
+ export declare function underBase(base: string, path: string): string;
package/dist/routes.js CHANGED
@@ -5,6 +5,12 @@
5
5
  // The route table itself is declared once, in Go, under sdk/plugin/webui, and
6
6
  // generated into ./generated/kit.ts. What is here is how a path is built from
7
7
  // it.
8
+ //
9
+ // The table's paths are the kit's own: tera's proxy serves a plugin either at
10
+ // the root of its own origin or, on a path-based installation, under
11
+ // `/plugins/<name>/` on tera's origin, and strips that prefix before the kit
12
+ // sees a request. A page therefore reaches every route, and tera's sign-out,
13
+ // under its app's base (`/` or `/plugins/<name>/`), which only the page knows.
8
14
  import { KIT_ROUTE_PREFIX } from "./generated/kit.js";
9
15
  /** The prefix every kit route is served under. */
10
16
  export const ROUTE_PREFIX = KIT_ROUTE_PREFIX;
@@ -30,3 +36,25 @@ export function routePath(...segments) {
30
36
  });
31
37
  return ROUTE_PREFIX + parts.join("/");
32
38
  }
39
+ /** The base of an app served at the root of its origin. */
40
+ export const ROOT_BASE = "/";
41
+ /**
42
+ * appBase checks the path an app is served under on its origin and answers it
43
+ * ending in `/`: `/` on a plugin's own origin, `/plugins/<name>/` on a
44
+ * path-based installation. A base that is not a root-absolute path on the
45
+ * page's own origin is refused.
46
+ */
47
+ export function appBase(base = ROOT_BASE) {
48
+ if (!base.startsWith("/") || base.startsWith("//") || /[?#\\]/.test(base)) {
49
+ throw new TypeError(`appBase: ${JSON.stringify(base)} is not a root-absolute path on this origin`);
50
+ }
51
+ return base.endsWith("/") ? base : base + "/";
52
+ }
53
+ /**
54
+ * underBase places a path the kit names under an app's base: a kit path
55
+ * (`/tera/v1/me`, from {@link routePath}) or a path relative to the base
56
+ * (`_tera/signout`, the kit's `signout_path`) alike.
57
+ */
58
+ export function underBase(base, path) {
59
+ return appBase(base) + (path.startsWith("/") ? path.slice(1) : path);
60
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * A person's step-up, from the page's side (ADR-173 D11 step 3).
3
+ *
4
+ * An act the plugin requires the person's confirmation for is refused
5
+ * `step_up_required`: by the plugin's own route, in the kit's envelope with
6
+ * `error.step_up`, or by a Work Order verb, relayed by the invoke route with
7
+ * the challenge in the refusal's `data.step_up`. Either names the step-up's
8
+ * id and the page on tera's origin the person confirms it on. The client then:
9
+ *
10
+ * 1. opens that page in a new window, and when the browser will not open one,
11
+ * navigates to it instead with a `return_to` naming this page, which tera
12
+ * accepts only on the plugin's own origin;
13
+ * 2. polls `GET /tera/v1/step-ups/{id}` until the step-up is satisfied,
14
+ * declined or expired, and closes the window it opened;
15
+ * 3. sends the identical request again with `Tera-Step-Up: <id>` — or, for a
16
+ * Work Order verb, with `step_up` in its params.
17
+ *
18
+ * A step-up confirms one act once: nothing here holds an id past the retry it
19
+ * was confirmed for, except across the navigation fallback, where the id rides
20
+ * back on the `return_to` as {@link STEP_UP_RESUME_PARAM} and is spent by the
21
+ * first request that needs a step-up after the page loads again.
22
+ */
23
+ import type { KitError } from "./generated/kit.ts";
24
+ /** The request header that carries a confirmed step-up's id to the plugin's route. */
25
+ export declare const STEP_UP_HEADER = "Tera-Step-Up";
26
+ /** The code of a refusal that needs the person's confirmation. */
27
+ export declare const STEP_UP_REQUIRED = "step_up_required";
28
+ /**
29
+ * The query parameter the navigation fallback adds to its `return_to`, naming
30
+ * the step-up the person went to confirm, so the page that loads again
31
+ * resumes it rather than beginning another.
32
+ */
33
+ export declare const STEP_UP_RESUME_PARAM = "tera_step_up";
34
+ /** How often the step-up's state is read while the person confirms it, by default. */
35
+ export declare const STEP_UP_POLL_MS = 1000;
36
+ /** What a refusal names of the step-up it needs: the kit envelope's `error.step_up`. */
37
+ export type StepUpChallenge = NonNullable<KitError["error"]["step_up"]>;
38
+ /** The part of a `Window` the client uses: whether the person closed it, and closing it. */
39
+ export interface StepUpWindow {
40
+ readonly closed: boolean;
41
+ close(): void;
42
+ }
43
+ export interface StepUpOptions {
44
+ /** Opens the step-up's page in a new window; `window.open(url, "_blank")` when absent. Answers null when the browser would not open one. */
45
+ openWindow?: (url: string) => StepUpWindow | null;
46
+ /** The page's own URL, which the navigation fallback returns to; `location.href` when absent. */
47
+ pageURL?: () => string;
48
+ /** Waits between reads of the step-up's state; a timer when absent. */
49
+ sleep?: (ms: number) => Promise<void>;
50
+ /** How long to wait between reads; {@link STEP_UP_POLL_MS} when absent. */
51
+ pollIntervalMs?: number;
52
+ }
53
+ /**
54
+ * How a step-up ended without being confirmed:
55
+ * - `declined` — the person said they did not ask for it;
56
+ * - `expired` — nobody confirmed it in time;
57
+ * - `spent` — it confirmed another request already;
58
+ * - `abandoned` — the person closed its window and it is still waiting;
59
+ * - `navigated` — the browser would not open a window, so the page was sent to
60
+ * tera's; it returns here, and the next request that needs a step-up resumes
61
+ * this one.
62
+ */
63
+ export type StepUpEnd = "declined" | "expired" | "spent" | "abandoned" | "navigated";
64
+ /** A step-up the person did not confirm: the request it was for was not sent again. */
65
+ export declare class StepUpError extends Error {
66
+ /** The step-up's id. */
67
+ readonly stepUpId: string;
68
+ /** How it ended. */
69
+ readonly end: StepUpEnd;
70
+ constructor(stepUpId: string, end: StepUpEnd, message: string);
71
+ }
72
+ /**
73
+ * The step-up a refusal needs confirmed, when it is one this client can have
74
+ * confirmed: code `step_up_required` with a page to open. A refusal naming an
75
+ * ended step-up carries no page, and is not one.
76
+ */
77
+ export declare function stepUpChallengeOf(code: string, stepUp: unknown): StepUpChallenge | null;
78
+ /**
79
+ * The step-up page's URL with `return_to` set to `returnTo`, itself carrying
80
+ * {@link STEP_UP_RESUME_PARAM}. tera follows it back only when it is on the
81
+ * plugin's own origin.
82
+ */
83
+ export declare function stepUpPageURL(pageURL: string, stepUpId: string, returnTo: string): string;
84
+ /** The step-up id a page loaded through the navigation fallback carries back, or null. */
85
+ export declare function resumedStepUp(pageURL: string): string | null;
86
+ export declare function defaultOpenWindow(url: string): StepUpWindow | null;
87
+ export declare function defaultSleep(ms: number): Promise<void>;
package/dist/stepup.js ADDED
@@ -0,0 +1,66 @@
1
+ /** The request header that carries a confirmed step-up's id to the plugin's route. */
2
+ export const STEP_UP_HEADER = "Tera-Step-Up";
3
+ /** The code of a refusal that needs the person's confirmation. */
4
+ export const STEP_UP_REQUIRED = "step_up_required";
5
+ /**
6
+ * The query parameter the navigation fallback adds to its `return_to`, naming
7
+ * the step-up the person went to confirm, so the page that loads again
8
+ * resumes it rather than beginning another.
9
+ */
10
+ export const STEP_UP_RESUME_PARAM = "tera_step_up";
11
+ /** How often the step-up's state is read while the person confirms it, by default. */
12
+ export const STEP_UP_POLL_MS = 1000;
13
+ /** A step-up the person did not confirm: the request it was for was not sent again. */
14
+ export class StepUpError extends Error {
15
+ /** The step-up's id. */
16
+ stepUpId;
17
+ /** How it ended. */
18
+ end;
19
+ constructor(stepUpId, end, message) {
20
+ super(message);
21
+ this.name = "StepUpError";
22
+ this.stepUpId = stepUpId;
23
+ this.end = end;
24
+ }
25
+ }
26
+ /**
27
+ * The step-up a refusal needs confirmed, when it is one this client can have
28
+ * confirmed: code `step_up_required` with a page to open. A refusal naming an
29
+ * ended step-up carries no page, and is not one.
30
+ */
31
+ export function stepUpChallengeOf(code, stepUp) {
32
+ if (code !== STEP_UP_REQUIRED || typeof stepUp !== "object" || stepUp === null)
33
+ return null;
34
+ const c = stepUp;
35
+ if (typeof c.id !== "string" || c.id === "" || typeof c.url !== "string" || c.url === "")
36
+ return null;
37
+ return c;
38
+ }
39
+ /**
40
+ * The step-up page's URL with `return_to` set to `returnTo`, itself carrying
41
+ * {@link STEP_UP_RESUME_PARAM}. tera follows it back only when it is on the
42
+ * plugin's own origin.
43
+ */
44
+ export function stepUpPageURL(pageURL, stepUpId, returnTo) {
45
+ const back = new URL(returnTo);
46
+ back.searchParams.set(STEP_UP_RESUME_PARAM, stepUpId);
47
+ const page = new URL(pageURL);
48
+ page.searchParams.set("return_to", back.toString());
49
+ return page.toString();
50
+ }
51
+ /** The step-up id a page loaded through the navigation fallback carries back, or null. */
52
+ export function resumedStepUp(pageURL) {
53
+ try {
54
+ const id = new URL(pageURL).searchParams.get(STEP_UP_RESUME_PARAM);
55
+ return id === null || id === "" ? null : id;
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ export function defaultOpenWindow(url) {
62
+ return globalThis.open?.(url, "_blank") ?? null;
63
+ }
64
+ export function defaultSleep(ms) {
65
+ return new Promise((resolve) => setTimeout(resolve, ms));
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kottera/tera-web",
3
- "version": "0.1.0-rc.3",
3
+ "version": "0.1.0-rc.4",
4
4
  "description": "The browser half of a UI over tera: a framework-free client for the /tera/v1/ routes a plugin's webui kit serves (ADR-162 D4).",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {