Skip to content

Commit 9d3c12d

Browse files
committed
feat(legal): /licenses page listing open-source dependency notices
Add a new page under the legal nav (sibling of /imprint, /privacy, /terms) that lists every production third-party dependency the app ships, with the SPDX identifier, project URL, and the actual LICENSE file body expandable in a dialog. Generated at web build time from the workspace's production deps; community integrations contribute their own group at request time. Shared scanner — packages/core/src/licenses.ts BFS from one or more root package.jsons via Node's `createRequire`, follows `dependencies + optionalDependencies`, dedupes by name@version, and extracts: * SPDX identifier from `license` / `licenses` (string, object, array) * Project URL from `homepage` or `repository.url` (with git+/.git/ git://github: normalisation) * Full LICENSE file body (LICENSE / LICENSE.md / LICENSE.txt / COPYING / UNLICENSE — capped at 64 KB) Exposed at `@openmapx/core/licenses` (new subpath export) so it can be imported standalone via `node --experimental-strip-types` without pulling in the full server graph. Also re-exported from `@openmapx/core/server` for in-process callers. Build-time generator — apps/web/scripts/generate-license-notices.mjs Walks deps from apps/web, apps/api, every workspace `packages/`, and every built-in `integrations/`. Writes apps/web/src/generated/open-source-licenses.json (gitignored, regenerated on every `predev` / `prebuild`). First run: 619 notices from 83 roots. Page — apps/web/src/app/(legal)/licenses/ Server component (`dynamic = "force-dynamic"`). Reuses the existing LegalPageShell so the sidebar nav + scroll-tracking come for free. One group for the platform itself plus one group per installed community integration. Each row: package + version, license (linked to the licenseUrl when present), project link, and a "View" button opening a dialog with the full LICENSE text. Community integrations always appear loadNotices.ts scans `custom_integrations/<id>/` at request time. For each installed integration we always emit a row sourced from manifest.json (id, version, license, documentation URL). When the artifact additionally ships `dist/licenses.json` (produced by the CLI packager below) we append the bundled-dep notices, deduped against the core list so React/MUI/etc. aren't re-listed. Half-installed staging dirs without a manifest.json are skipped. CLI packager writes dist/licenses.json packageIntegration() runs scanLicenses against the integration's source package.json (when present) and emits a `{ integrationId, notices[] }` JSON next to the dist bundles. Skips @openmapx/* workspace packages. Best-effort — silently skipped for integrations without a package.json or when the scan fails. Wiring * (legal)/layout.tsx — adds the Licenses tab * MapFooter.tsx — adds the footer link * packages/i18n locales — `legal.openSourceLicenses` / `.openSourceLicensesDescription` and `footer.licenses` in en+de * .gitignore — excludes the generated JSON * next.config.ts — `serverExternalPackages: ["esbuild"]` so Turbopack doesn't try to bundle @esbuild/<platform>-<arch>'s README when walking the @openmapx/core/server graph (esbuild only runs on the CLI side via `import("esbuild")`) Verified Dev server boots, GET /licenses returns 200 with the rendered table containing all 619 notices plus the integration self-row for any installed community integration. Types + lint + tests all green (2072 passed | 4 skipped).
1 parent cff4fe0 commit 9d3c12d

15 files changed

Lines changed: 854 additions & 4 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ custom_integrations/
6464
# Generated runtime modules served to community integrations via import maps
6565
apps/web/public/runtime/
6666

67+
# Generated open-source license notices (built by apps/web's prebuild step)
68+
apps/web/src/generated/open-source-licenses.json
69+
6770
# Integration local config (may contain API keys)
6871
integrations/*/config.json
6972

apps/web/next.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
77
const nextConfig: NextConfig = {
88
output: "standalone",
99
transpilePackages: ["@openmapx/core", "@openmapx/i18n"],
10+
// esbuild is loaded lazily by @openmapx/core's integration installer (only
11+
// when the CLI builds a community integration). Server components never run
12+
// that path, but Turbopack still walks the static `import("esbuild")` and
13+
// chokes on the `@esbuild/<platform>-<arch>` native binding's README. Leave
14+
// it external so it stays a runtime `require()`.
15+
serverExternalPackages: ["esbuild"],
1016
allowedDevOrigins: ["127.0.0.1", "localhost"],
1117
turbopack: {
1218
root: resolve(import.meta.dirname, "../.."),

apps/web/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
"version": "0.1.0",
44
"private": true,
55
"scripts": {
6-
"dev": "NODE_OPTIONS='--max-old-space-size=16384' pnpm build:runtime && next dev",
7-
"prebuild": "node scripts/copy-preset-icons.mjs && pnpm build:runtime",
6+
"dev": "NODE_OPTIONS='--max-old-space-size=16384' pnpm build:runtime && pnpm build:licenses && next dev",
7+
"prebuild": "node scripts/copy-preset-icons.mjs && pnpm build:runtime && pnpm build:licenses",
88
"build": "next build && pnpm build:sw && pnpm build:copy-assets",
99
"build:runtime": "node scripts/build-runtime-modules.mjs",
10+
"build:licenses": "node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/generate-license-notices.mjs",
1011
"build:sw": "esbuild src/sw.ts --bundle --outfile=public/sw.js --format=iife --platform=browser --define:process.env.NODE_ENV='\"production\"'",
1112
"build:copy-assets": "mkdir -p .next/standalone/apps/web/public .next/standalone/apps/web/.next/static && cp -R public/. .next/standalone/apps/web/public/ && cp -R .next/static/. .next/standalone/apps/web/.next/static/",
1213
"start": "node .next/standalone/apps/web/server.js",
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/env node
2+
// Build-time generator for the /licenses page.
3+
//
4+
// Walks production deps from app-web, app-api, the workspace `packages/`, and
5+
// every built-in integration under `integrations/`, then writes a static JSON
6+
// file consumed by `apps/web/src/app/(legal)/licenses/page.tsx`. Re-runs
7+
// under `predev` and `prebuild` so the file is always present in dev and
8+
// production builds.
9+
//
10+
// Community integrations are not scanned here — they ship as immutable
11+
// artifacts that may not be installed at web-build time. The licenses page
12+
// reads `dist/licenses.json` from each installed artifact at request time
13+
// and merges those notices on top of this static list.
14+
15+
import { existsSync, mkdirSync, readdirSync, renameSync, writeFileSync } from "node:fs";
16+
import { dirname, resolve } from "node:path";
17+
import { fileURLToPath } from "node:url";
18+
import { scanLicenses } from "@openmapx/core/licenses";
19+
20+
const scriptDir = dirname(fileURLToPath(import.meta.url));
21+
const webRoot = resolve(scriptDir, "..");
22+
const repoRoot = resolve(webRoot, "..", "..");
23+
const outDir = resolve(webRoot, "src/generated");
24+
const outPath = resolve(outDir, "open-source-licenses.json");
25+
26+
const rootPackageJsonPaths = [
27+
resolve(webRoot, "package.json"),
28+
resolve(repoRoot, "apps/api/package.json"),
29+
...workspaceChildren("packages"),
30+
...workspaceChildren("integrations"),
31+
].filter((p) => existsSync(p));
32+
33+
const notices = scanLicenses({
34+
rootPackageJsonPaths,
35+
// OpenMapX workspace packages aren't third-party deps with their own license
36+
// obligation; the integration source files have their own MIT notice in the
37+
// repo root. Drop them from the public-facing list.
38+
skipNamePrefixes: ["@openmapx/", "@integrations/", "@better-auth/passkey", "web", "api"],
39+
});
40+
41+
const payload = {
42+
generatedAt: new Date().toISOString(),
43+
rootCount: rootPackageJsonPaths.length,
44+
notices,
45+
};
46+
47+
mkdirSync(outDir, { recursive: true });
48+
writeFileSync(`${outPath}.tmp`, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
49+
renameSync(`${outPath}.tmp`, outPath);
50+
51+
console.log(
52+
`[generate-license-notices] ${notices.length} notices from ${rootPackageJsonPaths.length} roots → ${outPath}`,
53+
);
54+
55+
function workspaceChildren(dir) {
56+
const abs = resolve(repoRoot, dir);
57+
if (!existsSync(abs)) return [];
58+
return readdirSync(abs, { withFileTypes: true })
59+
.filter(
60+
(entry) => entry.isDirectory() && !entry.name.startsWith("_") && !entry.name.startsWith("."),
61+
)
62+
.map((entry) => resolve(abs, entry.name, "package.json"))
63+
.filter((p) => existsSync(p));
64+
}

apps/web/src/app/(legal)/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export default async function LegalLayout({ children }: { children: React.ReactN
4444
{ label: t("imprint"), href: "/imprint" },
4545
{ label: t("privacyPolicy"), href: "/privacy" },
4646
{ label: t("termsOfService"), href: "/terms" },
47+
{ label: t("openSourceLicenses"), href: "/licenses" },
4748
]}
4849
/>
4950
</Box>
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"use client";
2+
3+
import CloseIcon from "@mui/icons-material/Close";
4+
import Box from "@mui/material/Box";
5+
import Button from "@mui/material/Button";
6+
import Dialog from "@mui/material/Dialog";
7+
import DialogContent from "@mui/material/DialogContent";
8+
import DialogTitle from "@mui/material/DialogTitle";
9+
import IconButton from "@mui/material/IconButton";
10+
import Typography from "@mui/material/Typography";
11+
import { useState } from "react";
12+
13+
interface Props {
14+
packageName: string;
15+
version: string;
16+
license: string;
17+
text: string;
18+
triggerLabel: string;
19+
dialogTitle: string;
20+
closeLabel: string;
21+
}
22+
23+
export function LicenseTextDialog({ license, text, triggerLabel, dialogTitle, closeLabel }: Props) {
24+
const [open, setOpen] = useState(false);
25+
26+
return (
27+
<>
28+
<Button
29+
size="small"
30+
variant="text"
31+
onClick={() => setOpen(true)}
32+
sx={{ textTransform: "none", minWidth: 0 }}
33+
>
34+
{triggerLabel}
35+
</Button>
36+
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth>
37+
<DialogTitle sx={{ pr: 6 }}>
38+
<Typography component="span" sx={{ fontWeight: 600 }}>
39+
{dialogTitle}
40+
</Typography>
41+
<Typography component="span" color="text.secondary" sx={{ ml: 1, fontSize: 14 }}>
42+
({license})
43+
</Typography>
44+
<IconButton
45+
aria-label={closeLabel}
46+
onClick={() => setOpen(false)}
47+
sx={{ position: "absolute", right: 8, top: 8 }}
48+
>
49+
<CloseIcon />
50+
</IconButton>
51+
</DialogTitle>
52+
<DialogContent dividers>
53+
<Box
54+
component="pre"
55+
sx={{
56+
whiteSpace: "pre-wrap",
57+
wordBreak: "break-word",
58+
margin: 0,
59+
fontFamily:
60+
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
61+
fontSize: 13,
62+
lineHeight: 1.5,
63+
}}
64+
>
65+
{text}
66+
</Box>
67+
</DialogContent>
68+
</Dialog>
69+
</>
70+
);
71+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Server-side loader for /licenses. Combines the build-time static manifest
2+
// (web + api + built-in integrations) with one group per installed community
3+
// integration, sourced from the integration's `manifest.json` plus the
4+
// optional `dist/licenses.json` shipped inside its artifact. Runs on every
5+
// request so newly-installed integrations show up without rebuilding web.
6+
7+
import { type Dirent, existsSync, readdirSync, readFileSync } from "node:fs";
8+
import { join } from "node:path";
9+
import { type LicenseNotice, repoPaths } from "@openmapx/core/server";
10+
11+
export interface LicenseGroup {
12+
/** `core` for web/api/built-in deps, or the community integration id. */
13+
scope: string;
14+
/** Human-readable label shown in the table heading. */
15+
label: string;
16+
/** Sub-heading describing what this group's notices represent. */
17+
description?: string;
18+
notices: LicenseNotice[];
19+
}
20+
21+
export interface LicensesPageData {
22+
groups: LicenseGroup[];
23+
generatedAt: string | null;
24+
totalCount: number;
25+
}
26+
27+
interface StaticPayload {
28+
generatedAt?: string;
29+
notices?: LicenseNotice[];
30+
}
31+
32+
interface CommunityPayload {
33+
integrationId?: string;
34+
notices?: LicenseNotice[];
35+
}
36+
37+
interface CommunityManifest {
38+
id?: string;
39+
name?: string;
40+
version?: string;
41+
license?: string | { type?: string; url?: string };
42+
author?: string;
43+
documentation?: string;
44+
description?: string;
45+
}
46+
47+
export async function loadLicenseGroups(): Promise<LicensesPageData> {
48+
const groups: LicenseGroup[] = [];
49+
let generatedAt: string | null = null;
50+
51+
const staticPayload = await loadStaticManifest();
52+
if (staticPayload?.notices?.length) {
53+
groups.push({
54+
scope: "core",
55+
label: "OpenMapX (web app, API, built-in integrations)",
56+
notices: staticPayload.notices,
57+
});
58+
generatedAt = staticPayload.generatedAt ?? null;
59+
}
60+
61+
const seenInCore = new Set(staticPayload?.notices?.map((n) => `${n.name}@${n.version}`) ?? []);
62+
for (const group of loadCommunityGroups(seenInCore)) {
63+
groups.push(group);
64+
}
65+
66+
const totalCount = groups.reduce((sum, g) => sum + g.notices.length, 0);
67+
return { groups, generatedAt, totalCount };
68+
}
69+
70+
async function loadStaticManifest(): Promise<StaticPayload | null> {
71+
// Static import keeps Next's tracing aware of the file dependency; falling
72+
// back to a stub if the generator hasn't run yet lets `next dev` boot
73+
// without a build step.
74+
try {
75+
const mod = (await import("@/generated/open-source-licenses.json")) as {
76+
default?: StaticPayload;
77+
};
78+
return mod.default ?? null;
79+
} catch {
80+
return null;
81+
}
82+
}
83+
84+
function loadCommunityGroups(skipKeys: Set<string>): LicenseGroup[] {
85+
const groups: LicenseGroup[] = [];
86+
let customDir: string;
87+
try {
88+
customDir = repoPaths().customIntegrationsDir;
89+
} catch {
90+
return groups;
91+
}
92+
if (!existsSync(customDir)) return groups;
93+
94+
let entries: Dirent[];
95+
try {
96+
entries = readdirSync(customDir, { withFileTypes: true });
97+
} catch {
98+
return groups;
99+
}
100+
101+
for (const entry of entries) {
102+
if (!entry.isDirectory()) continue;
103+
if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
104+
105+
const integrationDir = join(customDir, entry.name);
106+
const manifest = readCommunityManifest(integrationDir);
107+
if (!manifest) continue; // Skip half-extracted/staging dirs.
108+
109+
const integrationId = manifest.id ?? entry.name;
110+
const integrationNotice = integrationSelfNotice(manifest, integrationId);
111+
112+
const bundledNotices = readBundledLicenses(integrationDir).filter(
113+
(n) => !skipKeys.has(`${n.name}@${n.version}`),
114+
);
115+
116+
groups.push({
117+
scope: entry.name,
118+
label: `Community integration · ${manifest.name ?? integrationId}`,
119+
description:
120+
bundledNotices.length > 0
121+
? "Integration license and bundled runtime dependencies."
122+
: "Integration license. No bundled dependency manifest was shipped.",
123+
notices: [integrationNotice, ...bundledNotices],
124+
});
125+
}
126+
127+
return groups.sort((a, b) => a.scope.localeCompare(b.scope));
128+
}
129+
130+
function readCommunityManifest(dir: string): CommunityManifest | null {
131+
const path = join(dir, "manifest.json");
132+
if (!existsSync(path)) return null;
133+
try {
134+
return JSON.parse(readFileSync(path, "utf8")) as CommunityManifest;
135+
} catch {
136+
return null;
137+
}
138+
}
139+
140+
function readBundledLicenses(dir: string): LicenseNotice[] {
141+
const path = join(dir, "dist", "licenses.json");
142+
if (!existsSync(path)) return [];
143+
let raw: string;
144+
try {
145+
raw = readFileSync(path, "utf8");
146+
} catch {
147+
return [];
148+
}
149+
let payload: CommunityPayload;
150+
try {
151+
payload = JSON.parse(raw) as CommunityPayload;
152+
} catch {
153+
return [];
154+
}
155+
return Array.isArray(payload.notices) ? payload.notices : [];
156+
}
157+
158+
function integrationSelfNotice(manifest: CommunityManifest, id: string): LicenseNotice {
159+
let license = "Not specified";
160+
let licenseUrl: string | undefined;
161+
const raw = manifest.license;
162+
if (typeof raw === "string" && raw.trim()) {
163+
license = raw.trim();
164+
} else if (raw && typeof raw === "object") {
165+
if (raw.type?.trim()) license = raw.type.trim();
166+
if (raw.url) licenseUrl = raw.url;
167+
}
168+
return {
169+
name: id,
170+
version: manifest.version ?? "?",
171+
license,
172+
licenseUrl,
173+
projectUrl: manifest.documentation,
174+
};
175+
}

0 commit comments

Comments
 (0)