slopkit 0.2.0 → 0.4.0-rc.0
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/DEPS.md +5 -3
- package/README.md +103 -1
- package/RULES.md +26 -6
- package/dist/api.d.ts +93 -0
- package/dist/api.js +111 -0
- package/dist/client.d.ts +21 -4
- package/dist/client.js +21 -2
- package/dist/conformance/agent-access.test.d.ts +1 -0
- package/dist/conformance/agent-access.test.js +74 -0
- package/dist/conformance/client.test.js +7 -7
- package/dist/conformance/fixtures.d.ts +1 -0
- package/dist/conformance/fixtures.js +5 -1
- package/dist/conformance/outbox-bound.test.d.ts +1 -0
- package/dist/conformance/outbox-bound.test.js +268 -0
- package/dist/conformance/paging-state.test.js +1 -0
- package/dist/conformance/push-bytes.test.d.ts +1 -0
- package/dist/conformance/push-bytes.test.js +174 -0
- package/dist/conformance/push-refusal.test.d.ts +1 -0
- package/dist/conformance/push-refusal.test.js +644 -0
- package/dist/conformance/states.test.js +19 -8
- package/dist/contract.d.ts +19 -6
- package/dist/contract.js +76 -9
- package/dist/server/auth.d.ts +4 -3
- package/dist/server/auth.js +14 -6
- package/dist/server/cli.d.ts +117 -0
- package/dist/server/cli.js +1534 -0
- package/dist/server/config.d.ts +4 -1
- package/dist/server/config.js +2 -2
- package/dist/server/keys.d.ts +17 -0
- package/dist/server/keys.js +127 -0
- package/dist/server/pull.js +158 -67
- package/dist/server/store.d.ts +101 -0
- package/dist/server/store.js +347 -0
- package/dist/server/sync.d.ts +158 -0
- package/dist/server/sync.js +293 -0
- package/dist/server/web.d.ts +62 -9
- package/dist/server/web.js +260 -35
- package/dist/states.d.ts +52 -4
- package/dist/states.js +20 -0
- package/dist/sync.d.ts +86 -2
- package/dist/sync.js +277 -59
- package/dist/test/agents.d.ts +19 -0
- package/dist/test/agents.js +62 -0
- package/dist/test/page.d.ts +91 -0
- package/dist/test/page.js +763 -0
- package/dist/test/states.d.ts +44 -7
- package/dist/test/states.js +152 -0
- package/dist/web/replica.d.ts +7 -0
- package/dist/web/replica.js +260 -47
- package/dist/web/runtime.d.ts +102 -0
- package/dist/web/runtime.js +335 -0
- package/package.json +39 -1
package/dist/server/web.js
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
* (an access proxy's JWT or the server's own OIDC login), the Host must be ours, and every POST and the socket must come
|
|
5
5
|
* from the page's own Origin. Responses carry a strict CSP and no framing.
|
|
6
6
|
*
|
|
7
|
+
* An agent's key (`Authorization: Bearer`) decides a request alone and needs no Origin: the Origin check guards
|
|
8
|
+
* credentials a browser sends on its own (cookies), and a bearer isn't one.
|
|
9
|
+
*
|
|
7
10
|
* Routes are contract endpoints: the request is checked against its schema before the handler runs and the reply
|
|
8
|
-
* after, so neither side can drift from the contract. Failures answer `{ kind, message }` with the kind's status.
|
|
11
|
+
* after, so neither side can drift from the contract. Failures answer `{ kind, message, path? }` with the kind's status.
|
|
12
|
+
* Raw routes (`rawRoute`) take and answer bytes behind the same guards, with their own body limit.
|
|
9
13
|
* The socket is a hint channel: the app sends small messages (pokes, pings); nothing correct depends on it.
|
|
10
14
|
* Every API request is logged with an id, status, time and whether a person or a service asked.
|
|
11
15
|
*/
|
|
@@ -32,19 +36,32 @@ const SECURITY = {
|
|
|
32
36
|
"x-frame-options": "DENY",
|
|
33
37
|
"content-security-policy": "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; manifest-src 'self'; worker-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
|
34
38
|
};
|
|
35
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* A handler's refusal, answered as a typed failure: the kit's error body, so the page can tell it from a proxy's.
|
|
41
|
+
* `path` names where a request broke its schema (`request.commands[1].command.value`).
|
|
42
|
+
*/
|
|
36
43
|
export class Refusal extends Error {
|
|
37
44
|
kind;
|
|
38
|
-
|
|
45
|
+
path;
|
|
46
|
+
constructor(kind, message, path) {
|
|
39
47
|
super(message);
|
|
40
48
|
this.kind = kind;
|
|
49
|
+
this.path = path;
|
|
41
50
|
}
|
|
42
51
|
}
|
|
43
|
-
|
|
52
|
+
/** A body limit as given: a positive whole number of bytes. */
|
|
53
|
+
function bodyBytes(bytes) {
|
|
54
|
+
if (!Number.isSafeInteger(bytes) || bytes < 1) {
|
|
55
|
+
throw new Error(`a body limit must be a positive integer, got ${bytes}`);
|
|
56
|
+
}
|
|
57
|
+
return bytes;
|
|
58
|
+
}
|
|
59
|
+
export function route(endpoint, handle, options = {}) {
|
|
44
60
|
return {
|
|
45
61
|
method: endpoint.method,
|
|
46
62
|
path: endpoint.path,
|
|
47
63
|
person: endpoint.person,
|
|
64
|
+
...(options.bodyLimit === undefined ? {} : { bodyLimit: bodyBytes(options.bodyLimit) }),
|
|
48
65
|
run: async (input, caller) => {
|
|
49
66
|
let request;
|
|
50
67
|
try {
|
|
@@ -52,7 +69,7 @@ export function route(endpoint, handle) {
|
|
|
52
69
|
}
|
|
53
70
|
catch (error) {
|
|
54
71
|
if (error instanceof Invalid) {
|
|
55
|
-
throw new Refusal("invalid", error.message);
|
|
72
|
+
throw new Refusal("invalid", error.message, error.path);
|
|
56
73
|
}
|
|
57
74
|
throw error;
|
|
58
75
|
}
|
|
@@ -61,6 +78,53 @@ export function route(endpoint, handle) {
|
|
|
61
78
|
},
|
|
62
79
|
};
|
|
63
80
|
}
|
|
81
|
+
export function rawRoute(definition) {
|
|
82
|
+
if (!/^\/[^*]*(\/\*)?$/.test(definition.path)) {
|
|
83
|
+
throw new Error(`a raw route's path is exact or ends in /*: ${definition.path}`);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
raw: true,
|
|
87
|
+
method: definition.method,
|
|
88
|
+
path: definition.path,
|
|
89
|
+
person: definition.person ?? false,
|
|
90
|
+
bodyLimit: bodyBytes(definition.bodyLimit ?? 65_536),
|
|
91
|
+
handle: definition.handle,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Types worth gzipping; images never are, whatever their format. */
|
|
95
|
+
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|manifest\+json))/i;
|
|
96
|
+
/** CSP directives that only take away: a handler may add these to the server's policy. */
|
|
97
|
+
const TIGHTENING = new Set([
|
|
98
|
+
"sandbox",
|
|
99
|
+
"upgrade-insecure-requests",
|
|
100
|
+
"block-all-mixed-content",
|
|
101
|
+
"require-trusted-types-for",
|
|
102
|
+
"trusted-types",
|
|
103
|
+
]);
|
|
104
|
+
function directives(policy) {
|
|
105
|
+
return new Map(policy
|
|
106
|
+
.split(";")
|
|
107
|
+
.map((part) => part.trim().replaceAll(/\s+/g, " "))
|
|
108
|
+
.filter((part) => part !== "")
|
|
109
|
+
.map((part) => {
|
|
110
|
+
const space = part.indexOf(" ");
|
|
111
|
+
return space === -1
|
|
112
|
+
? [part.toLowerCase(), ""]
|
|
113
|
+
: [part.slice(0, space).toLowerCase(), part.slice(space + 1)];
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The server's policy with the tightening directives a handler's policy names added (`sandbox` for bytes someone else
|
|
118
|
+
* made). Everything else in the handler's policy is ignored: the server's directives are never replaced or dropped.
|
|
119
|
+
*/
|
|
120
|
+
function tightened(base, offered) {
|
|
121
|
+
if (offered === undefined) {
|
|
122
|
+
return base;
|
|
123
|
+
}
|
|
124
|
+
const own = directives(base);
|
|
125
|
+
const added = [...directives(offered)].flatMap(([name, value]) => TIGHTENING.has(name) && !own.has(name) ? [value === "" ? name : `${name} ${value}`] : []);
|
|
126
|
+
return [base, ...added].join("; ");
|
|
127
|
+
}
|
|
64
128
|
function header(request, name) {
|
|
65
129
|
const value = request.headers[name];
|
|
66
130
|
return Array.isArray(value) ? value[0] : value;
|
|
@@ -72,11 +136,36 @@ function sessionToken(request, cookie) {
|
|
|
72
136
|
.find((part) => part.startsWith(`${cookie}=`))
|
|
73
137
|
?.slice(cookie.length + 1);
|
|
74
138
|
}
|
|
139
|
+
/** How long a sign-in keeps the page it interrupted. */
|
|
140
|
+
const BACK_SECONDS = 600;
|
|
141
|
+
function decodeCookie(value) {
|
|
142
|
+
try {
|
|
143
|
+
return value === undefined ? undefined : decodeURIComponent(value);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* A path on this server to return to, or nothing: never another origin (`//host`, `/\\host`, a scheme), and only
|
|
151
|
+
* printable ASCII, which a Location header always takes (the redirect to the login percent-encodes the rest).
|
|
152
|
+
*/
|
|
153
|
+
function onThisServer(path) {
|
|
154
|
+
return path !== null &&
|
|
155
|
+
path !== undefined &&
|
|
156
|
+
path.length <= 2048 &&
|
|
157
|
+
/^\/(?![/\\])[\u0021-\u005B\u005D-\u007E]*$/.test(path)
|
|
158
|
+
? path
|
|
159
|
+
: undefined;
|
|
160
|
+
}
|
|
75
161
|
/** Send a body, gzipped when the client takes it and it is worth it. */
|
|
76
162
|
function send(request, response, status, body, headers = {}) {
|
|
77
163
|
let bytes = typeof body === "string" ? Buffer.from(body) : body;
|
|
78
164
|
const extra = {};
|
|
79
|
-
|
|
165
|
+
const type = headers["content-type"] ?? "text/plain";
|
|
166
|
+
if (bytes.length > 1024 &&
|
|
167
|
+
COMPRESSIBLE.test(type) &&
|
|
168
|
+
/\bgzip\b/.test(header(request, "accept-encoding") ?? "")) {
|
|
80
169
|
bytes = gzipSync(bytes, { level: 6 });
|
|
81
170
|
extra["content-encoding"] = "gzip";
|
|
82
171
|
extra.vary = "accept-encoding";
|
|
@@ -90,18 +179,39 @@ function send(request, response, status, body, headers = {}) {
|
|
|
90
179
|
});
|
|
91
180
|
response.end(request.method === "HEAD" ? undefined : bytes);
|
|
92
181
|
}
|
|
93
|
-
function failure(request, response, kind, message) {
|
|
94
|
-
send(request, response, STATUS[kind], JSON.stringify({ kind, message }), {
|
|
95
|
-
"content-type": "application/json",
|
|
96
|
-
});
|
|
182
|
+
function failure(request, response, kind, message, path) {
|
|
183
|
+
send(request, response, STATUS[kind], JSON.stringify({ kind, message, ...(path === undefined ? {} : { path }) }), { "content-type": "application/json" });
|
|
97
184
|
}
|
|
98
|
-
|
|
99
|
-
|
|
185
|
+
/** The body's bytes, counted against `limit` as they arrive: past it, refused before anything reads them. */
|
|
186
|
+
async function readBytes(request, limit) {
|
|
187
|
+
const declared = Number(header(request, "content-length"));
|
|
188
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
189
|
+
throw new Refusal("invalid", "the request is too large");
|
|
190
|
+
}
|
|
191
|
+
const chunks = [];
|
|
192
|
+
let size = 0;
|
|
100
193
|
for await (const chunk of request) {
|
|
101
|
-
|
|
102
|
-
|
|
194
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
195
|
+
size += bytes.length;
|
|
196
|
+
if (size > limit) {
|
|
103
197
|
throw new Refusal("invalid", "the request is too large");
|
|
104
198
|
}
|
|
199
|
+
chunks.push(bytes);
|
|
200
|
+
}
|
|
201
|
+
return Buffer.concat(chunks);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The body as JSON: its bytes collected and counted against `limit`, then decoded once, so a character split between
|
|
205
|
+
* chunks arrives whole. Bytes that aren't UTF-8 are refused, never replaced.
|
|
206
|
+
*/
|
|
207
|
+
async function readBody(request, limit) {
|
|
208
|
+
const bytes = await readBytes(request, limit);
|
|
209
|
+
let text;
|
|
210
|
+
try {
|
|
211
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
throw new Refusal("invalid", "the request is not UTF-8");
|
|
105
215
|
}
|
|
106
216
|
try {
|
|
107
217
|
return text ? JSON.parse(text) : {};
|
|
@@ -116,22 +226,124 @@ export async function serveWeb(options) {
|
|
|
116
226
|
const hosts = new Set();
|
|
117
227
|
const origins = new Set();
|
|
118
228
|
let plainHost;
|
|
119
|
-
const
|
|
229
|
+
const contract = options.routes.filter((each) => !("raw" in each));
|
|
230
|
+
const raws = options.routes.filter((each) => "raw" in each);
|
|
231
|
+
const routes = new Map(contract.map((each) => [`${each.method} ${each.path}`, each]));
|
|
120
232
|
const manifest = JSON.stringify(options.manifest);
|
|
233
|
+
// A raw route can't take over the login, the socket or the manifest, or shadow another route.
|
|
234
|
+
for (const each of raws) {
|
|
235
|
+
const prefix = each.path.endsWith("/*") ? each.path.slice(0, -1) : undefined;
|
|
236
|
+
const covers = (path) => prefix === undefined
|
|
237
|
+
? path === each.path
|
|
238
|
+
: path.startsWith(prefix) || path === prefix.slice(0, -1) || prefix === "/";
|
|
239
|
+
if (each.path.startsWith("/auth/") ||
|
|
240
|
+
covers("/auth/login") ||
|
|
241
|
+
covers("/manifest.webmanifest") ||
|
|
242
|
+
(options.socket !== undefined && covers(options.socket.path)) ||
|
|
243
|
+
contract.some((other) => covers(other.path))) {
|
|
244
|
+
throw new Error(`a raw route can't serve ${each.path}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (new Set(raws.map((each) => `${each.method} ${each.path}`)).size !== raws.length) {
|
|
248
|
+
throw new Error("two raw routes have the same method and path");
|
|
249
|
+
}
|
|
250
|
+
/** The raw routes for a path, most specific first, with what a `/*` matched. */
|
|
251
|
+
function rawFor(path) {
|
|
252
|
+
// Matched on the decoded path, so a prefix's remainder is decoded exactly once.
|
|
253
|
+
return raws
|
|
254
|
+
.flatMap((each) => {
|
|
255
|
+
if (each.path === path) {
|
|
256
|
+
return [{ found: each, params: undefined, rank: Number.MAX_SAFE_INTEGER }];
|
|
257
|
+
}
|
|
258
|
+
const prefix = each.path.endsWith("/*") ? each.path.slice(0, -1) : undefined;
|
|
259
|
+
return prefix !== undefined && path.startsWith(prefix)
|
|
260
|
+
? [{ found: each, params: path.slice(prefix.length), rank: prefix.length }]
|
|
261
|
+
: [];
|
|
262
|
+
})
|
|
263
|
+
.toSorted((a, b) => b.rank - a.rank);
|
|
264
|
+
}
|
|
121
265
|
/** The caller, or undefined when not signed in, or false when signed in but not allowed. */
|
|
122
266
|
async function identify(request) {
|
|
267
|
+
const authorization = header(request, "authorization");
|
|
268
|
+
// Any Authorization is a bearer's claim: one that isn't a well-formed bearer is refused, never ignored.
|
|
269
|
+
const bearer = authorization === undefined ? undefined : (/^Bearer (\S+)$/i.exec(authorization)?.[1] ?? "");
|
|
123
270
|
const jwt = header(request, "cf-access-jwt-assertion");
|
|
124
|
-
const identities = await auth.identify(jwt, sessionToken(request, cookie));
|
|
271
|
+
const identities = await auth.identify(jwt, sessionToken(request, cookie), bearer);
|
|
125
272
|
if (identities === undefined || identities === false) {
|
|
126
273
|
return identities;
|
|
127
274
|
}
|
|
128
275
|
return {
|
|
129
276
|
identities,
|
|
130
|
-
person: !identities.some((identity) => identity.startsWith("service:")),
|
|
131
|
-
login: jwt === undefined ? "oidc" : "access",
|
|
277
|
+
person: !identities.some((identity) => identity.startsWith("service:") || identity.startsWith("key:")),
|
|
278
|
+
login: bearer === undefined ? (jwt === undefined ? "oidc" : "access") : "key",
|
|
132
279
|
request: randomBytes(6).toString("base64url"),
|
|
133
280
|
};
|
|
134
281
|
}
|
|
282
|
+
/** Whether a request may act: a key always (it isn't sent on its own); a cookie or proxy only from the page. */
|
|
283
|
+
const fromPage = (request, caller) => caller.login === "key" || origins.has(header(request, "origin") ?? "");
|
|
284
|
+
/** Log one request's outcome, as every route does. */
|
|
285
|
+
function logged(caller, method, path, status, started) {
|
|
286
|
+
log(status < 400 ? "info" : "warn", "request", {
|
|
287
|
+
request: caller.request,
|
|
288
|
+
method,
|
|
289
|
+
path,
|
|
290
|
+
status,
|
|
291
|
+
ms: Math.round(performance.now() - started),
|
|
292
|
+
person: caller.person,
|
|
293
|
+
...(caller.login === "key" ? { caller: caller.identities.join(" ") } : {}),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
async function raw(request, response, url, caller, candidates) {
|
|
297
|
+
const started = performance.now();
|
|
298
|
+
const method = request.method === "HEAD" ? "GET" : (request.method ?? "GET");
|
|
299
|
+
const match = candidates.find((each) => each.found.method === method);
|
|
300
|
+
let status = 200;
|
|
301
|
+
try {
|
|
302
|
+
if (match === undefined) {
|
|
303
|
+
status = 405;
|
|
304
|
+
send(request, response, 405, "Method not allowed");
|
|
305
|
+
logged(caller, method, url.pathname, status, started);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const { found, params } = match;
|
|
309
|
+
if (method === "POST" && !fromPage(request, caller)) {
|
|
310
|
+
throw new Refusal("forbidden", "bad origin");
|
|
311
|
+
}
|
|
312
|
+
if (found.person && !caller.person) {
|
|
313
|
+
throw new Refusal("forbidden", "only a person's login can do this");
|
|
314
|
+
}
|
|
315
|
+
const body = method === "POST" ? await readBytes(request, found.bodyLimit) : undefined;
|
|
316
|
+
const reply = await found.handle({
|
|
317
|
+
url,
|
|
318
|
+
params: params === undefined ? {} : { "*": params },
|
|
319
|
+
body,
|
|
320
|
+
headers: request.headers,
|
|
321
|
+
}, caller);
|
|
322
|
+
status = reply.status;
|
|
323
|
+
const offered = Object.entries(reply.headers ?? {}).find(([name]) => name.toLowerCase() === "content-security-policy")?.[1];
|
|
324
|
+
const own = Object.fromEntries(Object.entries(reply.headers ?? {}).filter(([name]) => !(name.toLowerCase() in SECURITY) &&
|
|
325
|
+
!["content-type", "content-encoding", "cache-control", "vary"].includes(name.toLowerCase())));
|
|
326
|
+
send(request, response, reply.status, reply.body, {
|
|
327
|
+
...own,
|
|
328
|
+
"content-type": reply.type,
|
|
329
|
+
"cache-control": reply.cache === "immutable" ? "private, max-age=31536000, immutable" : "no-store",
|
|
330
|
+
"content-security-policy": tightened(SECURITY["content-security-policy"], offered),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
const kind = error instanceof Refusal ? error.kind : "unavailable";
|
|
335
|
+
status = STATUS[kind];
|
|
336
|
+
if (!(error instanceof Refusal)) {
|
|
337
|
+
log("error", "handler failed", {
|
|
338
|
+
request: caller.request,
|
|
339
|
+
path: url.pathname,
|
|
340
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
failure(request, response, kind, error instanceof Refusal ? error.message : "the server failed; see its log");
|
|
344
|
+
}
|
|
345
|
+
logged(caller, method, url.pathname, status, started);
|
|
346
|
+
}
|
|
135
347
|
async function asset(request, response, path) {
|
|
136
348
|
const relative = normalize(decodeURIComponent(path)).replace(/^[/\\]+/, "");
|
|
137
349
|
if (relative.split(sep).includes("..")) {
|
|
@@ -179,7 +391,7 @@ export async function serveWeb(options) {
|
|
|
179
391
|
throw new Refusal("invalid", `no ${method} ${url.pathname}`);
|
|
180
392
|
}
|
|
181
393
|
// A page from anywhere else must never act with this browser's credentials.
|
|
182
|
-
if (method === "POST" && !
|
|
394
|
+
if (method === "POST" && !fromPage(request, caller)) {
|
|
183
395
|
throw new Refusal("forbidden", "bad origin");
|
|
184
396
|
}
|
|
185
397
|
if (found.person && !caller.person) {
|
|
@@ -187,7 +399,7 @@ export async function serveWeb(options) {
|
|
|
187
399
|
}
|
|
188
400
|
let input;
|
|
189
401
|
if (method === "POST") {
|
|
190
|
-
input = await readBody(request, options.bodyLimit ?? 65_536);
|
|
402
|
+
input = await readBody(request, found.bodyLimit ?? options.bodyLimit ?? 65_536);
|
|
191
403
|
}
|
|
192
404
|
else {
|
|
193
405
|
const q = url.searchParams.get("q");
|
|
@@ -212,16 +424,9 @@ export async function serveWeb(options) {
|
|
|
212
424
|
reason: message,
|
|
213
425
|
});
|
|
214
426
|
}
|
|
215
|
-
failure(request, response, kind, error instanceof Refusal ? message : "the server failed; see its log");
|
|
427
|
+
failure(request, response, kind, error instanceof Refusal ? message : "the server failed; see its log", error instanceof Refusal ? error.path : undefined);
|
|
216
428
|
}
|
|
217
|
-
|
|
218
|
-
request: caller.request,
|
|
219
|
-
method,
|
|
220
|
-
path: url.pathname,
|
|
221
|
-
status,
|
|
222
|
-
ms: Math.round(performance.now() - started),
|
|
223
|
-
person: caller.person,
|
|
224
|
-
});
|
|
429
|
+
logged(caller, method, url.pathname, status, started);
|
|
225
430
|
}
|
|
226
431
|
/** The sockets each sign-in session opened, so signing out closes them. */
|
|
227
432
|
const signedIn = new Map();
|
|
@@ -238,14 +443,21 @@ export async function serveWeb(options) {
|
|
|
238
443
|
if (!auth.oidc) {
|
|
239
444
|
return send(request, response, 404, "No login here");
|
|
240
445
|
}
|
|
241
|
-
|
|
446
|
+
const back = onThisServer(url.searchParams.get("back"));
|
|
447
|
+
return send(request, response, 302, "", {
|
|
448
|
+
location: await auth.loginUrl(callback),
|
|
449
|
+
// Where the sign-in returns, for the callback; only a path on this server is kept.
|
|
450
|
+
"set-cookie": `${cookie}_back=${back === undefined ? "" : encodeURIComponent(back)}; Path=/auth; HttpOnly;${secure} SameSite=Lax; Max-Age=${back === undefined ? 0 : BACK_SECONDS}`,
|
|
451
|
+
});
|
|
242
452
|
}
|
|
243
453
|
if (url.pathname === "/auth/callback" && request.method === "GET") {
|
|
244
454
|
try {
|
|
245
455
|
const token = await auth.callback(url.searchParams.get("state") ?? "", url.searchParams.get("code") ?? "");
|
|
246
456
|
log("info", "signed in");
|
|
457
|
+
// Every sign-in starts at /auth/login, which sets or clears this, so it is never an older one's.
|
|
458
|
+
const back = onThisServer(decodeCookie(sessionToken(request, `${cookie}_back`)));
|
|
247
459
|
return send(request, response, 302, "", {
|
|
248
|
-
location: "/",
|
|
460
|
+
location: back ?? "/",
|
|
249
461
|
"set-cookie": `${cookie}=${token}; Path=/; HttpOnly;${secure} SameSite=Lax; Max-Age=${7 * 86_400}`,
|
|
250
462
|
});
|
|
251
463
|
}
|
|
@@ -275,10 +487,23 @@ export async function serveWeb(options) {
|
|
|
275
487
|
log("warn", caller === false ? "refused" : "not signed in", { path: url.pathname });
|
|
276
488
|
const navigation = request.method === "GET" && (header(request, "accept") ?? "").includes("text/html");
|
|
277
489
|
if (caller === undefined && navigation && auth.oidc) {
|
|
278
|
-
return send(request, response, 302, "", {
|
|
490
|
+
return send(request, response, 302, "", {
|
|
491
|
+
location: `/auth/login?back=${encodeURIComponent(url.pathname + url.search)}`,
|
|
492
|
+
});
|
|
279
493
|
}
|
|
280
494
|
return failure(request, response, caller === false ? "forbidden" : "signed-out", caller === false ? "not allowed" : "sign in first");
|
|
281
495
|
}
|
|
496
|
+
let decoded;
|
|
497
|
+
try {
|
|
498
|
+
decoded = decodeURIComponent(url.pathname);
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
return send(request, response, 400, "Bad path");
|
|
502
|
+
}
|
|
503
|
+
const candidates = rawFor(decoded);
|
|
504
|
+
if (candidates.length > 0) {
|
|
505
|
+
return await raw(request, response, url, caller, candidates);
|
|
506
|
+
}
|
|
282
507
|
if (url.pathname.startsWith("/api/")) {
|
|
283
508
|
return await api(request, response, url, caller);
|
|
284
509
|
}
|
|
@@ -311,13 +536,13 @@ export async function serveWeb(options) {
|
|
|
311
536
|
new URL(request.url ?? "/", "http://localhost").pathname !== options.socket.path) {
|
|
312
537
|
return refuse(404);
|
|
313
538
|
}
|
|
314
|
-
if (!origins.has(header(request, "origin") ?? "")) {
|
|
315
|
-
return refuse(403);
|
|
316
|
-
}
|
|
317
539
|
const caller = await identify(request);
|
|
318
540
|
if (caller === undefined || caller === false) {
|
|
319
541
|
return refuse(caller === false ? 403 : 401);
|
|
320
542
|
}
|
|
543
|
+
if (!fromPage(request, caller)) {
|
|
544
|
+
return refuse(403);
|
|
545
|
+
}
|
|
321
546
|
const open = options.socket.open;
|
|
322
547
|
sockets.handleUpgrade(request, stream, head, (socket) => {
|
|
323
548
|
socket.on("error", () => {
|
package/dist/states.d.ts
CHANGED
|
@@ -77,25 +77,59 @@ export declare const LOOKS: {
|
|
|
77
77
|
* history. `log`: nothing to act on or misread.
|
|
78
78
|
*/
|
|
79
79
|
export type Place = "main" | "badge" | "panel" | "log";
|
|
80
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The work a `working` state waits on, so it can be checked to be under way (`slopkit/test/states` `viewProblems`): the
|
|
82
|
+
* server's job of a kind and key, a command in the page's outbox, or the page's own copy (loading, pulling, pushing,
|
|
83
|
+
* writing). A working state that owes nothing of the app's (the world is at work: a parcel in transit) says `owes: null`.
|
|
84
|
+
*/
|
|
85
|
+
export type Owed = {
|
|
86
|
+
readonly job: {
|
|
87
|
+
readonly kind: string;
|
|
88
|
+
readonly key: string;
|
|
89
|
+
};
|
|
90
|
+
} | {
|
|
91
|
+
readonly command: {
|
|
92
|
+
readonly client: string;
|
|
93
|
+
readonly id: number;
|
|
94
|
+
};
|
|
95
|
+
} | {
|
|
96
|
+
readonly sync: true;
|
|
97
|
+
};
|
|
98
|
+
type Others = Exclude<Meaning, "ok" | "partial" | "stale" | "working">;
|
|
99
|
+
type Placed<S> = {
|
|
81
100
|
readonly meaning: "ok";
|
|
82
101
|
readonly where: Place;
|
|
83
102
|
readonly claim: false;
|
|
103
|
+
readonly owes?: never;
|
|
84
104
|
} | {
|
|
85
105
|
readonly meaning: "partial" | "stale";
|
|
86
106
|
readonly where: "main";
|
|
87
107
|
readonly claim: true;
|
|
108
|
+
readonly owes?: never;
|
|
88
109
|
} | {
|
|
89
|
-
readonly meaning:
|
|
110
|
+
readonly meaning: Others;
|
|
90
111
|
readonly where: "main";
|
|
91
112
|
readonly claim: boolean;
|
|
113
|
+
readonly owes?: never;
|
|
92
114
|
} | {
|
|
93
|
-
readonly meaning:
|
|
115
|
+
readonly meaning: Others;
|
|
94
116
|
readonly where: "badge";
|
|
95
117
|
readonly claim: false;
|
|
118
|
+
readonly owes?: never;
|
|
119
|
+
} | {
|
|
120
|
+
readonly meaning: "working";
|
|
121
|
+
readonly where: "main";
|
|
122
|
+
readonly claim: boolean;
|
|
123
|
+
/** What is at work: checked to be under way whenever this is drawn. Required. */
|
|
124
|
+
readonly owes: ((state: S) => Owed) | null;
|
|
125
|
+
} | {
|
|
126
|
+
readonly meaning: "working";
|
|
127
|
+
readonly where: "badge";
|
|
128
|
+
readonly claim: false;
|
|
129
|
+
readonly owes: ((state: S) => Owed) | null;
|
|
96
130
|
};
|
|
97
131
|
/** One state's display: what it means, where and why it is drawn, its words, and what the person can do. */
|
|
98
|
-
export type Display<S, A> = Placed & {
|
|
132
|
+
export type Display<S, A> = Placed<S> & {
|
|
99
133
|
/** Why it is drawn there, or not at all. Required. */
|
|
100
134
|
readonly why: string;
|
|
101
135
|
readonly text: (state: S) => string;
|
|
@@ -109,9 +143,20 @@ export type Table<S extends {
|
|
|
109
143
|
kind: K;
|
|
110
144
|
}>, A>;
|
|
111
145
|
};
|
|
146
|
+
/**
|
|
147
|
+
* Brands a Line and a Screen carry, so a check holding one copy of this module still knows another copy's (a fresh
|
|
148
|
+
* module graph for each page load in a test, two installed versions).
|
|
149
|
+
*/
|
|
150
|
+
declare const LINE: unique symbol;
|
|
151
|
+
declare const SCREEN: unique symbol;
|
|
152
|
+
/** Whether a value is a Line, made by any copy of this module. */
|
|
153
|
+
export declare function isLine(value: unknown): value is Line<unknown>;
|
|
154
|
+
/** Whether a value is a Screen, made by any copy of this module. */
|
|
155
|
+
export declare function isScreen(value: unknown): value is Screen<unknown>;
|
|
112
156
|
/** A drawn state: only a subject makes one, from its table. */
|
|
113
157
|
export declare class Line<A> {
|
|
114
158
|
#private;
|
|
159
|
+
readonly [LINE] = true;
|
|
115
160
|
readonly subject: string;
|
|
116
161
|
readonly kind: string;
|
|
117
162
|
readonly meaning: Meaning;
|
|
@@ -121,11 +166,14 @@ export declare class Line<A> {
|
|
|
121
166
|
readonly tone: Tone;
|
|
122
167
|
readonly text: string;
|
|
123
168
|
readonly action: A | null;
|
|
169
|
+
/** The work a working state waits on (its table's `owes`), or null. */
|
|
170
|
+
readonly owed: Owed | null;
|
|
124
171
|
private constructor();
|
|
125
172
|
}
|
|
126
173
|
/** A screen's summary of all its parts: only `compose` makes one. */
|
|
127
174
|
export declare class Screen<A> {
|
|
128
175
|
#private;
|
|
176
|
+
readonly [SCREEN] = true;
|
|
129
177
|
/** Every part is ok. */
|
|
130
178
|
readonly ok: boolean;
|
|
131
179
|
/** The status line: the first part that isn't ok (by placement, severity, order), or the ok text; `more` counts the rest. */
|
package/dist/states.js
CHANGED
|
@@ -45,6 +45,20 @@ export const LOOKS = {
|
|
|
45
45
|
ours: { glyph: "✕", tone: "warning" },
|
|
46
46
|
gone: { glyph: "⊘", tone: "muted" },
|
|
47
47
|
};
|
|
48
|
+
/**
|
|
49
|
+
* Brands a Line and a Screen carry, so a check holding one copy of this module still knows another copy's (a fresh
|
|
50
|
+
* module graph for each page load in a test, two installed versions).
|
|
51
|
+
*/
|
|
52
|
+
const LINE = Symbol.for("slopkit.states.Line");
|
|
53
|
+
const SCREEN = Symbol.for("slopkit.states.Screen");
|
|
54
|
+
/** Whether a value is a Line, made by any copy of this module. */
|
|
55
|
+
export function isLine(value) {
|
|
56
|
+
return typeof value === "object" && value !== null && LINE in value;
|
|
57
|
+
}
|
|
58
|
+
/** Whether a value is a Screen, made by any copy of this module. */
|
|
59
|
+
export function isScreen(value) {
|
|
60
|
+
return typeof value === "object" && value !== null && SCREEN in value;
|
|
61
|
+
}
|
|
48
62
|
// Set by the classes' static blocks: nothing outside this module can make a Line or a Screen.
|
|
49
63
|
let makeLine;
|
|
50
64
|
let makeScreen;
|
|
@@ -53,6 +67,7 @@ export class Line {
|
|
|
53
67
|
// A private field makes the type nominal: no object literal or spread of a Line is one.
|
|
54
68
|
// oxlint-disable-next-line no-unused-private-class-members -- its presence is its use
|
|
55
69
|
#made = true;
|
|
70
|
+
[LINE] = true;
|
|
56
71
|
subject;
|
|
57
72
|
kind;
|
|
58
73
|
meaning;
|
|
@@ -62,6 +77,8 @@ export class Line {
|
|
|
62
77
|
tone;
|
|
63
78
|
text;
|
|
64
79
|
action;
|
|
80
|
+
/** The work a working state waits on (its table's `owes`), or null. */
|
|
81
|
+
owed;
|
|
65
82
|
constructor(fields) {
|
|
66
83
|
this.subject = fields.subject;
|
|
67
84
|
this.kind = fields.kind;
|
|
@@ -72,6 +89,7 @@ export class Line {
|
|
|
72
89
|
this.tone = fields.tone;
|
|
73
90
|
this.text = fields.text;
|
|
74
91
|
this.action = fields.action;
|
|
92
|
+
this.owed = fields.owed;
|
|
75
93
|
}
|
|
76
94
|
static {
|
|
77
95
|
makeLine = (fields) => new Line(fields);
|
|
@@ -81,6 +99,7 @@ export class Line {
|
|
|
81
99
|
export class Screen {
|
|
82
100
|
// oxlint-disable-next-line no-unused-private-class-members -- nominal, as Line's
|
|
83
101
|
#made = true;
|
|
102
|
+
[SCREEN] = true;
|
|
84
103
|
/** Every part is ok. */
|
|
85
104
|
ok;
|
|
86
105
|
/** The status line: the first part that isn't ok (by placement, severity, order), or the ok text; `more` counts the rest. */
|
|
@@ -185,6 +204,7 @@ export function states(options) {
|
|
|
185
204
|
tone: look.tone,
|
|
186
205
|
text,
|
|
187
206
|
action: display.action,
|
|
207
|
+
owed: display.meaning === "working" && display.owes !== null ? display.owes(state) : null,
|
|
188
208
|
});
|
|
189
209
|
},
|
|
190
210
|
};
|