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.
- package/dist/__tests__/unit/openapi-license-identifier.test.js +14 -4
- package/dist/__tests__/unit/openapi-license-identifier.test.js.map +1 -1
- package/dist/__tests__/unit/openapi-schema-patch-sync.test.js +24 -7
- package/dist/__tests__/unit/openapi-schema-patch-sync.test.js.map +1 -1
- package/dist/__tests__/unit/openapi-schema-runtime-patch.test.d.ts +2 -0
- package/dist/__tests__/unit/openapi-schema-runtime-patch.test.d.ts.map +1 -0
- package/dist/__tests__/unit/openapi-schema-runtime-patch.test.js +120 -0
- package/dist/__tests__/unit/openapi-schema-runtime-patch.test.js.map +1 -0
- package/dist/__tests__/unit/openapi-server-variable-description.test.js +14 -5
- package/dist/__tests__/unit/openapi-server-variable-description.test.js.map +1 -1
- package/dist/__tests__/unit/vendored-sync.test.js +4 -0
- package/dist/__tests__/unit/vendored-sync.test.js.map +1 -1
- package/dist/lib/deps.d.ts +10 -6
- package/dist/lib/deps.d.ts.map +1 -1
- package/dist/lib/deps.js +20 -8
- package/dist/lib/deps.js.map +1 -1
- package/dist/lib/openapi/patch-openapi-schemas-runtime.d.ts +24 -0
- package/dist/lib/openapi/patch-openapi-schemas-runtime.d.ts.map +1 -0
- package/dist/lib/openapi/patch-openapi-schemas-runtime.js +75 -0
- package/dist/lib/openapi/patch-openapi-schemas-runtime.js.map +1 -0
- package/dist/lib/openapi/validator.d.ts.map +1 -1
- package/dist/lib/openapi/validator.js +23 -0
- package/dist/lib/openapi/validator.js.map +1 -1
- package/package.json +3 -2
- package/scripts/patch-openapi-schemas.js +8 -3
- package/vendored/components/navigation/Sidebar.tsx +227 -153
- package/vendored/lib/middleware-helpers.ts +265 -2
- package/vendored/lib/openapi/patch-openapi-schemas-runtime.ts +83 -0
- package/vendored/lib/openapi/validator.ts +28 -0
- package/vendored/lib/project-resolver.ts +10 -0
- package/vendored/lib/screenshot-capture.ts +7 -1
- package/vendored/next.config.js +13 -1
- package/vendored/workspace-package-lock.json +10 -9
- 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’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
|
-
|
|
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;
|
package/vendored/next.config.js
CHANGED
|
@@ -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: [
|
|
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/
|
|
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.
|
|
2044
|
-
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.
|
|
2045
|
-
"integrity": "sha512-
|
|
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.
|
|
2063
|
-
"caniuse-lite": "^1.0.
|
|
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.
|
|
2893
|
-
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.
|
|
2894
|
-
"integrity": "sha512-
|
|
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
|
-
}
|