@deepraxis/app-template 0.0.0-stage → 0.1.0-dev.2990b29

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.
Files changed (54) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +6 -2
  3. package/SKILL.md +145 -0
  4. package/package.json +13 -4
  5. package/references/design.md +113 -0
  6. package/references/research.md +56 -0
  7. package/template/README.md +57 -0
  8. package/template/_gitignore +7 -0
  9. package/template/app/account/page.tsx +47 -0
  10. package/template/app/admin/page.tsx +53 -0
  11. package/template/app/api/auth/login/route.ts +30 -0
  12. package/template/app/api/auth/register/route.ts +24 -0
  13. package/template/app/api/health/route.ts +10 -0
  14. package/template/app/api/images/route.ts +15 -0
  15. package/template/app/globals.css +45 -0
  16. package/template/app/images/[...key]/route.ts +20 -0
  17. package/template/app/layout.tsx +35 -0
  18. package/template/app/login/page.tsx +26 -0
  19. package/template/app/logout/route.ts +9 -0
  20. package/template/app/page.tsx +21 -0
  21. package/template/app/register/page.tsx +25 -0
  22. package/template/components/ui/badge.tsx +26 -0
  23. package/template/components/ui/button.tsx +24 -0
  24. package/template/components/ui/card.tsx +17 -0
  25. package/template/components/ui/input.tsx +14 -0
  26. package/template/components/ui/label.tsx +6 -0
  27. package/template/env.d.ts +11 -0
  28. package/template/lib/auth.test.ts +61 -0
  29. package/template/lib/auth.ts +107 -0
  30. package/template/lib/db.ts +19 -0
  31. package/template/lib/design-tokens.test.ts +90 -0
  32. package/template/lib/format.test.ts +12 -0
  33. package/template/lib/format.ts +18 -0
  34. package/template/lib/guard.ts +37 -0
  35. package/template/lib/migrate.test.ts +50 -0
  36. package/template/lib/migrate.ts +54 -0
  37. package/template/lib/migrations.ts +5 -0
  38. package/template/lib/schema.ts +30 -0
  39. package/template/lib/session.ts +48 -0
  40. package/template/lib/storage.ts +32 -0
  41. package/template/lib/users.test.ts +51 -0
  42. package/template/lib/users.ts +46 -0
  43. package/template/lib/utils.ts +6 -0
  44. package/template/migrations/0001_init.sql +27 -0
  45. package/template/next.config.ts +4 -0
  46. package/template/package.json +39 -0
  47. package/template/postcss.config.cjs +1 -0
  48. package/template/praxis.jsonc +10 -0
  49. package/template/tailwind.config.ts +35 -0
  50. package/template/test/fake-d1.ts +38 -0
  51. package/template/tsconfig.json +17 -0
  52. package/template/vinext-plugins.ts +77 -0
  53. package/template/vite.config.ts +8 -0
  54. package/template/wrangler.jsonc +11 -0
