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
package/src/import/ssrf.ts
CHANGED
|
@@ -29,6 +29,13 @@ const NAT64_HEX_PATTERN = /^64:ff9b::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;
|
|
|
29
29
|
|
|
30
30
|
const IPV6_BRACKET_PATTERN = /^\[|\]$/g;
|
|
31
31
|
|
|
32
|
+
/** Match fc00::/7 ULA — first byte 0xfc or 0xfd followed by any byte. */
|
|
33
|
+
const IPV6_ULA_FC_PATTERN = /^fc[0-9a-f]{2}:/;
|
|
34
|
+
const IPV6_ULA_FD_PATTERN = /^fd[0-9a-f]{2}:/;
|
|
35
|
+
|
|
36
|
+
/** Strip trailing dots from an FQDN-form hostname ("localhost." -> "localhost"). */
|
|
37
|
+
const TRAILING_DOT_PATTERN = /\.+$/;
|
|
38
|
+
|
|
32
39
|
/**
|
|
33
40
|
* Private and reserved IP ranges that should never be fetched.
|
|
34
41
|
*
|
|
@@ -54,13 +61,35 @@ const BLOCKED_PATTERNS: Array<{ start: number; end: number }> = [
|
|
|
54
61
|
{ start: ip4ToNum(0, 0, 0, 0), end: ip4ToNum(0, 255, 255, 255) },
|
|
55
62
|
];
|
|
56
63
|
|
|
64
|
+
// Bracket-stripped form is used for lookups (validateExternalUrl strips
|
|
65
|
+
// brackets from parsed.hostname before checking), so "::1" appears here
|
|
66
|
+
// without brackets. The "::1" case is already covered by isPrivateIp, but
|
|
67
|
+
// keeping it here makes the intent explicit and gives a clearer error
|
|
68
|
+
// message for the common `http://[::1]/` form.
|
|
57
69
|
const BLOCKED_HOSTNAMES = new Set([
|
|
58
70
|
"localhost",
|
|
59
71
|
"metadata.google.internal",
|
|
60
72
|
"metadata.google",
|
|
61
|
-
"
|
|
73
|
+
"::1",
|
|
62
74
|
]);
|
|
63
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Wildcard DNS services that publicly resolve arbitrary IPs embedded in the
|
|
78
|
+
* hostname. Commonly used in local dev and by SSRF exploit tooling to bypass
|
|
79
|
+
* hostname-only blocklists (e.g. 127.0.0.1.nip.io -> 127.0.0.1).
|
|
80
|
+
*
|
|
81
|
+
* Matched case-insensitively as a suffix, so both the apex and any subdomain
|
|
82
|
+
* are blocked.
|
|
83
|
+
*/
|
|
84
|
+
const BLOCKED_HOSTNAME_SUFFIXES = [
|
|
85
|
+
"nip.io",
|
|
86
|
+
"sslip.io",
|
|
87
|
+
"xip.io",
|
|
88
|
+
"traefik.me",
|
|
89
|
+
"lvh.me",
|
|
90
|
+
"localtest.me",
|
|
91
|
+
];
|
|
92
|
+
|
|
64
93
|
/** Blocked URL schemes */
|
|
65
94
|
const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
|
|
66
95
|
|
|
@@ -115,22 +144,34 @@ export function normalizeIPv6MappedToIPv4(ip: string): string | null {
|
|
|
115
144
|
}
|
|
116
145
|
|
|
117
146
|
function isPrivateIp(ip: string): boolean {
|
|
147
|
+
// Normalize IPv6 strings to lowercase. `new URL().hostname` already
|
|
148
|
+
// lowercases, but resolver output (from DoH or an injected resolver) may
|
|
149
|
+
// not. Without this, "FE80::1" bypasses the link-local check.
|
|
150
|
+
const normalized = ip.toLowerCase();
|
|
151
|
+
|
|
118
152
|
// Handle IPv6 loopback
|
|
119
|
-
if (
|
|
153
|
+
if (normalized === "::1" || normalized === "::ffff:127.0.0.1") return true;
|
|
120
154
|
|
|
121
155
|
// Handle IPv4-mapped IPv6 in hex form (WHATWG URL parser normalizes to this)
|
|
122
156
|
// e.g. ::ffff:7f00:1 -> 127.0.0.1, ::ffff:a9fe:a9fe -> 169.254.169.254
|
|
123
|
-
const hexIpv4 = normalizeIPv6MappedToIPv4(
|
|
157
|
+
const hexIpv4 = normalizeIPv6MappedToIPv4(normalized);
|
|
124
158
|
if (hexIpv4) return isPrivateIp(hexIpv4);
|
|
125
159
|
|
|
126
160
|
// Handle IPv4-mapped IPv6 in dotted-decimal form
|
|
127
|
-
const v4Match =
|
|
128
|
-
const ipv4 = v4Match ? v4Match[1] :
|
|
161
|
+
const v4Match = normalized.match(IPV4_MAPPED_IPV6_DOTTED_PATTERN);
|
|
162
|
+
const ipv4 = v4Match ? v4Match[1] : normalized;
|
|
129
163
|
|
|
130
164
|
const num = parseIpv4(ipv4);
|
|
131
165
|
if (num === null) {
|
|
132
|
-
// If we can't parse it, block IPv6 addresses that look internal
|
|
133
|
-
|
|
166
|
+
// If we can't parse it, block IPv6 addresses that look internal.
|
|
167
|
+
// fc00::/7 is Unique Local (first byte 0xfc or 0xfd), fe80::/10 is
|
|
168
|
+
// link-local. Only match when followed by hex digit + colon to avoid
|
|
169
|
+
// collisions with hypothetical non-address strings.
|
|
170
|
+
return (
|
|
171
|
+
normalized.startsWith("fe80:") ||
|
|
172
|
+
IPV6_ULA_FC_PATTERN.test(normalized) ||
|
|
173
|
+
IPV6_ULA_FD_PATTERN.test(normalized)
|
|
174
|
+
);
|
|
134
175
|
}
|
|
135
176
|
|
|
136
177
|
return BLOCKED_PATTERNS.some((range) => num >= range.start && num <= range.end);
|
|
@@ -182,19 +223,215 @@ export function validateExternalUrl(url: string): URL {
|
|
|
182
223
|
// Strip brackets from IPv6 hostname
|
|
183
224
|
const hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, "");
|
|
184
225
|
|
|
226
|
+
// Normalize the hostname for blocklist matching: lowercase + strip any
|
|
227
|
+
// trailing dots. WHATWG preserves trailing dots on .hostname, so without
|
|
228
|
+
// this normalization "localhost." and "nip.io." bypass the checks.
|
|
229
|
+
const normalizedHost = hostname.toLowerCase().replace(TRAILING_DOT_PATTERN, "");
|
|
230
|
+
|
|
185
231
|
// Check against known internal hostnames
|
|
186
|
-
if (BLOCKED_HOSTNAMES.has(
|
|
232
|
+
if (BLOCKED_HOSTNAMES.has(normalizedHost)) {
|
|
187
233
|
throw new SsrfError("URLs targeting internal hosts are not allowed");
|
|
188
234
|
}
|
|
189
235
|
|
|
190
|
-
// Check
|
|
191
|
-
|
|
236
|
+
// Check against wildcard DNS services used by SSRF tooling to bypass
|
|
237
|
+
// hostname-only checks. Match the apex and any subdomain.
|
|
238
|
+
for (const suffix of BLOCKED_HOSTNAME_SUFFIXES) {
|
|
239
|
+
if (normalizedHost === suffix || normalizedHost.endsWith(`.${suffix}`)) {
|
|
240
|
+
throw new SsrfError("URLs targeting wildcard DNS services are not allowed");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Check if hostname is an IP address in a private range. Use the
|
|
245
|
+
// normalized form so "127.0.0.1.." and friends don't bypass parseIpv4
|
|
246
|
+
// (which rejects extra trailing dots).
|
|
247
|
+
if (isPrivateIp(normalizedHost)) {
|
|
192
248
|
throw new SsrfError("URLs targeting private IP addresses are not allowed");
|
|
193
249
|
}
|
|
194
250
|
|
|
195
251
|
return parsed;
|
|
196
252
|
}
|
|
197
253
|
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// DNS-aware validation
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A resolver that maps a hostname to a list of IPv4/IPv6 addresses.
|
|
260
|
+
* Injectable so callers can swap in OS-level DNS on Node, stub it in tests,
|
|
261
|
+
* or point to a different DoH endpoint.
|
|
262
|
+
*/
|
|
263
|
+
export type DnsResolver = (hostname: string) => Promise<string[]>;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Module-level default resolver. Tests can swap this with a stub so fetch
|
|
267
|
+
* mocks don't see unexpected DoH round-trips. Production code should leave
|
|
268
|
+
* it alone.
|
|
269
|
+
*/
|
|
270
|
+
let defaultResolver: DnsResolver | null = null;
|
|
271
|
+
|
|
272
|
+
/** Override the default DNS resolver. Returns the previous value. */
|
|
273
|
+
export function setDefaultDnsResolver(resolver: DnsResolver | null): DnsResolver | null {
|
|
274
|
+
const previous = defaultResolver;
|
|
275
|
+
defaultResolver = resolver;
|
|
276
|
+
return previous;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Timeout for a single DoH request, in milliseconds. */
|
|
280
|
+
const DOH_TIMEOUT_MS = 3000;
|
|
281
|
+
|
|
282
|
+
/** Default DoH endpoint — Cloudflare's public resolver. */
|
|
283
|
+
const DEFAULT_DOH_URL = "https://cloudflare-dns.com/dns-query";
|
|
284
|
+
|
|
285
|
+
interface DohAnswer {
|
|
286
|
+
data: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
interface DohResponse {
|
|
290
|
+
Status: number;
|
|
291
|
+
Answer: DohAnswer[];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function hasProperty<K extends string>(obj: unknown, key: K): obj is Record<K, unknown> {
|
|
295
|
+
return typeof obj === "object" && obj !== null && key in obj;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Narrow an unknown JSON body to a DohResponse shape we can read safely.
|
|
300
|
+
* Throws if the body doesn't look like a DoH response — a malformed body is
|
|
301
|
+
* indistinguishable from a failure and must not be silently treated as empty.
|
|
302
|
+
*/
|
|
303
|
+
function parseDohResponse(raw: unknown): DohResponse {
|
|
304
|
+
if (!hasProperty(raw, "Status") || typeof raw.Status !== "number") {
|
|
305
|
+
throw new Error("DoH response missing Status field");
|
|
306
|
+
}
|
|
307
|
+
const answers: DohAnswer[] = [];
|
|
308
|
+
if (hasProperty(raw, "Answer") && Array.isArray(raw.Answer)) {
|
|
309
|
+
for (const entry of raw.Answer) {
|
|
310
|
+
if (hasProperty(entry, "data") && typeof entry.data === "string") {
|
|
311
|
+
answers.push({ data: entry.data });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { Status: raw.Status, Answer: answers };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Resolve a hostname via DNS over HTTPS (Cloudflare). Returns all A and AAAA
|
|
320
|
+
* records. Works in both Workers and Node without requiring node:dns.
|
|
321
|
+
*
|
|
322
|
+
* Fails closed: any network error, non-2xx response, or DNS rcode != 0
|
|
323
|
+
* causes a rejected promise so the calling validator treats it as a block.
|
|
324
|
+
*/
|
|
325
|
+
export const cloudflareDohResolver: DnsResolver = async (hostname) => {
|
|
326
|
+
async function query(type: "A" | "AAAA"): Promise<string[]> {
|
|
327
|
+
const params = new URLSearchParams({ name: hostname, type });
|
|
328
|
+
const controller = new AbortController();
|
|
329
|
+
const timeout = setTimeout(() => controller.abort(), DOH_TIMEOUT_MS);
|
|
330
|
+
try {
|
|
331
|
+
const response = await globalThis.fetch(`${DEFAULT_DOH_URL}?${params.toString()}`, {
|
|
332
|
+
headers: { Accept: "application/dns-json" },
|
|
333
|
+
signal: controller.signal,
|
|
334
|
+
});
|
|
335
|
+
if (!response.ok) {
|
|
336
|
+
throw new Error(`DoH lookup failed: ${response.status}`);
|
|
337
|
+
}
|
|
338
|
+
const raw = await response.json();
|
|
339
|
+
const body = parseDohResponse(raw);
|
|
340
|
+
// NXDOMAIN (3) is a legitimate "does not exist" — treat as empty.
|
|
341
|
+
// Any other non-zero status (SERVFAIL=2, REFUSED=5, etc.) is
|
|
342
|
+
// ambiguous and could be a split-view attacker hiding records
|
|
343
|
+
// from our resolver. Fail closed.
|
|
344
|
+
if (body.Status === 3) return [];
|
|
345
|
+
if (body.Status !== 0) {
|
|
346
|
+
throw new Error(`DoH ${type} lookup failed: rcode=${body.Status}`);
|
|
347
|
+
}
|
|
348
|
+
// DoH Answer arrays often include CNAME records alongside A/AAAA
|
|
349
|
+
// records. Their `data` is a hostname, not an IP. Filter to just
|
|
350
|
+
// IP literals so isPrivateIp sees real addresses.
|
|
351
|
+
return body.Answer.map((a) => a.data).filter(isIpLiteral);
|
|
352
|
+
} finally {
|
|
353
|
+
clearTimeout(timeout);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const [a, aaaa] = await Promise.all([query("A"), query("AAAA")]);
|
|
358
|
+
return [...a, ...aaaa];
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Validate a URL and resolve its hostname to check the actual IPs against
|
|
363
|
+
* the private-range blocklist. This catches DNS rebinding attacks using
|
|
364
|
+
* attacker-controlled domains that publicly resolve to private addresses,
|
|
365
|
+
* and wildcard DNS services like nip.io used by exploit tooling.
|
|
366
|
+
*
|
|
367
|
+
* Runs `validateExternalUrl` first for cheap pre-flight checks (scheme,
|
|
368
|
+
* literal IP, known-bad hostnames). Then resolves the hostname and rejects
|
|
369
|
+
* if ANY returned address is private.
|
|
370
|
+
*
|
|
371
|
+
* Fails closed: if resolution fails or returns no records, throws SsrfError.
|
|
372
|
+
*
|
|
373
|
+
* **Caveats.** This does NOT fully close the TOCTOU between check and
|
|
374
|
+
* connect. Attacks that still work against this layer include:
|
|
375
|
+
*
|
|
376
|
+
* - TTL=0 rebind: authoritative server returns public IP to the check, then
|
|
377
|
+
* private IP to the subsequent fetch() a few milliseconds later.
|
|
378
|
+
* - Split-view via EDNS Client Subnet or source-IP inspection: the
|
|
379
|
+
* authoritative server returns public IP to Cloudflare's DoH resolver and
|
|
380
|
+
* private IP to the victim's own resolver (used by fetch()).
|
|
381
|
+
* - Host-file overrides or split-horizon corporate DNS on self-hosted Node.
|
|
382
|
+
* - Attacker-controlled rebinding services the caller has allowlisted.
|
|
383
|
+
*
|
|
384
|
+
* The only complete defense is a network-layer egress firewall. On
|
|
385
|
+
* Cloudflare Workers, the platform fetch pipeline provides most of that.
|
|
386
|
+
* On self-hosted Node, operators must restrict egress themselves.
|
|
387
|
+
*/
|
|
388
|
+
export async function resolveAndValidateExternalUrl(
|
|
389
|
+
url: string,
|
|
390
|
+
options?: { resolver?: DnsResolver },
|
|
391
|
+
): Promise<URL> {
|
|
392
|
+
const parsed = validateExternalUrl(url);
|
|
393
|
+
|
|
394
|
+
// Strip brackets from IPv6 hostnames
|
|
395
|
+
const hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, "");
|
|
396
|
+
|
|
397
|
+
// If the hostname is already an IP literal, validateExternalUrl has
|
|
398
|
+
// already checked it against the private-range list. Skip DNS.
|
|
399
|
+
if (isIpLiteral(hostname)) {
|
|
400
|
+
return parsed;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const resolver = options?.resolver ?? defaultResolver ?? cloudflareDohResolver;
|
|
404
|
+
|
|
405
|
+
let addresses: string[];
|
|
406
|
+
try {
|
|
407
|
+
addresses = await resolver(hostname);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
throw new SsrfError(
|
|
410
|
+
`Could not resolve hostname: ${error instanceof Error ? error.message : String(error)}`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (addresses.length === 0) {
|
|
415
|
+
throw new SsrfError("Hostname resolved to no addresses");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
for (const ip of addresses) {
|
|
419
|
+
if (isPrivateIp(ip)) {
|
|
420
|
+
throw new SsrfError("Hostname resolves to a private IP address");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return parsed;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** True when a string looks like an IPv4 or IPv6 literal. */
|
|
428
|
+
function isIpLiteral(host: string): boolean {
|
|
429
|
+
if (parseIpv4(host) !== null) return true;
|
|
430
|
+
// Very loose IPv6 heuristic — matches anything with a colon, which is
|
|
431
|
+
// never valid in DNS hostnames, so this is safe.
|
|
432
|
+
return host.includes(":");
|
|
433
|
+
}
|
|
434
|
+
|
|
198
435
|
/**
|
|
199
436
|
* Fetch a URL with SSRF protection on redirects.
|
|
200
437
|
*
|
|
@@ -208,12 +445,16 @@ export function validateExternalUrl(url: string): URL {
|
|
|
208
445
|
/** Headers that must be stripped when a redirect crosses origins */
|
|
209
446
|
const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"];
|
|
210
447
|
|
|
211
|
-
export async function ssrfSafeFetch(
|
|
448
|
+
export async function ssrfSafeFetch(
|
|
449
|
+
url: string,
|
|
450
|
+
init?: RequestInit,
|
|
451
|
+
options?: { resolver?: DnsResolver },
|
|
452
|
+
): Promise<Response> {
|
|
212
453
|
let currentUrl = url;
|
|
213
454
|
let currentInit = init;
|
|
214
455
|
|
|
215
456
|
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
|
216
|
-
|
|
457
|
+
await resolveAndValidateExternalUrl(currentUrl, options);
|
|
217
458
|
|
|
218
459
|
const response = await globalThis.fetch(currentUrl, {
|
|
219
460
|
...currentInit,
|
package/src/index.ts
CHANGED
|
@@ -130,6 +130,10 @@ export type {
|
|
|
130
130
|
export { getRequestContext, runWithContext } from "./request-context.js";
|
|
131
131
|
export type { EmDashRequestContext } from "./request-context.js";
|
|
132
132
|
|
|
133
|
+
// Defer work past the response (waitUntil on workerd, fire-and-forget on Node)
|
|
134
|
+
export { after } from "./after.js";
|
|
135
|
+
export type { WaitUntilFn } from "./after.js";
|
|
136
|
+
|
|
133
137
|
// i18n configuration (from Astro config)
|
|
134
138
|
export { getI18nConfig, isI18nEnabled, getFallbackChain } from "./i18n/config.js";
|
|
135
139
|
export type { I18nConfig } from "./i18n/config.js";
|
|
@@ -400,7 +404,9 @@ export {
|
|
|
400
404
|
getTerm,
|
|
401
405
|
getEntryTerms,
|
|
402
406
|
getTermsForEntries,
|
|
407
|
+
getAllTermsForEntries,
|
|
403
408
|
getEntriesByTerm,
|
|
409
|
+
invalidateTermCache,
|
|
404
410
|
} from "./taxonomies/index.js";
|
|
405
411
|
export type {
|
|
406
412
|
TaxonomyDef,
|
package/src/loader.ts
CHANGED
|
@@ -15,10 +15,12 @@ import type { LiveLoader } from "astro/loaders";
|
|
|
15
15
|
import { Kysely, sql, type Dialect } from "kysely";
|
|
16
16
|
|
|
17
17
|
import { currentTimestampValue, isPostgres } from "./database/dialect-helpers.js";
|
|
18
|
+
import { kyselyLogOption } from "./database/instrumentation.js";
|
|
18
19
|
import { decodeCursor, encodeCursor } from "./database/repositories/types.js";
|
|
19
20
|
import { validateIdentifier } from "./database/validate.js";
|
|
20
21
|
import type { Database } from "./index.js";
|
|
21
22
|
import { getRequestContext } from "./request-context.js";
|
|
23
|
+
import { isMissingTableError } from "./utils/db-errors.js";
|
|
22
24
|
|
|
23
25
|
const FIELD_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
24
26
|
|
|
@@ -63,26 +65,29 @@ function getTableName(type: string): string {
|
|
|
63
65
|
let taxonomyNames: Set<string> | null = null;
|
|
64
66
|
|
|
65
67
|
/**
|
|
66
|
-
* Get all taxonomy names (cached for primary DB,
|
|
68
|
+
* Get all taxonomy names (cached for the primary DB, bypassed only when
|
|
69
|
+
* the per-request DB is an isolated instance — playground / DO preview).
|
|
70
|
+
* Plain D1 Sessions routing shares schema with the singleton, so the
|
|
71
|
+
* module-scoped cache stays valid.
|
|
67
72
|
*/
|
|
68
73
|
async function getTaxonomyNames(db: Kysely<Database>): Promise<Set<string>> {
|
|
69
|
-
const
|
|
74
|
+
const hasIsolatedDb = getRequestContext()?.dbIsIsolated === true;
|
|
70
75
|
|
|
71
|
-
if (!
|
|
76
|
+
if (!hasIsolatedDb && taxonomyNames) {
|
|
72
77
|
return taxonomyNames;
|
|
73
78
|
}
|
|
74
79
|
|
|
75
80
|
try {
|
|
76
81
|
const defs = await db.selectFrom("_emdash_taxonomy_defs").select("name").execute();
|
|
77
82
|
const names = new Set(defs.map((d) => d.name));
|
|
78
|
-
if (!
|
|
83
|
+
if (!hasIsolatedDb) {
|
|
79
84
|
taxonomyNames = names;
|
|
80
85
|
}
|
|
81
86
|
return names;
|
|
82
87
|
} catch {
|
|
83
88
|
// Table doesn't exist yet, return empty set
|
|
84
89
|
const empty = new Set<string>();
|
|
85
|
-
if (!
|
|
90
|
+
if (!hasIsolatedDb) {
|
|
86
91
|
taxonomyNames = empty;
|
|
87
92
|
}
|
|
88
93
|
return empty;
|
|
@@ -406,7 +411,7 @@ export async function getDb(): Promise<Kysely<Database>> {
|
|
|
406
411
|
);
|
|
407
412
|
}
|
|
408
413
|
const dialect = virtualCreateDialect(virtualConfig.database.config);
|
|
409
|
-
dbInstance = new Kysely<Database>({ dialect });
|
|
414
|
+
dbInstance = new Kysely<Database>({ dialect, log: kyselyLogOption() });
|
|
410
415
|
}
|
|
411
416
|
return dbInstance;
|
|
412
417
|
}
|
|
@@ -617,18 +622,13 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
|
|
|
617
622
|
},
|
|
618
623
|
};
|
|
619
624
|
} catch (error) {
|
|
620
|
-
// Handle missing table gracefully - return empty collection
|
|
621
|
-
// This happens before migrations have run
|
|
622
|
-
|
|
623
|
-
const lowerMessage = message.toLowerCase();
|
|
624
|
-
if (
|
|
625
|
-
lowerMessage.includes("no such table") ||
|
|
626
|
-
(lowerMessage.includes("table") && lowerMessage.includes("does not exist")) ||
|
|
627
|
-
(lowerMessage.includes("relation") && lowerMessage.includes("does not exist"))
|
|
628
|
-
) {
|
|
625
|
+
// Handle missing table gracefully - return empty collection.
|
|
626
|
+
// This happens before migrations have run.
|
|
627
|
+
if (isMissingTableError(error)) {
|
|
629
628
|
return { entries: [] };
|
|
630
629
|
}
|
|
631
630
|
|
|
631
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
632
632
|
return {
|
|
633
633
|
error: new Error(`Failed to load collection: ${message}`),
|
|
634
634
|
};
|
|
@@ -751,18 +751,13 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
|
|
|
751
751
|
},
|
|
752
752
|
};
|
|
753
753
|
} catch (error) {
|
|
754
|
-
// Handle missing table gracefully - return undefined (not found)
|
|
755
|
-
// This happens before migrations have run
|
|
756
|
-
|
|
757
|
-
const lowerMessage = message.toLowerCase();
|
|
758
|
-
if (
|
|
759
|
-
lowerMessage.includes("no such table") ||
|
|
760
|
-
(lowerMessage.includes("table") && lowerMessage.includes("does not exist")) ||
|
|
761
|
-
(lowerMessage.includes("relation") && lowerMessage.includes("does not exist"))
|
|
762
|
-
) {
|
|
754
|
+
// Handle missing table gracefully - return undefined (not found).
|
|
755
|
+
// This happens before migrations have run.
|
|
756
|
+
if (isMissingTableError(error)) {
|
|
763
757
|
return undefined;
|
|
764
758
|
}
|
|
765
759
|
|
|
760
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
766
761
|
return {
|
|
767
762
|
error: new Error(`Failed to load entry: ${message}`),
|
|
768
763
|
};
|
package/src/mcp/server.ts
CHANGED
|
@@ -84,6 +84,15 @@ interface EmDashExtra {
|
|
|
84
84
|
tokenScopes?: string[];
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function isPublished(t: unknown): boolean {
|
|
88
|
+
return (
|
|
89
|
+
typeof t === "object" &&
|
|
90
|
+
t !== null &&
|
|
91
|
+
"status" in t &&
|
|
92
|
+
(t as Record<string, unknown>).status === "published"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
87
96
|
function getExtra(extra: { authInfo?: { extra?: Record<string, unknown> } }): EmDashExtra {
|
|
88
97
|
const payload = extra.authInfo?.extra as EmDashExtra | undefined;
|
|
89
98
|
if (!payload?.emdash) {
|
|
@@ -130,6 +139,26 @@ function requireRole(
|
|
|
130
139
|
}
|
|
131
140
|
}
|
|
132
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Whether the current user may read non-published content (drafts, scheduled,
|
|
144
|
+
* trashed, revisions, compare). SUBSCRIBER may hold content:read for
|
|
145
|
+
* member-only published content but must not see drafts.
|
|
146
|
+
*/
|
|
147
|
+
function canReadDrafts(extra: { authInfo?: { extra?: Record<string, unknown> } }): boolean {
|
|
148
|
+
const payload = getExtra(extra);
|
|
149
|
+
return hasPermission({ role: payload.userRole }, "content:read_drafts");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Throw if the current user cannot read non-published content. Used by
|
|
154
|
+
* editor-only views (revisions, compare, trash, preview-url).
|
|
155
|
+
*/
|
|
156
|
+
function requireDraftAccess(extra: { authInfo?: { extra?: Record<string, unknown> } }): void {
|
|
157
|
+
if (!canReadDrafts(extra)) {
|
|
158
|
+
throw new McpError(ErrorCode.InvalidRequest, "Insufficient permissions for this operation");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
133
162
|
/**
|
|
134
163
|
* Enforce ownership-based permission checks, mirroring the REST API's
|
|
135
164
|
* requireOwnerPerm() pattern.
|
|
@@ -240,9 +269,12 @@ export function createMcpServer(): McpServer {
|
|
|
240
269
|
async (args, extra) => {
|
|
241
270
|
requireScope(extra, "content:read");
|
|
242
271
|
const ec = getEmDash(extra);
|
|
272
|
+
// Subscribers must only see published content; force the status
|
|
273
|
+
// filter regardless of caller-supplied value.
|
|
274
|
+
const status = canReadDrafts(extra) ? args.status : "published";
|
|
243
275
|
return unwrap(
|
|
244
276
|
await ec.handleContentList(args.collection, {
|
|
245
|
-
status
|
|
277
|
+
status,
|
|
246
278
|
limit: args.limit,
|
|
247
279
|
cursor: args.cursor,
|
|
248
280
|
orderBy: args.orderBy,
|
|
@@ -276,7 +308,29 @@ export function createMcpServer(): McpServer {
|
|
|
276
308
|
async (args, extra) => {
|
|
277
309
|
requireScope(extra, "content:read");
|
|
278
310
|
const ec = getEmDash(extra);
|
|
279
|
-
|
|
311
|
+
const result = await ec.handleContentGet(args.collection, args.id, args.locale);
|
|
312
|
+
// Hide non-published items from users without draft access. Return a
|
|
313
|
+
// not-found error so subscribers can't enumerate draft IDs by status.
|
|
314
|
+
if (result.success && !canReadDrafts(extra)) {
|
|
315
|
+
const data =
|
|
316
|
+
result.data && typeof result.data === "object"
|
|
317
|
+
? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- handler returns unknown data; narrowed by typeof check
|
|
318
|
+
(result.data as Record<string, unknown>)
|
|
319
|
+
: undefined;
|
|
320
|
+
const item =
|
|
321
|
+
data?.item && typeof data.item === "object"
|
|
322
|
+
? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- narrowed by typeof check
|
|
323
|
+
(data.item as Record<string, unknown>)
|
|
324
|
+
: undefined;
|
|
325
|
+
const status = typeof item?.status === "string" ? item.status : null;
|
|
326
|
+
if (status !== "published") {
|
|
327
|
+
return unwrap({
|
|
328
|
+
success: false,
|
|
329
|
+
error: { code: "NOT_FOUND", message: `Content item not found: ${args.id}` },
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return unwrap(result);
|
|
280
334
|
},
|
|
281
335
|
);
|
|
282
336
|
|
|
@@ -676,6 +730,7 @@ export function createMcpServer(): McpServer {
|
|
|
676
730
|
},
|
|
677
731
|
async (args, extra) => {
|
|
678
732
|
requireScope(extra, "content:read");
|
|
733
|
+
requireDraftAccess(extra);
|
|
679
734
|
const ec = getEmDash(extra);
|
|
680
735
|
return unwrap(await ec.handleContentCompare(args.collection, args.id));
|
|
681
736
|
},
|
|
@@ -733,6 +788,7 @@ export function createMcpServer(): McpServer {
|
|
|
733
788
|
},
|
|
734
789
|
async (args, extra) => {
|
|
735
790
|
requireScope(extra, "content:read");
|
|
791
|
+
requireDraftAccess(extra);
|
|
736
792
|
const ec = getEmDash(extra);
|
|
737
793
|
return unwrap(
|
|
738
794
|
await ec.handleContentListTrashed(args.collection, {
|
|
@@ -780,7 +836,23 @@ export function createMcpServer(): McpServer {
|
|
|
780
836
|
async (args, extra) => {
|
|
781
837
|
requireScope(extra, "content:read");
|
|
782
838
|
const ec = getEmDash(extra);
|
|
783
|
-
|
|
839
|
+
const result = await ec.handleContentTranslations(args.collection, args.id);
|
|
840
|
+
// Filter out non-published translations for users without draft
|
|
841
|
+
// access so a subscriber can't enumerate locales that aren't yet live.
|
|
842
|
+
if (result.success && !canReadDrafts(extra)) {
|
|
843
|
+
const data =
|
|
844
|
+
result.data && typeof result.data === "object"
|
|
845
|
+
? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- handler returns unknown data; narrowed by typeof check
|
|
846
|
+
(result.data as Record<string, unknown>)
|
|
847
|
+
: undefined;
|
|
848
|
+
const translations = Array.isArray(data?.translations) ? data.translations : [];
|
|
849
|
+
const filtered = translations.filter(isPublished);
|
|
850
|
+
return unwrap({
|
|
851
|
+
success: true,
|
|
852
|
+
data: { ...data, translations: filtered },
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
return unwrap(result);
|
|
784
856
|
},
|
|
785
857
|
);
|
|
786
858
|
|
|
@@ -1460,6 +1532,7 @@ export function createMcpServer(): McpServer {
|
|
|
1460
1532
|
},
|
|
1461
1533
|
async (args, extra) => {
|
|
1462
1534
|
requireScope(extra, "content:read");
|
|
1535
|
+
requireDraftAccess(extra);
|
|
1463
1536
|
const ec = getEmDash(extra);
|
|
1464
1537
|
return unwrap(
|
|
1465
1538
|
await ec.handleRevisionList(args.collection, args.id, {
|
package/src/menus/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { sql } from "kysely";
|
|
|
10
10
|
import type { Database } from "../database/types.js";
|
|
11
11
|
import { validateIdentifier } from "../database/validate.js";
|
|
12
12
|
import { getDb } from "../loader.js";
|
|
13
|
+
import { requestCached } from "../request-cache.js";
|
|
13
14
|
import { sanitizeHref } from "../utils/url.js";
|
|
14
15
|
import type { Menu, MenuItem, MenuItemRow } from "./types.js";
|
|
15
16
|
|
|
@@ -26,9 +27,11 @@ import type { Menu, MenuItem, MenuItemRow } from "./types.js";
|
|
|
26
27
|
* }
|
|
27
28
|
* ```
|
|
28
29
|
*/
|
|
29
|
-
export
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
export function getMenu(name: string): Promise<Menu | null> {
|
|
31
|
+
return requestCached(`menu:${name}`, async () => {
|
|
32
|
+
const db = await getDb();
|
|
33
|
+
return getMenuWithDb(name, db);
|
|
34
|
+
});
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/**
|
package/src/page/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ export type { ResolvedPageMetadata } from "./metadata.js";
|
|
|
24
24
|
|
|
25
25
|
export { resolveFragments, renderFragments } from "./fragments.js";
|
|
26
26
|
|
|
27
|
-
export { generateBaseSeoContributions } from "./seo-contributions.js";
|
|
27
|
+
export { generateBaseSeoContributions, generateSiteSeoContributions } from "./seo-contributions.js";
|
|
28
28
|
export { cleanJsonLd, buildBlogPostingJsonLd, buildWebSiteJsonLd } from "./jsonld.js";
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { PageMetadataContribution, PublicPageContext } from "../plugins/types.js";
|
|
13
|
+
import type { SeoSettings } from "../settings/types.js";
|
|
13
14
|
import { buildBlogPostingJsonLd, buildWebSiteJsonLd } from "./jsonld.js";
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -134,3 +135,38 @@ export function generateBaseSeoContributions(page: PublicPageContext): PageMetad
|
|
|
134
135
|
|
|
135
136
|
return contributions;
|
|
136
137
|
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Generate site-level SEO metadata contributions from SiteSettings.seo.
|
|
141
|
+
*
|
|
142
|
+
* These tags apply to every page (search engine ownership verification),
|
|
143
|
+
* so they're sourced from site settings rather than per-page context.
|
|
144
|
+
* Returns an empty array when no relevant settings are configured.
|
|
145
|
+
*/
|
|
146
|
+
export function generateSiteSeoContributions(
|
|
147
|
+
seoSettings: SeoSettings | undefined,
|
|
148
|
+
): PageMetadataContribution[] {
|
|
149
|
+
const contributions: PageMetadataContribution[] = [];
|
|
150
|
+
|
|
151
|
+
if (!seoSettings) {
|
|
152
|
+
return contributions;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (seoSettings.googleVerification) {
|
|
156
|
+
contributions.push({
|
|
157
|
+
kind: "meta",
|
|
158
|
+
name: "google-site-verification",
|
|
159
|
+
content: seoSettings.googleVerification,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (seoSettings.bingVerification) {
|
|
164
|
+
contributions.push({
|
|
165
|
+
kind: "meta",
|
|
166
|
+
name: "msvalidate.01",
|
|
167
|
+
content: seoSettings.bingVerification,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return contributions;
|
|
172
|
+
}
|