emdash 0.1.1 → 0.3.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/{apply-kC39ev1Z.mjs → apply-Bqoekfbe.mjs} +57 -10
- package/dist/apply-Bqoekfbe.mjs.map +1 -0
- package/dist/astro/index.d.mts +23 -9
- package/dist/astro/index.d.mts.map +1 -1
- package/dist/astro/index.mjs +90 -25
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +3 -3
- package/dist/astro/middleware/auth.d.mts.map +1 -1
- package/dist/astro/middleware/auth.mjs +126 -55
- package/dist/astro/middleware/auth.mjs.map +1 -1
- package/dist/astro/middleware/redirect.mjs +2 -2
- package/dist/astro/middleware/request-context.mjs +1 -1
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +80 -41
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +27 -6
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-CL847F26.mjs → byline-BGj9p9Ht.mjs} +53 -31
- package/dist/byline-BGj9p9Ht.mjs.map +1 -0
- package/dist/{bylines-C2a-2TGt.mjs → bylines-BihaoIDY.mjs} +12 -10
- package/dist/{bylines-C2a-2TGt.mjs.map → bylines-BihaoIDY.mjs.map} +1 -1
- package/dist/cli/index.mjs +17 -14
- package/dist/cli/index.mjs.map +1 -1
- package/dist/{config-CKE8p9xM.mjs → config-Cq8H0SfX.mjs} +2 -10
- package/dist/{config-CKE8p9xM.mjs.map → config-Cq8H0SfX.mjs.map} +1 -1
- package/dist/{content-D6C2WsZC.mjs → content-BsBoyj8G.mjs} +35 -5
- package/dist/content-BsBoyj8G.mjs.map +1 -0
- package/dist/db/index.mjs +2 -2
- package/dist/{default-Cyi4aAxu.mjs → default-WYlzADZL.mjs} +1 -1
- package/dist/{default-Cyi4aAxu.mjs.map → default-WYlzADZL.mjs.map} +1 -1
- package/dist/{dialect-helpers-B9uSp2GJ.mjs → dialect-helpers-DhTzaUxP.mjs} +4 -1
- package/dist/dialect-helpers-DhTzaUxP.mjs.map +1 -0
- package/dist/{error-Cxz0tQeO.mjs → error-DrxtnGPg.mjs} +1 -1
- package/dist/{error-Cxz0tQeO.mjs.map → error-DrxtnGPg.mjs.map} +1 -1
- package/dist/{index-CLBc4gw-.d.mts → index-Cff7AimE.d.mts} +77 -15
- package/dist/index-Cff7AimE.d.mts.map +1 -0
- package/dist/index.d.mts +6 -6
- package/dist/index.mjs +19 -19
- package/dist/{load-yOOlckBj.mjs → load-Veizk2cT.mjs} +1 -1
- package/dist/{load-yOOlckBj.mjs.map → load-Veizk2cT.mjs.map} +1 -1
- package/dist/{loader-fz8Q_3EO.mjs → loader-BmYdf3Dr.mjs} +4 -2
- package/dist/loader-BmYdf3Dr.mjs.map +1 -0
- package/dist/{manifest-schema-CL8DWO9b.mjs → manifest-schema-CuMio1A9.mjs} +1 -1
- package/dist/{manifest-schema-CL8DWO9b.mjs.map → manifest-schema-CuMio1A9.mjs.map} +1 -1
- package/dist/media/local-runtime.d.mts +4 -4
- package/dist/page/index.d.mts +10 -1
- package/dist/page/index.d.mts.map +1 -1
- package/dist/page/index.mjs +8 -4
- package/dist/page/index.mjs.map +1 -1
- package/dist/plugins/adapt-sandbox-entry.d.mts +3 -3
- package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
- package/dist/{query-BVYN0PJ6.mjs → query-sesiOndV.mjs} +20 -8
- package/dist/{query-BVYN0PJ6.mjs.map → query-sesiOndV.mjs.map} +1 -1
- package/dist/{redirect-DIfIni3r.mjs → redirect-DUAk-Yl_.mjs} +9 -2
- package/dist/redirect-DUAk-Yl_.mjs.map +1 -0
- package/dist/{registry-BNYQKX_d.mjs → registry-DU18yVo0.mjs} +14 -4
- package/dist/registry-DU18yVo0.mjs.map +1 -0
- package/dist/{runner-BraqvGYk.mjs → runner-Biufrii2.mjs} +157 -132
- package/dist/runner-Biufrii2.mjs.map +1 -0
- package/dist/runner-EAtf0ZIe.d.mts.map +1 -1
- package/dist/runtime.d.mts +3 -3
- package/dist/runtime.mjs +2 -2
- package/dist/{search-C1gg67nN.mjs → search-BXB-jfu2.mjs} +241 -109
- package/dist/search-BXB-jfu2.mjs.map +1 -0
- package/dist/seed/index.d.mts +1 -1
- package/dist/seed/index.mjs +10 -10
- 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 +11 -3
- package/dist/storage/s3.d.mts.map +1 -1
- package/dist/storage/s3.mjs +76 -15
- package/dist/storage/s3.mjs.map +1 -1
- package/dist/{tokens-DpgrkrXK.mjs → tokens-DrB-W6Q-.mjs} +1 -1
- package/dist/{tokens-DpgrkrXK.mjs.map → tokens-DrB-W6Q-.mjs.map} +1 -1
- package/dist/{types-BRuPJGdV.d.mts → types-BbsYgi_R.d.mts} +3 -1
- package/dist/types-BbsYgi_R.d.mts.map +1 -0
- package/dist/{types-CUBbjgmP.mjs → types-Bec-r_3_.mjs} +1 -1
- package/dist/types-Bec-r_3_.mjs.map +1 -0
- package/dist/{types-DaNLHo_T.d.mts → types-C1-PVaS_.d.mts} +14 -6
- package/dist/types-C1-PVaS_.d.mts.map +1 -0
- package/dist/types-CMMN0pNg.mjs.map +1 -1
- package/dist/{types-BQo5JS0J.d.mts → types-CaKte3hR.d.mts} +78 -6
- package/dist/types-CaKte3hR.d.mts.map +1 -0
- package/dist/{types-CiA5Gac0.mjs → types-DuNbGKjF.mjs} +1 -1
- package/dist/{types-CiA5Gac0.mjs.map → types-DuNbGKjF.mjs.map} +1 -1
- package/dist/{validate-_rsF-Dx_.mjs → validate-CXnRKfJK.mjs} +2 -2
- package/dist/{validate-_rsF-Dx_.mjs.map → validate-CXnRKfJK.mjs.map} +1 -1
- package/dist/{validate-CqRJb_xU.mjs → validate-VPnKoIzW.mjs} +11 -11
- package/dist/{validate-CqRJb_xU.mjs.map → validate-VPnKoIzW.mjs.map} +1 -1
- package/dist/{validate-HtxZeaBi.d.mts → validate-bfg9OR6N.d.mts} +2 -2
- package/dist/{validate-HtxZeaBi.d.mts.map → validate-bfg9OR6N.d.mts.map} +1 -1
- package/dist/version-REAapfsU.mjs +7 -0
- package/dist/version-REAapfsU.mjs.map +1 -0
- package/package.json +6 -6
- package/src/api/csrf.ts +13 -2
- package/src/api/handlers/content.ts +7 -0
- package/src/api/handlers/dashboard.ts +4 -8
- package/src/api/handlers/device-flow.ts +55 -37
- package/src/api/handlers/index.ts +6 -1
- package/src/api/handlers/redirects.ts +95 -3
- package/src/api/handlers/seo.ts +48 -21
- package/src/api/public-url.ts +84 -0
- package/src/api/schemas/content.ts +2 -2
- package/src/api/schemas/menus.ts +12 -2
- package/src/api/schemas/redirects.ts +1 -0
- package/src/astro/integration/index.ts +30 -7
- package/src/astro/integration/routes.ts +13 -2
- package/src/astro/integration/runtime.ts +7 -5
- package/src/astro/integration/vite-config.ts +55 -9
- package/src/astro/middleware/auth.ts +60 -56
- package/src/astro/middleware/csp.ts +25 -0
- package/src/astro/middleware.ts +31 -3
- package/src/astro/routes/PluginRegistry.tsx +8 -2
- package/src/astro/routes/admin.astro +7 -2
- package/src/astro/routes/api/admin/users/[id]/disable.ts +18 -12
- package/src/astro/routes/api/admin/users/[id]/index.ts +26 -5
- package/src/astro/routes/api/auth/invite/complete.ts +3 -2
- package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +2 -1
- package/src/astro/routes/api/auth/oauth/[provider].ts +2 -1
- package/src/astro/routes/api/auth/passkey/options.ts +3 -2
- package/src/astro/routes/api/auth/passkey/register/options.ts +3 -2
- package/src/astro/routes/api/auth/passkey/register/verify.ts +3 -2
- package/src/astro/routes/api/auth/passkey/verify.ts +3 -2
- package/src/astro/routes/api/auth/signup/complete.ts +3 -2
- package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -0
- package/src/astro/routes/api/content/[collection]/index.ts +31 -3
- package/src/astro/routes/api/import/wordpress/execute.ts +9 -0
- package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +2 -0
- package/src/astro/routes/api/import/wordpress-plugin/execute.ts +10 -0
- package/src/astro/routes/api/manifest.ts +4 -1
- package/src/astro/routes/api/media/providers/[providerId]/[itemId].ts +7 -2
- package/src/astro/routes/api/oauth/authorize.ts +12 -7
- package/src/astro/routes/api/oauth/device/code.ts +5 -1
- package/src/astro/routes/api/setup/admin-verify.ts +3 -2
- package/src/astro/routes/api/setup/admin.ts +3 -2
- package/src/astro/routes/api/setup/dev-bypass.ts +2 -1
- package/src/astro/routes/api/setup/index.ts +3 -2
- package/src/astro/routes/api/snapshot.ts +2 -1
- package/src/astro/routes/api/themes/preview.ts +2 -1
- package/src/astro/routes/api/well-known/auth.ts +1 -0
- package/src/astro/routes/api/well-known/oauth-authorization-server.ts +3 -2
- package/src/astro/routes/api/well-known/oauth-protected-resource.ts +3 -2
- package/src/astro/routes/robots.txt.ts +5 -1
- package/src/astro/routes/sitemap-[collection].xml.ts +104 -0
- package/src/astro/routes/sitemap.xml.ts +18 -23
- package/src/astro/storage/adapters.ts +19 -5
- package/src/astro/storage/types.ts +12 -4
- package/src/astro/types.ts +28 -1
- package/src/auth/passkey-config.ts +6 -10
- package/src/bylines/index.ts +13 -10
- package/src/cli/commands/login.ts +5 -2
- package/src/components/InlinePortableTextEditor.tsx +5 -3
- package/src/content/converters/portable-text-to-prosemirror.ts +50 -2
- package/src/database/dialect-helpers.ts +3 -0
- package/src/database/migrations/034_published_at_index.ts +29 -0
- package/src/database/migrations/runner.ts +2 -0
- package/src/database/repositories/byline.ts +48 -42
- package/src/database/repositories/content.ts +28 -1
- package/src/database/repositories/options.ts +9 -3
- package/src/database/repositories/redirect.ts +13 -0
- package/src/database/repositories/seo.ts +34 -17
- package/src/database/repositories/types.ts +2 -0
- package/src/database/validate.ts +10 -10
- package/src/emdash-runtime.ts +66 -19
- package/src/import/index.ts +1 -1
- package/src/import/sources/wxr.ts +45 -2
- package/src/index.ts +10 -1
- package/src/loader.ts +2 -0
- package/src/mcp/server.ts +85 -5
- package/src/menus/index.ts +6 -1
- package/src/page/context.ts +13 -1
- package/src/page/jsonld.ts +10 -6
- package/src/page/seo-contributions.ts +1 -1
- package/src/plugins/context.ts +145 -35
- package/src/plugins/manager.ts +12 -0
- package/src/plugins/types.ts +80 -4
- package/src/query.ts +18 -0
- package/src/redirects/loops.ts +318 -0
- package/src/schema/registry.ts +8 -0
- package/src/search/fts-manager.ts +4 -0
- package/src/settings/index.ts +64 -0
- package/src/storage/s3.ts +94 -25
- package/src/storage/types.ts +13 -5
- package/src/utils/chunks.ts +17 -0
- package/src/utils/slugify.ts +11 -0
- package/src/version.ts +12 -0
- package/dist/apply-kC39ev1Z.mjs.map +0 -1
- package/dist/byline-CL847F26.mjs.map +0 -1
- package/dist/content-D6C2WsZC.mjs.map +0 -1
- package/dist/dialect-helpers-B9uSp2GJ.mjs.map +0 -1
- package/dist/index-CLBc4gw-.d.mts.map +0 -1
- package/dist/loader-fz8Q_3EO.mjs.map +0 -1
- package/dist/redirect-DIfIni3r.mjs.map +0 -1
- package/dist/registry-BNYQKX_d.mjs.map +0 -1
- package/dist/runner-BraqvGYk.mjs.map +0 -1
- package/dist/search-C1gg67nN.mjs.map +0 -1
- package/dist/types-BQo5JS0J.d.mts.map +0 -1
- package/dist/types-BRuPJGdV.d.mts.map +0 -1
- package/dist/types-CUBbjgmP.mjs.map +0 -1
- package/dist/types-DaNLHo_T.d.mts.map +0 -1
- /package/src/astro/routes/api/media/file/{[key].ts → [...key].ts} +0 -0
|
@@ -263,17 +263,19 @@ export interface EmDashConfig {
|
|
|
263
263
|
marketplace?: string;
|
|
264
264
|
|
|
265
265
|
/**
|
|
266
|
-
* Public browser origin for
|
|
266
|
+
* Public browser-facing origin for the site.
|
|
267
267
|
*
|
|
268
268
|
* Use when `Astro.url` / `request.url` do not match what users open — common with a
|
|
269
269
|
* **TLS-terminating reverse proxy**: the app often sees `http://` on the internal hop
|
|
270
|
-
* while the browser uses `https://`, which breaks WebAuthn
|
|
270
|
+
* while the browser uses `https://`, which breaks WebAuthn, CSRF, OAuth, and redirect URLs.
|
|
271
271
|
*
|
|
272
272
|
* Set to the full origin users type in the address bar (no path), e.g.
|
|
273
|
-
* `https://
|
|
274
|
-
*
|
|
273
|
+
* `https://mysite.example.com`. When not set, falls back to environment variables
|
|
274
|
+
* `EMDASH_SITE_URL` > `SITE_URL`, then to the request URL's origin.
|
|
275
|
+
*
|
|
276
|
+
* Replaces `passkeyPublicOrigin` (which only fixed passkeys).
|
|
275
277
|
*/
|
|
276
|
-
|
|
278
|
+
siteUrl?: string;
|
|
277
279
|
|
|
278
280
|
/**
|
|
279
281
|
* Enable playground mode for ephemeral "try EmDash" sites.
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Vite-specific configuration for EmDash.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
8
9
|
import { createRequire } from "node:module";
|
|
9
10
|
import { dirname, resolve } from "node:path";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
@@ -12,6 +13,7 @@ import { fileURLToPath } from "node:url";
|
|
|
12
13
|
import type { AstroConfig } from "astro";
|
|
13
14
|
import type { Plugin } from "vite";
|
|
14
15
|
|
|
16
|
+
import { COMMIT, VERSION } from "../../version.js";
|
|
15
17
|
import type { EmDashConfig, PluginDescriptor } from "./runtime.js";
|
|
16
18
|
import {
|
|
17
19
|
VIRTUAL_CONFIG_ID,
|
|
@@ -49,6 +51,45 @@ import {
|
|
|
49
51
|
generateBlockComponentsModule,
|
|
50
52
|
} from "./virtual-modules.js";
|
|
51
53
|
|
|
54
|
+
const LOCALE_MESSAGES_RE = /[/\\]([a-z]{2}(?:-[A-Z]{2})?)[/\\]messages\.mjs$/;
|
|
55
|
+
/**
|
|
56
|
+
* Vite plugin that compiles Lingui macros in admin source files.
|
|
57
|
+
* Only active in dev mode when the admin package is aliased to source for HMR.
|
|
58
|
+
* @babel/core is dynamically imported from admin's devDependencies —
|
|
59
|
+
* not declared by core, never ships to end users.
|
|
60
|
+
*/
|
|
61
|
+
function linguiMacroPlugin(adminSourcePath: string, adminDistPath: string): Plugin {
|
|
62
|
+
// Resolve @babel/core from admin's devDependencies, not core's.
|
|
63
|
+
const adminRequire = createRequire(resolve(adminDistPath, "index.js"));
|
|
64
|
+
const babelCorePath = adminRequire.resolve("@babel/core");
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
name: "emdash-lingui-macro",
|
|
68
|
+
enforce: "pre",
|
|
69
|
+
resolveId(id, importer) {
|
|
70
|
+
// Redirect relative locale catalog imports (e.g. ./de/messages.mjs) from
|
|
71
|
+
// within admin source to the compiled dist/locales/ directory, since
|
|
72
|
+
// lingui compile only runs during build — not in dev watch mode.
|
|
73
|
+
if (!importer?.startsWith(adminSourcePath)) return;
|
|
74
|
+
const match = id.match(LOCALE_MESSAGES_RE);
|
|
75
|
+
if (match?.[1]) {
|
|
76
|
+
return resolve(adminDistPath, "locales", match[1], "messages.mjs");
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
async transform(code, id) {
|
|
80
|
+
if (!id.startsWith(adminSourcePath) || !code.includes("@lingui")) return;
|
|
81
|
+
const { transformAsync } = (await import(babelCorePath)) as typeof import("@babel/core");
|
|
82
|
+
const result = await transformAsync(code, {
|
|
83
|
+
filename: id,
|
|
84
|
+
plugins: ["@lingui/babel-plugin-lingui-macro"],
|
|
85
|
+
parserOpts: { plugins: ["jsx", "typescript"] },
|
|
86
|
+
});
|
|
87
|
+
if (!result?.code) return;
|
|
88
|
+
return { code: result.code, map: result.map ?? undefined };
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
52
93
|
/**
|
|
53
94
|
* Resolve path to the admin package dist directory.
|
|
54
95
|
* Used for Vite alias to ensure the package is found in pnpm's isolated node_modules.
|
|
@@ -72,13 +113,8 @@ function resolveAdminSource(): string | undefined {
|
|
|
72
113
|
const packageRoot = resolve(dirname(adminPath), "..");
|
|
73
114
|
const srcEntry = resolve(packageRoot, "src", "index.ts");
|
|
74
115
|
|
|
75
|
-
// Only use source alias if the source directory actually exists
|
|
76
|
-
// (won't exist in published packages, only in the monorepo)
|
|
77
116
|
try {
|
|
78
|
-
|
|
79
|
-
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- CJS require returns any
|
|
80
|
-
const fs = require("node:fs") as typeof import("node:fs");
|
|
81
|
-
if (fs.existsSync(srcEntry)) {
|
|
117
|
+
if (existsSync(srcEntry)) {
|
|
82
118
|
return resolve(packageRoot, "src");
|
|
83
119
|
}
|
|
84
120
|
} catch {
|
|
@@ -243,6 +279,12 @@ export function createViteConfig(
|
|
|
243
279
|
const useSource = adminSourcePath !== undefined;
|
|
244
280
|
|
|
245
281
|
return {
|
|
282
|
+
// Astro SSR routes resolve version.ts from source (not tsdown dist),
|
|
283
|
+
// so Vite needs its own define pass for the __EMDASH_*__ placeholders.
|
|
284
|
+
define: {
|
|
285
|
+
__EMDASH_VERSION__: JSON.stringify(VERSION),
|
|
286
|
+
__EMDASH_COMMIT__: JSON.stringify(COMMIT),
|
|
287
|
+
},
|
|
246
288
|
resolve: {
|
|
247
289
|
dedupe: ["@emdash-cms/admin", "react", "react-dom"],
|
|
248
290
|
// Array form so more-specific entries are checked first.
|
|
@@ -250,14 +292,18 @@ export function createViteConfig(
|
|
|
250
292
|
// Vite's prefix matching on "@emdash-cms/admin" would resolve
|
|
251
293
|
// "@emdash-cms/admin/styles.css" through the source directory.
|
|
252
294
|
alias: [
|
|
253
|
-
// CSS: always dist (pre-compiled by @tailwindcss/cli)
|
|
254
295
|
{ find: "@emdash-cms/admin/styles.css", replacement: resolve(adminDistPath, "styles.css") },
|
|
255
|
-
// JS: source in dev (HMR), dist in build
|
|
256
296
|
{ find: "@emdash-cms/admin", replacement: useSource ? adminSourcePath : adminDistPath },
|
|
257
297
|
],
|
|
258
298
|
},
|
|
259
299
|
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Monorepo has both vite 6 (docs) and vite 7 (core). tsgo resolves correctly.
|
|
260
|
-
plugins: [
|
|
300
|
+
plugins: [
|
|
301
|
+
createVirtualModulesPlugin(options),
|
|
302
|
+
// In dev mode with source alias, compile Lingui macros on the fly
|
|
303
|
+
// and redirect locale .mjs imports to dist/.
|
|
304
|
+
// In production, macros are pre-compiled by tsdown in the admin package.
|
|
305
|
+
...(useSource ? [linguiMacroPlugin(adminSourcePath!, adminDistPath)] : []),
|
|
306
|
+
] as NonNullable<AstroConfig["vite"]>["plugins"],
|
|
261
307
|
// Handle native modules for SSR.
|
|
262
308
|
// On Node: external keeps native addons out of the SSR bundle.
|
|
263
309
|
// On Cloudflare: skip — the adapter handles externalization, and setting
|
|
@@ -20,6 +20,7 @@ import { authenticate as virtualAuthenticate } from "virtual:emdash/auth";
|
|
|
20
20
|
|
|
21
21
|
import { checkPublicCsrf } from "../../api/csrf.js";
|
|
22
22
|
import { apiError } from "../../api/error.js";
|
|
23
|
+
import { getPublicOrigin } from "../../api/public-url.js";
|
|
23
24
|
|
|
24
25
|
/** Cache headers for middleware error responses (matches API_CACHE_HEADERS in api/error.ts) */
|
|
25
26
|
const MW_CACHE_HEADERS = {
|
|
@@ -30,6 +31,7 @@ import { hasScope } from "../../auth/api-tokens.js";
|
|
|
30
31
|
import { getAuthMode, type ExternalAuthMode } from "../../auth/mode.js";
|
|
31
32
|
import type { ExternalAuthConfig } from "../../auth/types.js";
|
|
32
33
|
import type { EmDashHandlers, EmDashManifest } from "../types.js";
|
|
34
|
+
import { buildEmDashCsp } from "./csp.js";
|
|
33
35
|
|
|
34
36
|
declare global {
|
|
35
37
|
namespace App {
|
|
@@ -49,34 +51,37 @@ declare global {
|
|
|
49
51
|
|
|
50
52
|
// Role level constants (matching @emdash-cms/auth)
|
|
51
53
|
const ROLE_ADMIN = 50;
|
|
54
|
+
const MCP_ENDPOINT_PATH = "/_emdash/api/mcp";
|
|
52
55
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
56
|
+
function isUnsafeMethod(method: string): boolean {
|
|
57
|
+
return method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function csrfRejectedResponse(): Response {
|
|
61
|
+
return new Response(
|
|
62
|
+
JSON.stringify({ error: { code: "CSRF_REJECTED", message: "Missing required header" } }),
|
|
63
|
+
{
|
|
64
|
+
status: 403,
|
|
65
|
+
headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS },
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mcpUnauthorizedResponse(
|
|
71
|
+
url: URL,
|
|
72
|
+
config?: Parameters<typeof getPublicOrigin>[1],
|
|
73
|
+
): Response {
|
|
74
|
+
const origin = getPublicOrigin(url, config);
|
|
75
|
+
return Response.json(
|
|
76
|
+
{ error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } },
|
|
77
|
+
{
|
|
78
|
+
status: 401,
|
|
79
|
+
headers: {
|
|
80
|
+
"WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
|
|
81
|
+
...MW_CACHE_HEADERS,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
);
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
/**
|
|
@@ -108,6 +113,10 @@ const PUBLIC_API_EXACT = new Set([
|
|
|
108
113
|
"/_emdash/api/auth/passkey/verify",
|
|
109
114
|
"/_emdash/api/oauth/token",
|
|
110
115
|
"/_emdash/api/snapshot",
|
|
116
|
+
// Public site search — read-only. The query layer hardcodes status='published'
|
|
117
|
+
// so unauthenticated callers only see published content. Admin endpoints
|
|
118
|
+
// (/enable, /rebuild, /stats) remain private because they're not in this set.
|
|
119
|
+
"/_emdash/api/search",
|
|
111
120
|
]);
|
|
112
121
|
|
|
113
122
|
function isPublicEmDashRoute(pathname: string): boolean {
|
|
@@ -134,7 +143,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
134
143
|
if (isPublicApiRoute) {
|
|
135
144
|
const method = context.request.method.toUpperCase();
|
|
136
145
|
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
|
|
137
|
-
const
|
|
146
|
+
const publicOrigin = getPublicOrigin(url, context.locals.emdash?.config);
|
|
147
|
+
const csrfError = checkPublicCsrf(context.request, url, publicOrigin);
|
|
138
148
|
if (csrfError) return csrfError;
|
|
139
149
|
}
|
|
140
150
|
return next();
|
|
@@ -148,7 +158,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
148
158
|
if (isPluginRoute) {
|
|
149
159
|
const method = context.request.method.toUpperCase();
|
|
150
160
|
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
|
|
151
|
-
const
|
|
161
|
+
const publicOrigin = getPublicOrigin(url, context.locals.emdash?.config);
|
|
162
|
+
const csrfError = checkPublicCsrf(context.request, url, publicOrigin);
|
|
152
163
|
if (csrfError) return csrfError;
|
|
153
164
|
}
|
|
154
165
|
return handlePluginRouteAuth(context, next);
|
|
@@ -192,8 +203,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
192
203
|
};
|
|
193
204
|
// Add WWW-Authenticate header on MCP endpoint 401s to trigger OAuth discovery
|
|
194
205
|
if (url.pathname === "/_emdash/api/mcp") {
|
|
206
|
+
const origin = getPublicOrigin(url, context.locals.emdash?.config);
|
|
195
207
|
headers["WWW-Authenticate"] =
|
|
196
|
-
`Bearer resource_metadata="${
|
|
208
|
+
`Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`;
|
|
197
209
|
}
|
|
198
210
|
return new Response(
|
|
199
211
|
JSON.stringify({ error: { code: "INVALID_TOKEN", message: "Invalid or expired token" } }),
|
|
@@ -203,31 +215,31 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
203
215
|
|
|
204
216
|
const isTokenAuth = bearerResult === "authenticated";
|
|
205
217
|
|
|
218
|
+
// MCP discovery/tooling is bearer-only. Session/external auth should never
|
|
219
|
+
// be consulted for this endpoint, and unauthenticated requests must return
|
|
220
|
+
// the OAuth discovery-style 401 response.
|
|
221
|
+
const method = context.request.method.toUpperCase();
|
|
222
|
+
const isMcpEndpoint = url.pathname === MCP_ENDPOINT_PATH;
|
|
223
|
+
if (isMcpEndpoint && !isTokenAuth) {
|
|
224
|
+
return mcpUnauthorizedResponse(url, context.locals.emdash?.config);
|
|
225
|
+
}
|
|
226
|
+
|
|
206
227
|
// CSRF protection: require X-EmDash-Request header on state-changing requests.
|
|
207
228
|
// Skip for token-authenticated requests (tokens aren't ambient credentials).
|
|
208
229
|
// Browsers block cross-origin custom headers, so this prevents CSRF without tokens.
|
|
209
230
|
// OAuth authorize consent is exempt: it's a standard HTML form POST that can't
|
|
210
231
|
// include custom headers. The consent flow is protected by session + single-use codes.
|
|
211
|
-
const method = context.request.method.toUpperCase();
|
|
212
232
|
const isOAuthConsent = url.pathname.startsWith("/_emdash/oauth/authorize");
|
|
213
233
|
if (
|
|
214
234
|
isApiRoute &&
|
|
215
235
|
!isTokenAuth &&
|
|
216
236
|
!isOAuthConsent &&
|
|
217
|
-
method
|
|
218
|
-
method !== "HEAD" &&
|
|
219
|
-
method !== "OPTIONS" &&
|
|
237
|
+
isUnsafeMethod(method) &&
|
|
220
238
|
!isPublicApiRoute
|
|
221
239
|
) {
|
|
222
240
|
const csrfHeader = context.request.headers.get("X-EmDash-Request");
|
|
223
241
|
if (csrfHeader !== "1") {
|
|
224
|
-
return
|
|
225
|
-
JSON.stringify({ error: { code: "CSRF_REJECTED", message: "Missing required header" } }),
|
|
226
|
-
{
|
|
227
|
-
status: 403,
|
|
228
|
-
headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS },
|
|
229
|
-
},
|
|
230
|
-
);
|
|
242
|
+
return csrfRejectedResponse();
|
|
231
243
|
}
|
|
232
244
|
}
|
|
233
245
|
|
|
@@ -239,8 +251,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
239
251
|
|
|
240
252
|
const response = await next();
|
|
241
253
|
if (!import.meta.env.DEV) {
|
|
242
|
-
|
|
243
|
-
response.headers.set("Content-Security-Policy", buildEmDashCsp(marketplaceUrl));
|
|
254
|
+
response.headers.set("Content-Security-Policy", buildEmDashCsp());
|
|
244
255
|
}
|
|
245
256
|
return response;
|
|
246
257
|
}
|
|
@@ -249,8 +260,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
249
260
|
|
|
250
261
|
// Set strict CSP on all /_emdash responses (prod only)
|
|
251
262
|
if (!import.meta.env.DEV) {
|
|
252
|
-
|
|
253
|
-
response.headers.set("Content-Security-Policy", buildEmDashCsp(marketplaceUrl));
|
|
263
|
+
response.headers.set("Content-Security-Policy", buildEmDashCsp());
|
|
254
264
|
}
|
|
255
265
|
|
|
256
266
|
return response;
|
|
@@ -584,20 +594,13 @@ async function handlePasskeyAuth(
|
|
|
584
594
|
const sessionUser = await session?.get("user");
|
|
585
595
|
|
|
586
596
|
if (!sessionUser?.id) {
|
|
587
|
-
// Not authenticated
|
|
588
597
|
if (isApiRoute) {
|
|
589
|
-
const headers: Record<string, string> = { ...MW_CACHE_HEADERS };
|
|
590
|
-
// Add WWW-Authenticate on MCP endpoint 401s to trigger OAuth discovery
|
|
591
|
-
if (url.pathname === "/_emdash/api/mcp") {
|
|
592
|
-
headers["WWW-Authenticate"] =
|
|
593
|
-
`Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`;
|
|
594
|
-
}
|
|
595
598
|
return Response.json(
|
|
596
599
|
{ error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } },
|
|
597
|
-
{ status: 401, headers },
|
|
600
|
+
{ status: 401, headers: MW_CACHE_HEADERS },
|
|
598
601
|
);
|
|
599
602
|
}
|
|
600
|
-
const loginUrl = new URL("/_emdash/admin/login", url
|
|
603
|
+
const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
|
|
601
604
|
loginUrl.searchParams.set("redirect", url.pathname);
|
|
602
605
|
return context.redirect(loginUrl.toString());
|
|
603
606
|
}
|
|
@@ -615,7 +618,8 @@ async function handlePasskeyAuth(
|
|
|
615
618
|
{ status: 401, headers: MW_CACHE_HEADERS },
|
|
616
619
|
);
|
|
617
620
|
}
|
|
618
|
-
|
|
621
|
+
const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
|
|
622
|
+
return context.redirect(loginUrl.toString());
|
|
619
623
|
}
|
|
620
624
|
|
|
621
625
|
// Check if user is disabled
|
|
@@ -624,7 +628,7 @@ async function handlePasskeyAuth(
|
|
|
624
628
|
if (isApiRoute) {
|
|
625
629
|
return apiError("ACCOUNT_DISABLED", "Account disabled", 403);
|
|
626
630
|
}
|
|
627
|
-
const loginUrl = new URL("/_emdash/admin/login", url
|
|
631
|
+
const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
|
|
628
632
|
loginUrl.searchParams.set("error", "account_disabled");
|
|
629
633
|
return context.redirect(loginUrl.toString());
|
|
630
634
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict Content-Security-Policy for /_emdash routes (admin + API).
|
|
3
|
+
*
|
|
4
|
+
* Applied via middleware header rather than Astro's built-in CSP because
|
|
5
|
+
* Astro's auto-hashing defeats 'unsafe-inline' (CSP3 ignores 'unsafe-inline'
|
|
6
|
+
* when hashes are present), which would break user-facing pages.
|
|
7
|
+
*
|
|
8
|
+
* img-src allows any HTTPS origin because the admin renders user content that
|
|
9
|
+
* may reference external images (migrations, external hosting, embeds).
|
|
10
|
+
* Plugin security does not rely on img-src -- plugins run in V8 isolates with
|
|
11
|
+
* no DOM access, and connect-src 'self' blocks fetch-based exfiltration.
|
|
12
|
+
*/
|
|
13
|
+
export function buildEmDashCsp(): string {
|
|
14
|
+
return [
|
|
15
|
+
"default-src 'self'",
|
|
16
|
+
"script-src 'self' 'unsafe-inline'",
|
|
17
|
+
"style-src 'self' 'unsafe-inline'",
|
|
18
|
+
"connect-src 'self'",
|
|
19
|
+
"form-action 'self'",
|
|
20
|
+
"frame-ancestors 'none'",
|
|
21
|
+
"img-src 'self' https: data: blob:",
|
|
22
|
+
"object-src 'none'",
|
|
23
|
+
"base-uri 'self'",
|
|
24
|
+
].join("; ");
|
|
25
|
+
}
|
package/src/astro/middleware.ts
CHANGED
|
@@ -45,6 +45,7 @@ import type { SandboxRunner } from "../plugins/sandbox/types.js";
|
|
|
45
45
|
import type { ResolvedPlugin } from "../plugins/types.js";
|
|
46
46
|
import { runWithContext } from "../request-context.js";
|
|
47
47
|
import type { EmDashConfig } from "./integration/runtime.js";
|
|
48
|
+
import type { EmDashHandlers } from "./types.js";
|
|
48
49
|
|
|
49
50
|
// Cached runtime instance (persists across requests within worker)
|
|
50
51
|
let runtimeInstance: EmDashRuntime | null = null;
|
|
@@ -177,6 +178,7 @@ function setBaselineSecurityHeaders(response: Response): void {
|
|
|
177
178
|
|
|
178
179
|
/** Public routes that require the runtime (sitemap, robots.txt, etc.) */
|
|
179
180
|
const PUBLIC_RUNTIME_ROUTES = new Set(["/sitemap.xml", "/robots.txt"]);
|
|
181
|
+
const SITEMAP_COLLECTION_RE = /^\/sitemap-[a-z][a-z0-9_]*\.xml$/;
|
|
180
182
|
|
|
181
183
|
export const onRequest = defineMiddleware(async (context, next) => {
|
|
182
184
|
const { request, locals, cookies } = context;
|
|
@@ -185,7 +187,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
185
187
|
// Process /_emdash routes and public routes with an active session
|
|
186
188
|
// (logged-in editors need the runtime for toolbar/visual editing on public pages)
|
|
187
189
|
const isEmDashRoute = url.pathname.startsWith("/_emdash");
|
|
188
|
-
const isPublicRuntimeRoute =
|
|
190
|
+
const isPublicRuntimeRoute =
|
|
191
|
+
PUBLIC_RUNTIME_ROUTES.has(url.pathname) || SITEMAP_COLLECTION_RE.test(url.pathname);
|
|
189
192
|
|
|
190
193
|
// Check for edit mode cookie - editors viewing public pages need the runtime
|
|
191
194
|
// so auth middleware can verify their session for visual editing
|
|
@@ -198,7 +201,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
198
201
|
const playgroundDb = locals.__playgroundDb;
|
|
199
202
|
|
|
200
203
|
if (!isEmDashRoute && !isPublicRuntimeRoute && !hasEditCookie && !hasPreviewToken) {
|
|
201
|
-
const sessionUser = await context.session?.get("user");
|
|
204
|
+
const sessionUser = context.isPrerendered ? null : await context.session?.get("user");
|
|
202
205
|
if (!sessionUser && !playgroundDb) {
|
|
203
206
|
// On a fresh deployment the database may be completely empty.
|
|
204
207
|
// Public pages call getSiteSettings() / getMenu() via getDb(), which
|
|
@@ -222,6 +225,25 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
222
225
|
}
|
|
223
226
|
}
|
|
224
227
|
|
|
228
|
+
// Initialize the runtime for page:metadata and page:fragments hooks.
|
|
229
|
+
// The runtime is a cached singleton — after the first request,
|
|
230
|
+
// getRuntime() is just a null-check. This enables SEO plugins to
|
|
231
|
+
// contribute meta tags for all visitors, not just logged-in editors.
|
|
232
|
+
const config = getConfig();
|
|
233
|
+
if (config) {
|
|
234
|
+
try {
|
|
235
|
+
const runtime = await getRuntime(config);
|
|
236
|
+
setupVerified = true;
|
|
237
|
+
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- partial object; getPageRuntime() only checks for these two methods
|
|
238
|
+
locals.emdash = {
|
|
239
|
+
collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
|
|
240
|
+
collectPageFragments: runtime.collectPageFragments.bind(runtime),
|
|
241
|
+
} as EmDashHandlers;
|
|
242
|
+
} catch {
|
|
243
|
+
// Non-fatal — EmDashHead will fall back to base SEO contributions
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
225
247
|
const response = await next();
|
|
226
248
|
setBaselineSecurityHeaders(response);
|
|
227
249
|
return response;
|
|
@@ -298,6 +320,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
298
320
|
getMediaProvider: runtime.getMediaProvider.bind(runtime),
|
|
299
321
|
getMediaProviderList: runtime.getMediaProviderList.bind(runtime),
|
|
300
322
|
|
|
323
|
+
// Page contribution methods (for EmDashHead/EmDashBodyStart/EmDashBodyEnd)
|
|
324
|
+
collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
|
|
325
|
+
collectPageFragments: runtime.collectPageFragments.bind(runtime),
|
|
326
|
+
|
|
301
327
|
// Direct access (for advanced use cases)
|
|
302
328
|
storage: runtime.storage,
|
|
303
329
|
db: runtime.db,
|
|
@@ -350,7 +376,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
350
376
|
const d1Binding = (virtualGetD1Binding as (config: unknown) => unknown)(dbConfig);
|
|
351
377
|
|
|
352
378
|
if (d1Binding && typeof d1Binding === "object" && "withSession" in d1Binding) {
|
|
353
|
-
const isAuthenticated =
|
|
379
|
+
const isAuthenticated = context.isPrerendered
|
|
380
|
+
? false
|
|
381
|
+
: !!(await context.session?.get("user"));
|
|
354
382
|
const isWrite = request.method !== "GET" && request.method !== "HEAD";
|
|
355
383
|
|
|
356
384
|
// Determine session constraint:
|
|
@@ -7,9 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { AdminApp } from "@emdash-cms/admin";
|
|
10
|
+
import type { Messages } from "@lingui/core";
|
|
10
11
|
// @ts-ignore - virtual module generated by integration
|
|
11
12
|
import { pluginAdmins } from "virtual:emdash/admin-registry";
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
interface AdminWrapperProps {
|
|
15
|
+
locale: string;
|
|
16
|
+
messages: Messages;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function AdminWrapper({ locale, messages }: AdminWrapperProps) {
|
|
20
|
+
return <AdminApp pluginAdmins={pluginAdmins} locale={locale} messages={messages} />;
|
|
15
21
|
}
|
|
@@ -11,10 +11,15 @@ import "@emdash-cms/admin/styles.css";
|
|
|
11
11
|
import AdminWrapper from "emdash/routes/PluginRegistry";
|
|
12
12
|
|
|
13
13
|
export const prerender = false;
|
|
14
|
+
|
|
15
|
+
import { resolveLocale, loadMessages } from "@emdash-cms/admin/locales";
|
|
16
|
+
|
|
17
|
+
const resolvedLocale = resolveLocale(Astro.request);
|
|
18
|
+
const messages = await loadMessages(resolvedLocale);
|
|
14
19
|
---
|
|
15
20
|
|
|
16
21
|
<!doctype html>
|
|
17
|
-
<html lang=
|
|
22
|
+
<html lang={resolvedLocale}>
|
|
18
23
|
<head>
|
|
19
24
|
<meta charset="UTF-8" />
|
|
20
25
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
@@ -75,7 +80,7 @@ export const prerender = false;
|
|
|
75
80
|
<p>Loading EmDash...</p>
|
|
76
81
|
</div>
|
|
77
82
|
</div>
|
|
78
|
-
<AdminWrapper client:only="react" />
|
|
83
|
+
<AdminWrapper client:only="react" locale={resolvedLocale} messages={messages} />
|
|
79
84
|
</div>
|
|
80
85
|
</body>
|
|
81
86
|
</html>
|
|
@@ -9,6 +9,7 @@ import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
|
|
|
9
9
|
import type { APIRoute } from "astro";
|
|
10
10
|
|
|
11
11
|
import { apiError, apiSuccess, handleError } from "#api/error.js";
|
|
12
|
+
import { withTransaction } from "#db/transaction.js";
|
|
12
13
|
|
|
13
14
|
export const prerender = false;
|
|
14
15
|
|
|
@@ -43,20 +44,25 @@ export const POST: APIRoute = async ({ params, locals }) => {
|
|
|
43
44
|
return apiError("NOT_FOUND", "User not found", 404);
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
400,
|
|
54
|
-
);
|
|
47
|
+
// Wrap admin check + disable in a transaction to prevent two
|
|
48
|
+
// concurrent requests from both passing the count check.
|
|
49
|
+
const lastAdminBlocked = await withTransaction(emdash.db, async (trx) => {
|
|
50
|
+
const trxAdapter = createKyselyAdapter(trx);
|
|
51
|
+
if (targetUser.role === Role.ADMIN) {
|
|
52
|
+
const adminCount = await trxAdapter.countAdmins();
|
|
53
|
+
if (adminCount <= 1) return true;
|
|
55
54
|
}
|
|
56
|
-
|
|
55
|
+
await trxAdapter.updateUser(id, { disabled: true });
|
|
56
|
+
return false;
|
|
57
|
+
});
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
if (lastAdminBlocked) {
|
|
60
|
+
return apiError(
|
|
61
|
+
"VALIDATION_ERROR",
|
|
62
|
+
"Cannot disable the last admin. Promote another user first.",
|
|
63
|
+
400,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
60
66
|
|
|
61
67
|
// SEC-43: Revoke all OAuth tokens for the disabled user.
|
|
62
68
|
// Without this, existing refresh tokens remain valid for up to 90 days.
|
|
@@ -12,6 +12,7 @@ import type { APIRoute } from "astro";
|
|
|
12
12
|
import { apiError, apiSuccess, handleError } from "#api/error.js";
|
|
13
13
|
import { isParseError, parseBody } from "#api/parse.js";
|
|
14
14
|
import { userUpdateBody } from "#api/schemas.js";
|
|
15
|
+
import { withTransaction } from "#db/transaction.js";
|
|
15
16
|
|
|
16
17
|
export const prerender = false;
|
|
17
18
|
|
|
@@ -117,13 +118,33 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
|
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
// Wrap admin demotion guard + update in a transaction to prevent
|
|
122
|
+
// two concurrent demotions from both passing the count check.
|
|
123
|
+
const isDemotingAdmin =
|
|
124
|
+
role !== undefined && role < Role.ADMIN && targetUser.role === Role.ADMIN;
|
|
125
|
+
|
|
126
|
+
const lastAdminBlocked = await withTransaction(emdash.db, async (trx) => {
|
|
127
|
+
const trxAdapter = createKyselyAdapter(trx);
|
|
128
|
+
if (isDemotingAdmin) {
|
|
129
|
+
const adminCount = await trxAdapter.countAdmins();
|
|
130
|
+
if (adminCount <= 1) return true;
|
|
131
|
+
}
|
|
132
|
+
await trxAdapter.updateUser(id, {
|
|
133
|
+
name: body.name,
|
|
134
|
+
email: body.email,
|
|
135
|
+
role,
|
|
136
|
+
});
|
|
137
|
+
return false;
|
|
125
138
|
});
|
|
126
139
|
|
|
140
|
+
if (lastAdminBlocked) {
|
|
141
|
+
return apiError(
|
|
142
|
+
"LAST_ADMIN",
|
|
143
|
+
"Cannot demote the last admin. Promote another user first.",
|
|
144
|
+
400,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
127
148
|
// Fetch updated user
|
|
128
149
|
const updated = await adapter.getUserById(id);
|
|
129
150
|
|
|
@@ -15,6 +15,7 @@ import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/pa
|
|
|
15
15
|
|
|
16
16
|
import { apiError, apiSuccess, handleError } from "#api/error.js";
|
|
17
17
|
import { isParseError, parseBody } from "#api/parse.js";
|
|
18
|
+
import { getPublicOrigin } from "#api/public-url.js";
|
|
18
19
|
import { inviteCompleteBody } from "#api/schemas.js";
|
|
19
20
|
import { createChallengeStore } from "#auth/challenge-store.js";
|
|
20
21
|
import { getPasskeyConfig } from "#auth/passkey-config.js";
|
|
@@ -22,7 +23,6 @@ import { OptionsRepository } from "#db/repositories/options.js";
|
|
|
22
23
|
|
|
23
24
|
export const POST: APIRoute = async ({ request, locals, session }) => {
|
|
24
25
|
const { emdash } = locals;
|
|
25
|
-
const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
|
|
26
26
|
|
|
27
27
|
if (!emdash?.db) {
|
|
28
28
|
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
|
|
@@ -38,7 +38,8 @@ export const POST: APIRoute = async ({ request, locals, session }) => {
|
|
|
38
38
|
const url = new URL(request.url);
|
|
39
39
|
const options = new OptionsRepository(emdash.db);
|
|
40
40
|
const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
|
|
41
|
-
const
|
|
41
|
+
const siteUrl = getPublicOrigin(url, emdash?.config);
|
|
42
|
+
const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl);
|
|
42
43
|
|
|
43
44
|
// Verify the passkey registration response
|
|
44
45
|
const challengeStore = createChallengeStore(emdash.db);
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "@emdash-cms/auth";
|
|
18
18
|
import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
|
|
19
19
|
|
|
20
|
+
import { getPublicOrigin } from "#api/public-url.js";
|
|
20
21
|
import { createOAuthStateStore } from "#auth/oauth-state-store.js";
|
|
21
22
|
|
|
22
23
|
type ProviderName = "github" | "google";
|
|
@@ -126,7 +127,7 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect
|
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
const config: OAuthConsumerConfig = {
|
|
129
|
-
baseUrl: `${url
|
|
130
|
+
baseUrl: `${getPublicOrigin(url, emdash?.config)}/_emdash`,
|
|
130
131
|
providers,
|
|
131
132
|
canSelfSignup: async (email: string) => {
|
|
132
133
|
// Extract domain from email
|