···11+-- Enhance invites table with usage limits, expiry, and app role assignments
22+-- Note: SQLite doesn't support DROP COLUMN, so we keep old columns for backward compatibility
33+-- But we'll use the new columns going forward
44+55+-- Add new columns to invites table
66+ALTER TABLE invites ADD COLUMN max_uses INTEGER DEFAULT 1;
77+ALTER TABLE invites ADD COLUMN current_uses INTEGER NOT NULL DEFAULT 0;
88+ALTER TABLE invites ADD COLUMN expires_at INTEGER;
99+ALTER TABLE invites ADD COLUMN note TEXT;
1010+1111+-- Create invite_roles table for app-specific role assignments
1212+CREATE TABLE IF NOT EXISTS invite_roles (
1313+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1414+ invite_id INTEGER NOT NULL,
1515+ app_id INTEGER NOT NULL,
1616+ role TEXT NOT NULL,
1717+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
1818+ FOREIGN KEY (invite_id) REFERENCES invites(id) ON DELETE CASCADE,
1919+ FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE,
2020+ UNIQUE(invite_id, app_id)
2121+);
2222+2323+CREATE INDEX IF NOT EXISTS idx_invite_roles_invite_id ON invite_roles(invite_id);
2424+CREATE INDEX IF NOT EXISTS idx_invite_roles_app_id ON invite_roles(app_id);
2525+2626+-- Create invite_uses table to track each use (supports multi-use invites)
2727+CREATE TABLE IF NOT EXISTS invite_uses (
2828+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2929+ invite_id INTEGER NOT NULL,
3030+ user_id INTEGER NOT NULL,
3131+ used_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
3232+ FOREIGN KEY (invite_id) REFERENCES invites(id) ON DELETE CASCADE,
3333+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
3434+);
3535+3636+CREATE INDEX IF NOT EXISTS idx_invite_uses_invite_id ON invite_uses(invite_id);
3737+CREATE INDEX IF NOT EXISTS idx_invite_uses_user_id ON invite_uses(user_id);
3838+3939+-- Migrate existing single-use invites to new structure
4040+-- For invites that have been used, set current_uses = 1 and max_uses = 1
4141+UPDATE invites SET current_uses = 1, max_uses = 1 WHERE used = 1;
4242+4343+-- For unused invites, set max_uses = 1 and current_uses = 0
4444+UPDATE invites SET max_uses = 1, current_uses = 0 WHERE used = 0;
4545+4646+-- Migrate old invite uses to new invite_uses table
4747+INSERT INTO invite_uses (invite_id, user_id, used_at)
4848+SELECT id, used_by, used_at FROM invites WHERE used = 1 AND used_by IS NOT NULL AND used_at IS NOT NULL;
+5
src/migrations/005_add_app_roles.sql
···11+-- Add available_roles column to apps table (JSON array of role names)
22+ALTER TABLE apps ADD COLUMN available_roles TEXT;
33+44+-- Add default_role column to apps table
55+ALTER TABLE apps ADD COLUMN default_role TEXT;
+43-10
src/routes/auth.ts
···61616262 // Validate invite code
6363 const invite = db
6464- .query("SELECT id, used FROM invites WHERE code = ?")
6565- .get(inviteCode) as { id: number; used: number } | undefined;
6464+ .query("SELECT id, max_uses, current_uses, expires_at FROM invites WHERE code = ?")
6565+ .get(inviteCode) as { id: number; max_uses: number; current_uses: number; expires_at: number | null } | undefined;
66666767 if (!invite) {
6868 return Response.json({ error: "Invalid invite code" }, { status: 403 });
6969 }
70707171- if (invite.used === 1) {
7272- return Response.json({ error: "Invite code already used" }, { status: 403 });
7171+ const now = Math.floor(Date.now() / 1000);
7272+ if (invite.expires_at && invite.expires_at < now) {
7373+ return Response.json({ error: "Invite code expired" }, { status: 403 });
7474+ }
7575+7676+ if (invite.current_uses >= invite.max_uses) {
7777+ return Response.json({ error: "Invite code fully used" }, { status: 403 });
7378 }
7479 }
7580···157162158163 // If not bootstrap, validate invite code
159164 let inviteId: number | undefined;
165165+ let inviteRoles: Array<{ app_id: number; role: string }> = [];
160166 if (!isBootstrap) {
161167 if (!inviteCode) {
162168 return Response.json({ error: "Invite code required" }, { status: 403 });
163169 }
164170165171 const invite = db
166166- .query("SELECT id, used FROM invites WHERE code = ?")
167167- .get(inviteCode) as { id: number; used: number } | undefined;
172172+ .query("SELECT id, max_uses, current_uses, expires_at FROM invites WHERE code = ?")
173173+ .get(inviteCode) as { id: number; max_uses: number; current_uses: number; expires_at: number | null } | undefined;
168174169175 if (!invite) {
170176 return Response.json({ error: "Invalid invite code" }, { status: 403 });
171177 }
172178173173- if (invite.used === 1) {
174174- return Response.json({ error: "Invite code already used" }, { status: 403 });
179179+ const now = Math.floor(Date.now() / 1000);
180180+ if (invite.expires_at && invite.expires_at < now) {
181181+ return Response.json({ error: "Invite code expired" }, { status: 403 });
182182+ }
183183+184184+ if (invite.current_uses >= invite.max_uses) {
185185+ return Response.json({ error: "Invite code fully used" }, { status: 403 });
175186 }
176187177188 inviteId = invite.id;
189189+190190+ // Get app role assignments for this invite
191191+ inviteRoles = db.query(
192192+ "SELECT app_id, role FROM invite_roles WHERE invite_id = ?"
193193+ ).all(inviteId) as Array<{ app_id: number; role: string }>;
178194 }
179195180196 // Verify WebAuthn response
···225241 // Mark invite as used if applicable
226242 if (inviteId) {
227243 const usedAt = Math.floor(Date.now() / 1000);
244244+245245+ // Increment invite usage counter
228246 db.query(
229229- "UPDATE invites SET used = 1, used_by = ?, used_at = ? WHERE id = ?",
230230- ).run(user.id, usedAt, inviteId);
247247+ "UPDATE invites SET current_uses = current_uses + 1 WHERE id = ?",
248248+ ).run(inviteId);
249249+250250+ // Record this invite use
251251+ db.query(
252252+ "INSERT INTO invite_uses (invite_id, user_id, used_at) VALUES (?, ?, ?)",
253253+ ).run(inviteId, user.id, usedAt);
254254+255255+ // Assign app roles to the new user
256256+ if (inviteRoles.length > 0) {
257257+ const insertPermission = db.query(
258258+ "INSERT INTO permissions (user_id, app_id, role) VALUES (?, ?, ?)",
259259+ );
260260+ for (const { app_id, role } of inviteRoles) {
261261+ insertPermission.run(user.id, app_id, role);
262262+ }
263263+ }
231264 }
232265233266 // Delete challenge
+81-7
src/routes/clients.ts
···8282 last_used: number;
8383 }>;
84848585+ // Get distinct roles for each app
8686+ const appRoles = db
8787+ .query(
8888+ `SELECT a.id as app_id, p.role
8989+ FROM permissions p
9090+ JOIN apps a ON p.client_id = a.client_id
9191+ WHERE p.role IS NOT NULL AND p.role != ''
9292+ GROUP BY a.id, p.role
9393+ ORDER BY a.id, p.role`,
9494+ )
9595+ .all() as Array<{ app_id: number; role: string }>;
9696+9797+ // Group roles by app_id
9898+ const rolesByApp = new Map<number, string[]>();
9999+ for (const { app_id, role } of appRoles) {
100100+ if (!rolesByApp.has(app_id)) {
101101+ rolesByApp.set(app_id, []);
102102+ }
103103+ rolesByApp.get(app_id)!.push(role);
104104+ }
105105+85106 return Response.json({
86107 clients: clients.map((c) => ({
87108 id: c.id,
···93114 isPreregistered: c.is_preregistered === 1,
94115 firstSeen: c.first_seen,
95116 lastUsed: c.last_used,
117117+ roles: rolesByApp.get(c.id) || [],
96118 })),
97119 });
98120}
···109131110132 try {
111133 const body = await req.json();
112112- const { clientId, name, logoUrl, description, redirectUris } = body;
134134+ const { clientId, name, logoUrl, description, redirectUris, availableRoles, defaultRole } = body;
113135114136 if (!clientId || typeof clientId !== "string") {
115137 return Response.json({ error: "Client ID is required" }, { status: 400 });
···145167 const clientSecret = generateClientSecret();
146168 const clientSecretHash = hashSecret(clientSecret);
147169170170+ // Validate roles if provided
171171+ let rolesArray: string[] = [];
172172+ if (availableRoles) {
173173+ if (!Array.isArray(availableRoles)) {
174174+ return Response.json({ error: "Available roles must be an array" }, { status: 400 });
175175+ }
176176+ rolesArray = availableRoles.filter((r: unknown) => typeof r === 'string' && r.trim());
177177+ }
178178+179179+ // Validate default role is in available roles
180180+ if (defaultRole && rolesArray.length > 0 && !rolesArray.includes(defaultRole)) {
181181+ return Response.json({ error: "Default role must be one of the available roles" }, { status: 400 });
182182+ }
183183+148184 const result = db
149185 .query(
150150- `INSERT INTO apps (client_id, name, logo_url, description, redirect_uris, is_preregistered, client_secret_hash, first_seen, last_used)
151151- VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`,
186186+ `INSERT INTO apps (client_id, name, logo_url, description, redirect_uris, is_preregistered, client_secret_hash, available_roles, default_role, first_seen, last_used)
187187+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)`,
152188 )
153189 .run(
154190 clientId,
···157193 description || null,
158194 JSON.stringify(redirectUris),
159195 clientSecretHash,
196196+ rolesArray.length > 0 ? JSON.stringify(rolesArray) : null,
197197+ defaultRole || null,
160198 Math.floor(Date.now() / 1000),
161199 Math.floor(Date.now() / 1000),
162200 );
···200238 description,
201239 redirect_uris,
202240 is_preregistered,
241241+ available_roles,
242242+ default_role,
203243 first_seen,
204244 last_used
205245 FROM apps
···214254 description: string | null;
215255 redirect_uris: string;
216256 is_preregistered: number;
257257+ available_roles: string | null;
258258+ default_role: string | null;
217259 first_seen: number;
218260 last_used: number;
219261 }
···255297 description: client.description,
256298 redirectUris: JSON.parse(client.redirect_uris) as string[],
257299 isPreregistered: client.is_preregistered === 1,
300300+ availableRoles: client.available_roles ? JSON.parse(client.available_roles) as string[] : null,
301301+ defaultRole: client.default_role,
258302 firstSeen: client.first_seen,
259303 lastUsed: client.last_used,
260304 },
···281325282326 try {
283327 const body = await req.json();
284284- const { name, logoUrl, description, redirectUris } = body;
328328+ const { name, logoUrl, description, redirectUris, availableRoles, defaultRole } = body;
285329286330 const existing = db
287331 .query("SELECT id, is_preregistered FROM apps WHERE client_id = ?")
···305349 }
306350 }
307351352352+ // Validate roles if provided
353353+ let rolesArray: string[] | null = null;
354354+ if (availableRoles !== undefined) {
355355+ if (availableRoles === null) {
356356+ // Explicitly disable roles
357357+ rolesArray = null;
358358+ } else if (Array.isArray(availableRoles)) {
359359+ rolesArray = availableRoles.filter((r: unknown) => typeof r === 'string' && r.trim());
360360+ } else {
361361+ return Response.json({ error: "Available roles must be an array or null" }, { status: 400 });
362362+ }
363363+ }
364364+365365+ // Validate default role is in available roles
366366+ if (defaultRole && rolesArray && rolesArray.length > 0 && !rolesArray.includes(defaultRole)) {
367367+ return Response.json({ error: "Default role must be one of the available roles" }, { status: 400 });
368368+ }
369369+308370 db.query(
309371 `UPDATE apps
310310- SET name = ?, logo_url = ?, description = ?, redirect_uris = ?
372372+ SET name = ?, logo_url = ?, description = ?, redirect_uris = ?, available_roles = ?, default_role = ?
311373 WHERE client_id = ?`,
312374 ).run(
313375 name || null,
314376 logoUrl || null,
315377 description || null,
316378 redirectUris ? JSON.stringify(redirectUris) : null,
379379+ rolesArray !== null ? (rolesArray.length > 0 ? JSON.stringify(rolesArray) : null) : null,
380380+ defaultRole || null,
317381 clientId,
318382 );
319383···374438 }
375439376440 const client = db
377377- .query("SELECT id FROM apps WHERE client_id = ?")
378378- .get(clientId);
441441+ .query("SELECT id, available_roles FROM apps WHERE client_id = ?")
442442+ .get(clientId) as { id: number; available_roles: string | null } | undefined;
379443380444 if (!client) {
381445 return Response.json({ error: "Client not found" }, { status: 404 });
446446+ }
447447+448448+ // Validate role against available roles if defined
449449+ if (role && client.available_roles) {
450450+ const availableRoles = JSON.parse(client.available_roles) as string[];
451451+ if (!availableRoles.includes(role)) {
452452+ return Response.json({
453453+ error: `Role must be one of: ${availableRoles.join(', ')}`
454454+ }, { status: 400 });
455455+ }
382456 }
383457384458 const permission = db