jamdesk 1.1.167 → 1.1.169

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.
@@ -15,11 +15,12 @@ import {
15
15
  isSubdomain,
16
16
  } from './project-resolver';
17
17
  import { redis } from './redis';
18
- import { getDomainMapping, getDomainStatus, getProjectInactive } from './cached-redis';
19
- import { getForwardedHosts, isJamdeskDomain } from './domain-helpers';
18
+ import { getDomainConfig, getDomainMapping, getDomainStatus, getProjectInactive } from './cached-redis';
19
+ import { getForwardedHosts, isJamdeskDomain, normalizeForwardedHost, parseRedisConfig } from './domain-helpers';
20
20
  import { getRedirects, matchRedirect, mergeQueryStrings, isInvalidDestination } from './redirect-matcher';
21
21
  import { ASSET_PREFIX } from './docs-types';
22
22
  import { extractLanguageFromPath } from './language-utils';
23
+ import { NextResponse } from 'next/server';
23
24
  import type { NextRequest } from 'next/server';
24
25
 
25
26
  /**
@@ -77,6 +78,13 @@ export interface ProjectResolutionResult {
77
78
  * URL indexing — the upstream subdomain is not the public face).
78
79
  */
79
80
  customDomain?: string;
81
+ // NOTE: there is deliberately no `customDomainOnly` here. The gate
82
+ // (`customDomainOnlyBlock`) re-derives it from `getProjectConfig(slug)` on
83
+ // purpose — one of its call sites has only a slug parsed out of the URL, and
84
+ // a gate that trusts a caller-supplied resolution is a gate that diverges
85
+ // from the one the other call site enforces. Do not add the field back "to
86
+ // save a Redis read": the read is TtlCached, and the field would be an
87
+ // invitation to trust it.
80
88
  }
81
89
 
