@ccmsg/cli 0.1.0 → 0.2.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/package.json +2 -2
- package/src/auth/admin.ts +64 -0
- package/src/auth/auth.ts +985 -0
- package/src/auth/cbor.ts +150 -0
- package/src/auth/http.ts +280 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/records.ts +394 -0
- package/src/auth/topic.ts +30 -0
- package/src/auth/webauthn.ts +389 -0
- package/src/cli.ts +91 -0
- package/src/dispatch/dispatch.ts +9 -0
- package/src/instance/instance.ts +128 -14
- package/src/mesh/mesh.ts +53 -4
- package/src/mesh/relay.ts +7 -0
- package/src/sessions/registry.ts +18 -16
- package/src/transport/entry.ts +10 -0
- package/src/transport/ws.ts +15 -4
package/src/auth/cbor.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/** Just enough CBOR to read what an authenticator hands over (DR-0001 §2.10).
|
|
2
|
+
*
|
|
3
|
+
* Two values arrive in this encoding and no others: the attestation object,
|
|
4
|
+
* which is a map of three entries, and the COSE public key inside it, which is
|
|
5
|
+
* a map keyed by small integers. So the decoder covers the five major types
|
|
6
|
+
* those use — unsigned and negative integers, byte and text strings, arrays and
|
|
7
|
+
* maps — and refuses everything else rather than growing toward a general
|
|
8
|
+
* implementation nothing here would call.
|
|
9
|
+
*
|
|
10
|
+
* Indefinite lengths are refused for the same reason: WebAuthn requires
|
|
11
|
+
* canonical CTAP2 encoding, where every length is definite, so accepting one
|
|
12
|
+
* would be accepting something no conforming authenticator produces.
|
|
13
|
+
*
|
|
14
|
+
* Nesting is bounded because the input is attacker-supplied: a value that is
|
|
15
|
+
* nothing but a few thousand nested arrays costs one byte each to write and a
|
|
16
|
+
* stack frame each to read, and this decoder runs on a route reachable before
|
|
17
|
+
* anything is proven. */
|
|
18
|
+
|
|
19
|
+
export class CborError extends Error {}
|
|
20
|
+
|
|
21
|
+
/** How deep a value may nest. The two shapes this reads are a map of three
|
|
22
|
+
* entries holding a map of five, so anything past a handful is not one of
|
|
23
|
+
* them. */
|
|
24
|
+
const MAX_DEPTH = 8;
|
|
25
|
+
|
|
26
|
+
/** One decoded value, and where the next one starts. */
|
|
27
|
+
interface Read<T> {
|
|
28
|
+
readonly value: T;
|
|
29
|
+
readonly next: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type CborValue =
|
|
33
|
+
| number
|
|
34
|
+
| string
|
|
35
|
+
| Uint8Array
|
|
36
|
+
| CborValue[]
|
|
37
|
+
| Map<number | string, CborValue>
|
|
38
|
+
| boolean
|
|
39
|
+
| null;
|
|
40
|
+
|
|
41
|
+
/** Decode one value, and say how many bytes were left after it.
|
|
42
|
+
*
|
|
43
|
+
* The remainder is answered rather than ignored because trailing bytes mean
|
|
44
|
+
* the value was not what the encoder said it was, and the caller — which knows
|
|
45
|
+
* whether its input is exactly one value — is where that is worth refusing. */
|
|
46
|
+
export function decodeCbor(bytes: Uint8Array): { value: CborValue; rest: number } {
|
|
47
|
+
const read = value(bytes, 0, 0);
|
|
48
|
+
return { value: read.value, rest: bytes.length - read.next };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Decode one value that is the whole of the input. */
|
|
52
|
+
export function decodeCborWhole(bytes: Uint8Array): CborValue {
|
|
53
|
+
const { value: decoded, rest } = decodeCbor(bytes);
|
|
54
|
+
if (rest !== 0) throw new CborError(`${String(rest)} bytes follow the value`);
|
|
55
|
+
return decoded;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function value(bytes: Uint8Array, at: number, depth: number): Read<CborValue> {
|
|
59
|
+
if (depth > MAX_DEPTH) throw new CborError("this value nests too deeply to be read here");
|
|
60
|
+
const initial = byte(bytes, at);
|
|
61
|
+
const major = initial >> 5;
|
|
62
|
+
const minor = initial & 0x1f;
|
|
63
|
+
switch (major) {
|
|
64
|
+
case 0: {
|
|
65
|
+
const read = argument(bytes, at + 1, minor);
|
|
66
|
+
return { value: read.value, next: read.next };
|
|
67
|
+
}
|
|
68
|
+
case 1: {
|
|
69
|
+
const read = argument(bytes, at + 1, minor);
|
|
70
|
+
return { value: -1 - read.value, next: read.next };
|
|
71
|
+
}
|
|
72
|
+
case 2: {
|
|
73
|
+
const read = argument(bytes, at + 1, minor);
|
|
74
|
+
const end = read.next + read.value;
|
|
75
|
+
if (end > bytes.length) throw new CborError("a byte string runs past the end");
|
|
76
|
+
return { value: bytes.subarray(read.next, end), next: end };
|
|
77
|
+
}
|
|
78
|
+
case 3: {
|
|
79
|
+
const read = argument(bytes, at + 1, minor);
|
|
80
|
+
const end = read.next + read.value;
|
|
81
|
+
if (end > bytes.length) throw new CborError("a text string runs past the end");
|
|
82
|
+
return { value: new TextDecoder().decode(bytes.subarray(read.next, end)), next: end };
|
|
83
|
+
}
|
|
84
|
+
case 4: {
|
|
85
|
+
const read = argument(bytes, at + 1, minor);
|
|
86
|
+
const items: CborValue[] = [];
|
|
87
|
+
let cursor = read.next;
|
|
88
|
+
for (let i = 0; i < read.value; i += 1) {
|
|
89
|
+
const item = value(bytes, cursor, depth + 1);
|
|
90
|
+
items.push(item.value);
|
|
91
|
+
cursor = item.next;
|
|
92
|
+
}
|
|
93
|
+
return { value: items, next: cursor };
|
|
94
|
+
}
|
|
95
|
+
case 5: {
|
|
96
|
+
const read = argument(bytes, at + 1, minor);
|
|
97
|
+
const entries = new Map<number | string, CborValue>();
|
|
98
|
+
let cursor = read.next;
|
|
99
|
+
for (let i = 0; i < read.value; i += 1) {
|
|
100
|
+
const key = value(bytes, cursor, depth + 1);
|
|
101
|
+
if (typeof key.value !== "number" && typeof key.value !== "string") {
|
|
102
|
+
throw new CborError("a map key here is an integer or a string");
|
|
103
|
+
}
|
|
104
|
+
const held = value(bytes, key.next, depth + 1);
|
|
105
|
+
entries.set(key.value, held.value);
|
|
106
|
+
cursor = held.next;
|
|
107
|
+
}
|
|
108
|
+
return { value: entries, next: cursor };
|
|
109
|
+
}
|
|
110
|
+
case 7: {
|
|
111
|
+
// The three simple values that appear in an attestation statement.
|
|
112
|
+
if (minor === 20) return { value: false, next: at + 1 };
|
|
113
|
+
if (minor === 21) return { value: true, next: at + 1 };
|
|
114
|
+
if (minor === 22) return { value: null, next: at + 1 };
|
|
115
|
+
throw new CborError(`simple value ${String(minor)} is not read here`);
|
|
116
|
+
}
|
|
117
|
+
default:
|
|
118
|
+
throw new CborError(`major type ${String(major)} is not read here`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The count, length or value an initial byte's low bits introduce. */
|
|
123
|
+
function argument(bytes: Uint8Array, at: number, minor: number): Read<number> {
|
|
124
|
+
if (minor < 24) return { value: minor, next: at };
|
|
125
|
+
if (minor === 24) return { value: byte(bytes, at), next: at + 1 };
|
|
126
|
+
if (minor === 25) return { value: (byte(bytes, at) << 8) | byte(bytes, at + 1), next: at + 2 };
|
|
127
|
+
if (minor === 26) {
|
|
128
|
+
const held =
|
|
129
|
+
byte(bytes, at) * 0x1000000 +
|
|
130
|
+
(byte(bytes, at + 1) << 16) +
|
|
131
|
+
(byte(bytes, at + 2) << 8) +
|
|
132
|
+
byte(bytes, at + 3);
|
|
133
|
+
return { value: held, next: at + 4 };
|
|
134
|
+
}
|
|
135
|
+
// 27 is a 64-bit argument and 31 is an indefinite length. Neither appears in
|
|
136
|
+
// what an authenticator sends, and a length past 2^32 is not something this
|
|
137
|
+
// process would then go on to allocate.
|
|
138
|
+
throw new CborError(`length form ${String(minor)} is not read here`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function byte(bytes: Uint8Array, at: number): number {
|
|
142
|
+
const held = bytes[at];
|
|
143
|
+
if (held === undefined) throw new CborError("the value runs past the end");
|
|
144
|
+
return held;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One entry of a decoded map, refusing when the value is not what was wanted. */
|
|
148
|
+
export function mapEntry(value: CborValue, key: number | string): CborValue | undefined {
|
|
149
|
+
return value instanceof Map ? value.get(key) : undefined;
|
|
150
|
+
}
|
package/src/auth/http.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
type AuthAssertArgs,
|
|
4
|
+
type AuthRegisterArgs,
|
|
5
|
+
type ErrorCode,
|
|
6
|
+
type InstanceId,
|
|
7
|
+
OP_SCHEMAS,
|
|
8
|
+
type OpName,
|
|
9
|
+
validationErrors,
|
|
10
|
+
} from "@ccmsg/protocol";
|
|
11
|
+
import { OpError } from "../dispatch/index.ts";
|
|
12
|
+
import type { Auth, MintedSession } from "./auth.ts";
|
|
13
|
+
|
|
14
|
+
/** The four ops the contract carries over HTTP, and the path each is at.
|
|
15
|
+
*
|
|
16
|
+
* They are `needs_hello: false` ops like `hello` is, run before there is an
|
|
17
|
+
* identity to check, and the carrier is what makes them reachable from a page
|
|
18
|
+
* that has no connection yet — a `request_id` is synthesized here because
|
|
19
|
+
* there is no envelope to carry one (DR-0001 §2.9). */
|
|
20
|
+
const ROUTES = ["challenge", "register", "assert", "refresh"] as const;
|
|
21
|
+
type Route = (typeof ROUTES)[number];
|
|
22
|
+
|
|
23
|
+
/** Which op each route carries. The route is a name a proxy can see; the op is
|
|
24
|
+
* what the attribute table and the schemas are keyed by. */
|
|
25
|
+
const OP_OF: Record<Route, OpName> = {
|
|
26
|
+
challenge: "auth_challenge",
|
|
27
|
+
register: "auth_register",
|
|
28
|
+
assert: "auth_assert",
|
|
29
|
+
refresh: "auth_refresh_token",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Cap on an `/auth/*` body. Everything these take is a handful of base64url
|
|
33
|
+
* fields; an attestation object is a few hundred bytes. */
|
|
34
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
35
|
+
|
|
36
|
+
/** Which of the routes a path is, matched at the end rather than whole.
|
|
37
|
+
*
|
|
38
|
+
* The prefix is not read, for the reason the WebSocket's own entry is not:
|
|
39
|
+
* a proxy may put the instance under a prefix of its own, and which instance
|
|
40
|
+
* was meant is settled by the address it forwarded to (DR-0001 §2.7). */
|
|
41
|
+
export function authRouteOf(pathname: string): Route | undefined {
|
|
42
|
+
const marker = "/auth/";
|
|
43
|
+
const at = pathname.lastIndexOf(marker);
|
|
44
|
+
if (at === -1) return undefined;
|
|
45
|
+
const name = pathname.slice(at + marker.length);
|
|
46
|
+
return (ROUTES as readonly string[]).includes(name) ? (name as Route) : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The cookie path for a request: everything up to and including its `/auth/`.
|
|
50
|
+
*
|
|
51
|
+
* It narrows what the browser sends where, and nothing more — same-origin
|
|
52
|
+
* script can fetch any path, so this is not an authorization boundary (§2.4).
|
|
53
|
+
* What it is for is that two endpoints behind one origin (`/` and `/personal`)
|
|
54
|
+
* get cookies of their own. */
|
|
55
|
+
export function cookiePath(pathname: string): string {
|
|
56
|
+
const at = pathname.lastIndexOf("/auth/");
|
|
57
|
+
return at === -1 ? "/" : pathname.slice(0, at + "/auth/".length);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What a person's refresh cookie is called on this instance.
|
|
61
|
+
*
|
|
62
|
+
* Named after the instance and the subject so that two instances behind one
|
|
63
|
+
* origin, and two people at one browser, do not overwrite each other's. The
|
|
64
|
+
* digest is what keeps the id and the subject out of a header a page can
|
|
65
|
+
* read. */
|
|
66
|
+
export function cookieName(instance: InstanceId, sub: string): string {
|
|
67
|
+
const digest = createHash("sha256").update(`${instance}\n${sub}`).digest("hex").slice(0, 16);
|
|
68
|
+
return `__Secure-ccmsg-${digest}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The value of one cookie in a request, or nothing. */
|
|
72
|
+
export function cookieValue(header: string | null, name: string): string | undefined {
|
|
73
|
+
if (header === null) return undefined;
|
|
74
|
+
for (const part of header.split(";")) {
|
|
75
|
+
const at = part.indexOf("=");
|
|
76
|
+
if (at === -1) continue;
|
|
77
|
+
if (part.slice(0, at).trim() !== name) continue;
|
|
78
|
+
return part.slice(at + 1).trim();
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AuthRoutesDeps {
|
|
84
|
+
readonly auth: Auth;
|
|
85
|
+
readonly self: InstanceId;
|
|
86
|
+
readonly origins: () => readonly string[];
|
|
87
|
+
readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Serve `/auth/*`, or answer nothing when the request is for something else.
|
|
91
|
+
*
|
|
92
|
+
* Everything unauthenticated shares one rate limit and one origin check: these
|
|
93
|
+
* routes are reachable before anything is proven, and the work behind them is a
|
|
94
|
+
* signature verification (§2.4). */
|
|
95
|
+
export async function handleAuth(
|
|
96
|
+
request: Request,
|
|
97
|
+
deps: AuthRoutesDeps,
|
|
98
|
+
from: { ip?: string } = {},
|
|
99
|
+
): Promise<Response | undefined> {
|
|
100
|
+
const url = new URL(request.url);
|
|
101
|
+
const route = authRouteOf(url.pathname);
|
|
102
|
+
if (route === undefined) return undefined;
|
|
103
|
+
const origin = request.headers.get("origin");
|
|
104
|
+
const allowed = deps.origins();
|
|
105
|
+
// A page from an origin this instance does not serve is refused before
|
|
106
|
+
// anything else, including the preflight that would tell it to try.
|
|
107
|
+
if (origin !== null && !allowed.includes(origin)) {
|
|
108
|
+
return new Response("Forbidden", { status: 403 });
|
|
109
|
+
}
|
|
110
|
+
// These routes change state and are reachable before anything is proven, so
|
|
111
|
+
// an `Origin` is required rather than merely compared (DR-0001 §2.4): a
|
|
112
|
+
// request carrying none is not a page this instance serves, and admitting it
|
|
113
|
+
// would leave the comparison above optional to whoever is making the call.
|
|
114
|
+
if (origin === null && request.method !== "OPTIONS") {
|
|
115
|
+
return new Response("Forbidden", { status: 403 });
|
|
116
|
+
}
|
|
117
|
+
const cors: Record<string, string> =
|
|
118
|
+
origin === null
|
|
119
|
+
? {}
|
|
120
|
+
: {
|
|
121
|
+
"access-control-allow-origin": origin,
|
|
122
|
+
"access-control-allow-credentials": "true",
|
|
123
|
+
vary: "Origin",
|
|
124
|
+
};
|
|
125
|
+
if (request.method === "OPTIONS") {
|
|
126
|
+
return new Response(null, {
|
|
127
|
+
status: 204,
|
|
128
|
+
headers: {
|
|
129
|
+
...cors,
|
|
130
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
131
|
+
"access-control-allow-headers": "content-type",
|
|
132
|
+
"access-control-max-age": "600",
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (request.method !== "POST") {
|
|
137
|
+
return new Response("Method Not Allowed", { status: 405, headers: cors });
|
|
138
|
+
}
|
|
139
|
+
if (!deps.auth.allowRequest()) {
|
|
140
|
+
return new Response("too many requests", { status: 429, headers: cors });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let args: Record<string, unknown>;
|
|
144
|
+
try {
|
|
145
|
+
args = await body(request);
|
|
146
|
+
} catch (cause) {
|
|
147
|
+
return refusal("bad_request", String(cause), cors);
|
|
148
|
+
}
|
|
149
|
+
// The op's own schema, run before anything reads a field. The carrier
|
|
150
|
+
// synthesizes the envelope the contract asks for — there is no connection to
|
|
151
|
+
// carry one — so what is validated is the same frame dispatch would have
|
|
152
|
+
// validated, and a body missing a field is a refusal rather than a fault
|
|
153
|
+
// inside a handler.
|
|
154
|
+
const op = OP_OF[route];
|
|
155
|
+
// The body first, so a caller cannot name the op or the correlation id by
|
|
156
|
+
// putting either in it: what the carrier decided is what stands.
|
|
157
|
+
const frame = { ...args, op, request_id: `http-${crypto.randomUUID()}` };
|
|
158
|
+
const problems = validationErrors(OP_SCHEMAS[op].request, frame);
|
|
159
|
+
if (problems.length > 0) return refusal("invalid_args", problems.join("; "), cors);
|
|
160
|
+
|
|
161
|
+
const userAgent = request.headers.get("user-agent") ?? undefined;
|
|
162
|
+
const seen = {
|
|
163
|
+
...(from.ip === undefined ? {} : { ip: from.ip }),
|
|
164
|
+
...(userAgent === undefined ? {} : { userAgent }),
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
switch (route) {
|
|
168
|
+
case "challenge":
|
|
169
|
+
return answer(deps.auth.challenge(), cors);
|
|
170
|
+
case "register": {
|
|
171
|
+
const minted = await deps.auth.register(args as unknown as AuthRegisterArgs, seen);
|
|
172
|
+
return answer(minted.session, cors, setCookie(deps, url.pathname, minted));
|
|
173
|
+
}
|
|
174
|
+
case "assert": {
|
|
175
|
+
const minted = await deps.auth.assert(args as unknown as AuthAssertArgs, seen);
|
|
176
|
+
return answer(minted.session, cors, setCookie(deps, url.pathname, minted));
|
|
177
|
+
}
|
|
178
|
+
case "refresh": {
|
|
179
|
+
const held = refreshCookie(request, deps);
|
|
180
|
+
if (held === undefined) {
|
|
181
|
+
return refusal("auth_invalid", "この要求には refresh token がありません", cors);
|
|
182
|
+
}
|
|
183
|
+
const minted = await deps.auth.refreshToken(held);
|
|
184
|
+
return answer(minted.session, cors, setCookie(deps, url.pathname, minted));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (cause) {
|
|
188
|
+
if (cause instanceof OpError) return refusal(cause.code, cause.message, cors);
|
|
189
|
+
deps.log?.("an auth route failed", { route, error: String(cause) });
|
|
190
|
+
return refusal("internal_error", "この要求は処理できませんでした", cors);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The refresh token this request carries.
|
|
195
|
+
*
|
|
196
|
+
* Every cookie whose name has this instance's prefix is tried, because the name
|
|
197
|
+
* carries a digest of the subject and the caller has not said who they are yet.
|
|
198
|
+
* A browser holding two people's cookies presents both. */
|
|
199
|
+
function refreshCookie(request: Request, deps: AuthRoutesDeps): string | undefined {
|
|
200
|
+
const header = request.headers.get("cookie");
|
|
201
|
+
if (header === null) return undefined;
|
|
202
|
+
for (const part of header.split(";")) {
|
|
203
|
+
const at = part.indexOf("=");
|
|
204
|
+
if (at === -1) continue;
|
|
205
|
+
const name = part.slice(0, at).trim();
|
|
206
|
+
if (!name.startsWith("__Secure-ccmsg-")) continue;
|
|
207
|
+
const value = part.slice(at + 1).trim();
|
|
208
|
+
if (deps.auth.records.byRefresh(value) !== undefined) return value;
|
|
209
|
+
}
|
|
210
|
+
// None of them is a standing token. The first one is answered with anyway, so
|
|
211
|
+
// that a value rotated away is seen as the reuse it is rather than as an
|
|
212
|
+
// absent cookie.
|
|
213
|
+
for (const part of header.split(";")) {
|
|
214
|
+
const at = part.indexOf("=");
|
|
215
|
+
if (at !== -1 && part.slice(0, at).trim().startsWith("__Secure-ccmsg-")) {
|
|
216
|
+
return part.slice(at + 1).trim();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The `Set-Cookie` for what the op just minted.
|
|
223
|
+
*
|
|
224
|
+
* The refresh token is answered by the op to its caller rather than left in a
|
|
225
|
+
* slot on the domain, so two exchanges in flight cannot hand one caller the
|
|
226
|
+
* other's token. */
|
|
227
|
+
function setCookie(
|
|
228
|
+
deps: AuthRoutesDeps,
|
|
229
|
+
pathname: string,
|
|
230
|
+
minted: MintedSession,
|
|
231
|
+
): Record<string, string> {
|
|
232
|
+
const name = cookieName(deps.self, minted.session.sub);
|
|
233
|
+
const maxAge = Math.max(0, Math.floor((minted.refresh.expires_at - Date.now()) / 1000));
|
|
234
|
+
return {
|
|
235
|
+
"set-cookie": [
|
|
236
|
+
`${name}=${minted.refresh.value}`,
|
|
237
|
+
"HttpOnly",
|
|
238
|
+
"Secure",
|
|
239
|
+
"SameSite=Strict",
|
|
240
|
+
`Path=${cookiePath(pathname)}`,
|
|
241
|
+
`Max-Age=${String(maxAge)}`,
|
|
242
|
+
].join("; "),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function answer(
|
|
247
|
+
result: unknown,
|
|
248
|
+
cors: Record<string, string>,
|
|
249
|
+
extra: Record<string, string> = {},
|
|
250
|
+
): Response {
|
|
251
|
+
return Response.json(result, { headers: { ...cors, ...extra } });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A refusal in the contract's own error shape, so a page reads one thing
|
|
255
|
+
* whether the op travelled over HTTP or over the WebSocket. */
|
|
256
|
+
function refusal(code: ErrorCode | "bad_request", msg: string, cors: Record<string, string>) {
|
|
257
|
+
// A malformed request is the caller's to fix and an authentication failure is
|
|
258
|
+
// not, so the two do not share a status: 401 says "these credentials were not
|
|
259
|
+
// accepted", which is the wrong thing to tell somebody who left a field out.
|
|
260
|
+
const status =
|
|
261
|
+
code === "bad_request" || code === "invalid_args" ? 400 : code === "internal_error" ? 500 : 401;
|
|
262
|
+
return Response.json({ ok: false, error: { code, msg } }, { status, headers: cors });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function body(request: Request): Promise<Record<string, unknown>> {
|
|
266
|
+
const declared = Number(request.headers.get("content-length"));
|
|
267
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
268
|
+
throw new Error(`the body is over ${String(MAX_BODY_BYTES)} bytes`);
|
|
269
|
+
}
|
|
270
|
+
const text = await request.text();
|
|
271
|
+
if (text.length > MAX_BODY_BYTES) {
|
|
272
|
+
throw new Error(`the body is over ${String(MAX_BODY_BYTES)} bytes`);
|
|
273
|
+
}
|
|
274
|
+
if (text === "") return {};
|
|
275
|
+
const parsed: unknown = JSON.parse(text);
|
|
276
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
277
|
+
throw new Error("the body is a JSON object");
|
|
278
|
+
}
|
|
279
|
+
return parsed as Record<string, unknown>;
|
|
280
|
+
}
|