create-lacspace-app 2.5.0 → 2.7.0

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
@@ -40,7 +40,8 @@ function resolveContext(options = {}) {
40
40
  const seg = raw.split(/[\\/]/).filter(Boolean).pop() ?? "my-app";
41
41
  const name = seg.toLowerCase().replace(/[^a-z0-9-_]/g, "-").replace(/^-+|-+$/g, "") || "my-app";
42
42
  const features = normalizeFeatures(options.features);
43
- const mode = options.mode === "dynamic" ? "dynamic" : "static";
43
+ let mode = options.mode === "dynamic" ? "dynamic" : "static";
44
+ if (mode === "static" && features.some((f) => f.requiresBackend)) mode = "dynamic";
44
45
  return { name, template, features, mode };
45
46
  }
46
47
  function normalizeFeatures(requested) {
@@ -2166,6 +2167,117 @@ var FEATURES = [
2166
2167
  "Want semantic search? Upgrade to @lacspace/embeddings + @lacspace/vector (keyless-local via Ollama)."
2167
2168
  ],
2168
2169
  learn: "https://developer.lacspace.com/packages/rerank"
2170
+ },
2171
+ {
2172
+ key: "auth-pages",
2173
+ label: "Auth & account",
2174
+ description: "Account management on top of the built-in login/register: edit profile, change password, and TOTP two-factor auth (2FA) with backup codes. Full-stack.",
2175
+ requiresBackend: true,
2176
+ deps: {},
2177
+ files: () => ({ "app/account/settings/page.tsx": authSettingsPage() }),
2178
+ backend: (ctx) => ({
2179
+ deps: { "@lacspace/otp": "^1.2.0" },
2180
+ files: {
2181
+ "src/models/two-factor.ts": authTwoFactorModel(),
2182
+ "src/routes/account.ts": authAccountRoutes(ctx)
2183
+ },
2184
+ routes: [{ path: "/account", handler: "accountRoutes", auth: true, importLine: 'import accountRoutes from "./account.js";' }]
2185
+ }),
2186
+ nextSteps: [
2187
+ "Sign in, then open http://localhost:3000/account/settings.",
2188
+ "Enable 2FA: add the shown secret to an authenticator app (Google Authenticator, Authy\u2026), then verify.",
2189
+ "Backup codes are shown once on enable \u2014 store them somewhere safe."
2190
+ ],
2191
+ learn: "https://developer.lacspace.com/packages/otp"
2192
+ },
2193
+ {
2194
+ key: "analytics",
2195
+ label: "Analytics",
2196
+ description: "Privacy-first, cookieless web analytics: a tracker (@lacspace/analytics-lite), a collector that stores events in MongoDB, and a dashboard. Full-stack.",
2197
+ requiresBackend: true,
2198
+ deps: { "@lacspace/analytics-lite": "^1.1.0" },
2199
+ files: (ctx) => ({
2200
+ "components/analytics.tsx": analyticsComponent(ctx),
2201
+ "app/analytics/page.tsx": analyticsDashboard()
2202
+ }),
2203
+ backend: () => ({
2204
+ files: {
2205
+ "src/models/event.ts": analyticsEventModel(),
2206
+ "src/routes/events.ts": analyticsEventsRoutes()
2207
+ },
2208
+ routes: [{ path: "/events", handler: "eventRoutes", auth: false, importLine: 'import eventRoutes from "./events.js";' }]
2209
+ }),
2210
+ nextSteps: [
2211
+ "Add <Analytics /> to app/layout.tsx (inside <body>) to start tracking page views.",
2212
+ "Browse your site, then open http://localhost:3000/analytics (sign in) to see the dashboard.",
2213
+ "It's cookieless and stores no personal data \u2014 privacy-first by default."
2214
+ ],
2215
+ learn: "https://developer.lacspace.com/packages/analytics-lite"
2216
+ },
2217
+ {
2218
+ key: "payments",
2219
+ label: "Payments (Nepal)",
2220
+ description: "A checkout wired to eSewa & Khalti \u2014 orders, the signed eSewa flow (works in TEST with NO credentials), Khalti when keyed, integer-safe money. Full-stack.",
2221
+ requiresBackend: true,
2222
+ deps: {},
2223
+ files: () => ({
2224
+ "app/checkout/page.tsx": checkoutPage(),
2225
+ "app/checkout/success/page.tsx": checkoutSuccessPage(),
2226
+ "app/checkout/failed/page.tsx": checkoutFailedPage()
2227
+ }),
2228
+ backend: () => ({
2229
+ deps: { "@lacspace/esewa": "^1.2.0", "@lacspace/khalti": "^1.1.0", "@lacspace/money": "^1.1.0" },
2230
+ files: {
2231
+ "src/models/order.ts": ordersModel(),
2232
+ "src/routes/checkout.ts": checkoutRoutes()
2233
+ },
2234
+ routes: [{ path: "/checkout", handler: "checkoutRoutes", auth: true, importLine: 'import checkoutRoutes from "./checkout.js";' }],
2235
+ env: {
2236
+ ESEWA_MERCHANT_CODE: "eSewa merchant/product code (blank = eSewa TEST sandbox).",
2237
+ ESEWA_SECRET: "eSewa secret key (blank = TEST sandbox \u2014 payments work end-to-end in test).",
2238
+ KHALTI_SECRET: "Khalti secret key (required to enable Khalti; blank shows a 'configure Khalti' message)."
2239
+ }
2240
+ }),
2241
+ nextSteps: [
2242
+ "Sign in, then open http://localhost:3000/checkout and pay with eSewa \u2014 it works in TEST mode with no credentials.",
2243
+ "Enable Khalti by setting KHALTI_SECRET in .env (get a test key from https://khalti.com).",
2244
+ "Go live: set ESEWA_MERCHANT_CODE + ESEWA_SECRET (and a live KHALTI_SECRET)."
2245
+ ],
2246
+ learn: "https://developer.lacspace.com/packages/esewa"
2247
+ },
2248
+ {
2249
+ key: "email",
2250
+ label: "Email",
2251
+ description: "Transactional email \u2014 a ready mail service (@lacspace/mailer) with beautiful templates + address validation. Logs to the console until you add SMTP. Full-stack.",
2252
+ requiresBackend: true,
2253
+ deps: {},
2254
+ files: () => ({ "app/email-test/page.tsx": emailTestPage() }),
2255
+ backend: (ctx) => ({
2256
+ deps: {
2257
+ "@lacspace/mailer": "^1.2.0",
2258
+ "@lacspace/email-templates": "^1.1.0",
2259
+ "@lacspace/email-validate": "^1.1.0"
2260
+ },
2261
+ files: {
2262
+ "src/mail/mailer.ts": mailerLib(ctx),
2263
+ "src/routes/email.ts": emailRoutes()
2264
+ },
2265
+ routes: [{ path: "/email", handler: "emailRoutes", auth: true, importLine: 'import emailRoutes from "./email.js";' }],
2266
+ env: {
2267
+ SMTP_HOST: "SMTP host (blank in dev \u2014 emails are logged to the API console instead of sent).",
2268
+ SMTP_PORT: "SMTP port (default 587).",
2269
+ SMTP_SECURE: "true for implicit TLS on port 465; otherwise false.",
2270
+ SMTP_USER: "SMTP username.",
2271
+ SMTP_PASS: "SMTP password.",
2272
+ SMTP_FROM: 'Default From address, e.g. "Acme <no-reply@acme.com>".'
2273
+ }
2274
+ }),
2275
+ nextSteps: [
2276
+ "Sign in, then open http://localhost:3000/email-test and send yourself a sample email.",
2277
+ "With no SMTP_* set, the email is printed to the API console (dev). Set SMTP_* in .env to send for real.",
2278
+ "Reuse the helpers in backend/src/mail/mailer.ts: sendWelcome / sendVerify / sendReset."
2279
+ ],
2280
+ learn: "https://developer.lacspace.com/packages/mailer"
2169
2281
  }