82
90
  /**
@@ -880,3 +888,258 @@ export function getAssetsApiPath(pathname: string): string {
880
888
  .replace(/^public\//, '');
881
889
  return `/api/assets/${assetPath}`;
882
890
  }
891
+
892
+ /**
893
+ * Domain-side validation for the canonical override.
894
+ *
895
+ * `resolution.customDomain` is a projectCfg-side MIRROR: registerDomain
896
+ * writes it PRE-verification, and clearCustomDomainOnRemove's best-effort
897
+ * Redis delete can fail — so the mirror alone can be premature (domain
898
+ * never activated) or stale (domain removed). The DOMAIN-side records
899
+ * (`domain:<host>`, `domainStatus:<host>`, `domainCfg:<host>`) are written
900
+ * only at activation and deleted on removal, so they are the source of
901
+ * truth:
902
+ * - the mapping must point back at this project, and
903
+ * - domainStatus must be 'active' (or 'cached', mirroring how
904
+ * handleProjectResolution treats domainStatus).
905
+ *
906
+ * Returns null when validation fails — stale/premature mirror, or a
907
+ * transient Redis error (never break the request over a canonical tag).
908
+ * On success returns the domain's OWN serving mode from domainCfg
909
+ * (hostAtDocs defaults true when the field is absent, mirroring
910
+ * resolveCustomDomain), or `{ hostAtDocs: undefined }` when the domainCfg
911
+ * record is missing entirely (legacy domains) so the caller preserves the
912
+ * projectCfg-derived URL shape.
913
+ *
914
+ * Hot-path note: all three reads go through the TtlCache-fronted
915
+ * cached-redis helpers (60s positive TTL, single-flight), batched into one
916
+ * parallel round-trip — at most one Redis fetch per domain per instance
917
+ * per TTL window.
918
+ *
919
+ * Lives here (not proxy.ts) because `customDomainOnlyBlock` below needs it
920
+ * too — a helper used from two modules belongs in the shared one.
921
+ * `proxy.ts` re-exports it so existing imports keep resolving.
922
+ */
923
+ export async function validateCanonicalDomain(
924
+ customDomain: string,
925
+ projectSlug: string | null,
926
+ ): Promise<{ hostAtDocs: boolean | undefined } | null> {
927
+ try {
928
+ const [mapping, status, cfgRaw] = await Promise.all([
929
+ getDomainMapping(customDomain),
930
+ getDomainStatus(customDomain),
931
+ getDomainConfig(customDomain),
932
+ ]);
933
+ if (!projectSlug || mapping !== projectSlug) {
934
+ log('warn', '[canonical-domain-stale]', { customDomain, projectSlug, mapping });
935
+ return null;
936
+ }
937
+ if (status !== 'active' && status !== 'cached') {
938
+ log('warn', '[canonical-domain-inactive]', { customDomain, projectSlug, status });
939
+ return null;
940
+ }
941
+ const cfg = parseRedisConfig(cfgRaw);
942
+ if (!cfg) return { hostAtDocs: undefined };
943
+ return { hostAtDocs: cfg.hostAtDocs !== false };
944
+ } catch (e) {
945
+ log('warn', '[canonical-domain-check-failed]', { customDomain, error: (e as Error).message });
946
+ return null;
947
+ }
948
+ }
949
+
950
+ // ─────────────────────────────────────────────────────────────────────────
951
+ // "Custom domain only" mode
952
+ //
953
+ // When a project turns this on, a direct hit on <slug>.jamdesk.app 404s unless
954
+ // the request looks like it came through the customer's reverse proxy.
955
+ //
956
+ // This is VISIBILITY control, not ACCESS control: the marker below is a
957
+ // public, documented constant that anyone can append. It keeps crawlers,
958
+ // slug-guessers and idle clicks off the bare subdomain. Customers who need
959
+ // real access control use password protection — which the code below must
960
+ // never, ever bypass.
961
+ // ─────────────────────────────────────────────────────────────────────────
962
+
963
+ /**
964
+ * The public query marker a reverse proxy appends when it cannot set a request
965
+ * header. Vercel `rewrites` — the shape the dashboard hands out, and the shape
966
+ * most Next.js customers use — can carry a query param in the destination but
967
+ * CANNOT set an upstream header. This exists for them.
968
+ *
969
+ * Namespaced on purpose: a bare `proxy` would collide with a customer's own
970
+ * query params. DOCUMENTED AND PUBLIC — changing this string breaks every
971
+ * deployed customer rewrite.
972
+ */
973
+ export const PROXY_MARKER_PARAM = 'jd_proxy';
974
+
975
+ /**
976
+ * Read the marker and strip it from the URL in one shot, at a single
977
+ * chokepoint, before anything downstream reads the query string.
978
+ *
979
+ * Stripping matters because `request.nextUrl` feeds redirect Locations
980
+ * (trailing-slash, hostAtDocs) and the auth gate's `redirectFrom`. Left in, the
981
+ * marker bounces back to the browser on the customer's own domain, their proxy
982
+ * re-appends it, and it accumulates. It is not a secret — this is hygiene.
983
+ *
984
+ * CAVEAT: this mutates `request.nextUrl`, which NextResponse.next() does not
985
+ * propagate as a URL rewrite. Middleware-internal consumers see the stripped
986
+ * URL; the rendered page's own `searchParams` may still contain the marker.
987
+ * See the Test Strategy's manual-verification list.
988
+ *
989
+ * A present-but-wrong value (`?jd_proxy=yes`) is NOT proxied, but is still
990
+ * stripped.
991
+ */
992
+ export function consumeProxyMarker(request: NextRequest): boolean {
993
+ const raw = request.nextUrl.searchParams.get(PROXY_MARKER_PARAM);
994
+ if (raw === null) return false;
995
+ request.nextUrl.searchParams.delete(PROXY_MARKER_PARAM);
996
+ return raw === '1';
997
+ }
998
+
999
+ /**
1000
+ * Extensions that carry no documentation content and are fetched cross-origin
1001
+ * from <slug>.jamdesk.app by things that cannot attach a marker: the
1002
+ * dashboard's site-preview <img>, and the editor preview's <base href> image
1003
+ * loads (a root-relative `/images/x.png` ignores any query on the base href, so
1004
+ * there is no marker to give it).
1005
+ *
1006
+ * `.pdf` is DELIBERATELY ABSENT. isAssetRequest() includes it, but a PDF in a
1007
+ * customer's repo is documentation content — exempting it would leave a
1008
+ * content-shaped hole in the gate. Do not reuse isAssetRequest() here.
1009
+ * Derived from ASSET_EXTENSIONS so a future asset type can't silently be in
1010
+ * one list and not the other — only the .pdf carve-out may differ.
1011
+ */
1012
+ const PRESENTATION_EXTENSIONS = ASSET_EXTENSIONS.filter((ext) => ext !== '.pdf');
1013
+
1014
+ export function isPresentationAsset(pathname: string): boolean {
1015
+ // Lowercase first: /_jd/images/HERO.JPG is served today (isAssetRequest matches
1016
+ // by prefix, not extension), so the gate must not 404 it.
1017
+ const p = pathname.toLowerCase();
1018
+ return PRESENTATION_EXTENSIONS.some((ext) => p.endsWith(ext));
1019
+ }
1020
+
1021
+ /**
1022
+ * Should this request be 404'd because the project serves from its custom
1023
+ * domain only? Returns the response to send, or null to continue.
1024
+ *
1025
+ * Called from THREE places in proxy.ts, because the middleware early-exits
1026
+ * before its main body for the content-bearing internal API routes:
1027
+ *
1028
+ * 1. the internal-API block — /api/{chat,mcp,r2}/{slug}, which skip
1029
+ * shouldRunMiddleware and carry raw MDX, MCP, and RAG chat. A gate that
1030
+ * misses these does nothing at all. (/api/docs-search is intentionally
1031
+ * NOT covered here — it's an API-key-authenticated REST endpoint, not a
1032
+ * cookie-gated one; see the internalAuthMatch comment in proxy.ts.)
1033
+ * 2. the /api/assets branch — same early-exit, but the slug comes from the
1034
+ * HOST, not the path (the route otherwise trusts a client-supplied
1035
+ * x-project-slug). Presentation assets self-exempt below; `.pdf` does
1036
+ * not, which is the whole point of that branch.
1037
+ * 3. the main body — every page and content artifact.
1038
+ *
1039
+ * The helper is async and self-contained ON PURPOSE: it re-derives the custom
1040
+ * domain from Redis rather than trusting a caller-supplied resolution. The
1041
+ * call sites have very different context (one has only a slug parsed out of the
1042
+ * URL), and a helper that trusts its caller is a helper that diverges. The
1043
+ * reads are TtlCached. This is also why ProjectResolutionResult carries no
1044
+ * customDomainOnly field — see the note there.
1045
+ *
1046
+ * FAIL OPEN: blocks only when validateCanonicalDomain() confirms, live from
1047
+ * Redis, that the custom domain maps back to this slug and is active/cached.
1048
+ * Broken DNS, a removed domain, or a Redis outage all fail that check and the
1049
+ * subdomain keeps serving. A customer can never lock themselves out.
1050
+ */
1051
+ // Body for the custom-domain-only 404 when a human lands on the bare
1052
+ // subdomain. Deliberately Jamdesk-branded, never tenant-branded — the
1053
+ // project's name or logo here would leak exactly the association the
1054
+ // feature exists to hide. Palette mirrors marketing/app/globals.css.
1055
+ const CUSTOM_DOMAIN_ONLY_404_HTML = `<!doctype html>
1056
+ <html lang="en">
1057
+ <head>
1058
+ <meta charset="utf-8">
1059
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1060
+ <meta name="robots" content="noindex, nofollow">
1061
+ <title>Page not found</title>
1062
+ <style>
1063
+ :root { --bg:#faf8f5; --text:#1b3139; --muted:#5a6f77; --accent:#ff3621; --accent-hover:#eb1600; }
1064
+ @media (prefers-color-scheme: dark) {
1065
+ :root { --bg:#141b1e; --text:#e8eef1; --muted:#8fa8b3; }
1066
+ }
1067
+ * { margin:0; padding:0; box-sizing:border-box; }
1068
+ body { min-height:100vh; display:flex; align-items:center; justify-content:center; background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; text-align:center; padding:24px; }
1069
+ main { max-width:26rem; }
1070
+ .mark { font-size:1.1rem; font-weight:700; letter-spacing:-0.01em; margin-bottom:2.5rem; }
1071
+ h1 { font-size:1.5rem; font-weight:600; letter-spacing:-0.02em; margin-bottom:0.75rem; }
1072
+ p { color:var(--muted); line-height:1.6; margin-bottom:2rem; }
1073
+ a.home { display:inline-block; background:var(--accent); color:#fff; text-decoration:none; font-weight:600; font-size:0.95rem; padding:0.65rem 1.4rem; border-radius:8px; }
1074
+ a.home:hover { background:var(--accent-hover); }
1075
+ </style>
1076
+ </head>
1077
+ <body>
1078
+ <main>
1079
+ <div class="mark">Jamdesk</div>
1080
+ <h1>Page not found</h1>
1081
+ <p>There&rsquo;s nothing at this address.</p>
1082
+ <a class="home" href="https://www.jamdesk.com">Go to jamdesk.com</a>
1083
+ </main>
1084
+ </body>
1085
+ </html>
1086
+ `;
1087
+
1088
+ export async function customDomainOnlyBlock(args: {
1089
+ request: NextRequest;
1090
+ slug: string;
1091
+ pathname: string;
1092
+ hasProxyMarker: boolean;
1093
+ }): Promise<NextResponse | null> {
1094
+ const { request, slug, pathname, hasProxyMarker } = args;
1095
+
1096
+ // Platform kill switch — a per-project Redis write is not an incident
1097
+ // response. Tolerant of any truthy value except '0'/'false' (operators
1098
+ // shouldn't have to remember a magic string mid-incident) and trimmed
1099
+ // (Vercel env values can pick up trailing newlines — see CLAUDE.md
1100
+ // ISR_MODE incident). Mirrors isCacheDisabled() in lib/cached-redis.ts;
1101
+ // inlined rather than imported to avoid dragging that module's AWS SDK
1102
+ // deps into the edge bundle.
1103
+ const disabled = process.env.JD_CUSTOM_DOMAIN_ONLY_DISABLED?.trim().toLowerCase();
1104
+ if (disabled && disabled !== '0' && disabled !== 'false') return null;
1105
+
1106
+ if (hasProxyMarker) return null;
1107
+ if (isPresentationAsset(pathname)) return null;
1108
+
1109
+ // Not a bare-subdomain hit: the request arrived on the custom domain itself.
1110
+ const hostname = request.headers.get('host') || '';
1111
+ if (!isJamdeskDomain(hostname)) return null;
1112
+
1113
+ // Proxied by an edge that can set headers (CF worker, nginx, Apache, Caddy,
1114
+ // HAProxy). Exactly as strong as the public marker — the header is spoofable
1115
+ // and is NOT an auth boundary. Do not "harden" this; see the plan.
1116
+ // Normalized the same way every other consumer in this file reads this
1117
+ // header (lowercase, port stripped, first-of-comma-chain) — a raw read
1118
+ // would let `acme.jamdesk.app:443`/`ACME.JAMDESK.APP` fail isJamdeskDomain
1119
+ // and open the gate.
1120
+ const forwardedHost = normalizeForwardedHost(request.headers.get('x-jamdesk-forwarded-host'));
1121
+ if (forwardedHost && !isJamdeskDomain(forwardedHost)) return null;
1122
+
1123
+ const cfg = await getProjectConfig(slug);
1124
+ if (cfg.customDomainOnly !== true) return null;
1125
+ if (!cfg.customDomain) return null;
1126
+
1127
+ const domain = await validateCanonicalDomain(cfg.customDomain, slug);
1128
+ if (!domain) return null; // FAIL OPEN
1129
+
1130
+ // log() emits single-line JSON, satisfying the monorepo edge-logging rule.
1131
+ log('info', '[custom-domain-only]', { slug, pathname, host: hostname });
1132
+
1133
+ // Humans get the branded page; API consumers of this same gate (MCP,
1134
+ // chat, /api/assets) keep the terse text body so nothing has to parse
1135
+ // HTML. Status and the noindex/no-store headers are identical either way.
1136
+ const wantsHtml = (request.headers.get('accept') || '').includes('text/html');
1137
+ return new NextResponse(wantsHtml ? CUSTOM_DOMAIN_ONLY_404_HTML : 'Not Found', {
1138
+ status: 404,
1139
+ headers: {
1140
+ 'X-Robots-Tag': 'noindex, nofollow',
1141
+ 'Cache-Control': 'no-store',
1142
+ ...(wantsHtml ? { 'Content-Type': 'text/html; charset=utf-8' } : {}),
1143
+ },
1144
+ });
1145
+ }
@@ -107,6 +107,14 @@ export async function resolveCustomDomain(hostname: string): Promise<DomainResol
107
107
  export interface ProjectCfg {
108
108
  hostAtDocs: boolean;
109
109
  customDomain?: string;
110
+ /**
111
+ * "Custom domain only" — when true, a direct hit on <slug>.jamdesk.app is
112
+ * 404'd unless it looks proxied. Visibility control, NOT access control: the
113
+ * ?jd_proxy=1 marker that satisfies it is a public constant. Only ever
114
+ * enforced when the project's custom domain passes live Redis validation, so
115
+ * a broken domain fails OPEN.
116
+ */
117
+ customDomainOnly?: boolean;
110
118
  }
