jamdesk 1.1.213 → 1.1.214

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/lib/deps.js CHANGED
@@ -116,7 +116,7 @@ export const REQUIRED_DEPS = {
116
116
  'jose': '^6.2.3',
117
117
  // TypeScript (needed for Next.js to avoid auto-install breaking symlink)
118
118
  'typescript': 'npm:@typescript/typescript6@^6.0.2',
119
- '@types/node': '^26.4.1',
119
+ '@types/node': '^26.6.3',
120
120
  '@types/react': '^19.2.18',
121
121
  '@types/react-dom': '^19.2.5',
122
122
  '@next/third-parties': '^16.3.4',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.213",
3
+ "version": "1.1.214",
4
4
  "description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
5
5
  "keywords": [
6
6
  "jamdesk",
@@ -106,7 +106,7 @@
106
106
  "@inquirer/prompts": "^8.7.2",
107
107
  "ajv": "^8.17.1",
108
108
  "ajv-formats": "^3.0.1",
109
- "chalk": "^6.0.0",
109
+ "chalk": "^6.0.1",
110
110
  "commander": "^15.0.0",
111
111
  "dictionary-en": "^4.0.0",
112
112
  "estree-util-visit": "^2.0.0",
@@ -129,7 +129,7 @@
129
129
  "devDependencies": {
130
130
  "@mdx-js/mdx": "^3.1.1",
131
131
  "@types/fs-extra": "^11.0.0",
132
- "@types/node": "^26.4.1",
132
+ "@types/node": "^26.6.3",
133
133
  "@typescript/native": "npm:typescript@^7.0.2",
134
134
  "typescript": "npm:@typescript/typescript6@^6.0.2",
135
135
  "vitest": "^4.1.11"
@@ -20,6 +20,7 @@ import { apiSpecsMarkdownFooter } from '@/lib/api-specs-markdown-hint';
20
20
  import { collectLocalSpecPaths } from '@/lib/openapi/operation-description';
21
21
  import { operationMarkdownForRef } from '@/lib/openapi/enrich-page-infos';
22
22
  import { injectAgentDirective } from '@/lib/agent-directive';
23
+ import { absolutizeMarkdownLinks } from '@/lib/markdown-absolute-links';
23
24
  import { resolveMarkdownFallbackPath } from '@/lib/root-page-slug';
24
25
  import { logger } from '@/shared/logger';
25
26
  import type { DocsConfig } from '@/lib/docs-types';
@@ -153,7 +154,9 @@ export async function GET(
153
154
  // path returns whatever the source MDX happened to end with. 107 of snag's
154
155
  // 115 exports shipped without it, so anything appending to or concatenating
155
156
  // these files ran the last line of one into the next.
156
- const body = `${hinted.replace(/\s+$/, '')}\n`;
157
+ // Last, so links in the appended OpenAPI operation content are covered too.
158
+ const linked = absolutizeLinks(hinted, request.headers, project, hostAtDocs, linkPrefix);
159
+ const body = `${linked.replace(/\s+$/, '')}\n`;
157
160
 
158
161
  // Two paths reach this handler via proxy rewrite:
159
162
  // (a) `.md` URL: unique CDN cache key, safe to share.
@@ -177,6 +180,33 @@ export async function GET(
177
180
  });
178
181
  }
179
182
 
183
+ /**
184
+ * Root-relative links in the export become absolute URLs on the host the
185
+ * reader asked for (see absolutizeMarkdownLinks for why). ISR only: `jamdesk
186
+ * dev` serves from localhost over http, and getBaseUrl always builds https.
187
+ * The base comes from getBaseUrl, the same trusted-header resolution the
188
+ * api-specs footer and canonical tags use, so a custom domain's own prefix
189
+ * wins over the subdomain's. Never throws.
190
+ */
191
+ function absolutizeLinks(
192
+ markdown: string,
193
+ headers: Headers,
194
+ project: string,
195
+ hostAtDocs: boolean,
196
+ linkPrefix: string,
197
+ ): string {
198
+ if (!isIsrMode()) return markdown;
199
+ try {
200
+ return absolutizeMarkdownLinks(markdown, getBaseUrl(headers, project, hostAtDocs), linkPrefix);
201
+ } catch (err) {
202
+ logger.warn('[markdown-export] link absolutize skipped', {
203
+ project,
204
+ error: err instanceof Error ? err.message : String(err),
205
+ });
206
+ return markdown;
207
+ }
208
+ }
209
+
180
210
  /**
181
211
  * The two shapes fetchMdxContent throws for a missing page — the
182
212
  * `Page not found: …` message the S3 path builds and the error names the
@@ -26,6 +26,7 @@ export function Tooltip({
26
26
  const linkPrefix = useLinkPrefix();
27
27
  const [isVisible, setIsVisible] = useState(false);
28
28
  const [position, setPosition] = useState<'top' | 'bottom'>('top');
29
+ const [shiftX, setShiftX] = useState(0);
29
30
  const triggerRef = useRef<HTMLSpanElement>(null);
30
31
  const tooltipRef = useRef<HTMLSpanElement>(null);
31
32
  const tooltipId = useId();
@@ -39,6 +40,19 @@ export function Tooltip({
39
40
 
40
41
  // Show below if not enough space above
41
42
  setPosition(spaceAbove < tooltipHeight + 8 ? 'bottom' : 'top');
43
+
44
+ // The popover is centered on the trigger, so a trigger near the edge of
45
+ // the content column pushes half of it past the column's overflow edge,
46
+ // where it is clipped. Shift it back inside the nearest clipping
47
+ // ancestor (the viewport if there is none); the arrow counter-shifts so
48
+ // it still points at the trigger.
49
+ const bounds = clippingBounds(triggerRef.current);
50
+ const center = triggerRect.left + triggerRect.width / 2;
51
+ const halfWidth = tooltipRef.current.offsetWidth / 2;
52
+ const margin = 8;
53
+ const overflowLeft = bounds.left + margin - (center - halfWidth);
54
+ const overflowRight = center + halfWidth - (bounds.right - margin);
55
+ setShiftX(overflowLeft > 0 ? overflowLeft : overflowRight > 0 ? -overflowRight : 0);
42
56
  }
43
57
  }, [isVisible]);
44
58
 
@@ -64,7 +78,7 @@ export function Tooltip({
64
78
  ref={tooltipRef}
65
79
  id={tooltipId}
66
80
  role="tooltip"
67
- style={{ boxShadow: 'var(--shadow-lg)', display: 'block' }}
81
+ style={{ boxShadow: 'var(--shadow-lg)', display: 'block', marginLeft: `${shiftX}px` }}
68
82
  className={`absolute z-50 w-64 p-3 rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] transition-all duration-150 ${
69
83
  position === 'top'
70
84
  ? 'bottom-full left-1/2 -translate-x-1/2 mb-2'
@@ -99,6 +113,7 @@ export function Tooltip({
99
113
 
100
114
  {/* Arrow */}
101
115
  <span
116
+ style={{ marginLeft: `${-shiftX}px` }}
102
117
  className={`absolute left-1/2 -translate-x-1/2 w-2 h-2 rotate-45 border-[var(--color-border)] bg-[var(--color-bg-primary)] ${
103
118
  position === 'top'
104
119
  ? 'bottom-[-5px] border-r border-b'
@@ -109,3 +124,14 @@ export function Tooltip({
109
124
  </span>
110
125
  );
111
126
  }
127
+
128
+ /** Left/right edges of the nearest ancestor that clips horizontally. */
129
+ function clippingBounds(el: HTMLElement): { left: number; right: number } {
130
+ for (let node = el.parentElement; node; node = node.parentElement) {
131
+ if (getComputedStyle(node).overflowX !== 'visible') {
132
+ const rect = node.getBoundingClientRect();
133
+ return { left: rect.left, right: rect.right };
134
+ }
135
+ }
136
+ return { left: 0, right: document.documentElement.clientWidth };
137
+ }
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Email Notifier — sends internal build failure notifications via Resend.
2
+ * Email Notifier — sends internal build failure notifications via Mailfully.
3
3
  *
4
- * Env: BUILDER_RESEND_API_KEY, JAMDESK_REPORT_EMAIL
4
+ * Env: MAILFULLY_API_KEY, JAMDESK_REPORT_EMAIL
5
5
  */
6
6
 
7
7
  import { render } from '@react-email/render';
8
- import { Resend } from 'resend';
8
+ import { sendMailfullyEmail } from './mailfully.js';
9
9
  import { BuildFailureEmail, type BuildFailureEmailProps } from './email-templates/index.js';
10
10
 
11
11
  export type BuildFailureEmailInfo = BuildFailureEmailProps;
@@ -15,13 +15,13 @@ export type BuildFailureEmailInfo = BuildFailureEmailProps;
15
15
  * Gracefully skips if environment variables are not configured.
16
16
  */
17
17
  export async function sendInternalBuildFailureEmail(info: BuildFailureEmailInfo): Promise<void> {
18
- const apiKey = process.env.BUILDER_RESEND_API_KEY;
18
+ const apiKey = process.env.MAILFULLY_API_KEY;
19
19
  const reportEmail = process.env.JAMDESK_REPORT_EMAIL;
20
20
 
21
21
  if (!apiKey || !reportEmail) {
22
22
  console.log(JSON.stringify({
23
23
  severity: 'WARNING',
24
- message: `Email notification skipped - ${!apiKey ? 'BUILDER_RESEND_API_KEY' : 'JAMDESK_REPORT_EMAIL'} not configured`,
24
+ message: `Email notification skipped - ${!apiKey ? 'MAILFULLY_API_KEY' : 'JAMDESK_REPORT_EMAIL'} not configured`,
25
25
  projectId: info.projectId,
26
26
  buildId: info.buildId,
27
27
  }));
@@ -29,13 +29,11 @@ export async function sendInternalBuildFailureEmail(info: BuildFailureEmailInfo)
29
29
  }
30
30
 
31
31
  try {
32
- const resend = new Resend(apiKey);
33
-
34
32
  const template = BuildFailureEmail(info);
35
33
  const html = await render(template);
36
34
  const text = await render(template, {plainText: true});
37
35
 
38
- const result = await resend.emails.send({
36
+ const result = await sendMailfullyEmail(apiKey, {
39
37
  from: 'Jamdesk <support@jamdesk.com>',
40
38
  to: reportEmail,
41
39
  subject: `Jamdesk build failed: ${info.projectName || info.projectId}`,
@@ -114,7 +114,7 @@ export const figtree = Figtree({
114
114
  export const ibmPlexSerif = IBM_Plex_Serif({
115
115
  subsets: ['latin'],
116
116
  display: 'swap',
117
- weight: ['400', '600'],
117
+ weight: ['400', '500', '600'],
118
118
  variable: '--font-serif',
119
119
  preload: false,
120
120
  });
@@ -0,0 +1,86 @@
1
+ // DO NOT EDIT — this file is auto-synced from shared/. Edit the source in shared/ and run ./scripts/sync-shared.sh
2
+
3
+ /**
4
+ * Minimal Mailfully client — the one call Jamdesk makes (send an email).
5
+ * Not the `mailfully` npm SDK on purpose: it requires Node 24 and is
6
+ * ESM-only, and our Firebase Functions run Node 22. Never throws: every
7
+ * failure (transport, non-2xx, bad JSON) comes back on the error arm.
8
+ */
9
+
10
+ export type MailfullySendInput = {
11
+ from: string;
12
+ to: string | string[];
13
+ cc?: string | string[];
14
+ bcc?: string | string[];
15
+ replyTo?: string | string[];
16
+ subject: string;
17
+ html?: string;
18
+ text?: string;
19
+ headers?: Record<string, string>;
20
+ tags?: { name: string; value: string }[];
21
+ type?: "transactional" | "marketing";
22
+ };
23
+
24
+ export type MailfullyResult =
25
+ | { data: { id: string; status: "queued" | "canceled" }; error: null }
26
+ | {
27
+ data: null;
28
+ error: {
29
+ statusCode: number | null;
30
+ type: string | null;
31
+ message: string;
32
+ retryAfter: number | null;
33
+ };
34
+ };
35
+
36
+ const DEFAULT_BASE_URL = "https://api.mailfully.com";
37
+
38
+ function fail(
39
+ statusCode: number | null,
40
+ type: string | null,
41
+ message: string,
42
+ retryAfter: number | null,
43
+ ): MailfullyResult {
44
+ return { data: null, error: { statusCode, type, message, retryAfter } };
45
+ }
46
+
47
+ export async function sendMailfullyEmail(
48
+ apiKey: string,
49
+ input: MailfullySendInput,
50
+ opts: { idempotencyKey?: string; baseUrl?: string } = {},
51
+ ): Promise<MailfullyResult> {
52
+ const { replyTo, ...rest } = input;
53
+ const body = { ...rest, ...(replyTo ? { reply_to: replyTo } : {}) };
54
+ let res: Response;
55
+ try {
56
+ res = await fetch(`${opts.baseUrl ?? DEFAULT_BASE_URL}/v1/emails`, {
57
+ method: "POST",
58
+ headers: {
59
+ "Authorization": `Bearer ${apiKey}`,
60
+ "Content-Type": "application/json",
61
+ ...(opts.idempotencyKey ?
62
+ { "Idempotency-Key": opts.idempotencyKey } : {}),
63
+ },
64
+ body: JSON.stringify(body),
65
+ // A hung request would otherwise hold a newsletter run or a function
66
+ // until its own timeout; a timeout comes back as a transport error.
67
+ signal: AbortSignal.timeout(15_000),
68
+ });
69
+ } catch (err) {
70
+ return fail(null, null, (err as Error).message, null);
71
+ }
72
+ const retryAfter = Number(res.headers.get("retry-after")) || null;
73
+ let json: unknown;
74
+ try {
75
+ json = await res.json();
76
+ } catch {
77
+ return fail(res.status, null, "Malformed JSON response", retryAfter);
78
+ }
79
+ if (!res.ok) {
80
+ const e = (json as { error?: { type?: string; message?: string } }).error;
81
+ return fail(res.status, e?.type ?? null,
82
+ e?.message ?? `HTTP ${res.status}`, retryAfter);
83
+ }
84
+ const { id, status } = json as { id: string; status: "queued" | "canceled" };
85
+ return { data: { id, status }, error: null };
86
+ }
@@ -0,0 +1,197 @@
1
+ import { unified, type Processor } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkGfm from 'remark-gfm';
4
+ import { visit } from 'unist-util-visit';
5
+ import type { Root, Link, Definition, Html } from 'mdast';
6
+ import { prefixRelativeHref } from './link-prefix-helpers';
7
+
8
+ /**
9
+ * Rewrite root-relative page links in a served `.md` export to absolute URLs
10
+ * on the tenant's public host.
11
+ *
12
+ * Two reasons, both about agents reading the markdown away from the page:
13
+ *
14
+ * - The export is the author's MDX, so links are written as the author typed
15
+ * them (`/setup/access-control`). The HTML renderer adds the tenant's docs
16
+ * prefix to those hrefs (prefixRelativeHref), the export never did. On a
17
+ * subpath-mounted site such as jamdesk.com/docs, `/setup/access-control`
18
+ * resolves to the marketing site, not the docs page.
19
+ * - Agent pipelines drop the fetch URL (RAG chunks, summaries, pastes), so even
20
+ * a correct root-relative link stops resolving. afdocs 0.22's
21
+ * `markdown-link-portability` check warns on every page that has one.
22
+ *
23
+ * Only `[text](/path)` links and `[id]: /path` definitions are rewritten, with
24
+ * the same prefix rule the HTML renderer applies to `<a href>`. Images are left
25
+ * alone: custom-domain images are served through `/_jd/images`, not the docs
26
+ * prefix, so the anchor rule would point them at a 404. Code spans and fenced
27
+ * code are never touched because they are not link nodes in the parse tree.
28
+ *
29
+ * `baseUrl` is the public docs root (e.g. `https://jamdesk.com/docs`, from
30
+ * getBaseUrl). `linkPrefix` is the prefix the HTML renderer puts on anchors
31
+ * for this request. The two can differ: a subdomain served at the root whose
32
+ * custom domain serves at `/help`. So a link is first resolved the renderer's
33
+ * way, then the renderer's prefix is swapped for the base URL.
34
+ */
35
+ export function absolutizeMarkdownLinks(
36
+ markdown: string,
37
+ baseUrl: string,
38
+ linkPrefix: string,
39
+ ): string {
40
+ // Cheap exit: most pages have no root-relative link at all, and the parse is
41
+ // the only non-trivial cost on this route.
42
+ if (!/\]\(\s*<?\/(?!\/)|\]:\s*<?\/(?!\/)/.test(markdown)) return markdown;
43
+
44
+ // Frontmatter is YAML, not markdown: blank it (same length, newlines kept)
45
+ // so offsets still line up with `markdown` but nothing in it is parsed.
46
+ const fm = markdown.match(FRONTMATTER_RE);
47
+ const source = blankTagLines(
48
+ fm ? blank(fm[0]) + markdown.slice(fm[0].length) : markdown,
49
+ );
50
+
51
+ const edits: Edit[] = [];
52
+ try {
53
+ collectEdits(source, 0, baseUrl.replace(/\/+$/, ''), linkPrefix, edits, 0);
54
+ } catch {
55
+ return markdown;
56
+ }
57
+ if (edits.length === 0) return markdown;
58
+
59
+ let out = markdown;
60
+ for (const e of edits.sort((a, b) => b.start - a.start)) {
61
+ out = out.slice(0, e.start) + e.url + out.slice(e.end);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ interface Edit { start: number; end: number; url: string }
67
+
68
+ /**
69
+ * The source is MDX, and MDX has no indented code blocks: prose indented
70
+ * inside `<Step>` or `<Tab>` is still prose, and the HTML page renders its
71
+ * links. Plain markdown would read it as code and skip the link, so turn the
72
+ * construct off the way remark-mdx does. Fenced code is unaffected.
73
+ */
74
+ function mdxIndentation(this: Processor) {
75
+ const data = this.data();
76
+ const list = (data.micromarkExtensions ??= []) as unknown[];
77
+ list.push({ disable: { null: ['codeIndented'] } });
78
+ }
79
+
80
+ /** Built once: the export route runs this on every uncached `.md` request. */
81
+ const parser = unified().use(remarkParse).use(remarkGfm).use(mdxIndentation);
82
+
83
+ /** Same anchored frontmatter match as agent-directive.tsx. */
84
+ const FRONTMATTER_RE = /^\uFEFF?---\r?\n[\s\S]*?\r?\n---(\r?\n|$)/;
85
+
86
+ /** Replace every character except line breaks with a space. */
87
+ function blank(text: string): string {
88
+ return text.replace(/[^\r\n]/g, ' ');
89
+ }
90
+
91
+ /** One opening or closing tag. `[^>]*` stops at the first `>`, which is fine: only offsets matter. */
92
+ const TAG = /<\/?[A-Za-z][^>]*>/g;
93
+
94
+ /** A line made only of tags: `<Tab title="Py">`, `</Steps>`, `<iframe ...></iframe>`. */
95
+ const TAG_ONLY_LINE = /^[ \t]*(?:<\/?[A-Za-z][^>]*>[ \t]*)+$/;
96
+
97
+ /**
98
+ * Blank every tag-only line (same length) before parsing.
99
+ *
100
+ * A component tag directly above a fence (`<CodeGroup>` then a code fence) parses
101
+ * as a CommonMark HTML block that ends at the first blank line in the code.
102
+ * The rest of the code is then read as prose and its links rewritten, and
103
+ * the closing fence opens a new fence that swallows the prose after it. MDX
104
+ * never reads the tag as HTML, so with the tag lines gone the plain parser
105
+ * sees the same fences and paragraphs MDX does. Only offsets matter here, and
106
+ * blanking keeps them.
107
+ */
108
+ function blankTagLines(text: string): string {
109
+ return text
110
+ .split(/(\r?\n)/)
111
+ .map((part) => (TAG_ONLY_LINE.test(part) ? blank(part) : part))
112
+ .join('');
113
+ }
114
+
115
+ /**
116
+ * Walk one parse of `text` (which starts at `base` in the original string)
117
+ * and record a rewrite for every root-relative link or definition.
118
+ *
119
+ * Tag-only lines are blanked before the first pass, but a line that mixes a
120
+ * tag with text (`<li>See [a](/x).</li>`) still starts a raw HTML node that
121
+ * hides the links in it. MDX renders those as links, so such a node is parsed
122
+ * again with every tag in it blanked. One level of nesting per pass, capped
123
+ * so pathological input cannot recurse.
124
+ */
125
+ function collectEdits(
126
+ text: string,
127
+ base: number,
128
+ baseUrl: string,
129
+ linkPrefix: string,
130
+ edits: Edit[],
131
+ depth: number,
132
+ ): void {
133
+ const tree = parser.parse(text) as Root;
134
+
135
+ visit(tree, (node) => {
136
+ if (node.type === 'html') {
137
+ const start = node.position?.start.offset;
138
+ if (start === undefined || depth >= 4) return;
139
+ const end = node.position?.end.offset;
140
+ const value = (node as Html).value;
141
+ if (end === undefined || value.trimStart().startsWith('<!--')) return;
142
+ // Inside a container (blockquote, list item) mdast strips the `> ` or
143
+ // indent from continuation lines, so `value` is no longer the source
144
+ // span and its offsets would land in the wrong place. Skip those.
145
+ if (text.slice(start, end) !== value) return;
146
+ const inner = value.replace(TAG, blank);
147
+ if (inner !== value) {
148
+ collectEdits(inner, base + start, baseUrl, linkPrefix, edits, depth + 1);
149
+ }
150
+ return;
151
+ }
152
+ if (node.type !== 'link' && node.type !== 'definition') return;
153
+ const n = node as Link | Definition;
154
+ const url = n.url;
155
+ if (!url.startsWith('/') || url.startsWith('//')) return;
156
+ const start = n.position?.start.offset;
157
+ const end = n.position?.end.offset;
158
+ if (start === undefined || end === undefined) return;
159
+
160
+ // The destination sits after the label: `](` for a link, `]:` for a
161
+ // definition. Search from the end of the label so link text that happens
162
+ // to contain the same path is never the match.
163
+ const labelEnd = n.type === 'link' && n.children.length > 0
164
+ ? n.children[n.children.length - 1].position?.end.offset ?? start
165
+ : start;
166
+ const opener = n.type === 'link' ? '](' : ']:';
167
+ const openAt = text.indexOf(opener, labelEnd);
168
+ if (openAt === -1 || openAt >= end) return;
169
+ let destAt = openAt + opener.length;
170
+ while (destAt < end && /\s/.test(text[destAt])) destAt++;
171
+ if (text[destAt] === '<') destAt++;
172
+
173
+ // The parser hands back the decoded URL. Skip anything whose source text
174
+ // differs (entities, escapes) rather than guess at the raw span.
175
+ if (text.slice(destAt, destAt + url.length) !== url) return;
176
+
177
+ edits.push({
178
+ start: base + destAt,
179
+ end: base + destAt + url.length,
180
+ url: toAbsolute(url, baseUrl, linkPrefix),
181
+ });
182
+ });
183
+ }
184
+
185
+ /**
186
+ * `/setup` → prefixRelativeHref gives the path the HTML page links to
187
+ * (`/docs/setup`); dropping that renderer prefix leaves the page path under
188
+ * the docs root, which is then joined to the public base URL.
189
+ */
190
+ function toAbsolute(url: string, baseUrl: string, linkPrefix: string): string {
191
+ const rendered = prefixRelativeHref(url, linkPrefix);
192
+ const underPrefix = linkPrefix &&
193
+ (rendered === linkPrefix || rendered.startsWith(`${linkPrefix}/`) ||
194
+ rendered.startsWith(`${linkPrefix}?`) || rendered.startsWith(`${linkPrefix}#`));
195
+ const pagePath = underPrefix ? rendered.slice(linkPrefix.length) : rendered;
196
+ return `${baseUrl}${pagePath}`;
197
+ }
@@ -347,6 +347,9 @@ export async function validateForwardedHost(
347
347
  mappedProject: mappedProject || 'not-registered',
348
348
  status: status || 'missing',
349
349
  });
350
+ // dashboard/functions/src/reports/domain-reachability.ts matches
351
+ // this text (REFUSED_HOST_TEXT) to auto-detach dead retained
352
+ // domains. Change both together.
350
353
  return {
351
354
  allowed: false,
352
355
  error: `Domain "${forwardedHost}" is not authorized to serve this content.`,
@@ -1190,6 +1193,27 @@ export function isPresentationAsset(pathname: string): boolean {
1190
1193
  return PRESENTATION_EXTENSIONS.some((ext) => p.endsWith(ext));
1191
1194
  }
1192
1195
 
1196
+ // Exact shape written by lib/screenshot-capture.ts: a Firestore auto-ID build
1197
+ // id, then a per-capture 128-bit random hex file name.
1198
+ const SITE_SCREENSHOT_RE = /^\/_screenshots\/[A-Za-z0-9]{20}\/[0-9a-f]{32}\.jpg$/;
1199
+
1200
+ /**
1201
+ * The dashboard site-preview screenshot, exempt from the password/JWT gate.
1202
+ *
1203
+ * The dashboard's <img> is a cross-site subresource that never carries the
1204
+ * visitor's jd_auth cookie, so a gated site's preview always 401'd. The random
1205
+ * file name is the capability: the full URL is stored only on the members-only
1206
+ * build doc. The build ID is NOT secret (lastBuildId sits on the projects doc,
1207
+ * readable by any signed-in user), which is why the legacy
1208
+ * `/_screenshots/{buildId}/homepage.jpg` shape stays gated.
1209
+ *
1210
+ * The image is the REAL gated homepage (the capture mints an unlock cookie), so
1211
+ * never widen this match or write the file name anywhere world-readable.
1212
+ */
1213
+ export function isSiteScreenshotPath(pathname: string): boolean {
1214
+ return SITE_SCREENSHOT_RE.test(pathname);
1215
+ }
1216
+
1193
1217
  /**
1194
1218
  * Should this request be 404'd because the project serves from its custom
1195
1219
  * domain only? Returns the response to send, or null to continue.