@kedge/server 0.0.0-stage → 0.10.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/LICENSE +21 -0
- package/package.json +32 -3
- package/src/access.ts +477 -0
- package/src/admin-routes.ts +813 -0
- package/src/admin-shell.ts +72 -0
- package/src/app.ts +798 -0
- package/src/auth-routes.ts +381 -0
- package/src/capacity-routes.ts +440 -0
- package/src/entity-routes.ts +327 -0
- package/src/events.ts +84 -0
- package/src/http.ts +16 -0
- package/src/index.ts +2 -0
- package/src/meta-routes.ts +277 -0
- package/src/plugin-routes.ts +40 -0
- package/src/relay-routes.ts +192 -0
- package/src/track-routes.ts +102 -0
- package/src/types.ts +841 -0
- package/src/units/store.order.view.ts +9 -0
- package/src/units/store.product.view.ts +9 -0
- package/src/webhook-routes.ts +362 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Kedge Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kedge/server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/chandeldivyam/Kedge.git",
|
|
8
|
+
"directory": "packages/server"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=24"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"description": "Hono app: /store, /admin, /health.",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"hono": "4.13.13",
|
|
23
|
+
"@hono/zod-openapi": "1.6.3",
|
|
24
|
+
"zod": "4.6.7",
|
|
25
|
+
"@kedge/core": "0.10.0",
|
|
26
|
+
"@kedge/plugin-sdk": "0.10.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@cloudflare/workers-types": "5.20261024.1"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc --noEmit -p .",
|
|
33
|
+
"test": "vitest run"
|
|
34
|
+
}
|
|
6
35
|
}
|
package/src/access.ts
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
// Who is asking, resolved before routing (p0.5-auth-and-access.md §3.3, §2.9,
|
|
2
|
+
// §6.2): a staff session (cookie or bearer) or a secret key on /admin/*, a
|
|
3
|
+
// publishable key and an optional customer session on /store/*. Nothing here
|
|
4
|
+
// caches a permission: each request reads them fresh, so a revoked grant, a
|
|
5
|
+
// signed-out session or a revoked key is refused on the next request.
|
|
6
|
+
import {
|
|
7
|
+
type AccessContext,
|
|
8
|
+
ANONYMOUS,
|
|
9
|
+
auditChanges,
|
|
10
|
+
digestToken,
|
|
11
|
+
holds,
|
|
12
|
+
isKedgeError,
|
|
13
|
+
KedgeError,
|
|
14
|
+
type StoreProof,
|
|
15
|
+
} from "@kedge/core";
|
|
16
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
17
|
+
import { routePath } from "hono/route";
|
|
18
|
+
import { adminOriginsFor, authzFingerprint, checkAdminBuild, checkView, nameAnswer } from "./admin-shell";
|
|
19
|
+
import type { AccessKey, AccessSession, AdminScope, AuthDeps, ClientInfo, Pool, ResolvedAccess } from "./types";
|
|
20
|
+
|
|
21
|
+
export const COOKIE: Record<Pool, string> = { staff: "__Host-kedge_staff", customer: "__Host-kedge_customer" };
|
|
22
|
+
export const DEVICE_COOKIE = "__Host-kedge_device";
|
|
23
|
+
|
|
24
|
+
/** What a request is, once resolved; set as `c.var.access`. */
|
|
25
|
+
export interface RequestAccess {
|
|
26
|
+
ctx: AccessContext;
|
|
27
|
+
/** How the principal's credential came: a cookie needs CSRF on mutations, a bearer or key doesn't (§2.9). */
|
|
28
|
+
via: "cookie" | "bearer" | "key" | "none";
|
|
29
|
+
session?: AccessSession;
|
|
30
|
+
key?: AccessKey;
|
|
31
|
+
resolved?: ResolvedAccess;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The store's storefront, from its publishable key (§6.2); set as `c.var.storefront`. */
|
|
35
|
+
export interface RequestStorefront {
|
|
36
|
+
keyId: string;
|
|
37
|
+
storefrontId: string;
|
|
38
|
+
origins: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type KedgeEnv = {
|
|
42
|
+
Variables: {
|
|
43
|
+
access: RequestAccess;
|
|
44
|
+
storefront: RequestStorefront;
|
|
45
|
+
proof: StoreProof;
|
|
46
|
+
/** A cart route's customer cookie, to refresh when the cart renews its session. */
|
|
47
|
+
cartCookie?: string;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const NONE: RequestAccess = { ctx: ANONYMOUS, via: "none" };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Addresses past `key_fail` on a cart route, remembered by this isolate for
|
|
55
|
+
* the limit's window, so a flood of bad keys stops reaching cart objects;
|
|
56
|
+
* the D1 counter stays the authority across isolates.
|
|
57
|
+
*/
|
|
58
|
+
const keyBlocked = new Map<string, number>();
|
|
59
|
+
const KEY_BLOCK_MS = 10 * 60_000;
|
|
60
|
+
/** The address a limit counts, grouped as the D1 counters group it (IPv6 by /64 there; whole here, which only blocks less). */
|
|
61
|
+
const addressOf = (c: Context) => c.req.header("cf-connecting-ip") ?? "unknown";
|
|
62
|
+
|
|
63
|
+
const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
64
|
+
|
|
65
|
+
/** The client address limits count: `cf-connecting-ip`, never `x-forwarded-for` (§2.8). */
|
|
66
|
+
export function clientOf(c: Context): ClientInfo {
|
|
67
|
+
return { ip: c.req.header("cf-connecting-ip") ?? null, userAgent: c.req.header("user-agent")?.slice(0, 256) ?? null };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every value of one cookie. A request carrying two session cookies for a
|
|
72
|
+
* pool is refused, never guessed between (§2.1): a sibling host can plant a
|
|
73
|
+
* `Domain` cookie beside the `__Host-` one, and choosing would let it win.
|
|
74
|
+
*/
|
|
75
|
+
export function cookieValues(header: string | undefined, name: string): string[] {
|
|
76
|
+
if (!header) return [];
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
for (const part of header.split(";")) {
|
|
79
|
+
const i = part.indexOf("=");
|
|
80
|
+
if (i < 0) continue;
|
|
81
|
+
if (part.slice(0, i).trim() === name) out.push(part.slice(i + 1).trim());
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function bearerOf(c: Context): string | null {
|
|
87
|
+
const h = c.req.header("authorization");
|
|
88
|
+
if (!h) return null;
|
|
89
|
+
if (!h.startsWith("Bearer ")) throw new KedgeError("UNAUTHORIZED", "Missing or invalid credentials");
|
|
90
|
+
return h.slice(7).trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Constant-time comparison of two presented tokens. */
|
|
94
|
+
function same(a: string, b: string): boolean {
|
|
95
|
+
if (a.length !== b.length) return false;
|
|
96
|
+
let diff = 0;
|
|
97
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
98
|
+
return diff === 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The session credential of a pool, if any. Cookie and bearer together must
|
|
103
|
+
* be the same session, and are then a cookie request (§2.9 rule 1), so a
|
|
104
|
+
* bearer header can't exempt a cookie request from CSRF.
|
|
105
|
+
*/
|
|
106
|
+
export function sessionCredential(c: Context, pool: Pool): { token: string; via: "cookie" | "bearer" } | null {
|
|
107
|
+
const cookies = cookieValues(c.req.header("cookie"), COOKIE[pool]);
|
|
108
|
+
if (cookies.length > 1) throw new KedgeError("UNAUTHORIZED", "More than one session cookie");
|
|
109
|
+
const cookie = cookies[0] || null;
|
|
110
|
+
const bearer = bearerOf(c);
|
|
111
|
+
if (cookie && bearer && !same(cookie, bearer))
|
|
112
|
+
throw new KedgeError("UNAUTHORIZED", "The cookie and the bearer token name different sessions");
|
|
113
|
+
if (cookie) return { token: cookie, via: "cookie" };
|
|
114
|
+
if (bearer) return { token: bearer, via: "bearer" };
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Sets or clears a pool's session cookie: `__Host-`, so no `Domain` (§2.1). */
|
|
119
|
+
export function sessionCookie(pool: Pool, token: string | null, expiresAt: number | null, now: number): string {
|
|
120
|
+
const same = pool === "staff" ? "Strict" : "Lax";
|
|
121
|
+
const age = token && expiresAt ? Math.max(0, Math.floor((expiresAt - now) / 1000)) : 0;
|
|
122
|
+
return `${COOKIE[pool]}=${token ?? ""}; Path=/; Secure; HttpOnly; SameSite=${same}; Max-Age=${age}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function deviceCookie(token: string, maxAgeSeconds: number): string {
|
|
126
|
+
return `${DEVICE_COOKIE}=${token}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolves a session of `pool` and does its maintenance when due: staff
|
|
131
|
+
* activity, or a customer's renewal, whose new expiry refreshes the cookie.
|
|
132
|
+
*/
|
|
133
|
+
export async function resolveSession(
|
|
134
|
+
c: Context,
|
|
135
|
+
auth: AuthDeps,
|
|
136
|
+
pool: Pool,
|
|
137
|
+
cred: { token: string; via: "cookie" | "bearer" },
|
|
138
|
+
now: number,
|
|
139
|
+
): Promise<RequestAccess> {
|
|
140
|
+
const found = await auth.access.session(pool, cred.token, now);
|
|
141
|
+
if (!found?.session) throw new KedgeError("UNAUTHORIZED", "Your session has ended; sign in again");
|
|
142
|
+
if (auth.access.maintenanceDue(found.session, now)) {
|
|
143
|
+
const renewed = await auth.access.maintain(found.session, now);
|
|
144
|
+
if (renewed !== null && cred.via === "cookie")
|
|
145
|
+
c.header("set-cookie", sessionCookie(pool, cred.token, renewed, now), { append: true });
|
|
146
|
+
}
|
|
147
|
+
return { ctx: found.ctx, via: cred.via, session: found.session, resolved: found };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Verifies an API key; a failure counts against the address (§4.3). */
|
|
151
|
+
export async function resolveKey(c: Context, auth: AuthDeps, presented: string, now: number) {
|
|
152
|
+
const found = await auth.access.apiKey(presented, now);
|
|
153
|
+
if (!found?.key) {
|
|
154
|
+
const within = await auth.access.keyFailure(clientOf(c).ip, now);
|
|
155
|
+
if (!within) throw new KedgeError("RATE_LIMITED", "Too many failed key attempts; wait a little");
|
|
156
|
+
throw new KedgeError("KEY_INVALID", "Missing or invalid API key");
|
|
157
|
+
}
|
|
158
|
+
if (auth.access.keyTouchDue(found.key, now)) await auth.access.touchKey(found.key.id, now);
|
|
159
|
+
return found;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The CSRF rule (§2.9) for a cookie request: JSON where the route takes a
|
|
164
|
+
* body, `X-Kedge-CSRF` (the literal `1` before a session exists, the
|
|
165
|
+
* session's token after) and an `Origin` on the pool's allow-list, whatever
|
|
166
|
+
* `Sec-Fetch-Site` says. The custom header forces a preflight that the
|
|
167
|
+
* origin list refuses, so an unlisted page can't post with the cookie.
|
|
168
|
+
*/
|
|
169
|
+
export async function checkCsrf(
|
|
170
|
+
c: Context,
|
|
171
|
+
auth: AuthDeps,
|
|
172
|
+
origins: readonly string[],
|
|
173
|
+
opts: { sessionToken: string | null; json: boolean },
|
|
174
|
+
now: number,
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
const origin = c.req.header("origin");
|
|
177
|
+
if (!origin || !origins.includes(origin))
|
|
178
|
+
throw new KedgeError("CSRF_REJECTED", "This origin may not send this request");
|
|
179
|
+
if (opts.json && !/^application\/json\b/i.test(c.req.header("content-type") ?? ""))
|
|
180
|
+
throw new KedgeError("CSRF_REJECTED", "Send Content-Type: application/json");
|
|
181
|
+
const header = c.req.header("x-kedge-csrf");
|
|
182
|
+
if (!header) throw new KedgeError("CSRF_REJECTED", "Missing X-Kedge-CSRF");
|
|
183
|
+
if (opts.sessionToken === null) {
|
|
184
|
+
if (header !== "1") throw new KedgeError("CSRF_REJECTED", "X-Kedge-CSRF must be 1 before a session exists");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const v = await auth.access.csrf(opts.sessionToken, header, now);
|
|
188
|
+
if (v === "expired") throw new KedgeError("CSRF_EXPIRED", "The CSRF token expired; fetch the session and retry");
|
|
189
|
+
if (v !== "ok") throw new KedgeError("CSRF_REJECTED", "Invalid X-Kedge-CSRF");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const isSafe = (method: string) => SAFE.has(method);
|
|
193
|
+
|
|
194
|
+
// ---- every admin route declares its permission (§3.3) ------------------------------
|
|
195
|
+
|
|
196
|
+
export interface Declared {
|
|
197
|
+
permission: string;
|
|
198
|
+
/** The audit action of a mutation (defaults to the method and path). */
|
|
199
|
+
action?: string;
|
|
200
|
+
/** A read sent as POST (a search whose values may not go in a URL, §5.2): CSRF still applies, the batch scope doesn't. */
|
|
201
|
+
read?: boolean;
|
|
202
|
+
/**
|
|
203
|
+
* The route's store keeps its own command envelope (capacity commands, the
|
|
204
|
+
* admin store, creations): the scope records no key for it. Every other
|
|
205
|
+
* mutation's Idempotency-Key is recorded by the scope (§8, codex code r8).
|
|
206
|
+
*/
|
|
207
|
+
own?: boolean;
|
|
208
|
+
/**
|
|
209
|
+
* The route compiles its caller's row scopes into its query (the resource
|
|
210
|
+
* registry: orders). Any other read needs its permission unscoped, as a
|
|
211
|
+
* mutation does (codex code r9): a location-scoped capacity reader must not
|
|
212
|
+
* list every location's pools.
|
|
213
|
+
*/
|
|
214
|
+
scoped?: boolean;
|
|
215
|
+
/**
|
|
216
|
+
* The route's store authorizes inside its own audited batch (p0.7 §3.4,
|
|
217
|
+
* §4.5): the permission depends on the request (a track's transition), and
|
|
218
|
+
* a row-scoped grant is compiled into the batch and re-proved there by the
|
|
219
|
+
* scoped guard. The gate authenticates and checks CSRF only; `permission`
|
|
220
|
+
* names the route's family for the docs, and is never checked here.
|
|
221
|
+
*/
|
|
222
|
+
store?: boolean;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The declared permission of each `/admin/*` route. A route that isn't here
|
|
227
|
+
* is never reachable (404), and `verify` refuses to boot an app that
|
|
228
|
+
* registered one (§3.3), so a forgotten declaration fails loudly, not open.
|
|
229
|
+
*/
|
|
230
|
+
export class AdminPermissions {
|
|
231
|
+
private readonly map = new Map<string, Declared>();
|
|
232
|
+
|
|
233
|
+
/** Declares `route`'s permission; returns it, so a definition reads `app.openapi(admin.declare(p, createRoute(…)), h)`. */
|
|
234
|
+
declare<R extends { method: string; path: string }>(permission: string | Declared, route: R): R {
|
|
235
|
+
const d = typeof permission === "string" ? { permission } : permission;
|
|
236
|
+
this.map.set(key(route.method, honoPath(route.path)), d);
|
|
237
|
+
return route;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The permission of the route a request matched. */
|
|
241
|
+
of(c: Context): Declared | undefined {
|
|
242
|
+
return this.map.get(key(c.req.method, routePath(c, -1)));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Every registered `/admin/*` endpoint has a declaration (boot). */
|
|
246
|
+
verify(routes: readonly { method: string; path: string }[]): void {
|
|
247
|
+
const missing = routes
|
|
248
|
+
.filter((r) => r.path.startsWith("/admin/") && r.method !== "ALL")
|
|
249
|
+
.filter((r) => !this.map.has(key(r.method, r.path)))
|
|
250
|
+
.map((r) => `${r.method} ${r.path}`);
|
|
251
|
+
// The OpenAPI document is the one public /admin path.
|
|
252
|
+
const undeclared = missing.filter((m) => m !== "GET /admin/openapi.json");
|
|
253
|
+
// kedge-allow no-dynamic-throw: route paths, checked at boot
|
|
254
|
+
if (undeclared.length > 0) throw new Error(`admin routes without a permission: ${undeclared.join(", ")}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
entries(): [string, Declared][] {
|
|
258
|
+
return [...this.map.entries()];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const key = (method: string, path: string) => `${method.toUpperCase()} ${path}`;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A mutation's command identity for the scope's envelope (§8): the route, its
|
|
266
|
+
* path parameters (the targets) and its body. None without an Idempotency-Key.
|
|
267
|
+
*/
|
|
268
|
+
function commandOf(
|
|
269
|
+
c: Context,
|
|
270
|
+
route: string,
|
|
271
|
+
targets: readonly string[],
|
|
272
|
+
input: unknown,
|
|
273
|
+
): { command?: AdminScope["command"] } {
|
|
274
|
+
const k = c.req.header("idempotency-key");
|
|
275
|
+
if (k === undefined) return {};
|
|
276
|
+
if (k.length === 0 || new TextEncoder().encode(k).length > 255)
|
|
277
|
+
throw new KedgeError("BAD_REQUEST", "An Idempotency-Key is 1 to 255 bytes");
|
|
278
|
+
return { command: { key: k, operation: route, targets, input } };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The final route's path parameters in template order, from its template: in a
|
|
283
|
+
* middleware Hono's `param()` answers for the middleware's own route, which has none.
|
|
284
|
+
*/
|
|
285
|
+
function targetsOf(c: Context): string[] {
|
|
286
|
+
const template = routePath(c, -1).split("/");
|
|
287
|
+
const actual = c.req.path.split("/");
|
|
288
|
+
return template.flatMap((seg, i) => (seg.startsWith(":") ? [decodeURIComponent(actual[i] ?? "")] : []));
|
|
289
|
+
}
|
|
290
|
+
/** OpenAPI's `{id}` is Hono's `:id`. */
|
|
291
|
+
const honoPath = (path: string) => path.replace(/\{([^}]+)\}/g, ":$1");
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The /admin/* gate: a staff session or a secret key, CSRF for cookie
|
|
295
|
+
* mutations, the route's declared permission, and for a mutation the batch
|
|
296
|
+
* scope that audits and re-proves it in every batch (§7.1).
|
|
297
|
+
*/
|
|
298
|
+
export function adminGate(auth: AuthDeps, perms: AdminPermissions, now: () => number): MiddlewareHandler<KedgeEnv> {
|
|
299
|
+
return async (c, next) => {
|
|
300
|
+
if (c.req.method === "OPTIONS") return next();
|
|
301
|
+
if (c.req.path === "/admin/openapi.json") return next();
|
|
302
|
+
// A stale admin is refused before anything, reads included (p0.9 §2.4).
|
|
303
|
+
checkAdminBuild(c, auth.adminBuild ?? null);
|
|
304
|
+
const t = now();
|
|
305
|
+
const cred = sessionCredential(c, "staff");
|
|
306
|
+
let access: RequestAccess;
|
|
307
|
+
if (cred?.via === "bearer" && cred.token.startsWith("sk_")) {
|
|
308
|
+
const found = await resolveKey(c, auth, cred.token, t);
|
|
309
|
+
access = { ctx: found.ctx, via: "key", key: found.key, resolved: found };
|
|
310
|
+
} else if (cred?.via === "bearer" && cred.token.startsWith("kcs_")) {
|
|
311
|
+
// A CLI session (p0.10 §6.2): admitted here, on /admin/* only, never as a staff session.
|
|
312
|
+
const found = await auth.access.cliSession(cred.token, t);
|
|
313
|
+
if (!found) throw new KedgeError("UNAUTHORIZED", "This CLI session has ended; run the command again");
|
|
314
|
+
access = { ctx: found.ctx, via: "bearer", resolved: found };
|
|
315
|
+
} else if (cred) {
|
|
316
|
+
access = await resolveSession(c, auth, "staff", cred, t);
|
|
317
|
+
} else {
|
|
318
|
+
throw new KedgeError("UNAUTHORIZED", "Sign in first");
|
|
319
|
+
}
|
|
320
|
+
// Every staff answer, errors included, names the session and the authority that answered (p0.9 §9).
|
|
321
|
+
const fingerprint = await authzFingerprint(access.ctx, access.session?.method ?? null, auth.accessContract ?? "");
|
|
322
|
+
if (access.session) nameAnswer(c, access.session.id, fingerprint);
|
|
323
|
+
const declared = perms.of(c);
|
|
324
|
+
if (!declared) throw new KedgeError("NOT_FOUND", "Not found");
|
|
325
|
+
const mutation = !isSafe(c.req.method) && declared.read !== true;
|
|
326
|
+
if (!isSafe(c.req.method) && access.via === "cookie")
|
|
327
|
+
await checkCsrf(c, auth, adminOriginsFor(c, auth), { sessionToken: cred?.token ?? null, json: false }, t);
|
|
328
|
+
// A mutation made from a view this request no longer has runs nothing (p0.9 §6.3).
|
|
329
|
+
if (mutation) checkView(c, fingerprint);
|
|
330
|
+
// An invite's enrolment session may only enrol its first factor (§3.4).
|
|
331
|
+
if (access.session?.method.startsWith("invite:"))
|
|
332
|
+
throw new KedgeError("STRONG_AUTH_REQUIRED", "Enrol your authenticator first");
|
|
333
|
+
if (declared.store) {
|
|
334
|
+
c.set("access", access);
|
|
335
|
+
return next();
|
|
336
|
+
}
|
|
337
|
+
if (!holds(access.ctx.grants, declared.permission))
|
|
338
|
+
throw new KedgeError("FORBIDDEN", "You don't have permission for this", { permission: declared.permission });
|
|
339
|
+
c.set("access", access);
|
|
340
|
+
if (!mutation && !declared.scoped && access.ctx.grants.get(declared.permission)?.has("all") !== true)
|
|
341
|
+
throw new KedgeError("FORBIDDEN", "This read needs the permission for every row", {
|
|
342
|
+
permission: declared.permission,
|
|
343
|
+
});
|
|
344
|
+
if (!mutation) return next();
|
|
345
|
+
// A mutation needs its permission unscoped: P0.5 compiles no row-scoped
|
|
346
|
+
// write (§5.3 arrives with assignable resources), so a scoped holder is
|
|
347
|
+
// refused here and the guard re-proves `all` at commit.
|
|
348
|
+
if (access.ctx.grants.get(declared.permission)?.has("all") !== true)
|
|
349
|
+
throw new KedgeError("FORBIDDEN", "This change needs the permission for every row", {
|
|
350
|
+
permission: declared.permission,
|
|
351
|
+
});
|
|
352
|
+
const client = clientOf(c);
|
|
353
|
+
const route = `${c.req.method} ${routePath(c, -1)}`;
|
|
354
|
+
const target = targetsOf(c);
|
|
355
|
+
const input = await c.req.json().catch(() => null);
|
|
356
|
+
const scope: AdminScope = {
|
|
357
|
+
access: access.resolved as ResolvedAccess,
|
|
358
|
+
required: [declared.permission],
|
|
359
|
+
action: declared.action ?? route,
|
|
360
|
+
ip: client.ip,
|
|
361
|
+
userAgent: client.userAgent,
|
|
362
|
+
// The row names its target and the input, metadata only (§7.2, codex code r10).
|
|
363
|
+
entity: { type: c.req.path.split("/")[2] ?? "admin", id: target[0] ?? null },
|
|
364
|
+
changes: auditChanges(input),
|
|
365
|
+
...(declared.own ? {} : commandOf(c, route, target, input)),
|
|
366
|
+
};
|
|
367
|
+
const { replayed } = await auth.scoped.run(scope, async () => {
|
|
368
|
+
await next();
|
|
369
|
+
});
|
|
370
|
+
// A retry of a recorded command answers its outcome and ids (§8); so does a
|
|
371
|
+
// send that lost the race for its key to a concurrent one.
|
|
372
|
+
const outcome = replayed ?? (c.res.status >= 400 ? await auth.scoped.replayed(scope) : null);
|
|
373
|
+
if (outcome) c.res = c.json({ replayed: true, outcome: outcome.outcome, ids: outcome.ids }, 200);
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The /store/* gate (§6.2): a publishable key in `x-kedge-key`, whose
|
|
379
|
+
* storefront lists the request's `Origin` when one is sent (a same-origin
|
|
380
|
+
* GET or a server client sends none, and is judged by the key alone), and
|
|
381
|
+
* the customer's session if one is presented. A cookie mutation needs CSRF.
|
|
382
|
+
*/
|
|
383
|
+
export function storeGate(auth: AuthDeps, now: () => number): MiddlewareHandler<KedgeEnv> {
|
|
384
|
+
return async (c, next) => {
|
|
385
|
+
if (c.req.method === "OPTIONS") return next();
|
|
386
|
+
const t = now();
|
|
387
|
+
if (/^\/store\/carts(\/|$)/.test(c.req.path)) {
|
|
388
|
+
const address = addressOf(c);
|
|
389
|
+
if ((keyBlocked.get(address) ?? 0) > t)
|
|
390
|
+
throw new KedgeError("RATE_LIMITED", "Too many failed key attempts; wait a little");
|
|
391
|
+
// A failed key, refused here (malformed) or by the cart's batch, is counted
|
|
392
|
+
// against the address like any other failed verification (§4.3, codex code
|
|
393
|
+
// r7); a success costs nothing.
|
|
394
|
+
const failed = async (): Promise<boolean> => {
|
|
395
|
+
if (await auth.access.keyFailure(clientOf(c).ip, t)) return false;
|
|
396
|
+
keyBlocked.set(address, t + KEY_BLOCK_MS);
|
|
397
|
+
return true;
|
|
398
|
+
};
|
|
399
|
+
try {
|
|
400
|
+
c.set("proof", await cartProof(c, auth, t));
|
|
401
|
+
} catch (e) {
|
|
402
|
+
if (isKedgeError(e) && e.code === "KEY_INVALID" && (await failed()))
|
|
403
|
+
throw new KedgeError("RATE_LIMITED", "Too many failed key attempts; wait a little");
|
|
404
|
+
throw e;
|
|
405
|
+
}
|
|
406
|
+
const cred = sessionCredential(c, "customer");
|
|
407
|
+
if (cred?.via === "cookie") c.set("cartCookie", cred.token);
|
|
408
|
+
c.set("access", NONE);
|
|
409
|
+
await next();
|
|
410
|
+
if (c.res.status === 401) {
|
|
411
|
+
const body = (await c.res
|
|
412
|
+
.clone()
|
|
413
|
+
.json()
|
|
414
|
+
.catch(() => null)) as { error?: { code?: string } } | null;
|
|
415
|
+
if (body?.error?.code === "KEY_INVALID" && (await failed()))
|
|
416
|
+
c.res = c.json(new KedgeError("RATE_LIMITED", "Too many failed key attempts; wait a little").toJSON(), 429);
|
|
417
|
+
}
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
c.set("storefront", await storefrontOf(c, auth, t));
|
|
421
|
+
const cred = sessionCredential(c, "customer");
|
|
422
|
+
if (!cred) {
|
|
423
|
+
c.set("access", NONE);
|
|
424
|
+
return next();
|
|
425
|
+
}
|
|
426
|
+
const access = await resolveSession(c, auth, "customer", cred, t);
|
|
427
|
+
if (!isSafe(c.req.method) && access.via === "cookie")
|
|
428
|
+
await checkCsrf(c, auth, c.var.storefront.origins, { sessionToken: cred.token, json: false }, t);
|
|
429
|
+
c.set("access", access);
|
|
430
|
+
return next();
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* A cart route's credentials, unread (§6.2): the cart object re-proves the
|
|
436
|
+
* key and the customer inside the batch it sends anyway, so checkout stays a
|
|
437
|
+
* claim and a commit. CSRF still comes first, here: the token binds the
|
|
438
|
+
* session token's digest, and a cookie mutation must carry an `Origin`,
|
|
439
|
+
* which the cart's batch then requires its storefront to list.
|
|
440
|
+
*/
|
|
441
|
+
async function cartProof(c: Context, auth: AuthDeps, now: number): Promise<StoreProof> {
|
|
442
|
+
const presented = c.req.header("x-kedge-key");
|
|
443
|
+
if (!presented) throw new KedgeError("UNAUTHORIZED", "Missing publishable key (x-kedge-key)");
|
|
444
|
+
const key = await auth.access.keyProof(presented);
|
|
445
|
+
if (!key) throw new KedgeError("KEY_INVALID", "x-kedge-key takes a publishable key");
|
|
446
|
+
const origin = c.req.header("origin") ?? null;
|
|
447
|
+
const cred = sessionCredential(c, "customer");
|
|
448
|
+
if (cred?.via === "cookie" && !isSafe(c.req.method))
|
|
449
|
+
await checkCsrf(c, auth, origin ? [origin] : [], { sessionToken: cred.token, json: false }, now);
|
|
450
|
+
if (cred && !cred.token.startsWith("kcu_"))
|
|
451
|
+
throw new KedgeError("UNAUTHORIZED", "Your session has ended; sign in again");
|
|
452
|
+
// A key is at most 255 bytes, refused before any mutation (§8, Spree's bound).
|
|
453
|
+
const idem = c.req.header("idempotency-key") ?? null;
|
|
454
|
+
if (idem !== null && (idem.length === 0 || new TextEncoder().encode(idem).length > 255))
|
|
455
|
+
throw new KedgeError("BAD_REQUEST", "An Idempotency-Key is 1 to 255 bytes");
|
|
456
|
+
return {
|
|
457
|
+
key: { ...key, origin },
|
|
458
|
+
customer: cred ? { tokenDigest: await digestToken(cred.token) } : null,
|
|
459
|
+
...(idem !== null && !isSafe(c.req.method) ? { idempotencyKey: idem } : {}),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The publishable key's storefront, refusing an `Origin` it doesn't list. */
|
|
464
|
+
export async function storefrontOf(c: Context, auth: AuthDeps, now: number): Promise<RequestStorefront> {
|
|
465
|
+
const presented = c.req.header("x-kedge-key");
|
|
466
|
+
if (!presented) throw new KedgeError("UNAUTHORIZED", "Missing publishable key (x-kedge-key)");
|
|
467
|
+
if (!presented.startsWith("pk_")) throw new KedgeError("KEY_INVALID", "x-kedge-key takes a publishable key");
|
|
468
|
+
const found = await resolveKey(c, auth, presented, now);
|
|
469
|
+
const key = found.key as AccessKey;
|
|
470
|
+
if (key.type !== "publishable" || !key.storefrontId)
|
|
471
|
+
throw new KedgeError("KEY_INVALID", "x-kedge-key takes a publishable key");
|
|
472
|
+
const origin = c.req.header("origin");
|
|
473
|
+
if (origin && !key.origins.includes(origin))
|
|
474
|
+
// The same answer a cart route's batch gives (§6.2): the key isn't valid from this origin.
|
|
475
|
+
throw new KedgeError("KEY_INVALID", "This key's storefront doesn't allow this origin");
|
|
476
|
+
return { keyId: key.id, storefrontId: key.storefrontId, origins: key.origins };
|
|
477
|
+
}
|