111
119
 
112
120
  /**
@@ -134,6 +142,7 @@ export async function getProjectConfig(projectSlug: string): Promise<ProjectCfg>
134
142
  return {
135
143
  hostAtDocs: cfg?.hostAtDocs === true,
136
144
  customDomain: typeof cfg?.customDomain === 'string' ? cfg.customDomain : undefined,
145
+ customDomainOnly: cfg?.customDomainOnly === true,
137
146
  };
138
147
  } catch {
139
148
  return { hostAtDocs: false };
@@ -160,6 +169,7 @@ export async function getProjectConfigStrict(projectSlug: string): Promise<Proje
160
169
  return {
161
170
  hostAtDocs: cfg?.hostAtDocs === true,
162
171
  customDomain: typeof cfg?.customDomain === 'string' ? cfg.customDomain : undefined,
172
+ customDomainOnly: cfg?.customDomainOnly === true,
163
173
  };
164
174
  }
165
175
 
@@ -148,7 +148,13 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
148
148
  const { slug, buildId, siteUrl, hostAtDocs, firstPagePath, r2Client, r2Bucket } = options;
149
149
 
150
150
  // For hostAtDocs sites, homepage is at /docs/{firstPagePath}; otherwise /{firstPagePath}
151
- const homepageUrl = hostAtDocs ? `${siteUrl}/docs/${firstPagePath}` : `${siteUrl}/${firstPagePath}`;
151
+ //
152
+ // The bare subdomain 404s for projects in custom-domain-only mode, so the
153
+ // capture must look proxied. Subresources (CSS/JS/fonts/images) carry no
154
+ // marker but are exempt or middleware-skipped — see the subresource test in
155
+ // __tests__/proxy-custom-domain-only.test.ts.
156
+ const homepagePath = hostAtDocs ? `/docs/${firstPagePath}` : `/${firstPagePath}`;
157
+ const homepageUrl = `${siteUrl}${homepagePath}?jd_proxy=1`;
152
158
 
153
159
  let browser: Browser | null = null;
154
160
  let timeoutId: NodeJS.Timeout | null = null;