create-lacspace-app 2.14.0 → 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 +6 -0
- package/dist/index.js +56 -9
- package/dist/lib.cjs +56 -9
- package/dist/lib.js +56 -9
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -20,6 +20,12 @@ 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
|
+
|
|
23
29
|
> **New in v2.14 — the UI kit (18 add-ons).** The scaffold now comes with a real component layer, not just a page:
|
|
24
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.
|
|
25
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.
|
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
|
-
|
|
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}
|
|
434
|
+
${open}<div id="main" tabIndex={-1}>{children}</div>${close}
|
|
411
435
|
</ThemeProvider>
|
|
412
436
|
<script
|
|
413
437
|
type="application/ld+json"
|
|
@@ -7440,7 +7464,9 @@ var rootPkgJson = (ctx) => JSON.stringify({
|
|
|
7440
7464
|
start: 'concurrently -k -n api,web -c blue,magenta "npm:start:api" "npm:start:web"',
|
|
7441
7465
|
"start:api": `npm run start --workspace ${scope(ctx)}/backend`,
|
|
7442
7466
|
"start:web": `npm run start --workspace ${scope(ctx)}/frontend`,
|
|
7443
|
-
|
|
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`
|
|
7444
7470
|
},
|
|
7445
7471
|
devDependencies: { concurrently: "^9.1.0" },
|
|
7446
7472
|
engines: { node: ">=18" }
|
|
@@ -7825,6 +7851,7 @@ var backendPkgJson = (ctx) => {
|
|
|
7825
7851
|
"@lacspace/rate-limit": "^1.2.0",
|
|
7826
7852
|
"@lacspace/cache": "^1.1.0",
|
|
7827
7853
|
"@lacspace/logger": "^1.0.0",
|
|
7854
|
+
"@lacspace/headers": "^1.1.2",
|
|
7828
7855
|
...featureDeps,
|
|
7829
7856
|
[`${scope(ctx)}/types`]: "*"
|
|
7830
7857
|
},
|
|
@@ -8153,14 +8180,18 @@ var backendValidation = () => `import { v, type Infer } from "@lacspace/validate
|
|
|
8153
8180
|
// how this works: Zod-like schemas (@lacspace/validate). .parse(body) throws a
|
|
8154
8181
|
// ValidationError on bad input, which the error middleware turns into a clean 400
|
|
8155
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.
|
|
8156
8187
|
export const RegisterInput = v.object({
|
|
8157
8188
|
name: v.string().min(2).max(80),
|
|
8158
|
-
email: v.string().email(),
|
|
8189
|
+
email: v.string().email().trim().toLowerCase(),
|
|
8159
8190
|
password: v.string().min(8).max(200),
|
|
8160
8191
|
});
|
|
8161
8192
|
|
|
8162
8193
|
export const LoginInput = v.object({
|
|
8163
|
-
email: v.string().email(),
|
|
8194
|
+
email: v.string().email().trim().toLowerCase(),
|
|
8164
8195
|
password: v.string().min(1),
|
|
8165
8196
|
});
|
|
8166
8197
|
|
|
@@ -8296,19 +8327,35 @@ export default router;
|
|
|
8296
8327
|
`;
|
|
8297
8328
|
var backendApp = (ctx) => `import express from "express";
|
|
8298
8329
|
import cors from "cors";
|
|
8330
|
+
import { expressSecurityHeaders } from "@lacspace/headers";
|
|
8299
8331
|
import { env } from "./env.js";
|
|
8300
8332
|
import { requestLogger } from "./logger.js";
|
|
8301
8333
|
import { errorHandler } from "./middleware/error.js";
|
|
8302
8334
|
import { registerRoutes } from "./routes/index.js";
|
|
8303
8335
|
|
|
8304
|
-
// how this works: assembles the Express app \u2014 CORS for the
|
|
8305
|
-
// structured request logging (@lacspace/logger), a health
|
|
8306
|
-
// (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.
|
|
8307
8339
|
export function createApp(): express.Express {
|
|
8308
8340
|
const app = express();
|
|
8309
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
|
+
|
|
8310
8356
|
app.use(cors({ origin: env.CORS_ORIGIN.split(",").map((o) => o.trim()), credentials: true }));
|
|
8311
|
-
|
|
8357
|
+
// A JSON API has no business accepting a megabyte of body by default.
|
|
8358
|
+
app.use(express.json({ limit: "256kb" }));
|
|
8312
8359
|
app.use(requestLogger); // one structured log line per request
|
|
8313
8360
|
|
|
8314
8361
|
app.get("/health", (_req, res) => { res.json({ ok: true, service: "${ctx.name}-api" }); });
|
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
|
-
|
|
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}
|
|
438
|
+
${open}<div id="main" tabIndex={-1}>{children}</div>${close}
|
|
415
439
|
</ThemeProvider>
|
|
416
440
|
<script
|
|
417
441
|
type="application/ld+json"
|
|
@@ -7444,7 +7468,9 @@ var rootPkgJson = (ctx) => JSON.stringify({
|
|
|
7444
7468
|
start: 'concurrently -k -n api,web -c blue,magenta "npm:start:api" "npm:start:web"',
|
|
7445
7469
|
"start:api": `npm run start --workspace ${scope(ctx)}/backend`,
|
|
7446
7470
|
"start:web": `npm run start --workspace ${scope(ctx)}/frontend`,
|
|
7447
|
-
|
|
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`
|
|
7448
7474
|
},
|
|
7449
7475
|
devDependencies: { concurrently: "^9.1.0" },
|
|
7450
7476
|
engines: { node: ">=18" }
|
|
@@ -7829,6 +7855,7 @@ var backendPkgJson = (ctx) => {
|
|
|
7829
7855
|
"@lacspace/rate-limit": "^1.2.0",
|
|
7830
7856
|
"@lacspace/cache": "^1.1.0",
|
|
7831
7857
|
"@lacspace/logger": "^1.0.0",
|
|
7858
|
+
"@lacspace/headers": "^1.1.2",
|
|
7832
7859
|
...featureDeps,
|
|
7833
7860
|
[`${scope(ctx)}/types`]: "*"
|
|
7834
7861
|
},
|
|
@@ -8157,14 +8184,18 @@ var backendValidation = () => `import { v, type Infer } from "@lacspace/validate
|
|
|
8157
8184
|
// how this works: Zod-like schemas (@lacspace/validate). .parse(body) throws a
|
|
8158
8185
|
// ValidationError on bad input, which the error middleware turns into a clean 400
|
|
8159
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.
|
|
8160
8191
|
export const RegisterInput = v.object({
|
|
8161
8192
|
name: v.string().min(2).max(80),
|
|
8162
|
-
email: v.string().email(),
|
|
8193
|
+
email: v.string().email().trim().toLowerCase(),
|
|
8163
8194
|
password: v.string().min(8).max(200),
|
|
8164
8195
|
});
|
|
8165
8196
|
|
|
8166
8197
|
export const LoginInput = v.object({
|
|
8167
|
-
email: v.string().email(),
|
|
8198
|
+
email: v.string().email().trim().toLowerCase(),
|
|
8168
8199
|
password: v.string().min(1),
|
|
8169
8200
|
});
|
|
8170
8201
|
|
|
@@ -8300,19 +8331,35 @@ export default router;
|
|
|
8300
8331
|
`;
|
|
8301
8332
|
var backendApp = (ctx) => `import express from "express";
|
|
8302
8333
|
import cors from "cors";
|
|
8334
|
+
import { expressSecurityHeaders } from "@lacspace/headers";
|
|
8303
8335
|
import { env } from "./env.js";
|
|
8304
8336
|
import { requestLogger } from "./logger.js";
|
|
8305
8337
|
import { errorHandler } from "./middleware/error.js";
|
|
8306
8338
|
import { registerRoutes } from "./routes/index.js";
|
|
8307
8339
|
|
|
8308
|
-
// how this works: assembles the Express app \u2014 CORS for the
|
|
8309
|
-
// structured request logging (@lacspace/logger), a health
|
|
8310
|
-
// (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.
|
|
8311
8343
|
export function createApp(): express.Express {
|
|
8312
8344
|
const app = express();
|
|
8313
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
|
+
|
|
8314
8360
|
app.use(cors({ origin: env.CORS_ORIGIN.split(",").map((o) => o.trim()), credentials: true }));
|
|
8315
|
-
|
|
8361
|
+
// A JSON API has no business accepting a megabyte of body by default.
|
|
8362
|
+
app.use(express.json({ limit: "256kb" }));
|
|
8316
8363
|
app.use(requestLogger); // one structured log line per request
|
|
8317
8364
|
|
|
8318
8365
|
app.get("/health", (_req, res) => { res.json({ ok: true, service: "${ctx.name}-api" }); });
|
package/dist/lib.js
CHANGED
|
@@ -62,7 +62,8 @@ var pkgJson = (ctx) => JSON.stringify({
|
|
|
62
62
|
name: ctx.name,
|
|
63
63
|
version: "0.1.0",
|
|
64
64
|
private: true,
|
|
65
|
-
|
|
65
|
+
// `typecheck` so the full-stack root script can run it across both workspaces.
|
|
66
|
+
scripts: { dev: "next dev", build: "next build", start: "next start", lint: "next lint", typecheck: "tsc --noEmit" },
|
|
66
67
|
dependencies: {
|
|
67
68
|
next: "^15.1.0",
|
|
68
69
|
react: "^19.0.0",
|
|
@@ -240,6 +241,27 @@ h1, .display { letter-spacing: -0.035em; line-height: 1.02; }
|
|
|
240
241
|
::-webkit-scrollbar-thumb:hover { background: var(--hairline); }
|
|
241
242
|
:focus-visible { outline: 2px solid var(--accent-to); outline-offset: 2px; border-radius: 6px; }
|
|
242
243
|
|
|
244
|
+
/* Skip link: off-screen until it is focused, then the first thing you see.
|
|
245
|
+
The wrapper it targets carries tabIndex={-1}, so focus actually moves there
|
|
246
|
+
instead of the browser only scrolling. */
|
|
247
|
+
.skip-link {
|
|
248
|
+
position: fixed;
|
|
249
|
+
top: 10px;
|
|
250
|
+
left: 10px;
|
|
251
|
+
z-index: 100;
|
|
252
|
+
padding: 10px 16px;
|
|
253
|
+
border-radius: 10px;
|
|
254
|
+
background: var(--accent-to);
|
|
255
|
+
color: var(--on-accent);
|
|
256
|
+
font-weight: 600;
|
|
257
|
+
text-decoration: none;
|
|
258
|
+
transform: translateY(-160%);
|
|
259
|
+
transition: transform 0.18s ease;
|
|
260
|
+
}
|
|
261
|
+
.skip-link:focus { transform: translateY(0); }
|
|
262
|
+
@media (prefers-reduced-motion: reduce) { .skip-link { transition: none; } }
|
|
263
|
+
#main:focus { outline: none; }
|
|
264
|
+
|
|
243
265
|
.gradient-text {
|
|
244
266
|
background: linear-gradient(115deg, var(--accent-from), var(--accent-to));
|
|
245
267
|
-webkit-background-clip: text; background-clip: text; color: transparent;
|
|
@@ -404,11 +426,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
404
426
|
return (
|
|
405
427
|
<html lang="en" className={\`\${inter.variable} \${inter.className}\`} suppressHydrationWarning>
|
|
406
428
|
<body className="antialiased">
|
|
429
|
+
{/* Keyboard users land here first and can jump past the nav (WCAG 2.4.1). */}
|
|
430
|
+
<a href="#main" className="skip-link">Skip to content</a>
|
|
407
431
|
{/* \u2728 Dark / light / system theming with a built-in no-flash script \u2014 @lacspace/theme */}
|
|
408
432
|
<ThemeProvider defaultTheme="dark">
|
|
409
433
|
{/* \u2728 Press \u2318K / Ctrl-K anywhere \u2014 powered by @lacspace/ui */}
|
|
410
434
|
<CommandMenu />
|
|
411
|
-
${open}{children}
|
|
435
|
+
${open}<div id="main" tabIndex={-1}>{children}</div>${close}
|
|
412
436
|
</ThemeProvider>
|
|
413
437
|
<script
|
|
414
438
|
type="application/ld+json"
|
|
@@ -7441,7 +7465,9 @@ var rootPkgJson = (ctx) => JSON.stringify({
|
|
|
7441
7465
|
start: 'concurrently -k -n api,web -c blue,magenta "npm:start:api" "npm:start:web"',
|
|
7442
7466
|
"start:api": `npm run start --workspace ${scope(ctx)}/backend`,
|
|
7443
7467
|
"start:web": `npm run start --workspace ${scope(ctx)}/frontend`,
|
|
7444
|
-
|
|
7468
|
+
// Both workspaces: a gate that silently skips the frontend is worse than
|
|
7469
|
+
// no gate, because it reports green over unchecked code.
|
|
7470
|
+
typecheck: `npm run typecheck --workspace ${scope(ctx)}/backend && npm run typecheck --workspace ${scope(ctx)}/frontend`
|
|
7445
7471
|
},
|
|
7446
7472
|
devDependencies: { concurrently: "^9.1.0" },
|
|
7447
7473
|
engines: { node: ">=18" }
|
|
@@ -7826,6 +7852,7 @@ var backendPkgJson = (ctx) => {
|
|
|
7826
7852
|
"@lacspace/rate-limit": "^1.2.0",
|
|
7827
7853
|
"@lacspace/cache": "^1.1.0",
|
|
7828
7854
|
"@lacspace/logger": "^1.0.0",
|
|
7855
|
+
"@lacspace/headers": "^1.1.2",
|
|
7829
7856
|
...featureDeps,
|
|
7830
7857
|
[`${scope(ctx)}/types`]: "*"
|
|
7831
7858
|
},
|
|
@@ -8154,14 +8181,18 @@ var backendValidation = () => `import { v, type Infer } from "@lacspace/validate
|
|
|
8154
8181
|
// how this works: Zod-like schemas (@lacspace/validate). .parse(body) throws a
|
|
8155
8182
|
// ValidationError on bad input, which the error middleware turns into a clean 400
|
|
8156
8183
|
// with per-field messages.
|
|
8184
|
+
// Email is normalised HERE, not only in the schema. Mongoose applies
|
|
8185
|
+
// \`lowercase\`/\`trim\` setters when it SAVES a document, but not to query
|
|
8186
|
+
// filters \u2014 so \`User.findOne({ email })\` with "Bro@Gmail.com" would miss the
|
|
8187
|
+
// stored "bro@gmail.com", and the account would be unreachable at login.
|
|
8157
8188
|
export const RegisterInput = v.object({
|
|
8158
8189
|
name: v.string().min(2).max(80),
|
|
8159
|
-
email: v.string().email(),
|
|
8190
|
+
email: v.string().email().trim().toLowerCase(),
|
|
8160
8191
|
password: v.string().min(8).max(200),
|
|
8161
8192
|
});
|
|
8162
8193
|
|
|
8163
8194
|
export const LoginInput = v.object({
|
|
8164
|
-
email: v.string().email(),
|
|
8195
|
+
email: v.string().email().trim().toLowerCase(),
|
|
8165
8196
|
password: v.string().min(1),
|
|
8166
8197
|
});
|
|
8167
8198
|
|
|
@@ -8297,19 +8328,35 @@ export default router;
|
|
|
8297
8328
|
`;
|
|
8298
8329
|
var backendApp = (ctx) => `import express from "express";
|
|
8299
8330
|
import cors from "cors";
|
|
8331
|
+
import { expressSecurityHeaders } from "@lacspace/headers";
|
|
8300
8332
|
import { env } from "./env.js";
|
|
8301
8333
|
import { requestLogger } from "./logger.js";
|
|
8302
8334
|
import { errorHandler } from "./middleware/error.js";
|
|
8303
8335
|
import { registerRoutes } from "./routes/index.js";
|
|
8304
8336
|
|
|
8305
|
-
// how this works: assembles the Express app \u2014 CORS for the
|
|
8306
|
-
// structured request logging (@lacspace/logger), a health
|
|
8307
|
-
// (see routes/index.ts), then the error handler LAST.
|
|
8337
|
+
// how this works: assembles the Express app \u2014 security headers, CORS for the
|
|
8338
|
+
// frontend, JSON parsing, structured request logging (@lacspace/logger), a health
|
|
8339
|
+
// check, all route groups (see routes/index.ts), then the error handler LAST.
|
|
8308
8340
|
export function createApp(): express.Express {
|
|
8309
8341
|
const app = express();
|
|
8310
8342
|
|
|
8343
|
+
// Express advertises itself in every response by default; there is no reason
|
|
8344
|
+
// to tell the internet what the API is built on.
|
|
8345
|
+
app.disable("x-powered-by");
|
|
8346
|
+
|
|
8347
|
+
// The same hardening the frontend gets, tuned for an API: deny framing, send
|
|
8348
|
+
// no referrer, and a CSP that allows nothing \u2014 an API returns JSON, so there
|
|
8349
|
+
// is no script, style or image for a browser to load from it.
|
|
8350
|
+
app.use(expressSecurityHeaders({
|
|
8351
|
+
frameOptions: "DENY",
|
|
8352
|
+
referrerPolicy: "no-referrer",
|
|
8353
|
+
crossOriginResourcePolicy: "same-site",
|
|
8354
|
+
contentSecurityPolicy: { defaultSrc: ["'none'"], frameAncestors: ["'none'"] },
|
|
8355
|
+
}));
|
|
8356
|
+
|
|
8311
8357
|
app.use(cors({ origin: env.CORS_ORIGIN.split(",").map((o) => o.trim()), credentials: true }));
|
|
8312
|
-
|
|
8358
|
+
// A JSON API has no business accepting a megabyte of body by default.
|
|
8359
|
+
app.use(express.json({ limit: "256kb" }));
|
|
8313
8360
|
app.use(requestLogger); // one structured log line per request
|
|
8314
8361
|
|
|
8315
8362
|
app.get("/health", (_req, res) => { res.json({ ok: true, service: "${ctx.name}-api" }); });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-lacspace-app",
|
|
3
|
-
"version": "2.14.
|
|
4
|
-
"description": "Scaffold a beautiful, production-ready Next.js app from a Lacspace template
|
|
3
|
+
"version": "2.14.1",
|
|
4
|
+
"description": "Scaffold a beautiful, production-ready Next.js app from a Lacspace template \u2014 portfolio, business, e-commerce, SaaS, blog, docs, dashboard, restaurant or marketplace \u2014 pre-wired with SEO, security headers, sitemap and robots. Use it as a CLI (like create-next-app, but you start gorgeous) or import it as a library to generate projects programmatically.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-lacspace-app": "dist/index.js"
|