jamdesk 1.1.166 → 1.1.168

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.
Files changed (34) hide show
  1. package/dist/__tests__/unit/openapi-license-identifier.test.js +14 -4
  2. package/dist/__tests__/unit/openapi-license-identifier.test.js.map +1 -1
  3. package/dist/__tests__/unit/openapi-schema-patch-sync.test.js +24 -7
  4. package/dist/__tests__/unit/openapi-schema-patch-sync.test.js.map +1 -1
  5. package/dist/__tests__/unit/openapi-schema-runtime-patch.test.d.ts +2 -0
  6. package/dist/__tests__/unit/openapi-schema-runtime-patch.test.d.ts.map +1 -0
  7. package/dist/__tests__/unit/openapi-schema-runtime-patch.test.js +120 -0
  8. package/dist/__tests__/unit/openapi-schema-runtime-patch.test.js.map +1 -0
  9. package/dist/__tests__/unit/openapi-server-variable-description.test.js +14 -5
  10. package/dist/__tests__/unit/openapi-server-variable-description.test.js.map +1 -1
  11. package/dist/__tests__/unit/vendored-sync.test.js +4 -0
  12. package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
  13. package/dist/lib/deps.d.ts +10 -6
  14. package/dist/lib/deps.d.ts.map +1 -1
  15. package/dist/lib/deps.js +20 -8
  16. package/dist/lib/deps.js.map +1 -1
  17. package/dist/lib/openapi/patch-openapi-schemas-runtime.d.ts +24 -0
  18. package/dist/lib/openapi/patch-openapi-schemas-runtime.d.ts.map +1 -0
  19. package/dist/lib/openapi/patch-openapi-schemas-runtime.js +75 -0
  20. package/dist/lib/openapi/patch-openapi-schemas-runtime.js.map +1 -0
  21. package/dist/lib/openapi/validator.d.ts.map +1 -1
  22. package/dist/lib/openapi/validator.js +23 -0
  23. package/dist/lib/openapi/validator.js.map +1 -1
  24. package/package.json +3 -2
  25. package/scripts/patch-openapi-schemas.js +8 -3
  26. package/vendored/components/navigation/Sidebar.tsx +227 -153
  27. package/vendored/lib/middleware-helpers.ts +265 -2
  28. package/vendored/lib/openapi/patch-openapi-schemas-runtime.ts +83 -0
  29. package/vendored/lib/openapi/validator.ts +28 -0
  30. package/vendored/lib/project-resolver.ts +10 -0
  31. package/vendored/lib/screenshot-capture.ts +7 -1
  32. package/vendored/next.config.js +13 -1
  33. package/vendored/workspace-package-lock.json +10 -9
  34. package/vendored/lib/sidebar-prefetch-walker.ts +0 -50
