@kottera/tera-web 0.1.0-rc.4 → 0.1.0-rc.6

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
@@ -12,6 +12,8 @@ author's — and has no runtime dependencies. It is not yet published.
12
12
  | `src/index.ts` | The package's one entry point. Everything public is re-exported here. |
13
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/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/signin.ts` | Signing in again after tera forgets the page's session, as a daemon restart does: `isSignInRequired(response)` recognises the UI proxy's 401 `sign_in_required`, and `reSignIn()` loads the page again, which signs the person in with no prompt while their tera session lives. `TeraClient` and `FeedConnection` do it on their own, once; a page reading its own routes with its own `fetch` applies `isSignInRequired`. |
15
17
  | `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
18
  | `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. |
17
19
  | `src/feed/` | The stream connection (D4 item 2). |
@@ -37,3 +39,16 @@ is here for an author with one, `tsc -p sdk/web`. What the gate does run over
37
39
  the generated types is `src/contract.test.ts`: every committed schema has a
38
40
  generated type, the route table is the committed one, and a fixture of each
39
41
  D3 shape, annotated with its generated type, is valid against its schema.
42
+
43
+ ## Open on your phone
44
+
45
+ ```ts
46
+ const me = await client.me();
47
+ link.href = phoneURL(me, window.location, "/plugins/wb/"); // omit the base at the root of the plugin's own origin
48
+ ```
49
+
50
+ The link is the plugin's own `_tera/phone`, under its base. tera's proxy
51
+ redirects it to tera's `/oauth/phone` only for a page under this
52
+ installation's own base, and to the app's base for any other. The fragment
53
+ rides along, but survives on the phone only when no sign-in intervenes, so a
54
+ deep link a phone must open on its first visit routes by path or query.
package/dist/client.d.ts CHANGED
@@ -118,6 +118,8 @@ export declare class TeraClient {
118
118
  private readonly pollMs;
119
119
  /** Whether the step-up the page was loaded back with has been taken up. */
120
120
  private resumeTaken;
121
+ /** Whether this client has loaded the page again to sign in. */
122
+ private signingIn;
121
123
  constructor(options?: TeraClientOptions);
122
124
  /** `GET /tera/v1/me`: who is signed in, and where to send them to sign out. */
123
125
  me(): Promise<KitMeResponse>;
@@ -159,9 +161,20 @@ export declare class TeraClient {
159
161
  * person and the request sent once more with `Tera-Step-Up`; any other
160
162
  * answer, a success or a refusal, is returned as it arrived. A body sent
161
163
  * again must be one that can be: a string, a `Blob`, `FormData` or a
162
- * buffer, never a stream.
164
+ * buffer, never a stream. A `sign_in_required` answer loads the page again
165
+ * ({@link TeraClient.reSignIn}) and is returned as it arrived.
163
166
  */
164
167
  fetch(path: string, init?: RequestInit): Promise<Response>;
168
+ /**
169
+ * Loads the page again to sign the person in to the plugin's origin: a
170
+ * top-level navigation to the page's own URL, which tera's UI proxy answers
171
+ * with its silent sign-in. The client does it itself on a
172
+ * `sign_in_required` answer; a page calls it for one it read on its own.
173
+ * It navigates once per client: a second call does nothing.
174
+ */
175
+ reSignIn(): void;
176
+ /** Loads the page again when `request` rejects as `sign_in_required`, and rejects as it did. */
177
+ private signInOnRefusal;
165
178
  /**
166
179
  * Has the person confirm the step-up `challenge` names, and answers its id
167
180
  * once it is satisfied, to send the act again with. It opens the step-up's
package/dist/client.js CHANGED
@@ -43,13 +43,23 @@
43
43
  * params. A step-up the person declines, lets expire or walks away from
44
44
  * rejects with a {@link StepUpError}, and the request is not sent again.
45
45
  *
46
+ * # Signing in again
47
+ *
48
+ * A request tera's UI proxy answers `sign_in_required` — the person's session
49
+ * on the plugin's origin is gone, as it is after a daemon restart — loads the
50
+ * page again ({@link TeraClient.reSignIn}), which signs the person in, and
51
+ * still rejects (or, from {@link TeraClient.fetch}, answers) as it arrived.
52
+ * The page is loaded again once per client, however many requests were
53
+ * refused together. A feed the client opens does the same.
54
+ *
46
55
  * It is a plain class with an injected fetch, request factory, navigation,
47
56
  * window opener and timer, so its tests drive it against a fake and no
48
57
  * network.
49
58
  */
50
- import { appBase, routePath, underBase } from "./routes.js";
59
+ import { appBase, isBaseRelative, routePath, underBase } from "./routes.js";
51
60
  import { FeedConnection } from "./feed/connection.js";
52
61
  import { fetchOpener } from "./feed/sse.js";
62
+ import { SIGN_IN_REQUIRED, isSignInRequired } from "./signin.js";
53
63
  import { STEP_UP_HEADER, STEP_UP_POLL_MS, StepUpError, defaultOpenWindow, defaultSleep, resumedStepUp, stepUpChallengeOf, stepUpPageURL, } from "./stepup.js";
54
64
  /**
55
65
  * The `code` of a {@link KitRequestError} whose answer was not the kit's
@@ -102,6 +112,8 @@ export class TeraClient {
102
112
  pollMs;
103
113
  /** Whether the step-up the page was loaded back with has been taken up. */
104
114
  resumeTaken = false;
115
+ /** Whether this client has loaded the page again to sign in. */
116
+ signingIn = false;
105
117
  constructor(options = {}) {
106
118
  this.base = appBase(options.base);
107
119
  this.fetchFn = options.fetch ?? ((...args) => globalThis.fetch(...args));
@@ -157,7 +169,7 @@ export class TeraClient {
157
169
  * the page no longer wants it.
158
170
  */
159
171
  feed(options) {
160
- return new FeedConnection({ ...options, base: this.base, open: fetchOpener(this.fetchFn) });
172
+ return new FeedConnection({ ...options, base: this.base, open: fetchOpener(this.fetchFn), reSignIn: () => this.reSignIn() });
161
173
  }
162
174
  /**
163
175
  * `POST /tera/v1/uploads`: sends one file as the signed-in person and
@@ -167,13 +179,13 @@ export class TeraClient {
167
179
  */
168
180
  async upload(file, options = {}) {
169
181
  try {
170
- return await this.uploadOnce(file, options, null);
182
+ return await this.signInOnRefusal(this.uploadOnce(file, options, null));
171
183
  }
172
184
  catch (err) {
173
185
  const challenge = err instanceof KitRequestError ? stepUpChallengeOf(err.code, err.envelope?.step_up) : null;
174
186
  if (challenge === null)
175
187
  throw err;
176
- return this.uploadOnce(file, options, await this.confirmStepUp(challenge));
188
+ return this.signInOnRefusal(this.uploadOnce(file, options, await this.confirmStepUp(challenge)));
177
189
  }
178
190
  }
179
191
  uploadOnce(file, options, stepUpId) {
@@ -246,15 +258,47 @@ export class TeraClient {
246
258
  * person and the request sent once more with `Tera-Step-Up`; any other
247
259
  * answer, a success or a refusal, is returned as it arrived. A body sent
248
260
  * again must be one that can be: a string, a `Blob`, `FormData` or a
249
- * buffer, never a stream.
261
+ * buffer, never a stream. A `sign_in_required` answer loads the page again
262
+ * ({@link TeraClient.reSignIn}) and is returned as it arrived.
250
263
  */
251
264
  async fetch(path, init = {}) {
252
265
  const url = underBase(this.base, path);
253
266
  const first = await this.fetchSameOrigin(url, init, null);
267
+ if (await isSignInRequired(first)) {
268
+ this.reSignIn();
269
+ return first;
270
+ }
254
271
  const challenge = await stepUpRefusalOf(first);
255
272
  if (challenge === null)
256
273
  return first;
257
- return this.fetchSameOrigin(url, init, await this.confirmStepUp(challenge));
274
+ const second = await this.fetchSameOrigin(url, init, await this.confirmStepUp(challenge));
275
+ if (await isSignInRequired(second))
276
+ this.reSignIn();
277
+ return second;
278
+ }
279
+ /**
280
+ * Loads the page again to sign the person in to the plugin's origin: a
281
+ * top-level navigation to the page's own URL, which tera's UI proxy answers
282
+ * with its silent sign-in. The client does it itself on a
283
+ * `sign_in_required` answer; a page calls it for one it read on its own.
284
+ * It navigates once per client: a second call does nothing.
285
+ */
286
+ reSignIn() {
287
+ if (this.signingIn)
288
+ return;
289
+ this.signingIn = true;
290
+ this.navigate(this.pageURL());
291
+ }
292
+ /** Loads the page again when `request` rejects as `sign_in_required`, and rejects as it did. */
293
+ async signInOnRefusal(request) {
294
+ try {
295
+ return await request;
296
+ }
297
+ catch (err) {
298
+ if (err instanceof KitRequestError && err.status === 401 && err.code === SIGN_IN_REQUIRED)
299
+ this.reSignIn();
300
+ throw err;
301
+ }
258
302
  }
259
303
  /**
260
304
  * Has the person confirm the step-up `challenge` names, and answers its id
@@ -335,7 +379,7 @@ export class TeraClient {
335
379
  */
336
380
  async signOut() {
337
381
  const { signout_path: path } = await this.me();
338
- if (typeof path !== "string" || path === "" || path.startsWith("/") || /^[^/?#]*:/.test(path) || /[\\]/.test(path)) {
382
+ if (!isBaseRelative(path)) {
339
383
  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)}`);
