@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
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// The credential ceremonies over HTTP (p0.5-auth-and-access.md §2): staff on
|
|
2
|
+
// /auth/admin/*, customers on /auth/store/*. Every route here is in §2.8's
|
|
3
|
+
// rate table; anything else under /auth answers 404. A session-creating
|
|
4
|
+
// ceremony sets the pool's HttpOnly cookie, or, called with
|
|
5
|
+
// `X-Kedge-Session-Mode: bearer`, returns the token in its body and sets
|
|
6
|
+
// nothing (§2.9), so a page's scripts never read a cookie session.
|
|
7
|
+
import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
|
|
8
|
+
import { isKedgeError, KedgeError } from "@kedge/core";
|
|
9
|
+
import type { Context } from "hono";
|
|
10
|
+
import {
|
|
11
|
+
checkCsrf,
|
|
12
|
+
clientOf,
|
|
13
|
+
cookieValues,
|
|
14
|
+
DEVICE_COOKIE,
|
|
15
|
+
deviceCookie,
|
|
16
|
+
type KedgeEnv,
|
|
17
|
+
type RequestAccess,
|
|
18
|
+
resolveSession,
|
|
19
|
+
sessionCookie,
|
|
20
|
+
sessionCredential,
|
|
21
|
+
storefrontOf,
|
|
22
|
+
} from "./access";
|
|
23
|
+
import { adminOriginsFor, authzFingerprint, checkView, nameAnswer } from "./admin-shell";
|
|
24
|
+
import { jsonBody, ok } from "./http";
|
|
25
|
+
import type { AuthDeps, IssuedSession, Pool } from "./types";
|
|
26
|
+
|
|
27
|
+
/** The known-device cookie lives as long as its row (§2.8). */
|
|
28
|
+
const DEVICE_MAX_AGE_S = 90 * 86_400;
|
|
29
|
+
|
|
30
|
+
const iso = (ms: number | null) => (ms === null ? null : new Date(ms).toISOString());
|
|
31
|
+
const Email = z.string().min(3).max(320);
|
|
32
|
+
const Password = z.string().min(1).max(1024);
|
|
33
|
+
const Token = z.string().min(16).max(256);
|
|
34
|
+
|
|
35
|
+
/** Every /auth route, for the test that diffs the router against §2.8's table. */
|
|
36
|
+
export const AUTH_ROUTES = [
|
|
37
|
+
"POST /auth/admin/sign-in",
|
|
38
|
+
"POST /auth/admin/totp/challenge",
|
|
39
|
+
"POST /auth/admin/totp/enrol",
|
|
40
|
+
"POST /auth/admin/totp/confirm",
|
|
41
|
+
"POST /auth/admin/password",
|
|
42
|
+
"POST /auth/admin/sign-out",
|
|
43
|
+
"POST /auth/admin/sign-out-everywhere",
|
|
44
|
+
"GET /auth/admin/session",
|
|
45
|
+
"POST /auth/admin/invites/accept",
|
|
46
|
+
"POST /auth/admin/invites/resume",
|
|
47
|
+
"POST /auth/store/code",
|
|
48
|
+
"POST /auth/store/verify",
|
|
49
|
+
"POST /auth/store/sign-out",
|
|
50
|
+
"POST /auth/store/sign-out-everywhere",
|
|
51
|
+
"GET /auth/store/session",
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
export function authRoutes(app: OpenAPIHono<KedgeEnv>, auth: AuthDeps, now: () => number): void {
|
|
55
|
+
const { ceremonies } = auth;
|
|
56
|
+
|
|
57
|
+
/** The pool's allow-list: `ADMIN_ORIGINS` and the Worker's own origin (p0.9 §4.1), or the publishable key's storefront's. */
|
|
58
|
+
const originsOf = async (c: Context, pool: Pool, t: number) =>
|
|
59
|
+
pool === "staff" ? adminOriginsFor(c, auth) : (await storefrontOf(c, auth, t)).origins;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A session-creating ceremony has no session yet: JSON, `X-Kedge-CSRF: 1`
|
|
63
|
+
* and an allowed `Origin`, always, even in bearer mode (§2.9 rule 3), so a
|
|
64
|
+
* foreign page can't sign someone in to an account it controls.
|
|
65
|
+
*/
|
|
66
|
+
const creating = async (c: Context, pool: Pool, t: number) => {
|
|
67
|
+
c.header("cache-control", "no-store");
|
|
68
|
+
await checkCsrf(c, auth, await originsOf(c, pool, t), { sessionToken: null, json: true }, t);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** A session-bound ceremony: the caller's session; CSRF unless it came as a bearer token. */
|
|
72
|
+
const bound = async (c: Context, pool: Pool, t: number): Promise<RequestAccess & { token: string }> => {
|
|
73
|
+
c.header("cache-control", "no-store");
|
|
74
|
+
const cred = sessionCredential(c, pool);
|
|
75
|
+
if (!cred) throw new KedgeError("UNAUTHORIZED", "Sign in first");
|
|
76
|
+
const origins = await originsOf(c, pool, t);
|
|
77
|
+
const access = await resolveSession(c, auth, pool, cred, t);
|
|
78
|
+
// A staff answer names its session, errors included, so a form's `401` with it is the form's error (p0.9 §4.3).
|
|
79
|
+
const fingerprint =
|
|
80
|
+
pool === "staff" && access.session
|
|
81
|
+
? await authzFingerprint(access.ctx, access.session.method, auth.accessContract ?? "")
|
|
82
|
+
: null;
|
|
83
|
+
if (fingerprint && access.session) nameAnswer(c, access.session.id, fingerprint);
|
|
84
|
+
if (cred.via === "cookie" && c.req.method !== "GET")
|
|
85
|
+
await checkCsrf(c, auth, origins, { sessionToken: cred.token, json: true }, t);
|
|
86
|
+
// The view a form was built under, as the admin gate checks it (p0.9 §6.3): a password
|
|
87
|
+
// change sent from another member's view is refused before it runs (codex code r10).
|
|
88
|
+
if (fingerprint && c.req.method !== "GET") checkView(c, fingerprint);
|
|
89
|
+
return { ...access, token: cred.token };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** The ceremony's session to the client: a cookie, or the token in the body in bearer mode. */
|
|
93
|
+
const issue = (c: Context, s: IssuedSession, t: number) => {
|
|
94
|
+
const body = { session: { id: s.id, pool: s.pool, method: s.method, expires_at: iso(s.expiresAt) } };
|
|
95
|
+
if (c.req.header("x-kedge-session-mode") === "bearer") return { ...body, session_token: s.token };
|
|
96
|
+
c.header("set-cookie", sessionCookie(s.pool, s.token, s.expiresAt, t), { append: true });
|
|
97
|
+
return { ...body, csrf_token: s.csrfToken };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** The known-device token: a cookie, or in the body for a bearer client (§2.8). */
|
|
101
|
+
const device = (c: Context, token: string | null) => {
|
|
102
|
+
if (!token) return {};
|
|
103
|
+
if (c.req.header("x-kedge-session-mode") === "bearer") return { device_token: token };
|
|
104
|
+
c.header("set-cookie", deviceCookie(token, DEVICE_MAX_AGE_S), { append: true });
|
|
105
|
+
return {};
|
|
106
|
+
};
|
|
107
|
+
const presentedDevice = (c: Context, body: { device_token?: string | undefined }) => {
|
|
108
|
+
const cookies = cookieValues(c.req.header("cookie"), DEVICE_COOKIE);
|
|
109
|
+
return body.device_token ?? (cookies.length === 1 ? (cookies[0] as string) : null);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const signedOut = (c: Context, pool: Pool, t: number) => {
|
|
113
|
+
c.header("set-cookie", sessionCookie(pool, null, null, t), { append: true });
|
|
114
|
+
return c.json({ signed_out: true }, 200);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// ---- staff ----------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
app.openapi(
|
|
120
|
+
createRoute({
|
|
121
|
+
method: "post",
|
|
122
|
+
path: "/auth/admin/sign-in",
|
|
123
|
+
request: jsonBody(z.object({ email: Email, password: Password, device_token: Token.optional() })),
|
|
124
|
+
responses: ok("A session, or a TOTP challenge"),
|
|
125
|
+
}),
|
|
126
|
+
async (c) => {
|
|
127
|
+
const t = now();
|
|
128
|
+
await creating(c, "staff", t);
|
|
129
|
+
const b = c.req.valid("json");
|
|
130
|
+
const r = await ceremonies.staffSignIn(
|
|
131
|
+
{ email: b.email, password: b.password, deviceToken: presentedDevice(c, b) },
|
|
132
|
+
clientOf(c),
|
|
133
|
+
t,
|
|
134
|
+
);
|
|
135
|
+
if (r.kind === "challenge")
|
|
136
|
+
return c.json({ challenge: { token: r.challengeToken, expires_at: iso(r.expiresAt) } }, 200);
|
|
137
|
+
return c.json({ ...issue(c, r.session, t), ...device(c, r.deviceToken) }, 200);
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
app.openapi(
|
|
142
|
+
createRoute({
|
|
143
|
+
method: "post",
|
|
144
|
+
path: "/auth/admin/totp/challenge",
|
|
145
|
+
request: jsonBody(
|
|
146
|
+
z.object({ challenge_token: Token, code: z.string().regex(/^[0-9]{6}$/), device_token: Token.optional() }),
|
|
147
|
+
),
|
|
148
|
+
responses: ok("A password+totp session"),
|
|
149
|
+
}),
|
|
150
|
+
async (c) => {
|
|
151
|
+
const t = now();
|
|
152
|
+
await creating(c, "staff", t);
|
|
153
|
+
const b = c.req.valid("json");
|
|
154
|
+
const r = await ceremonies.staffTotpChallenge(
|
|
155
|
+
{ challengeToken: b.challenge_token, code: b.code, deviceToken: presentedDevice(c, b) },
|
|
156
|
+
clientOf(c),
|
|
157
|
+
t,
|
|
158
|
+
);
|
|
159
|
+
return c.json({ ...issue(c, r.session, t), ...device(c, r.deviceToken) }, 200);
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
app.openapi(
|
|
164
|
+
createRoute({
|
|
165
|
+
method: "post",
|
|
166
|
+
path: "/auth/admin/totp/enrol",
|
|
167
|
+
responses: ok("The otpauth URI, shown once, and the account's email"),
|
|
168
|
+
}),
|
|
169
|
+
async (c) => {
|
|
170
|
+
const t = now();
|
|
171
|
+
const a = await bound(c, "staff", t);
|
|
172
|
+
const staffId = a.session?.principalId as string;
|
|
173
|
+
const email = await ceremonies.staffEmail(staffId);
|
|
174
|
+
const r = await ceremonies.totpEnrolStart(
|
|
175
|
+
{ sessionId: a.session?.id as string, principalId: staffId },
|
|
176
|
+
email ?? staffId,
|
|
177
|
+
t,
|
|
178
|
+
);
|
|
179
|
+
// The invite page signs in after enrolment with this, not with an address the member typed:
|
|
180
|
+
// the account is the invite's email, and a different one only ever fails to sign in.
|
|
181
|
+
return c.json({ otpauth_uri: r.uri, email, expires_at: iso(r.expiresAt) }, 200);
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
app.openapi(
|
|
186
|
+
createRoute({
|
|
187
|
+
method: "post",
|
|
188
|
+
path: "/auth/admin/totp/confirm",
|
|
189
|
+
request: jsonBody(z.object({ code: z.string().regex(/^[0-9]{6}$/) })),
|
|
190
|
+
responses: ok("Enrolled; every other session ended"),
|
|
191
|
+
}),
|
|
192
|
+
async (c) => {
|
|
193
|
+
const t = now();
|
|
194
|
+
const a = await bound(c, "staff", t);
|
|
195
|
+
await ceremonies.totpEnrolConfirm(
|
|
196
|
+
{ sessionId: a.session?.id as string, principalId: a.session?.principalId as string },
|
|
197
|
+
c.req.valid("json").code,
|
|
198
|
+
clientOf(c),
|
|
199
|
+
t,
|
|
200
|
+
);
|
|
201
|
+
return c.json({ enrolled: true }, 200);
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
app.openapi(
|
|
206
|
+
createRoute({
|
|
207
|
+
method: "post",
|
|
208
|
+
path: "/auth/admin/password",
|
|
209
|
+
request: jsonBody(z.object({ current_password: Password, new_password: Password })),
|
|
210
|
+
responses: ok("Changed; every other session ended"),
|
|
211
|
+
}),
|
|
212
|
+
async (c) => {
|
|
213
|
+
const t = now();
|
|
214
|
+
const a = await bound(c, "staff", t);
|
|
215
|
+
const b = c.req.valid("json");
|
|
216
|
+
await ceremonies.staffChangePassword(
|
|
217
|
+
{ sessionId: a.session?.id as string, principalId: a.session?.principalId as string },
|
|
218
|
+
b.current_password,
|
|
219
|
+
b.new_password,
|
|
220
|
+
clientOf(c),
|
|
221
|
+
t,
|
|
222
|
+
);
|
|
223
|
+
return c.json({ changed: true }, 200);
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
app.openapi(
|
|
228
|
+
createRoute({
|
|
229
|
+
method: "post",
|
|
230
|
+
path: "/auth/admin/invites/accept",
|
|
231
|
+
request: jsonBody(z.object({ token: Token, password: Password, resume_nonce: z.string().min(32).max(128) })),
|
|
232
|
+
responses: ok("An enrolment session"),
|
|
233
|
+
}),
|
|
234
|
+
async (c) => {
|
|
235
|
+
const t = now();
|
|
236
|
+
await creating(c, "staff", t);
|
|
237
|
+
const b = c.req.valid("json");
|
|
238
|
+
const s = await ceremonies.acceptInvite(
|
|
239
|
+
{ token: b.token, password: b.password, resumeNonce: b.resume_nonce },
|
|
240
|
+
clientOf(c),
|
|
241
|
+
t,
|
|
242
|
+
);
|
|
243
|
+
return c.json(issue(c, s, t), 200);
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
app.openapi(
|
|
248
|
+
createRoute({
|
|
249
|
+
method: "post",
|
|
250
|
+
path: "/auth/admin/invites/resume",
|
|
251
|
+
request: jsonBody(z.object({ token: Token, password: Password, resume_nonce: z.string().min(32).max(128) })),
|
|
252
|
+
responses: ok("The enrolment session, replaced"),
|
|
253
|
+
}),
|
|
254
|
+
async (c) => {
|
|
255
|
+
const t = now();
|
|
256
|
+
await creating(c, "staff", t);
|
|
257
|
+
const b = c.req.valid("json");
|
|
258
|
+
const s = await ceremonies.resumeInvite(
|
|
259
|
+
{ token: b.token, password: b.password, resumeNonce: b.resume_nonce },
|
|
260
|
+
clientOf(c),
|
|
261
|
+
t,
|
|
262
|
+
);
|
|
263
|
+
return c.json(issue(c, s, t), 200);
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// ---- customers ------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
app.openapi(
|
|
270
|
+
createRoute({
|
|
271
|
+
method: "post",
|
|
272
|
+
path: "/auth/store/code",
|
|
273
|
+
request: jsonBody(z.object({ email: Email })),
|
|
274
|
+
responses: ok("Sent, if the address can receive it"),
|
|
275
|
+
}),
|
|
276
|
+
async (c) => {
|
|
277
|
+
const t = now();
|
|
278
|
+
await creating(c, "customer", t);
|
|
279
|
+
await ceremonies.requestCode(c.req.valid("json").email, clientOf(c), t);
|
|
280
|
+
return c.json({ sent: true }, 200);
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
app.openapi(
|
|
285
|
+
createRoute({
|
|
286
|
+
method: "post",
|
|
287
|
+
path: "/auth/store/verify",
|
|
288
|
+
request: jsonBody(z.object({ email: Email, code: z.string().max(16) })),
|
|
289
|
+
responses: ok("A customer session"),
|
|
290
|
+
}),
|
|
291
|
+
async (c) => {
|
|
292
|
+
const t = now();
|
|
293
|
+
await creating(c, "customer", t);
|
|
294
|
+
const b = c.req.valid("json");
|
|
295
|
+
return c.json(issue(c, await ceremonies.verifyCode(b.email, b.code, clientOf(c), t), t), 200);
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// ---- both pools: sign-out and the session read ------------------------------------
|
|
300
|
+
|
|
301
|
+
for (const [pool, base] of [
|
|
302
|
+
["staff", "/auth/admin"],
|
|
303
|
+
["customer", "/auth/store"],
|
|
304
|
+
] as const) {
|
|
305
|
+
const limited = async (policy: "session" | "session_read", c: Context, sessionId: string | null, t: number) => {
|
|
306
|
+
const subjects = sessionId ? { session: sessionId } : { address: clientOf(c).ip ?? "unknown" };
|
|
307
|
+
if (!(await auth.access.limit(policy, pool, subjects, t)))
|
|
308
|
+
throw new KedgeError("RATE_LIMITED", "Too many requests; wait a little");
|
|
309
|
+
};
|
|
310
|
+
/**
|
|
311
|
+
* The caller's session; a credential that resolves to none (forged,
|
|
312
|
+
* expired, signed out) is counted against the address first (codex code
|
|
313
|
+
* r11), so trying tokens costs what a request without one does.
|
|
314
|
+
*/
|
|
315
|
+
const counted = async (c: Context, t: number) => {
|
|
316
|
+
try {
|
|
317
|
+
return await bound(c, pool, t);
|
|
318
|
+
} catch (e) {
|
|
319
|
+
if (isKedgeError(e) && e.code === "UNAUTHORIZED") await limited("session_read", c, null, t);
|
|
320
|
+
throw e;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
app.openapi(createRoute({ method: "post", path: `${base}/sign-out`, responses: ok("Signed out") }), async (c) => {
|
|
325
|
+
const t = now();
|
|
326
|
+
const a = await counted(c, t);
|
|
327
|
+
await limited("session", c, a.session?.id ?? null, t);
|
|
328
|
+
await ceremonies.signOut(pool, a.token, t, { principalId: a.session?.principalId as string, ip: clientOf(c).ip });
|
|
329
|
+
return signedOut(c, pool, t);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
app.openapi(
|
|
333
|
+
createRoute({ method: "post", path: `${base}/sign-out-everywhere`, responses: ok("Every session ended") }),
|
|
334
|
+
async (c) => {
|
|
335
|
+
const t = now();
|
|
336
|
+
const a = await counted(c, t);
|
|
337
|
+
await limited("session", c, a.session?.id ?? null, t);
|
|
338
|
+
await ceremonies.signOutEverywhere(
|
|
339
|
+
{ sessionId: a.session?.id as string, principalId: a.session?.principalId as string },
|
|
340
|
+
pool,
|
|
341
|
+
t,
|
|
342
|
+
clientOf(c).ip,
|
|
343
|
+
);
|
|
344
|
+
return signedOut(c, pool, t);
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
app.openapi(
|
|
349
|
+
createRoute({ method: "get", path: `${base}/session`, responses: ok("The session and its CSRF token") }),
|
|
350
|
+
async (c) => {
|
|
351
|
+
const t = now();
|
|
352
|
+
c.header("cache-control", "no-store");
|
|
353
|
+
const cred = sessionCredential(c, pool);
|
|
354
|
+
if (!cred) {
|
|
355
|
+
await limited("session_read", c, null, t);
|
|
356
|
+
throw new KedgeError("UNAUTHORIZED", "Sign in first");
|
|
357
|
+
}
|
|
358
|
+
const a = await counted(c, t);
|
|
359
|
+
const s = a.session as NonNullable<RequestAccess["session"]>;
|
|
360
|
+
await limited("session_read", c, s.id, t);
|
|
361
|
+
const csrf = a.via === "cookie" ? await ceremonies.refreshCsrf(a.token, t) : null;
|
|
362
|
+
return c.json(
|
|
363
|
+
{
|
|
364
|
+
session: {
|
|
365
|
+
id: s.id,
|
|
366
|
+
pool: s.pool,
|
|
367
|
+
principal_id: s.principalId,
|
|
368
|
+
method: s.method,
|
|
369
|
+
expires_at: iso(s.expiresAt),
|
|
370
|
+
enrol_until: iso(s.enrolUntil),
|
|
371
|
+
// After a lost confirm the `invite:*` session stays; this says sign-in comes next (p0.9 §4.2).
|
|
372
|
+
...(pool === "staff" ? { factor_enrolled: await ceremonies.staffFactorEnrolled(s.principalId) } : {}),
|
|
373
|
+
},
|
|
374
|
+
...(csrf ? { csrf_token: csrf.csrfToken, csrf_expires_at: iso(csrf.expiresAt) } : {}),
|
|
375
|
+
},
|
|
376
|
+
200,
|
|
377
|
+
);
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|