feat(server): auth status, attachments list, user preferences
GET auth/status ({ hasUsers }), GET attachments?entity=&entityId= (owner
scoped, 404 for foreign targets) and GET/PATCH me/preferences (ratingScale,
defaultVisibility in users.preferences).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d547355e5c
commit
d204775235
@@ -1,4 +1,4 @@
|
||||
import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment, unlinkAttachment } from "@sttracker/core/attachments";
|
||||
import { listAttachmentLinks, listAttachments, readAttachment, requireLinkTarget, storeAttachment, unlinkAttachment } from "@sttracker/core/attachments";
|
||||
import { NotFoundError } from "@sttracker/core/errors";
|
||||
import type { AttachmentEntity } from "@sttracker/db/schema";
|
||||
import { z } from "zod";
|
||||
@@ -18,6 +18,18 @@ export function safeFilename(name: string): string {
|
||||
}
|
||||
|
||||
export const attachmentRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "attachments",
|
||||
summary: "Attachments linked to one of your records (oldest first), each with its links.",
|
||||
auth: "any:read",
|
||||
query: z.object({ entity: fields.shape.entity, entityId: z.string().min(1) }),
|
||||
handler: async ({ deps, user, query }) => {
|
||||
await requireLinkTarget(deps.db, user.id, query.entity as AttachmentEntity, query.entityId);
|
||||
const atts = await listAttachments(deps.db, user.id, query.entity as AttachmentEntity, query.entityId);
|
||||
return Promise.all(atts.map(async (a) => ({ ...a, links: await listAttachmentLinks(deps.db, user.id, a.id) })));
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "attachments",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createAppToken, createInvite, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth";
|
||||
import { createAppToken, createInvite, hasUsers, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth";
|
||||
import { getPreferences, RATING_SCALES, updatePreferences } from "@sttracker/core/preferences";
|
||||
import { NotFoundError } from "@sttracker/core/errors";
|
||||
import { z } from "zod";
|
||||
import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts";
|
||||
import { visibility } from "./schemas.ts";
|
||||
|
||||
const credentials = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
@@ -9,6 +11,13 @@ const credentials = z.object({
|
||||
});
|
||||
|
||||
export const authRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "auth/status",
|
||||
summary: "Whether any account exists yet (the web app shows first-run registration when not).",
|
||||
auth: "none",
|
||||
handler: async ({ deps }) => ({ hasUsers: await hasUsers(deps.db) }),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "auth/register",
|
||||
@@ -41,6 +50,21 @@ export const authRoutes = [
|
||||
},
|
||||
}),
|
||||
route({ method: "GET", path: "me", summary: "The signed-in user.", auth: "any:read", handler: async ({ user }) => user }),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "me/preferences",
|
||||
summary: "Your preferences: rating scale and default visibility.",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps, user }) => getPreferences(deps.db, user.id),
|
||||
}),
|
||||
route({
|
||||
method: "PATCH",
|
||||
path: "me/preferences",
|
||||
summary: "Update your preferences (partial).",
|
||||
auth: "any:write",
|
||||
body: z.object({ ratingScale: z.enum(RATING_SCALES), defaultVisibility: visibility }).partial().strict(),
|
||||
handler: async ({ deps, user, body }) => updatePreferences(deps.db, user.id, body as Parameters<typeof updatePreferences>[2]),
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "me/tokens",
|
||||
|
||||
@@ -286,6 +286,49 @@ forEachDialect("api", (getDb) => {
|
||||
expect((await call(app, "GET", `attachments/${att.id}`, { cookie: ana })).status).toBe(404);
|
||||
});
|
||||
|
||||
test("auth/status says whether any user exists, without auth", async () => {
|
||||
expect(await (await call(app, "GET", "auth/status")).json()).toEqual({ hasUsers: false });
|
||||
await signUp(app, "ana");
|
||||
expect(await (await call(app, "GET", "auth/status")).json()).toEqual({ hasUsers: true });
|
||||
});
|
||||
|
||||
test("attachments list: owner-scoped per entity; a foreign or unknown entity is 404", async () => {
|
||||
const ana = await signUp(app, "ana");
|
||||
const add = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string };
|
||||
const q = `attachments?entity=entry&entityId=${add.id}`;
|
||||
expect(await (await call(app, "GET", q, { cookie: ana })).json()).toEqual([]);
|
||||
const form = new FormData();
|
||||
form.set("file", new File([new Uint8Array([0x89, 0x50])], "shot.png", { type: "image/png" }));
|
||||
form.set("entity", "entry");
|
||||
form.set("entityId", add.id);
|
||||
const up = (await (await call(app, "POST", "attachments", { cookie: ana, form })).json()) as { id: string };
|
||||
const list = (await (await call(app, "GET", q, { cookie: ana })).json()) as { id: string; mime: string; filename: string; links: { id: string }[] }[];
|
||||
expect(list.map((a) => [a.id, a.mime, a.filename])).toEqual([[up.id, "image/png", "shot.png"]]);
|
||||
expect(list[0]!.links.length).toBe(1);
|
||||
|
||||
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
|
||||
const bob = await signUp(app, "bob", code);
|
||||
expect((await call(app, "GET", q, { cookie: bob })).status).toBe(404);
|
||||
expect((await call(app, "GET", "attachments?entity=entry&entityId=nope", { cookie: ana })).status).toBe(404);
|
||||
expect((await call(app, "GET", `attachments?entity=bogus&entityId=${add.id}`, { cookie: ana })).status).toBe(400);
|
||||
expect((await call(app, "GET", q)).status).toBe(401);
|
||||
});
|
||||
|
||||
test("me/preferences: defaults, validated PATCH, and quick add uses defaultVisibility", async () => {
|
||||
const ana = await signUp(app, "ana");
|
||||
expect(await (await call(app, "GET", "me/preferences", { cookie: ana })).json()).toEqual({ ratingScale: "5half", defaultVisibility: "private" });
|
||||
const bad = await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "7" } });
|
||||
expect(bad.status).toBe(400);
|
||||
expect((await call(app, "PATCH", "me/preferences", { cookie: ana, body: { extra: 1 } })).status).toBe(400);
|
||||
const ok = await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "10" } });
|
||||
expect(await ok.json()).toEqual({ ratingScale: "10", defaultVisibility: "private" });
|
||||
await call(app, "PATCH", "me/preferences", { cookie: ana, body: { defaultVisibility: "friends" } });
|
||||
expect(await (await call(app, "GET", "me/preferences", { cookie: ana })).json()).toEqual({ ratingScale: "10", defaultVisibility: "friends" });
|
||||
const e = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { visibility: string };
|
||||
expect(e.visibility).toBe("friends");
|
||||
expect((await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "5" }, noCsrf: true })).status).toBe(403);
|
||||
});
|
||||
|
||||
test("JSON bodies need Content-Type application/json (415)", async () => {
|
||||
const res = await app.fetch(
|
||||
new Request(`${BASE}/auth/register`, {
|
||||
|
||||
@@ -111,6 +111,11 @@ export async function login(
|
||||
return { user: toUser(row), sessionToken };
|
||||
}
|
||||
|
||||
/** True once any account exists (the first one becomes admin). */
|
||||
export async function hasUsers(db: Db): Promise<boolean> {
|
||||
return (await db.k.selectFrom("users").select("id").limit(1).executeTakeFirst()) !== undefined;
|
||||
}
|
||||
|
||||
export async function getSessionUser(db: Db, token: string): Promise<User | null> {
|
||||
const row = await db.k
|
||||
.selectFrom("sessions")
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { fromJson, toJson } from "@sttracker/db/json";
|
||||
import { nowIso } from "@sttracker/db/ids";
|
||||
import type { Visibility } from "@sttracker/db/schema";
|
||||
import { VISIBILITIES } from "./entries.ts";
|
||||
import { NotFoundError, ValidationError } from "./errors.ts";
|
||||
|
||||
export const RATING_SCALES = ["5half", "5", "10", "100"] as const;
|
||||
export type RatingScale = (typeof RATING_SCALES)[number];
|
||||
|
||||
/** Per-user settings, stored as JSON in `users.preferences`. Keys match what `defaultVisibility()` reads. */
|
||||
export interface Preferences {
|
||||
ratingScale: RatingScale;
|
||||
defaultVisibility: Visibility;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: Preferences = { ratingScale: "5half", defaultVisibility: "private" };
|
||||
|
||||
async function readRaw(db: Db, userId: string): Promise<Record<string, unknown>> {
|
||||
const u = await db.k.selectFrom("users").select("preferences").where("id", "=", userId).executeTakeFirst();
|
||||
if (!u) throw new NotFoundError("user");
|
||||
return fromJson<Record<string, unknown>>(u.preferences, {});
|
||||
}
|
||||
|
||||
/** The user's preferences; missing or invalid stored values fall back to the defaults. */
|
||||
export async function getPreferences(db: Db, userId: string): Promise<Preferences> {
|
||||
const raw = await readRaw(db, userId);
|
||||
return {
|
||||
ratingScale: (RATING_SCALES as readonly unknown[]).includes(raw.ratingScale) ? (raw.ratingScale as RatingScale) : DEFAULT_PREFERENCES.ratingScale,
|
||||
defaultVisibility: (VISIBILITIES as readonly unknown[]).includes(raw.defaultVisibility)
|
||||
? (raw.defaultVisibility as Visibility)
|
||||
: DEFAULT_PREFERENCES.defaultVisibility,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge a partial update into the stored preferences (unknown stored keys are kept). */
|
||||
export async function updatePreferences(db: Db, userId: string, patch: Partial<Preferences>): Promise<Preferences> {
|
||||
if (patch.ratingScale !== undefined && !RATING_SCALES.includes(patch.ratingScale)) throw new ValidationError("ratingScale", "invalid");
|
||||
if (patch.defaultVisibility !== undefined && !VISIBILITIES.includes(patch.defaultVisibility)) throw new ValidationError("defaultVisibility", "invalid");
|
||||
const raw = await readRaw(db, userId);
|
||||
const next = { ...raw, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) };
|
||||
await db.k.updateTable("users").set({ preferences: toJson(next), updated_at: nowIso() }).where("id", "=", userId).execute();
|
||||
return getPreferences(db, userId);
|
||||
}
|
||||
Reference in New Issue
Block a user