340
384
  }
341
385
  this.navigate(underBase(this.base, path));
@@ -346,13 +390,13 @@ export class TeraClient {
346
390
  }
347
391
  async json(url, init, stepUp = true) {
348
392
  try {
349
- return await this.jsonOnce(url, init, null);
393
+ return await this.signInOnRefusal(this.jsonOnce(url, init, null));
350
394
  }
351
395
  catch (err) {
352
396
  const challenge = stepUp && err instanceof KitRequestError ? stepUpChallengeOf(err.code, err.envelope?.step_up) : null;
353
397
  if (challenge === null)
354
398
  throw err;
355
- return this.jsonOnce(url, init, await this.confirmStepUp(challenge));
399
+ return this.signInOnRefusal(this.jsonOnce(url, init, await this.confirmStepUp(challenge)));
356
400
  }
357
401
  }
358
402
  async jsonOnce(url, init, stepUpId) {
@@ -99,6 +99,12 @@ export interface FeedConnectionOptions {
99
99
  orderKey?: (record: FeedFrameRecord) => number | undefined;
100
100
  /** Opens one stream; {@link fetchOpener} when absent. */
101
101
  open?: FeedOpener;
102
+ /**
103
+ * Loads the page again when tera's UI proxy refuses the stream
104
+ * `sign_in_required`, which signs the person in; {@link reSignIn} when
105
+ * absent. A {@link TeraClient}'s feed passes the client's own.
106
+ */
107
+ reSignIn?: () => void;
102
108
  clock?: Clock;
103
109
  /** {@link browserSignals} when absent. */
104
110
  signals?: FeedSignals;
@@ -16,6 +16,7 @@
16
16
  * | a `reauthenticate` event (D7: the token expired) | reconnect at once |
17
17
  * | a `credential_expired` status (D7, the same ending) | reconnect at once |
18
18
  * | a status reporting loss | one replay, rationed (see Loss) |
19
+ * | a 401 `sign_in_required` (tera forgot the session) | STOP, and load the page again |
19
20
  * | any other 4xx, a 501, or a success that is not an | STOP: a refusal. A person has to |
20
21
  * | event stream | act ({@link FeedConnection.retry}) |
21
22
  *
@@ -65,6 +66,7 @@
65
66
  */
66
67
  import { routePath, underBase, ROOT_BASE } from "../routes.js";
67
68
  import { fetchOpener, isRefusal } from "./sse.js";
69
+ import { isSignInRequiredAnswer, reSignIn } from "../signin.js";
68
70
  export const BASE_DELAY_MS = 1_000;
69
71
  export const MAX_DELAY_MS = 30_000;
70
72
  /** Replays allowed in {@link REPLAY_WINDOW_MS} before a loss is surfaced. */
@@ -233,6 +235,10 @@ export class FeedConnection {
233
235
  this.generation += 1;
234
236
  this.teardown();
235
237
  this.set({ phase: "refused", refusal: err, lastError: err.message, delayMs: 0 });
238
+ // The person's session on the plugin's origin is gone; only a page
239
+ // load brings it back, and that ends this connection with the page.
240
+ if (isSignInRequiredAnswer(err.status, err.contentType, err.body))
241
+ (this.options.reSignIn ?? reSignIn)();
236
242
  return;
237
243
  }
238
244
  this.reopen(err instanceof Error ? err.message : String(err));
@@ -2,11 +2,11 @@ import type { FeedFrameRecord, FeedFrameStatus } from "./feed.ts";
2
2
  /**
3
3
  * The vocabulary of the kit's own error codes (sdk/plugin/webui.KitCodes).
4
4
  *
5
- * Every code the kit answers with when no verb gave a verdict. Only tera_unavailable is retryable. A failure envelope may also carry a verb's own code, or tera's own code for a call it refused, verbatim, which is not a member: a reader keeps a code it does not know as it arrived and branches on retryable.
5
+ * Every code the kit answers with when no verb gave a verdict, and sign_in_required, which tera's UI proxy answers in front of the kit when a request that is not a page load reaches the plugin's origin with no live session: load the page again to sign in. Only tera_unavailable is retryable. A failure envelope may also carry a verb's own code, or tera's own code for a call it refused, verbatim, which is not a member: a reader keeps a code it does not know as it arrived and branches on retryable.
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" | "step_up_required";
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" | "sign_in_required";
10
10
  /**
11
11
  * Kit error envelope.
12
12
  *
@@ -33,6 +33,7 @@ export type KitError = {
33
33
  url?: string;
34
34
  methods?: string[];
35
35
  expires_at?: string;
36
+ label?: string;
36
37
  state?: string;
37
38
  };
38
39
  };
@@ -85,6 +86,8 @@ export type KitMeResponse = {
85
86
  subject: string;
86
87
  /** 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. */
87
88
  signout_path: string;
89
+ /** 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. */
90
+ phone_path: string;
88
91
  /** 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
92
  display_name?: string;
90
93
  /** 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. */
@@ -155,10 +158,13 @@ export declare const KIT_SCHEMAS: {
155
158
  export declare const KIT_ROUTE_PREFIX = "/tera/v1/";
156
159
  /** tera's own sign-out path, which `GET /tera/v1/me` answers as signout_path: relative, resolved against the app's base. */
157
160
  export declare const KIT_SIGNOUT_PATH = "_tera/signout";
161
+ /** tera's proxy's phone path, which `GET /tera/v1/me` answers as phone_path: relative, resolved against the app's base. */
162
+ export declare const KIT_PHONE_PATH = "_tera/phone";
158
163
  /** The kit's route table (ADR-162 D3), as sdk/plugin/webui declares it. */
159
164
  export declare const KIT_ROUTE_TABLE: {
160
165
  readonly prefix: "/tera/v1/";
161
166
  readonly signout_path: "_tera/signout";
167
+ readonly phone_path: "_tera/phone";
162
168
  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.";
163
169
  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.";
164
170
  readonly error: {
@@ -170,7 +176,7 @@ export declare const KIT_ROUTE_TABLE: {
170
176
  readonly name: "me";
171
177
  readonly method: "GET";
172
178
  readonly path: "/tera/v1/me";
173
- readonly summary: "Who is signed in, and where to send them to sign out.";
179
+ 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.";
174
180
  readonly same_origin: false;
175
181
  readonly response: {
176
182
  readonly media_type: "application/json";
@@ -17,10 +17,13 @@ export const KIT_SCHEMAS = {
17
17
  export const KIT_ROUTE_PREFIX = "/tera/v1/";
18
18
  /** tera's own sign-out path, which `GET /tera/v1/me` answers as signout_path: relative, resolved against the app's base. */
19
19
  export const KIT_SIGNOUT_PATH = "_tera/signout";
20
+ /** tera's proxy's phone path, which `GET /tera/v1/me` answers as phone_path: relative, resolved against the app's base. */
21
+ export const KIT_PHONE_PATH = "_tera/phone";
20
22
  /** The kit's route table (ADR-162 D3), as sdk/plugin/webui declares it. */
21
23
  export const KIT_ROUTE_TABLE = {
22
24
  prefix: "/tera/v1/",
23
25
  signout_path: "_tera/signout",
26
+ phone_path: "_tera/phone",
24
27
  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.",
25
28
  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.",
26
29
  error: {
@@ -33,7 +36,7 @@ export const KIT_ROUTE_TABLE = {
33
36
  name: "me",
34
37
  method: "GET",
35
38
  path: "/tera/v1/me",
36
- summary: "Who is signed in, and where to send them to sign out.",
39
+ summary: "Who is signed in, where to send them to sign out, and where to send them to open a page on their phone.",
37
40
  same_origin: false,
38
41
  response: {
39
42
  media_type: "application/json",
@@ -124,7 +124,7 @@ export type SharesMakeRequest = {
124
124
  *
125
125
  * Schema: sdk/shares/schema/v1/refusal-code.json
126
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";
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_plugin_only" | "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
128
  /**
129
129
  * A share act refused.
130
130
  *
@@ -143,6 +143,8 @@ export type SharesRefusal = {
143
143
  existing?: string;
144
144
  /** On share_invitation_refused, why the address was not invited (inviter_limited, email_unavailable, ...). */
145
145
  invitation?: string;
146
+ /** On share_plugin_only, the installation's name: the plugin the share is made or extended through. */
147
+ plugin?: string;
146
148
  };
147
149
  /**
148
150
  * A share.
@@ -476,6 +476,21 @@ export type WorkOrderCreateParams = {
476
476
  needs_approval?: boolean;
477
477
  /** Existing Work Order to file this one beneath, making it part of that train. It must be one you can read with work_order.get; one you cannot is refused exactly as that read would refuse it. The new Work Order takes that parent's cohort, verbatim; beside it `cohort` accepts `{"new": true, "label"?: …}`, a sub-unit nested under the parent's cohort, and `{"of": "<work-order-id>"}`, joining the cohort of a Work Order in the parent's tree. */
478
478
  parent_id?: string;
479
+ /** Directories of the filing plugin's data volume this Work Order's dispatch is handed. Only a plugin may name them — as itself or acting for a person, or on create_child as the taker of its own off-tera work — and only for a type of its own vault; any other filer is refused (plugin_data_not_a_plugin). The dispatch is placed on the host holding the volume and sees the named directories and nothing else of it. Name input, output or both; neither may contain the other. */
480
+ plugin_data?: {
481
+ /** The one directory the dispatch reads, mounted read-only and told to the step in TERA_PLUGIN_DATA_INPUT. It must exist when the step starts (plugin_data_unavailable). */
482
+ input?: {
483
+ /** A clean relative path inside the plugin's data volume: no `..`, not absolute. */
484
+ dir: string;
485
+ };
486
+ /** The one directory the dispatch writes, told to the step in TERA_PLUGIN_DATA_OUTPUT. Created if absent; refused when not empty at the step's start (plugin_output_not_empty). */
487
+ output?: {
488
+ /** The most the directory may hold when the step ends: at least 1 GiB, at most the installation's hosted { step_output_limit }. Past it the step fails (plugin_output_bound_exceeded). */
489
+ bound_bytes: number;
490
+ /** A clean relative path inside the plugin's data volume: no `..`, not absolute. */
491
+ dir: string;
492
+ };
493
+ };
479
494
  /** Dispatch priority; higher runs sooner among ready Work Orders. */
480
495
  priority?: number;
481
496
  /** Typed, NON-CAUSAL relations from this Work Order to others (supersedes / duplicate_of / about / related). Each entry is a {relation, work_order_id} pair with an optional note, and `work_order_id` is a bare Work Order id — the same spelling `depends_on` and `parent_id` use, never a `vault://` ref. Set them here when the fact is known at filing time, which for the superseding case it usually is; discovered later, use work_order.relate. Three of the four gate nothing: no dispatch is blocked, delayed or cancelled by `duplicate_of`, `about` or `related`. A `supersedes` entry is the exception and it is the point of setting one here — it RETIRES the Work Order it names, in this call's own transaction: that Work Order moves to `superseded` with `superseded_by` set to this one, and can no longer be retried. The target must be in `failed:awaiting-ack`; one that is still running is refused, and the door is work_order.force_fail(id, reason) first. A terminal one is refused too — there is no retry to close — and so is a `supersedes` entry on a drafted create, since a draft may never be released. To record the claim WITHOUT moving the row, use work_order.relate. Same authorization as work_order.relate — a worker-class caller is refused, and an operator-class one needs `api/v1/workorders/relate` on top of its create grant, because the edge is the relate operation whichever call delivered it. Every target must exist, and no two entries may name the same target. Reach on the target is required for the two EVALUATIVE relations (`supersedes`, `duplicate_of`) and not for the two descriptive ones (`about`, `related`), per entry rather than per payload. */
@@ -590,6 +605,21 @@ export type WorkOrderCreateChildParams = {
590
605
  on_child_failure?: "fail-fast" | "wait-others" | "ignore";
591
606
  /** The off-tera Work Order you hold, to file this child beneath (ADR-139 D6). Honoured only for its current taker; omit it inside a dispatch, where the parent is the dispatch's own Work Order. */
592
607
  parent?: string;
608
+ /** Directories of the filing plugin's data volume this Work Order's dispatch is handed. Only a plugin may name them — as itself or acting for a person, or on create_child as the taker of its own off-tera work — and only for a type of its own vault; any other filer is refused (plugin_data_not_a_plugin). The dispatch is placed on the host holding the volume and sees the named directories and nothing else of it. Name input, output or both; neither may contain the other. */
609
+ plugin_data?: {
610
+ /** The one directory the dispatch reads, mounted read-only and told to the step in TERA_PLUGIN_DATA_INPUT. It must exist when the step starts (plugin_data_unavailable). */
611
+ input?: {
612
+ /** A clean relative path inside the plugin's data volume: no `..`, not absolute. */
613
+ dir: string;
614
+ };
615
+ /** The one directory the dispatch writes, told to the step in TERA_PLUGIN_DATA_OUTPUT. Created if absent; refused when not empty at the step's start (plugin_output_not_empty). */
616
+ output?: {
617
+ /** The most the directory may hold when the step ends: at least 1 GiB, at most the installation's hosted { step_output_limit }. Past it the step fails (plugin_output_bound_exceeded). */
618
+ bound_bytes: number;
619
+ /** A clean relative path inside the plugin's data volume: no `..`, not absolute. */
620
+ dir: string;
621
+ };
622
+ };
593
623
  /** Informational: echoed back for your own bookkeeping. What governs whether the parent waits is the wait it declares, not this field. */
594
624
  policy?: "wait-for-all" | "wait-for-any" | "fire-and-forget";
595
625
  /** Dispatch priority; higher runs sooner among ready Work Orders. */
@@ -1404,6 +1434,11 @@ export type WorkOrderGetResult = {
1404
1434
  } | null;
1405
1435
  created_by?: string;
1406
1436
  created_via?: string;
1437
+ released_via?: string;
1438
+ lent_by?: string;
1439
+ arrival_evidence?: {
1440
+ [key: string]: string;
1441
+ };
1407
1442
  created_at: string;
1408
1443
  updated_at: string;
1409
1444
  off_tera: boolean;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { ROOT_BASE, ROUTE_PREFIX, appBase, routePath, underBase } from "./routes.ts";
2
+ export { phoneURL } from "./phone.ts";
3
+ export { SIGN_IN_REQUIRED, isSignInRequired, isSignInRequiredAnswer, reSignIn, type ReSignInOptions } from "./signin.ts";
2
4
  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
5
  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";
4
6
  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";
@@ -9,7 +11,7 @@ export type * from "./generated/shares.ts";
9
11
  export type * from "./generated/workorder.ts";
10
12
  export type * from "./generated/workorder-events.ts";
11
13
  export { FEED_SCHEMAS } from "./generated/feed.ts";
12
- export { KIT_ROUTE_PREFIX, KIT_ROUTE_TABLE, KIT_SCHEMAS, KIT_SIGNOUT_PATH } from "./generated/kit.ts";
14
+ export { KIT_ROUTE_PREFIX, KIT_ROUTE_TABLE, KIT_PHONE_PATH, KIT_SCHEMAS, KIT_SIGNOUT_PATH } from "./generated/kit.ts";
13
15
  export { SHARES_SCHEMAS } from "./generated/shares.ts";
14
16
  export { WORK_ORDER_SCHEMAS } from "./generated/workorder.ts";
15
17
  export { WORK_ORDER_EVENT_SCHEMAS } from "./generated/workorder-events.ts";
package/dist/index.js CHANGED
@@ -3,12 +3,14 @@
3
3
  // Framework-free, with no runtime dependencies: it carries no UI component,
4
4
  // because the framework is the author's.
5
5
  export { ROOT_BASE, ROUTE_PREFIX, appBase, routePath, underBase } from "./routes.js";
6
+ export { phoneURL } from "./phone.js";
7
+ export { SIGN_IN_REQUIRED, isSignInRequired, isSignInRequiredAnswer, reSignIn } from "./signin.js";
6
8
  export { TeraClient, KitRequestError, UNRECOGNIZED_RESPONSE, } from "./client.js";
7
9
  export { STEP_UP_HEADER, STEP_UP_POLL_MS, STEP_UP_REQUIRED, STEP_UP_RESUME_PARAM, StepUpError, resumedStepUp, stepUpChallengeOf, stepUpPageURL, } from "./stepup.js";
8
10
  export { BASE_DELAY_MS, MAX_DELAY_MS, REPLAY_LIMIT, REPLAY_WINDOW_MS, HEARTBEAT_GRACE_MS, REAUTHENTICATE_EVENT, FeedConnection, browserSignals, defaultOrderKey, } from "./feed/connection.js";
9
11
  export { FeedOpenError, SSEParser, fetchOpener, isRefusal } from "./feed/sse.js";
10
12
  export { FEED_SCHEMAS } from "./generated/feed.js";
11
- export { KIT_ROUTE_PREFIX, KIT_ROUTE_TABLE, KIT_SCHEMAS, KIT_SIGNOUT_PATH } from "./generated/kit.js";
13
+ export { KIT_ROUTE_PREFIX, KIT_ROUTE_TABLE, KIT_PHONE_PATH, KIT_SCHEMAS, KIT_SIGNOUT_PATH } from "./generated/kit.js";
12
14
  export { SHARES_SCHEMAS } from "./generated/shares.js";
13
15
  export { WORK_ORDER_SCHEMAS } from "./generated/workorder.js";
14
16
  export { WORK_ORDER_EVENT_SCHEMAS } from "./generated/workorder-events.js";
@@ -0,0 +1,11 @@
1
+ import type { KitMeResponse } from "./generated/kit.ts";
2
+ /**
3
+ * phoneURL builds the "Open on your phone" link of the page at `location`:
4
+ * the `phone_path` `me` answers, resolved against the app's `base` (`/`, or
5
+ * `/plugins/<name>/` on a path-based installation, as
6
+ * `TeraClientOptions.base`), with `next` set to `location.href`, fragment
7
+ * included. It answers a root-absolute path on the page's own origin. A
8
+ * `phone_path` that is not a path relative to the base is refused rather
9
+ * than followed.
10
+ */
11
+ export declare function phoneURL(me: Pick<KitMeResponse, "phone_path">, location: Pick<Location, "href">, base?: string): string;
package/dist/phone.js ADDED
@@ -0,0 +1,24 @@
1
+ // "Open on your phone" (ADR-177 D4): a link to tera's phone page, which draws
2
+ // the page's URL as a QR code. A page builds it without knowing tera's URL:
3
+ // it links to its own proxy's phone path, under its app's base, and tera's
4
+ // proxy redirects to tera's phone page for a URL under this installation's
5
+ // own base and to the app's base for any other.
6
+ import { ROOT_BASE, isBaseRelative, underBase } from "./routes.js";
7
+ /** The query parameter naming the page the phone opens. */
8
+ const NEXT_PARAM = "next";
9
+ /**
10
+ * phoneURL builds the "Open on your phone" link of the page at `location`:
11
+ * the `phone_path` `me` answers, resolved against the app's `base` (`/`, or
12
+ * `/plugins/<name>/` on a path-based installation, as
13
+ * `TeraClientOptions.base`), with `next` set to `location.href`, fragment
14
+ * included. It answers a root-absolute path on the page's own origin. A
15
+ * `phone_path` that is not a path relative to the base is refused rather
16
+ * than followed.
17
+ */
18
+ export function phoneURL(me, location, base = ROOT_BASE) {
19
+ const path = me.phone_path;
20
+ if (!isBaseRelative(path)) {
21
+ throw new TypeError(`phoneURL: phone_path is not a path relative to the app's base: ${JSON.stringify(path)}`);
22
+ }
23
+ return underBase(base, path) + "?" + new URLSearchParams({ [NEXT_PARAM]: location.href }).toString();
24
+ }
package/dist/routes.d.ts CHANGED
@@ -23,3 +23,10 @@ export declare function appBase(base?: string): string;
23
23
  * (`_tera/signout`, the kit's `signout_path`) alike.
24
24
  */
25
25
  export declare function underBase(base: string, path: string): string;
26
+ /**
27
+ * isBaseRelative reports whether a path the kit answers (`signout_path`,
28
+ * `phone_path`) is relative to the app's base: not empty, not root-absolute,
29
+ * naming no scheme and holding no backslash, so that {@link underBase} keeps
30
+ * it on the page's own origin under its base.
31
+ */
32
+ export declare function isBaseRelative(path: unknown): path is string;
package/dist/routes.js CHANGED
@@ -58,3 +58,12 @@ export function appBase(base = ROOT_BASE) {
58
58
  export function underBase(base, path) {
59
59
  return appBase(base) + (path.startsWith("/") ? path.slice(1) : path);
60
60
  }
61
+ /**
62
+ * isBaseRelative reports whether a path the kit answers (`signout_path`,
63
+ * `phone_path`) is relative to the app's base: not empty, not root-absolute,
64
+ * naming no scheme and holding no backslash, so that {@link underBase} keeps
65
+ * it on the page's own origin under its base.
66
+ */
67
+ export function isBaseRelative(path) {
68
+ return typeof path === "string" && path !== "" && !path.startsWith("/") && !/^[^/?#]*:/.test(path) && !path.includes("\\");
69
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Signing in again after tera forgets the page's session.
3
+ *
4
+ * tera's UI proxy holds a plugin origin's sessions in memory, so a daemon
5
+ * restart forgets every one. Only a page load can sign the person in again —
6
+ * silently, while their tera session lives — so the proxy answers anything
7
+ * else that arrives with no live session, a script's `fetch` among them, 401
8
+ * in the kit's envelope with code {@link SIGN_IN_REQUIRED}. The page recovers
9
+ * by loading itself again ({@link reSignIn}).
10
+ *
11
+ * That cannot loop: a page load never receives this answer, it starts the
12
+ * sign-in. A person whose tera session is gone too lands on tera's sign-in
13
+ * page, which is where they have to be.
14
+ *
15
+ * It is not the kit's `unauthenticated`, which says the request did reach the
16
+ * plugin, with no verified caller; loading the page again does not fix that.
17
+ *
18
+ * {@link TeraClient} and {@link FeedConnection} react on their own. A page
19
+ * reading one of the plugin's routes with its own `fetch` applies
20
+ * {@link isSignInRequired} to the response:
21
+ *
22
+ * const res = await fetch("api/boards");
23
+ * if (await isSignInRequired(res)) return reSignIn();
24
+ */
25
+ /** The code of the proxy's answer to a request that needs the page loaded again to sign in. */
26
+ export declare const SIGN_IN_REQUIRED = "sign_in_required";
27
+ export interface ReSignInOptions {
28
+ /** Sends the page to a URL; `location.assign` when absent. */
29
+ navigate?: (url: string) => void;
30
+ /** The page's own URL, which is loaded again; `location.href` when absent. */
31
+ pageURL?: () => string;
32
+ }
33
+ /**
34
+ * Whether an answer is the proxy's `sign_in_required`: HTTP 401, a JSON body,
35
+ * and the kit's envelope carrying that code. Any other 401 — the kit's own
36
+ * `unauthenticated`, another code, a body that is not JSON — is not.
37
+ */
38
+ export declare function isSignInRequiredAnswer(status: number, contentType: string, body: string): boolean;
39
+ /**
40
+ * Whether `res` is the proxy's `sign_in_required` answer. It reads a copy of
41
+ * the body, so `res` can still be read by the caller.
42
+ */
43
+ export declare function isSignInRequired(res: Response): Promise<boolean>;
44
+ /**
45
+ * Loads the page again, a top-level navigation to its own URL, which signs
46
+ * the person in to the plugin's origin and returns them where they were.
47
+ */
48
+ export declare function reSignIn(options?: ReSignInOptions): void;
package/dist/signin.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Signing in again after tera forgets the page's session.
3
+ *
4
+ * tera's UI proxy holds a plugin origin's sessions in memory, so a daemon
5
+ * restart forgets every one. Only a page load can sign the person in again —
6
+ * silently, while their tera session lives — so the proxy answers anything
7
+ * else that arrives with no live session, a script's `fetch` among them, 401
8
+ * in the kit's envelope with code {@link SIGN_IN_REQUIRED}. The page recovers
9
+ * by loading itself again ({@link reSignIn}).
10
+ *
11
+ * That cannot loop: a page load never receives this answer, it starts the
12
+ * sign-in. A person whose tera session is gone too lands on tera's sign-in
13
+ * page, which is where they have to be.
14
+ *
15
+ * It is not the kit's `unauthenticated`, which says the request did reach the
16
+ * plugin, with no verified caller; loading the page again does not fix that.
17
+ *
18
+ * {@link TeraClient} and {@link FeedConnection} react on their own. A page
19
+ * reading one of the plugin's routes with its own `fetch` applies
20
+ * {@link isSignInRequired} to the response:
21
+ *
22
+ * const res = await fetch("api/boards");
23
+ * if (await isSignInRequired(res)) return reSignIn();
24
+ */
25
+ /** The code of the proxy's answer to a request that needs the page loaded again to sign in. */
26
+ export const SIGN_IN_REQUIRED = "sign_in_required";
27
+ const JSON_TYPE = "application/json";
28
+ /**
29
+ * Whether an answer is the proxy's `sign_in_required`: HTTP 401, a JSON body,
30
+ * and the kit's envelope carrying that code. Any other 401 — the kit's own
31
+ * `unauthenticated`, another code, a body that is not JSON — is not.
32
+ */
33
+ export function isSignInRequiredAnswer(status, contentType, body) {
34
+ if (status !== 401)
35
+ return false;
36
+ if (contentType.toLowerCase().split(";")[0].trim() !== JSON_TYPE)
37
+ return false;
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(body);
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ if (typeof parsed !== "object" || parsed === null)
46
+ return false;
47
+ const error = parsed.error;
48
+ return typeof error === "object" && error !== null && error.code === SIGN_IN_REQUIRED;
49
+ }
50
+ /**
51
+ * Whether `res` is the proxy's `sign_in_required` answer. It reads a copy of
52
+ * the body, so `res` can still be read by the caller.
53
+ */
54
+ export async function isSignInRequired(res) {
55
+ if (res.status !== 401)
56
+ return false;
57
+ let text;
58
+ try {
59
+ text = await res.clone().text();
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ return isSignInRequiredAnswer(res.status, res.headers.get("content-type") ?? "", text);
65
+ }
66
+ /**
67
+ * Loads the page again, a top-level navigation to its own URL, which signs
68
+ * the person in to the plugin's origin and returns them where they were.
69
+ */
70
+ export function reSignIn(options = {}) {
71
+ const navigate = options.navigate ?? ((url) => globalThis.location.assign(url));
72
+ const pageURL = options.pageURL ?? (() => globalThis.location.href);
73
+ navigate(pageURL());
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kottera/tera-web",
3
- "version": "0.1.0-rc.4",
3
+ "version": "0.1.0-rc.6",
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": {