@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 +15 -0
- package/dist/client.d.ts +14 -1
- package/dist/client.js +53 -9
- package/dist/feed/connection.d.ts +6 -0
- package/dist/feed/connection.js +6 -0
- package/dist/generated/kit.d.ts +9 -3
- package/dist/generated/kit.js +4 -1
- package/dist/generated/shares.d.ts +3 -1
- package/dist/generated/workorder.d.ts +35 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/phone.d.ts +11 -0
- package/dist/phone.js +24 -0
- package/dist/routes.d.ts +7 -0
- package/dist/routes.js +9 -0
- package/dist/signin.d.ts +48 -0
- package/dist/signin.js +74 -0
- package/package.json +1 -1
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
|
-
|
|
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 (
|
|
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;
|
package/dist/feed/connection.js
CHANGED
|
@@ -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));
|
package/dist/generated/kit.d.ts
CHANGED
|
@@ -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
|
|
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";
|
package/dist/generated/kit.js
CHANGED
|
@@ -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
|
|
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";
|
package/dist/phone.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/signin.d.ts
ADDED
|
@@ -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.
|
|
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": {
|