style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
+116
-117
@@ -21,29 +21,29 @@
|
||||
* Mounted under /api/* in index.ts — behind oidcAuthMiddleware.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Hono } from 'hono'
|
||||
import type { Context } from 'hono'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { z } from 'zod'
|
||||
import { and, or, eq, desc } from 'drizzle-orm'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { calendarEvents, calendars, users, calendarOutbox } from '../db/schema.js'
|
||||
import { expandOccurrences } from '../broker/expand.js'
|
||||
import { extractRruleString } from '../broker/vevent.js'
|
||||
import { getAuth } from '../auth/middleware.js'
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js'
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { and, or, eq, desc } from 'drizzle-orm';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { calendarEvents, calendars, users, calendarOutbox } from '../db/schema.js';
|
||||
import { expandOccurrences } from '../broker/expand.js';
|
||||
import { extractRruleString } from '../broker/vevent.js';
|
||||
import { getAuth } from '../auth/middleware.js';
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js'
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
export const eventsRouter = new Hono()
|
||||
export const eventsRouter = new Hono();
|
||||
|
||||
/** Shared-family calendar rose color (D-06). */
|
||||
const SHARED_FAMILY_COLOR = '#F25C7A'
|
||||
const SHARED_FAMILY_COLOR = '#F25C7A';
|
||||
|
||||
/** Maximum allowed date-window span to prevent DoS (T-02b-02). */
|
||||
const MAX_WINDOW_DAYS = 90
|
||||
const MAX_WINDOW_DAYS = 90;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helper — shared by all write endpoints
|
||||
@@ -61,22 +61,22 @@ const MAX_WINDOW_DAYS = 90
|
||||
// ContextVariableMap augmentation in auth/devBypass.ts (typed as the DEV_USER shape),
|
||||
// and getAuth(c) accepts a Context — so no `any` / eslint-disable is needed here.
|
||||
async function resolveUserId(c: Context): Promise<number | null> {
|
||||
const devUser = c.get('user') as { id: number } | undefined
|
||||
if (devUser) return devUser.id
|
||||
const devUser = c.get('user') as { id: number } | undefined;
|
||||
if (devUser) return devUser.id;
|
||||
|
||||
const auth = await getAuth(c)
|
||||
if (!auth) return null
|
||||
const auth = await getAuth(c);
|
||||
if (!auth) return null;
|
||||
|
||||
const iss = (auth.iss as string | undefined) ?? ''
|
||||
const sub = auth.sub ?? ''
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
|
||||
// Derive displayName via the shared helper (name → preferred_username → email
|
||||
// → sub fallback) so the write-path upsert agrees with me.ts and never
|
||||
// overwrites a correctly-derived name with a worse one.
|
||||
const displayName = deriveDisplayName(auth)
|
||||
const displayName = deriveDisplayName(auth);
|
||||
|
||||
const user = await upsertUser(iss, sub, displayName)
|
||||
return user?.id ?? null
|
||||
const user = await upsertUser(iss, sub, displayName);
|
||||
return user?.id ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -87,7 +87,7 @@ async function resolveUserId(c: Context): Promise<number | null> {
|
||||
const eventsQuerySchema = z.object({
|
||||
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Shared event field validation (V5 — bounded lengths, T-03-08).
|
||||
@@ -111,14 +111,17 @@ const eventFieldsSchema = z.object({
|
||||
// ≤10-char non-date string cannot survive .replace(/-/g,'') and inject extra ';'-delimited
|
||||
// RRULE parts when spliced into the UNTIL template (outboxWorker.assembleRruleString).
|
||||
// int().min(1) prevents zero/negative counts.
|
||||
recurrenceUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), // 'YYYY-MM-DD' → RRULE UNTIL
|
||||
recurrenceUntil: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(), // 'YYYY-MM-DD' → RRULE UNTIL
|
||||
recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT
|
||||
})
|
||||
});
|
||||
|
||||
/** sync-status query params. */
|
||||
const syncStatusQuerySchema = z.object({
|
||||
uid: z.string().min(1).max(512),
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/events?start=YYYY-MM-DD&end=YYYY-MM-DD
|
||||
@@ -132,17 +135,17 @@ const syncStatusQuerySchema = z.object({
|
||||
// ---------------------------------------------------------------------------
|
||||
eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
|
||||
// Resolve the current user first — only return events for owned + shared calendars (T-03-06).
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const { start, end } = c.req.valid('query')
|
||||
const { start, end } = c.req.valid('query');
|
||||
|
||||
// --- Window span guard (T-02b-02) ---
|
||||
const windowStartDate = new Date(start + 'T00:00:00Z')
|
||||
const windowEndDate = new Date(end + 'T00:00:00Z')
|
||||
const spanDays = (windowEndDate.getTime() - windowStartDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
const windowStartDate = new Date(start + 'T00:00:00Z');
|
||||
const windowEndDate = new Date(end + 'T00:00:00Z');
|
||||
const spanDays = (windowEndDate.getTime() - windowStartDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (spanDays > MAX_WINDOW_DAYS || spanDays <= 0) {
|
||||
return c.json({ error: 'Date window must be between 1 and 90 days' }, 400)
|
||||
return c.json({ error: 'Date window must be between 1 and 90 days' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -211,11 +214,11 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// --- Expand each row into concrete occurrences ---
|
||||
const allOccurrences = rows.flatMap((row) => {
|
||||
const color = row.isShared ? SHARED_FAMILY_COLOR : row.userColor
|
||||
const color = row.isShared ? SHARED_FAMILY_COLOR : row.userColor;
|
||||
return expandOccurrences(
|
||||
row.rawVevent,
|
||||
windowStartDate,
|
||||
@@ -226,15 +229,15 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
|
||||
row.ownerName ?? null,
|
||||
color,
|
||||
row.isShared,
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return c.json({ occurrences: allOccurrences })
|
||||
return c.json({ occurrences: allOccurrences });
|
||||
} catch (err) {
|
||||
console.error('[events] DB query or expansion failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[events] DB query or expansion failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/events/create
|
||||
@@ -244,16 +247,16 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
|
||||
// Does NOT build a VEVENT and does NOT call Fastmail — that is the worker's job (D-12).
|
||||
// ---------------------------------------------------------------------------
|
||||
eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const payload = c.req.valid('json')
|
||||
const payload = c.req.valid('json');
|
||||
|
||||
try {
|
||||
// --- Resolve target calendar (D-03 / T-03-06) ---
|
||||
// If calendarUrl given: assert the calendar is owned by the current user OR is shared.
|
||||
// If not given: use the first personal calendar (D-01 last-used is a frontend concern).
|
||||
let targetCalendarUrl: string
|
||||
let targetCalendarUrl: string;
|
||||
|
||||
if (payload.calendarUrl) {
|
||||
// Look up the calendar — it must be owned by the current user or be shared.
|
||||
@@ -265,12 +268,12 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
|
||||
eq(calendars.url, payload.calendarUrl),
|
||||
or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
if (!calRow) {
|
||||
return c.json({ error: 'Calendar not found or access denied' }, 403)
|
||||
return c.json({ error: 'Calendar not found or access denied' }, 403);
|
||||
}
|
||||
targetCalendarUrl = calRow.url
|
||||
targetCalendarUrl = calRow.url;
|
||||
} else {
|
||||
// Default to the member's first personal calendar (D-01).
|
||||
// WR-02: add a deterministic ORDER BY + LIMIT. Without them, a member with
|
||||
@@ -282,16 +285,16 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
|
||||
.from(calendars)
|
||||
.where(eq(calendars.userId, currentUserId))
|
||||
.orderBy(calendars.id)
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
if (!calRow) {
|
||||
return c.json({ error: 'No writable calendar found for user' }, 422)
|
||||
return c.json({ error: 'No writable calendar found for user' }, 422);
|
||||
}
|
||||
targetCalendarUrl = calRow.url
|
||||
targetCalendarUrl = calRow.url;
|
||||
}
|
||||
|
||||
// Generate a UID for the new event (Node.js 22 built-in)
|
||||
const uid = `${randomUUID()}@familysync`
|
||||
const uid = `${randomUUID()}@familysync`;
|
||||
|
||||
// Enqueue the outbox row (pending) — the worker builds the VEVENT and calls Fastmail.
|
||||
await db.insert(calendarOutbox).values({
|
||||
@@ -301,14 +304,14 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
|
||||
uid,
|
||||
calendarUrl: targetCalendarUrl,
|
||||
payload: JSON.stringify(payload),
|
||||
})
|
||||
});
|
||||
|
||||
return c.json({ uid }, 202)
|
||||
return c.json({ uid }, 202);
|
||||
} catch (err) {
|
||||
console.error('[events/create] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[events/create] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/events/:uid/edit
|
||||
@@ -319,11 +322,11 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
|
||||
// Returns 202 immediately (D-05). Does NOT call Fastmail (D-12).
|
||||
// ---------------------------------------------------------------------------
|
||||
eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const uid = c.req.param('uid')
|
||||
const payload = c.req.valid('json')
|
||||
const uid = c.req.param('uid');
|
||||
const payload = c.req.valid('json');
|
||||
|
||||
try {
|
||||
// --- Look up the event and verify ownership ---
|
||||
@@ -358,10 +361,10 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
|
||||
),
|
||||
)
|
||||
.orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`)
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
if (!eventRow) {
|
||||
return c.json({ error: 'Event not found' }, 404)
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Ownership check: must be the calendar owner or shared (T-03-06).
|
||||
@@ -372,20 +375,20 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
|
||||
const [calRow] = await db
|
||||
.select({ isShared: calendars.isShared })
|
||||
.from(calendars)
|
||||
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)))
|
||||
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)));
|
||||
|
||||
if (!calRow) {
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
const newCalendarUrl = payload.calendarUrl ?? eventRow.calendarUrl
|
||||
const isCalendarMove = newCalendarUrl !== eventRow.calendarUrl
|
||||
const newCalendarUrl = payload.calendarUrl ?? eventRow.calendarUrl;
|
||||
const isCalendarMove = newCalendarUrl !== eventRow.calendarUrl;
|
||||
|
||||
if (isCalendarMove) {
|
||||
// D-04: edit-as-move — insert delete+create pair in one transaction (D-04 / Pitfall 5)
|
||||
const newUid = `${randomUUID()}@familysync`
|
||||
const groupId = randomUUID()
|
||||
const newUid = `${randomUUID()}@familysync`;
|
||||
const groupId = randomUUID();
|
||||
|
||||
// CR-01: carry the existing RRULE through the move. The edit payload omits
|
||||
// `recurrence` (the occurrence contract does not expose it, D-03), and the
|
||||
@@ -398,13 +401,9 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
|
||||
// Only stash when the edit did NOT carry an explicit recurrence: an explicit
|
||||
// value (including 'none') is a deliberate user change and must win.
|
||||
const preservedRrule =
|
||||
payload.recurrence === undefined
|
||||
? extractRruleString(eventRow.rawVevent ?? '')
|
||||
: undefined
|
||||
payload.recurrence === undefined ? extractRruleString(eventRow.rawVevent ?? '') : undefined;
|
||||
const createPayload =
|
||||
preservedRrule !== undefined
|
||||
? { ...payload, _preservedRrule: preservedRrule }
|
||||
: payload
|
||||
preservedRrule !== undefined ? { ...payload, _preservedRrule: preservedRrule } : payload;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Delete from old calendar
|
||||
@@ -417,7 +416,7 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
|
||||
calendarObjectUrl: eventRow.objectUrl ?? undefined,
|
||||
etag: eventRow.etag ?? undefined,
|
||||
groupId,
|
||||
})
|
||||
});
|
||||
// Create on new calendar
|
||||
await tx.insert(calendarOutbox).values({
|
||||
userId: currentUserId,
|
||||
@@ -427,10 +426,10 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
|
||||
calendarUrl: newCalendarUrl,
|
||||
payload: JSON.stringify(createPayload),
|
||||
groupId,
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
return c.json({ uid: newUid }, 202)
|
||||
return c.json({ uid: newUid }, 202);
|
||||
}
|
||||
|
||||
// Same calendar — simple update row
|
||||
@@ -443,14 +442,14 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
|
||||
calendarObjectUrl: eventRow.objectUrl ?? undefined,
|
||||
etag: eventRow.etag ?? undefined,
|
||||
payload: JSON.stringify(payload),
|
||||
})
|
||||
});
|
||||
|
||||
return c.json({ uid }, 202)
|
||||
return c.json({ uid }, 202);
|
||||
} catch (err) {
|
||||
console.error('[events/edit] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[events/edit] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/events/:uid
|
||||
@@ -459,10 +458,10 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
|
||||
// Returns 202 immediately (D-05). Does NOT call Fastmail (D-12).
|
||||
// ---------------------------------------------------------------------------
|
||||
eventsRouter.delete('/:uid', async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const uid = c.req.param('uid')
|
||||
const uid = c.req.param('uid');
|
||||
|
||||
try {
|
||||
// Look up the event — join calendars so calendars.url / calendars.userId are accessible.
|
||||
@@ -490,10 +489,10 @@ eventsRouter.delete('/:uid', async (c) => {
|
||||
),
|
||||
)
|
||||
.orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`)
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
if (!eventRow) {
|
||||
return c.json({ error: 'Event not found' }, 404)
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
// Ownership check (T-03-06)
|
||||
@@ -501,10 +500,10 @@ eventsRouter.delete('/:uid', async (c) => {
|
||||
const [calRow] = await db
|
||||
.select({ isShared: calendars.isShared })
|
||||
.from(calendars)
|
||||
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)))
|
||||
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)));
|
||||
|
||||
if (!calRow) {
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,14 +516,14 @@ eventsRouter.delete('/:uid', async (c) => {
|
||||
calendarUrl: eventRow.calendarUrl ?? '',
|
||||
calendarObjectUrl: eventRow.objectUrl ?? undefined,
|
||||
etag: eventRow.etag ?? undefined,
|
||||
})
|
||||
});
|
||||
|
||||
return c.json({ uid }, 202)
|
||||
return c.json({ uid }, 202);
|
||||
} catch (err) {
|
||||
console.error('[events/delete] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[events/delete] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/events/sync-status?uid=<uid>
|
||||
@@ -534,10 +533,10 @@ eventsRouter.delete('/:uid', async (c) => {
|
||||
// Returns { uid, status: 'done' } when no outbox row exists (nothing pending = settled).
|
||||
// ---------------------------------------------------------------------------
|
||||
eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const { uid } = c.req.valid('query')
|
||||
const { uid } = c.req.valid('query');
|
||||
|
||||
try {
|
||||
// Scope strictly to current member's rows (T-03-07 — never leak another member's outbox).
|
||||
@@ -562,24 +561,24 @@ eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), asy
|
||||
sql`case ${calendarOutbox.status} when 'failed' then 0 when 'dead' then 0 when 'pending' then 1 else 2 end`,
|
||||
desc(calendarOutbox.createdAt),
|
||||
)
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
if (!rows.length) {
|
||||
// No outbox row → nothing pending = settled as done
|
||||
return c.json({ uid, status: 'done' })
|
||||
return c.json({ uid, status: 'done' });
|
||||
}
|
||||
|
||||
const row = rows[0]
|
||||
const row = rows[0];
|
||||
return c.json({
|
||||
uid: row.uid,
|
||||
status: row.status,
|
||||
...(row.lastError != null ? { error: row.lastError } : {}),
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[events/sync-status] DB query failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[events/sync-status] DB query failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/events/writable-calendars
|
||||
@@ -595,8 +594,8 @@ eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), asy
|
||||
// Response: { calendars: [{ url, displayName, color, isShared }] }
|
||||
// ---------------------------------------------------------------------------
|
||||
eventsRouter.get('/writable-calendars', async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
try {
|
||||
// D-03 writable set: own personal calendars + shared Family calendar.
|
||||
@@ -609,7 +608,7 @@ eventsRouter.get('/writable-calendars', async (c) => {
|
||||
isShared: calendars.isShared,
|
||||
})
|
||||
.from(calendars)
|
||||
.where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)))
|
||||
.where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)));
|
||||
|
||||
return c.json({
|
||||
calendars: rows.map((row) => ({
|
||||
@@ -618,9 +617,9 @@ eventsRouter.get('/writable-calendars', async (c) => {
|
||||
color: row.color ?? '',
|
||||
isShared: row.isShared,
|
||||
})),
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[events/writable-calendars] DB query failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[events/writable-calendars] DB query failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Hono } from 'hono'
|
||||
import { db } from '../db/client.js'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { Hono } from 'hono';
|
||||
import { db } from '../db/client.js';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const healthRouter = new Hono()
|
||||
export const healthRouter = new Hono();
|
||||
|
||||
/**
|
||||
* GET /health — unauthenticated endpoint that proves a real DB read+write round-trip.
|
||||
@@ -17,11 +17,11 @@ healthRouter.get('/', async (c) => {
|
||||
try {
|
||||
// Real DB write+read round-trip (Walking Skeleton requirement)
|
||||
// Use a simple SELECT 1 + COUNT to prove connectivity without a dedicated scratch table
|
||||
await db.execute(sql`SELECT 1`)
|
||||
await db.execute(sql`SELECT 1`);
|
||||
|
||||
return c.json({ ok: true, db: 'up' })
|
||||
return c.json({ ok: true, db: 'up' });
|
||||
} catch (err) {
|
||||
console.error('[health] DB round-trip failed:', err)
|
||||
return c.json({ ok: false, db: 'down' }, 503)
|
||||
console.error('[health] DB round-trip failed:', err);
|
||||
return c.json({ ok: false, db: 'down' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
+207
-215
@@ -17,23 +17,23 @@
|
||||
* Mounted under /api/* in index.ts — behind oidcAuthMiddleware (or dev-bypass).
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import type { Context } from 'hono'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { z } from 'zod'
|
||||
import { and, asc, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { lists, listShares, listItems, users } from '../db/schema.js'
|
||||
import { getAuth } from '../auth/middleware.js'
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js'
|
||||
import { rankForAppend } from '../lib/rank.js'
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { and, asc, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { lists, listShares, listItems, users } from '../db/schema.js';
|
||||
import { getAuth } from '../auth/middleware.js';
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||
import { rankForAppend } from '../lib/rank.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js'
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
import { publishListEvent } from '../lib/listEmitter.js'
|
||||
import { notifyListChange } from '../lib/listChangeDispatcher.js'
|
||||
import { publishListEvent } from '../lib/listEmitter.js';
|
||||
import { notifyListChange } from '../lib/listChangeDispatcher.js';
|
||||
|
||||
export const listsRouter = new Hono()
|
||||
export const listsRouter = new Hono();
|
||||
|
||||
/**
|
||||
* listItemsRouter — single-item mutation routes.
|
||||
@@ -45,7 +45,7 @@ export const listsRouter = new Hono()
|
||||
* Separate from listsRouter (mounted at /api/lists) per the RESEARCH.md
|
||||
* architecture diagram.
|
||||
*/
|
||||
export const listItemsRouter = new Hono()
|
||||
export const listItemsRouter = new Hono();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helper — copied verbatim from events.ts per project convention.
|
||||
@@ -56,17 +56,17 @@ export const listItemsRouter = new Hono()
|
||||
// 2. OIDC path: call getAuth(c). If null → unauthenticated, return null.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function resolveUserId(c: Context): Promise<number | null> {
|
||||
const devUser = c.get('user') as { id: number } | undefined
|
||||
if (devUser) return devUser.id
|
||||
const devUser = c.get('user') as { id: number } | undefined;
|
||||
if (devUser) return devUser.id;
|
||||
|
||||
const auth = await getAuth(c)
|
||||
if (!auth) return null
|
||||
const auth = await getAuth(c);
|
||||
if (!auth) return null;
|
||||
|
||||
const iss = (auth.iss as string | undefined) ?? ''
|
||||
const sub = auth.sub ?? ''
|
||||
const displayName = deriveDisplayName(auth)
|
||||
const user = await upsertUser(iss, sub, displayName)
|
||||
return user?.id ?? null
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
const displayName = deriveDisplayName(auth);
|
||||
const user = await upsertUser(iss, sub, displayName);
|
||||
return user?.id ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -79,7 +79,7 @@ async function resolveUserId(c: Context): Promise<number | null> {
|
||||
const createListSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
isShared: z.boolean().default(true),
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Patch list — name and/or isShared; at least one field required (T-04-07).
|
||||
@@ -92,14 +92,14 @@ const patchListSchema = z
|
||||
.partial()
|
||||
.refine((obj) => Object.keys(obj).length >= 1, {
|
||||
message: 'PATCH must update at least one field (name or isShared)',
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Create item — text 1..500 (T-04-06 XSS: plain-text only, no HTML).
|
||||
*/
|
||||
const createItemSchema = z.object({
|
||||
text: z.string().min(1).max(500),
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-field PATCH for list items (D-08, T-04-07).
|
||||
@@ -115,7 +115,7 @@ const patchItemSchema = z
|
||||
.partial()
|
||||
.refine((obj) => Object.keys(obj).length === 1, {
|
||||
message: 'PATCH must update exactly one field (checked, text, or position)',
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: check list access (owner OR sharee)
|
||||
@@ -124,19 +124,18 @@ const patchItemSchema = z
|
||||
async function checkListAccess(
|
||||
listId: number,
|
||||
userId: number,
|
||||
): Promise<{ allowed: true; isOwner: boolean; listRow: typeof lists.$inferSelect } | { allowed: false; notFound: boolean }> {
|
||||
const [listRow] = await db
|
||||
.select()
|
||||
.from(lists)
|
||||
.where(eq(lists.id, listId))
|
||||
.limit(1)
|
||||
): Promise<
|
||||
| { allowed: true; isOwner: boolean; listRow: typeof lists.$inferSelect }
|
||||
| { allowed: false; notFound: boolean }
|
||||
> {
|
||||
const [listRow] = await db.select().from(lists).where(eq(lists.id, listId)).limit(1);
|
||||
|
||||
if (!listRow) {
|
||||
return { allowed: false, notFound: true }
|
||||
return { allowed: false, notFound: true };
|
||||
}
|
||||
|
||||
if (listRow.ownerId === userId) {
|
||||
return { allowed: true, isOwner: true, listRow }
|
||||
return { allowed: true, isOwner: true, listRow };
|
||||
}
|
||||
|
||||
// Check list_shares
|
||||
@@ -144,13 +143,13 @@ async function checkListAccess(
|
||||
.select({ listId: listShares.listId })
|
||||
.from(listShares)
|
||||
.where(and(eq(listShares.listId, listId), eq(listShares.userId, userId)))
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
if (shareRow) {
|
||||
return { allowed: true, isOwner: false, listRow }
|
||||
return { allowed: true, isOwner: false, listRow };
|
||||
}
|
||||
|
||||
return { allowed: false, notFound: false }
|
||||
return { allowed: false, notFound: false };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -162,35 +161,29 @@ async function checkListAccess(
|
||||
// Security: T-04-02 — WHERE owner_id = caller OR id IN list_shares.userId = caller.
|
||||
// ---------------------------------------------------------------------------
|
||||
listsRouter.get('/', async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
try {
|
||||
// Collect all list IDs accessible to this user: owned + shared
|
||||
const owned = await db
|
||||
.select({ id: lists.id })
|
||||
.from(lists)
|
||||
.where(eq(lists.ownerId, currentUserId))
|
||||
.where(eq(lists.ownerId, currentUserId));
|
||||
|
||||
const shared = await db
|
||||
.select({ listId: listShares.listId })
|
||||
.from(listShares)
|
||||
.where(eq(listShares.userId, currentUserId))
|
||||
.where(eq(listShares.userId, currentUserId));
|
||||
|
||||
const accessibleIds = [...new Set([
|
||||
...owned.map((r) => r.id),
|
||||
...shared.map((r) => r.listId),
|
||||
])]
|
||||
const accessibleIds = [...new Set([...owned.map((r) => r.id), ...shared.map((r) => r.listId)])];
|
||||
|
||||
if (accessibleIds.length === 0) {
|
||||
return c.json({ lists: [] })
|
||||
return c.json({ lists: [] });
|
||||
}
|
||||
|
||||
// Fetch list rows for accessible IDs
|
||||
const listRows = await db
|
||||
.select()
|
||||
.from(lists)
|
||||
.where(inArray(lists.id, accessibleIds))
|
||||
const listRows = await db.select().from(lists).where(inArray(lists.id, accessibleIds));
|
||||
|
||||
// Compute item counts per list
|
||||
const countRows = await db
|
||||
@@ -201,24 +194,24 @@ listsRouter.get('/', async (c) => {
|
||||
})
|
||||
.from(listItems)
|
||||
.where(inArray(listItems.listId, accessibleIds))
|
||||
.groupBy(listItems.listId, listItems.checked)
|
||||
.groupBy(listItems.listId, listItems.checked);
|
||||
|
||||
// Build counts map: { listId -> { active, done } }
|
||||
const countsMap = new Map<number, { active: number; done: number }>()
|
||||
const countsMap = new Map<number, { active: number; done: number }>();
|
||||
for (const row of countRows) {
|
||||
if (!countsMap.has(row.listId)) {
|
||||
countsMap.set(row.listId, { active: 0, done: 0 })
|
||||
countsMap.set(row.listId, { active: 0, done: 0 });
|
||||
}
|
||||
const entry = countsMap.get(row.listId)!
|
||||
const entry = countsMap.get(row.listId)!;
|
||||
if (row.checked) {
|
||||
entry.done += Number(row.count)
|
||||
entry.done += Number(row.count);
|
||||
} else {
|
||||
entry.active += Number(row.count)
|
||||
entry.active += Number(row.count);
|
||||
}
|
||||
}
|
||||
|
||||
const result = listRows.map((list) => {
|
||||
const counts = countsMap.get(list.id) ?? { active: 0, done: 0 }
|
||||
const counts = countsMap.get(list.id) ?? { active: 0, done: 0 };
|
||||
return {
|
||||
id: list.id,
|
||||
name: list.name,
|
||||
@@ -228,15 +221,15 @@ listsRouter.get('/', async (c) => {
|
||||
doneCount: counts.done,
|
||||
createdAt: list.createdAt,
|
||||
updatedAt: list.updatedAt,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
return c.json({ lists: result })
|
||||
return c.json({ lists: result });
|
||||
} catch (err) {
|
||||
console.error('[lists/GET /] DB query failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/GET /] DB query failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/lists
|
||||
@@ -248,19 +241,19 @@ listsRouter.get('/', async (c) => {
|
||||
// Security: T-04-08 — shares are server-managed only; no client endpoint for shares.
|
||||
// ---------------------------------------------------------------------------
|
||||
listsRouter.post('/', zValidator('json', createListSchema), async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const { name, isShared } = c.req.valid('json')
|
||||
const { name, isShared } = c.req.valid('json');
|
||||
|
||||
try {
|
||||
// Insert the list
|
||||
const [inserted] = await db
|
||||
.insert(lists)
|
||||
.values({ ownerId: currentUserId, name, isShared })
|
||||
.$returningId()
|
||||
.$returningId();
|
||||
|
||||
const listId = inserted.id
|
||||
const listId = inserted.id;
|
||||
|
||||
// Auto-populate list_shares for all other members when isShared=true (D-01, D-02, OQ-3)
|
||||
if (isShared) {
|
||||
@@ -268,24 +261,22 @@ listsRouter.post('/', zValidator('json', createListSchema), async (c) => {
|
||||
const otherUsers = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(sql`${users.id} != ${currentUserId}`)
|
||||
.where(sql`${users.id} != ${currentUserId}`);
|
||||
|
||||
if (otherUsers.length > 0) {
|
||||
await db.insert(listShares).values(
|
||||
otherUsers.map((u) => ({ listId, userId: u.id })),
|
||||
)
|
||||
await db.insert(listShares).values(otherUsers.map((u) => ({ listId, userId: u.id })));
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch the newly created list to return canonical shape
|
||||
const [newList] = await db
|
||||
.select()
|
||||
.from(lists)
|
||||
.where(eq(lists.id, listId))
|
||||
.limit(1)
|
||||
const [newList] = await db.select().from(lists).where(eq(lists.id, listId)).limit(1);
|
||||
|
||||
// Fan-out: notify accessible subscribers that this list was created/updated (LIST-04)
|
||||
publishListEvent(listId, { type: 'list:updated', listId, payload: { id: listId, name: newList.name } })
|
||||
publishListEvent(listId, {
|
||||
type: 'list:updated',
|
||||
listId,
|
||||
payload: { id: listId, name: newList.name },
|
||||
});
|
||||
|
||||
return c.json(
|
||||
{
|
||||
@@ -299,12 +290,12 @@ listsRouter.post('/', zValidator('json', createListSchema), async (c) => {
|
||||
updatedAt: newList.updatedAt,
|
||||
},
|
||||
201,
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[lists/POST /] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/POST /] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/lists/:id
|
||||
@@ -318,35 +309,35 @@ listsRouter.post('/', zValidator('json', createListSchema), async (c) => {
|
||||
// T-04-07 — zod whitelists name/isShared only.
|
||||
// ---------------------------------------------------------------------------
|
||||
listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const listId = Number(c.req.param('id'))
|
||||
const patch = c.req.valid('json')
|
||||
const listId = Number(c.req.param('id'));
|
||||
const patch = c.req.valid('json');
|
||||
|
||||
try {
|
||||
const access = await checkListAccess(listId, currentUserId)
|
||||
const access = await checkListAccess(listId, currentUserId);
|
||||
|
||||
if (!access.allowed) {
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404)
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404);
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
// T-04-08 / T-04-05: owner-only guard for isShared mutations.
|
||||
// A sharee may rename a list (patch.name) but must never mutate list_shares.
|
||||
if (patch.isShared !== undefined && !access.isOwner) {
|
||||
return c.json({ error: 'Only the list owner can change sharing settings' }, 403)
|
||||
return c.json({ error: 'Only the list owner can change sharing settings' }, 403);
|
||||
}
|
||||
|
||||
const prevIsShared = access.listRow.isShared
|
||||
const newIsShared = patch.isShared ?? prevIsShared
|
||||
const prevIsShared = access.listRow.isShared;
|
||||
const newIsShared = patch.isShared ?? prevIsShared;
|
||||
|
||||
// Apply field updates
|
||||
const updateValues: Partial<typeof lists.$inferInsert> = {}
|
||||
if (patch.name !== undefined) updateValues.name = patch.name
|
||||
if (patch.isShared !== undefined) updateValues.isShared = patch.isShared
|
||||
const updateValues: Partial<typeof lists.$inferInsert> = {};
|
||||
if (patch.name !== undefined) updateValues.name = patch.name;
|
||||
if (patch.isShared !== undefined) updateValues.isShared = patch.isShared;
|
||||
|
||||
await db.update(lists).set(updateValues).where(eq(lists.id, listId))
|
||||
await db.update(lists).set(updateValues).where(eq(lists.id, listId));
|
||||
|
||||
// Owner-only: reconcile list_shares on visibility change
|
||||
if (patch.isShared !== undefined && patch.isShared !== prevIsShared) {
|
||||
@@ -355,13 +346,13 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
|
||||
const otherUsers = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(sql`${users.id} != ${access.listRow.ownerId}`)
|
||||
.where(sql`${users.id} != ${access.listRow.ownerId}`);
|
||||
|
||||
if (otherUsers.length > 0) {
|
||||
// Use INSERT IGNORE semantics by catching duplicate key errors gracefully
|
||||
for (const u of otherUsers) {
|
||||
try {
|
||||
await db.insert(listShares).values({ listId, userId: u.id })
|
||||
await db.insert(listShares).values({ listId, userId: u.id });
|
||||
} catch {
|
||||
// Duplicate key — share already exists, skip
|
||||
}
|
||||
@@ -369,23 +360,21 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
|
||||
}
|
||||
} else {
|
||||
// true → false: remove all non-owner shares
|
||||
await db
|
||||
.delete(listShares)
|
||||
.where(eq(listShares.listId, listId))
|
||||
await db.delete(listShares).where(eq(listShares.listId, listId));
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch and return the updated list
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(lists)
|
||||
.where(eq(lists.id, listId))
|
||||
.limit(1)
|
||||
const [updated] = await db.select().from(lists).where(eq(lists.id, listId)).limit(1);
|
||||
|
||||
// Fan-out: notify accessible subscribers that this list metadata changed (LIST-04)
|
||||
publishListEvent(listId, { type: 'list:updated', listId, payload: { id: listId, name: updated.name } })
|
||||
publishListEvent(listId, {
|
||||
type: 'list:updated',
|
||||
listId,
|
||||
payload: { id: listId, name: updated.name },
|
||||
});
|
||||
// Push: coalesced list-change notification to other accessible members (NOTIF-02)
|
||||
notifyListChange(listId, currentUserId)
|
||||
notifyListChange(listId, currentUserId);
|
||||
|
||||
return c.json({
|
||||
id: updated.id,
|
||||
@@ -394,12 +383,12 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
|
||||
ownerId: updated.ownerId,
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[lists/PATCH /:id] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/PATCH /:id] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/lists/:id
|
||||
@@ -410,38 +399,38 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
|
||||
// Security: T-04-05 — only the owner can delete; non-owner/non-sharee → 403.
|
||||
// ---------------------------------------------------------------------------
|
||||
listsRouter.delete('/:id', async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const listId = Number(c.req.param('id'))
|
||||
const listId = Number(c.req.param('id'));
|
||||
|
||||
try {
|
||||
const access = await checkListAccess(listId, currentUserId)
|
||||
const access = await checkListAccess(listId, currentUserId);
|
||||
|
||||
if (!access.allowed) {
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404)
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404);
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
// Owner-only delete (plan spec: "owner-only delete is the safe default")
|
||||
if (!access.isOwner) {
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
await db.delete(lists).where(eq(lists.id, listId))
|
||||
await db.delete(lists).where(eq(lists.id, listId));
|
||||
|
||||
// Fan-out: notify accessible subscribers that this list was deleted (LIST-04)
|
||||
publishListEvent(listId, { type: 'list:deleted', listId, payload: { id: listId } })
|
||||
publishListEvent(listId, { type: 'list:deleted', listId, payload: { id: listId } });
|
||||
// Push: coalesced list-change notification to other accessible members (NOTIF-02)
|
||||
// Note: list is already deleted from DB; notifyListChange handles missing list gracefully.
|
||||
notifyListChange(listId, currentUserId)
|
||||
notifyListChange(listId, currentUserId);
|
||||
|
||||
return c.json({ id: listId })
|
||||
return c.json({ id: listId });
|
||||
} catch (err) {
|
||||
console.error('[lists/DELETE /:id] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/DELETE /:id] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Item routes (LIST-02)
|
||||
@@ -465,17 +454,17 @@ listsRouter.delete('/:id', async (c) => {
|
||||
// Security: list access check before insert.
|
||||
// ---------------------------------------------------------------------------
|
||||
listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const listId = Number(c.req.param('id'))
|
||||
const { text } = c.req.valid('json')
|
||||
const listId = Number(c.req.param('id'));
|
||||
const { text } = c.req.valid('json');
|
||||
|
||||
try {
|
||||
const access = await checkListAccess(listId, currentUserId)
|
||||
const access = await checkListAccess(listId, currentUserId);
|
||||
if (!access.allowed) {
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404)
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404);
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
// Find the last active item's rank (unchecked, sorted DESC by rank, limit 1)
|
||||
@@ -484,25 +473,29 @@ listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) =
|
||||
.from(listItems)
|
||||
.where(and(eq(listItems.listId, listId), eq(listItems.checked, false)))
|
||||
.orderBy(sql`${listItems.rank} DESC`)
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
const newRank = rankForAppend(lastActive?.rank ?? null)
|
||||
const newRank = rankForAppend(lastActive?.rank ?? null);
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(listItems)
|
||||
.values({ listId, text, rank: newRank })
|
||||
.$returningId()
|
||||
.$returningId();
|
||||
|
||||
const [newItem] = await db
|
||||
.select()
|
||||
.from(listItems)
|
||||
.where(eq(listItems.id, inserted.id))
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
// Fan-out: notify accessible subscribers that an item was added (LIST-04)
|
||||
publishListEvent(listId, { type: 'item:added', listId, payload: { id: newItem.id, listId, text: newItem.text } })
|
||||
publishListEvent(listId, {
|
||||
type: 'item:added',
|
||||
listId,
|
||||
payload: { id: newItem.id, listId, text: newItem.text },
|
||||
});
|
||||
// Push: coalesced list-change notification to other accessible members (NOTIF-02)
|
||||
notifyListChange(listId, currentUserId)
|
||||
notifyListChange(listId, currentUserId);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
@@ -515,12 +508,12 @@ listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) =
|
||||
updatedAt: newItem.updatedAt,
|
||||
},
|
||||
201,
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[lists/POST /:id/items] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/POST /:id/items] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/lists/:id/items
|
||||
@@ -529,23 +522,23 @@ listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) =
|
||||
// Access-gated: owner OR sharee only (T-04-05).
|
||||
// ---------------------------------------------------------------------------
|
||||
listsRouter.get('/:id/items', async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const listId = Number(c.req.param('id'))
|
||||
const listId = Number(c.req.param('id'));
|
||||
|
||||
try {
|
||||
const access = await checkListAccess(listId, currentUserId)
|
||||
const access = await checkListAccess(listId, currentUserId);
|
||||
if (!access.allowed) {
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404)
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404);
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(listItems)
|
||||
.where(eq(listItems.listId, listId))
|
||||
.orderBy(asc(listItems.rank))
|
||||
.orderBy(asc(listItems.rank));
|
||||
|
||||
const result = items.map((item) => ({
|
||||
id: item.id,
|
||||
@@ -555,14 +548,14 @@ listsRouter.get('/:id/items', async (c) => {
|
||||
rank: item.rank,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
}))
|
||||
}));
|
||||
|
||||
return c.json({ items: result })
|
||||
return c.json({ items: result });
|
||||
} catch (err) {
|
||||
console.error('[lists/GET /:id/items] DB query failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/GET /:id/items] DB query failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/list-items/:itemId
|
||||
@@ -577,79 +570,74 @@ listsRouter.get('/:id/items', async (c) => {
|
||||
// T-04-09 — if item row is missing (deleted), 404 (no upsert).
|
||||
// ---------------------------------------------------------------------------
|
||||
listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const itemId = Number(c.req.param('itemId'))
|
||||
const patch = c.req.valid('json')
|
||||
const itemId = Number(c.req.param('itemId'));
|
||||
const patch = c.req.valid('json');
|
||||
|
||||
try {
|
||||
// Fetch the item to get its listId for access verification
|
||||
const [item] = await db
|
||||
.select()
|
||||
.from(listItems)
|
||||
.where(eq(listItems.id, itemId))
|
||||
.limit(1)
|
||||
const [item] = await db.select().from(listItems).where(eq(listItems.id, itemId)).limit(1);
|
||||
|
||||
// T-04-09: if deleted, 404 (no resurrection)
|
||||
if (!item) return c.json({ error: 'Not found' }, 404)
|
||||
if (!item) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const access = await checkListAccess(item.listId, currentUserId)
|
||||
const access = await checkListAccess(item.listId, currentUserId);
|
||||
if (!access.allowed) {
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404)
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404);
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
// Build the update payload — single-field write with updatedAt=NOW()
|
||||
const updateValues: {
|
||||
checked?: boolean
|
||||
text?: string
|
||||
rank?: string
|
||||
updatedAt?: Date
|
||||
} = {}
|
||||
checked?: boolean;
|
||||
text?: string;
|
||||
rank?: string;
|
||||
updatedAt?: Date;
|
||||
} = {};
|
||||
|
||||
if (patch.checked !== undefined) {
|
||||
updateValues.checked = patch.checked
|
||||
updateValues.checked = patch.checked;
|
||||
|
||||
// Open Question 2: uncheck → recompute rank to active-bottom
|
||||
if (patch.checked === false) {
|
||||
const [lastActive] = await db
|
||||
.select({ rank: listItems.rank })
|
||||
.from(listItems)
|
||||
.where(and(
|
||||
eq(listItems.listId, item.listId),
|
||||
eq(listItems.checked, false),
|
||||
sql`${listItems.id} != ${itemId}`,
|
||||
))
|
||||
.where(
|
||||
and(
|
||||
eq(listItems.listId, item.listId),
|
||||
eq(listItems.checked, false),
|
||||
sql`${listItems.id} != ${itemId}`,
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${listItems.rank} DESC`)
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
updateValues.rank = rankForAppend(lastActive?.rank ?? null)
|
||||
updateValues.rank = rankForAppend(lastActive?.rank ?? null);
|
||||
}
|
||||
} else if (patch.text !== undefined) {
|
||||
updateValues.text = patch.text
|
||||
updateValues.text = patch.text;
|
||||
} else if (patch.position !== undefined) {
|
||||
updateValues.rank = patch.position
|
||||
updateValues.rank = patch.position;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(listItems)
|
||||
.set(updateValues)
|
||||
.where(eq(listItems.id, itemId))
|
||||
await db.update(listItems).set(updateValues).where(eq(listItems.id, itemId));
|
||||
|
||||
// Re-fetch to return the updated row
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(listItems)
|
||||
.where(eq(listItems.id, itemId))
|
||||
.limit(1)
|
||||
const [updated] = await db.select().from(listItems).where(eq(listItems.id, itemId)).limit(1);
|
||||
|
||||
// Fan-out: notify accessible subscribers that an item was updated (LIST-04)
|
||||
publishListEvent(item.listId, { type: 'item:updated', listId: item.listId, payload: { id: updated.id, listId: updated.listId } })
|
||||
publishListEvent(item.listId, {
|
||||
type: 'item:updated',
|
||||
listId: item.listId,
|
||||
payload: { id: updated.id, listId: updated.listId },
|
||||
});
|
||||
// Push: coalesced list-change notification — only for meaningful changes (D-01).
|
||||
// Reorder (position) patches do NOT trigger a push; only checked/text changes do.
|
||||
if (patch.position === undefined) {
|
||||
notifyListChange(item.listId, currentUserId)
|
||||
notifyListChange(item.listId, currentUserId);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
@@ -660,12 +648,12 @@ listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c)
|
||||
rank: updated.rank,
|
||||
createdAt: updated.createdAt,
|
||||
updatedAt: updated.updatedAt,
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[lists/PATCH /list-items/:itemId] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/PATCH /list-items/:itemId] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/list-items/:itemId
|
||||
@@ -676,10 +664,10 @@ listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c)
|
||||
// Security: T-04-05 — list access check (owner OR sharee) before delete.
|
||||
// ---------------------------------------------------------------------------
|
||||
listItemsRouter.delete('/:itemId', async (c) => {
|
||||
const currentUserId = await resolveUserId(c)
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const itemId = Number(c.req.param('itemId'))
|
||||
const itemId = Number(c.req.param('itemId'));
|
||||
|
||||
try {
|
||||
// Fetch item to get listId for access check
|
||||
@@ -687,27 +675,31 @@ listItemsRouter.delete('/:itemId', async (c) => {
|
||||
.select({ id: listItems.id, listId: listItems.listId })
|
||||
.from(listItems)
|
||||
.where(eq(listItems.id, itemId))
|
||||
.limit(1)
|
||||
.limit(1);
|
||||
|
||||
if (!item) return c.json({ error: 'Not found' }, 404)
|
||||
if (!item) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const access = await checkListAccess(item.listId, currentUserId)
|
||||
const access = await checkListAccess(item.listId, currentUserId);
|
||||
if (!access.allowed) {
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404)
|
||||
return c.json({ error: 'Access denied' }, 403)
|
||||
if (access.notFound) return c.json({ error: 'Not found' }, 404);
|
||||
return c.json({ error: 'Access denied' }, 403);
|
||||
}
|
||||
|
||||
// Delete-wins (D-09): delete is final; no rollback path.
|
||||
await db.delete(listItems).where(eq(listItems.id, itemId))
|
||||
await db.delete(listItems).where(eq(listItems.id, itemId));
|
||||
|
||||
// Fan-out: notify accessible subscribers that an item was deleted (LIST-04)
|
||||
publishListEvent(item.listId, { type: 'item:deleted', listId: item.listId, payload: { id: itemId } })
|
||||
publishListEvent(item.listId, {
|
||||
type: 'item:deleted',
|
||||
listId: item.listId,
|
||||
payload: { id: itemId },
|
||||
});
|
||||
// Push: coalesced list-change notification to other accessible members (NOTIF-02)
|
||||
notifyListChange(item.listId, currentUserId)
|
||||
notifyListChange(item.listId, currentUserId);
|
||||
|
||||
return c.json({ id: itemId })
|
||||
return c.json({ id: itemId });
|
||||
} catch (err) {
|
||||
console.error('[lists/DELETE /list-items/:itemId] DB operation failed:', err)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
console.error('[lists/DELETE /list-items/:itemId] DB operation failed:', err);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
+16
-16
@@ -19,18 +19,18 @@
|
||||
* No credential or refresh-token data is included in the response (T-02-04).
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import { getAuth } from '../auth/middleware.js'
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js'
|
||||
import { Hono } from 'hono';
|
||||
import { getAuth } from '../auth/middleware.js';
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js'
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
export const meRouter = new Hono()
|
||||
export const meRouter = new Hono();
|
||||
|
||||
meRouter.get('/', async (c) => {
|
||||
// Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active.
|
||||
// Return the injected dev identity directly — no DB round-trip, no OIDC session needed.
|
||||
const devUser = c.get('user')
|
||||
const devUser = c.get('user');
|
||||
if (devUser) {
|
||||
return c.json({
|
||||
user: {
|
||||
@@ -38,30 +38,30 @@ meRouter.get('/', async (c) => {
|
||||
displayName: devUser.displayName,
|
||||
color: devUser.color,
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Normal OIDC path: getAuth returns null only if the session is invalid.
|
||||
// oidcAuthMiddleware on /api/* redirects unauthenticated requests before this handler
|
||||
// is reached, so null here indicates a genuine session error.
|
||||
const auth = await getAuth(c)
|
||||
const auth = await getAuth(c);
|
||||
if (!auth) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
// iss and sub are the stable identity fields — identity is always keyed on iss+sub (D-10).
|
||||
const iss = (auth.iss as string | undefined) ?? ''
|
||||
const sub = auth.sub ?? ''
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
|
||||
// Derive the best available display name from OIDC claims (name →
|
||||
// preferred_username → email → sub fallback). Shared helper keeps every
|
||||
// upsert call site in agreement (see deriveDisplayName).
|
||||
const displayName = deriveDisplayName(auth)
|
||||
const displayName = deriveDisplayName(auth);
|
||||
|
||||
const user = await upsertUser(iss, sub, displayName)
|
||||
const user = await upsertUser(iss, sub, displayName);
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Could not resolve user' }, 500)
|
||||
return c.json({ error: 'Could not resolve user' }, 500);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
@@ -70,5 +70,5 @@ meRouter.get('/', async (c) => {
|
||||
displayName: user.displayName,
|
||||
color: user.color,
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
+38
-38
@@ -12,19 +12,19 @@
|
||||
* Mounted under /api/push in index.ts.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import type { Context } from 'hono'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { z } from 'zod'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '../db/client.js'
|
||||
import { pushSubscriptions } from '../db/schema.js'
|
||||
import { getAuth } from '../auth/middleware.js'
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js'
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../db/client.js';
|
||||
import { pushSubscriptions } from '../db/schema.js';
|
||||
import { getAuth } from '../auth/middleware.js';
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js'
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
export const pushRouter = new Hono()
|
||||
export const pushRouter = new Hono();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helper — Duplicated per router (not extracted to shared module).
|
||||
@@ -35,17 +35,17 @@ export const pushRouter = new Hono()
|
||||
// 2. OIDC path: call getAuth(c). If null → unauthenticated, return null.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function resolveUserId(c: Context): Promise<number | null> {
|
||||
const devUser = c.get('user') as { id: number } | undefined
|
||||
if (devUser) return devUser.id
|
||||
const devUser = c.get('user') as { id: number } | undefined;
|
||||
if (devUser) return devUser.id;
|
||||
|
||||
const auth = await getAuth(c)
|
||||
if (!auth) return null
|
||||
const auth = await getAuth(c);
|
||||
if (!auth) return null;
|
||||
|
||||
const iss = (auth.iss as string | undefined) ?? ''
|
||||
const sub = auth.sub ?? ''
|
||||
const displayName = deriveDisplayName(auth)
|
||||
const user = await upsertUser(iss, sub, displayName)
|
||||
return user?.id ?? null
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
const displayName = deriveDisplayName(auth);
|
||||
const user = await upsertUser(iss, sub, displayName);
|
||||
return user?.id ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -63,7 +63,7 @@ const subscribeSchema = z.object({
|
||||
p256dh: z.string().min(1).max(512),
|
||||
auth: z.string().min(1).max(256),
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/push/vapid-public-key
|
||||
@@ -76,8 +76,8 @@ const subscribeSchema = z.object({
|
||||
// subscribe to push anyway).
|
||||
// ---------------------------------------------------------------------------
|
||||
pushRouter.get('/vapid-public-key', (c) => {
|
||||
return c.json({ publicKey: process.env.VAPID_PUBLIC_KEY ?? '' })
|
||||
})
|
||||
return c.json({ publicKey: process.env.VAPID_PUBLIC_KEY ?? '' });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/push/subscription
|
||||
@@ -90,10 +90,10 @@ pushRouter.get('/vapid-public-key', (c) => {
|
||||
// T-05-10 — zod subscribeSchema validates all fields before DB write.
|
||||
// ---------------------------------------------------------------------------
|
||||
pushRouter.post('/subscription', zValidator('json', subscribeSchema), async (c) => {
|
||||
const userId = await resolveUserId(c)
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const userId = await resolveUserId(c);
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const body = c.req.valid('json')
|
||||
const body = c.req.valid('json');
|
||||
|
||||
try {
|
||||
await db
|
||||
@@ -110,17 +110,17 @@ pushRouter.post('/subscription', zValidator('json', subscribeSchema), async (c)
|
||||
p256dh: body.keys.p256dh,
|
||||
auth: body.keys.auth,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return c.json({ ok: true }, 201)
|
||||
return c.json({ ok: true }, 201);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[push/POST /subscription] DB operation failed:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/push/subscription
|
||||
@@ -129,17 +129,17 @@ pushRouter.post('/subscription', zValidator('json', subscribeSchema), async (c)
|
||||
// Scoped to caller only — cannot delete another member's subscriptions (T-05-13).
|
||||
// ---------------------------------------------------------------------------
|
||||
pushRouter.delete('/subscription', async (c) => {
|
||||
const userId = await resolveUserId(c)
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const userId = await resolveUserId(c);
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
try {
|
||||
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.userId, userId))
|
||||
return c.json({ ok: true })
|
||||
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.userId, userId));
|
||||
return c.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[push/DELETE /subscription] DB operation failed:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
return c.json({ error: 'Service unavailable' }, 503)
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
+39
-39
@@ -11,34 +11,34 @@
|
||||
* RESEARCH Pattern 5 (heartbeat) + Finding 1 (lists scoped fan-out)
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import type { Context } from 'hono'
|
||||
import { streamSSE } from 'hono/streaming'
|
||||
import { getAuth } from '../auth/middleware.js'
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js'
|
||||
import { subscribeListEvents } from '../lib/listEmitter.js'
|
||||
import { getAccessibleListIds } from '../lib/listAccess.js'
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import { getAuth } from '../auth/middleware.js';
|
||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||
import { subscribeListEvents } from '../lib/listEmitter.js';
|
||||
import { getAccessibleListIds } from '../lib/listAccess.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js'
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
export const sseRouter = new Hono()
|
||||
export const sseRouter = new Hono();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helper — same pattern as lists.ts (per-router duplication convention).
|
||||
// Resolution order: dev-bypass user first, then OIDC.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function resolveUserId(c: Context): Promise<number | null> {
|
||||
const devUser = c.get('user') as { id: number } | undefined
|
||||
if (devUser) return devUser.id
|
||||
const devUser = c.get('user') as { id: number } | undefined;
|
||||
if (devUser) return devUser.id;
|
||||
|
||||
const auth = await getAuth(c)
|
||||
if (!auth) return null
|
||||
const auth = await getAuth(c);
|
||||
if (!auth) return null;
|
||||
|
||||
const iss = (auth.iss as string | undefined) ?? ''
|
||||
const sub = auth.sub ?? ''
|
||||
const displayName = deriveDisplayName(auth)
|
||||
const user = await upsertUser(iss, sub, displayName)
|
||||
return user?.id ?? null
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
const displayName = deriveDisplayName(auth);
|
||||
const user = await upsertUser(iss, sub, displayName);
|
||||
return user?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,17 +48,17 @@ async function resolveUserId(c: Context): Promise<number | null> {
|
||||
*/
|
||||
sseRouter.get('/heartbeat', (c) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
let id = 0
|
||||
let id = 0;
|
||||
while (!stream.aborted) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ ts: new Date().toISOString(), id }),
|
||||
event: 'heartbeat',
|
||||
id: String(id++),
|
||||
})
|
||||
await stream.sleep(10_000)
|
||||
});
|
||||
await stream.sleep(10_000);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /lists
|
||||
@@ -83,13 +83,13 @@ sseRouter.get('/heartbeat', (c) => {
|
||||
* D-12: PWA refetchInterval: 30000 polling fallback always active.
|
||||
*/
|
||||
sseRouter.get('/lists', async (c) => {
|
||||
const userId = await resolveUserId(c)
|
||||
if (userId === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const userId = await resolveUserId(c);
|
||||
if (userId === null) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const accessibleListIds = await getAccessibleListIds(userId)
|
||||
const accessibleListIds = await getAccessibleListIds(userId);
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const unsubscribers: Array<() => void> = []
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
|
||||
// Subscribe to each accessible list's channel (D-04 — scoped, not global)
|
||||
for (const listId of accessibleListIds) {
|
||||
@@ -97,31 +97,31 @@ sseRouter.get('/lists', async (c) => {
|
||||
// The handler signature is void-returning; wrap the async write in void+catch.
|
||||
// writeSSE errors are non-fatal — the SSE loop detects stream.aborted and cleans up.
|
||||
void (async () => {
|
||||
if (stream.aborted) return
|
||||
if (stream.aborted) return;
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
event: event.type,
|
||||
id: `${listId}-${Date.now()}`,
|
||||
})
|
||||
});
|
||||
})().catch((err: unknown) => {
|
||||
console.error('[sse] writeSSE error:', err)
|
||||
})
|
||||
})
|
||||
unsubscribers.push(unsub)
|
||||
console.error('[sse] writeSSE error:', err);
|
||||
});
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
}
|
||||
|
||||
// 30s heartbeat — keeps Pangolin connection alive (smoke-tested in Phase 1)
|
||||
let tick = 0
|
||||
let tick = 0;
|
||||
while (!stream.aborted) {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify({ ts: new Date().toISOString() }),
|
||||
event: 'heartbeat',
|
||||
id: String(tick++),
|
||||
})
|
||||
await stream.sleep(30_000)
|
||||
});
|
||||
await stream.sleep(30_000);
|
||||
}
|
||||
|
||||
// Cleanup all subscriptions on client disconnect
|
||||
unsubscribers.forEach((unsub) => unsub())
|
||||
})
|
||||
})
|
||||
unsubscribers.forEach((unsub) => unsub());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user