@@ -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
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Runtime (in-memory) self-heal for two defects in
3
+ * @apidevtools/openapi-schemas@2.1.0's bundled OpenAPI 3.1 meta-schema.
4
+ *
5
+ * Why runtime: npm 12 blocks lifecycle scripts not covered by `allowScripts`
6
+ * (only a `warn install-scripts` line, exit 0), so the package postinstall
7
+ * (scripts/patch-openapi-schemas.js) can no longer be relied on to have
8
+ * patched the on-disk schema — the 2026-07-13 `jamdesk dev` failure on a
9
+ * valid spec was exactly this. swagger-parser's validator reads the schema
10
+ * from this module singleton (lib/validators/schema.js:
11
+ * `const { openapi } = require("@apidevtools/openapi-schemas")`), and
12
+ * require() of JSON returns a cached mutable object — so fixing
13
+ * `openapi.v31` in place heals every subsequent SwaggerParser.validate()
14
+ * in this process regardless of installer, script blocking, or file
15
+ * permissions. Disk is never touched.
16
+ *
17
+ * Defect 1 — server-variable typo: `properties` declares `descriptions`
18
+ * (plural). With `unevaluatedProperties: false` on the same def, every
19
+ * spec-valid `description` is rejected with
20
+ * "#/servers/N/variables/<name> must NOT have unevaluated properties".
21
+ * Defect 2 — license over-constraint: `oneOf` forces identifier|url;
22
+ * OpenAPI 3.1 §4.8.4 requires only `name`. Correct guard:
23
+ * `not: { required: ['identifier', 'url'] }`.
24
+ *
25
+ * Sync chain (drift-guarded by
26
+ * cli/src/__tests__/unit/openapi-schema-patch-sync.test.ts): this file,
27
+ * cli/src/lib/openapi/patch-openapi-schemas-runtime.ts, both package
28
+ * postinstall scripts, and patchWorkspaceOpenApiSchemas() in
29
+ * cli/src/lib/deps.ts. If you change one, change all five source sites
30
+ * (the vendored copy under cli/vendored/ is regenerated by `npm run
31
+ * vendor` and drift-guarded as a sixth).
32
+ */
33
+ import { openapi } from '@apidevtools/openapi-schemas';
34
+
35
+ type SchemaDef = {
36
+ properties?: Record<string, unknown>;
37
+ oneOf?: unknown;
38
+ not?: unknown;
39
+ };
40
+
41
+ /**
42
+ * @returns true when the schema is in the known-good healed state after the
43
+ * call; false when the heal could not verify it (upstream reshape, missing
44
+ * $defs). Callers warn on false so a real user failure is debuggable instead
45
+ * of reproducing the original opaque validation error.
46
+ *
47
+ * The tripwire catches upstream reshapes ONLY. It cannot detect a split module
48
+ * instance — if `openapi-schemas` resolves to two copies, this heals and
49
+ * verifies one of them and still returns true while swagger-parser reads the
50
+ * other. A single instance rests on TWO guarantees, both of which must hold:
51
+ * (a) physical copy: the exact `2.1.0` pin (cli + build-service +
52
+ * REQUIRED_DEPS) plus npm dedup against swagger-parser's own range, so
53
+ * node_modules never contains a second nested copy;
54
+ * (b) bundler copy: `@apidevtools/openapi-schemas` stays in
55
+ * `serverExternalPackages` alongside `@apidevtools/swagger-parser` in
56
+ * EVERY Next config (build-service/next.config.mjs,
57
+ * cli/config/next.config.js), so Next never bundles one while
58
+ * externalizing the other. (npm dedup does nothing about a
59
+ * bundler-created duplicate — that is a separate failure mode.)
60
+ * Break either one and the heal silently patches a duplicate, returns true,
61
+ * and validation regresses to the original opaque error.
62
+ */
63
+ export function patchOpenApiSchemasInMemory(): boolean {
64
+ const v31 = (openapi as unknown as { v31?: { $defs?: Record<string, SchemaDef> } }).v31;
65
+ if (!v31?.$defs) return false; // Unexpected upstream shape — leave alone.
66
+
67
+ const props = v31.$defs['server-variable']?.properties;
68
+ if (props && !props.description && props.descriptions) {
69
+ props.description = props.descriptions;
70
+ delete props.descriptions;
71
+ }
72
+
73
+ const license = v31.$defs.license;
74
+ if (license && Array.isArray(license.oneOf)) {
75
+ delete license.oneOf;
76
+ license.not = { required: ['identifier', 'url'] };
77
+ }
78
+
79
+ // `license` must EXIST and be oneOf-free. Asserting only `!oneOf` would
80
+ // report healthy when upstream drops the license def entirely (undefined
81
+ // has no `.oneOf`) — exactly the reshape class this tripwire guards.
82
+ return Boolean(props?.description) && Boolean(license) && !license.oneOf;
83
+ }
@@ -11,6 +11,25 @@ import path from 'path';
11
11
  import type { OpenAPI } from 'openapi-types';