2170
2282
  ];
2171
2283
  var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
@@ -5416,6 +5528,733 @@ export default function SearchPage() {
5416
5528
  );
5417
5529
  }
5418
5530
  `;
5531
+ var authTwoFactorModel = () => `import mongoose from "mongoose";
5532
+ import { uuidv7 } from "@lacspace/id";
5533
+
5534
+ // how this works: a separate collection for 2FA so the base User model stays simple.
5535
+ // NOTE: the TOTP secret is stored as-is here \u2014 in production, encrypt it at rest with
5536
+ // @lacspace/crypto (encrypt/decrypt) using a key from your env.
5537
+ export interface TwoFactorDoc {
5538
+ _id: string;
5539
+ userId: string;
5540
+ secret: string;
5541
+ enabled: boolean;
5542
+ backupHashes: string[];
5543
+ createdAt: Date;
5544
+ updatedAt: Date;
5545
+ }
5546
+
5547
+ const schema = new mongoose.Schema<TwoFactorDoc>(
5548
+ {
5549
+ _id: { type: String, default: () => uuidv7() },
5550
+ userId: { type: String, required: true, unique: true, index: true },
5551
+ secret: { type: String, required: true },
5552
+ enabled: { type: Boolean, default: false },
5553
+ backupHashes: { type: [String], default: [] },
5554
+ },
5555
+ { timestamps: true },
5556
+ );
5557
+
5558
+ export const TwoFactor =
5559
+ (mongoose.models.TwoFactor as mongoose.Model<TwoFactorDoc>) ?? mongoose.model<TwoFactorDoc>("TwoFactor", schema);
5560
+ `;
5561
+ var authAccountRoutes = (ctx) => `import express from "express";
5562
+ import { hash, verify as verifyPassword } from "@lacspace/password";
5563
+ import { setupTotp, verifyTotp, generateBackupCodes, verifyBackupCode } from "@lacspace/otp";
5564
+ import { v } from "@lacspace/validate";
5565
+ import { asyncHandler, HttpError } from "../http.js";
5566
+ import { User } from "../models/user.js";
5567
+ import { TwoFactor } from "../models/two-factor.js";
5568
+
5569
+ // This whole group is mounted behind requireAuth (see routes/index.ts), so
5570
+ // req.user is always set here.
5571
+ const router = express.Router();
5572
+ const ISSUER = "${ctx.name}";
5573
+
5574
+ const ProfileInput = v.object({ name: v.string().min(2).max(80) });
5575
+ const PasswordInput = v.object({ currentPassword: v.string().min(1), newPassword: v.string().min(8).max(200) });
5576
+ const CodeInput = v.object({ code: v.string().min(6).max(12) });
5577
+
5578
+ // GET /account/2fa \u2014 is two-factor enabled?
5579
+ router.get("/2fa", asyncHandler(async (req, res) => {
5580
+ const tf = await TwoFactor.findOne({ userId: req.user!.sub });
5581
+ res.json({ enabled: Boolean(tf?.enabled) });
5582
+ }));
5583
+
5584
+ // PATCH /account/profile \u2014 update your display name.
5585
+ router.patch("/profile", asyncHandler(async (req, res) => {
5586
+ const { name } = ProfileInput.parse(req.body);
5587
+ const user = await User.findByIdAndUpdate(req.user!.sub, { $set: { name } }, { new: true });
5588
+ if (!user) throw new HttpError(404, "User not found");
5589
+ res.json({ id: String(user._id), name: user.name, email: user.email });
5590
+ }));
5591
+
5592
+ // POST /account/password \u2014 change your password (verifies the current one).
5593
+ router.post("/password", asyncHandler(async (req, res) => {
5594
+ const { currentPassword, newPassword } = PasswordInput.parse(req.body);
5595
+ const user = await User.findById(req.user!.sub);
5596
+ if (!user || !(await verifyPassword(currentPassword, user.passwordHash))) {
5597
+ throw new HttpError(401, "Current password is incorrect");
5598
+ }
5599
+ user.passwordHash = await hash(newPassword);
5600
+ await user.save();
5601
+ res.json({ ok: true });
5602
+ }));
5603
+
5604
+ // POST /account/2fa/setup \u2014 create a TOTP secret; show \`uri\` as a QR / \`secret\` to type.
5605
+ router.post("/2fa/setup", asyncHandler(async (req, res) => {
5606
+ const user = await User.findById(req.user!.sub);
5607
+ if (!user) throw new HttpError(404, "User not found");
5608
+ const { secret, uri } = setupTotp({ account: user.email, issuer: ISSUER });
5609
+ await TwoFactor.findOneAndUpdate(
5610
+ { userId: String(user._id) },
5611
+ { $set: { secret, enabled: false } },
5612
+ { upsert: true, new: true },
5613
+ );
5614
+ res.json({ secret, uri });
5615
+ }));
5616
+
5617
+ // POST /account/2fa/enable \u2014 verify a code, turn 2FA on, return one-time backup codes.
5618
+ router.post("/2fa/enable", asyncHandler(async (req, res) => {
5619
+ const { code } = CodeInput.parse(req.body);
5620
+ const tf = await TwoFactor.findOne({ userId: req.user!.sub });
5621
+ if (!tf) throw new HttpError(400, "Run 2FA setup first");
5622
+ if ((await verifyTotp(code, tf.secret, { window: 1 })) === null) throw new HttpError(401, "Invalid code");
5623
+ const { codes, hashes } = await generateBackupCodes(10);
5624
+ tf.enabled = true;
5625
+ tf.backupHashes = hashes;
5626
+ await tf.save();
5627
+ res.json({ enabled: true, backupCodes: codes });
5628
+ }));
5629
+
5630
+ // POST /account/2fa/disable \u2014 verify a TOTP or backup code, then turn 2FA off.
5631
+ router.post("/2fa/disable", asyncHandler(async (req, res) => {
5632
+ const { code } = CodeInput.parse(req.body);
5633
+ const tf = await TwoFactor.findOne({ userId: req.user!.sub });
5634
+ if (!tf || !tf.enabled) { res.json({ enabled: false }); return; }
5635
+ const ok = (await verifyTotp(code, tf.secret, { window: 1 })) !== null || (await verifyBackupCode(code, tf.backupHashes)) >= 0;
5636
+ if (!ok) throw new HttpError(401, "Invalid code");
5637
+ await TwoFactor.deleteOne({ userId: req.user!.sub });
5638
+ res.json({ enabled: false });
5639
+ }));
5640
+
5641
+ export default router;
5642
+ `;
5643
+ var authSettingsPage = () => `"use client";
5644
+ import { useEffect, useState } from "react";
5645
+ import { useRouter } from "next/navigation";
5646
+ import { getToken } from "@/lib/api";
5647
+
5648
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
5649
+
5650
+ async function call<T>(path: string, body?: unknown, method = "POST"): Promise<T> {
5651
+ const token = getToken();
5652
+ const res = await fetch(API + path, {
5653
+ method,
5654
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
5655
+ body: body === undefined ? undefined : JSON.stringify(body),
5656
+ });
5657
+ const data: unknown = await res.json().catch(() => ({}));
5658
+ if (!res.ok) throw new Error((data as { error?: string }).error ?? "Request failed");
5659
+ return data as T;
5660
+ }
5661
+ const msgOf = (e: unknown) => (e instanceof Error ? e.message : "Something went wrong");
5662
+ const field = "w-full rounded-xl border border-hairline bg-surface px-4 py-2 outline-none";
5663
+ const card = "rounded-2xl border border-hairline p-5 space-y-3";
5664
+ const btn = "rounded-full gradient-bg px-4 py-2 text-sm font-semibold on-accent";
5665
+
5666
+ export default function SettingsPage() {
5667
+ const router = useRouter();
5668
+ const [name, setName] = useState("");
5669
+ const [current, setCurrent] = useState("");
5670
+ const [next, setNext] = useState("");
5671
+ const [enabled, setEnabled] = useState(false);
5672
+ const [setup, setSetup] = useState<{ secret: string; uri: string } | null>(null);
5673
+ const [code, setCode] = useState("");
5674
+ const [backup, setBackup] = useState<string[] | null>(null);
5675
+ const [msg, setMsg] = useState<string | null>(null);
5676
+
5677
+ const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(null), 3000); };
5678
+
5679
+ useEffect(() => {
5680
+ if (!getToken()) { router.push("/login"); return; }
5681
+ call<{ enabled: boolean }>("/account/2fa", undefined, "GET").then((r) => setEnabled(r.enabled)).catch(() => {});
5682
+ }, [router]);
5683
+
5684
+ async function saveName(e: React.FormEvent) {
5685
+ e.preventDefault();
5686
+ try { await call("/account/profile", { name }, "PATCH"); flash("Profile updated"); } catch (err) { flash(msgOf(err)); }
5687
+ }
5688
+ async function savePassword(e: React.FormEvent) {
5689
+ e.preventDefault();
5690
+ try { await call("/account/password", { currentPassword: current, newPassword: next }); setCurrent(""); setNext(""); flash("Password changed"); } catch (err) { flash(msgOf(err)); }
5691
+ }
5692
+ async function begin2fa() {
5693
+ try { setSetup(await call<{ secret: string; uri: string }>("/account/2fa/setup")); } catch (err) { flash(msgOf(err)); }
5694
+ }
5695
+ async function enable2fa() {
5696
+ try { const r = await call<{ backupCodes: string[] }>("/account/2fa/enable", { code }); setBackup(r.backupCodes); setEnabled(true); setSetup(null); setCode(""); } catch (err) { flash(msgOf(err)); }
5697
+ }
5698
+ async function disable2fa() {
5699
+ const c = window.prompt("Enter a current 2FA code (or a backup code) to disable:");
5700
+ if (!c) return;
5701
+ try { await call("/account/2fa/disable", { code: c }); setEnabled(false); flash("2FA disabled"); } catch (err) { flash(msgOf(err)); }
5702
+ }
5703
+
5704
+ return (
5705
+ <main className="mx-auto max-w-lg space-y-6 px-6 py-16">
5706
+ <h1 className="text-2xl font-bold">Account settings</h1>
5707
+ {msg && <p className="rounded-xl border border-hairline bg-surface px-4 py-2 text-sm">{msg}</p>}
5708
+
5709
+ <form onSubmit={saveName} className={card}>
5710
+ <h2 className="font-semibold">Profile</h2>
5711
+ <input value={name} onChange={(e) => setName(e.target.value)} placeholder="New display name" className={field} />
5712
+ <button className={btn}>Save name</button>
5713
+ </form>
5714
+
5715
+ <form onSubmit={savePassword} className={card}>
5716
+ <h2 className="font-semibold">Change password</h2>
5717
+ <input type="password" value={current} onChange={(e) => setCurrent(e.target.value)} placeholder="Current password" className={field} />
5718
+ <input type="password" value={next} onChange={(e) => setNext(e.target.value)} placeholder="New password (min 8)" className={field} />
5719
+ <button className={btn}>Change password</button>
5720
+ </form>
5721
+
5722
+ <div className={card}>
5723
+ <h2 className="font-semibold">Two-factor authentication {enabled && <span className="text-green-500">\xB7 on</span>}</h2>
5724
+ {!enabled && !setup && <button onClick={begin2fa} className={btn}>Enable 2FA</button>}
5725
+ {!enabled && setup && (
5726
+ <div className="space-y-3">
5727
+ <p className="text-sm text-muted">Add this secret to your authenticator app (Google Authenticator, Authy\u2026):</p>
5728
+ <code className="block break-all rounded-lg bg-surface p-3 text-sm">{setup.secret}</code>
5729
+ <input value={code} onChange={(e) => setCode(e.target.value)} placeholder="6-digit code" className={field} />
5730
+ <button onClick={enable2fa} className={btn}>Verify & enable</button>
5731
+ </div>
5732
+ )}
5733
+ {enabled && <button onClick={disable2fa} className="rounded-full border border-hairline px-4 py-2 text-sm">Disable 2FA</button>}
5734
+ {backup && (
5735
+ <div className="mt-2 space-y-2">
5736
+ <p className="text-sm font-medium">Save these backup codes (shown once):</p>
5737
+ <ul className="grid grid-cols-2 gap-1 rounded-lg bg-surface p-3 font-mono text-sm">
5738
+ {backup.map((b) => <li key={b}>{b}</li>)}
5739
+ </ul>
5740
+ </div>
5741
+ )}
5742
+ </div>
5743
+ </main>
5744
+ );
5745
+ }
5746
+ `;
5747
+ var analyticsEventModel = () => `import mongoose from "mongoose";
5748
+ import { uuidv7 } from "@lacspace/id";
5749
+
5750
+ export interface EventDoc {
5751
+ _id: string;
5752
+ type: string;
5753
+ path: string;
5754
+ referrer: string;
5755
+ screen: string;
5756
+ sid: string;
5757
+ props: Record<string, unknown>;
5758
+ ts: Date;
5759
+ }
5760
+
5761
+ const schema = new mongoose.Schema<EventDoc>({
5762
+ _id: { type: String, default: () => uuidv7() },
5763
+ type: { type: String, default: "pageview", index: true },
5764
+ path: { type: String, default: "/" },
5765
+ referrer: { type: String, default: "" },
5766
+ screen: { type: String, default: "" },
5767
+ sid: { type: String, default: "" },
5768
+ props: { type: mongoose.Schema.Types.Mixed, default: {} },
5769
+ ts: { type: Date, default: Date.now, index: true },
5770
+ });
5771
+
5772
+ export const Event =
5773
+ (mongoose.models.Event as mongoose.Model<EventDoc>) ?? mongoose.model<EventDoc>("Event", schema);
5774
+ `;
5775
+ var analyticsEventsRoutes = () => `import express from "express";
5776
+ import { asyncHandler } from "../http.js";
5777
+ import { requireAuth } from "../middleware/auth.js";
5778
+ import { Event } from "../models/event.js";
5779
+
5780
+ const router = express.Router();
5781
+
5782
+ // POST /events \u2014 the tracker beacon posts here. Public + cookieless (no PII stored).
5783
+ router.post("/", asyncHandler(async (req, res) => {
5784
+ const b = (req.body ?? {}) as Record<string, unknown>;
5785
+ await Event.create({
5786
+ type: String(b.type ?? "pageview"),
5787
+ path: String(b.path ?? "/"),
5788
+ referrer: String(b.referrer ?? ""),
5789
+ screen: String(b.screen ?? ""),
5790
+ sid: String(b.sid ?? ""),
5791
+ props: b.props && typeof b.props === "object" ? (b.props as Record<string, unknown>) : {},
5792
+ });
5793
+ res.status(204).end();
5794
+ }));
5795
+
5796
+ // GET /events/summary \u2014 dashboard aggregates (protected: only signed-in owners).
5797
+ router.get("/summary", requireAuth, asyncHandler(async (_req, res) => {
5798
+ const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
5799
+ const [total, pageviews, topPaths, byDay] = await Promise.all([
5800
+ Event.countDocuments({}),
5801
+ Event.countDocuments({ type: "pageview" }),
5802
+ Event.aggregate([
5803
+ { $match: { type: "pageview" } },
5804
+ { $group: { _id: "$path", count: { $sum: 1 } } },
5805
+ { $sort: { count: -1 } },
5806
+ { $limit: 8 },
5807
+ ]),
5808
+ Event.aggregate([
5809
+ { $match: { ts: { $gte: since } } },
5810
+ { $group: { _id: { $dateToString: { format: "%Y-%m-%d", date: "$ts" } }, count: { $sum: 1 } } },
5811
+ { $sort: { _id: 1 } },
5812
+ ]),
5813
+ ]);
5814
+ res.json({
5815
+ total,
5816
+ pageviews,
5817
+ topPaths: topPaths.map((p: { _id: string; count: number }) => ({ path: p._id, count: p.count })),
5818
+ byDay: byDay.map((d: { _id: string; count: number }) => ({ day: d._id, count: d.count })),
5819
+ });
5820
+ }));
5821
+
5822
+ export default router;
5823
+ `;
5824
+ var analyticsComponent = (ctx) => `"use client";
5825
+ import { useEffect } from "react";
5826
+ import { createAnalytics } from "@lacspace/analytics-lite";
5827
+
5828
+ // how this works: cookieless, privacy-first tracking. It POSTs page views + events to
5829
+ // your backend /events collector (stored in MongoDB). No cookies, no localStorage, no
5830
+ // personal data. Respects the browser's Do-Not-Track.
5831
+ const ENDPOINT = (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000") + "/events";
5832
+
5833
+ export function Analytics() {
5834
+ useEffect(() => {
5835
+ const a = createAnalytics({ endpoint: ENDPOINT, siteId: ${JSON.stringify(ctx.name)}, respectDNT: true });
5836
+ a.pageview();
5837
+ return a.autoTrack();
5838
+ }, []);
5839
+ return null;
5840
+ }
5841
+ `;
5842
+ var analyticsDashboard = () => `"use client";
5843
+ import { useEffect, useState } from "react";
5844
+ import { useRouter } from "next/navigation";
5845
+ import { getToken } from "@/lib/api";
5846
+
5847
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
5848
+
5849
+ interface Summary {
5850
+ total: number;
5851
+ pageviews: number;
5852
+ topPaths: { path: string; count: number }[];
5853
+ byDay: { day: string; count: number }[];
5854
+ }
5855
+
5856
+ export default function AnalyticsPage() {
5857
+ const router = useRouter();
5858
+ const [data, setData] = useState<Summary | null>(null);
5859
+ const [error, setError] = useState<string | null>(null);
5860
+
5861
+ useEffect(() => {
5862
+ const token = getToken();
5863
+ if (!token) { router.push("/login"); return; }
5864
+ fetch(API + "/events/summary", { headers: { Authorization: "Bearer " + token } })
5865
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error("Please sign in"))))
5866
+ .then(setData)
5867
+ .catch((e) => setError(e instanceof Error ? e.message : "Failed to load"));
5868
+ }, [router]);
5869
+
5870
+ if (error) return <main className="mx-auto max-w-3xl px-6 py-16 text-muted">{error}</main>;
5871
+ if (!data) return <main className="mx-auto max-w-3xl px-6 py-16 text-muted">Loading\u2026</main>;
5872
+ const max = Math.max(1, ...data.byDay.map((d) => d.count));
5873
+
5874
+ return (
5875
+ <main className="mx-auto max-w-3xl px-6 py-16">
5876
+ <h1 className="text-3xl font-bold">Analytics</h1>
5877
+ <p className="mt-1 text-muted">Cookieless, privacy-first \u2014 last 30 days.</p>
5878
+ <div className="mt-6 grid grid-cols-2 gap-4">
5879
+ <div className="rounded-2xl border border-hairline p-5"><p className="text-sm text-muted">Total events</p><p className="text-3xl font-bold">{data.total}</p></div>
5880
+ <div className="rounded-2xl border border-hairline p-5"><p className="text-sm text-muted">Page views</p><p className="text-3xl font-bold">{data.pageviews}</p></div>
5881
+ </div>
5882
+ <h2 className="mt-8 font-semibold">Top pages</h2>
5883
+ <ul className="mt-3 space-y-2">
5884
+ {data.topPaths.length === 0 && <li className="text-muted">No data yet \u2014 browse your site with &lt;Analytics/&gt; mounted.</li>}
5885
+ {data.topPaths.map((p) => (
5886
+ <li key={p.path} className="flex justify-between border-b border-hairline pb-2"><span className="truncate">{p.path}</span><span className="text-muted">{p.count}</span></li>
5887
+ ))}
5888
+ </ul>
5889
+ <h2 className="mt-8 font-semibold">Events per day</h2>
5890
+ <div className="mt-3 flex items-end gap-1" style={{ height: 120 }}>
5891
+ {data.byDay.map((d) => (
5892
+ <div key={d.day} className="flex-1 rounded-t" style={{ height: (d.count / max) * 100 + "%", backgroundColor: "var(--accent, #6366f1)" }} title={d.day + ": " + d.count} />
5893
+ ))}
5894
+ </div>
5895
+ </main>
5896
+ );
5897
+ }
5898
+ `;
5899
+ var ordersModel = () => `import mongoose from "mongoose";
5900
+ import { uuidv7 } from "@lacspace/id";
5901
+
5902
+ export interface OrderDoc {
5903
+ _id: string;
5904
+ userId: string;
5905
+ label: string;
5906
+ amountPaisa: number;
5907
+ currency: string;
5908
+ status: "pending" | "paid" | "failed";
5909
+ gateway: string;
5910
+ ref: string;
5911
+ createdAt: Date;
5912
+ updatedAt: Date;
5913
+ }
5914
+
5915
+ const schema = new mongoose.Schema<OrderDoc>(
5916
+ {
5917
+ _id: { type: String, default: () => uuidv7() },
5918
+ userId: { type: String, required: true, index: true },
5919
+ label: { type: String, required: true },
5920
+ amountPaisa: { type: Number, required: true },
5921
+ currency: { type: String, default: "NPR" },
5922
+ status: { type: String, enum: ["pending", "paid", "failed"], default: "pending" },
5923
+ gateway: { type: String, default: "" },
5924
+ ref: { type: String, default: "" },
5925
+ },
5926
+ { timestamps: true },
5927
+ );
5928
+
5929
+ export const Order =
5930
+ (mongoose.models.Order as mongoose.Model<OrderDoc>) ?? mongoose.model<OrderDoc>("Order", schema);
5931
+ `;
5932
+ var checkoutRoutes = () => `import express from "express";
5933
+ import {
5934
+ buildForm, verifyResponse, paisaToRupees, generateTransactionUuid,
5935
+ ESEWA_TEST_SECRET, ESEWA_TEST_PRODUCT_CODE,
5936
+ } from "@lacspace/esewa";
5937
+ import { initiate, lookup } from "@lacspace/khalti";
5938
+ import { Money, formatBasic } from "@lacspace/money";
5939
+ import { v } from "@lacspace/validate";
5940
+ import { asyncHandler, HttpError } from "../http.js";
5941
+ import { env } from "../env.js";
5942
+ import { Order } from "../models/order.js";
5943
+
5944
+ // how this works: create an order, then start a gateway payment. eSewa works in TEST
5945
+ // mode with the package's baked-in sandbox keys (no credentials!). Khalti needs a real
5946
+ // secret. After the customer returns, the frontend calls /verify \u2014 never trust the
5947
+ // redirect alone, always confirm server-side.
5948
+ const router = express.Router();
5949
+ const web = () => env.CORS_ORIGIN.split(",")[0]!.trim();
5950
+
5951
+ const CreateInput = v.object({ label: v.string().min(1).max(120), amount: v.number().positive() });
5952
+
5953
+ // POST /checkout \u2014 create a pending order (amount in RUPEES from the UI \u2192 stored as paisa).
5954
+ router.post("/", asyncHandler(async (req, res) => {
5955
+ const { label, amount } = CreateInput.parse(req.body);
5956
+ const amountPaisa = Money.of(amount, "NPR").toMinor();
5957
+ const order = await Order.create({ userId: req.user!.sub, label, amountPaisa });
5958
+ res.status(201).json({ id: String(order._id), label, amountPaisa, display: formatBasic(Money.fromMinor(amountPaisa, "NPR")) });
5959
+ }));
5960
+
5961
+ // POST /checkout/:id/esewa \u2014 build the signed eSewa form to auto-POST from the browser.
5962
+ router.post("/:id/esewa", asyncHandler(async (req, res) => {
5963
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
5964
+ if (!order) throw new HttpError(404, "Order not found");
5965
+ const secret = process.env.ESEWA_SECRET ?? ESEWA_TEST_SECRET;
5966
+ const productCode = process.env.ESEWA_MERCHANT_CODE ?? ESEWA_TEST_PRODUCT_CODE;
5967
+ const esewaEnv: "test" | "prod" = process.env.ESEWA_SECRET ? "prod" : "test";
5968
+ const uuid = generateTransactionUuid();
5969
+ order.ref = uuid;
5970
+ order.gateway = "esewa";
5971
+ await order.save();
5972
+ const form = await buildForm(
5973
+ {
5974
+ amount: paisaToRupees(order.amountPaisa),
5975
+ transactionUuid: uuid,
5976
+ productCode,
5977
+ successUrl: web() + "/checkout/success?orderId=" + String(order._id),
5978
+ failureUrl: web() + "/checkout/failed?orderId=" + String(order._id),
5979
+ },
5980
+ { secret, env: esewaEnv },
5981
+ );
5982
+ res.json(form);
5983
+ }));
5984
+
5985
+ // POST /checkout/:id/esewa/verify \u2014 verify the base64 \`data\` eSewa returned; mark paid.
5986
+ router.post("/:id/esewa/verify", asyncHandler(async (req, res) => {
5987
+ const { data } = v.object({ data: v.string().min(1) }).parse(req.body);
5988
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
5989
+ if (!order) throw new HttpError(404, "Order not found");
5990
+ const secret = process.env.ESEWA_SECRET ?? ESEWA_TEST_SECRET;
5991
+ const result = await verifyResponse(data, secret);
5992
+ const status = String((result.data as { status?: string }).status ?? "");
5993
+ if (!result.valid || status !== "COMPLETE") throw new HttpError(400, "Payment could not be verified");
5994
+ order.status = "paid";
5995
+ await order.save();
5996
+ res.json({ id: String(order._id), status: "paid" });
5997
+ }));
5998
+
5999
+ // POST /checkout/:id/khalti \u2014 start a Khalti payment (needs KHALTI_SECRET, else 501).
6000
+ router.post("/:id/khalti", asyncHandler(async (req, res) => {
6001
+ const secretKey = process.env.KHALTI_SECRET;
6002
+ if (!secretKey) throw new HttpError(501, "Set KHALTI_SECRET in .env to enable Khalti (see .env.example).");
6003
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
6004
+ if (!order) throw new HttpError(404, "Order not found");
6005
+ order.gateway = "khalti";
6006
+ await order.save();
6007
+ const r = await initiate(
6008
+ {
6009
+ return_url: web() + "/checkout/success?orderId=" + String(order._id),
6010
+ website_url: web(),
6011
+ amount: order.amountPaisa,
6012
+ purchase_order_id: String(order._id),
6013
+ purchase_order_name: order.label,
6014
+ },
6015
+ { secretKey, env: "test" },
6016
+ );
6017
+ order.ref = r.pidx;
6018
+ await order.save();
6019
+ res.json({ paymentUrl: r.payment_url, pidx: r.pidx });
6020
+ }));
6021
+
6022
+ // POST /checkout/:id/khalti/verify \u2014 authoritative lookup by pidx; mark paid.
6023
+ router.post("/:id/khalti/verify", asyncHandler(async (req, res) => {
6024
+ const secretKey = process.env.KHALTI_SECRET;
6025
+ if (!secretKey) throw new HttpError(501, "Khalti is not configured");
6026
+ const { pidx } = v.object({ pidx: v.string().min(1) }).parse(req.body);
6027
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
6028
+ if (!order) throw new HttpError(404, "Order not found");
6029
+ const r = await lookup(pidx, { secretKey, env: "test" });
6030
+ if (r.status !== "Completed") throw new HttpError(400, "Payment status: " + String(r.status));
6031
+ order.status = "paid";
6032
+ await order.save();
6033
+ res.json({ id: String(order._id), status: "paid" });
6034
+ }));
6035
+
6036
+ export default router;
6037
+ `;
6038
+ var checkoutPage = () => `"use client";
6039
+ import { useState } from "react";
6040
+ import { getToken } from "@/lib/api";
6041
+
6042
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6043
+ const msgOf = (e: unknown) => (e instanceof Error ? e.message : "Something went wrong");
6044
+
6045
+ async function call<T>(path: string, body: unknown): Promise<T> {
6046
+ const token = getToken();
6047
+ const res = await fetch(API + path, {
6048
+ method: "POST",
6049
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
6050
+ body: JSON.stringify(body),
6051
+ });
6052
+ const data: unknown = await res.json().catch(() => ({}));
6053
+ if (!res.ok) throw new Error((data as { error?: string }).error ?? "Request failed");
6054
+ return data as T;
6055
+ }
6056
+
6057
+ export default function CheckoutPage() {
6058
+ const [label, setLabel] = useState("Pro plan");
6059
+ const [amount, setAmount] = useState(1000);
6060
+ const [orderId, setOrderId] = useState<string | null>(null);
6061
+ const [msg, setMsg] = useState<string | null>(null);
6062
+
6063
+ async function createOrder(e: React.FormEvent) {
6064
+ e.preventDefault();
6065
+ try {
6066
+ const o = await call<{ id: string; display: string }>("/checkout", { label, amount });
6067
+ setOrderId(o.id);
6068
+ setMsg("Order " + o.display + " created \u2014 choose how to pay.");
6069
+ } catch (err) { setMsg(msgOf(err)); }
6070
+ }
6071
+ async function payEsewa() {
6072
+ if (!orderId) return;
6073
+ try {
6074
+ const form = await call<{ action: string; fields: Record<string, string> }>("/checkout/" + orderId + "/esewa", {});
6075
+ const f = document.createElement("form");
6076
+ f.method = "POST";
6077
+ f.action = form.action;
6078
+ for (const [k, val] of Object.entries(form.fields)) {
6079
+ const i = document.createElement("input");
6080
+ i.type = "hidden"; i.name = k; i.value = val;
6081
+ f.appendChild(i);
6082
+ }
6083
+ document.body.appendChild(f);
6084
+ f.submit();
6085
+ } catch (err) { setMsg(msgOf(err)); }
6086
+ }
6087
+ async function payKhalti() {
6088
+ if (!orderId) return;
6089
+ try {
6090
+ const r = await call<{ paymentUrl: string }>("/checkout/" + orderId + "/khalti", {});
6091
+ window.location.href = r.paymentUrl;
6092
+ } catch (err) { setMsg(msgOf(err)); }
6093
+ }
6094
+
6095
+ return (
6096
+ <main className="mx-auto max-w-md px-6 py-16">
6097
+ <h1 className="text-2xl font-bold">Checkout</h1>
6098
+ <p className="mt-1 text-sm text-muted">Sign in first, then create an order and pay.</p>
6099
+ <form onSubmit={createOrder} className="mt-6 space-y-3 rounded-2xl border border-hairline p-5">
6100
+ <input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="What are you buying?" className="w-full rounded-xl border border-hairline bg-surface px-4 py-2 outline-none" />
6101
+ <input type="number" min={10} value={amount} onChange={(e) => setAmount(Number(e.target.value))} placeholder="Amount (NPR)" className="w-full rounded-xl border border-hairline bg-surface px-4 py-2 outline-none" />
6102
+ <button className="w-full rounded-full gradient-bg px-4 py-2 font-semibold on-accent">Create order</button>
6103
+ </form>
6104
+ {msg && <p className="mt-4 text-sm text-muted">{msg}</p>}
6105
+ {orderId && (
6106
+ <div className="mt-6 space-y-3">
6107
+ <button onClick={payEsewa} className="w-full rounded-full border border-hairline px-4 py-3 font-semibold transition hover:bg-surface">Pay with eSewa <span className="text-muted">(works in test)</span></button>
6108
+ <button onClick={payKhalti} className="w-full rounded-full border border-hairline px-4 py-3 font-semibold transition hover:bg-surface">Pay with Khalti</button>
6109
+ </div>
6110
+ )}
6111
+ <p className="mt-6 text-xs text-muted">eSewa works out-of-the-box in test mode. Khalti needs KHALTI_SECRET in .env.</p>
6112
+ </main>
6113
+ );
6114
+ }
6115
+ `;
6116
+ var checkoutSuccessPage = () => `"use client";
6117
+ import { useEffect, useState } from "react";
6118
+ import Link from "next/link";
6119
+ import { getToken } from "@/lib/api";
6120
+
6121
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6122
+
6123
+ export default function CheckoutSuccessPage() {
6124
+ const [status, setStatus] = useState<"verifying" | "paid" | "failed">("verifying");
6125
+ const [detail, setDetail] = useState("");
6126
+
6127
+ useEffect(() => {
6128
+ const q = new URLSearchParams(window.location.search);
6129
+ const orderId = q.get("orderId");
6130
+ const data = q.get("data"); // eSewa returns a base64 \`data\` param
6131
+ const pidx = q.get("pidx"); // Khalti returns \`pidx\`
6132
+ const token = getToken();
6133
+ if (!orderId || !token) { setStatus("failed"); setDetail("Missing order or session \u2014 please sign in."); return; }
6134
+ (async () => {
6135
+ let path = ""; let body: Record<string, string> = {};
6136
+ if (data) { path = "/checkout/" + orderId + "/esewa/verify"; body = { data }; }
6137
+ else if (pidx) { path = "/checkout/" + orderId + "/khalti/verify"; body = { pidx }; }
6138
+ else { setStatus("failed"); setDetail("No payment token was returned."); return; }
6139
+ try {
6140
+ const res = await fetch(API + path, {
6141
+ method: "POST",
6142
+ headers: { "Content-Type": "application/json", Authorization: "Bearer " + token },
6143
+ body: JSON.stringify(body),
6144
+ });
6145
+ if (!res.ok) { const e: unknown = await res.json().catch(() => ({})); throw new Error((e as { error?: string }).error ?? "Verification failed"); }
6146
+ setStatus("paid");
6147
+ } catch (e) { setStatus("failed"); setDetail(e instanceof Error ? e.message : "Verification failed"); }
6148
+ })();
6149
+ }, []);
6150
+
6151
+ return (
6152
+ <main className="mx-auto max-w-md px-6 py-24 text-center">
6153
+ {status === "verifying" && <p className="text-muted">Verifying your payment\u2026</p>}
6154
+ {status === "paid" && (<><h1 className="text-3xl font-bold">Payment successful \u{1F389}</h1><p className="mt-2 text-muted">Thank you \u2014 your order is paid.</p></>)}
6155
+ {status === "failed" && (<><h1 className="text-2xl font-bold">Payment not completed</h1><p className="mt-2 text-muted">{detail}</p></>)}
6156
+ <Link href="/checkout" className="mt-6 inline-block rounded-full border border-hairline px-4 py-2 text-sm">Back to checkout</Link>
6157
+ </main>
6158
+ );
6159
+ }
6160
+ `;
6161
+ var checkoutFailedPage = () => `import Link from "next/link";
6162
+
6163
+ export default function CheckoutFailedPage() {
6164
+ return (
6165
+ <main className="mx-auto max-w-md px-6 py-24 text-center">
6166
+ <h1 className="text-2xl font-bold">Payment cancelled</h1>
6167
+ <p className="mt-2 text-muted">Your payment was not completed.</p>
6168
+ <Link href="/checkout" className="mt-6 inline-block rounded-full border border-hairline px-4 py-2 text-sm">Try again</Link>
6169
+ </main>
6170
+ );
6171
+ }
6172
+ `;
6173
+ var mailerLib = (ctx) => `import { createTransport, createJsonTransport, mailerFromEnv, type Transport } from "@lacspace/mailer";
6174
+ import { welcomeEmail, verifyEmail, passwordResetEmail, toPlainText } from "@lacspace/email-templates";
6175
+
6176
+ // how this works: if SMTP_* env vars are set, it sends real email over SMTP; otherwise
6177
+ // it logs the full message to the console (so the app runs with NO credentials). Adding
6178
+ // SMTP_* later switches to real delivery with no code change.
6179
+ export const transport: Transport = process.env.SMTP_HOST
6180
+ ? createTransport(mailerFromEnv())
6181
+ : createJsonTransport((json) => console.log("\\n[email:dev] set SMTP_* in .env to deliver for real:\\n" + json + "\\n"));
6182
+
6183
+ const FROM = process.env.SMTP_FROM ?? ${JSON.stringify(ctx.name + " <no-reply@example.com>")};
6184
+ const brand = { brandName: ${JSON.stringify(ctx.name)} };
6185
+
6186
+ export function sendWelcome(to: string, name?: string) {
6187
+ const html = welcomeEmail({ name, message: "Thanks for joining " + brand.brandName + "!", ...brand });
6188
+ return transport.send({ from: FROM, to, subject: "Welcome to " + brand.brandName, html, text: toPlainText(html) });
6189
+ }
6190
+ export function sendVerify(to: string, verifyUrl: string) {
6191
+ const html = verifyEmail({ verifyUrl, expiresMinutes: 30, ...brand });
6192
+ return transport.send({ from: FROM, to, subject: "Verify your email", html, text: toPlainText(html) });
6193
+ }
6194
+ export function sendReset(to: string, resetUrl: string) {
6195
+ const html = passwordResetEmail({ resetUrl, expiresMinutes: 30, ...brand });
6196
+ return transport.send({ from: FROM, to, subject: "Reset your password", html, text: toPlainText(html) });
6197
+ }
6198
+ `;
6199
+ var emailRoutes = () => `import express from "express";
6200
+ import { validateEmail } from "@lacspace/email-validate";
6201
+ import { asyncHandler, HttpError } from "../http.js";
6202
+ import { sendWelcome } from "../mail/mailer.js";
6203
+
6204
+ const router = express.Router();
6205
+
6206
+ // POST /email/test \u2014 send a sample welcome email (validates the address first).
6207
+ router.post("/test", asyncHandler(async (req, res) => {
6208
+ const to = String((req.body ?? {}).to ?? "").trim();
6209
+ const check = validateEmail(to);
6210
+ if (!check.valid) throw new HttpError(400, check.reason ?? "Invalid email address");
6211
+ const result = await sendWelcome(check.normalized ?? to);
6212
+ res.json({ ok: true, messageId: result.messageId });
6213
+ }));
6214
+
6215
+ export default router;
6216
+ `;
6217
+ var emailTestPage = () => `"use client";
6218
+ import { useState } from "react";
6219
+ import { getToken } from "@/lib/api";
6220
+
6221
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6222
+
6223
+ export default function EmailTestPage() {
6224
+ const [to, setTo] = useState("");
6225
+ const [msg, setMsg] = useState<string | null>(null);
6226
+ const [busy, setBusy] = useState(false);
6227
+
6228
+ async function send(e: React.FormEvent) {
6229
+ e.preventDefault();
6230
+ setBusy(true); setMsg(null);
6231
+ try {
6232
+ const token = getToken();
6233
+ const res = await fetch(API + "/email/test", {
6234
+ method: "POST",
6235
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
6236
+ body: JSON.stringify({ to }),
6237
+ });
6238
+ const data: unknown = await res.json().catch(() => ({}));
6239
+ if (!res.ok) throw new Error((data as { error?: string }).error ?? "Failed to send");
6240
+ setMsg("Sent! If no SMTP is configured, check the API console for the email.");
6241
+ } catch (err) { setMsg(err instanceof Error ? err.message : "Failed"); }
6242
+ finally { setBusy(false); }
6243
+ }
6244
+
6245
+ return (
6246
+ <main className="mx-auto max-w-md px-6 py-16">
6247
+ <h1 className="text-2xl font-bold">Send a test email</h1>
6248
+ <p className="mt-1 text-muted">With no SMTP configured, the email is logged to the API console.</p>
6249
+ <form onSubmit={send} className="mt-6 space-y-3">
6250
+ <input type="email" required value={to} onChange={(e) => setTo(e.target.value)} placeholder="you@example.com" className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none" />
6251
+ <button disabled={busy} className="w-full rounded-full gradient-bg px-4 py-3 font-semibold on-accent disabled:opacity-60">{busy ? "Sending\u2026" : "Send test email"}</button>
6252
+ </form>
6253
+ {msg && <p className="mt-4 text-sm text-muted">{msg}</p>}
6254
+ </main>
6255
+ );
6256
+ }
6257
+ `;
5419
6258
  var scope = (ctx) => `@${ctx.name}`;
5420
6259
  var typesPkgJson = (ctx) => JSON.stringify({
5421
6260
  name: `${scope(ctx)}/types`,
@@ -5815,44 +6654,67 @@ function buildFullStack(ctx) {
5815
6654
  out[".gitignore"] = rootGitignore();
5816
6655
  out[".env.example"] = rootEnvExample(ctx);
5817
6656
  out["README.md"] = rootReadme(ctx);
6657
+ const envBase = out[".env.example"];
6658
+ const seen = new Set(
6659
+ (envBase.match(/^#?\s*([A-Z0-9_]+)=/gm) ?? []).map((l) => l.replace(/^#?\s*/, "").replace(/=.*/, ""))
6660
+ );
6661
+ const lines = [];
6662
+ for (const f of ctx.features) {
6663
+ const b = f.backend?.(ctx);
6664
+ if (!b?.env) continue;
6665
+ const fresh = Object.entries(b.env).filter(([name]) => !seen.has(name));
6666
+ if (!fresh.length) continue;
6667
+ lines.push("", `# --- ${f.label} (${f.key}) ---`);
6668
+ for (const [name, comment] of fresh) {
6669
+ if (comment) lines.push(`# ${comment}`);
6670
+ lines.push(`${name}=`);
6671
+ seen.add(name);
6672
+ }
6673
+ }
6674
+ if (lines.length) out[".env.example"] = envBase.replace(/\n?$/, "\n") + lines.join("\n") + "\n";
5818
6675
  return out;
