···11+# Crush Memory - Indiko Project
22+33+## User Preferences
44+55+- **DO NOT** run the server - user will always run it themselves
66+- **DO NOT** test the server by starting it
77+- Use Bun's `routes` object in server config, not manual fetch handler routing
88+99+## Architecture Patterns
1010+1111+### Route Organization
1212+- Use separate route files in `src/routes/` directory
1313+- Export handler functions that accept `Request` and return `Response`
1414+- Import handlers in `src/index.ts` and wire them in the `routes` object
1515+- Use Bun's built-in routing: `routes: { "/path": handler }`
1616+- Example: `src/routes/auth.ts` contains authentication-related routes
1717+1818+### Project Structure
1919+```
2020+src/
2121+├── db.ts # Database setup and exports
2222+├── index.ts # Main server entry point
2323+├── routes/ # Route handlers (server-side)
2424+│ └── auth.ts # Authentication routes
2525+├── client/ # Client-side TypeScript modules
2626+│ └── login.ts # Login page logic
2727+├── html/ # HTML templates (Bun bundles them with script imports)
2828+└── migrations/ # SQL migrations
2929+```
3030+3131+### Client-Side Code
3232+- Extract JavaScript from HTML into separate TypeScript modules in `src/client/`
3333+- Import client modules into HTML with `<script type="module" src="../client/file.ts"></script>`
3434+- Bun will bundle the imports automatically
3535+- Static assets (images, favicons) in `public/` are served at root path
3636+- In HTML files: use paths relative to server root (e.g., `/logo.svg`, `/favicon.svg`) since Bun bundles HTML and resolves paths from server context
3737+3838+## Commands
3939+4040+(Add test/lint/build commands here as discovered)
4141+4242+## Code Style
4343+4444+- Use tabs for indentation
4545+- TypeScript with Bun runtime
4646+- Use SQLite with WAL mode
4747+- Route handlers: `(req: Request) => Response`
+1-1
README.md
···11# Indiko
2233-No that was not a typo the project's name actually is `indiko`! This is a small implementation of [IndieAuth](https://indieweb.org/How_to_set_up_web_sign-in_on_your_own_domain) running on cloudflare workers and serving as the authentication provider for my homelab / side projects.
33+No that was not a typo the project's name actually is `indiko`! This is a small implementation of [IndieAuth](https://indieweb.org/How_to_set_up_web_sign-in_on_your_own_domain) running on bun with sqlite and lit web components and serving as the authentication provider for my homelab / side projects.
4455The canonical repo for this is hosted on tangled over at [`dunkirk.sh/indiko`](https://tangled.org/@dunkirk.sh/indiko)
66
···11+-- Full schema for indiko
22+CREATE TABLE IF NOT EXISTS users (
33+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44+ username TEXT NOT NULL UNIQUE,
55+ name TEXT NOT NULL,
66+ email TEXT,
77+ photo TEXT,
88+ url TEXT,
99+ is_admin INTEGER NOT NULL DEFAULT 0,
1010+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
1111+);
1212+1313+CREATE TABLE IF NOT EXISTS credentials (
1414+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1515+ user_id INTEGER NOT NULL,
1616+ credential_id BLOB NOT NULL UNIQUE,
1717+ public_key BLOB NOT NULL,
1818+ counter INTEGER NOT NULL DEFAULT 0,
1919+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
2020+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
2121+);
2222+2323+CREATE TABLE IF NOT EXISTS sessions (
2424+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2525+ token TEXT NOT NULL UNIQUE,
2626+ user_id INTEGER NOT NULL,
2727+ expires_at INTEGER NOT NULL,
2828+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
2929+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
3030+);
3131+3232+CREATE TABLE IF NOT EXISTS challenges (
3333+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3434+ challenge TEXT NOT NULL UNIQUE,
3535+ username TEXT NOT NULL,
3636+ type TEXT NOT NULL CHECK(type IN ('registration', 'authentication')),
3737+ expires_at INTEGER NOT NULL,
3838+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
3939+);
4040+4141+CREATE TABLE IF NOT EXISTS invites (
4242+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4343+ code TEXT NOT NULL UNIQUE,
4444+ created_by INTEGER NOT NULL,
4545+ used INTEGER NOT NULL DEFAULT 0,
4646+ used_by INTEGER,
4747+ used_at INTEGER,
4848+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
4949+ FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
5050+ FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
5151+);
5252+5353+CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
5454+CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
5555+CREATE INDEX IF NOT EXISTS idx_challenges_challenge ON challenges(challenge);
5656+CREATE INDEX IF NOT EXISTS idx_challenges_expires_at ON challenges(expires_at);
5757+CREATE INDEX IF NOT EXISTS idx_credentials_user_id ON credentials(user_id);
5858+CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code);
+38
src/routes/api.ts
···11+import { db } from "../db";
22+33+export function hello(req: Request): Response {
44+ const authHeader = req.headers.get("Authorization");
55+66+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
77+ return Response.json({ error: "Unauthorized" }, { status: 401 });
88+ }
99+1010+ const token = authHeader.substring(7);
1111+1212+ // Look up session
1313+ const session = db
1414+ .query(
1515+ `SELECT s.expires_at, u.username, u.is_admin
1616+ FROM sessions s
1717+ JOIN users u ON s.user_id = u.id
1818+ WHERE s.token = ?`,
1919+ )
2020+ .get(token) as
2121+ | { expires_at: number; username: string; is_admin: number }
2222+ | undefined;
2323+2424+ if (!session) {
2525+ return Response.json({ error: "Invalid session" }, { status: 401 });
2626+ }
2727+2828+ const now = Math.floor(Date.now() / 1000);
2929+ if (session.expires_at < now) {
3030+ return Response.json({ error: "Session expired" }, { status: 401 });
3131+ }
3232+3333+ return Response.json({
3434+ message: `Hello ${session.username}! You're authenticated with passkeys.`,
3535+ username: session.username,
3636+ isAdmin: session.is_admin === 1,
3737+ });
3838+}
+390
src/routes/auth.ts
···11+import { db } from "../db";
22+import {
33+ generateRegistrationOptions,
44+ verifyRegistrationResponse,
55+ generateAuthenticationOptions,
66+ verifyAuthenticationResponse,
77+ type VerifiedRegistrationResponse,
88+ type VerifiedAuthenticationResponse,
99+ type PublicKeyCredentialCreationOptionsJSON,
1010+ type RegistrationResponseJSON,
1111+ type PublicKeyCredentialRequestOptionsJSON,
1212+ type AuthenticationResponseJSON,
1313+} from "@simplewebauthn/server";
1414+1515+const RP_NAME = "Indiko";
1616+1717+export function canRegister(req: Request): Response {
1818+ const userCount = db
1919+ .query("SELECT COUNT(*) as count FROM users")
2020+ .get() as { count: number };
2121+2222+ return Response.json({
2323+ canRegister: userCount.count === 0,
2424+ bootstrapMode: userCount.count === 0,
2525+ });
2626+}
2727+2828+export async function registerOptions(req: Request): Promise<Response> {
2929+ try {
3030+ const body = await req.json();
3131+ const { username } = body;
3232+3333+ if (!username || typeof username !== "string") {
3434+ return Response.json({ error: "Username required" }, { status: 400 });
3535+ }
3636+3737+ // Check if username already exists
3838+ const existingUser = db
3939+ .query("SELECT id FROM users WHERE username = ?")
4040+ .get(username);
4141+4242+ if (existingUser) {
4343+ return Response.json(
4444+ { error: "Username already taken" },
4545+ { status: 400 },
4646+ );
4747+ }
4848+4949+ // Check if this is bootstrap (first user)
5050+ const userCount = db
5151+ .query("SELECT COUNT(*) as count FROM users")
5252+ .get() as { count: number };
5353+5454+ const isBootstrap = userCount.count === 0;
5555+5656+ if (!isBootstrap) {
5757+ return Response.json({ error: "Registration closed" }, { status: 403 });
5858+ }
5959+6060+ // Generate WebAuthn registration options
6161+ const options: PublicKeyCredentialCreationOptionsJSON =
6262+ await generateRegistrationOptions({
6363+ rpName: RP_NAME,
6464+ rpID: process.env.RP_ID!,
6565+ userName: username,
6666+ userDisplayName: username,
6767+ attestationType: "none",
6868+ authenticatorSelection: {
6969+ residentKey: "required",
7070+ userVerification: "required",
7171+ authenticatorAttachment: "platform",
7272+ },
7373+ });
7474+7575+ // Store challenge
7676+ const expiresAt = Math.floor(Date.now() / 1000) + 300; // 5 minutes
7777+ db.query(
7878+ "INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'registration', ?)",
7979+ ).run(options.challenge, username, expiresAt);
8080+8181+ return Response.json(options);
8282+ } catch (error) {
8383+ console.error("Registration options error:", error);
8484+ return Response.json({ error: "Internal server error" }, { status: 500 });
8585+ }
8686+}
8787+8888+export async function registerVerify(req: Request): Promise<Response> {
8989+ try {
9090+ const body = await req.json();
9191+ const { username, response, challenge: expectedChallenge } = body as {
9292+ username: string;
9393+ response: RegistrationResponseJSON;
9494+ challenge?: string;
9595+ };
9696+9797+ if (!username || !response) {
9898+ return Response.json(
9999+ { error: "Username and response required" },
100100+ { status: 400 },
101101+ );
102102+ }
103103+104104+ // Check if username already exists
105105+ const existingUser = db
106106+ .query("SELECT id FROM users WHERE username = ?")
107107+ .get(username);
108108+109109+ if (existingUser) {
110110+ return Response.json(
111111+ { error: "Username already taken" },
112112+ { status: 400 },
113113+ );
114114+ }
115115+116116+ // Verify challenge exists and is valid
117117+ const challenge = db
118118+ .query(
119119+ "SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'registration'",
120120+ )
121121+ .get(expectedChallenge, username) as
122122+ | { challenge: string; expires_at: number }
123123+ | undefined;
124124+125125+ if (!challenge) {
126126+ return Response.json({ error: "Invalid challenge" }, { status: 400 });
127127+ }
128128+129129+ const now = Math.floor(Date.now() / 1000);
130130+ if (challenge.expires_at < now) {
131131+ return Response.json({ error: "Challenge expired" }, { status: 400 });
132132+ }
133133+134134+ // Check if this is bootstrap (first user)
135135+ const userCount = db
136136+ .query("SELECT COUNT(*) as count FROM users")
137137+ .get() as { count: number };
138138+139139+ const isBootstrap = userCount.count === 0;
140140+141141+ if (!isBootstrap) {
142142+ return Response.json({ error: "Registration closed" }, { status: 403 });
143143+ }
144144+145145+ // Verify WebAuthn response
146146+ let verification: VerifiedRegistrationResponse;
147147+ try {
148148+ verification = await verifyRegistrationResponse({
149149+ response,
150150+ expectedChallenge: challenge.challenge,
151151+ expectedOrigin: process.env.ORIGIN!,
152152+ expectedRPID: process.env.RP_ID!,
153153+ });
154154+ } catch (error) {
155155+ console.error("WebAuthn verification failed:", error);
156156+ return Response.json(
157157+ { error: "Verification failed" },
158158+ { status: 400 },
159159+ );
160160+ }
161161+162162+ if (!verification.verified || !verification.registrationInfo) {
163163+ return Response.json(
164164+ { error: "Verification failed" },
165165+ { status: 400 },
166166+ );
167167+ }
168168+169169+ const { credential } = verification.registrationInfo;
170170+171171+ // Create user (bootstrap is always admin)
172172+ const insertUser = db.query(
173173+ "INSERT INTO users (username, name, is_admin) VALUES (?, ?, 1) RETURNING id",
174174+ );
175175+ const user = insertUser.get(username, username) as {
176176+ id: number;
177177+ };
178178+179179+ // Store credential
180180+ // credential.id is a Uint8Array, convert to Buffer for storage
181181+ db.query(
182182+ "INSERT INTO credentials (user_id, credential_id, public_key, counter) VALUES (?, ?, ?, ?)",
183183+ ).run(
184184+ user.id,
185185+ Buffer.from(credential.id),
186186+ Buffer.from(credential.publicKey),
187187+ credential.counter,
188188+ );
189189+190190+ // Delete challenge
191191+ db.query("DELETE FROM challenges WHERE challenge = ?").run(
192192+ challenge.challenge,
193193+ );
194194+195195+ // Create session
196196+ const token = crypto.randomUUID();
197197+ const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24 hours
198198+ db.query(
199199+ "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
200200+ ).run(token, user.id, expiresAt);
201201+202202+ return Response.json({
203203+ token,
204204+ username,
205205+ isAdmin: true,
206206+ });
207207+ } catch (error) {
208208+ console.error("Registration verify error:", error);
209209+ return Response.json({ error: "Internal server error" }, { status: 500 });
210210+ }
211211+}
212212+213213+export async function loginOptions(req: Request): Promise<Response> {
214214+ try {
215215+ const body = await req.json();
216216+ const { username } = body;
217217+218218+ if (!username || typeof username !== "string") {
219219+ return Response.json({ error: "Username required" }, { status: 400 });
220220+ }
221221+222222+ // Check if user exists
223223+ const user = db
224224+ .query("SELECT id FROM users WHERE username = ?")
225225+ .get(username) as { id: number } | undefined;
226226+227227+ if (!user) {
228228+ return Response.json({ error: "User not found" }, { status: 404 });
229229+ }
230230+231231+ // Get user's credentials (just to verify they exist)
232232+ const credentials = db
233233+ .query("SELECT credential_id FROM credentials WHERE user_id = ?")
234234+ .all(user.id) as { credential_id: Buffer }[];
235235+236236+ if (credentials.length === 0) {
237237+ return Response.json(
238238+ { error: "No credentials found" },
239239+ { status: 404 },
240240+ );
241241+ }
242242+243243+ // Generate authentication options
244244+ // For discoverable credentials, omit allowCredentials to let password managers
245245+ // show all available passkeys for this RP ID
246246+ const options: PublicKeyCredentialRequestOptionsJSON =
247247+ await generateAuthenticationOptions({
248248+ rpID: process.env.RP_ID!,
249249+ userVerification: "required",
250250+ });
251251+252252+ // Store challenge
253253+ const expiresAt = Math.floor(Date.now() / 1000) + 300; // 5 minutes
254254+ db.query(
255255+ "INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'authentication', ?)",
256256+ ).run(options.challenge, username, expiresAt);
257257+258258+ return Response.json(options);
259259+ } catch (error) {
260260+ console.error("Login options error:", error);
261261+ return Response.json({ error: "Internal server error" }, { status: 500 });
262262+ }
263263+}
264264+265265+export async function loginVerify(req: Request): Promise<Response> {
266266+ try {
267267+ const body = await req.json();
268268+ const { username, response } = body as {
269269+ username: string;
270270+ response: AuthenticationResponseJSON;
271271+ };
272272+273273+ if (!username || !response) {
274274+ return Response.json(
275275+ { error: "Username and response required" },
276276+ { status: 400 },
277277+ );
278278+ }
279279+280280+ // Look up credential by ID
281281+ // Current database has credential_id stored as Buffer containing ASCII text of base64url string
282282+ // So we need to compare the string value, not decode it
283283+ const credentialIdString = response.id; // This is the base64url string like "rHvdOyMkR-6nxGBcDmtV4g"
284284+285285+ const credentialWithUser = db
286286+ .query(
287287+ "SELECT c.credential_id, c.public_key, c.counter, c.user_id, u.username FROM credentials c JOIN users u ON c.user_id = u.id WHERE c.credential_id = ?",
288288+ )
289289+ .get(Buffer.from(credentialIdString)) as
290290+ | { credential_id: Buffer; public_key: Buffer; counter: number; user_id: number; username: string }
291291+ | undefined;
292292+293293+ if (!credentialWithUser) {
294294+ return Response.json(
295295+ { error: "Credential not found" },
296296+ { status: 404 },
297297+ );
298298+ }
299299+300300+ // Verify the username matches (if provided)
301301+ if (username && credentialWithUser.username !== username) {
302302+ return Response.json(
303303+ { error: "Credential does not belong to this user" },
304304+ { status: 403 },
305305+ );
306306+ }
307307+308308+ const credential = {
309309+ credential_id: credentialWithUser.credential_id,
310310+ public_key: credentialWithUser.public_key,
311311+ counter: credentialWithUser.counter,
312312+ };
313313+ const user = { id: credentialWithUser.user_id };
314314+315315+ // Verify challenge exists and is valid
316316+ // Use the discovered username from the credential
317317+ const challenge = db
318318+ .query(
319319+ "SELECT challenge, expires_at FROM challenges WHERE username = ? AND type = 'authentication' ORDER BY created_at DESC LIMIT 1",
320320+ )
321321+ .get(credentialWithUser.username) as
322322+ | { challenge: string; expires_at: number }
323323+ | undefined;
324324+325325+ if (!challenge) {
326326+ return Response.json({ error: "Invalid challenge" }, { status: 400 });
327327+ }
328328+329329+ const now = Math.floor(Date.now() / 1000);
330330+ if (challenge.expires_at < now) {
331331+ return Response.json({ error: "Challenge expired" }, { status: 400 });
332332+ }
333333+334334+ // Verify authentication response
335335+ let verification: VerifiedAuthenticationResponse;
336336+ try {
337337+ verification = await verifyAuthenticationResponse({
338338+ response,
339339+ expectedChallenge: challenge.challenge,
340340+ expectedOrigin: process.env.ORIGIN!,
341341+ expectedRPID: process.env.RP_ID!,
342342+ credential: {
343343+ id: credential.credential_id,
344344+ publicKey: credential.public_key,
345345+ counter: credential.counter,
346346+ },
347347+ });
348348+ } catch (error) {
349349+ console.error("WebAuthn verification failed:", error);
350350+ return Response.json(
351351+ { error: "Verification failed" },
352352+ { status: 400 },
353353+ );
354354+ }
355355+356356+ if (!verification.verified) {
357357+ return Response.json(
358358+ { error: "Verification failed" },
359359+ { status: 400 },
360360+ );
361361+ }
362362+363363+ // Update credential counter
364364+ db.query("UPDATE credentials SET counter = ? WHERE user_id = ? AND credential_id = ?").run(
365365+ verification.authenticationInfo.newCounter,
366366+ user.id,
367367+ credential.credential_id,
368368+ );
369369+370370+ // Delete challenge
371371+ db.query("DELETE FROM challenges WHERE challenge = ?").run(
372372+ challenge.challenge,
373373+ );
374374+375375+ // Create session
376376+ const token = crypto.randomUUID();
377377+ const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24 hours
378378+ db.query(
379379+ "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
380380+ ).run(token, user.id, expiresAt);
381381+382382+ return Response.json({
383383+ token,
384384+ username,
385385+ });
386386+ } catch (error) {
387387+ console.error("Login verify error:", error);
388388+ return Response.json({ error: "Internal server error" }, { status: 500 });
389389+ }
390390+}