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

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
@@ -11,11 +11,15 @@ author's — and has no runtime dependencies. It is not yet published.
11
11
  |------|---------------|
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
- | `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. |
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), `stream(options)` (a `StreamConnection` probed with the client's fetch), `terminal(options)` (a `TerminalView` opened the same way), `upload(file, options)` with progress, and `signOut()`. A kit refusal throws `KitRequestError`; a verb's refusal answers as its verdict. |
15
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`, `FeedConnection` and `StreamConnection` do it on their own, once; a page reading its own routes with its own `fetch` applies `isSignInRequired`. |
17
+ | `src/unreached.ts` | The codes tera's UI proxy answers the page's script with in the kit's envelope when the plugin did not answer: `PLUGIN_BUSY` and `PLUGIN_STARTING` (503, with `Retry-After`), `PLUGIN_RUNNER_UNREACHABLE` and `PLUGIN_NOT_ANSWERING` (502), all retryable, listed as `PLUGIN_UNREACHED_CODES`. |
16
18
  | `src/stepup.ts` | A person's step-up from the page's side (ADR-173 D11): the step-up header, the `return_to` the navigation fallback carries, and `StepUpError`. Every `TeraClient` request — `me()`, `invoke()`, `upload()` and `fetch(path)` to the plugin's own routes — has the person confirm a `step_up_required` and sends the request once more; `stepUp(id)` reads `GET /tera/v1/step-ups/{id}`. |
17
- | `src/generated/` | The contract, generated from the committed schemas under `sdk/plugin/webui/schema/`, `sdk/workorder/schema/`, `sdk/feed/schema/` and `sdk/shares/schema/` (D4 item 1; the share types, ADR-174 D14): one type per schema, the kit's route table as `KIT_ROUTE_TABLE`, and the maps from a route or a feed event to the type it carries. Never hand-edited, and never hand-write a type it declares: `make web-types` regenerates it, and `TestWebTypes_MatchCommitted` (`cmd/internal/genwebtypes`) fails on a hand edit or a stale generation. |
18
- | `src/feed/` | The stream connection (D4 item 2). |
19
+ | `src/generated/` | The contract, generated from the committed schemas under `sdk/plugin/webui/schema/`, `sdk/workorder/schema/`, `sdk/feed/schema/`, `sdk/shares/schema/` and `sdk/wsstream/schema/` (D4 item 1; the share types, ADR-174 D14; the close codes, ADR-181 D15): one type per schema, the kit's route table as `KIT_ROUTE_TABLE`, a closed set of named constants as a map by name (`STREAM_CLOSE_CODES`), 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. |
20
+ | `src/feed/` | The feed connection (D4 item 2). |
21
+ | `src/stream/` | `StreamConnection`, one WebSocket to a kit stream route (ADR-181 D9, D11 item 1), reopened by the close code the far side chose: after backoff on `going_away`, `retry_later` and any code it does not know, at once on `reconnect` (three in 30 seconds, then backoff), never on `refused` or `too_big`, and on a normal closure as the protocol above said with `onNormalClose`. A failed handshake is settled by one probe of `GET /tera/v1/me`; more than 1 MiB unsent is a defect, closed and reconnected; `reconnect(reason)` gives up on a socket the protocol above finds dead; `close()` is the page's own end. |
22
+ | `src/terminal/` | `TerminalView`, one view of an attended session's terminal (ADR-181 D11 item 2): `terminalwire` over a `StreamConnection` to the kit's terminal route, holding ADR-180's client duties itself — the resume offset in bytes handed to the renderer, an `ACK` only for bytes the renderer's `parsed` callback reported, at most 64 KiB of `INPUT` beyond `INPUT_ACK` (a paste queued in the view), a one-byte keystroke the window cannot hold sent as `SIGNAL` (P-R2), input dropped while history is drawn, `CONTROL {take}` before a viewer's first keystroke, `RESIZE` only while the view sets the size, and the step after each `CLOSE` reason. The renderer is named by its shape (`TerminalRenderer`, which xterm.js's `Terminal` meets), so xterm.js is the page's dependency, not the package's. Its tests drive it with a scripted socket and a recording renderer; no real browser runs here. |
19
23
  | `src/**/*.test.ts` | The suite, beside what it tests. |
