slopkit 0.3.0 → 0.4.0-rc.1
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 +96 -1
- package/RULES.md +26 -6
- package/dist/api.d.ts +93 -0
- package/dist/api.js +111 -0
- package/dist/client.d.ts +5 -1
- package/dist/conformance/agent-access.test.d.ts +1 -0
- package/dist/conformance/agent-access.test.js +74 -0
- package/dist/conformance/paging-state.test.js +1 -0
- package/dist/conformance/states.test.js +19 -8
- 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/store.d.ts +101 -0
- package/dist/server/store.js +347 -0
- package/dist/server/sync.d.ts +164 -0
- package/dist/server/sync.js +293 -0
- package/dist/server/web.d.ts +54 -5
- package/dist/server/web.js +236 -28
- package/dist/states.d.ts +52 -4
- package/dist/states.js +20 -0
- package/dist/sync.d.ts +2 -0
- package/dist/sync.js +1 -0
- 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 +214 -43
- 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
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
|
*/
|
|
@@ -45,11 +49,19 @@ export class Refusal extends Error {
|
|
|
45
49
|
this.path = path;
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
|
-
|
|
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 = {}) {
|
|
49
60
|
return {
|
|
50
61
|
method: endpoint.method,
|
|
51
62
|
path: endpoint.path,
|
|
52
63
|
person: endpoint.person,
|
|
64
|
+
...(options.bodyLimit === undefined ? {} : { bodyLimit: bodyBytes(options.bodyLimit) }),
|
|
53
65
|
run: async (input, caller) => {
|
|
54
66
|
let request;
|
|
55
67
|
try {
|
|
@@ -66,6 +78,53 @@ export function route(endpoint, handle) {
|
|
|
66
78
|
},
|
|
67
79
|
};
|
|
68
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
|
+
}
|
|
69
128
|
function header(request, name) {
|
|
70
129
|
const value = request.headers[name];
|
|
71
130
|
return Array.isArray(value) ? value[0] : value;
|
|
@@ -77,11 +136,36 @@ function sessionToken(request, cookie) {
|
|
|
77
136
|
.find((part) => part.startsWith(`${cookie}=`))
|
|
78
137
|
?.slice(cookie.length + 1);
|
|
79
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
|
+
}
|
|
80
161
|
/** Send a body, gzipped when the client takes it and it is worth it. */
|
|
81
162
|
function send(request, response, status, body, headers = {}) {
|
|
82
163
|
let bytes = typeof body === "string" ? Buffer.from(body) : body;
|
|
83
164
|
const extra = {};
|
|
84
|
-
|
|
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") ?? "")) {
|
|
85
169
|
bytes = gzipSync(bytes, { level: 6 });
|
|
86
170
|
extra["content-encoding"] = "gzip";
|
|
87
171
|
extra.vary = "accept-encoding";
|
|
@@ -98,11 +182,12 @@ function send(request, response, status, body, headers = {}) {
|
|
|
98
182
|
function failure(request, response, kind, message, path) {
|
|
99
183
|
send(request, response, STATUS[kind], JSON.stringify({ kind, message, ...(path === undefined ? {} : { path }) }), { "content-type": "application/json" });
|
|
100
184
|
}
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
}
|
|
106
191
|
const chunks = [];
|
|
107
192
|
let size = 0;
|
|
108
193
|
for await (const chunk of request) {
|
|
@@ -113,9 +198,17 @@ async function readBody(request, limit) {
|
|
|
113
198
|
}
|
|
114
199
|
chunks.push(bytes);
|
|
115
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);
|
|
116
209
|
let text;
|
|
117
210
|
try {
|
|
118
|
-
text = new TextDecoder("utf-8", { fatal: true }).decode(
|
|
211
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
119
212
|
}
|
|
120
213
|
catch {
|
|
121
214
|
throw new Refusal("invalid", "the request is not UTF-8");
|
|
@@ -133,22 +226,124 @@ export async function serveWeb(options) {
|
|
|
133
226
|
const hosts = new Set();
|
|
134
227
|
const origins = new Set();
|
|
135
228
|
let plainHost;
|
|
136
|
-
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]));
|
|
137
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
|
+
}
|
|
138
265
|
/** The caller, or undefined when not signed in, or false when signed in but not allowed. */
|
|
139
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] ?? "");
|
|
140
270
|
const jwt = header(request, "cf-access-jwt-assertion");
|
|
141
|
-
const identities = await auth.identify(jwt, sessionToken(request, cookie));
|
|
271
|
+
const identities = await auth.identify(jwt, sessionToken(request, cookie), bearer);
|
|
142
272
|
if (identities === undefined || identities === false) {
|
|
143
273
|
return identities;
|
|
144
274
|
}
|
|
145
275
|
return {
|
|
146
276
|
identities,
|
|
147
|
-
person: !identities.some((identity) => identity.startsWith("service:")),
|
|
148
|
-
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",
|
|
149
279
|
request: randomBytes(6).toString("base64url"),
|
|
150
280
|
};
|
|
151
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
|
+
}
|
|
152
347
|
async function asset(request, response, path) {
|
|
153
348
|
const relative = normalize(decodeURIComponent(path)).replace(/^[/\\]+/, "");
|
|
154
349
|
if (relative.split(sep).includes("..")) {
|
|
@@ -196,7 +391,7 @@ export async function serveWeb(options) {
|
|
|
196
391
|
throw new Refusal("invalid", `no ${method} ${url.pathname}`);
|
|
197
392
|
}
|
|
198
393
|
// A page from anywhere else must never act with this browser's credentials.
|
|
199
|
-
if (method === "POST" && !
|
|
394
|
+
if (method === "POST" && !fromPage(request, caller)) {
|
|
200
395
|
throw new Refusal("forbidden", "bad origin");
|
|
201
396
|
}
|
|
202
397
|
if (found.person && !caller.person) {
|
|
@@ -204,7 +399,7 @@ export async function serveWeb(options) {
|
|
|
204
399
|
}
|
|
205
400
|
let input;
|
|
206
401
|
if (method === "POST") {
|
|
207
|
-
input = await readBody(request, options.bodyLimit ?? 65_536);
|
|
402
|
+
input = await readBody(request, found.bodyLimit ?? options.bodyLimit ?? 65_536);
|
|
208
403
|
}
|
|
209
404
|
else {
|
|
210
405
|
const q = url.searchParams.get("q");
|
|
@@ -231,14 +426,7 @@ export async function serveWeb(options) {
|
|
|
231
426
|
}
|
|
232
427
|
failure(request, response, kind, error instanceof Refusal ? message : "the server failed; see its log", error instanceof Refusal ? error.path : undefined);
|
|
233
428
|
}
|
|
234
|
-
|
|
235
|
-
request: caller.request,
|
|
236
|
-
method,
|
|
237
|
-
path: url.pathname,
|
|
238
|
-
status,
|
|
239
|
-
ms: Math.round(performance.now() - started),
|
|
240
|
-
person: caller.person,
|
|
241
|
-
});
|
|
429
|
+
logged(caller, method, url.pathname, status, started);
|
|
242
430
|
}
|
|
243
431
|
/** The sockets each sign-in session opened, so signing out closes them. */
|
|
244
432
|
const signedIn = new Map();
|
|
@@ -255,14 +443,21 @@ export async function serveWeb(options) {
|
|
|
255
443
|
if (!auth.oidc) {
|
|
256
444
|
return send(request, response, 404, "No login here");
|
|
257
445
|
}
|
|
258
|
-
|
|
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
|
+
});
|
|
259
452
|
}
|
|
260
453
|
if (url.pathname === "/auth/callback" && request.method === "GET") {
|
|
261
454
|
try {
|
|
262
455
|
const token = await auth.callback(url.searchParams.get("state") ?? "", url.searchParams.get("code") ?? "");
|
|
263
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`)));
|
|
264
459
|
return send(request, response, 302, "", {
|
|
265
|
-
location: "/",
|
|
460
|
+
location: back ?? "/",
|
|
266
461
|
"set-cookie": `${cookie}=${token}; Path=/; HttpOnly;${secure} SameSite=Lax; Max-Age=${7 * 86_400}`,
|
|
267
462
|
});
|
|
268
463
|
}
|
|
@@ -292,10 +487,23 @@ export async function serveWeb(options) {
|
|
|
292
487
|
log("warn", caller === false ? "refused" : "not signed in", { path: url.pathname });
|
|
293
488
|
const navigation = request.method === "GET" && (header(request, "accept") ?? "").includes("text/html");
|
|
294
489
|
if (caller === undefined && navigation && auth.oidc) {
|
|
295
|
-
return send(request, response, 302, "", {
|
|
490
|
+
return send(request, response, 302, "", {
|
|
491
|
+
location: `/auth/login?back=${encodeURIComponent(url.pathname + url.search)}`,
|
|
492
|
+
});
|
|
296
493
|
}
|
|
297
494
|
return failure(request, response, caller === false ? "forbidden" : "signed-out", caller === false ? "not allowed" : "sign in first");
|
|
298
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
|
+
}
|
|
299
507
|
if (url.pathname.startsWith("/api/")) {
|
|
300
508
|
return await api(request, response, url, caller);
|
|
301
509
|
}
|
|
@@ -328,13 +536,13 @@ export async function serveWeb(options) {
|
|
|
328
536
|
new URL(request.url ?? "/", "http://localhost").pathname !== options.socket.path) {
|
|
329
537
|
return refuse(404);
|
|
330
538
|
}
|
|
331
|
-
if (!origins.has(header(request, "origin") ?? "")) {
|
|
332
|
-
return refuse(403);
|
|
333
|
-
}
|
|
334
539
|
const caller = await identify(request);
|
|
335
540
|
if (caller === undefined || caller === false) {
|
|
336
541
|
return refuse(caller === false ? 403 : 401);
|
|
337
542
|
}
|
|
543
|
+
if (!fromPage(request, caller)) {
|
|
544
|
+
return refuse(403);
|
|
545
|
+
}
|
|
338
546
|
const open = options.socket.open;
|
|
339
547
|
sockets.handleUpgrade(request, stream, head, (socket) => {
|
|
340
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
|
};
|
package/dist/sync.d.ts
CHANGED
|
@@ -317,6 +317,8 @@ export type Msg<T, P, S, C, O> = {
|
|
|
317
317
|
replica: Replica<T, S>;
|
|
318
318
|
commands: Pending<C, O>[];
|
|
319
319
|
unreadable: number;
|
|
320
|
+
/** The copy owes a real reset before it is trusted (lifted from an earlier release's row shapes). */
|
|
321
|
+
restart?: "paged";
|
|
320
322
|
now: number;
|
|
321
323
|
} | {
|
|
322
324
|
type: "loadFailed";
|
package/dist/sync.js
CHANGED
|
@@ -635,6 +635,7 @@ export function createSync(spec) {
|
|
|
635
635
|
replica: msg.replica,
|
|
636
636
|
saved: { ...at.saved, dataset: msg.replica.dataset, revision: msg.replica.revision },
|
|
637
637
|
target: Math.max(at.target, msg.replica.revision),
|
|
638
|
+
restart: msg.restart ?? at.restart,
|
|
638
639
|
commands: [...resumed, ...at.commands],
|
|
639
640
|
unreadable: msg.unreadable,
|
|
640
641
|
},
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Endpoint } from "../contract.ts";
|
|
2
|
+
export type AgentCase = {
|
|
3
|
+
name: string;
|
|
4
|
+
check: (base: string, token: string) => Promise<string[]>;
|
|
5
|
+
};
|
|
6
|
+
/** A call an agent's key makes and gets answered. */
|
|
7
|
+
export declare function reads<Req, Res>(endpoint: Endpoint<Req, Res>, input: Req, name?: string): AgentCase;
|
|
8
|
+
/** A call only the person may make: the key gets `forbidden`, or an answer `refusal` judges a refusal. */
|
|
9
|
+
export declare function refused<Req, Res>(endpoint: Endpoint<Req, Res>, input: Req, options?: {
|
|
10
|
+
name?: string;
|
|
11
|
+
refusal?: (reply: Res) => boolean;
|
|
12
|
+
}): AgentCase;
|
|
13
|
+
/** Run every case against the app at `url` with an allowed key's `token`; throws listing every failure. */
|
|
14
|
+
export declare function agentAccess(options: {
|
|
15
|
+
url: string;
|
|
16
|
+
token: string;
|
|
17
|
+
reads: readonly AgentCase[];
|
|
18
|
+
refused: readonly AgentCase[];
|
|
19
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent access, checked on an app's real server: the app lists what its agents read with a key and what only the
|
|
3
|
+
* person may do, and `agentAccess` proves over HTTP, as an agent calls it (a bearer key, no Origin, no login), that:
|
|
4
|
+
*
|
|
5
|
+
* - the key reads each `reads` case, and the reply is the contract's;
|
|
6
|
+
* - none of those reads is public: without credentials it's `signed-out`, with an unknown key `forbidden`;
|
|
7
|
+
* - the key is refused each `refused` case: `forbidden`, or a reply the case judges a refusal (a sync push answers
|
|
8
|
+
* each command it refuses with a receipt).
|
|
9
|
+
*
|
|
10
|
+
* Every failure is collected and thrown together, so one run shows the whole gap.
|
|
11
|
+
*/
|
|
12
|
+
import { call } from "../contract.js";
|
|
13
|
+
const bearer = (token) => ({ authorization: `Bearer ${token}` });
|
|
14
|
+
const said = (result) => result.ok ? "an answer" : `${result.failure.kind}: ${result.failure.message}`;
|
|
15
|
+
/** A call an agent's key makes and gets answered. */
|
|
16
|
+
export function reads(endpoint, input, name) {
|
|
17
|
+
const named = name ?? `${endpoint.method} ${endpoint.path}`;
|
|
18
|
+
return {
|
|
19
|
+
name: named,
|
|
20
|
+
check: async (base, token) => {
|
|
21
|
+
const failures = [];
|
|
22
|
+
const read = await call(endpoint, input, { base, headers: bearer(token) });
|
|
23
|
+
if (!read.ok) {
|
|
24
|
+
failures.push(`${named}: a key should read it, got ${said(read)}`);
|
|
25
|
+
}
|
|
26
|
+
const anonymous = await call(endpoint, input, { base });
|
|
27
|
+
if (anonymous.ok || anonymous.failure.kind !== "signed-out") {
|
|
28
|
+
failures.push(`${named}: without credentials it should be signed-out, got ${said(anonymous)}`);
|
|
29
|
+
}
|
|
30
|
+
const unknown = await call(endpoint, input, { base, headers: bearer("nobody.not-a-key") });
|
|
31
|
+
if (unknown.ok || unknown.failure.kind !== "forbidden") {
|
|
32
|
+
failures.push(`${named}: an unknown key should be forbidden, got ${said(unknown)}`);
|
|
33
|
+
}
|
|
34
|
+
return failures;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** A call only the person may make: the key gets `forbidden`, or an answer `refusal` judges a refusal. */
|
|
39
|
+
export function refused(endpoint, input, options = {}) {
|
|
40
|
+
const named = options.name ?? `${endpoint.method} ${endpoint.path}`;
|
|
41
|
+
return {
|
|
42
|
+
name: named,
|
|
43
|
+
check: async (base, token) => {
|
|
44
|
+
const result = await call(endpoint, input, { base, headers: bearer(token) });
|
|
45
|
+
const refusal = result.ok
|
|
46
|
+
? options.refusal?.(result.value) === true
|
|
47
|
+
: result.failure.kind === "forbidden";
|
|
48
|
+
return refusal ? [] : [`${named}: a key should be refused, got ${said(result)}`];
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Run every case against the app at `url` with an allowed key's `token`; throws listing every failure. */
|
|
53
|
+
export async function agentAccess(options) {
|
|
54
|
+
if (options.reads.length === 0 || options.refused.length === 0) {
|
|
55
|
+
throw new Error("agentAccess: name at least one read and one refused call, or it proves nothing");
|
|
56
|
+
}
|
|
57
|
+
const base = options.url.replace(/\/$/, "");
|
|
58
|
+
const failures = (await Promise.all([...options.reads, ...options.refused].map((each) => each.check(base, options.token)))).flat();
|
|
59
|
+
if (failures.length > 0) {
|
|
60
|
+
throw new Error(`agent access:\n${failures.map((line) => `- ${line}`).join("\n")}`);
|
|
61
|
+
}
|
|
62
|
+
}
|