v3: added index default tab selection setting, fixed notification pages not loading, reduced invite token length, global player state survives page reloads, many visual fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 44s

This commit is contained in:
khannurien
2026-06-27 19:44:35 +00:00
parent 88f1eb3d03
commit c29f45fbc8
25 changed files with 733 additions and 278 deletions

View File

@@ -3,9 +3,7 @@ import { jwtVerify, SignJWT } from "@panva/jose";
import {
type AuthPayload,
InvitePayload,
isAuthPayload,
isInvitePayload,
isPasswordResetPayload,
type PasswordResetPayload,
} from "../model/interfaces.ts";
@@ -18,28 +16,6 @@ if (!JWT_SECRET) {
}
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
// ── Invite tokens ─────────────────────────────────────────────────────────────
export async function createInviteToken(inviterId: string): Promise<string> {
return await new SignJWT({ purpose: "invite", inviterId })
.setProtectedHeader({ alg: "HS256" })
.setJti(crypto.randomUUID())
.setExpirationTime("7d")
.sign(JWT_KEY);
}
export async function verifyInviteToken(
token: string,
): Promise<InvitePayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_KEY);
if (!isInvitePayload(payload)) return null;
return payload as InvitePayload;
} catch {
return null;
}
}
// ── Password reset tokens ─────────────────────────────────────────────────────
export async function createPasswordResetToken(

View File

@@ -165,19 +165,6 @@ export function isAuthPayload(obj: unknown): obj is AuthPayload {
"exp" in obj && typeof obj.exp === "number";
}
export interface InvitePayload {
purpose: "invite";
inviterId: string;
exp: number;
}
export function isInvitePayload(obj: unknown): obj is InvitePayload {
return !!obj && typeof obj === "object" &&
"purpose" in obj && (obj as Record<string, unknown>).purpose === "invite" &&
"inviterId" in obj &&
typeof (obj as Record<string, unknown>).inviterId === "string";
}
export interface PasswordResetPayload {
purpose: "password-reset";
userId: string;

View File

@@ -6,19 +6,19 @@ import { createInvite, validateInvite } from "../services/invite-service.ts";
const router = new Router({ prefix: "/api/invites" });
// Create a new invite link (any authenticated user)
router.post("/", authMiddleware, async (ctx: AuthContext) => {
router.post("/", authMiddleware, (ctx: AuthContext) => {
if (!ctx.state.user) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
}
const token = await createInvite(ctx.state.user.userId);
const token = createInvite(ctx.state.user.userId);
ctx.response.status = 201;
ctx.response.body = { success: true, data: { token } };
});
// Validate an invite token (used by the register page before showing the form)
router.get("/:token", async (ctx) => {
router.get("/:token", (ctx) => {
try {
await validateInvite(ctx.params.token);
validateInvite(ctx.params.token);
ctx.response.body = { success: true, data: null };
} catch {
throw new APIException(

View File

@@ -58,7 +58,7 @@ router.post("/register", async (ctx) => {
}
// Validate invite — throws 404/409 if bad
const inviterId = await validateInvite(body.inviteToken);
const inviterId = validateInvite(body.inviteToken);
const user = await createUser(body, inviterId);

View File

@@ -1,9 +1,15 @@
import { randomBytes } from "node:crypto";
import { APIErrorCode, APIException } from "../model/interfaces.ts";
import { db, isInviteRow } from "../model/db.ts";
import { createInviteToken, verifyInviteToken } from "../lib/jwt.ts";
import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts";
export async function createInvite(inviterId: string): Promise<string> {
const token = await createInviteToken(inviterId);
/** Short, opaque, URL-safe invite token (128 bits of entropy → 22 chars). */
function generateInviteToken(): string {
return randomBytes(16).toString("base64url");
}
export function createInvite(inviterId: string): string {
const token = generateInviteToken();
db.prepare(
`INSERT INTO invites (token, inviter_id, created_at) VALUES (?, ?, ?);`,
).run(token, inviterId, new Date().toISOString());
@@ -11,25 +17,25 @@ export async function createInvite(inviterId: string): Promise<string> {
}
/**
* Verifies the JWT signature + expiry and checks the token exists and has not
* been used. Returns the inviterId on success; throws APIException otherwise.
* Looks the token up, checks it has not been used and has not expired (same
* 7-day window as the startup cleanup). Returns the inviterId on success;
* throws APIException otherwise.
*
* Tokens are opaque random strings stored as the row's primary key. Invites
* created before this scheme (full JWTs) are still stored verbatim in the
* token column, so they continue to validate by direct lookup.
*/
export async function validateInvite(token: string): Promise<string> {
const payload = await verifyInviteToken(token);
if (!payload) {
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"Invalid or expired invite",
);
}
export function validateInvite(token: string): string {
const row = db.prepare(
`SELECT token, inviter_id, used_at, created_at FROM invites WHERE token = ?;`,
).get(token);
if (!row || !isInviteRow(row)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Invite not found");
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"Invalid or expired invite",
);
}
if (row.used_at !== null) {
@@ -40,7 +46,17 @@ export async function validateInvite(token: string): Promise<string> {
);
}
return payload.inviterId;
const expiresAt = new Date(row.created_at).getTime() +
UNUSED_INVITES_RETENTION_DAYS * 24 * 60 * 60 * 1000;
if (Date.now() > expiresAt) {
throw new APIException(
APIErrorCode.NOT_FOUND,
404,
"Invalid or expired invite",
);
}
return row.inviter_id;
}
/**