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