20
24
 
21
25
  ## Tests
package/dist/client.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { FeedConnection, type FeedConnectionOptions } from "./feed/connection.ts";
2
+ import { StreamConnection, type StreamConnectionOptions } from "./stream/connection.ts";
3
+ import { TerminalView, type TerminalViewOptions } from "./terminal/view.ts";
2
4
  import type { KitError, KitInvokeResponse, KitMeResponse, KitStepUpResponse, KitUploadResponse } from "./generated/kit.ts";
3
5
  import { type StepUpChallenge, type StepUpOptions } from "./stepup.ts";
4
6
  /** The body of a kit failure: the envelope's `error` member. */
@@ -107,6 +109,10 @@ export interface TeraClientOptions {
107
109
  }
108
110
  /** The options of {@link TeraClient.feed}: a {@link FeedConnection}'s, with the opener the client's own. */
109
111
  export type FeedOptions = Omit<FeedConnectionOptions, "open" | "base">;
112
+ /** The options of {@link TeraClient.stream}: a {@link StreamConnection}'s, with the probe's fetch the client's own. */
113
+ export type StreamOptions = Omit<StreamConnectionOptions, "fetch" | "base" | "reSignIn">;
114
+ /** What {@link TeraClient.terminal} takes: a view's options but what the client supplies. */
115
+ export type TerminalOptions = Omit<TerminalViewOptions, "fetch" | "base" | "reSignIn">;
110
116
  export declare class TeraClient {
111
117
  private readonly base;
112
118
  private readonly fetchFn;
@@ -118,6 +124,8 @@ export declare class TeraClient {
118
124
  private readonly pollMs;
119
125
  /** Whether the step-up the page was loaded back with has been taken up. */
120
126
  private resumeTaken;
127
+ /** Whether this client has loaded the page again to sign in. */
128
+ private signingIn;
121
129
  constructor(options?: TeraClientOptions);
122
130
  /** `GET /tera/v1/me`: who is signed in, and where to send them to sign out. */
123
131
  me(): Promise<KitMeResponse>;
@@ -138,6 +146,18 @@ export declare class TeraClient {
138
146
  * the page no longer wants it.
139
147
  */
140
148
  feed(options: FeedOptions): FeedConnection;
149
+ /**
150
+ * A {@link StreamConnection} to one of the kit's stream routes, its failed
151
+ * handshakes probed with this client's fetch. It opens at once: call
152
+ * `close()` on it when the page no longer wants it.
153
+ */
154
+ stream(options: StreamOptions): StreamConnection;
155
+ /**
156
+ * A {@link TerminalView} of an attended session's terminal at the kit's
157
+ * terminal route, its failed handshakes probed with this client's fetch. It
158
+ * opens at once: call `detach()` on it when the pane closes.
159
+ */
160
+ terminal(options: TerminalOptions): TerminalView;
141
161
  /**
142
162
  * `POST /tera/v1/uploads`: sends one file as the signed-in person and
143
163
  * answers the staged artifact, whose `handle` a Work Order's attachments
@@ -159,9 +179,20 @@ export declare class TeraClient {
159
179
  * person and the request sent once more with `Tera-Step-Up`; any other
160
180
  * answer, a success or a refusal, is returned as it arrived. A body sent
161
181
  * again must be one that can be: a string, a `Blob`, `FormData` or a
162
- * buffer, never a stream.
182
+ * buffer, never a stream. A `sign_in_required` answer loads the page again
183
+ * ({@link TeraClient.reSignIn}) and is returned as it arrived.
163
184
  */
164
185
  fetch(path: string, init?: RequestInit): Promise<Response>;
186
+ /**
187
+ * Loads the page again to sign the person in to the plugin's origin: a
188
+ * top-level navigation to the page's own URL, which tera's UI proxy answers
189
+ * with its silent sign-in. The client does it itself on a
190
+ * `sign_in_required` answer; a page calls it for one it read on its own.
191
+ * It navigates once per client: a second call does nothing.
192
+ */
193
+ reSignIn(): void;
194
+ /** Loads the page again when `request` rejects as `sign_in_required`, and rejects as it did. */
195
+ private signInOnRefusal;
165
196
  /**
166
197
  * Has the person confirm the step-up `challenge` names, and answers its id
167
198
  * once it is satisfied, to send the act again with. It opens the step-up's
package/dist/client.js CHANGED
@@ -43,6 +43,15 @@
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 or stream 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.
@@ -50,6 +59,9 @@
50
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 { StreamConnection } from "./stream/connection.js";
63
+ import { TerminalView } from "./terminal/view.js";
64
+ import { SIGN_IN_REQUIRED, isSignInRequired } from "./signin.js";
53
65
  import { STEP_UP_HEADER, STEP_UP_POLL_MS, StepUpError, defaultOpenWindow, defaultSleep, resumedStepUp, stepUpChallengeOf, stepUpPageURL, } from "./stepup.js";
54
66
  /**
55
67
  * The `code` of a {@link KitRequestError} whose answer was not the kit's
@@ -102,6 +114,8 @@ export class TeraClient {
102
114
  pollMs;
103
115
  /** Whether the step-up the page was loaded back with has been taken up. */
104
116
  resumeTaken = false;
117
+ /** Whether this client has loaded the page again to sign in. */
118
+ signingIn = false;
105
119
  constructor(options = {}) {
106
120
  this.base = appBase(options.base);
107
121
  this.fetchFn = options.fetch ?? ((...args) => globalThis.fetch(...args));
@@ -157,7 +171,23 @@ export class TeraClient {
157
171
  * the page no longer wants it.
158
172
  */
159
173
  feed(options) {
160
- return new FeedConnection({ ...options, base: this.base, open: fetchOpener(this.fetchFn) });
174
+ return new FeedConnection({ ...options, base: this.base, open: fetchOpener(this.fetchFn), reSignIn: () => this.reSignIn() });
175
+ }
176
+ /**
177
+ * A {@link StreamConnection} to one of the kit's stream routes, its failed
178
+ * handshakes probed with this client's fetch. It opens at once: call
179
+ * `close()` on it when the page no longer wants it.
180
+ */
181
+ stream(options) {
182
+ return new StreamConnection({ ...options, base: this.base, fetch: this.fetchFn, reSignIn: () => this.reSignIn() });
183
+ }
184
+ /**
185
+ * A {@link TerminalView} of an attended session's terminal at the kit's
186
+ * terminal route, its failed handshakes probed with this client's fetch. It
187
+ * opens at once: call `detach()` on it when the pane closes.
188
+ */
189
+ terminal(options) {
190
+ return new TerminalView({ ...options, base: this.base, fetch: this.fetchFn, reSignIn: () => this.reSignIn() });
161
191
  }
162
192
  /**
163
193
  * `POST /tera/v1/uploads`: sends one file as the signed-in person and
@@ -167,13 +197,13 @@ export class TeraClient {
167
197
  */
168
198
  async upload(file, options = {}) {
169
199
  try {
170
- return await this.uploadOnce(file, options, null);
200
+ return await this.signInOnRefusal(this.uploadOnce(file, options, null));
171
201
  }
172
202
  catch (err) {
173
203
  const challenge = err instanceof KitRequestError ? stepUpChallengeOf(err.code, err.envelope?.step_up) : null;
174
204
  if (challenge === null)
175
205
  throw err;
176
- return this.uploadOnce(file, options, await this.confirmStepUp(challenge));
206
+ return this.signInOnRefusal(this.uploadOnce(file, options, await this.confirmStepUp(challenge)));
177
207
  }
178
208
  }
179
209
  uploadOnce(file, options, stepUpId) {
@@ -246,15 +276,47 @@ export class TeraClient {
246
276
  * person and the request sent once more with `Tera-Step-Up`; any other
247
277
  * answer, a success or a refusal, is returned as it arrived. A body sent
248
278
  * again must be one that can be: a string, a `Blob`, `FormData` or a
249
- * buffer, never a stream.
279
+ * buffer, never a stream. A `sign_in_required` answer loads the page again
280
+ * ({@link TeraClient.reSignIn}) and is returned as it arrived.
250
281
  */
251
282
  async fetch(path, init = {}) {
252
283
  const url = underBase(this.base, path);
253
284
  const first = await this.fetchSameOrigin(url, init, null);
285
+ if (await isSignInRequired(first)) {
286
+ this.reSignIn();
287
+ return first;
288
+ }
254
289
  const challenge = await stepUpRefusalOf(first);
255
290
  if (challenge === null)
256
291
  return first;
257
- return this.fetchSameOrigin(url, init, await this.confirmStepUp(challenge));
292
+ const second = await this.fetchSameOrigin(url, init, await this.confirmStepUp(challenge));
293
+ if (await isSignInRequired(second))
294
+ this.reSignIn();
295
+ return second;
296
+ }
297
+ /**
298
+ * Loads the page again to sign the person in to the plugin's origin: a
299
+ * top-level navigation to the page's own URL, which tera's UI proxy answers
300
+ * with its silent sign-in. The client does it itself on a
301
+ * `sign_in_required` answer; a page calls it for one it read on its own.
302
+ * It navigates once per client: a second call does nothing.
303
+ */
304
+ reSignIn() {
305
+ if (this.signingIn)
306
+ return;
307
+ this.signingIn = true;
308
+ this.navigate(this.pageURL());
309
+ }
310
+ /** Loads the page again when `request` rejects as `sign_in_required`, and rejects as it did. */
311
+ async signInOnRefusal(request) {
312
+ try {
313
+ return await request;
314
+ }
315
+ catch (err) {
316
+ if (err instanceof KitRequestError && err.status === 401 && err.code === SIGN_IN_REQUIRED)
317
+ this.reSignIn();
318
+ throw err;
319
+ }
258
320
  }
259
321
  /**
260
322
  * Has the person confirm the step-up `challenge` names, and answers its id
@@ -346,13 +408,13 @@ export class TeraClient {
346
408
  }
347
409
  async json(url, init, stepUp = true) {
348
410
  try {
349
- return await this.jsonOnce(url, init, null);
411
+ return await this.signInOnRefusal(this.jsonOnce(url, init, null));
350
412
  }
351
413
  catch (err) {
352
414
  const challenge = stepUp && err instanceof KitRequestError ? stepUpChallengeOf(err.code, err.envelope?.step_up) : null;
353
415
  if (challenge === null)
354
416
  throw err;
355
- return this.jsonOnce(url, init, await this.confirmStepUp(challenge));
417
+ return this.signInOnRefusal(this.jsonOnce(url, init, await this.confirmStepUp(challenge)));
356
418
  }
357
419
  }
358
420
  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; and the codes the proxy answers such a request with when it did not reach the plugin: plugin_busy (503, the plugin is at its runner link's stream cap, with Retry-After), plugin_starting (503, tera has not yet settled where the plugin runs, with Retry-After), plugin_runner_unreachable (502, the plugin's runner has no live link) and plugin_not_answering (502, the plugin's UI did not answer on its socket). Only tera_unavailable and those four are 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" | "plugin_busy" | "plugin_starting" | "plugin_runner_unreachable" | "plugin_not_answering";
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
  };
@@ -282,6 +283,73 @@ export declare const KIT_ROUTE_TABLE: {
282
283
  readonly schema: "https://kottera.dev/schemas/plugin/webui/step-up.response/v1";
283
284
  readonly description: "The step-up's state.";
284
285
  };
286
+ }, {
287
+ readonly name: "terminal";
288
+ readonly method: "GET";
289
+ readonly path: "/tera/v1/workorders/{id}/terminal";
290
+ readonly summary: "Open one view of an attended session's terminal as the person (ADR-181 D8): a WebSocket the kit splices to tera's terminal door, opened with the person's token. Which Work Order it is, and whether the person may see it, is tera's to decide; the kit keeps no list of sessions. Every refusal after the upgrade is a close: a query of the wrong shape is refused invalid_request; tera's 401 is reconnect, reason reauthenticate, and a second within the minute refused; its 400, 403 and 404 are refused with tera's code (not_attendee, attend_view_not_granted, not_found); its 410 is refused, reason ended, a session with no process and none coming without the person's act; its 409 (not_running, a session whose process is coming), 408, 429 and 5xx, and no answer, are retry_later with tera's code or tera_unavailable. A text message is refused binary_only.";
291
+ readonly same_origin: true;
292
+ readonly path_params: readonly [{
293
+ readonly name: "id";
294
+ readonly description: "The attended Work Order's id.";
295
+ readonly required: true;
296
+ }];
297
+ readonly query: readonly [{
298
+ readonly name: "view";
299
+ readonly description: "The view's id: a canonical, lower-case UUIDv7 the page mints per pane and keeps across reconnects.";
300
+ readonly required: true;
301
+ }, {
302
+ readonly name: "want";
303
+ readonly description: "Whether the view asks to type (control) or only to watch (view).";
304
+ readonly required: true;
305
+ readonly values: readonly ["control", "view"];
306
+ }, {
307
+ readonly name: "resume";
308
+ readonly description: "The output offset the page has handed to its renderer so far, a decimal integer from 0 to 9007199254740991; absent on a fresh view, which replays.";
309
+ }, {
310
+ readonly name: "cols";
311
+ readonly description: "The view's width in cells, a decimal integer from 1 to 65535.";
312
+ readonly required: true;
313
+ }, {
314
+ readonly name: "rows";
315
+ readonly description: "The view's height in cells, a decimal integer from 1 to 65535.";
316
+ readonly required: true;
317
+ }];
318
+ readonly response: {
319
+ readonly media_type: "application/octet-stream";
320
+ readonly description: "A WebSocket upgrade (101), then messages both ways until either side closes, with the close vocabulary.";
321
+ };
322
+ readonly upgrade: "websocket";
323
+ readonly subprotocol: "tera.terminalwire.v1";
324
+ readonly messages: {
325
+ readonly media_type: "application/octet-stream";
326
+ readonly description: "One sdk/terminalwire frame per binary message, each way, at most 16388 bytes. The kit relays each as it arrived, reading no payload and redacting nothing.";
327
+ };
328
+ }];
329
+ readonly close_codes: readonly [{
330
+ readonly code: 1000;
331
+ readonly name: "ended";
332
+ readonly does: "what the protocol above said last; otherwise nothing";
333
+ }, {
334
+ readonly code: 1001;
335
+ readonly name: "going_away";
336
+ readonly does: "reconnects after backoff: the peer is stopping or stopped answering pings";
337
+ }, {
338
+ readonly code: 1009;
339
+ readonly name: "too_big";
340
+ readonly does: "nothing: a message broke the route's bound, a client defect";
341
+ }, {
342
+ readonly code: 4000;
343
+ readonly name: "reconnect";
344
+ readonly does: "reconnects at once with the same identity: a token renewal";
345
+ }, {
346
+ readonly code: 4001;
347
+ readonly name: "retry_later";
348
+ readonly does: "reconnects after backoff: the far side is unreachable or not ready";
349
+ }, {
350
+ readonly code: 4003;
351
+ readonly name: "refused";
352
+ readonly does: "nothing until something changes; the reason is the error code";
285
353
  }];
286
354
  };
287
355
  /** The name of a route in KIT_ROUTE_TABLE. */
@@ -167,5 +167,91 @@ export const KIT_ROUTE_TABLE = {
167
167
  description: "The step-up's state.",
168
168
  },
169
169
  },
170
+ {
171
+ name: "terminal",
172
+ method: "GET",
173
+ path: "/tera/v1/workorders/{id}/terminal",
174
+ summary: "Open one view of an attended session's terminal as the person (ADR-181 D8): a WebSocket the kit splices to tera's terminal door, opened with the person's token. Which Work Order it is, and whether the person may see it, is tera's to decide; the kit keeps no list of sessions. Every refusal after the upgrade is a close: a query of the wrong shape is refused invalid_request; tera's 401 is reconnect, reason reauthenticate, and a second within the minute refused; its 400, 403 and 404 are refused with tera's code (not_attendee, attend_view_not_granted, not_found); its 410 is refused, reason ended, a session with no process and none coming without the person's act; its 409 (not_running, a session whose process is coming), 408, 429 and 5xx, and no answer, are retry_later with tera's code or tera_unavailable. A text message is refused binary_only.",
175
+ same_origin: true,
176
+ path_params: [
177
+ {
178
+ name: "id",
179
+ description: "The attended Work Order's id.",
180
+ required: true,
181
+ },
182
+ ],
183
+ query: [
184
+ {
185
+ name: "view",
186
+ description: "The view's id: a canonical, lower-case UUIDv7 the page mints per pane and keeps across reconnects.",
187
+ required: true,
188
+ },
189
+ {
190
+ name: "want",
191
+ description: "Whether the view asks to type (control) or only to watch (view).",
192
+ required: true,
193
+ values: [
194
+ "control",
195
+ "view",
196
+ ],
197
+ },
198
+ {
199
+ name: "resume",
200
+ description: "The output offset the page has handed to its renderer so far, a decimal integer from 0 to 9007199254740991; absent on a fresh view, which replays.",
201
+ },
202
+ {
203
+ name: "cols",
204
+ description: "The view's width in cells, a decimal integer from 1 to 65535.",
205
+ required: true,
206
+ },
207
+ {
208
+ name: "rows",
209
+ description: "The view's height in cells, a decimal integer from 1 to 65535.",
210
+ required: true,
211
+ },
212
+ ],
213
+ response: {
214
+ media_type: "application/octet-stream",
215
+ description: "A WebSocket upgrade (101), then messages both ways until either side closes, with the close vocabulary.",
216
+ },
217
+ upgrade: "websocket",
218
+ subprotocol: "tera.terminalwire.v1",
219
+ messages: {
220
+ media_type: "application/octet-stream",
221
+ description: "One sdk/terminalwire frame per binary message, each way, at most 16388 bytes. The kit relays each as it arrived, reading no payload and redacting nothing.",
222
+ },
223
+ },
224
+ ],
225
+ close_codes: [
226
+ {
227
+ code: 1000,
228
+ name: "ended",
229
+ does: "what the protocol above said last; otherwise nothing",
230
+ },
231
+ {
232
+ code: 1001,
233
+ name: "going_away",
234
+ does: "reconnects after backoff: the peer is stopping or stopped answering pings",
235
+ },
236
+ {
237
+ code: 1009,
238
+ name: "too_big",
239
+ does: "nothing: a message broke the route's bound, a client defect",
240
+ },
241
+ {
242
+ code: 4000,
243
+ name: "reconnect",
244
+ does: "reconnects at once with the same identity: a token renewal",
245
+ },
246
+ {
247
+ code: 4001,
248
+ name: "retry_later",
249
+ does: "reconnects after backoff: the far side is unreachable or not ready",
250
+ },
251
+ {
252
+ code: 4003,
253
+ name: "refused",
254
+ does: "nothing until something changes; the reason is the error code",
255
+ },
170
256
  ],
171
257
  };
@@ -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.
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Schema: sdk/workorder/schema/events/v1/event-kind.workorder.json
7
7
  */
8
- export type WorkOrderEventKind = "cohort.set" | "off_tera.wake" | "relation.set" | "relation.removed" | "off_tera.taken" | "off_tera.progress" | "off_tera.reposted" | "off_tera.late_report" | "off_tera.checks_started" | "created" | "transitioned" | "acknowledged" | "retention_expired" | "edited" | "seat.reclaimed" | "claim.overdue" | "claim.unreadable" | "subject_claim.overridden" | "seat.cooling" | "seat.credential_held" | "member_credential.requested" | "member_credential.settled" | "seat.acquired" | "seat.runner_released" | "seat.meters_voided" | "step.binding" | "session.terminated" | "postcondition.verdict" | "parked_run.unreadable" | "parked_run.reclaimed" | "dispatch.settled_unobserved" | "parked_run.parked" | "parked_run.resumed" | "resume.refused" | "dispatch.salvaged" | "workspace.retired" | "dispatch.routed" | "session.lost" | "activity_partition.pinned" | "session.continuity" | "session.context_compacted" | "upstream.answer_forwarded" | "invocation.checkpoint" | "invocation.guidance" | "invocation.guidance.ack" | "invocation.reply" | "invocation.checkpoint.unreadable" | "invocation.guidance.unreadable" | "config_pin.unreadable" | "config_pin.repinned" | "type.followed" | "observation.scrubbed" | "observation.secret-shape-redacted" | "observation.promoted" | "observation.refuted" | "observation.purged" | "conversation.input_barrier" | "walk_resume.fell_back" | "store.ahead_of_binary" | "artifact.published" | "history.dropped" | "history.archived" | "seat.work_order_released" | "step.settled_late" | "step.reattached" | "relay.unrebuilt" | "references.recorded";
8
+ export type WorkOrderEventKind = "cohort.set" | "off_tera.wake" | "relation.set" | "relation.removed" | "off_tera.taken" | "off_tera.progress" | "off_tera.reposted" | "off_tera.late_report" | "off_tera.checks_started" | "created" | "transitioned" | "acknowledged" | "retention_expired" | "edited" | "seat.reclaimed" | "claim.overdue" | "claim.unreadable" | "subject_claim.overridden" | "seat.cooling" | "seat.credential_held" | "member_credential.requested" | "member_credential.settled" | "seat.acquired" | "seat.runner_released" | "seat.meters_voided" | "step.binding" | "session.terminated" | "postcondition.verdict" | "parked_run.unreadable" | "parked_run.reclaimed" | "dispatch.settled_unobserved" | "parked_run.parked" | "parked_run.resumed" | "resume.refused" | "dispatch.salvaged" | "workspace.retired" | "dispatch.routed" | "session.lost" | "activity_partition.pinned" | "session.continuity" | "session.context_compacted" | "upstream.answer_forwarded" | "invocation.checkpoint" | "invocation.guidance" | "invocation.guidance.ack" | "invocation.reply" | "invocation.checkpoint.unreadable" | "invocation.guidance.unreadable" | "config_pin.unreadable" | "config_pin.repinned" | "type.followed" | "observation.scrubbed" | "observation.secret-shape-redacted" | "observation.promoted" | "observation.refuted" | "observation.purged" | "conversation.input_barrier" | "walk_resume.fell_back" | "store.ahead_of_binary" | "artifact.published" | "history.dropped" | "history.archived" | "seat.work_order_released" | "step.settled_late" | "step.reattached" | "relay.unrebuilt" | "references.recorded" | "attended.wall_reported" | "attended.signal";
9
9
  /**
10
10
  * `workorder` event payload (event/workorder).
11
11
  *