create-lacspace-app 2.5.0 → 2.6.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/README.md +6 -0
- package/dist/index.js +508 -49
- package/dist/lib.cjs +508 -49
- package/dist/lib.d.cts +34 -1
- package/dist/lib.d.ts +34 -1
- package/dist/lib.js +508 -49
- package/package.json +1 -1
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
|
-
|
|
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,52 @@ 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"
|
|
2169
2216
|
}
|
|
2170
2217
|
];
|
|
2171
2218
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -5416,6 +5463,374 @@ export default function SearchPage() {
|
|
|
5416
5463
|
);
|
|
5417
5464
|
}
|
|
5418
5465
|
`;
|
|
5466
|
+
var authTwoFactorModel = () => `import mongoose from "mongoose";
|
|
5467
|
+
import { uuidv7 } from "@lacspace/id";
|
|
5468
|
+
|
|
5469
|
+
// how this works: a separate collection for 2FA so the base User model stays simple.
|
|
5470
|
+
// NOTE: the TOTP secret is stored as-is here \u2014 in production, encrypt it at rest with
|
|
5471
|
+
// @lacspace/crypto (encrypt/decrypt) using a key from your env.
|
|
5472
|
+
export interface TwoFactorDoc {
|
|
5473
|
+
_id: string;
|
|
5474
|
+
userId: string;
|
|
5475
|
+
secret: string;
|
|
5476
|
+
enabled: boolean;
|
|
5477
|
+
backupHashes: string[];
|
|
5478
|
+
createdAt: Date;
|
|
5479
|
+
updatedAt: Date;
|
|
5480
|
+
}
|
|
5481
|
+
|
|
5482
|
+
const schema = new mongoose.Schema<TwoFactorDoc>(
|
|
5483
|
+
{
|
|
5484
|
+
_id: { type: String, default: () => uuidv7() },
|
|
5485
|
+
userId: { type: String, required: true, unique: true, index: true },
|
|
5486
|
+
secret: { type: String, required: true },
|
|
5487
|
+
enabled: { type: Boolean, default: false },
|
|
5488
|
+
backupHashes: { type: [String], default: [] },
|
|
5489
|
+
},
|
|
5490
|
+
{ timestamps: true },
|
|
5491
|
+
);
|
|
5492
|
+
|
|
5493
|
+
export const TwoFactor =
|
|
5494
|
+
(mongoose.models.TwoFactor as mongoose.Model<TwoFactorDoc>) ?? mongoose.model<TwoFactorDoc>("TwoFactor", schema);
|
|
5495
|
+
`;
|
|
5496
|
+
var authAccountRoutes = (ctx) => `import express from "express";
|
|
5497
|
+
import { hash, verify as verifyPassword } from "@lacspace/password";
|
|
5498
|
+
import { setupTotp, verifyTotp, generateBackupCodes, verifyBackupCode } from "@lacspace/otp";
|
|
5499
|
+
import { v } from "@lacspace/validate";
|
|
5500
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
5501
|
+
import { User } from "../models/user.js";
|
|
5502
|
+
import { TwoFactor } from "../models/two-factor.js";
|
|
5503
|
+
|
|
5504
|
+
// This whole group is mounted behind requireAuth (see routes/index.ts), so
|
|
5505
|
+
// req.user is always set here.
|
|
5506
|
+
const router = express.Router();
|
|
5507
|
+
const ISSUER = "${ctx.name}";
|
|
5508
|
+
|
|
5509
|
+
const ProfileInput = v.object({ name: v.string().min(2).max(80) });
|
|
5510
|
+
const PasswordInput = v.object({ currentPassword: v.string().min(1), newPassword: v.string().min(8).max(200) });
|
|
5511
|
+
const CodeInput = v.object({ code: v.string().min(6).max(12) });
|
|
5512
|
+
|
|
5513
|
+
// GET /account/2fa \u2014 is two-factor enabled?
|
|
5514
|
+
router.get("/2fa", asyncHandler(async (req, res) => {
|
|
5515
|
+
const tf = await TwoFactor.findOne({ userId: req.user!.sub });
|
|
5516
|
+
res.json({ enabled: Boolean(tf?.enabled) });
|
|
5517
|
+
}));
|
|
5518
|
+
|
|
5519
|
+
// PATCH /account/profile \u2014 update your display name.
|
|
5520
|
+
router.patch("/profile", asyncHandler(async (req, res) => {
|
|
5521
|
+
const { name } = ProfileInput.parse(req.body);
|
|
5522
|
+
const user = await User.findByIdAndUpdate(req.user!.sub, { $set: { name } }, { new: true });
|
|
5523
|
+
if (!user) throw new HttpError(404, "User not found");
|
|
5524
|
+
res.json({ id: String(user._id), name: user.name, email: user.email });
|
|
5525
|
+
}));
|
|
5526
|
+
|
|
5527
|
+
// POST /account/password \u2014 change your password (verifies the current one).
|
|
5528
|
+
router.post("/password", asyncHandler(async (req, res) => {
|
|
5529
|
+
const { currentPassword, newPassword } = PasswordInput.parse(req.body);
|
|
5530
|
+
const user = await User.findById(req.user!.sub);
|
|
5531
|
+
if (!user || !(await verifyPassword(currentPassword, user.passwordHash))) {
|
|
5532
|
+
throw new HttpError(401, "Current password is incorrect");
|
|
5533
|
+
}
|
|
5534
|
+
user.passwordHash = await hash(newPassword);
|
|
5535
|
+
await user.save();
|
|
5536
|
+
res.json({ ok: true });
|
|
5537
|
+
}));
|
|
5538
|
+
|
|
5539
|
+
// POST /account/2fa/setup \u2014 create a TOTP secret; show \`uri\` as a QR / \`secret\` to type.
|
|
5540
|
+
router.post("/2fa/setup", asyncHandler(async (req, res) => {
|
|
5541
|
+
const user = await User.findById(req.user!.sub);
|
|
5542
|
+
if (!user) throw new HttpError(404, "User not found");
|
|
5543
|
+
const { secret, uri } = setupTotp({ account: user.email, issuer: ISSUER });
|
|
5544
|
+
await TwoFactor.findOneAndUpdate(
|
|
5545
|
+
{ userId: String(user._id) },
|
|
5546
|
+
{ $set: { secret, enabled: false } },
|
|
5547
|
+
{ upsert: true, new: true },
|
|
5548
|
+
);
|
|
5549
|
+
res.json({ secret, uri });
|
|
5550
|
+
}));
|
|
5551
|
+
|
|
5552
|
+
// POST /account/2fa/enable \u2014 verify a code, turn 2FA on, return one-time backup codes.
|
|
5553
|
+
router.post("/2fa/enable", asyncHandler(async (req, res) => {
|
|
5554
|
+
const { code } = CodeInput.parse(req.body);
|
|
5555
|
+
const tf = await TwoFactor.findOne({ userId: req.user!.sub });
|
|
5556
|
+
if (!tf) throw new HttpError(400, "Run 2FA setup first");
|
|
5557
|
+
if ((await verifyTotp(code, tf.secret, { window: 1 })) === null) throw new HttpError(401, "Invalid code");
|
|
5558
|
+
const { codes, hashes } = await generateBackupCodes(10);
|
|
5559
|
+
tf.enabled = true;
|
|
5560
|
+
tf.backupHashes = hashes;
|
|
5561
|
+
await tf.save();
|
|
5562
|
+
res.json({ enabled: true, backupCodes: codes });
|
|
5563
|
+
}));
|
|
5564
|
+
|
|
5565
|
+
// POST /account/2fa/disable \u2014 verify a TOTP or backup code, then turn 2FA off.
|
|
5566
|
+
router.post("/2fa/disable", asyncHandler(async (req, res) => {
|
|
5567
|
+
const { code } = CodeInput.parse(req.body);
|
|
5568
|
+
const tf = await TwoFactor.findOne({ userId: req.user!.sub });
|
|
5569
|
+
if (!tf || !tf.enabled) { res.json({ enabled: false }); return; }
|
|
5570
|
+
const ok = (await verifyTotp(code, tf.secret, { window: 1 })) !== null || (await verifyBackupCode(code, tf.backupHashes)) >= 0;
|
|
5571
|
+
if (!ok) throw new HttpError(401, "Invalid code");
|
|
5572
|
+
await TwoFactor.deleteOne({ userId: req.user!.sub });
|
|
5573
|
+
res.json({ enabled: false });
|
|
5574
|
+
}));
|
|
5575
|
+
|
|
5576
|
+
export default router;
|
|
5577
|
+
`;
|
|
5578
|
+
var authSettingsPage = () => `"use client";
|
|
5579
|
+
import { useEffect, useState } from "react";
|
|
5580
|
+
import { useRouter } from "next/navigation";
|
|
5581
|
+
import { getToken } from "@/lib/api";
|
|
5582
|
+
|
|
5583
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
5584
|
+
|
|
5585
|
+
async function call<T>(path: string, body?: unknown, method = "POST"): Promise<T> {
|
|
5586
|
+
const token = getToken();
|
|
5587
|
+
const res = await fetch(API + path, {
|
|
5588
|
+
method,
|
|
5589
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
|
|
5590
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
5591
|
+
});
|
|
5592
|
+
const data: unknown = await res.json().catch(() => ({}));
|
|
5593
|
+
if (!res.ok) throw new Error((data as { error?: string }).error ?? "Request failed");
|
|
5594
|
+
return data as T;
|
|
5595
|
+
}
|
|
5596
|
+
const msgOf = (e: unknown) => (e instanceof Error ? e.message : "Something went wrong");
|
|
5597
|
+
const field = "w-full rounded-xl border border-hairline bg-surface px-4 py-2 outline-none";
|
|
5598
|
+
const card = "rounded-2xl border border-hairline p-5 space-y-3";
|
|
5599
|
+
const btn = "rounded-full gradient-bg px-4 py-2 text-sm font-semibold on-accent";
|
|
5600
|
+
|
|
5601
|
+
export default function SettingsPage() {
|
|
5602
|
+
const router = useRouter();
|
|
5603
|
+
const [name, setName] = useState("");
|
|
5604
|
+
const [current, setCurrent] = useState("");
|
|
5605
|
+
const [next, setNext] = useState("");
|
|
5606
|
+
const [enabled, setEnabled] = useState(false);
|
|
5607
|
+
const [setup, setSetup] = useState<{ secret: string; uri: string } | null>(null);
|
|
5608
|
+
const [code, setCode] = useState("");
|
|
5609
|
+
const [backup, setBackup] = useState<string[] | null>(null);
|
|
5610
|
+
const [msg, setMsg] = useState<string | null>(null);
|
|
5611
|
+
|
|
5612
|
+
const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(null), 3000); };
|
|
5613
|
+
|
|
5614
|
+
useEffect(() => {
|
|
5615
|
+
if (!getToken()) { router.push("/login"); return; }
|
|
5616
|
+
call<{ enabled: boolean }>("/account/2fa", undefined, "GET").then((r) => setEnabled(r.enabled)).catch(() => {});
|
|
5617
|
+
}, [router]);
|
|
5618
|
+
|
|
5619
|
+
async function saveName(e: React.FormEvent) {
|
|
5620
|
+
e.preventDefault();
|
|
5621
|
+
try { await call("/account/profile", { name }, "PATCH"); flash("Profile updated"); } catch (err) { flash(msgOf(err)); }
|
|
5622
|
+
}
|
|
5623
|
+
async function savePassword(e: React.FormEvent) {
|
|
5624
|
+
e.preventDefault();
|
|
5625
|
+
try { await call("/account/password", { currentPassword: current, newPassword: next }); setCurrent(""); setNext(""); flash("Password changed"); } catch (err) { flash(msgOf(err)); }
|
|
5626
|
+
}
|
|
5627
|
+
async function begin2fa() {
|
|
5628
|
+
try { setSetup(await call<{ secret: string; uri: string }>("/account/2fa/setup")); } catch (err) { flash(msgOf(err)); }
|
|
5629
|
+
}
|
|
5630
|
+
async function enable2fa() {
|
|
5631
|
+
try { const r = await call<{ backupCodes: string[] }>("/account/2fa/enable", { code }); setBackup(r.backupCodes); setEnabled(true); setSetup(null); setCode(""); } catch (err) { flash(msgOf(err)); }
|
|
5632
|
+
}
|
|
5633
|
+
async function disable2fa() {
|
|
5634
|
+
const c = window.prompt("Enter a current 2FA code (or a backup code) to disable:");
|
|
5635
|
+
if (!c) return;
|
|
5636
|
+
try { await call("/account/2fa/disable", { code: c }); setEnabled(false); flash("2FA disabled"); } catch (err) { flash(msgOf(err)); }
|
|
5637
|
+
}
|
|
5638
|
+
|
|
5639
|
+
return (
|
|
5640
|
+
<main className="mx-auto max-w-lg space-y-6 px-6 py-16">
|
|
5641
|
+
<h1 className="text-2xl font-bold">Account settings</h1>
|
|
5642
|
+
{msg && <p className="rounded-xl border border-hairline bg-surface px-4 py-2 text-sm">{msg}</p>}
|
|
5643
|
+
|
|
5644
|
+
<form onSubmit={saveName} className={card}>
|
|
5645
|
+
<h2 className="font-semibold">Profile</h2>
|
|
5646
|
+
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="New display name" className={field} />
|
|
5647
|
+
<button className={btn}>Save name</button>
|
|
5648
|
+
</form>
|
|
5649
|
+
|
|
5650
|
+
<form onSubmit={savePassword} className={card}>
|
|
5651
|
+
<h2 className="font-semibold">Change password</h2>
|
|
5652
|
+
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)} placeholder="Current password" className={field} />
|
|
5653
|
+
<input type="password" value={next} onChange={(e) => setNext(e.target.value)} placeholder="New password (min 8)" className={field} />
|
|
5654
|
+
<button className={btn}>Change password</button>
|
|
5655
|
+
</form>
|
|
5656
|
+
|
|
5657
|
+
<div className={card}>
|
|
5658
|
+
<h2 className="font-semibold">Two-factor authentication {enabled && <span className="text-green-500">\xB7 on</span>}</h2>
|
|
5659
|
+
{!enabled && !setup && <button onClick={begin2fa} className={btn}>Enable 2FA</button>}
|
|
5660
|
+
{!enabled && setup && (
|
|
5661
|
+
<div className="space-y-3">
|
|
5662
|
+
<p className="text-sm text-muted">Add this secret to your authenticator app (Google Authenticator, Authy\u2026):</p>
|
|
5663
|
+
<code className="block break-all rounded-lg bg-surface p-3 text-sm">{setup.secret}</code>
|
|
5664
|
+
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="6-digit code" className={field} />
|
|
5665
|
+
<button onClick={enable2fa} className={btn}>Verify & enable</button>
|
|
5666
|
+
</div>
|
|
5667
|
+
)}
|
|
5668
|
+
{enabled && <button onClick={disable2fa} className="rounded-full border border-hairline px-4 py-2 text-sm">Disable 2FA</button>}
|
|
5669
|
+
{backup && (
|
|
5670
|
+
<div className="mt-2 space-y-2">
|
|
5671
|
+
<p className="text-sm font-medium">Save these backup codes (shown once):</p>
|
|
5672
|
+
<ul className="grid grid-cols-2 gap-1 rounded-lg bg-surface p-3 font-mono text-sm">
|
|
5673
|
+
{backup.map((b) => <li key={b}>{b}</li>)}
|
|
5674
|
+
</ul>
|
|
5675
|
+
</div>
|
|
5676
|
+
)}
|
|
5677
|
+
</div>
|
|
5678
|
+
</main>
|
|
5679
|
+
);
|
|
5680
|
+
}
|
|
5681
|
+
`;
|
|
5682
|
+
var analyticsEventModel = () => `import mongoose from "mongoose";
|
|
5683
|
+
import { uuidv7 } from "@lacspace/id";
|
|
5684
|
+
|
|
5685
|
+
export interface EventDoc {
|
|
5686
|
+
_id: string;
|
|
5687
|
+
type: string;
|
|
5688
|
+
path: string;
|
|
5689
|
+
referrer: string;
|
|
5690
|
+
screen: string;
|
|
5691
|
+
sid: string;
|
|
5692
|
+
props: Record<string, unknown>;
|
|
5693
|
+
ts: Date;
|
|
5694
|
+
}
|
|
5695
|
+
|
|
5696
|
+
const schema = new mongoose.Schema<EventDoc>({
|
|
5697
|
+
_id: { type: String, default: () => uuidv7() },
|
|
5698
|
+
type: { type: String, default: "pageview", index: true },
|
|
5699
|
+
path: { type: String, default: "/" },
|
|
5700
|
+
referrer: { type: String, default: "" },
|
|
5701
|
+
screen: { type: String, default: "" },
|
|
5702
|
+
sid: { type: String, default: "" },
|
|
5703
|
+
props: { type: mongoose.Schema.Types.Mixed, default: {} },
|
|
5704
|
+
ts: { type: Date, default: Date.now, index: true },
|
|
5705
|
+
});
|
|
5706
|
+
|
|
5707
|
+
export const Event =
|
|
5708
|
+
(mongoose.models.Event as mongoose.Model<EventDoc>) ?? mongoose.model<EventDoc>("Event", schema);
|
|
5709
|
+
`;
|
|
5710
|
+
var analyticsEventsRoutes = () => `import express from "express";
|
|
5711
|
+
import { asyncHandler } from "../http.js";
|
|
5712
|
+
import { requireAuth } from "../middleware/auth.js";
|
|
5713
|
+
import { Event } from "../models/event.js";
|
|
5714
|
+
|
|
5715
|
+
const router = express.Router();
|
|
5716
|
+
|
|
5717
|
+
// POST /events \u2014 the tracker beacon posts here. Public + cookieless (no PII stored).
|
|
5718
|
+
router.post("/", asyncHandler(async (req, res) => {
|
|
5719
|
+
const b = (req.body ?? {}) as Record<string, unknown>;
|
|
5720
|
+
await Event.create({
|
|
5721
|
+
type: String(b.type ?? "pageview"),
|
|
5722
|
+
path: String(b.path ?? "/"),
|
|
5723
|
+
referrer: String(b.referrer ?? ""),
|
|
5724
|
+
screen: String(b.screen ?? ""),
|
|
5725
|
+
sid: String(b.sid ?? ""),
|
|
5726
|
+
props: b.props && typeof b.props === "object" ? (b.props as Record<string, unknown>) : {},
|
|
5727
|
+
});
|
|
5728
|
+
res.status(204).end();
|
|
5729
|
+
}));
|
|
5730
|
+
|
|
5731
|
+
// GET /events/summary \u2014 dashboard aggregates (protected: only signed-in owners).
|
|
5732
|
+
router.get("/summary", requireAuth, asyncHandler(async (_req, res) => {
|
|
5733
|
+
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
5734
|
+
const [total, pageviews, topPaths, byDay] = await Promise.all([
|
|
5735
|
+
Event.countDocuments({}),
|
|
5736
|
+
Event.countDocuments({ type: "pageview" }),
|
|
5737
|
+
Event.aggregate([
|
|
5738
|
+
{ $match: { type: "pageview" } },
|
|
5739
|
+
{ $group: { _id: "$path", count: { $sum: 1 } } },
|
|
5740
|
+
{ $sort: { count: -1 } },
|
|
5741
|
+
{ $limit: 8 },
|
|
5742
|
+
]),
|
|
5743
|
+
Event.aggregate([
|
|
5744
|
+
{ $match: { ts: { $gte: since } } },
|
|
5745
|
+
{ $group: { _id: { $dateToString: { format: "%Y-%m-%d", date: "$ts" } }, count: { $sum: 1 } } },
|
|
5746
|
+
{ $sort: { _id: 1 } },
|
|
5747
|
+
]),
|
|
5748
|
+
]);
|
|
5749
|
+
res.json({
|
|
5750
|
+
total,
|
|
5751
|
+
pageviews,
|
|
5752
|
+
topPaths: topPaths.map((p: { _id: string; count: number }) => ({ path: p._id, count: p.count })),
|
|
5753
|
+
byDay: byDay.map((d: { _id: string; count: number }) => ({ day: d._id, count: d.count })),
|
|
5754
|
+
});
|
|
5755
|
+
}));
|
|
5756
|
+
|
|
5757
|
+
export default router;
|
|
5758
|
+
`;
|
|
5759
|
+
var analyticsComponent = (ctx) => `"use client";
|
|
5760
|
+
import { useEffect } from "react";
|
|
5761
|
+
import { createAnalytics } from "@lacspace/analytics-lite";
|
|
5762
|
+
|
|
5763
|
+
// how this works: cookieless, privacy-first tracking. It POSTs page views + events to
|
|
5764
|
+
// your backend /events collector (stored in MongoDB). No cookies, no localStorage, no
|
|
5765
|
+
// personal data. Respects the browser's Do-Not-Track.
|
|
5766
|
+
const ENDPOINT = (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000") + "/events";
|
|
5767
|
+
|
|
5768
|
+
export function Analytics() {
|
|
5769
|
+
useEffect(() => {
|
|
5770
|
+
const a = createAnalytics({ endpoint: ENDPOINT, siteId: ${JSON.stringify(ctx.name)}, respectDNT: true });
|
|
5771
|
+
a.pageview();
|
|
5772
|
+
return a.autoTrack();
|
|
5773
|
+
}, []);
|
|
5774
|
+
return null;
|
|
5775
|
+
}
|
|
5776
|
+
`;
|
|
5777
|
+
var analyticsDashboard = () => `"use client";
|
|
5778
|
+
import { useEffect, useState } from "react";
|
|
5779
|
+
import { useRouter } from "next/navigation";
|
|
5780
|
+
import { getToken } from "@/lib/api";
|
|
5781
|
+
|
|
5782
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
5783
|
+
|
|
5784
|
+
interface Summary {
|
|
5785
|
+
total: number;
|
|
5786
|
+
pageviews: number;
|
|
5787
|
+
topPaths: { path: string; count: number }[];
|
|
5788
|
+
byDay: { day: string; count: number }[];
|
|
5789
|
+
}
|
|
5790
|
+
|
|
5791
|
+
export default function AnalyticsPage() {
|
|
5792
|
+
const router = useRouter();
|
|
5793
|
+
const [data, setData] = useState<Summary | null>(null);
|
|
5794
|
+
const [error, setError] = useState<string | null>(null);
|
|
5795
|
+
|
|
5796
|
+
useEffect(() => {
|
|
5797
|
+
const token = getToken();
|
|
5798
|
+
if (!token) { router.push("/login"); return; }
|
|
5799
|
+
fetch(API + "/events/summary", { headers: { Authorization: "Bearer " + token } })
|
|
5800
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("Please sign in"))))
|
|
5801
|
+
.then(setData)
|
|
5802
|
+
.catch((e) => setError(e instanceof Error ? e.message : "Failed to load"));
|
|
5803
|
+
}, [router]);
|
|
5804
|
+
|
|
5805
|
+
if (error) return <main className="mx-auto max-w-3xl px-6 py-16 text-muted">{error}</main>;
|
|
5806
|
+
if (!data) return <main className="mx-auto max-w-3xl px-6 py-16 text-muted">Loading\u2026</main>;
|
|
5807
|
+
const max = Math.max(1, ...data.byDay.map((d) => d.count));
|
|
5808
|
+
|
|
5809
|
+
return (
|
|
5810
|
+
<main className="mx-auto max-w-3xl px-6 py-16">
|
|
5811
|
+
<h1 className="text-3xl font-bold">Analytics</h1>
|
|
5812
|
+
<p className="mt-1 text-muted">Cookieless, privacy-first \u2014 last 30 days.</p>
|
|
5813
|
+
<div className="mt-6 grid grid-cols-2 gap-4">
|
|
5814
|
+
<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>
|
|
5815
|
+
<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>
|
|
5816
|
+
</div>
|
|
5817
|
+
<h2 className="mt-8 font-semibold">Top pages</h2>
|
|
5818
|
+
<ul className="mt-3 space-y-2">
|
|
5819
|
+
{data.topPaths.length === 0 && <li className="text-muted">No data yet \u2014 browse your site with <Analytics/> mounted.</li>}
|
|
5820
|
+
{data.topPaths.map((p) => (
|
|
5821
|
+
<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>
|
|
5822
|
+
))}
|
|
5823
|
+
</ul>
|
|
5824
|
+
<h2 className="mt-8 font-semibold">Events per day</h2>
|
|
5825
|
+
<div className="mt-3 flex items-end gap-1" style={{ height: 120 }}>
|
|
5826
|
+
{data.byDay.map((d) => (
|
|
5827
|
+
<div key={d.day} className="flex-1 rounded-t" style={{ height: (d.count / max) * 100 + "%", backgroundColor: "var(--accent, #6366f1)" }} title={d.day + ": " + d.count} />
|
|
5828
|
+
))}
|
|
5829
|
+
</div>
|
|
5830
|
+
</main>
|
|
5831
|
+
);
|
|
5832
|
+
}
|
|
5833
|
+
`;
|
|
5419
5834
|
var scope = (ctx) => `@${ctx.name}`;
|
|
5420
5835
|
var typesPkgJson = (ctx) => JSON.stringify({
|
|
5421
5836
|
name: `${scope(ctx)}/types`,
|
|
@@ -5815,44 +6230,67 @@ function buildFullStack(ctx) {
|
|
|
5815
6230
|
out[".gitignore"] = rootGitignore();
|
|
5816
6231
|
out[".env.example"] = rootEnvExample(ctx);
|
|
5817
6232
|
out["README.md"] = rootReadme(ctx);
|
|
6233
|
+
const envBase = out[".env.example"];
|
|
6234
|
+
const seen = new Set(
|
|
6235
|
+
(envBase.match(/^#?\s*([A-Z0-9_]+)=/gm) ?? []).map((l) => l.replace(/^#?\s*/, "").replace(/=.*/, ""))
|
|
6236
|
+
);
|
|
6237
|
+
const lines = [];
|
|
6238
|
+
for (const f of ctx.features) {
|
|
6239
|
+
const b = f.backend?.(ctx);
|
|
6240
|
+
if (!b?.env) continue;
|
|
6241
|
+
const fresh = Object.entries(b.env).filter(([name]) => !seen.has(name));
|
|
6242
|
+
if (!fresh.length) continue;
|
|
6243
|
+
lines.push("", `# --- ${f.label} (${f.key}) ---`);
|
|
6244
|
+
for (const [name, comment] of fresh) {
|
|
6245
|
+
if (comment) lines.push(`# ${comment}`);
|
|
6246
|
+
lines.push(`${name}=`);
|
|
6247
|
+
seen.add(name);
|
|
6248
|
+
}
|
|
6249
|
+
}
|
|
6250
|
+
if (lines.length) out[".env.example"] = envBase.replace(/\n?$/, "\n") + lines.join("\n") + "\n";
|
|
5818
6251
|
return out;
|
|
5819
6252
|
}
|
|
5820
|
-
var backendPkgJson = (ctx) =>
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
5854
|
-
|
|
5855
|
-
|
|
6253
|
+
var backendPkgJson = (ctx) => {
|
|
6254
|
+
const featureDeps = {};
|
|
6255
|
+
for (const f of ctx.features) Object.assign(featureDeps, f.backend?.(ctx)?.deps ?? {});
|
|
6256
|
+
return JSON.stringify({
|
|
6257
|
+
name: `${scope(ctx)}/backend`,
|
|
6258
|
+
version: "0.1.0",
|
|
6259
|
+
private: true,
|
|
6260
|
+
type: "module",
|
|
6261
|
+
main: "dist/index.js",
|
|
6262
|
+
scripts: {
|
|
6263
|
+
dev: "tsx watch src/index.ts",
|
|
6264
|
+
build: "tsc -p tsconfig.json",
|
|
6265
|
+
start: "node dist/index.js",
|
|
6266
|
+
typecheck: "tsc -p tsconfig.json --noEmit"
|
|
6267
|
+
},
|
|
6268
|
+
dependencies: {
|
|
6269
|
+
express: "^4.21.2",
|
|
6270
|
+
cors: "^2.8.5",
|
|
6271
|
+
mongoose: "^8.9.0",
|
|
6272
|
+
ioredis: "^5.4.2",
|
|
6273
|
+
dotenv: "^16.4.7",
|
|
6274
|
+
// ✨ Backend built on zero-dep @lacspace/* packages instead of the usual grab-bag.
|
|
6275
|
+
"@lacspace/env": "^1.1.0",
|
|
6276
|
+
"@lacspace/jwt": "^1.4.0",
|
|
6277
|
+
"@lacspace/password": "^1.1.0",
|
|
6278
|
+
"@lacspace/validate": "^1.1.0",
|
|
6279
|
+
"@lacspace/id": "^1.1.0",
|
|
6280
|
+
"@lacspace/rate-limit": "^1.2.0",
|
|
6281
|
+
"@lacspace/cache": "^1.1.0",
|
|
6282
|
+
...featureDeps,
|
|
6283
|
+
[`${scope(ctx)}/types`]: "*"
|
|
6284
|
+
},
|
|
6285
|
+
devDependencies: {
|
|
6286
|
+
typescript: "^5.7.0",
|
|
6287
|
+
tsx: "^4.19.2",
|
|
6288
|
+
"@types/node": "^22.10.0",
|
|
6289
|
+
"@types/express": "^4.17.21",
|
|
6290
|
+
"@types/cors": "^2.8.17"
|
|
6291
|
+
}
|
|
6292
|
+
}, null, 2) + "\n";
|
|
6293
|
+
};
|
|
5856
6294
|
var backendTsconfig = (ctx) => JSON.stringify({
|
|
5857
6295
|
compilerOptions: {
|
|
5858
6296
|
target: "ES2022",
|
|
@@ -6278,35 +6716,49 @@ router.delete("/:id", asyncHandler(async (req, res) => {
|
|
|
6278
6716
|
export default router;
|
|
6279
6717
|
`;
|
|
6280
6718
|
var backendApp = (ctx) => `import express from "express";
|
|
6281
|
-
import type { RequestHandler } from "express";
|
|
6282
6719
|
import cors from "cors";
|
|
6283
|
-
import { rateLimit, expressRateLimit } from "@lacspace/rate-limit";
|
|
6284
6720
|
import { env } from "./env.js";
|
|
6285
6721
|
import { errorHandler } from "./middleware/error.js";
|
|
6286
|
-
import
|
|
6287
|
-
import noteRoutes from "./routes/notes.js";
|
|
6722
|
+
import { registerRoutes } from "./routes/index.js";
|
|
6288
6723
|
|
|
6289
6724
|
// how this works: assembles the Express app \u2014 CORS for the frontend, JSON parsing,
|
|
6290
|
-
// a health check,
|
|
6725
|
+
// a health check, all route groups (see routes/index.ts), then the error handler LAST.
|
|
6291
6726
|
export function createApp(): express.Express {
|
|
6292
6727
|
const app = express();
|
|
6293
6728
|
|
|
6294
6729
|
app.use(cors({ origin: env.CORS_ORIGIN.split(",").map((o) => o.trim()), credentials: true }));
|
|
6295
6730
|
app.use(express.json());
|
|
6296
6731
|
|
|
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
6732
|
app.get("/health", (_req, res) => { res.json({ ok: true, service: "${ctx.name}-api" }); });
|
|
6301
|
-
app
|
|
6302
|
-
app.use("/notes", noteRoutes);
|
|
6733
|
+
registerRoutes(app);
|
|
6303
6734
|
|
|
6304
6735
|
app.use(errorHandler);
|
|
6305
6736
|
return app;
|
|
6306
6737
|
}
|
|
6307
6738
|
`;
|
|
6739
|
+
var backendRoutesIndex = (ctx) => {
|
|
6740
|
+
const featureRoutes = ctx.features.flatMap((f) => f.backend?.(ctx)?.routes ?? []);
|
|
6741
|
+
const needsAuth = featureRoutes.some((r) => r.auth);
|
|
6742
|
+
const importLines = featureRoutes.map((r) => r.importLine).join("\n");
|
|
6743
|
+
const mountLines = featureRoutes.map((r) => ` app.use(${JSON.stringify(r.path)}, ${r.auth ? "requireAuth, " : ""}${r.handler});`).join("\n");
|
|
6744
|
+
return `import type { Express, RequestHandler } from "express";
|
|
6745
|
+
import { rateLimit, expressRateLimit } from "@lacspace/rate-limit";
|
|
6746
|
+
${needsAuth ? 'import { requireAuth } from "../middleware/auth.js";\n' : ""}import authRoutes from "./auth.js";
|
|
6747
|
+
import noteRoutes from "./notes.js";
|
|
6748
|
+
${importLines ? importLines + "\n" : ""}
|
|
6749
|
+
// how this works: every route group is mounted here. Add a new resource by creating
|
|
6750
|
+
// a router in routes/ and adding one app.use(...) line below.
|
|
6751
|
+
export function registerRoutes(app: Express): void {
|
|
6752
|
+
// Brute-force protection on auth: 20 requests / minute / IP (@lacspace/rate-limit).
|
|
6753
|
+
const authLimiter = expressRateLimit(rateLimit({ limit: 20, windowMs: 60_000 })) as unknown as RequestHandler;
|
|
6754
|
+
app.use("/auth", authLimiter, authRoutes);
|
|
6755
|
+
app.use("/notes", noteRoutes);
|
|
6756
|
+
${mountLines}
|
|
6757
|
+
}
|
|
6758
|
+
`;
|
|
6759
|
+
};
|
|
6308
6760
|
function backendFiles(ctx) {
|
|
6309
|
-
|
|
6761
|
+
const files = {
|
|
6310
6762
|
"backend/package.json": backendPkgJson(ctx),
|
|
6311
6763
|
"backend/tsconfig.json": backendTsconfig(ctx),
|
|
6312
6764
|
"backend/.gitignore": backendGitignore(),
|
|
@@ -6325,8 +6777,15 @@ function backendFiles(ctx) {
|
|
|
6325
6777
|
"backend/src/models/user.ts": backendUserModel(),
|
|
6326
6778
|
"backend/src/models/note.ts": backendNoteModel(),
|
|
6327
6779
|
"backend/src/routes/auth.ts": backendAuthRoutes(ctx),
|
|
6328
|
-
"backend/src/routes/notes.ts": backendNoteRoutes(ctx)
|
|
6780
|
+
"backend/src/routes/notes.ts": backendNoteRoutes(ctx),
|
|
6781
|
+
"backend/src/routes/index.ts": backendRoutesIndex(ctx)
|
|
6329
6782
|
};
|
|
6783
|
+
for (const f of ctx.features) {
|
|
6784
|
+
const b = f.backend?.(ctx);
|
|
6785
|
+
if (!b?.files) continue;
|
|
6786
|
+
for (const [rel, content] of Object.entries(b.files)) files["backend/" + rel] = content;
|
|
6787
|
+
}
|
|
6788
|
+
return files;
|
|
6330
6789
|
}
|
|
6331
6790
|
var splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
|
|
6332
6791
|
function parseArgs(list) {
|
package/dist/lib.d.cts
CHANGED
|
@@ -34,6 +34,39 @@ interface FeatureDef {
|
|
|
34
34
|
nextSteps: string[];
|
|
35
35
|
/** Optional "learn more" URL for `LEARN.md`. */
|
|
36
36
|
learn?: string;
|
|
37
|
+
/**
|
|
38
|
+
* When true, this add-on needs the full-stack backend (a database + a real API).
|
|
39
|
+
* Requesting it in `static` mode automatically upgrades the project to `dynamic`.
|
|
40
|
+
*/
|
|
41
|
+
requiresBackend?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Optional backend (Express) contribution — applied **only in dynamic mode**.
|
|
44
|
+
* Lets an add-on drop backend files, register routes, add backend deps and
|
|
45
|
+
* document backend env vars. See {@link FeatureBackend}.
|
|
46
|
+
*/
|
|
47
|
+
backend?: (ctx: Ctx) => FeatureBackend;
|
|
48
|
+
}
|
|
49
|
+
/** An add-on's contribution to the `backend/` workspace (dynamic mode only). */
|
|
50
|
+
interface FeatureBackend {
|
|
51
|
+
/** Files under `backend/` — e.g. `"src/routes/payments.ts"`, `"src/models/payment.ts"`. */
|
|
52
|
+
files?: Record<string, string>;
|
|
53
|
+
/** Extra backend deps, merged into `backend/package.json`. */
|
|
54
|
+
deps?: Record<string, string>;
|
|
55
|
+
/** Express route groups this add-on mounts (assembled in `src/routes/index.ts`). */
|
|
56
|
+
routes?: FeatureRoute[];
|
|
57
|
+
/** Extra root `.env.example` entries — `NAME: "explanatory comment"`. */
|
|
58
|
+
env?: Record<string, string>;
|
|
59
|
+
}
|
|
60
|
+
/** One Express route group registered by a backend add-on. */
|
|
61
|
+
interface FeatureRoute {
|
|
62
|
+
/** Mount path, e.g. `"/payments"`. */
|
|
63
|
+
path: string;
|
|
64
|
+
/** The exact import line, e.g. `import paymentRoutes from "./payments.js";`. */
|
|
65
|
+
importLine: string;
|
|
66
|
+
/** The router identifier used in `app.use`, e.g. `"paymentRoutes"`. */
|
|
67
|
+
handler: string;
|
|
68
|
+
/** Protect the group with the shared `requireAuth` middleware. */
|
|
69
|
+
auth?: boolean;
|
|
37
70
|
}
|
|
38
71
|
interface Ctx {
|
|
39
72
|
name: string;
|
|
@@ -129,4 +162,4 @@ interface ScaffoldResult {
|
|
|
129
162
|
*/
|
|
130
163
|
declare function scaffold(options?: ScaffoldOptions): Promise<ScaffoldResult>;
|
|
131
164
|
|
|
132
|
-
export { type FeatureDef, type GenerateOptions, type ProjectFiles, type ScaffoldOptions, type ScaffoldResult, type TemplateDef, generateProject, getFeature, getSection, getTemplate, listFeatures, listSections, listTemplates, scaffold, templateKeys, templates };
|
|
165
|
+
export { type FeatureBackend, type FeatureDef, type FeatureRoute, type GenerateOptions, type ProjectFiles, type ScaffoldOptions, type ScaffoldResult, type TemplateDef, generateProject, getFeature, getSection, getTemplate, listFeatures, listSections, listTemplates, scaffold, templateKeys, templates };
|