@@ -0,0 +1,24 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { credentialsSchema, hashPassword, MissingSecretError, requireSecret } from "@/lib/auth";
3
+ import { getDb } from "@/lib/db";
4
+ import { redirectResponse } from "@/lib/guard";
5
+ import { createSession, sessionCookie } from "@/lib/session";
6
+ import { createUser } from "@/lib/users";
7
+
8
+ // Rate limiting for sign-up (per IP) would go here, before any hashing or database work.
9
+ export async function POST(req: Request) {
10
+ try {
11
+ requireSecret(env.SESSION_SECRET); // fail before creating a user that could never sign in
12
+ const form = await req.formData();
13
+ const parsed = credentialsSchema.safeParse({ username: form.get("username"), password: form.get("password") });
14
+ if (!parsed.success) return redirectResponse(`/register?error=${encodeURIComponent(parsed.error.issues[0]!.message)}`);
15
+ const db = await getDb();
16
+ const created = await createUser(db, { username: parsed.data.username, passwordHash: await hashPassword(parsed.data.password) });
17
+ if (!created.ok) return redirectResponse(`/register?error=${encodeURIComponent("That username is taken")}`);
18
+ const session = await createSession(db, created.user.id, env.SESSION_SECRET);
19
+ return redirectResponse("/account", sessionCookie(session.value, session.expiresAt));
20
+ } catch (e) {
21
+ if (e instanceof MissingSecretError) return new Response(e.message, { status: 500 });
22
+ throw e;
23
+ }
24
+ }
@@ -0,0 +1,10 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { getDb } from "@/lib/db";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET() {
7
+ const db = await getDb();
8
+ await db.run(sql`SELECT 1`);
9
+ return Response.json({ ok: true });
10
+ }
@@ -0,0 +1,15 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { getDb } from "@/lib/db";
3
+ import { getCurrentUser, redirectResponse } from "@/lib/guard";
4
+ import { checkImage, saveImage } from "@/lib/storage";
5
+
6
+ export async function POST(req: Request) {
7
+ const user = await getCurrentUser(req.headers.get("cookie"));
8
+ if (!user) return redirectResponse("/login?next=%2Faccount");
9
+ const file = (await req.formData()).get("image");
10
+ if (!(file instanceof File)) return redirectResponse(`/account?error=${encodeURIComponent("Choose an image to upload")}`);
11
+ const problem = checkImage(file);
12
+ if (problem) return redirectResponse(`/account?error=${encodeURIComponent(problem)}`);
13
+ await saveImage(await getDb(), env.IMAGES, user.id, file);
14
+ return redirectResponse("/account");
15
+ }
@@ -0,0 +1,45 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* The look of the app lives here. Values are HSL triplets (hue saturation lightness) used as hsl(var(--x)).
6
+ Every *-foreground must keep at least 4.5:1 contrast on its background; the design test checks it. */
7
+ @layer base {
8
+ :root {
9
+ --background: 0 0% 99%;
10
+ --foreground: 222 25% 12%;
11
+ --card: 0 0% 100%;
12
+ --card-foreground: 222 25% 12%;
13
+ --muted: 220 14% 96%;
14
+ --muted-foreground: 220 9% 38%;
15
+ --border: 220 13% 88%;
16
+ --input: 220 13% 78%;
17
+ --ring: 222 47% 11%;
18
+ --primary: 222 47% 11%;
19
+ --primary-foreground: 0 0% 98%;
20
+ --accent: 220 14% 93%;
21
+ --accent-foreground: 222 47% 11%;
22
+ --destructive: 0 70% 38%;
23
+ --destructive-foreground: 0 0% 100%;
24
+ --success: 142 70% 22%;
25
+ --success-foreground: 0 0% 100%;
26
+ --warning: 40 95% 55%;
27
+ --warning-foreground: 30 80% 10%;
28
+ --radius: 0.5rem;
29
+ }
30
+
31
+ body {
32
+ @apply bg-background text-foreground antialiased;
33
+ font-size: 16px;
34
+ line-height: 1.7;
35
+ }
36
+
37
+ h1, h2, h3, h4, h5, h6 {
38
+ @apply font-bold;
39
+ }
40
+
41
+ :focus-visible {
42
+ outline: 2px solid hsl(var(--ring));
43
+ outline-offset: 2px;
44
+ }
45
+ }
@@ -0,0 +1,20 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { getDb } from "@/lib/db";
3
+ import { getCurrentUser } from "@/lib/guard";
4
+ import { findImage } from "@/lib/storage";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ // Images are private: only the owner and admins can read them.
9
+ export async function GET(req: Request, { params }: { params: Promise<{ key: string[] }> }) {
10
+ const user = await getCurrentUser(req.headers.get("cookie"));
11
+ if (!user) return new Response("Sign in first", { status: 401 });
12
+ const key = (await params).key.join("/");
13
+ const image = await findImage(await getDb(), key);
14
+ if (!image || (image.ownerId !== user.id && user.role !== "admin")) return new Response("Not found", { status: 404 });
15
+ const object = await env.IMAGES.get(key);
16
+ if (!object) return new Response("Not found", { status: 404 });
17
+ return new Response(object.body, {
18
+ headers: { "Content-Type": image.contentType, "Content-Length": String(image.size), "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff" },
19
+ });
20
+ }
@@ -0,0 +1,35 @@
1
+ import "./globals.css";
2
+ import Link from "next/link";
3
+ import { getCurrentUser } from "@/lib/guard";
4
+
5
+ export const metadata = { title: "Praxis App", description: "A Praxis application" };
6
+ export const dynamic = "force-dynamic";
7
+
8
+ export default async function RootLayout({ children }: { children: React.ReactNode }) {
9
+ const user = await getCurrentUser();
10
+ return (
11
+ <html lang="en">
12
+ <body className="min-h-screen">
13
+ <header className="border-b border-border bg-card">
14
+ <nav className="mx-auto flex max-w-4xl flex-wrap items-center gap-x-4 gap-y-1 px-4 py-2 text-sm">
15
+ <Link href="/" className="inline-flex min-h-11 items-center font-semibold">Praxis App</Link>
16
+ <span className="hidden flex-1 sm:block" />
17
+ {user ? (
18
+ <>
19
+ <Link href="/account" className="inline-flex min-h-11 items-center">Account</Link>
20
+ {user.role === "admin" && <Link href="/admin" className="inline-flex min-h-11 items-center">Admin</Link>}
21
+ <form action="/logout" method="post"><button className="inline-flex min-h-11 items-center text-muted-foreground underline-offset-4 hover:underline">Sign out ({user.username})</button></form>
22
+ </>
23
+ ) : (
24
+ <>
25
+ <Link href="/login" className="inline-flex min-h-11 items-center">Sign in</Link>
26
+ <Link href="/register" className="inline-flex min-h-11 items-center">Sign up</Link>
27
+ </>
28
+ )}
29
+ </nav>
30
+ </header>
31
+ <main className="mx-auto max-w-4xl px-4 py-8">{children}</main>
32
+ </body>
33
+ </html>
34
+ );
35
+ }
@@ -0,0 +1,26 @@
1
+ import Link from "next/link";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Label } from "@/components/ui/label";
6
+
7
+ export const dynamic = "force-dynamic";
8
+
9
+ export default async function Login({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
10
+ const { error, next } = await searchParams;
11
+ return (
12
+ <Card className="mx-auto max-w-sm">
13
+ <CardHeader><CardTitle>Sign in</CardTitle></CardHeader>
14
+ <CardContent>
15
+ <form action="/api/auth/login" method="post" className="space-y-4">
16
+ <input type="hidden" name="next" value={next ?? ""} />
17
+ <div className="space-y-1.5"><Label htmlFor="username">Username</Label><Input id="username" name="username" autoComplete="username" required /></div>
18
+ <div className="space-y-1.5"><Label htmlFor="password">Password</Label><Input id="password" name="password" type="password" autoComplete="current-password" required /></div>
19
+ {error && <p role="alert" className="text-sm text-destructive">{error}</p>}
20
+ <Button type="submit" className="w-full">Sign in</Button>
21
+ </form>
22
+ <p className="mt-4 text-sm"><Link className="underline" href="/register">Create an account</Link></p>
23
+ </CardContent>
24
+ </Card>
25
+ );
26
+ }
@@ -0,0 +1,9 @@
1
+ import { env } from "cloudflare:workers";
2
+ import { getDb } from "@/lib/db";
3
+ import { redirectResponse } from "@/lib/guard";
4
+ import { clearedSessionCookie, destroySession } from "@/lib/session";
5
+
6
+ export async function POST(req: Request) {
7
+ await destroySession(await getDb(), req.headers.get("cookie"), env.SESSION_SECRET);
8
+ return redirectResponse("/", clearedSessionCookie);
9
+ }
@@ -0,0 +1,21 @@
1
+ import Link from "next/link";
2
+ import { getCurrentUser } from "@/lib/guard";
3
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export default async function Home() {
8
+ const user = await getCurrentUser();
9
+ return (
10
+ <Card>
11
+ <CardHeader><CardTitle>Welcome</CardTitle></CardHeader>
12
+ <CardContent className="space-y-3 text-sm">
13
+ {user ? (
14
+ <p>Signed in as <strong>{user.username}</strong> ({user.role}). <Link className="underline" href="/account">Go to your account</Link>.</p>
15
+ ) : (
16
+ <p>You are not signed in. <Link className="underline" href="/login">Sign in</Link> or <Link className="underline" href="/register">create an account</Link>.</p>
17
+ )}
18
+ </CardContent>
19
+ </Card>
20
+ );
21
+ }
@@ -0,0 +1,25 @@
1
+ import Link from "next/link";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Label } from "@/components/ui/label";
6
+
7
+ export const dynamic = "force-dynamic";
8
+
9
+ export default async function Register({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
10
+ const { error } = await searchParams;
11
+ return (
12
+ <Card className="mx-auto max-w-sm">
13
+ <CardHeader><CardTitle>Create account</CardTitle></CardHeader>
14
+ <CardContent>
15
+ <form action="/api/auth/register" method="post" className="space-y-4">
16
+ <div className="space-y-1.5"><Label htmlFor="username">Username</Label><Input id="username" name="username" autoComplete="username" required /></div>
17
+ <div className="space-y-1.5"><Label htmlFor="password">Password</Label><Input id="password" name="password" type="password" autoComplete="new-password" required /></div>
18
+ {error && <p role="alert" className="text-sm text-destructive">{error}</p>}
19
+ <Button type="submit" className="w-full">Create account</Button>
20
+ </form>
21
+ <p className="mt-4 text-sm"><Link className="underline" href="/login">Already have an account?</Link></p>
22
+ </CardContent>
23
+ </Card>
24
+ );
25
+ }
@@ -0,0 +1,26 @@
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ const badgeVariants = cva(
6
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default: "border-transparent bg-primary text-primary-foreground",
11
+ secondary: "border-transparent bg-accent text-accent-foreground",
12
+ success: "border-transparent bg-success text-success-foreground",
13
+ warning: "border-transparent bg-warning text-warning-foreground",
14
+ destructive: "border-transparent bg-destructive text-destructive-foreground",
15
+ outline: "border-border text-foreground",
16
+ },
17
+ },
18
+ defaultVariants: { variant: "default" },
19
+ },
20
+ );
21
+
22
+ export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
23
+
24
+ export function Badge({ className, variant, ...props }: BadgeProps) {
25
+ return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
26
+ }
@@ -0,0 +1,24 @@
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ const buttonVariants = cva(
6
+ "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
7
+ {
8
+ variants: {
9
+ variant: {
10
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
11
+ outline: "border border-input bg-card text-card-foreground hover:bg-accent",
12
+ ghost: "hover:bg-accent hover:text-accent-foreground",
13
+ },
14
+ size: { default: "h-11 px-5 py-2", sm: "h-11 px-4" },
15
+ },
16
+ defaultVariants: { variant: "default", size: "default" },
17
+ },
18
+ );
19
+
20
+ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
21
+
22
+ export function Button({ className, variant, size, ...props }: ButtonProps) {
23
+ return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
24
+ }
@@ -0,0 +1,17 @@
1
+ import * as React from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ type DivProps = React.HTMLAttributes<HTMLDivElement>;
5
+
6
+ export function Card({ className, ...props }: DivProps) {
7
+ return <div className={cn("rounded-lg border border-border bg-card text-card-foreground shadow-sm", className)} {...props} />;
8
+ }
9
+ export function CardHeader({ className, ...props }: DivProps) {
10
+ return <div className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />;
11
+ }
12
+ export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
13
+ return <h2 className={cn("text-lg font-semibold leading-none", className)} {...props} />;
14
+ }
15
+ export function CardContent({ className, ...props }: DivProps) {
16
+ return <div className={cn("p-6 pt-0", className)} {...props} />;
17
+ }
@@ -0,0 +1,14 @@
1
+ import * as React from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
5
+ return (
6
+ <input
7
+ className={cn(
8
+ "flex h-11 w-full rounded-md border border-input bg-card px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
9
+ className,
10
+ )}
11
+ {...props}
12
+ />
13
+ );
14
+ }
@@ -0,0 +1,6 @@
1
+ import * as React from "react";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
5
+ return <label className={cn("text-sm font-medium leading-none", className)} {...props} />;
6
+ }
@@ -0,0 +1,11 @@
1
+ // Types of the bindings declared in wrangler.jsonc and the secrets declared in praxis.jsonc.
2
+ // Read them with `import { env } from "cloudflare:workers"`; add a line here for every new one.
3
+ declare namespace Cloudflare {
4
+ interface Env {
5
+ DB: D1Database;
6
+ IMAGES: R2Bucket;
7
+ ASSETS: Fetcher;
8
+ /** Signs session cookies; created by `praxis app deploy` ("generate" in praxis.jsonc). */
9
+ SESSION_SECRET?: string;
10
+ }
11
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { credentialsSchema, hashPassword, MissingSecretError, newSessionToken, sha256Hex, signToken, verifyPassword, verifySignedToken } from "./auth";
3
+
4
+ describe("passwords", () => {
5
+ test("hash has the documented format and verifies", async () => {
6
+ const stored = await hashPassword("correct horse");
7
+ expect(stored).toMatch(/^pbkdf2\$100000\$[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+$/);
8
+ expect(await verifyPassword("correct horse", stored)).toBe(true);
9
+ });
10
+ test("wrong password is rejected", async () => {
11
+ expect(await verifyPassword("wrong horse", await hashPassword("correct horse"))).toBe(false);
12
+ });
13
+ test("same password hashes differently (random salt)", async () => {
14
+ expect(await hashPassword("same-password")).not.toBe(await hashPassword("same-password"));
15
+ });
16
+ test("malformed or over-limit stored values never verify", async () => {
17
+ expect(await verifyPassword("x", "garbage")).toBe(false);
18
+ expect(await verifyPassword("x", "pbkdf2$99999999$AAAA$AAAA")).toBe(false);
19
+ });
20
+ });
21
+
22
+ describe("signed session token", () => {
23
+ const secret = "test-secret";
24
+ test("round-trips", async () => {
25
+ const token = newSessionToken();
26
+ expect(await verifySignedToken(await signToken(token, secret), secret)).toBe(token);
27
+ });
28
+ test("tampered signature or token is rejected", async () => {
29
+ const token = newSessionToken();
30
+ const value = await signToken(token, secret);
31
+ const flipped = value.slice(0, -2) + (value.endsWith("AA") ? "BB" : "AA");
32
+ expect(await verifySignedToken(flipped, secret)).toBeNull();
33
+ expect(await verifySignedToken(`${newSessionToken()}.${value.split(".")[1]}`, secret)).toBeNull();
34
+ });
35
+ test("a signature made with another secret is rejected", async () => {
36
+ expect(await verifySignedToken(await signToken("tok", "other-secret"), secret)).toBeNull();
37
+ });
38
+ test("garbage cookie values are rejected, not thrown", async () => {
39
+ for (const v of ["", "nodot", ".sig", "tok.", "tok.%%%"]) expect(await verifySignedToken(v, secret)).toBeNull();
40
+ });
41
+ test("missing secret throws a clear error naming SESSION_SECRET and never signs", async () => {
42
+ await expect(signToken("tok", undefined)).rejects.toThrow(MissingSecretError);
43
+ await expect(signToken("tok", "")).rejects.toThrow(/SESSION_SECRET/);
44
+ await expect(verifySignedToken("tok.sig", undefined)).rejects.toThrow(MissingSecretError);
45
+ });
46
+ test("session id is the sha256 of the token", async () => {
47
+ expect(await sha256Hex("abc")).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
48
+ });
49
+ });
50
+
51
+ describe("validation", () => {
52
+ test("username and password bounds", () => {
53
+ const ok = (username: string, password: string) => credentialsSchema.safeParse({ username, password }).success;
54
+ expect(ok("alice_1", "12345678")).toBe(true);
55
+ expect(ok("ab", "12345678")).toBe(false);
56
+ expect(ok("Alice", "12345678")).toBe(false);
57
+ expect(ok("a".repeat(33), "12345678")).toBe(false);
58
+ expect(ok("alice", "1234567")).toBe(false);
59
+ expect(ok("alice", "x".repeat(129))).toBe(false);
60
+ });
61
+ });
@@ -0,0 +1,107 @@
1
+ // Credentials and cookie signing, WebCrypto only. No framework or Cloudflare imports, so it runs under `bun test`.
2
+ //
3
+ // Password hash format: pbkdf2$<iterations>$<salt_b64>$<hash_b64> (PBKDF2-SHA256, 16-byte salt).
4
+ // 100000 iterations is the maximum the Workers runtime accepts.
5
+ // Cookie value: <token>.<signature>, signature = HMAC-SHA256(token) keyed by the SESSION_SECRET binding.
6
+ // Rate limiting (per IP and per username on sign-in and sign-up) belongs in the two route handlers under app/api/auth/.
7
+ import { z } from "zod";
8
+
9
+ export const PBKDF2_ITERATIONS = 100000;
10
+
11
+ export const usernameSchema = z.string().regex(/^[a-z0-9_]{3,32}$/, "Username: 3-32 characters of a-z, 0-9 and _");
12
+ export const passwordSchema = z.string().min(8, "Password: at least 8 characters").max(128, "Password: at most 128 characters");
13
+ export const credentialsSchema = z.object({ username: usernameSchema, password: passwordSchema });
14
+
15
+ export class MissingSecretError extends Error {
16
+ readonly status = 500;
17
+ constructor() {
18
+ super("SESSION_SECRET is not configured: pass it as a secret_text binding when deploying. Refusing to sign sessions without it.");
19
+ this.name = "MissingSecretError";
20
+ }
21
+ }
22
+
23
+ export function requireSecret(secret: string | undefined): string {
24
+ if (!secret) throw new MissingSecretError();
25
+ return secret;
26
+ }
27
+
28
+ type Bytes = Uint8Array<ArrayBuffer>;
29
+ const enc = new TextEncoder();
30
+
31
+ function toB64(bytes: Uint8Array): string {
32
+ let s = "";
33
+ for (const b of bytes) s += String.fromCharCode(b);
34
+ return btoa(s);
35
+ }
36
+ function fromB64(b64: string): Bytes {
37
+ const s = atob(b64);
38
+ const out = new Uint8Array(s.length);
39
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
40
+ return out;
41
+ }
42
+ const toB64Url = (bytes: Uint8Array) => toB64(bytes).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
43
+ const fromB64Url = (s: string) => fromB64(s.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - (s.length % 4)) % 4));
44
+
45
+ /** Constant-time comparison of two byte arrays (length difference is not secret). */
46
+ export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
47
+ if (a.length !== b.length) return false;
48
+ let diff = 0;
49
+ for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
50
+ return diff === 0;
51
+ }
52
+
53
+ async function pbkdf2(password: string, salt: Bytes, iterations: number): Promise<Uint8Array> {
54
+ const key = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveBits"]);
55
+ const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations }, key, 256);
56
+ return new Uint8Array(bits);
57
+ }
58
+
59
+ export async function hashPassword(password: string): Promise<string> {
60
+ const salt = crypto.getRandomValues(new Uint8Array(16));
61
+ const hash = await pbkdf2(password, salt, PBKDF2_ITERATIONS);
62
+ return `pbkdf2$${PBKDF2_ITERATIONS}$${toB64(salt)}$${toB64(hash)}`;
63
+ }
64
+
65
+ export async function verifyPassword(password: string, stored: string): Promise<boolean> {
66
+ const [scheme, iterations, salt, hash] = stored.split("$");
67
+ const n = Number(iterations);
68
+ if (scheme !== "pbkdf2" || !Number.isInteger(n) || n < 1 || n > PBKDF2_ITERATIONS || !salt || !hash) return false;
69
+ const actual = await pbkdf2(password, fromB64(salt), n);
70
+ return timingSafeEqual(actual, fromB64(hash));
71
+ }
72
+
73
+ /** A fresh random session token (32 bytes, base64url). Only its sha256 is stored. */
74
+ export function newSessionToken(): string {
75
+ return toB64Url(crypto.getRandomValues(new Uint8Array(32)));
76
+ }
77
+
78
+ export async function sha256Hex(value: string): Promise<string> {
79
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(value)));
80
+ return [...digest].map(b => b.toString(16).padStart(2, "0")).join("");
81
+ }
82
+
83
+ const hmacKey = (secret: string, usage: "sign" | "verify") =>
84
+ crypto.subtle.importKey("raw", enc.encode(requireSecret(secret)), { name: "HMAC", hash: "SHA-256" }, false, [usage]);
85
+
86
+ /** `token.signature` — the cookie value. Throws MissingSecretError when the secret is empty. */
87
+ export async function signToken(token: string, secret: string | undefined): Promise<string> {
88
+ const key = await hmacKey(requireSecret(secret), "sign");
89
+ const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(token)));
90
+ return `${token}.${toB64Url(sig)}`;
91
+ }
92
+
93
+ /** The token when the signature is valid, otherwise null. Verification is constant-time (subtle.verify). */
94
+ export async function verifySignedToken(value: string | null | undefined, secret: string | undefined): Promise<string | null> {
95
+ if (!value) return null;
96
+ const dot = value.lastIndexOf(".");
97
+ if (dot <= 0) return null;
98
+ const token = value.slice(0, dot);
99
+ let sig: Bytes;
100
+ try {
101
+ sig = fromB64Url(value.slice(dot + 1));
102
+ } catch {
103
+ return null;
104
+ }
105
+ const key = await hmacKey(requireSecret(secret), "verify");
106
+ return (await crypto.subtle.verify("HMAC", key, sig, enc.encode(token))) ? token : null;
107
+ }
@@ -0,0 +1,19 @@
1
+ // Database access. `getDb()` applies pending migrations once per isolate, then returns a drizzle client.
2
+ import { env } from "cloudflare:workers";
3
+ import { drizzle } from "drizzle-orm/d1";
4
+ import { runMigrations } from "./migrate";
5
+ import { migrations } from "./migrations";
6
+ import * as schema from "./schema";
7
+
8
+ let ready: Promise<unknown> | undefined;
9
+
10
+ export async function getDb() {
11
+ ready ??= runMigrations(env.DB, migrations).catch(e => {
12
+ ready = undefined; // retry on the next request instead of caching the failure
13
+ throw e;
14
+ });
15
+ await ready;
16
+ return drizzle(env.DB, { schema });
17
+ }
18
+
19
+ export type Db = Awaited<ReturnType<typeof getDb>>;
@@ -0,0 +1,90 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const root = path.join(import.meta.dir, "..");
6
+
7
+ /** WCAG 2.x contrast ratio between two HSL triplets written as "H S% L%". */
8
+ export function hslToRgb(triplet: string): [number, number, number] {
9
+ const m = triplet.trim().match(/^(-?[\d.]+)\s+([\d.]+)%\s+([\d.]+)%$/);
10
+ if (!m) throw new Error(`not an HSL triplet: "${triplet}"`);
11
+ const h = ((Number(m[1]) % 360) + 360) % 360, s = Number(m[2]) / 100, l = Number(m[3]) / 100;
12
+ const k = (n: number) => (n + h / 30) % 12;
13
+ const a = s * Math.min(l, 1 - l);
14
+ const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
15
+ return [f(0), f(8), f(4)];
16
+ }
17
+ export function contrast(fg: string, bg: string): number {
18
+ const lum = (t: string) => {
19
+ const [r, g, b] = hslToRgb(t).map(c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)) as [number, number, number];
20
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
21
+ };
22
+ const [hi, lo] = [lum(fg), lum(bg)].sort((x, y) => y - x) as [number, number];
23
+ return (hi + 0.05) / (lo + 0.05);
24
+ }
25
+
26
+ function tokens(): Record<string, string> {
27
+ const css = fs.readFileSync(path.join(root, "app/globals.css"), "utf8");
28
+ const block = css.match(/:root\s*\{([^}]*)\}/)?.[1] ?? "";
29
+ return Object.fromEntries([...block.matchAll(/--([\w-]+):\s*([^;]+);/g)].map(m => [m[1]!, m[2]!.trim()]));
30
+ }
31
+
32
+ const REQUIRED = [
33
+ "background", "foreground", "card", "card-foreground", "muted", "muted-foreground", "border", "input", "ring",
34
+ "primary", "primary-foreground", "accent", "accent-foreground", "destructive", "destructive-foreground",
35
+ "success", "success-foreground", "warning", "warning-foreground", "radius",
36
+ ];
37
+ // [foreground token, background token] pairs that carry text.
38
+ const PAIRS: [string, string][] = [
39
+ ["foreground", "background"], ["card-foreground", "card"], ["muted-foreground", "background"], ["muted-foreground", "card"],
40
+ ["muted-foreground", "muted"], ["primary-foreground", "primary"], ["accent-foreground", "accent"],
41
+ ["destructive-foreground", "destructive"], ["success-foreground", "success"], ["warning-foreground", "warning"],
42
+ ["destructive", "background"], ["destructive", "card"],
43
+ ];
44
+
45
+ describe("design tokens", () => {
46
+ const t = tokens();
47
+ test("every required token is defined", () => {
48
+ expect(REQUIRED.filter(k => !(k in t))).toEqual([]);
49
+ });
50
+ for (const [fg, bg] of PAIRS) {
51
+ test(`${fg} on ${bg} has at least 4.5:1 contrast`, () => {
52
+ const ratio = contrast(t[fg]!, t[bg]!);
53
+ expect(ratio, `${fg} (${t[fg]}) on ${bg} (${t[bg]}) is ${ratio.toFixed(2)}:1`).toBeGreaterThanOrEqual(4.5);
54
+ });
55
+ }
56
+ test("contrast function matches known values", () => {
57
+ expect(contrast("0 0% 0%", "0 0% 100%")).toBeCloseTo(21, 1);
58
+ expect(contrast("0 0% 100%", "0 0% 100%")).toBeCloseTo(1, 5);
59
+ });
60
+ });
61
+
62
+ const PALETTE = "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose";
63
+ const RAW_COLOR = new RegExp(`\\b(?:bg|text|border|ring|from|to|via|fill|stroke|outline|divide|placeholder|decoration|shadow)-(?:${PALETTE})-\\d+\\b`, "g");
64
+ const HEX_IN_CLASS = /className\s*=\s*(?:"[^"]*|'[^']*|\{[^}]*)#[0-9a-fA-F]{3,8}\b|\[(?:#[0-9a-fA-F]{3,8})\]/g;
65
+
66
+ function sources(dir: string): string[] {
67
+ const out: string[] = [];
68
+ const abs = path.join(root, dir);
69
+ if (!fs.existsSync(abs)) return out;
70
+ for (const e of fs.readdirSync(abs, { recursive: true, withFileTypes: true })) {
71
+ if (e.isFile() && /\.(ts|tsx)$/.test(e.name)) out.push(path.relative(root, path.join(e.parentPath, e.name)));
72
+ }
73
+ return out;
74
+ }
75
+
76
+ export function findRawColors(file: string, text: string): string[] {
77
+ return [...(text.match(RAW_COLOR) ?? []), ...(text.match(HEX_IN_CLASS) ?? [])].map(c => `${file}: ${c}`);
78
+ }
79
+
80
+ describe("semantic classes only", () => {
81
+ test("detector flags palette classes and hex colors", () => {
82
+ expect(findRawColors("x.tsx", `<p className="bg-slate-900 hover:text-pink-500">`)).toHaveLength(2);
83
+ expect(findRawColors("x.tsx", `<p className="bg-[#ff0000]">`)).toHaveLength(1);
84
+ expect(findRawColors("x.tsx", `<p className="bg-primary text-muted-foreground">`)).toEqual([]);
85
+ });
86
+ test("app/ and components/ use no raw palette class or hex color", () => {
87
+ const hits = [...sources("app"), ...sources("components")].flatMap(f => findRawColors(f, fs.readFileSync(path.join(root, f), "utf8")));
88
+ expect(hits, `Use semantic classes (bg-primary, text-muted-foreground, text-destructive...) or edit the tokens in app/globals.css:\n${hits.join("\n")}`).toEqual([]);
89
+ });
90
+ });
@@ -0,0 +1,12 @@
1
+ import { expect, test } from "bun:test";
2
+ import { formatBytes, formatDateTime } from "./format";
3
+
4
+ test("dates show minutes in the app time zone, not raw ISO", () => {
5
+ expect(formatDateTime(Date.UTC(2026, 9, 25, 15, 3, 49))).toBe("2026-10-25 23:03");
6
+ });
7
+
8
+ test("sizes use readable units", () => {
9
+ expect(formatBytes(70)).toBe("70 B");
10
+ expect(formatBytes(12_595)).toBe("12.3 KB");
11
+ expect(formatBytes(4_718_592)).toBe("4.5 MB");
12
+ });