12
12
  import type { ValidationResult, OpenApiValidationError } from './types';
13
13
  import { formatOpenApiError, createFileNotFoundError } from './errors';
14
+ import { patchOpenApiSchemasInMemory } from './patch-openapi-schemas-runtime';
15
+ import { logger } from '../../shared/logger';
16
+
17
+ /**
18
+ * The self-heal tripwire warn is per-process, not per-spec: validateOpenApiSpecs()
19
+ * loops over every spec in docs.json, and a schema that can't be verified once
20
+ * can't be verified for any of them — without this flag a project with N specs
21
+ * logs the identical warning N times.
22
+ */
23
+ let selfHealWarned = false;
24
+
25
+ function warnSelfHealOnce(): void {
26
+ if (selfHealWarned) return;
27
+ selfHealWarned = true;
28
+ logger.warn(
29
+ '[jamdesk] OpenAPI 3.1 meta-schema self-heal could not verify the expected schema shape; ' +
30
+ 'validation may falsely reject server-variable descriptions or name-only licenses.'
31
+ );
32
+ }
14
33
 
15
34
  /**
16
35
  * Detect OpenAPI version from a parsed spec
@@ -82,6 +101,15 @@ export async function validateOpenApiSpec(
82
101
  specPath: string,
83
102
  projectDir: string
84
103
  ): Promise<ValidationResult> {
104
+ // Self-heal the 3.1 meta-schema before every validation: postinstall may
105
+ // never have run (npm 12 blocks install scripts by default). Idempotent,
106
+ // O(1) after the first call. A false return = heal couldn't verify the
107
+ // schema shape — warn (once per process) so users don't hit the original
108
+ // opaque error blind.
109
+ if (!patchOpenApiSchemasInMemory()) {
110
+ warnSelfHealOnce();
111
+ }
112
+
85
113
  const isUrl = specPath.startsWith('http://') || specPath.startsWith('https://');
86
114
 
87
115
  let target: string;
@@ -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;
@@ -47,10 +47,22 @@ const nextConfig = {
47
47
  ],
48
48
  },
49
49
  // Keep swagger-parser external (uses Node.js APIs).
50
+ // @apidevtools/openapi-schemas MUST stay external alongside it — the pairing
51
+ // is load-bearing, not cosmetic. patchOpenApiSchemasInMemory() heals the
52
+ // openapi-schemas module singleton that swagger-parser reads at validate()
53
+ // time; if one package is external and the other is bundled, Next produces
54
+ // TWO module instances and the self-heal patches a bundled duplicate,
55
+ // verifies THAT copy, and returns true while swagger-parser keeps reading
56
+ // the untouched node_modules copy — a silent no-op that reports success.
57
+ // Pinned by build-service/__tests__/next-config-external-packages.test.ts.
50
58
  // @terrastruct/d2 ships a WASM binary; keep it external so it loads from
51
59
  // node_modules at runtime instead of being bundled by Turbopack — the
52
60
  // same mitigation pattern used for the Shiki/oniguruma WASM landmine.
53
- serverExternalPackages: ['@apidevtools/swagger-parser', '@terrastruct/d2'],
61
+ serverExternalPackages: [
62
+ '@apidevtools/swagger-parser',
63
+ '@apidevtools/openapi-schemas',
64
+ '@terrastruct/d2',
65
+ ],
54
66
  // Turbopack config - only resolveAlias, NOT root (root causes caching issues)
55
67
  turbopack: {
56
68
  resolveAlias: {
@@ -6,7 +6,8 @@
6
6
  "": {
7
7
  "name": "jamdesk-workspace",
8
8
  "dependencies": {
9
- "@apidevtools/swagger-parser": "^12.1.0",
9
+ "@apidevtools/openapi-schemas": "2.1.0",
10
+ "@apidevtools/swagger-parser": "12.1.0",
10
11
  "@babel/standalone": "^8.0.4",
11
12
  "@fortawesome/fontawesome-svg-core": "^7.1.0",
12
13
  "@fortawesome/free-brands-svg-icons": "^7.1.0",
@@ -2040,9 +2041,9 @@
2040
2041
  }
2041
2042
  },
2042
2043
  "node_modules/autoprefixer": {
2043
- "version": "10.5.2",
2044
- "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz",
2045
- "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==",
2044
+ "version": "10.5.3",
2045
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.3.tgz",
2046
+ "integrity": "sha512-bJRzflk8GgE4JX+iZNEwz9f9p460NCHnU7bd+CZ9vIjIlZuTkt6F3WSl2oNO8StZBFx17nLEsiQ6H2wcZiY7nA==",
2046
2047
  "funding": [
2047
2048
  {
2048
2049
  "type": "opencollective",
@@ -2059,8 +2060,8 @@
2059
2060
  ],
2060
2061
  "license": "MIT",
2061
2062
  "dependencies": {
2062
- "browserslist": "^4.28.4",
2063
- "caniuse-lite": "^1.0.30001799",
2063
+ "browserslist": "^4.28.6",
2064
+ "caniuse-lite": "^1.0.30001805",
2064
2065
  "fraction.js": "^5.3.4",
2065
2066
  "picocolors": "^1.1.1",
2066
2067
  "postcss-value-parser": "^4.2.0"
@@ -2889,9 +2890,9 @@
2889
2890
  "license": "MIT"
2890
2891
  },
2891
2892
  "node_modules/electron-to-chromium": {
2892
- "version": "1.5.389",
2893
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz",
2894
- "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==",
2893
+ "version": "1.5.392",
2894
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz",
2895
+ "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==",
2895
2896
  "license": "ISC"
2896
2897
  },
2897
2898
  "node_modules/enhanced-resolve": {
@@ -1,50 +0,0 @@
1
- import type { ResolvedGroup } from '@/lib/navigation-resolver';
2
-
3
- /**
4
- * Flatten a resolved navigation tree to the list of hrefs that should be
5
- * prefetched under the existing group-engagement rule from Sidebar.tsx:
6
- *
7
- * shouldPrefetch = level !== 0 || isExpanded || !group.name
8
- *
9
- * Children of a level-0 group emit only when that group is expanded.
10
- * Children of nameless or deeper-level groups always emit. Order matches
11
- * the rendered sidebar so paced prefetches mirror what a user is most
12
- * likely to click next.
13
- */
14
- export function getPrefetchableHrefs(
15
- groups: ResolvedGroup[],
16
- expandedGroups: Set<string>,
17
- linkPrefix: string,
18
- level: number = 0,
19
- ): string[] {
20
- const out: string[] = [];
21
- const walk = (groups: ResolvedGroup[], level: number) => {
22
- for (const group of groups) {
23
- const isExpanded = expandedGroups.has(group.name);
24
- const groupOpen = level !== 0 || isExpanded || !group.name;
25
- if (!groupOpen) continue;
26
-
27
- if (group.items) {
28
- for (const item of group.items) {
29
- if (item.type === 'page') {
30
- out.push(`${linkPrefix}/${item.page.path}`);
31
- } else if (item.type === 'group') {
32
- walk([item.group], level + 1);
33
- }
34
- }
35
- } else {
36
- for (const page of group.pages) {
37
- out.push(`${linkPrefix}/${page.path}`);
38
- }
39
- if (group.nested) walk(group.nested, level + 1);
40
- }
41
- }
42
- };
43
- walk(groups, level);
44
-
45
- // Dedup: a docs.json config can reference the same page from two groups
46
- // (uncommon but legal). Without this, the pacer fires router.prefetch()
47
- // twice for the same href since scheduleNext walks the items array
48
- // literally and `seen` is populated up-front, not within the chain.
49
- return Array.from(new Set(out));
50
- }