@deepraxis/app-template 0.0.0-stage → 0.1.0-dev.378725e
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/LICENSE +67 -0
- package/README.md +6 -2
- package/SKILL.md +143 -0
- package/package.json +12 -4
- package/template/README.md +53 -0
- package/template/_gitignore +7 -0
- package/template/app/account/page.tsx +45 -0
- package/template/app/admin/page.tsx +49 -0
- package/template/app/api/auth/login/route.ts +30 -0
- package/template/app/api/auth/register/route.ts +24 -0
- package/template/app/api/health/route.ts +10 -0
- package/template/app/api/images/route.ts +15 -0
- package/template/app/globals.css +3 -0
- package/template/app/images/[...key]/route.ts +20 -0
- package/template/app/layout.tsx +35 -0
- package/template/app/login/page.tsx +26 -0
- package/template/app/logout/route.ts +9 -0
- package/template/app/page.tsx +21 -0
- package/template/app/register/page.tsx +25 -0
- package/template/components/ui/button.tsx +24 -0
- package/template/components/ui/card.tsx +17 -0
- package/template/components/ui/input.tsx +14 -0
- package/template/components/ui/label.tsx +6 -0
- package/template/env.d.ts +11 -0
- package/template/lib/auth.test.ts +61 -0
- package/template/lib/auth.ts +107 -0
- package/template/lib/db.ts +19 -0
- package/template/lib/guard.ts +37 -0
- package/template/lib/migrate.test.ts +50 -0
- package/template/lib/migrate.ts +54 -0
- package/template/lib/migrations.ts +5 -0
- package/template/lib/schema.ts +30 -0
- package/template/lib/session.ts +48 -0
- package/template/lib/storage.ts +32 -0
- package/template/lib/users.test.ts +51 -0
- package/template/lib/users.ts +46 -0
- package/template/lib/utils.ts +6 -0
- package/template/migrations/0001_init.sql +27 -0
- package/template/next.config.ts +4 -0
- package/template/package.json +39 -0
- package/template/postcss.config.cjs +1 -0
- package/template/praxis.jsonc +10 -0
- package/template/tailwind.config.ts +7 -0
- package/template/test/fake-d1.ts +38 -0
- package/template/tsconfig.json +17 -0
- package/template/vinext-plugins.ts +77 -0
- package/template/vite.config.ts +8 -0
- package/template/wrangler.jsonc +11 -0
|
@@ -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-red-600">{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,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-slate-400 disabled:pointer-events-none disabled:opacity-50",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "bg-slate-900 text-white hover:bg-slate-800",
|
|
11
|
+
outline: "border border-slate-300 bg-white hover:bg-slate-50",
|
|
12
|
+
ghost: "hover:bg-slate-100",
|
|
13
|
+
},
|
|
14
|
+
size: { default: "h-9 px-4 py-2", sm: "h-8 px-3" },
|
|
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-slate-200 bg-white 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-9 w-full rounded-md border border-slate-300 bg-white px-3 py-1 text-sm shadow-sm placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:opacity-50",
|
|
9
|
+
className,
|
|
10
|
+
)}
|
|
11
|
+
{...props}
|
|
12
|
+
/>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -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,37 @@
|
|
|
1
|
+
// Request-level helpers for pages and route handlers: who is signed in, and who may see what.
|
|
2
|
+
import { env } from "cloudflare:workers";
|
|
3
|
+
import { headers } from "next/headers";
|
|
4
|
+
import { redirect } from "next/navigation";
|
|
5
|
+
import { getDb } from "./db";
|
|
6
|
+
import { userFromCookie } from "./session";
|
|
7
|
+
import type { User } from "./users";
|
|
8
|
+
|
|
9
|
+
export async function getCurrentUser(cookieHeader?: string | null): Promise<User | null> {
|
|
10
|
+
if (!env.SESSION_SECRET) return null; // nobody can be signed in without the secret
|
|
11
|
+
const cookie = cookieHeader !== undefined ? cookieHeader : (await headers()).get("cookie");
|
|
12
|
+
return userFromCookie(await getDb(), cookie, env.SESSION_SECRET);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Only accept same-site relative paths as a post-login destination. */
|
|
16
|
+
export function safeNext(next: string | null | undefined, fallback = "/account"): string {
|
|
17
|
+
return next && next.startsWith("/") && !next.startsWith("//") && !next.startsWith("/\\") ? next : fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function requireUser(next: string): Promise<User> {
|
|
21
|
+
const user = await getCurrentUser();
|
|
22
|
+
if (!user) redirect(`/login?next=${encodeURIComponent(next)}`);
|
|
23
|
+
return user;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function requireAdmin(next: string): Promise<User> {
|
|
27
|
+
const user = await requireUser(next);
|
|
28
|
+
if (user.role !== "admin") redirect("/account");
|
|
29
|
+
return user;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 303 redirect for form posts, optionally setting a cookie. */
|
|
33
|
+
export function redirectResponse(location: string, setCookie?: string): Response {
|
|
34
|
+
const h = new Headers({ Location: location });
|
|
35
|
+
if (setCookie) h.append("Set-Cookie", setCookie);
|
|
36
|
+
return new Response(null, { status: 303, headers: h });
|
|
37
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { fakeD1 } from "../test/fake-d1";
|
|
3
|
+
import { runMigrations, type Migration } from "./migrate";
|
|
4
|
+
|
|
5
|
+
const list: Migration[] = [
|
|
6
|
+
{ name: "0001_a", sql: "CREATE TABLE IF NOT EXISTS a (id INTEGER); CREATE INDEX IF NOT EXISTS a_id ON a(id);" },
|
|
7
|
+
{ name: "0002_b", sql: "CREATE TABLE IF NOT EXISTS b (id INTEGER);" },
|
|
8
|
+
];
|
|
9
|
+
const applied = (db: ReturnType<typeof fakeD1>["db"]) => db.query("SELECT name FROM _migrations ORDER BY applied_at, name").all().map((r: any) => r.name);
|
|
10
|
+
|
|
11
|
+
describe("runMigrations", () => {
|
|
12
|
+
test("applies pending migrations in order and records them", async () => {
|
|
13
|
+
const { d1, db, log } = fakeD1();
|
|
14
|
+
expect(await runMigrations(d1, list)).toEqual(["0001_a", "0002_b"]);
|
|
15
|
+
expect(applied(db)).toEqual(["0001_a", "0002_b"]);
|
|
16
|
+
expect(log.findIndex(s => s.includes("TABLE IF NOT EXISTS a "))).toBeLessThan(log.findIndex(s => s.includes("TABLE IF NOT EXISTS b ")));
|
|
17
|
+
expect(db.query("SELECT name FROM sqlite_master WHERE name IN ('a','b','a_id')").all()).toHaveLength(3);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("is idempotent: a second run applies nothing and runs no migration SQL", async () => {
|
|
21
|
+
const { d1, log } = fakeD1();
|
|
22
|
+
await runMigrations(d1, list);
|
|
23
|
+
const migrationWrites = () => log.filter(s => !s.includes("_migrations (name TEXT")).length;
|
|
24
|
+
const before = migrationWrites();
|
|
25
|
+
expect(await runMigrations(d1, list)).toEqual([]);
|
|
26
|
+
expect(migrationWrites()).toBe(before);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("skips migrations already recorded and applies only the new one", async () => {
|
|
30
|
+
const { d1, db } = fakeD1();
|
|
31
|
+
await runMigrations(d1, [list[0]!]);
|
|
32
|
+
expect(await runMigrations(d1, list)).toEqual(["0002_b"]);
|
|
33
|
+
expect(applied(db)).toEqual(["0001_a", "0002_b"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a failing migration rolls back whole and is not recorded", async () => {
|
|
37
|
+
const { d1, db } = fakeD1();
|
|
38
|
+
await expect(runMigrations(d1, [{ name: "0001_bad", sql: "CREATE TABLE t (id INTEGER); SELECT nope FROM missing;" }])).rejects.toThrow();
|
|
39
|
+
expect(applied(db)).toEqual([]);
|
|
40
|
+
expect(db.query("SELECT name FROM sqlite_master WHERE name = 't'").all()).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a race lost to another isolate that recorded the migration is not an error", async () => {
|
|
44
|
+
const { d1, db } = fakeD1();
|
|
45
|
+
await runMigrations(d1, []);
|
|
46
|
+
// The "other isolate" wins between our read and our batch: simulate by recording it from inside the first statement's failure.
|
|
47
|
+
const racing = { ...d1, batch: async (s: any[]) => { if (s.length > 1) { db.run("CREATE TABLE r (id INTEGER)"); db.run("INSERT INTO _migrations VALUES ('0001_r', 1)"); throw new Error("table r already exists"); } return d1.batch(s); } } as any;
|
|
48
|
+
expect(await runMigrations(racing, [{ name: "0001_r", sql: "CREATE TABLE r (id INTEGER);" }])).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Runtime migrator. The platform has no remote migrate command, so pending migrations are applied
|
|
2
|
+
// by the app itself, once per isolate, before the first database use (see lib/db.ts).
|
|
3
|
+
//
|
|
4
|
+
// How to add a migration:
|
|
5
|
+
// 1. Create migrations/000N_what_it_does.sql (next number). Plain SQL, statements ending in `;`.
|
|
6
|
+
// 2. Import it with `?raw` and append `{ name: "000N_what_it_does", sql }` in lib/migrations.ts.
|
|
7
|
+
// 3. Add the matching drizzle table or column to lib/schema.ts.
|
|
8
|
+
// Never edit or reorder an applied migration; add a new one.
|
|
9
|
+
//
|
|
10
|
+
// Safety: each migration runs in one D1 batch (a transaction) together with the INSERT OR IGNORE
|
|
11
|
+
// that records it in `_migrations`. Write statements so they are harmless to re-run
|
|
12
|
+
// (CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS). If two isolates race on a first request
|
|
13
|
+
// and one batch fails (for example a duplicate ALTER TABLE), the loser re-reads `_migrations` and
|
|
14
|
+
// carries on when the winner already recorded the migration.
|
|
15
|
+
|
|
16
|
+
export interface Migration {
|
|
17
|
+
name: string;
|
|
18
|
+
sql: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Db = Pick<D1Database, "prepare" | "batch">;
|
|
22
|
+
|
|
23
|
+
/** Split a migration file into single statements. Do not put `;` inside string literals or triggers. */
|
|
24
|
+
export function splitStatements(sql: string): string[] {
|
|
25
|
+
return sql
|
|
26
|
+
.split(";")
|
|
27
|
+
.map(s => s.trim())
|
|
28
|
+
.filter(s => s.length > 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function appliedNames(db: Db): Promise<Set<string>> {
|
|
32
|
+
const { results } = await db.prepare("SELECT name FROM _migrations").all<{ name: string }>();
|
|
33
|
+
return new Set(results.map(r => r.name));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Apply every pending migration in list order. Returns the names applied by this call. */
|
|
37
|
+
export async function runMigrations(db: Db, migrations: Migration[], now: () => number = Date.now): Promise<string[]> {
|
|
38
|
+
await db.batch([db.prepare("CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)")]);
|
|
39
|
+
const applied = await appliedNames(db);
|
|
40
|
+
const ran: string[] = [];
|
|
41
|
+
for (const m of migrations) {
|
|
42
|
+
if (applied.has(m.name)) continue;
|
|
43
|
+
try {
|
|
44
|
+
await db.batch([
|
|
45
|
+
...splitStatements(m.sql).map(s => db.prepare(s)),
|
|
46
|
+
db.prepare("INSERT OR IGNORE INTO _migrations (name, applied_at) VALUES (?, ?)").bind(m.name, now()),
|
|
47
|
+
]);
|
|
48
|
+
ran.push(m.name);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
if (!(await appliedNames(db)).has(m.name)) throw e;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return ran;
|
|
54
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The bundled migration list. Add one import + one entry per new file in migrations/ (see lib/migrate.ts).
|
|
2
|
+
import type { Migration } from "./migrate";
|
|
3
|
+
import m0001 from "../migrations/0001_init.sql?raw";
|
|
4
|
+
|
|
5
|
+
export const migrations: Migration[] = [{ name: "0001_init", sql: m0001 }];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// drizzle table definitions. Keep in step with migrations/*.sql (the SQL files are the source of truth).
|
|
2
|
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
3
|
+
|
|
4
|
+
export const users = sqliteTable("users", {
|
|
5
|
+
id: text("id").primaryKey(),
|
|
6
|
+
username: text("username").notNull().unique(),
|
|
7
|
+
passwordHash: text("password_hash").notNull(),
|
|
8
|
+
role: text("role", { enum: ["user", "admin"] }).notNull().default("user"),
|
|
9
|
+
createdAt: integer("created_at").notNull(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const sessions = sqliteTable("sessions", {
|
|
13
|
+
/** sha256 hex of the session token; the token itself is never stored. */
|
|
14
|
+
id: text("id").primaryKey(),
|
|
15
|
+
userId: text("user_id").notNull(),
|
|
16
|
+
expiresAt: integer("expires_at").notNull(),
|
|
17
|
+
createdAt: integer("created_at").notNull(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const images = sqliteTable("images", {
|
|
21
|
+
id: text("id").primaryKey(),
|
|
22
|
+
ownerId: text("owner_id").notNull(),
|
|
23
|
+
key: text("key").notNull().unique(),
|
|
24
|
+
contentType: text("content_type").notNull(),
|
|
25
|
+
size: integer("size").notNull(),
|
|
26
|
+
createdAt: integer("created_at").notNull(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export type Schema = { users: typeof users; sessions: typeof sessions; images: typeof images };
|
|
30
|
+
export type Role = "user" | "admin";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Sessions: D1 rows keyed by sha256(token), plus the cookie string. Pure over the db and secret passed in.
|
|
2
|
+
import { and, eq, gt } from "drizzle-orm";
|
|
3
|
+
import { newSessionToken, sha256Hex, signToken, verifySignedToken } from "./auth";
|
|
4
|
+
import { sessions, users } from "./schema";
|
|
5
|
+
import type { AnyDb, User } from "./users";
|
|
6
|
+
|
|
7
|
+
export const SESSION_COOKIE = "session";
|
|
8
|
+
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
/** Create a session row and return the signed cookie value. Throws MissingSecretError without a secret. */
|
|
11
|
+
export async function createSession(db: AnyDb, userId: string, secret: string | undefined, now = Date.now()) {
|
|
12
|
+
const token = newSessionToken();
|
|
13
|
+
const value = await signToken(token, secret); // fails before anything is written when the secret is missing
|
|
14
|
+
await db.insert(sessions).values({ id: await sha256Hex(token), userId, expiresAt: now + SESSION_TTL_MS, createdAt: now });
|
|
15
|
+
return { value, expiresAt: now + SESSION_TTL_MS };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function sessionCookie(value: string, expiresAt: number): string {
|
|
19
|
+
return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Expires=${new Date(expiresAt).toUTCString()}`;
|
|
20
|
+
}
|
|
21
|
+
export const clearedSessionCookie = `${SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
|
|
22
|
+
|
|
23
|
+
export function readCookie(header: string | null, name: string): string | null {
|
|
24
|
+
for (const part of (header ?? "").split(";")) {
|
|
25
|
+
const eq = part.indexOf("=");
|
|
26
|
+
if (eq > 0 && part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The signed-in user for a Cookie header, or null (no cookie, bad signature, unknown or expired session). */
|
|
32
|
+
export async function userFromCookie(db: AnyDb, cookieHeader: string | null, secret: string | undefined, now = Date.now()): Promise<User | null> {
|
|
33
|
+
const raw = readCookie(cookieHeader, SESSION_COOKIE);
|
|
34
|
+
const token = await verifySignedToken(raw, secret);
|
|
35
|
+
if (!token) return null;
|
|
36
|
+
const [row] = await db
|
|
37
|
+
.select({ id: users.id, username: users.username, role: users.role, createdAt: users.createdAt })
|
|
38
|
+
.from(sessions)
|
|
39
|
+
.innerJoin(users, eq(users.id, sessions.userId))
|
|
40
|
+
.where(and(eq(sessions.id, await sha256Hex(token)), gt(sessions.expiresAt, now)))
|
|
41
|
+
.limit(1);
|
|
42
|
+
return row ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function destroySession(db: AnyDb, cookieHeader: string | null, secret: string | undefined) {
|
|
46
|
+
const token = await verifySignedToken(readCookie(cookieHeader, SESSION_COOKIE), secret);
|
|
47
|
+
if (token) await db.delete(sessions).where(eq(sessions.id, await sha256Hex(token)));
|
|
48
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Image storage on R2 (binding IMAGES). Rules live here; pages and routes only call these functions.
|
|
2
|
+
import { eq, desc } from "drizzle-orm";
|
|
3
|
+
import { images } from "./schema";
|
|
4
|
+
import type { AnyDb } from "./users";
|
|
5
|
+
|
|
6
|
+
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
7
|
+
export const IMAGE_TYPES: Record<string, string> = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp" };
|
|
8
|
+
|
|
9
|
+
/** An error message when the file is not an acceptable image, otherwise null. */
|
|
10
|
+
export function checkImage(file: { type: string; size: number }): string | null {
|
|
11
|
+
if (!(file.type in IMAGE_TYPES)) return "Only JPEG, PNG and WebP images are accepted";
|
|
12
|
+
if (file.size === 0) return "The file is empty";
|
|
13
|
+
if (file.size > MAX_IMAGE_BYTES) return "The image is larger than 5 MB";
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function saveImage(db: AnyDb, bucket: R2Bucket, ownerId: string, file: File) {
|
|
18
|
+
const id = crypto.randomUUID();
|
|
19
|
+
const key = `u/${ownerId}/${crypto.randomUUID().replaceAll("-", "")}.${IMAGE_TYPES[file.type]}`;
|
|
20
|
+
await bucket.put(key, await file.arrayBuffer(), { httpMetadata: { contentType: file.type } });
|
|
21
|
+
await db.insert(images).values({ id, ownerId, key, contentType: file.type, size: file.size, createdAt: Date.now() });
|
|
22
|
+
return { id, key };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function listImages(db: AnyDb, ownerId: string) {
|
|
26
|
+
return db.select().from(images).where(eq(images.ownerId, ownerId)).orderBy(desc(images.createdAt));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function findImage(db: AnyDb, key: string) {
|
|
30
|
+
const [row] = await db.select().from(images).where(eq(images.key, key)).limit(1);
|
|
31
|
+
return row ?? null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
4
|
+
import { fakeD1 } from "../test/fake-d1";
|
|
5
|
+
import { runMigrations } from "./migrate";
|
|
6
|
+
import { createSession, sessionCookie, userFromCookie } from "./session";
|
|
7
|
+
import * as schema from "./schema";
|
|
8
|
+
import { createUser } from "./users";
|
|
9
|
+
|
|
10
|
+
let db: ReturnType<typeof drizzle<typeof schema>>;
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
const fake = fakeD1();
|
|
13
|
+
await runMigrations(fake.d1, [{ name: "0001_init", sql: readFileSync(new URL("../migrations/0001_init.sql", import.meta.url), "utf8") }]);
|
|
14
|
+
db = drizzle(fake.db, { schema });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("createUser", () => {
|
|
18
|
+
test("the first user is admin, everyone after is user", async () => {
|
|
19
|
+
const a = await createUser(db, { username: "alice", passwordHash: "h" });
|
|
20
|
+
const b = await createUser(db, { username: "bob", passwordHash: "h" });
|
|
21
|
+
expect(a.ok && a.user.role).toBe("admin");
|
|
22
|
+
expect(b.ok && b.user.role).toBe("user");
|
|
23
|
+
});
|
|
24
|
+
test("a duplicate username is reported, not thrown", async () => {
|
|
25
|
+
await createUser(db, { username: "alice", passwordHash: "h" });
|
|
26
|
+
expect(await createUser(db, { username: "alice", passwordHash: "h" })).toEqual({ ok: false, error: "username-taken" });
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("sessions", () => {
|
|
31
|
+
test("a signed cookie resolves to its user; tampering, expiry and a wrong secret do not", async () => {
|
|
32
|
+
const created = await createUser(db, { username: "alice", passwordHash: "h" });
|
|
33
|
+
if (!created.ok) throw new Error("setup");
|
|
34
|
+
const s = await createSession(db, created.user.id, "sec", 1000);
|
|
35
|
+
const cookie = `session=${s.value}`;
|
|
36
|
+
expect((await userFromCookie(db, cookie, "sec", 2000))?.username).toBe("alice");
|
|
37
|
+
expect(await userFromCookie(db, `session=${s.value}x`, "sec", 2000)).toBeNull();
|
|
38
|
+
expect(await userFromCookie(db, cookie, "other", 2000)).toBeNull();
|
|
39
|
+
expect(await userFromCookie(db, cookie, "sec", s.expiresAt + 1)).toBeNull();
|
|
40
|
+
expect(db.select().from(schema.sessions).all()[0]!.id).not.toContain(s.value.split(".")[0]!); // only the hash is stored
|
|
41
|
+
});
|
|
42
|
+
test("the cookie is HttpOnly, Secure, SameSite=Lax, Path=/", () => {
|
|
43
|
+
expect(sessionCookie("v", 0)).toMatch(/^session=v; Path=\/; HttpOnly; Secure; SameSite=Lax; Expires=/);
|
|
44
|
+
});
|
|
45
|
+
test("creating a session without a secret throws and writes nothing", async () => {
|
|
46
|
+
const created = await createUser(db, { username: "alice", passwordHash: "h" });
|
|
47
|
+
if (!created.ok) throw new Error("setup");
|
|
48
|
+
await expect(createSession(db, created.user.id, undefined)).rejects.toThrow(/SESSION_SECRET/);
|
|
49
|
+
expect(db.select().from(schema.sessions).all()).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
});
|