5819
6676
  }
5820
- var backendPkgJson = (ctx) => JSON.stringify({
5821
- name: `${scope(ctx)}/backend`,
5822
- version: "0.1.0",
5823
- private: true,
5824
- type: "module",
5825
- main: "dist/index.js",
5826
- scripts: {
5827
- dev: "tsx watch src/index.ts",
5828
- build: "tsc -p tsconfig.json",
5829
- start: "node dist/index.js",
5830
- typecheck: "tsc -p tsconfig.json --noEmit"
5831
- },
5832
- dependencies: {
5833
- express: "^4.21.2",
5834
- cors: "^2.8.5",
5835
- mongoose: "^8.9.0",
5836
- ioredis: "^5.4.2",
5837
- dotenv: "^16.4.7",
5838
- // ✨ Backend built on zero-dep @lacspace/* packages instead of the usual grab-bag.
5839
- "@lacspace/env": "^1.1.0",
5840
- "@lacspace/jwt": "^1.4.0",
5841
- "@lacspace/password": "^1.1.0",
5842
- "@lacspace/validate": "^1.1.0",
5843
- "@lacspace/id": "^1.1.0",
5844
- "@lacspace/rate-limit": "^1.2.0",
5845
- "@lacspace/cache": "^1.1.0",
5846
- [`${scope(ctx)}/types`]: "*"
5847
- },
5848
- devDependencies: {
5849
- typescript: "^5.7.0",
5850
- tsx: "^4.19.2",
5851
- "@types/node": "^22.10.0",
5852
- "@types/express": "^4.17.21",
5853
- "@types/cors": "^2.8.17"
5854
- }
5855
- }, null, 2) + "\n";
6677
+ var backendPkgJson = (ctx) => {
6678
+ const featureDeps = {};
6679
+ for (const f of ctx.features) Object.assign(featureDeps, f.backend?.(ctx)?.deps ?? {});
6680
+ return JSON.stringify({
6681
+ name: `${scope(ctx)}/backend`,
6682
+ version: "0.1.0",
6683
+ private: true,
6684
+ type: "module",
6685
+ main: "dist/index.js",
6686
+ scripts: {
6687
+ dev: "tsx watch src/index.ts",
6688
+ build: "tsc -p tsconfig.json",
6689
+ start: "node dist/index.js",
6690
+ typecheck: "tsc -p tsconfig.json --noEmit"
6691
+ },
6692
+ dependencies: {
6693
+ express: "^4.21.2",
6694
+ cors: "^2.8.5",
6695
+ mongoose: "^8.9.0",
6696
+ ioredis: "^5.4.2",
6697
+ dotenv: "^16.4.7",
6698
+ // ✨ Backend built on zero-dep @lacspace/* packages instead of the usual grab-bag.
6699
+ "@lacspace/env": "^1.1.0",
6700
+ "@lacspace/jwt": "^1.4.0",
6701
+ "@lacspace/password": "^1.1.0",
6702
+ "@lacspace/validate": "^1.1.0",
6703
+ "@lacspace/id": "^1.1.0",
6704
+ "@lacspace/rate-limit": "^1.2.0",
6705
+ "@lacspace/cache": "^1.1.0",
6706
+ ...featureDeps,
6707
+ [`${scope(ctx)}/types`]: "*"
6708
+ },
6709
+ devDependencies: {
6710
+ typescript: "^5.7.0",
6711
+ tsx: "^4.19.2",
6712
+ "@types/node": "^22.10.0",
6713
+ "@types/express": "^4.17.21",
6714
+ "@types/cors": "^2.8.17"
6715
+ }
6716
+ }, null, 2) + "\n";
6717
+ };
5856
6718
  var backendTsconfig = (ctx) => JSON.stringify({
5857
6719
  compilerOptions: {
5858
6720
  target: "ES2022",
@@ -6278,35 +7140,49 @@ router.delete("/:id", asyncHandler(async (req, res) => {
6278
7140
  export default router;
6279
7141
  `;
6280
7142
  var backendApp = (ctx) => `import express from "express";
6281
- import type { RequestHandler } from "express";
6282
7143
  import cors from "cors";
6283
- import { rateLimit, expressRateLimit } from "@lacspace/rate-limit";
6284
7144
  import { env } from "./env.js";
6285
7145
  import { errorHandler } from "./middleware/error.js";
6286
- import authRoutes from "./routes/auth.js";
6287
- import noteRoutes from "./routes/notes.js";
7146
+ import { registerRoutes } from "./routes/index.js";
6288
7147
 
6289
7148
  // how this works: assembles the Express app \u2014 CORS for the frontend, JSON parsing,
6290
- // a health check, the auth + notes routers, then the error handler LAST.
7149
+ // a health check, all route groups (see routes/index.ts), then the error handler LAST.
6291
7150
  export function createApp(): express.Express {
6292
7151
  const app = express();
6293
7152
 
6294
7153
  app.use(cors({ origin: env.CORS_ORIGIN.split(",").map((o) => o.trim()), credentials: true }));
6295
7154
  app.use(express.json());
6296
7155
 
6297
- // Brute-force protection on auth: 20 requests / minute / IP (@lacspace/rate-limit).
6298
- const authLimiter = expressRateLimit(rateLimit({ limit: 20, windowMs: 60_000 })) as unknown as RequestHandler;
6299
-
6300
7156
  app.get("/health", (_req, res) => { res.json({ ok: true, service: "${ctx.name}-api" }); });
6301
- app.use("/auth", authLimiter, authRoutes);
6302
- app.use("/notes", noteRoutes);
7157
+ registerRoutes(app);
6303
7158
 
6304
7159
  app.use(errorHandler);
6305
7160
  return app;
6306
7161
  }
6307
7162
  `;
7163
+ var backendRoutesIndex = (ctx) => {
7164
+ const featureRoutes = ctx.features.flatMap((f) => f.backend?.(ctx)?.routes ?? []);
7165
+ const needsAuth = featureRoutes.some((r) => r.auth);
7166
+ const importLines = featureRoutes.map((r) => r.importLine).join("\n");
7167
+ const mountLines = featureRoutes.map((r) => ` app.use(${JSON.stringify(r.path)}, ${r.auth ? "requireAuth, " : ""}${r.handler});`).join("\n");
7168
+ return `import type { Express, RequestHandler } from "express";
7169
+ import { rateLimit, expressRateLimit } from "@lacspace/rate-limit";
7170
+ ${needsAuth ? 'import { requireAuth } from "../middleware/auth.js";\n' : ""}import authRoutes from "./auth.js";
7171
+ import noteRoutes from "./notes.js";
7172
+ ${importLines ? importLines + "\n" : ""}
7173
+ // how this works: every route group is mounted here. Add a new resource by creating
7174
+ // a router in routes/ and adding one app.use(...) line below.
7175
+ export function registerRoutes(app: Express): void {
7176
+ // Brute-force protection on auth: 20 requests / minute / IP (@lacspace/rate-limit).
7177
+ const authLimiter = expressRateLimit(rateLimit({ limit: 20, windowMs: 60_000 })) as unknown as RequestHandler;
7178
+ app.use("/auth", authLimiter, authRoutes);
7179
+ app.use("/notes", noteRoutes);
7180
+ ${mountLines}
7181
+ }
7182
+ `;
7183
+ };
6308
7184
  function backendFiles(ctx) {
6309
- return {
7185
+ const files = {
6310
7186
  "backend/package.json": backendPkgJson(ctx),
6311
7187
  "backend/tsconfig.json": backendTsconfig(ctx),
6312
7188
  "backend/.gitignore": backendGitignore(),
@@ -6325,8 +7201,15 @@ function backendFiles(ctx) {
6325
7201
  "backend/src/models/user.ts": backendUserModel(),
6326
7202
  "backend/src/models/note.ts": backendNoteModel(),
6327
7203
  "backend/src/routes/auth.ts": backendAuthRoutes(ctx),
6328
- "backend/src/routes/notes.ts": backendNoteRoutes(ctx)
7204
+ "backend/src/routes/notes.ts": backendNoteRoutes(ctx),
7205
+ "backend/src/routes/index.ts": backendRoutesIndex(ctx)
6329
7206
  };
7207
+ for (const f of ctx.features) {
7208
+ const b = f.backend?.(ctx);
7209
+ if (!b?.files) continue;
7210
+ for (const [rel, content] of Object.entries(b.files)) files["backend/" + rel] = content;
7211
+ }
7212
+ return files;
6330
7213
  }
6331
7214
  var splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
6332
7215
  function parseArgs(list) {