diff --git a/scripts/seed-dev.ts b/scripts/seed-dev.ts
index 57b4cd2..6d9f72e 100644
--- a/scripts/seed-dev.ts
+++ b/scripts/seed-dev.ts
@@ -47,12 +47,48 @@ const now = Math.floor(Date.now() / 1000);
// tacy is always the admin (index 0). Look up existing users by username
// so we can seed on top of a live db without --reset.
const users = [
- { username: "tacy", name: "Kieran Klukas", email: "kieran@dunkirk.sh", tier: "admin", status: "active" },
- { username: "alice", name: "Alice Carter", email: "alice@example.com", tier: "developer", status: "active" },
- { username: "bob", name: "Bob Nguyen", email: "bob@example.com", tier: "user", status: "active" },
- { username: "charlie", name: "Charlie Park", email: "charlie@example.com", tier: "user", status: "suspended" },
- { username: "dana", name: "Dana Williams", email: "dana@example.com", tier: "developer", status: "active" },
- { username: "eve", name: "Eve Martinez", email: null, tier: "user", status: "active" },
+ {
+ username: "tacy",
+ name: "Kieran Klukas",
+ email: "kieran@dunkirk.sh",
+ tier: "admin",
+ status: "active",
+ },
+ {
+ username: "alice",
+ name: "Alice Carter",
+ email: "alice@example.com",
+ tier: "developer",
+ status: "active",
+ },
+ {
+ username: "bob",
+ name: "Bob Nguyen",
+ email: "bob@example.com",
+ tier: "user",
+ status: "active",
+ },
+ {
+ username: "charlie",
+ name: "Charlie Park",
+ email: "charlie@example.com",
+ tier: "user",
+ status: "suspended",
+ },
+ {
+ username: "dana",
+ name: "Dana Williams",
+ email: "dana@example.com",
+ tier: "developer",
+ status: "active",
+ },
+ {
+ username: "eve",
+ name: "Eve Martinez",
+ email: null,
+ tier: "user",
+ status: "active",
+ },
];
const userIds: number[] = [];
@@ -85,7 +121,9 @@ for (const u of users) {
insertedUsers++;
}
}
-console.log(`${insertedUsers} new users inserted, ${users.length - insertedUsers} existing`);
+console.log(
+ `${insertedUsers} new users inserted, ${users.length - insertedUsers} existing`,
+);
// --- Credentials (fake passkeys) ---
// Only insert fake creds for newly-created users. Existing users keep their real passkeys.
@@ -128,7 +166,10 @@ if (!existingSession) {
console.log("inserted 1 dev session (token: dev-session-tacy)");
} else {
// Refresh expiry on existing session
- db.query("UPDATE sessions SET expires_at = ? WHERE token = ?").run(now + 86400, sessionToken);
+ db.query("UPDATE sessions SET expires_at = ? WHERE token = ?").run(
+ now + 86400,
+ sessionToken,
+ );
console.log("refreshed existing dev session (token: dev-session-tacy)");
}
@@ -355,7 +396,9 @@ let insertedTokens = 0;
for (const t of tokens) {
// Check if this user+client combo already has an active token
const existing = db
- .query("SELECT id FROM tokens WHERE user_id = ? AND client_id = ? AND revoked = 0")
+ .query(
+ "SELECT id FROM tokens WHERE user_id = ? AND client_id = ? AND revoked = 0",
+ )
.get(userIds[t.user], t.client) as { id: number } | undefined;
if (existing) continue;
diff --git a/src/client/admin-clients.ts b/src/client/admin-clients.ts
index 7a4bb00..bf67b5f 100644
--- a/src/client/admin-clients.ts
+++ b/src/client/admin-clients.ts
@@ -19,9 +19,9 @@ declare global {
}
import "./ds";
+import { apiFetch } from "./api";
import type IToast from "./ds/toast";
import { escapeHtml } from "./escape";
-import { apiFetch } from "./api";
const clientsList = document.getElementById("clientsList") as HTMLElement;
const createClientBtn = document.getElementById(
@@ -46,8 +46,7 @@ function showToast(message: string, type: "success" | "error" = "success") {
async function checkAuth() {
try {
- const response = await apiFetch("/api/hello", {
- });
+ const response = await apiFetch("/api/hello", {});
if (response.status === 401 || response.status === 403) {
window.location.href = "/login";
@@ -93,8 +92,7 @@ interface ClientUser {
async function loadClients() {
try {
- const response = await apiFetch("/api/admin/clients", {
- });
+ const response = await apiFetch("/api/admin/clients", {});
if (!response.ok) {
throw new Error("Failed to load clients");
@@ -217,8 +215,7 @@ window.toggleClient = async (clientId: string) => {
const response = await apiFetch(
`/api/admin/clients/${encodeURIComponent(clientId)}`,
{
- headers: {
- },
+ headers: {},
},
);
@@ -367,8 +364,7 @@ window.editClient = async (clientId: string) => {
const response = await apiFetch(
`/api/admin/clients/${encodeURIComponent(clientId)}`,
{
- headers: {
- },
+ headers: {},
},
);
@@ -426,8 +422,7 @@ window.deleteClient = async (clientId: string, event?: Event) => {
`/api/admin/clients/${encodeURIComponent(clientId)}`,
{
method: "DELETE",
- headers: {
- },
+ headers: {},
},
);
@@ -626,8 +621,7 @@ window.regenerateSecret = async (clientId: string, event?: Event) => {
`/api/admin/clients/${encodeURIComponent(clientId)}/secret`,
{
method: "POST",
- headers: {
- },
+ headers: {},
},
);
@@ -700,8 +694,7 @@ window.revokeUserPermission = async (
`/api/admin/apps/${encodeURIComponent(clientId)}/users/${encodeURIComponent(username)}`,
{
method: "DELETE",
- headers: {
- },
+ headers: {},
},
);
diff --git a/src/client/admin-invites.ts b/src/client/admin-invites.ts
index fb05f99..d6df8ea 100644
--- a/src/client/admin-invites.ts
+++ b/src/client/admin-invites.ts
@@ -1,6 +1,6 @@
import "./ds";
-import { escapeHtml } from "./escape";
import { apiFetch } from "./api";
+import { escapeHtml } from "./escape";
declare global {
interface Window {
@@ -20,8 +20,7 @@ const createInviteBtn = document.getElementById(
// Check auth and display user
async function checkAuth() {
try {
- const response = await apiFetch("/api/hello", {
- });
+ const response = await apiFetch("/api/hello", {});
if (response.status === 401 || response.status === 403) {
window.location.href = "/login";
@@ -56,8 +55,7 @@ async function createInvite() {
async function loadAppsForInvite() {
try {
- const response = await apiFetch("/api/admin/clients", {
- });
+ const response = await apiFetch("/api/admin/clients", {});
if (!response.ok) {
throw new Error("Failed to load apps");
@@ -83,10 +81,13 @@ async function loadAppsForInvite() {
name: string;
roles: string[];
}) => {
- const roleOptions =
+ const roleOptions =
app.roles.length > 0
? app.roles
- .map((role) => ``)
+ .map(
+ (role) =>
+ ``,
+ )
.join("")
: '';
@@ -247,8 +248,7 @@ window.closeCreateInviteModal = closeCreateInviteModal;
async function loadInvites() {
try {
- const response = await apiFetch("/api/invites", {
- });
+ const response = await apiFetch("/api/invites", {});
if (!response.ok) {
throw new Error("Failed to load invites");
@@ -299,7 +299,7 @@ async function loadInvites() {
? `Expires: ${new Date(invite.expiresAt * 1000).toLocaleString()}`
: "No expiry";
- const roleInfo =
+ const roleInfo =
invite.appRoles.length > 0
? `
app roles
@@ -448,8 +448,7 @@ let currentEditInviteId: number | null = null;
// Make editInvite globally available for onclick handler
window.editInvite = async (inviteId: number) => {
try {
- const response = await apiFetch("/api/invites", {
- });
+ const response = await apiFetch("/api/invites", {});
if (!response.ok) {
throw new Error("Failed to load invite");
@@ -584,8 +583,7 @@ window.deleteInvite = async (inviteId: number, event?: Event) => {
try {
const response = await apiFetch(`/api/invites/${inviteId}`, {
method: "DELETE",
- headers: {
- },
+ headers: {},
});
if (!response.ok) {
diff --git a/src/client/admin.ts b/src/client/admin.ts
index 070c41b..2e5aaa2 100644
--- a/src/client/admin.ts
+++ b/src/client/admin.ts
@@ -1,6 +1,6 @@
import "./ds";
-import { escapeHtml } from "./escape";
import { apiFetch } from "./api";
+import { escapeHtml } from "./escape";
const usersList = document.getElementById("usersList") as HTMLElement;
let currentUserId: number;
@@ -8,8 +8,7 @@ let currentUserId: number;
// Check auth and display user
async function checkAuth() {
try {
- const response = await apiFetch("/api/hello", {
- });
+ const response = await apiFetch("/api/hello", {});
if (response.status === 401 || response.status === 403) {
window.location.href = "/login";
@@ -35,8 +34,7 @@ async function checkAuth() {
async function loadUsers() {
try {
- const response = await apiFetch("/api/users", {
- });
+ const response = await apiFetch("/api/users", {});
if (!response.ok) {
throw new Error("Failed to load users");
@@ -141,8 +139,7 @@ async function handleUserAction(e: Event) {
const response = await apiFetch(endpoint, {
method,
- headers: {
- },
+ headers: {},
});
if (!response.ok) {
diff --git a/src/client/index.ts b/src/client/index.ts
index 4048145..bf8905c 100644
--- a/src/client/index.ts
+++ b/src/client/index.ts
@@ -1,10 +1,9 @@
import { startRegistration } from "@simplewebauthn/browser";
import "./ds";
+import { apiFetch } from "./api";
import type IButton from "./ds/button";
import type IToast from "./ds/toast";
import { escapeHtml } from "./escape";
-import { apiFetch } from "./api";
-
let welcome!: HTMLElement;
let subtitle!: HTMLElement;
@@ -26,7 +25,6 @@ let dangerZone!: HTMLElement;
let isAdmin = false;
-
function $(id: string): HTMLElement {
const el = document.getElementById(id);
if (!el) {
@@ -117,8 +115,7 @@ function updateAvatarPreview(photo: string | null, username: string) {
// Check auth and display user
async function checkAuth() {
try {
- const response = await apiFetch("/api/hello", {
- });
+ const response = await apiFetch("/api/hello", {});
if (response.status === 401 || response.status === 403) {
window.location.href = "/login";
@@ -142,8 +139,7 @@ async function checkAuth() {
async function loadProfile() {
try {
- const response = await apiFetch("/api/profile", {
- });
+ const response = await apiFetch("/api/profile", {});
if (!response.ok) {
throw new Error("Failed to load profile");
@@ -178,8 +174,7 @@ async function loadProfile() {
async function loadRecentApps() {
try {
- const response = await apiFetch("/api/apps", {
- });
+ const response = await apiFetch("/api/apps", {});
if (!response.ok) {
throw new Error("Failed to load apps");
@@ -282,7 +277,7 @@ async function onDeleteAccount() {
try {
const response = await apiFetch("/api/profile", {
- method: "DELETE"
+ method: "DELETE",
});
if (!response.ok) {
@@ -304,8 +299,7 @@ async function onDeleteAccount() {
async function loadPasskeys() {
try {
- const response = await apiFetch("/api/passkeys", {
- });
+ const response = await apiFetch("/api/passkeys", {});
if (!response.ok) {
throw new Error("Failed to load passkeys");
@@ -376,7 +370,7 @@ async function onPasskeyRemove(e: CustomEvent<{ id: string }>) {
try {
const response = await apiFetch(`/api/passkeys/${id}`, {
- method: "DELETE"
+ method: "DELETE",
});
if (!response.ok) {
@@ -444,7 +438,7 @@ async function onAddPasskey() {
try {
// Get registration options
const optionsRes = await apiFetch("/api/passkeys/add/options", {
- method: "POST"
+ method: "POST",
});
if (!optionsRes.ok) {
diff --git a/src/index.ts b/src/index.ts
index cb0d20d..19a1f2a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -106,7 +106,6 @@ import {
console.log(`[Startup] Environment validated (${nodeEnv} mode)`);
})();
-
const server = Bun.serve({
port: env.PORT ? Number.parseInt(env.PORT, 10) : 3000,
routes: {
diff --git a/src/lib/oauth/pages.ts b/src/lib/oauth/pages.ts
index fa1a2f0..caa97d2 100644
--- a/src/lib/oauth/pages.ts
+++ b/src/lib/oauth/pages.ts
@@ -418,7 +418,10 @@ export function consentPage(opts: ConsentPageOptions): Response {
`
: "";
const resourceHidden = resources
- .map((r) => ``)
+ .map(
+ (r) =>
+ ``,
+ )
.join("");
const scopeItems = opts.scopes
diff --git a/src/lib/oauth/resource.ts b/src/lib/oauth/resource.ts
index da32f74..138aef6 100644
--- a/src/lib/oauth/resource.ts
+++ b/src/lib/oauth/resource.ts
@@ -21,7 +21,8 @@ export function validateResources(values: string[]): string[] | null {
} catch {
return null;
}
- if ((u.protocol !== "https:" && u.protocol !== "http:") || u.hash) return null;
+ if ((u.protocol !== "https:" && u.protocol !== "http:") || u.hash)
+ return null;
const norm = u.origin + u.pathname.replace(/\/+$/, "");
if (!out.includes(norm)) out.push(norm);
}
@@ -51,7 +52,9 @@ export interface ResourceInfo {
* an optional `logo_uri`). Best-effort + SSRF-safe: a resource with no PRM, an
* unreachable one, or a bad logo URL just falls back to its hostname.
*/
-export async function resourceDisplay(resources: string[]): Promise {
+export async function resourceDisplay(
+ resources: string[],
+): Promise {
return Promise.all(resources.map(fetchResourceInfo));
}
@@ -65,11 +68,18 @@ async function fetchResourceInfo(id: string): Promise {
const info: ResourceInfo = { id, name: host, host };
try {
const prmUrl = `${id.replace(/\/+$/, "")}/.well-known/oauth-protected-resource`;
- const res = await safeFetch(prmUrl, { timeout: 3000, headers: { accept: "application/json" } });
+ const res = await safeFetch(prmUrl, {
+ timeout: 3000,
+ headers: { accept: "application/json" },
+ });
if (!res.success) return info;
- const prm = (await res.data.json()) as { resource_name?: string; logo_uri?: string };
+ const prm = (await res.data.json()) as {
+ resource_name?: string;
+ logo_uri?: string;
+ };
if (prm.resource_name) info.name = prm.resource_name;
- if (prm.logo_uri && validateExternalURL(prm.logo_uri).safe) info.logo = prm.logo_uri;
+ if (prm.logo_uri && validateExternalURL(prm.logo_uri).safe)
+ info.logo = prm.logo_uri;
} catch {
/* best-effort: hostname fallback already set */
}
diff --git a/src/lib/ssrf-safe-fetch.ts b/src/lib/ssrf-safe-fetch.ts
index 3ac0e15..7db3c68 100644
--- a/src/lib/ssrf-safe-fetch.ts
+++ b/src/lib/ssrf-safe-fetch.ts
@@ -79,7 +79,9 @@ function isPrivateIP(ip: string): boolean {
}
// IPv4-mapped IPv6 in hex form (::ffff:a01:101 = ::ffff:10.1.1.1)
- const hexMappedMatch = ipv6.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
+ const hexMappedMatch = ipv6.match(
+ /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i,
+ );
if (hexMappedMatch?.[1] && hexMappedMatch[2]) {
const hi = Number.parseInt(hexMappedMatch[1], 16);
const lo = Number.parseInt(hexMappedMatch[2], 16);
@@ -274,13 +276,19 @@ async function resolveAndValidateIPs(hostname: string): Promise<{
// Literal IPs don't need resolution — validate directly.
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
return isPrivateIP(hostname)
- ? { safe: false, error: "Cannot fetch from private/reserved IP addresses" }
+ ? {
+ safe: false,
+ error: "Cannot fetch from private/reserved IP addresses",
+ }
: { safe: true };
}
if (hostname.startsWith("[") && hostname.endsWith("]")) {
return isPrivateIP(hostname)
- ? { safe: false, error: "Cannot fetch from private/reserved IP addresses" }
+ ? {
+ safe: false,
+ error: "Cannot fetch from private/reserved IP addresses",
+ }
: { safe: true };
}
diff --git a/src/routes/auth.ts b/src/routes/auth.ts
index e9eb3a8..c40fa76 100644
--- a/src/routes/auth.ts
+++ b/src/routes/auth.ts
@@ -46,10 +46,7 @@ export async function registerOptions(req: Request): Promise {
return Response.json({ error: "Username required" }, { status: 400 });
}
- if (
- username.length > 128 ||
- !/^[A-Za-z0-9._@-]+$/.test(username)
- ) {
+ if (username.length > 128 || !/^[A-Za-z0-9._@-]+$/.test(username)) {
return Response.json(
{ error: "Invalid username format" },
{ status: 400 },
@@ -184,10 +181,7 @@ export async function registerVerify(req: Request): Promise {
);
}
- if (
- username.length > 128 ||
- !/^[A-Za-z0-9._@-]+$/.test(username)
- ) {
+ if (username.length > 128 || !/^[A-Za-z0-9._@-]+$/.test(username)) {
return Response.json(
{ error: "Invalid username format" },
{ status: 400 },
@@ -697,11 +691,7 @@ export async function loginVerify(req: Request): Promise {
// Update credential counter
db.query(
"UPDATE credentials SET counter = ? WHERE user_id = ? AND credential_id = ?",
- ).run(
- newCounter,
- user.id,
- credential.credential_id,
- );
+ ).run(newCounter, user.id, credential.credential_id);
// Create session
const token = crypto.randomUUID();
diff --git a/src/routes/oauth/authorize.ts b/src/routes/oauth/authorize.ts
index 297a27b..34de084 100644
--- a/src/routes/oauth/authorize.ts
+++ b/src/routes/oauth/authorize.ts
@@ -2,13 +2,13 @@ import crypto from "node:crypto";
import { db } from "../../db";
import { ensureApp } from "../../lib/oauth/client-metadata";
import { consentPage, errorPage, escapeHtml } from "../../lib/oauth/pages";
-import { canonicalizeURL } from "../../lib/oauth/urls";
import {
- resourceDisplay,
type ResourceInfo,
+ resourceDisplay,
resourcesToStored,
validateResources,
} from "../../lib/oauth/resource";
+import { canonicalizeURL } from "../../lib/oauth/urls";
import { getCsrfToken, getUserFromCookie } from "../../lib/session";
import { token } from "./token";
@@ -288,7 +288,9 @@ async function showConsentScreen(
// Resolve the requested resources to friendly name + icon (via their PRM) so
// the consent screen shows WHAT kloe is being granted access to, not a URL.
- const resourceInfos: ResourceInfo[] = resources.length ? await resourceDisplay(resources) : [];
+ const resourceInfos: ResourceInfo[] = resources.length
+ ? await resourceDisplay(resources)
+ : [];
return consentPage({
username: user.username,
@@ -408,7 +410,9 @@ export async function authorizePost(req: Request): Promise {
// Resource indicators carried through the consent form (hidden fields).
// Re-validate — the POST body is attacker-controllable.
- const approvedResources = validateResources(formData.getAll("resource") as string[]);
+ const approvedResources = validateResources(
+ formData.getAll("resource") as string[],
+ );
if (approvedResources === null) {
return new Response("invalid_target", { status: 400 });
}
diff --git a/src/routes/oauth/device.ts b/src/routes/oauth/device.ts
index d0e9aef..118597e 100644
--- a/src/routes/oauth/device.ts
+++ b/src/routes/oauth/device.ts
@@ -75,7 +75,11 @@ export async function deviceAuthorization(req: Request): Promise {
// RFC 8707 resource indicator (device flow carries it from the start).
const resources = validateResources(rawResource ? [rawResource] : []);
if (resources === null) {
- return oauthError(400, "invalid_target", "resource must be an absolute URI without a fragment");
+ return oauthError(
+ 400,
+ "invalid_target",
+ "resource must be an absolute URI without a fragment",
+ );
}
const resourceStored = resourcesToStored(resources);
diff --git a/src/routes/oauth/token.ts b/src/routes/oauth/token.ts
index 636bf15..5c36368 100644
--- a/src/routes/oauth/token.ts
+++ b/src/routes/oauth/token.ts
@@ -354,7 +354,9 @@ async function handleDeviceCodeGrant(
db.query("DELETE FROM device_codes WHERE id = ?").run(deviceCode.id);
const user = db
- .query("SELECT username, name, email, photo, url, status FROM users WHERE id = ?")
+ .query(
+ "SELECT username, name, email, photo, url, status FROM users WHERE id = ?",
+ )
.get(deviceCode.user_id) as
| {
username: string;
@@ -590,7 +592,9 @@ async function handleAuthorizationCodeGrant(
}
const user = db
- .query("SELECT username, name, email, photo, url, status FROM users WHERE id = ?")
+ .query(
+ "SELECT username, name, email, photo, url, status FROM users WHERE id = ?",
+ )
.get(authcode.user_id) as
| {
username: string;
@@ -788,8 +792,15 @@ export async function tokenIntrospect(req: Request): Promise {
// RFC 8707 / 7662: echo the token's audience so a resource server can
// confirm the token was minted for it. One resource → a string, several →
// an array; omitted entirely when the token is unscoped.
- const resources = tokenData.resource ? tokenData.resource.split(" ").filter(Boolean) : [];
- const aud = resources.length === 1 ? resources[0] : resources.length > 1 ? resources : undefined;
+ const resources = tokenData.resource
+ ? tokenData.resource.split(" ").filter(Boolean)
+ : [];
+ const aud =
+ resources.length === 1
+ ? resources[0]
+ : resources.length > 1
+ ? resources
+ : undefined;
return Response.json({
active: true,
diff --git a/test/cleanup.test.ts b/test/cleanup.test.ts
index f5b50da..5166705 100644
--- a/test/cleanup.test.ts
+++ b/test/cleanup.test.ts
@@ -127,7 +127,15 @@ describe("sweepExpiredRecords", () => {
).run("sess-dead", userId, now - 10);
db.query(
"INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used) VALUES (?, ?, ?, ?, ?, ?, ?, 0)",
- ).run("code-dead", userId, CLIENT_ID, `${CLIENT_ID}callback`, "[]", "c", now - 10);
+ ).run(
+ "code-dead",
+ userId,
+ CLIENT_ID,
+ `${CLIENT_ID}callback`,
+ "[]",
+ "c",
+ now - 10,
+ );
db.query(
"INSERT INTO device_codes (device_code, user_code, client_id, scope, expires_at) VALUES (?, ?, ?, ?, ?)",
).run("dc-dead", "UC-DEAD", CLIENT_ID, "profile", now - 10);