jamdesk 1.1.76 → 1.1.77

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.
@@ -58,8 +58,23 @@ export interface ProjectResolutionResult {
58
58
  * (i.e., a user's own domain pointing at our CNAME), vs via the
59
59
  * subdomain branch (slug.jamdesk.app). Used by the branded inactive
60
60
  * page to show owner-sign-in hints only to real customers.
61
+ *
62
+ * Renamed from `customDomain` to disambiguate from the new
63
+ * `customDomain?: string` mirror below (which carries the public
64
+ * canonical hostname read from `projectCfg`). The two fields answer
65
+ * different questions:
66
+ * - `viaCustomDomain` = "did this request hit the domain: Redis key?"
67
+ * - `customDomain` = "what is the project's registered public face?"
61
68
  */
62
- customDomain?: boolean;
69
+ viaCustomDomain?: boolean;
70
+ /**
71
+ * Public canonical hostname for this slug, mirrored from `projectCfg`.
72
+ * Set only when the project owner has registered + activated a custom
73
+ * domain. Used by the proxy to override the canonical URL when serving
74
+ * a hostAtDocs project directly via *.jamdesk.app (avoiding duplicate-
75
+ * URL indexing — the upstream subdomain is not the public face).
76
+ */
77
+ customDomain?: string;
63
78
  }
64
79
 
65
80
  /**
@@ -124,7 +139,11 @@ export async function handleProjectResolution(
124
139
  slug,
125
140
  });
126
141
  const config = await getProjectConfig(slug);
127
- return { projectSlug: slug, hostAtDocs: config.hostAtDocs };
142
+ return {
143
+ projectSlug: slug,
144
+ hostAtDocs: config.hostAtDocs,
145
+ customDomain: config.customDomain,
146
+ };
128
147
  }
129
148
 
130
149
  log('info', 'Resolving project from hostname', { hostname });
@@ -166,8 +185,18 @@ export async function handleProjectResolution(
166
185
  getProjectConfig(projectSlug),
167
186
  getProjectInactive(projectSlug),
168
187
  ]);
169
- log('info', 'Project resolved from subdomain', { hostname, projectSlug, inactive });
170
- return { projectSlug, hostAtDocs: config.hostAtDocs, inactive };
188
+ log('info', 'Project resolved from subdomain', {
189
+ hostname,
190
+ projectSlug,
191
+ inactive,
192
+ customDomain: config.customDomain,
193
+ });
194
+ return {
195
+ projectSlug,
196
+ hostAtDocs: config.hostAtDocs,
197
+ inactive,
198
+ customDomain: config.customDomain,
199
+ };
171
200
  }
172
201
  }
173
202
 
@@ -204,7 +233,7 @@ export async function handleProjectResolution(
204
233
  hostAtDocs: resolution.hostAtDocs,
205
234
  domainStatus: resolution.domainStatus,
206
235
  inactive,
207
- customDomain: true,
236
+ viaCustomDomain: true,
208
237
  };
209
238
  }
210
239
 
@@ -596,15 +625,48 @@ const TRUSTED_PROXY_HEADERS = [
596
625
  'x-jd-custom-domain',
597
626
  'x-jd-project-name',
598
627
  'x-jd-project-logo',
628
+ // Canonical override: middleware writes this when serving *.jamdesk.app
629
+ // directly for a hostAtDocs project that has a registered custom
630
+ // domain. The page render path uses it to emit the public-face canonical
631
+ // instead of the upstream subdomain URL.
632
+ 'x-jd-canonical-host',
633
+ // Set when *.jamdesk.app serves a hostAtDocs project that has NOT
634
+ // registered a custom domain yet — page emits robots: noindex so the
635
+ // upstream URL doesn't compete with the (yet-to-arrive) public face
636
+ // in search results.
637
+ 'x-jd-noindex',
599
638
  ] as const;
600
639
 
640
+ /**
641
+ * Optional extras for `buildProjectHeaders`. All fields are middleware-
642
+ * authored; the corresponding header is in `TRUSTED_PROXY_HEADERS` so an
643
+ * inbound spoof is stripped before we set our own value.
644
+ */
645
+ export interface BuildProjectHeadersOptions {
646
+ /**
647
+ * Public-face canonical host. Set when serving *.jamdesk.app directly
648
+ * for a hostAtDocs project that has a registered custom domain — the
649
+ * page render path emits this host in <link rel="canonical"> instead
650
+ * of the upstream subdomain.
651
+ */
652
+ canonicalHost?: string;
653
+ /**
654
+ * When true, emit `x-jd-noindex: true` so the page emits a robots
655
+ * noindex tag. Used for *.jamdesk.app subdomains of hostAtDocs
656
+ * projects without a custom domain yet.
657
+ */
658
+ noindex?: boolean;
659
+ }
660
+
601
661
  /**
602
662
  * Build response headers with project context.
603
663
  *
604
664
  * Sets:
605
- * - x-project-slug — resolved project for downstream handlers
606
- * - x-host-at-docs — whether docs are mounted at /docs
607
- * - x-jd-language — locale code if the path starts with one (e.g. /fr/...)
665
+ * - x-project-slug — resolved project for downstream handlers
666
+ * - x-host-at-docs — whether docs are mounted at /docs
667
+ * - x-jd-language — locale code if the path starts with one (e.g. /fr/...)
668
+ * - x-jd-canonical-host — public-face host (opts.canonicalHost)
669
+ * - x-jd-noindex — "true" when opts.noindex is set
608
670
  *
609
671
  * Strips any client-supplied copies of those headers from the inbound
610
672
  * request to prevent header smuggling.
@@ -615,6 +677,7 @@ const TRUSTED_PROXY_HEADERS = [
615
677
  * @param pathname - The request pathname (request.nextUrl.pathname or, for
616
678
  * the unlock direct-hit branch, the `from` query param). Used to derive
617
679
  * the active locale so the root layout can emit <html lang>.
680
+ * @param opts - Optional extras (canonical host override, noindex flag).
618
681
  * @returns New headers with project context added
619
682
  */
620
683
  export function buildProjectHeaders(
@@ -622,6 +685,7 @@ export function buildProjectHeaders(
622
685
  existingHeaders: Headers,
623
686
  hostAtDocs: boolean,
624
687
  pathname: string,
688
+ opts: BuildProjectHeadersOptions = {},
625
689
  ): Headers {
626
690
  const newHeaders = new Headers(existingHeaders);
627
691
  for (const h of TRUSTED_PROXY_HEADERS) {
@@ -636,6 +700,13 @@ export function buildProjectHeaders(
636
700
  newHeaders.set('x-jd-language', language);
637
701
  }
638
702
 
703
+ if (opts.canonicalHost) {
704
+ newHeaders.set('x-jd-canonical-host', opts.canonicalHost);
705
+ }
706
+ if (opts.noindex) {
707
+ newHeaders.set('x-jd-noindex', 'true');
708
+ }
709
+
639
710
  return newHeaders;
640
711
  }
641
712
 
@@ -130,15 +130,20 @@ export function parseCacheKey(cacheKey: string): { projectSlug: string; pagePath
130
130
  /**
131
131
  * Get base URL for SEO metadata in ISR mode.
132
132
  *
133
- * Derives the canonical base URL from request headers.
134
- * Prefers x-jamdesk-forwarded-host (set by Cloudflare Worker proxy) over the
135
- * host header, so that canonical URLs point to the user-facing domain
136
- * (e.g., jamdesk.com) rather than the ISR subdomain (jamdesk-docs.jamdesk.app).
133
+ * Header priority (highest first):
134
+ * 1. x-jd-canonical-host internal override set by middleware when a
135
+ * hostAtDocs project is served directly via *.jamdesk.app and has a
136
+ * registered customDomain. Forces the canonical to the public face.
137
+ * 2. x-jamdesk-forwarded-host — set by the Cloudflare Worker proxy
138
+ * (e.g. jamdesk.com → forwarded `jamdesk.com`).
139
+ * 3. host header — direct request hostname.
140
+ * 4. Subdomain fallback `<slug>.jamdesk.app` (no headers available).
137
141
  *
138
- * Security: The forwarded host header is validated by middleware
139
- * (validateForwardedHost in middleware-helpers.ts) before the page component runs.
142
+ * Both override headers are in TRUSTED_PROXY_HEADERS so a client can't
143
+ * spoof them.
140
144
  *
141
- * When hostAtDocs=true, includes /docs path prefix to match the actual URL structure.
145
+ * When hostAtDocs=true, includes /docs path prefix (forwarded-host and
146
+ * canonical-host paths only; subdomain fallback always serves at root).
142
147
  *
143
148
  * @param headers - Request headers object
144
149
  * @param projectSlug - Project identifier (fallback for subdomain URL)
@@ -146,9 +151,9 @@ export function parseCacheKey(cacheKey: string): { projectSlug: string; pagePath
146
151
  * @returns Base URL (e.g., 'https://jamdesk.com/docs' or 'https://acme.jamdesk.app')
147
152
  */
148
153
  export function getBaseUrl(headers: Headers, projectSlug: string, hostAtDocs = false): string {
149
- // Prefer forwarded host (from Cloudflare Worker proxy) for correct canonical URLs
154
+ const canonicalHost = headers.get('x-jd-canonical-host');
150
155
  const forwardedHost = headers.get('x-jamdesk-forwarded-host');
151
- const host = forwardedHost || headers.get('host');
156
+ const host = canonicalHost || forwardedHost || headers.get('host');
152
157
 
153
158
  if (host) {
154
159
  const hostname = host.split(':')[0];
@@ -97,6 +97,17 @@ export async function resolveCustomDomain(hostname: string): Promise<DomainResol
97
97
  }
98
98
  }
99
99
 
100
+ /**
101
+ * Resolved project configuration mirrored from Redis `projectCfg:<slug>`.
102
+ *
103
+ * `customDomain` is set on activation by the domain.ts helper when a custom
104
+ * domain is TXT-verified. Used by middleware to emit canonical-host headers.
105
+ */
106
+ export interface ProjectCfg {
107
+ hostAtDocs: boolean;
108
+ customDomain?: string;
109
+ }
110
+
100
111
  /**
101
112
  * Get project configuration for a subdomain (silent-fallback variant).
102
113
  * Used for hostAtDocs setting on *.jamdesk.app domains.
@@ -111,7 +122,7 @@ export async function resolveCustomDomain(hostname: string): Promise<DomainResol
111
122
  * would memoize a wrong `false` for the cache TTL, the exact failure
112
123
  * mode that produced the jamdesk.com/docs/* 404 incident.
113
124
  */
114
- export async function getProjectConfig(projectSlug: string): Promise<{ hostAtDocs: boolean }> {
125
+ export async function getProjectConfig(projectSlug: string): Promise<ProjectCfg> {
115
126
  if (!redis) {
116
127
  return { hostAtDocs: false };
117
128
  }
@@ -119,8 +130,10 @@ export async function getProjectConfig(projectSlug: string): Promise<{ hostAtDoc
119
130
  try {
120
131
  const cfgRaw = await redis.get(`projectCfg:${projectSlug}`);
121
132
  const cfg = parseRedisConfig(cfgRaw);
122
- // Default to false (root path) for subdomains unless explicitly set to true
123
- return { hostAtDocs: cfg?.hostAtDocs === true };
133
+ return {
134
+ hostAtDocs: cfg?.hostAtDocs === true,
135
+ customDomain: typeof cfg?.customDomain === 'string' ? cfg.customDomain : undefined,
136
+ };
124
137
  } catch {
125
138
  return { hostAtDocs: false };
126
139
  }
@@ -136,14 +149,17 @@ export async function getProjectConfig(projectSlug: string): Promise<{ hostAtDoc
136
149
  * A missing `projectCfg:<slug>` key is NOT an error — that's the legitimate
137
150
  * "project hasn't opted into hostAtDocs" case and is safe to cache.
138
151
  */
139
- export async function getProjectConfigStrict(projectSlug: string): Promise<{ hostAtDocs: boolean }> {
152
+ export async function getProjectConfigStrict(projectSlug: string): Promise<ProjectCfg> {
140
153
  if (!redis) {
141
154
  return { hostAtDocs: false };
142
155
  }
143
156
 
144
157
  const cfgRaw = await redis.get(`projectCfg:${projectSlug}`);
145
158
  const cfg = parseRedisConfig(cfgRaw);
146
- return { hostAtDocs: cfg?.hostAtDocs === true };
159
+ return {
160
+ hostAtDocs: cfg?.hostAtDocs === true,
161
+ customDomain: typeof cfg?.customDomain === 'string' ? cfg.customDomain : undefined,
162
+ };
147
163
  }
148
164
 
149
165
  /**
@@ -191,6 +191,13 @@ function resolveSlug(normalizedSlug: string[], config: DocsConfig): string[] {
191
191
  export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
192
192
  const { slug: slugInput, projectSlug, hostAtDocs, requestHeaders } = input;
193
193
 
194
+ // Middleware sets `x-jd-noindex: true` when serving a hostAtDocs project
195
+ // directly via *.jamdesk.app and the project has no registered custom
196
+ // domain yet. The upstream subdomain shouldn't compete with the (yet-to-
197
+ // arrive) public face in search results — emit robots noindex so Google
198
+ // skips it. See proxy.ts → projectHeaderOptsForCanonical for the source.
199
+ const noindexHeader = requestHeaders?.get('x-jd-noindex') === 'true';
200
+
194
201
  if (isIsrMode()) {
195
202
  if (!projectSlug) return { title: 'Not Found' };
196
203
  const exists = await projectExists(projectSlug);
@@ -234,11 +241,18 @@ export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
234
241
 
235
242
  const markdownHref = `${hostAtDocs ? '/docs/' : '/'}${pagePath}.md`;
236
243
 
244
+ // noindexHeader (middleware) and isRoot override frontmatter robots
245
+ // (already in seoMetadata) via spread order.
246
+ const robotsOverride =
247
+ isRoot || noindexHeader
248
+ ? { robots: { index: false, follow: true } as const }
249
+ : {};
250
+
237
251
  return {
238
252
  title: titleValue,
239
253
  description: data.description || '',
240
254
  ...seoMetadata,
241
- ...(isRoot && { robots: { index: false, follow: true } }),
255
+ ...robotsOverride,
242
256
  alternates: {
243
257
  ...seoMetadata.alternates,
244
258
  types: {
@@ -2987,9 +2987,9 @@
2987
2987
  "license": "MIT"
2988
2988
  },
2989
2989
  "node_modules/electron-to-chromium": {
2990
- "version": "1.5.352",
2991
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.352.tgz",
2992
- "integrity": "sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==",
2990
+ "version": "1.5.353",
2991
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz",
2992
+ "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
2993
2993
  "license": "ISC"
2994
2994
  },
2995
2995
  "node_modules/enhanced-resolve": {