emdash 0.5.0 → 0.7.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/dist/{adapters-C2BzVy0p.d.mts → adapters-Di31kZ28.d.mts} +16 -1
- package/dist/adapters-Di31kZ28.d.mts.map +1 -0
- package/dist/{apply-Cma_PiF6.mjs → apply-5uslYdUu.mjs} +197 -25
- package/dist/apply-5uslYdUu.mjs.map +1 -0
- package/dist/astro/index.d.mts +6 -6
- package/dist/astro/index.d.mts.map +1 -1
- package/dist/astro/index.mjs +203 -33
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +5 -5
- package/dist/astro/middleware/auth.d.mts.map +1 -1
- package/dist/astro/middleware/auth.mjs +30 -4
- package/dist/astro/middleware/auth.mjs.map +1 -1
- package/dist/astro/middleware/redirect.mjs +2 -2
- package/dist/astro/middleware/request-context.d.mts.map +1 -1
- package/dist/astro/middleware/request-context.mjs +11 -4
- package/dist/astro/middleware/request-context.mjs.map +1 -1
- package/dist/astro/middleware/setup.mjs +1 -1
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +467 -186
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +17 -9
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-WuOq9MFJ.mjs → byline-C4OVd8b3.mjs} +3 -19
- package/dist/byline-C4OVd8b3.mjs.map +1 -0
- package/dist/{bylines-C_Wsnz4L.mjs → bylines-hPTW79hw.mjs} +20 -33
- package/dist/bylines-hPTW79hw.mjs.map +1 -0
- package/dist/{cache-E3Dts-yT.mjs → cache-BkKBuIvS.mjs} +1 -1
- package/dist/{cache-E3Dts-yT.mjs.map → cache-BkKBuIvS.mjs.map} +1 -1
- package/dist/chunks-HGz06Soa.mjs +19 -0
- package/dist/chunks-HGz06Soa.mjs.map +1 -0
- package/dist/cli/index.mjs +12 -11
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/cf-access.d.mts +1 -1
- package/dist/client/index.d.mts +1 -1
- package/dist/client/index.mjs +1 -1
- package/dist/{config-DkxPrM9l.mjs → config-BXwuX8Bx.mjs} +1 -1
- package/dist/{config-DkxPrM9l.mjs.map → config-BXwuX8Bx.mjs.map} +1 -1
- package/dist/{connection-B4zVnQIa.mjs → connection-2igzM-AT.mjs} +19 -2
- package/dist/connection-2igzM-AT.mjs.map +1 -0
- package/dist/{content-BsBoyj8G.mjs → content-D7J5y73J.mjs} +27 -1
- package/dist/{content-BsBoyj8G.mjs.map → content-D7J5y73J.mjs.map} +1 -1
- package/dist/database/instrumentation.d.mts +45 -0
- package/dist/database/instrumentation.d.mts.map +1 -0
- package/dist/database/instrumentation.mjs +61 -0
- package/dist/database/instrumentation.mjs.map +1 -0
- package/dist/db/index.d.mts +3 -3
- package/dist/db/index.mjs +1 -1
- package/dist/db/index.mjs.map +1 -1
- package/dist/db/libsql.d.mts +1 -1
- package/dist/db/postgres.d.mts +1 -1
- package/dist/db/sqlite.d.mts +1 -1
- package/dist/db-errors-D0UT85nC.mjs +41 -0
- package/dist/db-errors-D0UT85nC.mjs.map +1 -0
- package/dist/{default-PUx9RK6u.mjs → default-CME5YdZ3.mjs} +1 -1
- package/dist/{default-PUx9RK6u.mjs.map → default-CME5YdZ3.mjs.map} +1 -1
- package/dist/{error-HBeQbVhV.mjs → error-CiYn9yDu.mjs} +1 -1
- package/dist/{error-HBeQbVhV.mjs.map → error-CiYn9yDu.mjs.map} +1 -1
- package/dist/{index-CCWzlriB.d.mts → index-De6_Xv3v.d.mts} +209 -19
- package/dist/index-De6_Xv3v.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +23 -21
- package/dist/{load-BhSSm-TS.mjs → load-CBcmDIot.mjs} +1 -1
- package/dist/{load-BhSSm-TS.mjs.map → load-CBcmDIot.mjs.map} +1 -1
- package/dist/{loader-BYzwzORf.mjs → loader-DeiBJEMe.mjs} +18 -12
- package/dist/loader-DeiBJEMe.mjs.map +1 -0
- package/dist/{manifest-schema-BsXINkQD.mjs → manifest-schema-V30qsMft.mjs} +1 -1
- package/dist/{manifest-schema-BsXINkQD.mjs.map → manifest-schema-V30qsMft.mjs.map} +1 -1
- package/dist/media/index.d.mts +1 -1
- package/dist/media/index.mjs +1 -1
- package/dist/media/local-runtime.d.mts +7 -7
- package/dist/{mode-CyPLdO3C.mjs → mode-CpNnGkPz.mjs} +1 -1
- package/dist/{mode-CyPLdO3C.mjs.map → mode-CpNnGkPz.mjs.map} +1 -1
- package/dist/page/index.d.mts +11 -2
- package/dist/page/index.d.mts.map +1 -1
- package/dist/page/index.mjs +23 -1
- package/dist/page/index.mjs.map +1 -1
- package/dist/{placeholder-DntBEQo7.mjs → placeholder-C-fk5hYI.mjs} +1 -1
- package/dist/{placeholder-DntBEQo7.mjs.map → placeholder-C-fk5hYI.mjs.map} +1 -1
- package/dist/{placeholder-BBCtpTES.d.mts → placeholder-tzpqGWII.d.mts} +1 -1
- package/dist/{placeholder-BBCtpTES.d.mts.map → placeholder-tzpqGWII.d.mts.map} +1 -1
- package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
- package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
- package/dist/{query-B6Vu0d2i.mjs → query-g4Ug-9j9.mjs} +79 -12
- package/dist/query-g4Ug-9j9.mjs.map +1 -0
- package/dist/{redirect-7lGhLBNZ.mjs → redirect-CN0Rt9Ob.mjs} +66 -10
- package/dist/redirect-CN0Rt9Ob.mjs.map +1 -0
- package/dist/{registry-BgnP3ysR.mjs → registry-Ci3WxVAr.mjs} +133 -97
- package/dist/registry-Ci3WxVAr.mjs.map +1 -0
- package/dist/request-cache-DiR961CV.mjs +79 -0
- package/dist/request-cache-DiR961CV.mjs.map +1 -0
- package/dist/request-context.d.mts +19 -16
- package/dist/request-context.d.mts.map +1 -1
- package/dist/request-context.mjs.map +1 -1
- package/dist/{runner-DYv3rX8P.d.mts → runner-BR2xKwhn.d.mts} +2 -2
- package/dist/{runner-DYv3rX8P.d.mts.map → runner-BR2xKwhn.d.mts.map} +1 -1
- package/dist/{runner-Cd-_WyDo.mjs → runner-tQ7BJ4T7.mjs} +211 -134
- package/dist/runner-tQ7BJ4T7.mjs.map +1 -0
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +1 -1
- package/dist/{search-Cn1SYvYF.mjs → search-B0effn3j.mjs} +210 -226
- package/dist/search-B0effn3j.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +10 -9
- package/dist/seo/index.d.mts +1 -1
- package/dist/storage/local.d.mts +1 -1
- package/dist/storage/local.mjs +1 -1
- package/dist/storage/s3.d.mts +1 -1
- package/dist/storage/s3.mjs +1 -1
- package/dist/taxonomies-K2z0Uhnj.mjs +308 -0
- package/dist/taxonomies-K2z0Uhnj.mjs.map +1 -0
- package/dist/{tokens-DKHiCYCB.mjs → tokens-BFPFx3CA.mjs} +1 -1
- package/dist/{tokens-DKHiCYCB.mjs.map → tokens-BFPFx3CA.mjs.map} +1 -1
- package/dist/{transport-BtcQ-Z7T.mjs → transport-BykRfpyy.mjs} +1 -1
- package/dist/{transport-BtcQ-Z7T.mjs.map → transport-BykRfpyy.mjs.map} +1 -1
- package/dist/{transport-CKQA_G44.d.mts → transport-H4Iwx7tC.d.mts} +1 -1
- package/dist/{transport-CKQA_G44.d.mts.map → transport-H4Iwx7tC.d.mts.map} +1 -1
- package/dist/{types-BmkQR1En.d.mts → types-6CUZRrZP.d.mts} +1 -1
- package/dist/{types-BmkQR1En.d.mts.map → types-6CUZRrZP.d.mts.map} +1 -1
- package/dist/{types-Dz9_WMS6.mjs → types-BH2L167P.mjs} +1 -1
- package/dist/{types-Dz9_WMS6.mjs.map → types-BH2L167P.mjs.map} +1 -1
- package/dist/{types-B6BzlZxx.d.mts → types-C2v0c34j.d.mts} +10 -1
- package/dist/{types-B6BzlZxx.d.mts.map → types-C2v0c34j.d.mts.map} +1 -1
- package/dist/{types-DNZpaCBk.d.mts → types-CFWjXmus.d.mts} +1 -1
- package/dist/{types-DNZpaCBk.d.mts.map → types-CFWjXmus.d.mts.map} +1 -1
- package/dist/{types-DeG21anB.d.mts → types-CnZYHyLW.d.mts} +55 -5
- package/dist/types-CnZYHyLW.d.mts.map +1 -0
- package/dist/{types-xxCWI3j0.mjs → types-DDS4MxsT.mjs} +11 -3
- package/dist/types-DDS4MxsT.mjs.map +1 -0
- package/dist/{types-C3ronwXb.d.mts → types-DgrIP0tF.d.mts} +102 -4
- package/dist/types-DgrIP0tF.d.mts.map +1 -0
- package/dist/{validate-DuZDIxfy.mjs → validate-CqsNItbt.mjs} +2 -2
- package/dist/{validate-DuZDIxfy.mjs.map → validate-CqsNItbt.mjs.map} +1 -1
- package/dist/{validate-Db1yNL3i.d.mts → validate-kM8Pjuf7.d.mts} +5 -52
- package/dist/validate-kM8Pjuf7.d.mts.map +1 -0
- package/dist/version-BnTKdfam.mjs +7 -0
- package/dist/{version-CMMjTuqu.mjs.map → version-BnTKdfam.mjs.map} +1 -1
- package/package.json +10 -5
- package/src/after.ts +62 -0
- package/src/api/handlers/content.ts +2 -0
- package/src/api/handlers/oauth-authorization.ts +2 -32
- package/src/api/handlers/oauth-clients.ts +40 -4
- package/src/api/handlers/taxonomies.ts +13 -0
- package/src/api/oauth/redirect-uri.ts +34 -0
- package/src/api/openapi/document.ts +126 -118
- package/src/api/schemas/content.ts +8 -0
- package/src/api/schemas/media.ts +26 -15
- package/src/api/schemas/schema.ts +1 -0
- package/src/astro/integration/font-provider.ts +178 -0
- package/src/astro/integration/index.ts +44 -0
- package/src/astro/integration/routes.ts +6 -0
- package/src/astro/integration/runtime.ts +117 -0
- package/src/astro/integration/virtual-modules.ts +41 -39
- package/src/astro/integration/vite-config.ts +16 -5
- package/src/astro/middleware/auth.ts +33 -1
- package/src/astro/middleware/request-context.ts +15 -3
- package/src/astro/middleware.ts +340 -263
- package/src/astro/routes/admin.astro +21 -10
- package/src/astro/routes/api/auth/magic-link/send.ts +2 -1
- package/src/astro/routes/api/auth/passkey/options.ts +2 -1
- package/src/astro/routes/api/auth/passkey/verify.ts +5 -1
- package/src/astro/routes/api/auth/signup/request.ts +26 -8
- package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +10 -6
- package/src/astro/routes/api/content/[collection]/[id]/compare.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +5 -0
- package/src/astro/routes/api/content/[collection]/[id]/translations.ts +26 -0
- package/src/astro/routes/api/content/[collection]/[id].ts +30 -2
- package/src/astro/routes/api/content/[collection]/index.ts +19 -1
- package/src/astro/routes/api/content/[collection]/trash.ts +1 -1
- package/src/astro/routes/api/import/wordpress/execute.ts +1 -1
- package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +4 -3
- package/src/astro/routes/api/import/wordpress-plugin/execute.ts +5 -4
- package/src/astro/routes/api/manifest.ts +7 -0
- package/src/astro/routes/api/media/upload-url.ts +10 -2
- package/src/astro/routes/api/media.ts +10 -7
- package/src/astro/routes/api/oauth/device/code.ts +2 -1
- package/src/astro/routes/api/oauth/device/token.ts +2 -1
- package/src/astro/routes/api/oauth/register.ts +178 -0
- package/src/astro/routes/api/oauth/token.ts +15 -0
- package/src/astro/routes/api/openapi.json.ts +15 -5
- package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +2 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +1 -0
- package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +1 -0
- package/src/astro/routes/api/search/index.ts +5 -0
- package/src/astro/routes/api/search/suggest.ts +3 -0
- package/src/astro/routes/api/setup/admin-verify.ts +30 -5
- package/src/astro/routes/api/setup/admin.ts +32 -8
- package/src/astro/routes/api/setup/index.ts +5 -2
- package/src/astro/routes/api/taxonomies/index.ts +1 -0
- package/src/astro/routes/api/well-known/oauth-authorization-server.ts +1 -1
- package/src/astro/types.ts +9 -0
- package/src/auth/rate-limit.ts +50 -22
- package/src/auth/setup-nonce.ts +22 -0
- package/src/auth/trusted-proxy.ts +92 -0
- package/src/bylines/index.ts +22 -45
- package/src/components/EmDashHead.astro +23 -7
- package/src/database/connection.ts +23 -1
- package/src/database/instrumentation.ts +98 -0
- package/src/database/migrations/035_bounded_404_log.ts +112 -0
- package/src/database/migrations/runner.ts +2 -0
- package/src/database/repositories/content.ts +39 -0
- package/src/database/repositories/options.ts +25 -0
- package/src/database/repositories/redirect.ts +111 -8
- package/src/database/types.ts +9 -0
- package/src/db/adapters.ts +15 -0
- package/src/emdash-runtime.ts +312 -92
- package/src/import/registry.ts +4 -3
- package/src/import/ssrf.ts +253 -12
- package/src/index.ts +6 -0
- package/src/loader.ts +19 -24
- package/src/mcp/server.ts +76 -3
- package/src/menus/index.ts +6 -3
- package/src/page/index.ts +1 -1
- package/src/page/seo-contributions.ts +36 -0
- package/src/plugins/context.ts +15 -3
- package/src/plugins/manager.ts +6 -0
- package/src/plugins/request-meta.ts +66 -15
- package/src/plugins/routes.ts +3 -1
- package/src/query.ts +104 -7
- package/src/request-cache.ts +106 -0
- package/src/request-context.ts +19 -0
- package/src/schema/query.ts +5 -2
- package/src/schema/registry.ts +243 -166
- package/src/schema/types.ts +13 -2
- package/src/schema/zod-generator.ts +4 -0
- package/src/search/fts-manager.ts +19 -5
- package/src/search/query.ts +4 -3
- package/src/seed/apply.ts +41 -1
- package/src/settings/index.ts +24 -5
- package/src/taxonomies/index.ts +324 -124
- package/src/utils/db-errors.ts +46 -0
- package/src/virtual-modules.d.ts +31 -10
- package/src/visual-editing/toolbar.ts +6 -1
- package/src/widgets/index.ts +54 -25
- package/dist/adapters-C2BzVy0p.d.mts.map +0 -1
- package/dist/apply-Cma_PiF6.mjs.map +0 -1
- package/dist/byline-WuOq9MFJ.mjs.map +0 -1
- package/dist/bylines-C_Wsnz4L.mjs.map +0 -1
- package/dist/connection-B4zVnQIa.mjs.map +0 -1
- package/dist/index-CCWzlriB.d.mts.map +0 -1
- package/dist/loader-BYzwzORf.mjs.map +0 -1
- package/dist/query-B6Vu0d2i.mjs.map +0 -1
- package/dist/redirect-7lGhLBNZ.mjs.map +0 -1
- package/dist/registry-BgnP3ysR.mjs.map +0 -1
- package/dist/runner-Cd-_WyDo.mjs.map +0 -1
- package/dist/search-Cn1SYvYF.mjs.map +0 -1
- package/dist/types-C3ronwXb.d.mts.map +0 -1
- package/dist/types-DeG21anB.d.mts.map +0 -1
- package/dist/types-xxCWI3j0.mjs.map +0 -1
- package/dist/validate-Db1yNL3i.d.mts.map +0 -1
- package/dist/version-CMMjTuqu.mjs +0 -7
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /_emdash/api/oauth/register
|
|
3
|
+
*
|
|
4
|
+
* RFC 7591 Dynamic Client Registration. Public, unauthenticated.
|
|
5
|
+
* MCP clients (e.g. Claude Code) call this to register themselves
|
|
6
|
+
* before starting the OAuth authorization flow.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { APIRoute } from "astro";
|
|
10
|
+
|
|
11
|
+
import { apiError, handleError } from "#api/error.js";
|
|
12
|
+
import { handleOAuthClientCreate } from "#api/handlers/oauth-clients.js";
|
|
13
|
+
|
|
14
|
+
export const prerender = false;
|
|
15
|
+
|
|
16
|
+
const OAUTH_REGISTRATION_HEADERS: HeadersInit = {
|
|
17
|
+
"Cache-Control": "no-store",
|
|
18
|
+
Pragma: "no-cache",
|
|
19
|
+
// RFC 7591 dynamic client registration is called cross-origin by MCP clients,
|
|
20
|
+
// CLIs, and native apps. The endpoint is anonymous and carries no ambient
|
|
21
|
+
// credentials, so CORS `*` is safe.
|
|
22
|
+
"Access-Control-Allow-Origin": "*",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const OAUTH_PREFLIGHT_HEADERS: HeadersInit = {
|
|
26
|
+
"Access-Control-Allow-Origin": "*",
|
|
27
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
28
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
29
|
+
"Access-Control-Max-Age": "86400",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const SUPPORTED_GRANT_TYPES = new Set([
|
|
33
|
+
"authorization_code",
|
|
34
|
+
"refresh_token",
|
|
35
|
+
"urn:ietf:params:oauth:grant-type:device_code",
|
|
36
|
+
]);
|
|
37
|
+
const SUPPORTED_RESPONSE_TYPES = new Set(["code"]);
|
|
38
|
+
|
|
39
|
+
function registrationError(description: string, status = 400): Response {
|
|
40
|
+
return Response.json(
|
|
41
|
+
{
|
|
42
|
+
error: "invalid_client_metadata",
|
|
43
|
+
error_description: description,
|
|
44
|
+
},
|
|
45
|
+
{ status, headers: OAUTH_REGISTRATION_HEADERS },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isStringArray(value: unknown): value is string[] {
|
|
54
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseScope(value: unknown): string[] | Response | undefined {
|
|
58
|
+
if (value === undefined) return undefined;
|
|
59
|
+
if (typeof value === "string") {
|
|
60
|
+
const scopes = value.split(" ").filter(Boolean);
|
|
61
|
+
return scopes.length > 0 ? scopes : undefined;
|
|
62
|
+
}
|
|
63
|
+
if (isStringArray(value)) {
|
|
64
|
+
const scopes = value.filter(Boolean);
|
|
65
|
+
return scopes.length > 0 ? scopes : undefined;
|
|
66
|
+
}
|
|
67
|
+
return registrationError("scope must be a string or array of strings");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseSupportedStringArray(
|
|
71
|
+
value: unknown,
|
|
72
|
+
field: string,
|
|
73
|
+
supported: ReadonlySet<string>,
|
|
74
|
+
): string[] | Response | undefined {
|
|
75
|
+
if (value === undefined) return undefined;
|
|
76
|
+
if (!isStringArray(value)) {
|
|
77
|
+
return registrationError(`${field} must be an array of strings`);
|
|
78
|
+
}
|
|
79
|
+
const invalidValue = value.find((item) => !supported.has(item));
|
|
80
|
+
if (invalidValue) {
|
|
81
|
+
return registrationError(`${field} contains unsupported value: ${invalidValue}`);
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const OPTIONS: APIRoute = () => {
|
|
87
|
+
return new Response(null, { status: 204, headers: OAUTH_PREFLIGHT_HEADERS });
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const POST: APIRoute = async ({ request, locals }) => {
|
|
91
|
+
const { emdash } = locals;
|
|
92
|
+
|
|
93
|
+
if (!emdash?.db) {
|
|
94
|
+
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
let body: unknown;
|
|
99
|
+
try {
|
|
100
|
+
body = await request.json();
|
|
101
|
+
} catch {
|
|
102
|
+
return registrationError("Request body must be valid JSON");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!isRecord(body)) {
|
|
106
|
+
return registrationError("Request body must be a JSON object");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// redirect_uris is the only required field per RFC 7591 §2
|
|
110
|
+
if (!isStringArray(body.redirect_uris) || body.redirect_uris.length === 0) {
|
|
111
|
+
return registrationError("redirect_uris must be a non-empty array of strings");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
body.token_endpoint_auth_method !== undefined &&
|
|
116
|
+
body.token_endpoint_auth_method !== "none"
|
|
117
|
+
) {
|
|
118
|
+
return registrationError("Only token_endpoint_auth_method=none is supported");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const grantTypes = parseSupportedStringArray(
|
|
122
|
+
body.grant_types,
|
|
123
|
+
"grant_types",
|
|
124
|
+
SUPPORTED_GRANT_TYPES,
|
|
125
|
+
);
|
|
126
|
+
if (grantTypes instanceof Response) {
|
|
127
|
+
return grantTypes;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const responseTypes = parseSupportedStringArray(
|
|
131
|
+
body.response_types,
|
|
132
|
+
"response_types",
|
|
133
|
+
SUPPORTED_RESPONSE_TYPES,
|
|
134
|
+
);
|
|
135
|
+
if (responseTypes instanceof Response) {
|
|
136
|
+
return responseTypes;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const scopes = parseScope(body.scope);
|
|
140
|
+
if (scopes instanceof Response) {
|
|
141
|
+
return scopes;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const clientId = crypto.randomUUID();
|
|
145
|
+
const clientName =
|
|
146
|
+
typeof body.client_name === "string" && body.client_name
|
|
147
|
+
? body.client_name
|
|
148
|
+
: `dynamic-${clientId.slice(0, 8)}`;
|
|
149
|
+
|
|
150
|
+
const result = await handleOAuthClientCreate(emdash.db, {
|
|
151
|
+
id: clientId,
|
|
152
|
+
name: clientName,
|
|
153
|
+
redirectUris: body.redirect_uris,
|
|
154
|
+
scopes,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!result.success) {
|
|
158
|
+
return registrationError(result.error.message);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// RFC 7591 §3.2.1 response
|
|
162
|
+
return Response.json(
|
|
163
|
+
{
|
|
164
|
+
client_id: result.data.id,
|
|
165
|
+
client_id_issued_at: Math.floor(new Date(result.data.createdAt).getTime() / 1000),
|
|
166
|
+
redirect_uris: result.data.redirectUris,
|
|
167
|
+
client_name: result.data.name,
|
|
168
|
+
grant_types: grantTypes ?? ["authorization_code", "refresh_token"],
|
|
169
|
+
response_types: responseTypes ?? ["code"],
|
|
170
|
+
token_endpoint_auth_method: "none",
|
|
171
|
+
scope: result.data.scopes ? result.data.scopes.join(" ") : undefined,
|
|
172
|
+
},
|
|
173
|
+
{ status: 201, headers: OAUTH_REGISTRATION_HEADERS },
|
|
174
|
+
);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return handleError(error, "Failed to register OAuth client", "CLIENT_REGISTER_ERROR");
|
|
177
|
+
}
|
|
178
|
+
};
|
|
@@ -87,6 +87,10 @@ const refreshSchema = z.object({
|
|
|
87
87
|
// Handler
|
|
88
88
|
// ---------------------------------------------------------------------------
|
|
89
89
|
|
|
90
|
+
export const OPTIONS: APIRoute = () => {
|
|
91
|
+
return new Response(null, { status: 204, headers: OAUTH_PREFLIGHT_HEADERS });
|
|
92
|
+
};
|
|
93
|
+
|
|
90
94
|
export const POST: APIRoute = async ({ request, locals }) => {
|
|
91
95
|
const { emdash } = locals;
|
|
92
96
|
|
|
@@ -166,6 +170,17 @@ const OAUTH_TOKEN_HEADERS: HeadersInit = {
|
|
|
166
170
|
"Content-Type": "application/json",
|
|
167
171
|
"Cache-Control": "no-store",
|
|
168
172
|
Pragma: "no-cache",
|
|
173
|
+
// OAuth 2.1 token endpoint is called cross-origin by external clients. Caller
|
|
174
|
+
// must present PKCE code_verifier / device_code / refresh_token on each request,
|
|
175
|
+
// so there is no ambient credential for CSRF to exploit.
|
|
176
|
+
"Access-Control-Allow-Origin": "*",
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const OAUTH_PREFLIGHT_HEADERS: HeadersInit = {
|
|
180
|
+
"Access-Control-Allow-Origin": "*",
|
|
181
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
182
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
183
|
+
"Access-Control-Max-Age": "86400",
|
|
169
184
|
};
|
|
170
185
|
|
|
171
186
|
function oauthSuccess(data: unknown): Response {
|
|
@@ -15,13 +15,23 @@ export const prerender = false;
|
|
|
15
15
|
|
|
16
16
|
let cachedSpec: string | null = null;
|
|
17
17
|
|
|
18
|
-
export const GET: APIRoute = async () => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
export const GET: APIRoute = async ({ locals }) => {
|
|
19
|
+
const { emdash } = locals;
|
|
20
|
+
if (!cachedSpec && emdash) {
|
|
21
|
+
try {
|
|
22
|
+
const doc = generateOpenApiDocument({ maxUploadSize: emdash.config.maxUploadSize });
|
|
23
|
+
cachedSpec = JSON.stringify(doc);
|
|
24
|
+
} catch {
|
|
25
|
+
return new Response(
|
|
26
|
+
JSON.stringify({ error: "Failed to generate OpenAPI document: invalid configuration" }),
|
|
27
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
28
|
+
);
|
|
29
|
+
}
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
|
|
32
|
+
const spec = cachedSpec ?? JSON.stringify(generateOpenApiDocument());
|
|
33
|
+
|
|
34
|
+
return new Response(spec, {
|
|
25
35
|
status: 200,
|
|
26
36
|
headers: {
|
|
27
37
|
"Content-Type": "application/json",
|
|
@@ -57,6 +57,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
|
|
57
57
|
fieldSlug,
|
|
58
58
|
body as UpdateFieldInput,
|
|
59
59
|
);
|
|
60
|
+
if (result.success) emdash!.invalidateManifest();
|
|
60
61
|
return unwrapResult(result);
|
|
61
62
|
};
|
|
62
63
|
|
|
@@ -72,5 +73,6 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
|
|
|
72
73
|
if (denied) return denied;
|
|
73
74
|
|
|
74
75
|
const result = await handleSchemaFieldDelete(emdash!.db, collectionSlug, fieldSlug);
|
|
76
|
+
if (result.success) emdash!.invalidateManifest();
|
|
75
77
|
return unwrapResult(result);
|
|
76
78
|
};
|
|
@@ -28,5 +28,6 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
|
|
|
28
28
|
if (isParseError(body)) return body;
|
|
29
29
|
|
|
30
30
|
const result = await handleSchemaFieldReorder(emdash!.db, collectionSlug, body.fieldSlugs);
|
|
31
|
+
if (result.success) emdash!.invalidateManifest();
|
|
31
32
|
return unwrapResult(result);
|
|
32
33
|
};
|
|
@@ -37,6 +37,11 @@ export const GET: APIRoute = async ({ url, locals }) => {
|
|
|
37
37
|
: undefined;
|
|
38
38
|
|
|
39
39
|
try {
|
|
40
|
+
// Verify FTS indexes are healthy on first use. At most once per worker
|
|
41
|
+
// lifetime; no-op after that. Moved off the cold-start hot path to
|
|
42
|
+
// keep anonymous public reads fast.
|
|
43
|
+
await emdash.ensureSearchHealthy?.();
|
|
44
|
+
|
|
40
45
|
const result = await searchWithDb(emdash.db, query.q, {
|
|
41
46
|
collections,
|
|
42
47
|
status: query.status,
|
|
@@ -36,6 +36,9 @@ export const GET: APIRoute = async ({ url, locals }) => {
|
|
|
36
36
|
: undefined;
|
|
37
37
|
|
|
38
38
|
try {
|
|
39
|
+
// Verify FTS indexes are healthy on first use. See search/index.ts.
|
|
40
|
+
await emdash.ensureSearchHealthy?.();
|
|
41
|
+
|
|
39
42
|
const suggestions = await getSuggestions(emdash.db, query.q, {
|
|
40
43
|
collections,
|
|
41
44
|
locale: query.locale,
|
|
@@ -8,7 +8,7 @@ import type { APIRoute } from "astro";
|
|
|
8
8
|
|
|
9
9
|
export const prerender = false;
|
|
10
10
|
|
|
11
|
-
import { Role } from "@emdash-cms/auth";
|
|
11
|
+
import { Role, secureCompare } from "@emdash-cms/auth";
|
|
12
12
|
import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
|
|
13
13
|
import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/passkey";
|
|
14
14
|
|
|
@@ -18,9 +18,10 @@ import { getPublicOrigin } from "#api/public-url.js";
|
|
|
18
18
|
import { setupAdminVerifyBody } from "#api/schemas.js";
|
|
19
19
|
import { createChallengeStore } from "#auth/challenge-store.js";
|
|
20
20
|
import { getPasskeyConfig } from "#auth/passkey-config.js";
|
|
21
|
+
import { SETUP_NONCE_COOKIE } from "#auth/setup-nonce.js";
|
|
21
22
|
import { OptionsRepository } from "#db/repositories/options.js";
|
|
22
23
|
|
|
23
|
-
export const POST: APIRoute = async ({ request, locals }) => {
|
|
24
|
+
export const POST: APIRoute = async ({ cookies, request, locals }) => {
|
|
24
25
|
const { emdash } = locals;
|
|
25
26
|
|
|
26
27
|
if (!emdash?.db) {
|
|
@@ -45,12 +46,35 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
// Get setup state
|
|
48
|
-
const setupState = await options.get
|
|
49
|
+
const setupState = await options.get<{
|
|
50
|
+
step?: string;
|
|
51
|
+
email?: string;
|
|
52
|
+
name?: string | null;
|
|
53
|
+
nonce?: string;
|
|
54
|
+
}>("emdash:setup_state");
|
|
49
55
|
|
|
50
56
|
if (!setupState || setupState.step !== "admin") {
|
|
51
57
|
return apiError("INVALID_STATE", "Invalid setup state. Please restart setup.", 400);
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
// Verify the session nonce. The cookie was minted by POST /setup/admin
|
|
61
|
+
// and stored alongside setup_state; presenting a matching cookie is
|
|
62
|
+
// proof that this verify call comes from the same browser that
|
|
63
|
+
// started the admin step. Constant-time compare to avoid leaking the
|
|
64
|
+
// stored value through timing.
|
|
65
|
+
const cookieNonce = cookies.get(SETUP_NONCE_COOKIE)?.value;
|
|
66
|
+
if (!setupState.nonce || !cookieNonce || !secureCompare(cookieNonce, setupState.nonce)) {
|
|
67
|
+
return apiError(
|
|
68
|
+
"INVALID_STATE",
|
|
69
|
+
"Setup session expired or tampered with. Please restart the admin step.",
|
|
70
|
+
400,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!setupState.email) {
|
|
75
|
+
return apiError("INVALID_STATE", "Invalid setup state. Please restart setup.", 400);
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
// Parse request body
|
|
55
79
|
const body = await parseBody(request, setupAdminVerifyBody);
|
|
56
80
|
if (isParseError(body)) return body;
|
|
@@ -73,7 +97,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
73
97
|
// Create the admin user
|
|
74
98
|
const user = await adapter.createUser({
|
|
75
99
|
email: setupState.email,
|
|
76
|
-
name: setupState.name,
|
|
100
|
+
name: setupState.name ?? null,
|
|
77
101
|
role: Role.ADMIN,
|
|
78
102
|
emailVerified: false, // No email verification for first user
|
|
79
103
|
});
|
|
@@ -84,8 +108,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
84
108
|
// Mark setup as complete
|
|
85
109
|
await options.set("emdash:setup_complete", true);
|
|
86
110
|
|
|
87
|
-
// Clean up setup state
|
|
111
|
+
// Clean up setup state and the session nonce cookie
|
|
88
112
|
await options.delete("emdash:setup_state");
|
|
113
|
+
cookies.delete(SETUP_NONCE_COOKIE, { path: "/_emdash/" });
|
|
89
114
|
|
|
90
115
|
return apiSuccess({
|
|
91
116
|
success: true,
|
|
@@ -8,6 +8,7 @@ import type { APIRoute } from "astro";
|
|
|
8
8
|
|
|
9
9
|
export const prerender = false;
|
|
10
10
|
|
|
11
|
+
import { generateToken } from "@emdash-cms/auth";
|
|
11
12
|
import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
|
|
12
13
|
import { generateRegistrationOptions } from "@emdash-cms/auth/passkey";
|
|
13
14
|
|
|
@@ -17,9 +18,10 @@ import { getPublicOrigin } from "#api/public-url.js";
|
|
|
17
18
|
import { setupAdminBody } from "#api/schemas.js";
|
|
18
19
|
import { createChallengeStore } from "#auth/challenge-store.js";
|
|
19
20
|
import { getPasskeyConfig } from "#auth/passkey-config.js";
|
|
21
|
+
import { SETUP_NONCE_COOKIE, SETUP_NONCE_MAX_AGE_SECONDS } from "#auth/setup-nonce.js";
|
|
20
22
|
import { OptionsRepository } from "#db/repositories/options.js";
|
|
21
23
|
|
|
22
|
-
export const POST: APIRoute = async ({ request, locals }) => {
|
|
24
|
+
export const POST: APIRoute = async ({ cookies, request, locals }) => {
|
|
23
25
|
const { emdash } = locals;
|
|
24
26
|
|
|
25
27
|
if (!emdash?.db) {
|
|
@@ -47,12 +49,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
47
49
|
const body = await parseBody(request, setupAdminBody);
|
|
48
50
|
if (isParseError(body)) return body;
|
|
49
51
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
// Mint a fresh session nonce. This binds the follow-up
|
|
53
|
+
// /setup/admin/verify call to the same browser that made this
|
|
54
|
+
// request, so an unauthenticated attacker on another host cannot
|
|
55
|
+
// substitute their own email into the setup state during the
|
|
56
|
+
// setup window. Rotates on every call so a legitimate retry
|
|
57
|
+
// always gets a working session.
|
|
58
|
+
const nonce = generateToken();
|
|
56
59
|
|
|
57
60
|
// Get passkey config
|
|
58
61
|
const url = new URL(request.url);
|
|
@@ -78,12 +81,33 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
78
81
|
challengeStore,
|
|
79
82
|
);
|
|
80
83
|
|
|
81
|
-
// Store the
|
|
84
|
+
// Store the nonce alongside the rest of the setup state. The verify
|
|
85
|
+
// endpoint will constant-time compare this with the incoming cookie.
|
|
82
86
|
await options.set("emdash:setup_state", {
|
|
83
87
|
step: "admin",
|
|
84
88
|
email: body.email.toLowerCase(),
|
|
85
89
|
name: body.name || null,
|
|
86
90
|
tempUserId: tempUser.id,
|
|
91
|
+
nonce,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// HttpOnly + SameSite=Strict + path-scoped. The cookie must not be
|
|
95
|
+
// accessible to JS (nothing in the admin UI needs to read it) and
|
|
96
|
+
// must not be sent on cross-site navigations. The /_emdash/ path
|
|
97
|
+
// scope keeps it away from user-authored frontend code.
|
|
98
|
+
//
|
|
99
|
+
// Derive `secure` from the public origin, not the internal request
|
|
100
|
+
// URL. Behind a TLS-terminating reverse proxy the internal hop is
|
|
101
|
+
// often `http:` while the browser-facing origin is `https:` —
|
|
102
|
+
// using `url.protocol` there would drop the Secure flag on a
|
|
103
|
+
// sensitive cookie over the public HTTPS connection.
|
|
104
|
+
const publicOrigin = new URL(siteUrl);
|
|
105
|
+
cookies.set(SETUP_NONCE_COOKIE, nonce, {
|
|
106
|
+
path: "/_emdash/",
|
|
107
|
+
httpOnly: true,
|
|
108
|
+
sameSite: "strict",
|
|
109
|
+
secure: publicOrigin.protocol === "https:",
|
|
110
|
+
maxAge: SETUP_NONCE_MAX_AGE_SECONDS,
|
|
87
111
|
});
|
|
88
112
|
|
|
89
113
|
return apiSuccess({
|
|
@@ -89,9 +89,12 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
|
|
|
89
89
|
const options = new OptionsRepository(emdash.db);
|
|
90
90
|
|
|
91
91
|
// Store the canonical site URL from the setup request.
|
|
92
|
-
//
|
|
92
|
+
// Write-once at the DB level so concurrent setup POSTs can't both
|
|
93
|
+
// observe an empty value and race to write. A spoofed Host header
|
|
94
|
+
// on a later call during the wizard window must not be able to
|
|
95
|
+
// replace the first value.
|
|
93
96
|
const siteUrl = getPublicOrigin(url, emdash.config);
|
|
94
|
-
await options.
|
|
97
|
+
await options.setIfAbsent("emdash:site_url", siteUrl);
|
|
95
98
|
|
|
96
99
|
if (useExternalAuth) {
|
|
97
100
|
// External auth mode: mark setup complete now
|
|
@@ -52,6 +52,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
52
52
|
if (isParseError(body)) return body;
|
|
53
53
|
|
|
54
54
|
const result = await handleTaxonomyCreate(emdash.db, body);
|
|
55
|
+
if (result.success) emdash.invalidateManifest();
|
|
55
56
|
return unwrapResult(result, 201);
|
|
56
57
|
} catch (error) {
|
|
57
58
|
return handleError(error, "Failed to create taxonomy", "TAXONOMY_CREATE_ERROR");
|
|
@@ -33,8 +33,8 @@ export const GET: APIRoute = async ({ url, locals }) => {
|
|
|
33
33
|
"urn:ietf:params:oauth:grant-type:device_code",
|
|
34
34
|
],
|
|
35
35
|
code_challenge_methods_supported: ["S256"],
|
|
36
|
+
registration_endpoint: `${origin}/_emdash/api/oauth/register`,
|
|
36
37
|
token_endpoint_auth_methods_supported: ["none"],
|
|
37
|
-
client_id_metadata_document_supported: true,
|
|
38
38
|
device_authorization_endpoint: `${origin}/_emdash/api/oauth/device/code`,
|
|
39
39
|
},
|
|
40
40
|
{
|
package/src/astro/types.ts
CHANGED
|
@@ -142,6 +142,15 @@ export interface EmDashManifest {
|
|
|
142
142
|
* When true, the admin UI can show marketplace browse/install features.
|
|
143
143
|
*/
|
|
144
144
|
marketplace?: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Admin branding overrides for white-labeling.
|
|
147
|
+
* Set via the `admin` config in `astro.config.mjs`.
|
|
148
|
+
*/
|
|
149
|
+
admin?: {
|
|
150
|
+
logo?: string;
|
|
151
|
+
siteName?: string;
|
|
152
|
+
favicon?: string;
|
|
153
|
+
};
|
|
145
154
|
}
|
|
146
155
|
|
|
147
156
|
/**
|
package/src/auth/rate-limit.ts
CHANGED
|
@@ -100,44 +100,72 @@ export function rateLimitResponse(retryAfterSeconds: number): Response {
|
|
|
100
100
|
*
|
|
101
101
|
* Resolution order:
|
|
102
102
|
* 1. `CF-Connecting-IP` — trusted only when the Cloudflare `cf` object is
|
|
103
|
-
* present
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* 3.
|
|
103
|
+
* present. CF edge overwrites any client-supplied value, so this is the
|
|
104
|
+
* cryptographically trustworthy path on Workers. Operator-declared
|
|
105
|
+
* trusted headers cannot override it.
|
|
106
|
+
* 2. `X-Forwarded-For` (first entry) — trusted only when the `cf` object
|
|
107
|
+
* is present (CF sets this reliably).
|
|
108
|
+
* 3. Operator-declared trusted proxy headers (ordered list) — used as a
|
|
109
|
+
* fallback for non-CF deployments behind a reverse proxy the operator
|
|
110
|
+
* controls. Also applies as a fill-in on CF when the CF headers are
|
|
111
|
+
* absent (e.g. internal cron handlers).
|
|
112
|
+
* 4. `null` — no trusted IP available. Callers must handle this gracefully
|
|
109
113
|
* (e.g. skip rate limiting).
|
|
110
114
|
*
|
|
115
|
+
* Pass `trustedHeaders` from `getTrustedProxyHeaders(emdash.config)` so
|
|
116
|
+
* self-hosted non-CF deployments can opt into reading a specific header.
|
|
117
|
+
*
|
|
111
118
|
* Aligned with `extractRequestMeta` in `plugins/request-meta.ts`.
|
|
112
119
|
*/
|
|
113
|
-
export function getClientIp(request: Request): string | null {
|
|
120
|
+
export function getClientIp(request: Request, trustedHeaders: string[] = []): string | null {
|
|
114
121
|
const headers = request.headers;
|
|
115
122
|
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- CF Workers runtime shape
|
|
116
123
|
const cf = (request as unknown as { cf?: Record<string, unknown> }).cf;
|
|
117
124
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
// On Cloudflare, prefer the cryptographically trustworthy headers. An
|
|
126
|
+
// attacker can't spoof these — the CF edge strips/overwrites them.
|
|
127
|
+
if (cf) {
|
|
128
|
+
const cfIp = headers.get("cf-connecting-ip")?.trim();
|
|
129
|
+
if (cfIp && IP_PATTERN.test(cfIp)) {
|
|
130
|
+
return cfIp;
|
|
131
|
+
}
|
|
122
132
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
const xff = headers.get("x-forwarded-for");
|
|
134
|
+
if (xff) {
|
|
135
|
+
const first = xff.split(",")[0]?.trim();
|
|
136
|
+
if (first && IP_PATTERN.test(first)) {
|
|
137
|
+
return first;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
127
140
|
}
|
|
128
141
|
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
return first;
|
|
135
|
-
}
|
|
142
|
+
// Fall through to operator-declared trusted headers. On CF this fills
|
|
143
|
+
// in when the CF headers are absent; off-CF it's the primary source.
|
|
144
|
+
for (const name of trustedHeaders) {
|
|
145
|
+
const value = readIpFromHeader(headers, name);
|
|
146
|
+
if (value) return value;
|
|
136
147
|
}
|
|
137
148
|
|
|
138
149
|
return null;
|
|
139
150
|
}
|
|
140
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Read an IP from an operator-declared trusted header. XFF-style headers
|
|
154
|
+
* are parsed as comma-separated lists and the first entry is used.
|
|
155
|
+
*/
|
|
156
|
+
function readIpFromHeader(headers: Headers, name: string): string | null {
|
|
157
|
+
const value = headers.get(name);
|
|
158
|
+
if (!value) return null;
|
|
159
|
+
if (name.toLowerCase().endsWith("forwarded-for")) {
|
|
160
|
+
const first = value.split(",")[0]?.trim();
|
|
161
|
+
if (!first) return null;
|
|
162
|
+
return IP_PATTERN.test(first) ? first : null;
|
|
163
|
+
}
|
|
164
|
+
const trimmed = value.trim();
|
|
165
|
+
if (!trimmed) return null;
|
|
166
|
+
return IP_PATTERN.test(trimmed) ? trimmed : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
141
169
|
/**
|
|
142
170
|
* Delete expired rate limit entries.
|
|
143
171
|
*
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session binding for the first-setup admin-creation flow.
|
|
3
|
+
*
|
|
4
|
+
* Shared constants for the nonce cookie that ties /_emdash/api/setup/admin
|
|
5
|
+
* and /_emdash/api/setup/admin/verify to the same browser. Without this
|
|
6
|
+
* binding, any unauthenticated caller could POST /setup/admin during the
|
|
7
|
+
* setup window and substitute their own email into the stored setup state
|
|
8
|
+
* before the legitimate admin completes passkey verification.
|
|
9
|
+
*
|
|
10
|
+
* Implementation lives in the two route handlers; this module is just
|
|
11
|
+
* the name / lifetime so both ends agree.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Cookie name carrying the setup-admin session nonce. */
|
|
15
|
+
export const SETUP_NONCE_COOKIE = "emdash_setup_nonce";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Cookie max-age in seconds. One hour is plenty of time to complete
|
|
19
|
+
* a passkey registration; if the user lingers longer the admin step
|
|
20
|
+
* can simply be retried.
|
|
21
|
+
*/
|
|
22
|
+
export const SETUP_NONCE_MAX_AGE_SECONDS = 60 * 60;
|