create-lacspace-app 2.13.1 → 2.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib.cjs CHANGED
@@ -65,7 +65,8 @@ var pkgJson = (ctx) => JSON.stringify({
65
65
  name: ctx.name,
66
66
  version: "0.1.0",
67
67
  private: true,
68
- scripts: { dev: "next dev", build: "next build", start: "next start", lint: "next lint" },
68
+ // `typecheck` so the full-stack root script can run it across both workspaces.
69
+ scripts: { dev: "next dev", build: "next build", start: "next start", lint: "next lint", typecheck: "tsc --noEmit" },
69
70
  dependencies: {
70
71
  next: "^15.1.0",
71
72
  react: "^19.0.0",
@@ -243,6 +244,27 @@ h1, .display { letter-spacing: -0.035em; line-height: 1.02; }
243
244
  ::-webkit-scrollbar-thumb:hover { background: var(--hairline); }
244
245
  :focus-visible { outline: 2px solid var(--accent-to); outline-offset: 2px; border-radius: 6px; }
245
246
 
247
+ /* Skip link: off-screen until it is focused, then the first thing you see.
248
+ The wrapper it targets carries tabIndex={-1}, so focus actually moves there
249
+ instead of the browser only scrolling. */
250
+ .skip-link {
251
+ position: fixed;
252
+ top: 10px;
253
+ left: 10px;
254
+ z-index: 100;
255
+ padding: 10px 16px;
256
+ border-radius: 10px;
257
+ background: var(--accent-to);
258
+ color: var(--on-accent);
259
+ font-weight: 600;
260
+ text-decoration: none;
261
+ transform: translateY(-160%);
262
+ transition: transform 0.18s ease;
263
+ }
264
+ .skip-link:focus { transform: translateY(0); }
265
+ @media (prefers-reduced-motion: reduce) { .skip-link { transition: none; } }
266
+ #main:focus { outline: none; }
267
+
246
268
  .gradient-text {
247
269
  background: linear-gradient(115deg, var(--accent-from), var(--accent-to));
248
270
  -webkit-background-clip: text; background-clip: text; color: transparent;
@@ -407,11 +429,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
407
429
  return (
408
430
  <html lang="en" className={\`\${inter.variable} \${inter.className}\`} suppressHydrationWarning>
409
431
  <body className="antialiased">
432
+ {/* Keyboard users land here first and can jump past the nav (WCAG 2.4.1). */}
433
+ <a href="#main" className="skip-link">Skip to content</a>
410
434
  {/* \u2728 Dark / light / system theming with a built-in no-flash script \u2014 @lacspace/theme */}
411
435
  <ThemeProvider defaultTheme="dark">
412
436
  {/* \u2728 Press \u2318K / Ctrl-K anywhere \u2014 powered by @lacspace/ui */}
413
437
  <CommandMenu />
414
- ${open}{children}${close}
438
+ ${open}<div id="main" tabIndex={-1}>{children}</div>${close}
415
439
  </ThemeProvider>
416
440
  <script
417
441
  type="application/ld+json"
@@ -2438,8 +2462,364 @@ var FEATURES = [
2438
2462
  "SSE auto-reconnects and needs no WebSocket server \u2014 great for notifications and live feeds."
2439
2463
  ],
2440
2464
  learn: "https://developer.lacspace.com/packages/sse"
2465
+ },
2466
+ {
2467
+ key: "ui",
2468
+ label: "UI components",
2469
+ description: "The @lacspace/components library wired into this template \u2014 96 accessible components, a theme bridge so they inherit your accent and dark mode, and a working settings page. Frontend, any template.",
2470
+ deps: { "@lacspace/components": "^1.0.1" },
2471
+ files: () => ({
2472
+ "app/lacspace-ui.css": uiThemeBridge(),
2473
+ "app/ui/layout.tsx": uiRouteLayout(),
2474
+ "app/ui/page.tsx": uiShowcasePage(),
2475
+ "components/ui-provider.tsx": uiProviderComponent()
2476
+ }),
2477
+ nextSteps: [
2478
+ "Run `npm run dev` and open http://localhost:3000/ui \u2014 a real form, a confirm dialog and toasts, already themed.",
2479
+ 'Use the kit anywhere: import "@lacspace/components/styles.css" and "./lacspace-ui.css" in app/layout.tsx, then wrap <body> with <UIProvider> from @/components/ui-provider.',
2480
+ "Rebrand everything from app/lacspace-ui.css \u2014 it maps this template's accent, radius and font onto the kit's --lac-* variables, so dark mode follows your theme toggle."
2481
+ ],
2482
+ learn: "https://developer.lacspace.com/components"
2483
+ },
2484
+ {
2485
+ key: "dataviz",
2486
+ label: "Charts, tables & dates",
2487
+ description: "A working insights page from @lacspace/charts, @lacspace/table and @lacspace/date \u2014 SVG charts, a sortable/exportable data table and a date-range filter. No canvas, no D3, no grid licence. Frontend, any template.",
2488
+ deps: {
2489
+ "@lacspace/components": "^1.0.1",
2490
+ "@lacspace/charts": "^1.0.0",
2491
+ "@lacspace/table": "^1.0.0",
2492
+ "@lacspace/date": "^1.0.0"
2493
+ },
2494
+ files: () => ({
2495
+ "app/lacspace-ui.css": uiThemeBridge(),
2496
+ "app/insights/layout.tsx": dataVizRouteLayout(),
2497
+ "app/insights/page.tsx": dataVizPage()
2498
+ }),
2499
+ nextSteps: [
2500
+ "Run `npm run dev` and open http://localhost:3000/insights \u2014 stats, a line chart, a date-range filter and an exportable table.",
2501
+ "Swap the sample arrays at the top of app/insights/page.tsx for your own data; everything else is already wired.",
2502
+ "The CSV export neutralises leading =, +, - and @, so an exported cell can never run as a formula in Excel."
2503
+ ],
2504
+ learn: "https://developer.lacspace.com/components#charts"
2441
2505
  }
2442
2506
  ];
2507
+ var uiThemeBridge = () => `/* Map this app's tokens onto @lacspace/components.
2508
+ *
2509
+ * The kit's own dark palette lives behind \`prefers-color-scheme\` and a
2510
+ * \`data-theme\` attribute, but this app switches themes with a \`.dark\` class on
2511
+ * <html>. The repeated \`:root\` below is deliberate: it raises specificity just
2512
+ * enough to win against the kit's own theme blocks, whatever the OS setting is,
2513
+ * so the components follow YOUR toggle and never fight it.
2514
+ *
2515
+ * Every line here is a suggestion \u2014 change any of them and the whole kit
2516
+ * follows. The full token list is at the top of @lacspace/components/styles.css.
2517
+ */
2518
+ :root:root:root {
2519
+ /* Brand */
2520
+ --lac-accent: var(--accent-to);
2521
+ --lac-accent-hover: var(--accent-from);
2522
+ --lac-accent-active: var(--accent-from);
2523
+ --lac-accent-fg: var(--on-accent);
2524
+ --lac-accent-soft: color-mix(in oklab, var(--accent-to) 14%, transparent);
2525
+ --lac-accent-ring: color-mix(in oklab, var(--accent-to) 38%, transparent);
2526
+
2527
+ /* Surfaces & text \u2014 these already swap with the .dark class, so dark mode
2528
+ comes along for free. */
2529
+ --lac-bg: var(--bg);
2530
+ --lac-bg-subtle: var(--surface);
2531
+ --lac-bg-muted: var(--panel);
2532
+ --lac-surface: var(--surface);
2533
+ --lac-surface-raised: var(--panel);
2534
+ --lac-fg: var(--fg);
2535
+ --lac-fg-muted: var(--muted);
2536
+ --lac-fg-faint: var(--faint);
2537
+ --lac-fg-on-accent: var(--on-accent);
2538
+
2539
+ /* Lines & depth */
2540
+ --lac-border: var(--hairline);
2541
+ --lac-border-strong: color-mix(in oklab, var(--fg) 22%, transparent);
2542
+ --lac-shadow-sm: var(--shadow-sm);
2543
+ --lac-shadow: var(--shadow);
2544
+ --lac-shadow-lg: var(--shadow-lg);
2545
+
2546
+ /* Shape & type */
2547
+ --lac-radius: var(--radius);
2548
+ --lac-radius-lg: var(--radius-lg);
2549
+ --lac-font: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
2550
+ }
2551
+ `;
2552
+ var uiRouteLayout = () => `import "@lacspace/components/styles.css";
2553
+ import "../lacspace-ui.css";
2554
+ import { ToastProvider } from "@lacspace/components";
2555
+
2556
+ // Scoped to this route so the add-on needs no edits to app/layout.tsx. To use
2557
+ // the kit across the whole app, move these two imports there and wrap <body>
2558
+ // with <UIProvider> from @/components/ui-provider instead.
2559
+ export default function UILayout({ children }: { children: React.ReactNode }) {
2560
+ return <ToastProvider>{children}</ToastProvider>;
2561
+ }
2562
+ `;
2563
+ var uiProviderComponent = () => `"use client";
2564
+ import { ToastProvider } from "@lacspace/components";
2565
+
2566
+ /**
2567
+ * Wrap <body> with this once to use the component kit across the whole app,
2568
+ * and import the two stylesheets in app/layout.tsx:
2569
+ *
2570
+ * import "@lacspace/components/styles.css";
2571
+ * import "./lacspace-ui.css";
2572
+ */
2573
+ export function UIProvider({ children }: { children: React.ReactNode }) {
2574
+ return <ToastProvider position="bottom-right">{children}</ToastProvider>;
2575
+ }
2576
+ `;
2577
+ var uiShowcasePage = () => `"use client";
2578
+ import { useState } from "react";
2579
+ import {
2580
+ Alert,
2581
+ Badge,
2582
+ Button,
2583
+ Card,
2584
+ CardBody,
2585
+ CardDescription,
2586
+ CardHeader,
2587
+ CardTitle,
2588
+ ConfirmDialog,
2589
+ Field,
2590
+ Input,
2591
+ PasswordInput,
2592
+ Select,
2593
+ Stack,
2594
+ Switch,
2595
+ useToast,
2596
+ } from "@lacspace/components";
2597
+
2598
+ // Everything below is real: a form that validates, a dialog that waits for its
2599
+ // promise, and a toast queue \u2014 none of it styled with utility classes. The look
2600
+ // comes from app/lacspace-ui.css, which maps this template's tokens onto the kit.
2601
+ export default function UIPage() {
2602
+ const { toast } = useToast();
2603
+ const [confirming, setConfirming] = useState(false);
2604
+ const [updates, setUpdates] = useState(true);
2605
+ const [errors, setErrors] = useState<{ email?: string }>({});
2606
+
2607
+ function save(form: FormData) {
2608
+ const email = String(form.get("email") ?? "");
2609
+ if (!email.includes("@")) {
2610
+ setErrors({ email: "That doesn't look like an email address." });
2611
+ return;
2612
+ }
2613
+ setErrors({});
2614
+ toast({ title: "Settings saved", tone: "success" });
2615
+ }
2616
+
2617
+ return (
2618
+ <main className="mx-auto max-w-2xl px-6 py-16">
2619
+ <Stack gap={4}>
2620
+ <Card>
2621
+ <CardHeader>
2622
+ <CardTitle>Account settings</CardTitle>
2623
+ <CardDescription>
2624
+ 96 components from @lacspace/components \u2014 accessible, server-render safe, and
2625
+ themed by CSS variables instead of utility classes.
2626
+ </CardDescription>
2627
+ </CardHeader>
2628
+ <CardBody>
2629
+ <form action={save}>
2630
+ <Stack gap={3}>
2631
+ <Field label="Work email" hint="We never share it." error={errors.email}>
2632
+ {({ id, describedBy, invalid }) => (
2633
+ <Input
2634
+ id={id}
2635
+ name="email"
2636
+ type="email"
2637
+ defaultValue="you@company.com"
2638
+ aria-describedby={describedBy}
2639
+ invalid={invalid}
2640
+ />
2641
+ )}
2642
+ </Field>
2643
+
2644
+ <Field label="New password" hint="At least 12 characters.">
2645
+ {({ id, describedBy }) => (
2646
+ <PasswordInput id={id} name="password" aria-describedby={describedBy} strength />
2647
+ )}
2648
+ </Field>
2649
+
2650
+ <Field label="Plan">
2651
+ {({ id }) => (
2652
+ <Select
2653
+ id={id}
2654
+ name="plan"
2655
+ defaultValue="pro"
2656
+ options={[
2657
+ { value: "free", label: "Free" },
2658
+ { value: "pro", label: "Pro" },
2659
+ { value: "team", label: "Team" },
2660
+ ]}
2661
+ />
2662
+ )}
2663
+ </Field>
2664
+
2665
+ <Switch checked={updates} onChange={setUpdates} label="Email me about product updates" />
2666
+
2667
+ <Button type="submit" full>
2668
+ Save changes
2669
+ </Button>
2670
+ </Stack>
2671
+ </form>
2672
+ </CardBody>
2673
+ </Card>
2674
+
2675
+ <Alert tone="info" title="Rebrand the whole kit from one file">
2676
+ Open <code>app/lacspace-ui.css</code>. It maps this template&rsquo;s accent, radius and font
2677
+ onto the kit&rsquo;s variables, so the components already match \u2014 and they follow your
2678
+ existing dark-mode toggle.
2679
+ </Alert>
2680
+
2681
+ <Card>
2682
+ <CardBody>
2683
+ <Stack direction="row" gap={2} align="center" wrap>
2684
+ <Badge tone="success" dot>
2685
+ Live
2686
+ </Badge>
2687
+ <Button variant="soft" onClick={() => toast("Saved to drafts")}>
2688
+ Show a toast
2689
+ </Button>
2690
+ <Button tone="danger" variant="soft" onClick={() => setConfirming(true)}>
2691
+ Delete account
2692
+ </Button>
2693
+ </Stack>
2694
+ </CardBody>
2695
+ </Card>
2696
+ </Stack>
2697
+
2698
+ <ConfirmDialog
2699
+ open={confirming}
2700
+ onOpenChange={setConfirming}
2701
+ tone="danger"
2702
+ title="Delete this account?"
2703
+ message="Everything in it goes with it. This cannot be undone."
2704
+ confirmLabel="Delete account"
2705
+ onConfirm={async () => {
2706
+ // Return a promise and the button spins, blocks repeat clicks, and
2707
+ // keeps the dialog open if it rejects \u2014 a failed delete never looks
2708
+ // like a successful one.
2709
+ await new Promise((resolve) => setTimeout(resolve, 700));
2710
+ toast({ title: "Account deleted", tone: "success" });
2711
+ }}
2712
+ />
2713
+ </main>
2714
+ );
2715
+ }
2716
+ `;
2717
+ var dataVizRouteLayout = () => `import "@lacspace/components/styles.css";
2718
+ import "@lacspace/charts/styles.css";
2719
+ import "@lacspace/table/styles.css";
2720
+ import "@lacspace/date/styles.css";
2721
+ import "../lacspace-ui.css";
2722
+
2723
+ // Scoped to this route; move these imports to app/layout.tsx to use the kit
2724
+ // everywhere. Order matters: lacspace-ui.css maps this app's tokens onto the
2725
+ // kit, so it comes last.
2726
+ export default function InsightsLayout({ children }: { children: React.ReactNode }) {
2727
+ return <>{children}</>;
2728
+ }
2729
+ `;
2730
+ var dataVizPage = () => `"use client";
2731
+ import { useState } from "react";
2732
+ import { Heading, Grid, Stack, Stat, Text } from "@lacspace/components";
2733
+ import { LineChart } from "@lacspace/charts";
2734
+ import { DataTable, textColumn, badgeColumn, currencyColumn, numberColumn } from "@lacspace/table";
2735
+ import { DateRangePicker, defaultPresets } from "@lacspace/date";
2736
+
2737
+ // Swap these three arrays for your own data \u2014 nothing else needs to change.
2738
+ const MONTHS = ["Apr", "May", "Jun", "Jul", "Aug", "Sep"];
2739
+ const REVENUE = [18, 21, 25, 24, 31, 38];
2740
+ const TARGET = [20, 22, 24, 26, 28, 30];
2741
+
2742
+ type Account = {
2743
+ id: string;
2744
+ account: string;
2745
+ plan: string;
2746
+ seats: number;
2747
+ mrr: number;
2748
+ };
2749
+
2750
+ const ACCOUNTS: Account[] = [
2751
+ { id: "1", account: "Northwind", plan: "team", seats: 42, mrr: 1890 },
2752
+ { id: "2", account: "Umbrella", plan: "pro", seats: 12, mrr: 540 },
2753
+ { id: "3", account: "Initech", plan: "team", seats: 68, mrr: 3060 },
2754
+ { id: "4", account: "Hooli", plan: "free", seats: 3, mrr: 0 },
2755
+ { id: "5", account: "Stark Industries", plan: "pro", seats: 19, mrr: 855 },
2756
+ { id: "6", account: "Wayne Enterprises", plan: "team", seats: 51, mrr: 2295 },
2757
+ ];
2758
+
2759
+ export default function InsightsPage() {
2760
+ const [range, setRange] = useState<unknown>(null);
2761
+
2762
+ return (
2763
+ <main className="mx-auto max-w-5xl px-6 py-16">
2764
+ <Stack gap={5}>
2765
+ <Stack direction="row" justify="between" align="center" gap={3} wrap>
2766
+ <div>
2767
+ <Heading level={1}>Insights</Heading>
2768
+ <Text tone="muted">Charts, a data table and a date filter \u2014 all zero-dependency.</Text>
2769
+ </div>
2770
+ <DateRangePicker
2771
+ numberOfMonths={2}
2772
+ presets={defaultPresets()}
2773
+ separator=" \u2013 "
2774
+ onChange={setRange}
2775
+ />
2776
+ </Stack>
2777
+
2778
+ <Grid columns={{ base: 1, md: 3 }} gap={3}>
2779
+ <Stat label="MRR" value="$8,640" delta={12.4} comparison="vs last month" />
2780
+ <Stat label="Seats" value="195" delta={6.1} comparison="vs last month" />
2781
+ <Stat label="Churn" value="1.8%" delta={-0.3} invertDelta comparison="vs last month" />
2782
+ </Grid>
2783
+
2784
+ <LineChart
2785
+ labels={MONTHS}
2786
+ series={[
2787
+ { name: "Revenue", data: REVENUE, area: true },
2788
+ { name: "Target", data: TARGET, dashed: true },
2789
+ ]}
2790
+ curve
2791
+ dots
2792
+ tooltip
2793
+ responsive
2794
+ dataTable
2795
+ formatValue={(n) => "$" + n + "k"}
2796
+ />
2797
+
2798
+ <DataTable<Account>
2799
+ caption="Accounts"
2800
+ data={ACCOUNTS}
2801
+ getRowId={(row) => row.id}
2802
+ columns={[
2803
+ textColumn<Account>("account", { header: "Account", key: "account", pinned: "left" }),
2804
+ badgeColumn<Account>("plan", {
2805
+ header: "Plan",
2806
+ key: "plan",
2807
+ tones: { team: "success", pro: "info", free: "default" },
2808
+ }),
2809
+ numberColumn<Account>("seats", { header: "Seats", key: "seats", aggregate: "sum" }),
2810
+ currencyColumn<Account>("mrr", { header: "MRR", key: "mrr", currency: "USD", aggregate: "sum" }),
2811
+ ]}
2812
+ defaultSort={[{ id: "mrr", direction: "desc" }]}
2813
+ searchable
2814
+ selectable
2815
+ exportable
2816
+ stickyHeader
2817
+ />
2818
+ </Stack>
2819
+ </main>
2820
+ );
2821
+ }
2822
+ `;
2443
2823
  var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
2444
2824
  import { stream } from "@lacspace/ai";
2445
2825
  import type { Message } from "@lacspace/ai";
@@ -7088,7 +7468,9 @@ var rootPkgJson = (ctx) => JSON.stringify({
7088
7468
  start: 'concurrently -k -n api,web -c blue,magenta "npm:start:api" "npm:start:web"',
7089
7469
  "start:api": `npm run start --workspace ${scope(ctx)}/backend`,
7090
7470
  "start:web": `npm run start --workspace ${scope(ctx)}/frontend`,
7091
- typecheck: `npm run typecheck --workspace ${scope(ctx)}/backend`
7471
+ // Both workspaces: a gate that silently skips the frontend is worse than
7472
+ // no gate, because it reports green over unchecked code.
7473
+ typecheck: `npm run typecheck --workspace ${scope(ctx)}/backend && npm run typecheck --workspace ${scope(ctx)}/frontend`
7092
7474
  },
7093
7475
  devDependencies: { concurrently: "^9.1.0" },
7094
7476
  engines: { node: ">=18" }
@@ -7473,6 +7855,7 @@ var backendPkgJson = (ctx) => {
7473
7855
  "@lacspace/rate-limit": "^1.2.0",
7474
7856
  "@lacspace/cache": "^1.1.0",
7475
7857
  "@lacspace/logger": "^1.0.0",
7858
+ "@lacspace/headers": "^1.1.2",
7476
7859
  ...featureDeps,
7477
7860
  [`${scope(ctx)}/types`]: "*"
7478
7861
  },
@@ -7801,14 +8184,18 @@ var backendValidation = () => `import { v, type Infer } from "@lacspace/validate
7801
8184
  // how this works: Zod-like schemas (@lacspace/validate). .parse(body) throws a
7802
8185
  // ValidationError on bad input, which the error middleware turns into a clean 400
7803
8186
  // with per-field messages.
8187
+ // Email is normalised HERE, not only in the schema. Mongoose applies
8188
+ // \`lowercase\`/\`trim\` setters when it SAVES a document, but not to query
8189
+ // filters \u2014 so \`User.findOne({ email })\` with "Bro@Gmail.com" would miss the
8190
+ // stored "bro@gmail.com", and the account would be unreachable at login.
7804
8191
  export const RegisterInput = v.object({
7805
8192
  name: v.string().min(2).max(80),
7806
- email: v.string().email(),
8193
+ email: v.string().email().trim().toLowerCase(),
7807
8194
  password: v.string().min(8).max(200),
7808
8195
  });
7809
8196
 
7810
8197
  export const LoginInput = v.object({
7811
- email: v.string().email(),
8198
+ email: v.string().email().trim().toLowerCase(),
7812
8199
  password: v.string().min(1),
7813
8200
  });
7814
8201
 
@@ -7944,19 +8331,35 @@ export default router;
7944
8331
  `;
7945
8332
  var backendApp = (ctx) => `import express from "express";
7946
8333
  import cors from "cors";
8334
+ import { expressSecurityHeaders } from "@lacspace/headers";
7947
8335
  import { env } from "./env.js";
7948
8336
  import { requestLogger } from "./logger.js";
7949
8337
  import { errorHandler } from "./middleware/error.js";
7950
8338
  import { registerRoutes } from "./routes/index.js";
7951
8339
 
7952
- // how this works: assembles the Express app \u2014 CORS for the frontend, JSON parsing,
7953
- // structured request logging (@lacspace/logger), a health check, all route groups
7954
- // (see routes/index.ts), then the error handler LAST.
8340
+ // how this works: assembles the Express app \u2014 security headers, CORS for the
8341
+ // frontend, JSON parsing, structured request logging (@lacspace/logger), a health
8342
+ // check, all route groups (see routes/index.ts), then the error handler LAST.
7955
8343
  export function createApp(): express.Express {
7956
8344
  const app = express();
7957
8345
 
8346
+ // Express advertises itself in every response by default; there is no reason
8347
+ // to tell the internet what the API is built on.
8348
+ app.disable("x-powered-by");
8349
+
8350
+ // The same hardening the frontend gets, tuned for an API: deny framing, send
8351
+ // no referrer, and a CSP that allows nothing \u2014 an API returns JSON, so there
8352
+ // is no script, style or image for a browser to load from it.
8353
+ app.use(expressSecurityHeaders({
8354
+ frameOptions: "DENY",
8355
+ referrerPolicy: "no-referrer",
8356
+ crossOriginResourcePolicy: "same-site",
8357
+ contentSecurityPolicy: { defaultSrc: ["'none'"], frameAncestors: ["'none'"] },
8358
+ }));
8359
+
7958
8360
  app.use(cors({ origin: env.CORS_ORIGIN.split(",").map((o) => o.trim()), credentials: true }));
7959
- app.use(express.json());
8361
+ // A JSON API has no business accepting a megabyte of body by default.
8362
+ app.use(express.json({ limit: "256kb" }));
7960
8363
  app.use(requestLogger); // one structured log line per request
7961
8364
 
7962
8365
  app.get("/health", (_req, res) => { res.json({ ok: true, service: "${ctx.name}-api" }); });