diff --git a/apps/dashboard/app/activity/page.tsx b/apps/dashboard/app/activity/page.tsx index 977a9e0..c36dd8f 100644 --- a/apps/dashboard/app/activity/page.tsx +++ b/apps/dashboard/app/activity/page.tsx @@ -12,7 +12,9 @@ import { } from "@guildpass/integration-client"; import type { ActivityChange } from "@guildpass/integration-client"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useGuild } from "@/lib/guild/GuildProvider";`nimport { usePathname, useRouter, useSearchParams } from "next/navigation";`nimport type { ActivitySortOrder } from "@/lib/activity/query"; +import { useGuild } from "@/lib/guild/GuildProvider"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import type { ActivitySortOrder } from "@/lib/activity/query"; const TYPE_ICON: Record = { "member.joined": "๐Ÿ‘ค", @@ -94,7 +96,17 @@ function readLimit(value: string | null): number { return PAGE_SIZE_OPTIONS.includes(parsed as (typeof PAGE_SIZE_OPTIONS)[number]) ? parsed : 10; } export default function ActivityPage() { - const { guildId, guild } = useGuild();`n const router = useRouter();`n const pathname = usePathname();`n const searchParams = useSearchParams();`n const [type, setType] = useState(() => (searchParams.get("type") as ActivityEventType | null) ?? "");`n const [source, setSource] = useState(() => (searchParams.get("source") as ActivityEventSource | null) ?? "");`n const [severity, setSeverity] = useState(() => (searchParams.get("severity") as ActivityEventSeverity | null) ?? "");`n const [actor, setActor] = useState(() => searchParams.get("actor") ?? "");`n const [from, setFrom] = useState(() => searchParams.get("from") ?? "");`n const [sort, setSort] = useState(() => readSort(searchParams.get("sort")));`n const [limit, setLimit] = useState(() => readLimit(searchParams.get("limit"))); + const { guildId, guild } = useGuild(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [type, setType] = useState(() => (searchParams.get("type") as ActivityEventType | null) ?? ""); + const [source, setSource] = useState(() => (searchParams.get("source") as ActivityEventSource | null) ?? ""); + const [severity, setSeverity] = useState(() => (searchParams.get("severity") as ActivityEventSeverity | null) ?? ""); + const [actor, setActor] = useState(() => searchParams.get("actor") ?? ""); + const [from, setFrom] = useState(() => searchParams.get("from") ?? ""); + const [sort, setSort] = useState(() => readSort(searchParams.get("sort"))); + const [limit, setLimit] = useState(() => readLimit(searchParams.get("limit"))); const { intervalMs } = getActivityRefreshConfig(); const updateActivityQuery = useCallback( (updates: { @@ -108,7 +120,12 @@ export default function ActivityPage() { }) => { const next = new URLSearchParams(searchParams.toString()); const setOrDelete = (key: string, value: string) => { - value.trim() ? next.set(key, value.trim()) : next.delete(key); + const trimmed = value.trim(); + if (trimmed) { + next.set(key, trimmed); + } else { + next.delete(key); + } }; if (updates.type !== undefined) setOrDelete("type", updates.type); @@ -117,10 +134,18 @@ export default function ActivityPage() { if (updates.actor !== undefined) setOrDelete("actor", updates.actor); if (updates.from !== undefined) setOrDelete("from", updates.from); if (updates.sort !== undefined) { - updates.sort === "newest" ? next.delete("sort") : next.set("sort", updates.sort); + if (updates.sort === "newest") { + next.delete("sort"); + } else { + next.set("sort", updates.sort); + } } if (updates.limit !== undefined) { - updates.limit === 10 ? next.delete("limit") : next.set("limit", String(updates.limit)); + if (updates.limit === 10) { + next.delete("limit"); + } else { + next.set("limit", String(updates.limit)); + } } const query = next.toString(); @@ -170,7 +195,8 @@ export default function ActivityPage() { source: source || undefined, severity: severity || undefined, actor: actor.trim() || undefined, - from: fromIso,`n sort, + from: fromIso, + sort, autoRefresh: true, simulate: false, guildId, @@ -183,7 +209,11 @@ export default function ActivityPage() { setSource(""); setSeverity(""); setActor(""); - setFrom("");`n setSort("newest");`n setLimit(10);`n updateActivityQuery({ type: "", source: "", severity: "", actor: "", from: "", sort: "newest", limit: 10 });`n }; + setFrom(""); + setSort("newest"); + setLimit(10); + updateActivityQuery({ type: "", source: "", severity: "", actor: "", from: "", sort: "newest", limit: 10 }); + }; return ( { const guard = await requireSessionAndPermission(request, getActiveGuildId(request), "activity:read"); if (!guard.ok) return guard.response; + // Reconnecting clients identify their last received event via the native + // Last-Event-ID header (automatic EventSource retry) or an explicit query + // param (manual reconnect). Snapshot before subscribing so anything + // published in between arrives through the live subscription instead. + const resumeCursor = + request.headers.get("Last-Event-ID") ?? + new URL(request.url).searchParams.get("lastEventId"); + const missedEvents = resumeCursor + ? getEventsAfterCursor(await activityStorage.getEvents(), resumeCursor) + : []; + let dispose = () => {}; const stream = new ReadableStream({ start(controller) { @@ -73,6 +86,9 @@ export async function GET(request: Request): Promise { }; enqueueFrame(READY_FRAME); + for (const missed of missedEvents) { + enqueueFrame(encodeActivityEvent(missed)); + } if (request.signal.aborted) { onAbort(); } else { diff --git a/apps/dashboard/app/api/admin/reconcile/route.ts b/apps/dashboard/app/api/admin/reconcile/route.ts index 2ac9b3b..3fb8849 100644 --- a/apps/dashboard/app/api/admin/reconcile/route.ts +++ b/apps/dashboard/app/api/admin/reconcile/route.ts @@ -31,18 +31,18 @@ import { getActiveGuildId } from "@/lib/guild-context"; // โ”€โ”€ Counting strategies โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // // Same defaults as the CLI script. In production, these should use direct SQL -// queries for performance. For mock mode, they count all entries since members -// and passes are not yet partitioned by guild in the mock data model. +// queries for performance. Member and pass repositories are tenant-scoped, so +// counts are always per guild (see docs/multi-tenancy.md). -async function countMembersForGuild(_guildId: string): Promise { +async function countMembersForGuild(guildId: string): Promise { const memberRepo = getMemberRepository(); - const all = await memberRepo.getAll(); + const all = await memberRepo.getAll(guildId); return all.length; } -async function countPassesForGuild(_guildId: string): Promise { +async function countPassesForGuild(guildId: string): Promise { const passRepo = getPassRepository(); - const all = await passRepo.getAll(); + const all = await passRepo.getAll(guildId); return all.length; } diff --git a/apps/dashboard/app/api/guilds/route.ts b/apps/dashboard/app/api/guilds/route.ts index dc6bb8e..32a11f5 100644 --- a/apps/dashboard/app/api/guilds/route.ts +++ b/apps/dashboard/app/api/guilds/route.ts @@ -41,37 +41,6 @@ export async function GET(): Promise { * โš ๏ธ In production, resolve the session from the request (JWT / cookie) * instead of using MOCK_SESSION, then assertPermission against it. */ -export async function POST(request: Request): Promise { - try { - assertCsrfToken(request); - } catch (err) { - if (err instanceof CsrfError) { - return apiError(err.message, 403); - } - throw err; - } - - try { - assertPermission(MOCK_API_SESSION, "guilds:write"); - } catch (err) { - if (err instanceof PermissionDeniedError) { - return apiError(err.message, 403); - } - throw err; - } - - return handleApiError(async () => { - // TODO: implement guild request: Request): Promise { - try { - assertCsrfToken(request); - } catch (err) { - if (err instanceof CsrfError) { - return apiError(err.message, 403); - } - throw err; - } - - return { message: "Guild created (stub)" }; export async function POST(request: Request): Promise { const guard = await requireSessionAndPermission(request, getActiveGuildId(request), "guilds:write"); if (!guard.ok) return guard.response; diff --git a/apps/dashboard/app/api/members/route.ts b/apps/dashboard/app/api/members/route.ts index 2e62482..8c02bed 100644 --- a/apps/dashboard/app/api/members/route.ts +++ b/apps/dashboard/app/api/members/route.ts @@ -36,6 +36,16 @@ export async function GET(request: Request): Promise { const query = parseMemberListQuery(request); if (apiMode === "live") { + // Live mode only supports direct lookups; a bare list request is + // unsupported โ€” reject it before constructing a client we won't use. + if (!wallet && !discordUserId) { + return apiUnsupported( + "members.list", + apiMode, + "Live mode requires a lookup (wallet or discordUserId)" + ); + } + const testClient = (globalThis as any).__TEST_INTEGRATION_CLIENT; let client; @@ -191,8 +201,9 @@ export async function PATCH(request: Request): Promise { const memberRepository = getMemberRepository(); const guildId = getActiveGuildId(request); + const { version: expectedVersion, ...updateData } = validation.data; const existing = validation.data.roles ? await memberRepository.getById(guildId, id) : null; - const updated = await memberRepository.update(guildId, id, validation.data, expectedVersion); + const updated = await memberRepository.update(guildId, id, updateData, expectedVersion); if (!updated) throw new NotFoundError("Member not found."); const rolesChanged = existing && validation.data.roles && JSON.stringify(existing.roles) !== JSON.stringify(validation.data.roles); if (rolesChanged) { diff --git a/apps/dashboard/app/api/passes/route.ts b/apps/dashboard/app/api/passes/route.ts index dc40131..41dea75 100644 --- a/apps/dashboard/app/api/passes/route.ts +++ b/apps/dashboard/app/api/passes/route.ts @@ -6,6 +6,7 @@ import { } from "@/lib/api-helpers"; import { NotFoundError } from "@/lib/api-errors"; import { mockPasses, type Pass } from "@/lib/mock-data"; +import { getActiveGuildId } from "@/lib/guild-context"; import { requireSessionAndPermission } from "@/lib/auth/require-permission"; import { getApiMode } from "@/lib/env"; import { getPassRepository } from "@/lib/repositories/factory"; @@ -37,11 +38,7 @@ export async function GET( try { const passRepository = getPassRepository(); -<<<<<<< HEAD - return await passRepository.query(query); -======= return await passRepository.query(getActiveGuildId(request), query); ->>>>>>> main } catch (error) { console.error("Error fetching passes:", error); return getFallbackPasses(request, query); @@ -66,15 +63,10 @@ function isPassStatus(value: string | null): value is Pass["status"] { return value !== null && PASS_STATUSES.includes(value as Pass["status"]); } -<<<<<<< HEAD -function getFallbackPasses(query: PassListQuery) { - const filtered = filterPasses(mockPasses, query); -======= function getFallbackPasses(request: Request, query: PassListQuery) { const guildId = getActiveGuildId(request); const scoped = mockPasses.filter((pass) => pass.guildId === guildId); const filtered = filterPasses(scoped, query); ->>>>>>> main return paginateItems(filtered, query); } @@ -97,11 +89,7 @@ export async function POST(request: Request): Promise { } const passRepository = getPassRepository(); -<<<<<<< HEAD - const created = await passRepository.create(validation.data); -======= const created = await passRepository.create(getActiveGuildId(request), validation.data); ->>>>>>> main await recordDashboardActivity({ type: "pass.created", entity: { type: "pass", id: created.id, name: created.name }, @@ -139,11 +127,7 @@ export async function PATCH(request: Request): Promise { } const passRepository = getPassRepository(); -<<<<<<< HEAD - const updated = await passRepository.update(id, validation.data); -======= const updated = await passRepository.update(getActiveGuildId(request), id, validation.data); ->>>>>>> main if (!updated) throw new NotFoundError("Pass not found."); await recordDashboardActivity({ type: "pass.updated", @@ -170,14 +154,10 @@ export async function DELETE(request: Request): Promise { return handleApiError(async () => { const passRepository = getPassRepository(); -<<<<<<< HEAD - const pass = await passRepository.getById(id); -======= const guildId = getActiveGuildId(request); const pass = await passRepository.getById(guildId, id); ->>>>>>> main if (!pass) throw new NotFoundError("Pass not found."); - const success = await passRepository.delete(id); + const success = await passRepository.delete(guildId, id); if (!success) throw new NotFoundError("Pass not found."); await recordDashboardActivity({ type: "pass.deleted", diff --git a/apps/dashboard/app/api/verify/route.ts b/apps/dashboard/app/api/verify/route.ts index 17af94e..19b29db 100644 --- a/apps/dashboard/app/api/verify/route.ts +++ b/apps/dashboard/app/api/verify/route.ts @@ -6,7 +6,7 @@ handleApiError, } from "@/lib/api-helpers"; import { validateLiveModeEnv, getApiMode } from "@/lib/env"; import { IntegrationClient, type VerificationResult } from "@guildpass/integration-client"; -import { isValidChecksumAddress, normaliseAddress } from "@/dashboard/lib/address"; +import { isValidChecksumAddress, normaliseAddress } from "@/lib/address"; export async function POST(request: Request): Promise { return handleApiError(async () => { diff --git a/apps/dashboard/app/guilds/page.tsx b/apps/dashboard/app/guilds/page.tsx index b444c69..f8c9ad0 100644 --- a/apps/dashboard/app/guilds/page.tsx +++ b/apps/dashboard/app/guilds/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { getClientApiMode } from '@/lib/client-env'; import DashboardLayout from "@/components/DashboardLayout"; import UnsupportedBanner from "@/components/UnsupportedBanner"; +import EmptyState from "@/components/EmptyState"; import { mockGuilds, type Guild as MockGuild } from "@/lib/mock-data"; import { useEffect, useState, useRef } from "react"; import { useSession } from "@/lib/hooks/useSession"; @@ -140,16 +141,6 @@ export default function GuildsPage() { )} {listState !== "unsupported" && ( -<<<<<<< HEAD -
- {guilds.map((guild) => { - const isPending = pendingIds.has(guild.id); - return ( -
-
-

{guild.name}

- {isPending && updating...} -======= guilds.length === 0 ? ( )}
->>>>>>> main
-

{guild.description}

-
-
- Members: - {guild.memberCount} -
-
- Passes: - {guild.passCount} -
-
- - {canWrite && ( -
- - ร‚ยท - -
- )} -
- ); - })} - + ); + })} + + ) )}
); diff --git a/apps/dashboard/app/members/page.tsx b/apps/dashboard/app/members/page.tsx index 8ca8bdc..5a282c5 100644 --- a/apps/dashboard/app/members/page.tsx +++ b/apps/dashboard/app/members/page.tsx @@ -25,7 +25,8 @@ import { invalidateAfterMutation, queryKeys, } from "@/lib/cache/query-cache"; -import { useQueryInvalidation } from "@/lib/cache/use-query-invalidation";`nimport { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useQueryInvalidation } from "@/lib/cache/use-query-invalidation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; type ListState = "loading" | "loaded" | "unsupported" | "error"; @@ -107,19 +108,39 @@ export default function MembersPage() { if (updates.search !== undefined) { const value = updates.search.trim(); - value ? next.set("search", value) : next.delete("search"); + if (value) { + next.set("search", value); + } else { + next.delete("search"); + } } if (updates.status !== undefined) { - updates.status === "all" ? next.delete("status") : next.set("status", updates.status); + if (updates.status === "all") { + next.delete("status"); + } else { + next.set("status", updates.status); + } } if (updates.role !== undefined) { - updates.role === "all" ? next.delete("role") : next.set("role", updates.role); + if (updates.role === "all") { + next.delete("role"); + } else { + next.set("role", updates.role); + } } if (updates.guild !== undefined) { - updates.guild ? next.set("guild", updates.guild) : next.delete("guild"); + if (updates.guild) { + next.set("guild", updates.guild); + } else { + next.delete("guild"); + } } if (updates.page !== undefined) { - updates.page && updates.page > 1 ? next.set("page", String(updates.page)) : next.delete("page"); + if (updates.page && updates.page > 1) { + next.set("page", String(updates.page)); + } else { + next.delete("page"); + } } const query = next.toString(); diff --git a/apps/dashboard/app/settings/page.tsx b/apps/dashboard/app/settings/page.tsx index 44ed8d5..ae3f453 100644 --- a/apps/dashboard/app/settings/page.tsx +++ b/apps/dashboard/app/settings/page.tsx @@ -24,13 +24,8 @@ import { canEditSettings } from "@/lib/permissions"; import { useOptimisticMutation } from "@/lib/hooks/useOptimisticMutation"; import { readApiResult } from "@/lib/api-client"; import type { DashboardSettings } from "@/lib/settings"; -<<<<<<< HEAD -import { useState, useRef, useEffect } from "react"; -======= -import { SUPPORTED_TIMEZONES } from "@/lib/timezones"; import { validateEmailField } from "@/lib/validation/settings"; import { useState, useRef, useEffect, useCallback } from "react"; ->>>>>>> main export default function SettingsPage() { const session = useSession(); diff --git a/apps/dashboard/components/Sidebar.tsx b/apps/dashboard/components/Sidebar.tsx index c5e7d89..a865739 100644 --- a/apps/dashboard/components/Sidebar.tsx +++ b/apps/dashboard/components/Sidebar.tsx @@ -36,7 +36,7 @@ export default function Sidebar({ const pathname = usePathname(); const router = useRouter(); const guildCtx = useOptionalGuild(); - const badge = session ? ROLE_BADGE[session.role] : null; + const badge = session?.role ? ROLE_BADGE[session.role] : null; const handleGuildChange = (nextId: string) => { if (!guildCtx || nextId === guildCtx.guildId) return; diff --git a/apps/dashboard/lib/activity/client-stream.ts b/apps/dashboard/lib/activity/client-stream.ts index 442897b..ac2ea38 100644 --- a/apps/dashboard/lib/activity/client-stream.ts +++ b/apps/dashboard/lib/activity/client-stream.ts @@ -6,6 +6,11 @@ export interface ActivityEventSourceLike { close(): void; } +export interface ActivityStreamCursor { + lastEventId: string | null; + lastEventTimestamp: string | null; +} + export interface ActivityStreamConnectionOptions { connectionTimeoutMs?: number; createEventSource?: (url: string) => ActivityEventSourceLike; @@ -13,11 +18,32 @@ export interface ActivityStreamConnectionOptions { onEvent: (event: ActivityEvent) => void; onFallback: () => void; onReady?: () => void; + /** Called after a dropped stream reconnects, with the cursor to backfill from. */ + onReconnect?: (cursor: ActivityStreamCursor) => void; + /** Consecutive failed attempts before giving up to the polling fallback. */ + maxReconnectAttempts?: number; + reconnectBaseMs?: number; + reconnectCapMs?: number; + /** Injected for tests; defaults to Math.random. */ + random?: () => number; url?: string; } const DEFAULT_CONNECTION_TIMEOUT_MS = 10_000; const DEFAULT_HEARTBEAT_TIMEOUT_MS = 45_000; +const DEFAULT_MAX_RECONNECT_ATTEMPTS = 5; +const DEFAULT_RECONNECT_BASE_MS = 1_000; +const DEFAULT_RECONNECT_CAP_MS = 30_000; + +export function reconnectDelayMs( + attempt: number, + baseMs: number, + capMs: number, + random: () => number = Math.random +): number { + const exponential = Math.min(capMs, baseMs * 2 ** attempt); + return Math.round(exponential * (0.75 + random() * 0.5)); +} export function connectActivityStream({ connectionTimeoutMs = DEFAULT_CONNECTION_TIMEOUT_MS, @@ -26,13 +52,25 @@ export function connectActivityStream({ onEvent, onFallback, onReady = () => {}, + onReconnect = () => {}, + maxReconnectAttempts = DEFAULT_MAX_RECONNECT_ATTEMPTS, + reconnectBaseMs = DEFAULT_RECONNECT_BASE_MS, + reconnectCapMs = DEFAULT_RECONNECT_CAP_MS, + random = Math.random, url = "/api/activity/stream", }: ActivityStreamConnectionOptions): () => void { let source: ActivityEventSourceLike | null = null; let stopped = false; let fallbackStarted = false; let ready = false; + let everConnected = false; + let failedAttempts = 0; let watchdog: ReturnType | null = null; + let reconnectTimer: ReturnType | null = null; + const cursor: ActivityStreamCursor = { + lastEventId: null, + lastEventTimestamp: null, + }; const clearWatchdog = () => { if (watchdog === null) return; @@ -40,9 +78,15 @@ export function connectActivityStream({ watchdog = null; }; + const clearReconnectTimer = () => { + if (reconnectTimer === null) return; + clearTimeout(reconnectTimer); + reconnectTimer = null; + }; + const armWatchdog = (timeoutMs: number) => { clearWatchdog(); - watchdog = setTimeout(startFallback, timeoutMs); + watchdog = setTimeout(handleDrop, timeoutMs); }; const markAlive = () => { @@ -53,7 +97,11 @@ export function connectActivityStream({ const onActivity = ((rawEvent: Event) => { markAlive(); const event = parseActivityEvent(rawEvent); - if (event) onEvent(event); + if (!event) return; + const messageId = (rawEvent as { lastEventId?: unknown }).lastEventId; + cursor.lastEventId = typeof messageId === "string" && messageId ? messageId : event.id; + cursor.lastEventTimestamp = event.timestamp; + onEvent(event); }) as EventListener; const onHeartbeat = (() => { @@ -62,9 +110,16 @@ export function connectActivityStream({ const onReadyEvent = (() => { const firstReady = !ready; + const resumed = everConnected && failedAttempts > 0; ready = true; + everConnected = true; + failedAttempts = 0; markAlive(); - if (firstReady) onReady(); + if (resumed) { + onReconnect({ ...cursor }); + } else if (firstReady) { + onReady(); + } }) as EventListener; const detach = () => { @@ -81,27 +136,51 @@ export function connectActivityStream({ const startFallback = () => { if (stopped || fallbackStarted) return; fallbackStarted = true; + clearReconnectTimer(); detach(); onFallback(); }; - const onError = (() => { - startFallback(); - }) as EventListener; + const connect = () => { + if (stopped || fallbackStarted) return; + const attemptUrl = cursor.lastEventId + ? `${url}${url.includes("?") ? "&" : "?"}lastEventId=${encodeURIComponent(cursor.lastEventId)}` + : url; + try { + source = createEventSource(attemptUrl); + source.addEventListener("activity", onActivity); + source.addEventListener("error", onError); + source.addEventListener("heartbeat", onHeartbeat); + source.addEventListener("ready", onReadyEvent); + armWatchdog(connectionTimeoutMs); + } catch { + handleDrop(); + } + }; - try { - source = createEventSource(url); - source.addEventListener("activity", onActivity); - source.addEventListener("error", onError); - source.addEventListener("heartbeat", onHeartbeat); - source.addEventListener("ready", onReadyEvent); - armWatchdog(connectionTimeoutMs); - } catch { - startFallback(); + function handleDrop() { + if (stopped || fallbackStarted) return; + detach(); + ready = false; + if (failedAttempts >= maxReconnectAttempts) { + startFallback(); + return; + } + const delay = reconnectDelayMs(failedAttempts, reconnectBaseMs, reconnectCapMs, random); + failedAttempts += 1; + clearReconnectTimer(); + reconnectTimer = setTimeout(connect, delay); + } + + function onError() { + handleDrop(); } + connect(); + return () => { stopped = true; + clearReconnectTimer(); detach(); }; } diff --git a/apps/dashboard/lib/activity/mapper.ts b/apps/dashboard/lib/activity/mapper.ts index c67aed8..4bc1b38 100644 --- a/apps/dashboard/lib/activity/mapper.ts +++ b/apps/dashboard/lib/activity/mapper.ts @@ -55,8 +55,6 @@ export function mapWebhookToActivity(payload: WebhookPayload): ActivityEvent | n timestamp, entity: { type: "member", - id: data.id ?? "", - name: data.name, id: entityId(data.id, data.wallet, data.name), name: typeof data.name === "string" ? data.name : undefined, }, @@ -80,8 +78,6 @@ export function mapWebhookToActivity(payload: WebhookPayload): ActivityEvent | n timestamp, entity: { type: "pass", - id: data.id ?? "", - name: data.name, id: entityId(data.id, data.name), name: typeof data.name === "string" ? data.name : undefined, }, @@ -105,8 +101,6 @@ export function mapWebhookToActivity(payload: WebhookPayload): ActivityEvent | n timestamp, entity: { type: "pass", - id: data.id ?? "", - name: data.name, id: entityId(data.id, data.name), name: typeof data.name === "string" ? data.name : undefined, }, diff --git a/apps/dashboard/lib/activity/query.ts b/apps/dashboard/lib/activity/query.ts index 90e5ecd..478a810 100644 --- a/apps/dashboard/lib/activity/query.ts +++ b/apps/dashboard/lib/activity/query.ts @@ -46,9 +46,13 @@ const ENTITY_TYPES = new Set([ "member", "verification", "webhook", -]);`n`nconst SORT_ORDERS = new Set(["newest", "oldest"]); +]); + +const SORT_ORDERS = new Set(["newest", "oldest"]); -export type ActivitySortOrder = "newest" | "oldest";`n`nexport interface ActivityQuery { +export type ActivitySortOrder = "newest" | "oldest"; + +export interface ActivityQuery { limit?: number; cursor?: string; type?: ActivityEventType; @@ -56,7 +60,9 @@ export type ActivitySortOrder = "newest" | "oldest";`n`nexport interface Activit severity?: ActivityEventSeverity; entityType?: ActivityEventEntity["type"]; actor?: string; - from?: string;`n sort?: ActivitySortOrder;`n} + from?: string; + sort?: ActivitySortOrder; +} export interface ActivityQueryResult { events: ActivityEvent[]; @@ -148,7 +154,11 @@ export function parseActivityQuery( query.actor = actor.toLowerCase(); } - readEnum(searchParams, "sort", SORT_ORDERS, errors, (value) => {`n query.sort = value;`n });`n`n const from = searchParams.get("from"); + readEnum(searchParams, "sort", SORT_ORDERS, errors, (value) => { + query.sort = value; + }); + + const from = searchParams.get("from"); if (from) { const timestamp = new Date(from).getTime(); if (Number.isNaN(timestamp)) { @@ -183,7 +193,12 @@ function clampLimit(limit: number): number { return Math.min(Math.max(limit, 1), MAX_ACTIVITY_LIMIT); } -function compareActivityEvents(a: ActivityEvent, b: ActivityEvent, sort: ActivitySortOrder): number {`n const newestFirst = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();`n const timeDiff = sort === "oldest" ? -newestFirst : newestFirst;`n if (timeDiff !== 0) return timeDiff;`n return sort === "oldest" ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id);`n} +function compareActivityEvents(a: ActivityEvent, b: ActivityEvent, sort: ActivitySortOrder = "newest"): number { + const newestFirst = new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); + const timeDiff = sort === "oldest" ? -newestFirst : newestFirst; + if (timeDiff !== 0) return timeDiff; + return sort === "oldest" ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id); +} function matchesActor(event: ActivityEvent, actorFilter: string): boolean { return [event.actor.id, event.actor.name, event.actor.wallet] diff --git a/apps/dashboard/lib/activity/stream.ts b/apps/dashboard/lib/activity/stream.ts index 5ef934e..a72e6b4 100644 --- a/apps/dashboard/lib/activity/stream.ts +++ b/apps/dashboard/lib/activity/stream.ts @@ -36,7 +36,24 @@ export function subscribeToActivityEvents( } export function encodeActivityEvent(event: ActivityEvent): string { - return `event: activity\ndata: ${JSON.stringify(event)}\n\n`; + // The id line lets native EventSource clients track lastEventId and send + // Last-Event-ID on reconnect, which the stream route replays from. + return `id: ${event.id}\nevent: activity\ndata: ${JSON.stringify(event)}\n\n`; +} + +/** + * Returns the events newer than `cursor` (an event id), oldest first, ready + * to replay to a reconnecting client. Events are expected newest-first. + * Returns [] when the cursor is unknown (e.g. evicted from storage) โ€” the + * client's REST backfill covers that case. + */ +export function getEventsAfterCursor( + events: ActivityEvent[], + cursor: string +): ActivityEvent[] { + const cursorIndex = events.findIndex((event) => event.id === cursor); + if (cursorIndex <= 0) return []; + return events.slice(0, cursorIndex).reverse(); } export function getActivitySubscriberCount(): number { diff --git a/apps/dashboard/lib/activity/validation.ts b/apps/dashboard/lib/activity/validation.ts index 510ec93..7ca2850 100644 --- a/apps/dashboard/lib/activity/validation.ts +++ b/apps/dashboard/lib/activity/validation.ts @@ -87,7 +87,7 @@ export function validateWebhookPayload(rawBody: string): ValidationResult { } // Additional semantic validation: ensure wallets are checksummed and normalise them const parsedData = dataResult.data as Record; - const walletField = (parsedData.wallet as string) | undefined; + const walletField = parsedData.wallet as string | undefined; if (walletField) { if (!isValidChecksumAddress(walletField)) { return { valid: false, error: `data.wallet: wallet must be a checksummed Ethereum address`, field: "data.wallet" }; diff --git a/apps/dashboard/lib/auth/session-store.ts b/apps/dashboard/lib/auth/session-store.ts index 516323e..199edd7 100644 --- a/apps/dashboard/lib/auth/session-store.ts +++ b/apps/dashboard/lib/auth/session-store.ts @@ -297,7 +297,7 @@ export function createSessionStore(): SessionStore { sid: sessionId, name, role, - permissions: ROLE_PERMISSIONS[role], + permissions: [...ROLE_PERMISSIONS[role]], iat: nowSec, exp: nowSec + ACCESS_TOKEN_TTL, }; diff --git a/apps/dashboard/lib/auth/session.ts b/apps/dashboard/lib/auth/session.ts index 13599f0..8cfe253 100644 --- a/apps/dashboard/lib/auth/session.ts +++ b/apps/dashboard/lib/auth/session.ts @@ -45,30 +45,24 @@ export type GuildRoles = Record; export interface Session { userId: string; name: string; - /** The user's single assigned role */ - role: Role; - /** - * Flat list of permissions granted to this session. - * Derived from ROLE_PERMISSIONS[role] at session-creation time so that - * individual permission checks are O(1) array includes. - */ - permissions: Permission[]; + roles: GuildRoles; + /** Guild selected by the current dashboard context. */ + activeGuildId: string; /** * CSRF token bound to this session. * Used for the double-submit cookie pattern: the server sets this as a cookie, * and the client reads the cookie and sends it back as an X-CSRF-Token header * on every mutating request. This prevents cross-site request forgery attacks. + * Optional: only cookie-based browser sessions carry one โ€” sessions resolved + * from bearer tokens (Authorization header) are not CSRF-vulnerable and do + * not need it. */ - csrfToken: string; -} - roles: GuildRoles; - /** Guild selected by the current dashboard context. */ - activeGuildId: string; + csrfToken?: string; /** @deprecated Migration-only compatibility field. Do not authorize with it. */ role?: Role; /** @deprecated Migration-only compatibility field. Do not authorize with it. */ - permissions?: Permission[]; + permissions: Permission[]; } export const ROLE_PERMISSIONS: Record = { @@ -84,6 +78,7 @@ function createMockSession(role: Role, userId: string, name: string): Session { name, roles: { [DEFAULT_GUILD_ID]: role }, activeGuildId: DEFAULT_GUILD_ID, + csrfToken: MOCK_CSRF_TOKEN, // Retained temporarily for local integrations that only render the badge. role, permissions: [...ROLE_PERMISSIONS[role]], @@ -92,34 +87,6 @@ function createMockSession(role: Role, userId: string, name: string): Session { /** Single-guild mock sessions remain convenient for local development. */ export const MOCK_SESSIONS: Record = { - owner: { - userId: "mock-owner-001", - name: "Owner Alice", - role: "owner", - permissions: ROLE_PERMISSIONS.owner, - csrfToken: MOCK_CSRF_TOKEN, - }, - admin: { - userId: "mock-admin-001", - name: "Admin Bob", - role: "admin", - permissions: ROLE_PERMISSIONS.admin, - csrfToken: MOCK_CSRF_TOKEN, - }, - moderator: { - userId: "mock-moderator-001", - name: "Moderator Charlie", - role: "moderator", - permissions: ROLE_PERMISSIONS.moderator, - csrfToken: MOCK_CSRF_TOKEN, - }, - readonly: { - userId: "mock-readonly-001", - name: "Viewer Diana", - role: "readonly", - permissions: ROLE_PERMISSIONS.readonly, - csrfToken: MOCK_CSRF_TOKEN, - }, owner: createMockSession("owner", "mock-owner-001", "Owner Alice"), admin: createMockSession("admin", "mock-admin-001", "Admin Bob"), moderator: createMockSession("moderator", "mock-moderator-001", "Moderator Charlie"), diff --git a/apps/dashboard/lib/data/activity-service.ts b/apps/dashboard/lib/data/activity-service.ts index 17b252c..4c19fb7 100644 --- a/apps/dashboard/lib/data/activity-service.ts +++ b/apps/dashboard/lib/data/activity-service.ts @@ -19,7 +19,7 @@ class ActivityService { /** * Create a new activity event and store it */ - async createEvent(event: Omit & Partial>): Promise { + async createEvent(event: Omit & Partial>): Promise { const fullEvent: ActivityEvent = { ...event, id: `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, diff --git a/apps/dashboard/lib/env.ts b/apps/dashboard/lib/env.ts index 6de774a..49a61a9 100644 --- a/apps/dashboard/lib/env.ts +++ b/apps/dashboard/lib/env.ts @@ -17,6 +17,7 @@ */ import { + dashboardEnvBaseSchema, dashboardEnvSchema, validateEnv, type DashboardEnv, @@ -25,17 +26,23 @@ import { PublicApiError } from "./api-errors"; const isServer = typeof window === "undefined"; -let cachedEnv: DashboardEnv | null = null; - /** - * Validate `process.env` against the dashboard schema (once, then cached). + * Validate `process.env` against the field-level dashboard schema. * Server-only: throws `EnvValidationError` with an actionable message naming - * every missing or invalid variable. + * every invalid variable. Validation runs on each call (it is + * sub-millisecond for this schema) so callers always see the current + * process.env โ€” a cached snapshot would go stale in tests and in any runtime + * that rotates env. + * + * Plain reads use the field-level schema only: cross-field, mode-dependent + * requirements (live mode needs core credentials, durable mode needs + * DATABASE_URL) are enforced by `validateStartupEnv` and by the consumers + * that actually need those values (e.g. `validateLiveModeEnv`), so a + * misconfigured optional mode fails with a clear error at the point of use + * instead of breaking every unrelated env read. */ function getValidatedEnv(): DashboardEnv { - if (cachedEnv) return cachedEnv; - cachedEnv = validateEnv(dashboardEnvSchema); - return cachedEnv; + return validateEnv(dashboardEnvBaseSchema); } /** @@ -44,7 +51,7 @@ function getValidatedEnv(): DashboardEnv { * request handler. Returns the typed, validated environment. */ export function validateStartupEnv(): DashboardEnv { - return getValidatedEnv(); + return validateEnv(dashboardEnvSchema); } /** diff --git a/apps/dashboard/lib/hooks/useActivityFeed.ts b/apps/dashboard/lib/hooks/useActivityFeed.ts index e577c03..1c22de5 100644 --- a/apps/dashboard/lib/hooks/useActivityFeed.ts +++ b/apps/dashboard/lib/hooks/useActivityFeed.ts @@ -68,7 +68,9 @@ export function useActivityFeed({ severity, entityType, actor, - from,`n sort,`n refreshIntervalMs, + from, + sort, + refreshIntervalMs, autoRefresh = true, simulate = true, guildId, @@ -82,6 +84,7 @@ export function useActivityFeed({ const [total, setTotal] = useState(0); const [error, setError] = useState(null); const seenIds = useRef(new Set()); + const latestSeenTimestamp = useRef(null); const queryVersion = useRef(0); const requestVersion = useRef(0); @@ -97,7 +100,9 @@ export function useActivityFeed({ severity, entityType, actor: actor?.trim() || undefined, - from,`n sort,`n guildId, + from, + sort, + guildId, }), [limit, type, source, severity, entityType, actor, from, sort, guildId] ); @@ -109,25 +114,40 @@ export function useActivityFeed({ severity, entityType, actor: actor?.trim() || undefined, - from,`n sort,`n }), + from, + sort, + }), [actor, entityType, from, guildId, limit, severity, source, sort, type] ); const cacheRevision = useQueryInvalidation(activityQueryKey); const hasFilters = Boolean(type || source || severity || entityType || actor?.trim() || from); + const noteSeenTimestamps = useCallback((incoming: ActivityEvent[]) => { + for (const event of incoming) { + if ( + latestSeenTimestamp.current === null || + new Date(event.timestamp).getTime() > new Date(latestSeenTimestamp.current).getTime() + ) { + latestSeenTimestamp.current = event.timestamp; + } + } + }, []); + const replaceEvents = useCallback((incoming: ActivityEvent[]) => { + noteSeenTimestamps(incoming); setEvents((previous) => { - const byId = new Map(previous.map((event) => [event.id, event])); + const byId = new Map(previous.map((event) => [event.id, event])); incoming.forEach((event) => byId.set(event.id, event)); const bounded = [...byId.values()].sort(compareActivityEvents).slice(0, maxEvents); seenIds.current = new Set(bounded.map((event) => event.id)); return bounded; }); setLastUpdated(new Date()); - }, [maxEvents]); + }, [maxEvents, noteSeenTimestamps]); const appendEvents = useCallback((incoming: ActivityEvent[]) => { + noteSeenTimestamps(incoming); const fresh = incoming.filter((event) => !seenIds.current.has(event.id)); if (fresh.length === 0) return; @@ -140,9 +160,10 @@ export function useActivityFeed({ return bounded; }); setLastUpdated(new Date()); - }, [maxEvents]); + }, [maxEvents, noteSeenTimestamps]); const prependLiveEvent = useCallback((event: ActivityEvent) => { + noteSeenTimestamps([event]); if (seenIds.current.has(event.id)) return; if (filterActivityEvents([event], query).events.length === 0) return; @@ -157,7 +178,7 @@ export function useActivityFeed({ setTotal((previous) => previous + 1); setLastUpdated(new Date()); setError(null); - }, [maxEvents, query]); + }, [maxEvents, noteSeenTimestamps, query]); const fetchLatest = useCallback(async ({ simulateEvent = true } = {}) => { const version = queryVersion.current; @@ -250,8 +271,31 @@ export function useActivityFeed({ ); }; + // After a dropped stream reconnects, backfill anything published during + // the outage through the REST API before live events resume. The from + // filter is inclusive and appendEvents dedupes by id, so overlap with + // the server-side replay and the live stream is harmless. + const backfillMissedEvents = async () => { + const since = latestSeenTimestamp.current; + if (!since) { + if (!disposed) void fetchLatest({ simulateEvent: false }); + return; + } + const version = queryVersion.current; + try { + const data = await fetchActivity({ ...query, from: since, sort: "oldest" }); + if (disposed || version !== queryVersion.current) return; + appendEvents(data.events.map(toActivityEvent)); + setTotal((previous) => Math.max(previous, data.total)); + setError(null); + } catch { + // The polling fallback and the next reconciliation cover a failed backfill. + } + }; + queryVersion.current += 1; seenIds.current.clear(); + latestSeenTimestamp.current = null; setEvents([]); setNextCursor(null); setTotal(0); @@ -273,6 +317,9 @@ export function useActivityFeed({ onReady: () => { void fetchLatest({ simulateEvent: false }); }, + onReconnect: () => { + void backfillMissedEvents(); + }, }); } } @@ -283,7 +330,7 @@ export function useActivityFeed({ stopStream(); stopPolling(); }; - }, [autoRefresh, cacheRevision, fallbackIntervalMs, fetchLatest, guildId, prependLiveEvent]); + }, [autoRefresh, cacheRevision, fallbackIntervalMs, fetchLatest, guildId, prependLiveEvent, appendEvents, query]); return { events, diff --git a/apps/dashboard/lib/mock-data.ts b/apps/dashboard/lib/mock-data.ts index e527a67..43c7f28 100644 --- a/apps/dashboard/lib/mock-data.ts +++ b/apps/dashboard/lib/mock-data.ts @@ -5,6 +5,8 @@ import { GUILD_ID_HEADER } from "./guild-context"; export interface Pass { id: string; + /** Owning guild (tenant). Every pass belongs to exactly one guild. */ + guildId: string; name: string; description: string; status: "active" | "inactive" | "draft"; @@ -25,6 +27,8 @@ export interface Guild { export interface Member { id: string; + /** Owning guild (tenant). Every member record belongs to exactly one guild. */ + guildId: string; wallet: string; name: string; status: "active" | "inactive" | "pending"; @@ -46,13 +50,6 @@ export interface Activity { changes?: ActivityChange[]; } -<<<<<<< HEAD -export const mockPasses: Pass[] = [ - { id: "1", name: "Founder Pass", description: "Exclusive early access pass for founding members", status: "active", price: 0.1, maxSupply: 100, currentSupply: 42, createdAt: "2025-01-15T00:00:00Z" }, - { id: "2", name: "Premium Pass", description: "Full access to all guild features", status: "active", price: 0.05, maxSupply: 500, currentSupply: 189, createdAt: "2025-02-20T00:00:00Z" }, - { id: "3", name: "Community Pass", description: "Basic community access", status: "active", price: 0, maxSupply: null, currentSupply: 1203, createdAt: "2025-01-01T00:00:00Z" }, - { id: "4", name: "VIP Pass", description: "Top-tier VIP membership", status: "draft", price: 1, maxSupply: 50, currentSupply: 0, createdAt: "2025-06-01T00:00:00Z" }, -======= /** * Default guild used when no tenant scope is supplied (header / cookie / route). * Not a hard-coded product assumption โ€” only a fallback for unscoped requests. @@ -84,7 +81,6 @@ export const mockGuilds: Guild[] = [ passCount: 3, createdAt: "2025-03-05T00:00:00Z", }, ->>>>>>> main ]; export const mockPasses: Pass[] = [ @@ -204,16 +200,11 @@ export const mockPasses: Pass[] = [ ]; export const mockMembers: Member[] = [ -<<<<<<< HEAD - { id: "1", wallet: "0x742d35Cc6634C0532925a3b8879539d43374e290", name: "Alice", status: "active", roles: ["admin", "member"], joinedAt: "2024-12-01T00:00:00Z", lastActive: "2025-06-10T12:34:56Z" }, - { id: "2", wallet: "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1", name: "Bob", status: "active", roles: ["member", "contributor"], joinedAt: "2025-01-05T00:00:00Z", lastActive: "2025-06-11T08:23:45Z" }, - { id: "3", wallet: "0xFFcf8Ff64036412b493244b40b914f562419246F", name: "Charlie", status: "pending", roles: [], joinedAt: "2025-06-12T00:00:00Z", lastActive: "2025-06-12T09:15:22Z" }, - { id: "4", wallet: "0x1234567890123456789012345678901234567890", name: "Diana", status: "inactive", roles: ["member"], joinedAt: "2025-02-14T00:00:00Z", lastActive: "2025-04-20T14:30:00Z" }, -======= // Guild 1 { id: "1", guildId: "1", + version: 1, wallet: "0x742d35Cc6634C0532925a3b8879539d43374e290", name: "Alice", status: "active", @@ -224,6 +215,7 @@ export const mockMembers: Member[] = [ { id: "2", guildId: "1", + version: 1, wallet: "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1", name: "Bob", status: "active", @@ -234,6 +226,7 @@ export const mockMembers: Member[] = [ { id: "3", guildId: "1", + version: 1, wallet: "0xFFcf8Ff64036412b493244b40b914f562419246F", name: "Charlie", status: "pending", @@ -244,6 +237,7 @@ export const mockMembers: Member[] = [ { id: "4", guildId: "1", + version: 1, wallet: "0x1234567890123456789012345678901234567890", name: "Diana", status: "inactive", @@ -255,6 +249,7 @@ export const mockMembers: Member[] = [ { id: "5", guildId: "2", + version: 1, wallet: "0x1111111111111111111111111111111111111111", name: "Eve", status: "active", @@ -265,6 +260,7 @@ export const mockMembers: Member[] = [ { id: "6", guildId: "2", + version: 1, wallet: "0x2222222222222222222222222222222222222222", name: "Frank", status: "active", @@ -275,6 +271,7 @@ export const mockMembers: Member[] = [ { id: "7", guildId: "2", + version: 1, wallet: "0x3333333333333333333333333333333333333333", name: "Grace", status: "pending", @@ -286,6 +283,7 @@ export const mockMembers: Member[] = [ { id: "8", guildId: "3", + version: 1, wallet: "0x4444444444444444444444444444444444444444", name: "Hank", status: "active", @@ -296,6 +294,7 @@ export const mockMembers: Member[] = [ { id: "9", guildId: "3", + version: 1, wallet: "0x5555555555555555555555555555555555555555", name: "Ivy", status: "active", @@ -306,6 +305,7 @@ export const mockMembers: Member[] = [ { id: "10", guildId: "3", + version: 1, wallet: "0x6666666666666666666666666666666666666666", name: "Jack", status: "inactive", @@ -313,7 +313,6 @@ export const mockMembers: Member[] = [ joinedAt: "2025-04-02T00:00:00Z", lastActive: "2025-05-01T08:00:00Z", }, ->>>>>>> main ]; export const mockActivity: Activity[] = [ diff --git a/apps/dashboard/lib/repositories/adapters/durable.ts b/apps/dashboard/lib/repositories/adapters/durable.ts index f9ad20c..97c4dea 100644 --- a/apps/dashboard/lib/repositories/adapters/durable.ts +++ b/apps/dashboard/lib/repositories/adapters/durable.ts @@ -17,10 +17,10 @@ import crypto from "crypto"; * create/delete operations interleave. Deriving the value on read makes * drift impossible: the number is always whatever the member/pass repos * actually contain. - * - The Member and Pass types carry no guildId foreign key (see mock-data.ts): - * this dashboard models a single workspace, so a guild's counts reflect the - * total membership / pass supply of the workspace. There is no per-guild - * partition to sum, which makes read-time derivation both simple and exact. + * - The Member and Pass types carry a guildId foreign key (see mock-data.ts), + * and the member/pass repositories are tenant-scoped: a guild's counts are + * derived by querying each repository with that guild's scope, so counts + * always reflect exactly the records owned by that guild. * - Tradeoff: each read pays for a getAll on members and passes. For the * in-memory adapter this is an O(n) Map scan and negligible. A future SQL * backend can swap this for an indexed COUNT(*) or a maintained counter @@ -37,9 +37,13 @@ import type { IMemberRepository, IActivityRepository, ISettingsRepository, + MemberCreateData, MemberListQuery, + MemberUpdateData, PaginatedResult, + PassCreateData, PassListQuery, + PassUpdateData, } from "../types"; import type { Pass, Guild, Member } from "../../mock-data"; import type { ActivityEvent } from "@/lib/activity/types"; @@ -151,39 +155,53 @@ abstract class DurableRepository { * - Never log sensitive data * - Return 404 for missing records, not errors * - Handle concurrent writes gracefully + * + * Multi-tenant isolation (see docs/multi-tenancy.md): + * - The `passes` table MUST carry a NOT NULL `guild_id` foreign key + * - Every statement MUST filter on it (`WHERE guild_id = $1 AND id = $2`) โ€” + * never look a record up by `id` alone and compare afterwards + * - `guild_id` is immutable: INSERT sets it from the scope parameter, + * UPDATE must never include it in the SET clause + * - A scoped query that matches a record in another guild returns + * null/false, identical to a missing record + * - Implementations must pass the isolation contract suites in + * apps/dashboard/test/repositories/contracts.ts */ export class DurablePassRepository extends DurableRepository implements IPassRepository { - async getAll(): Promise { - // TODO: Implement against selected backend + async getAll(_guildId: string): Promise { + // TODO: Implement against selected backend (SELECT ... WHERE guild_id = $1) throw new Error("DurablePassRepository not yet implemented. Configure STORAGE_BACKEND in .env"); } - async query(_options: PassListQuery = {}): Promise> { - // Durable backends should push search/filter/pagination into indexed queries. + async query(_guildId: string, _options: PassListQuery = {}): Promise> { + // Durable backends should push search/filter/pagination into indexed + // queries; every predicate must be ANDed with guild_id = $1. throw new Error("DurablePassRepository not yet implemented. Configure STORAGE_BACKEND in .env"); } - async getById(_id: string): Promise { + async getById(_guildId: string, _id: string): Promise { + // TODO: SELECT ... WHERE guild_id = $1 AND id = $2 throw new Error("DurablePassRepository not yet implemented"); } - async create(_pass: Omit): Promise { + async create(_guildId: string, _pass: PassCreateData): Promise { // TODO: Implement with transaction support: - // 1. INSERT into passes table + // 1. INSERT into passes table with guild_id from the scope parameter // 2. Call this.recordDiff({}, created, "pass.created", desc, "pass", id, name) throw new Error("DurablePassRepository not yet implemented"); } - async update(_id: string, _pass: Partial): Promise { + async update(_guildId: string, _id: string, _pass: PassUpdateData): Promise { // TODO: Implement with optimistic locking or version column: - // 1. SELECT ... FOR UPDATE (or equivalent) + // 1. SELECT ... WHERE guild_id = $1 AND id = $2 FOR UPDATE (or equivalent) // 2. Call this.recordDiff(existing, updated, "pass.updated", desc, "pass", id, name) - // 3. UPDATE + // 3. UPDATE (guild_id must never appear in the SET clause) throw new Error("DurablePassRepository not yet implemented"); } - async delete(_id: string): Promise { + async delete(_guildId: string, _id: string): Promise { // TODO: Implement soft-delete pattern for audit trail + // (DELETE/UPDATE ... WHERE guild_id = $1 AND id = $2) throw new Error("DurablePassRepository not yet implemented"); } } @@ -269,7 +287,9 @@ export class DurableGuildRepository extends DurableRepository implements IGuildR const existing = this.guilds.get(id); if (!existing) return null; // id is immutable; counts are derived, so ignore any attempt to set them. - const { memberCount: _mc, passCount: _pc, ...patch } = guild; + const patch: Partial = { ...guild }; + delete patch.memberCount; + delete patch.passCount; const updated: Guild = { ...existing, ...patch, id }; this.guilds.set(id, updated); await this.recordDiff( @@ -314,40 +334,6 @@ export class DurableGuildRepository extends DurableRepository implements IGuildR * - Track member status changes for audit purposes */ export class DurableMemberRepository extends DurableRepository implements IMemberRepository { -<<<<<<< HEAD - async getAll(): Promise { - throw new Error("DurableMemberRepository not yet implemented"); - } - - async query(_options: MemberListQuery = {}): Promise> { - // Durable backends should push search/filter/pagination into indexed queries. - throw new Error("DurableMemberRepository not yet implemented"); - } - - async getById(_id: string): Promise { - throw new Error("DurableMemberRepository not yet implemented"); - } - - async getByWallet(_wallet: string): Promise { - // High-traffic operation; should be indexed - throw new Error("DurableMemberRepository not yet implemented"); - } - - async create(_member: Omit): Promise { - // TODO: Within transaction โ€” INSERT, then this.recordDiff({}, created, "member.joined", desc, "member", id, name) - throw new Error("DurableMemberRepository not yet implemented"); - } - - async update(_id: string, _member: Partial): Promise { - // TODO: Within transaction โ€” SELECT FOR UPDATE, compute diff via - // this.recordDiff(existing, updated, eventType, desc, "member", id, name), - // then UPDATE. Use member.roles_changed when roles differ, otherwise member.left. - throw new Error("DurableMemberRepository not yet implemented"); - } - - async delete(_id: string): Promise { - throw new Error("DurableMemberRepository not yet implemented"); -======= private members: Map = new Map(); private walletIndex: Map = new Map(); private nextId = 1; @@ -401,7 +387,17 @@ export class DurableMemberRepository extends DurableRepository implements IMembe async create(guildId: string, member: MemberCreateData): Promise { return this.writeLock.runExclusive(async () => { const id = String(this.nextId++); - const newMember: Member = { ...member, id, guildId, version: 1 }; + const now = new Date().toISOString(); + const newMember: Member = { + ...member, + status: member.status ?? "pending", + roles: member.roles ?? [], + joinedAt: member.joinedAt ?? now, + lastActive: member.lastActive ?? now, + id, + guildId, + version: 1, + }; this.members.set(id, newMember); this.walletIndex.set(this.walletKey(guildId, member.wallet), id); @@ -505,7 +501,6 @@ export class DurableMemberRepository extends DurableRepository implements IMembe for (let i = 0; i < members.length; i += chunkSize) { yield members.slice(i, i + chunkSize); } ->>>>>>> main } } @@ -519,7 +514,7 @@ export class DurableMemberRepository extends DurableRepository implements IMembe * - Keep raw JSON metadata for future schema evolution */ export class DurableActivityRepository extends DurableRepository implements IActivityRepository { - async append(_event: Omit & Partial>): Promise { + async append(_event: Omit & Partial>): Promise { throw new Error("DurableActivityRepository not yet implemented"); } async query(_options?: { diff --git a/apps/dashboard/lib/repositories/adapters/mock.ts b/apps/dashboard/lib/repositories/adapters/mock.ts index 27b7139..1721191 100644 --- a/apps/dashboard/lib/repositories/adapters/mock.ts +++ b/apps/dashboard/lib/repositories/adapters/mock.ts @@ -10,9 +10,13 @@ import type { IMemberRepository, IActivityRepository, ISettingsRepository, + MemberCreateData, MemberListQuery, + MemberUpdateData, PaginatedResult, + PassCreateData, PassListQuery, + PassUpdateData, } from "../types"; import type { Pass, Guild, Member } from "../../mock-data"; import type { ActivityEvent } from "@/lib/activity/types"; @@ -21,13 +25,21 @@ import type { DashboardSettings } from "../../settings"; import { mockPasses, mockGuilds, mockMembers } from "../../mock-data"; import { DEFAULT_SETTINGS } from "../../settings"; import { filterMembers, filterPasses, paginateItems } from "@/lib/pagination"; +import { computeDiff } from "@/lib/activity/diff"; +import { ConflictError } from "@/lib/api-errors"; /** * Mock pass repository: in-memory storage. + * + * Multi-tenant isolation (docs/multi-tenancy.md): every method takes an + * explicit `guildId` scope; a scoped call that references another guild's + * record behaves exactly as if the record does not exist, and `guildId` is + * pinned from the scope parameter on writes so a payload can never move a + * record across tenants. */ export class MockPassRepository implements IPassRepository { private passes: Map = new Map(); - private nextId = 5; + private nextId = 11; private activityRepo?: IActivityRepository; constructor(activityRepo?: IActivityRepository) { @@ -35,24 +47,33 @@ export class MockPassRepository implements IPassRepository { this.activityRepo = activityRepo; } - async getAll(): Promise { - return Array.from(this.passes.values()); + /** Resolve a pass only if it belongs to the given guild. */ + private getScoped(guildId: string, id: string): Pass | null { + const pass = this.passes.get(id); + return pass && pass.guildId === guildId ? pass : null; } - async query(options: PassListQuery = {}): Promise> { - const filtered = filterPasses(await this.getAll(), options); + async getAll(guildId: string): Promise { + return Array.from(this.passes.values()).filter((p) => p.guildId === guildId); + } + + async query(guildId: string, options: PassListQuery = {}): Promise> { + const filtered = filterPasses(await this.getAll(guildId), options); return paginateItems(filtered, options); } - async getById(id: string): Promise { - return this.passes.get(id) ?? null; + async getById(guildId: string, id: string): Promise { + return this.getScoped(guildId, id); } - async create(pass: Omit): Promise { + async create(guildId: string, pass: PassCreateData): Promise { const id = String(this.nextId++); const newPass: Pass = { ...pass, + status: pass.status ?? "draft", + currentSupply: pass.currentSupply ?? 0, id, + guildId, createdAt: new Date().toISOString(), }; this.passes.set(id, newPass); @@ -64,10 +85,10 @@ export class MockPassRepository implements IPassRepository { return newPass; } - async update(id: string, pass: Partial): Promise { - const existing = this.passes.get(id); + async update(guildId: string, id: string, pass: PassUpdateData): Promise { + const existing = this.getScoped(guildId, id); if (!existing) return null; - const updated = { ...existing, ...pass, id }; + const updated: Pass = { ...existing, ...pass, id, guildId: existing.guildId }; this.passes.set(id, updated); // Compute diff and record activity @@ -82,10 +103,11 @@ export class MockPassRepository implements IPassRepository { return updated; } - async delete(id: string): Promise { - const existing = this.passes.get(id); + async delete(guildId: string, id: string): Promise { + const existing = this.getScoped(guildId, id); + if (!existing) return false; const deleted = this.passes.delete(id); - if (deleted && existing) { + if (deleted) { await this.recordActivity("pass.deleted", `Pass deleted: ${existing.name}`, existing); } return deleted; @@ -193,44 +215,70 @@ export class MockGuildRepository implements IGuildRepository { /** * Mock member repository: in-memory storage. + * + * Multi-tenant isolation (docs/multi-tenancy.md): every method takes an + * explicit `guildId` scope; wallets are unique per guild (the wallet index is + * keyed on `(guildId, wallet)`), and a scoped call that references another + * guild's record behaves exactly as if the record does not exist. */ export class MockMemberRepository implements IMemberRepository { private members: Map = new Map(); private walletIndex: Map = new Map(); - private nextId = 5; + private nextId = 11; private activityRepo?: IActivityRepository; constructor(activityRepo?: IActivityRepository) { mockMembers.forEach((m) => { this.members.set(m.id, { ...m }); - this.walletIndex.set(m.wallet, m.id); + this.walletIndex.set(this.walletKey(m.guildId, m.wallet), m.id); }); this.activityRepo = activityRepo; } - async getAll(): Promise { - return Array.from(this.members.values()); + /** Composite wallet-index key: wallets are unique per guild, not globally. */ + private walletKey(guildId: string, wallet: string): string { + return `${guildId}::${wallet}`; + } + + /** Resolve a member only if it belongs to the given guild. */ + private getScoped(guildId: string, id: string): Member | null { + const member = this.members.get(id); + return member && member.guildId === guildId ? member : null; } - async query(options: MemberListQuery = {}): Promise> { - const filtered = filterMembers(await this.getAll(), options); + async getAll(guildId: string): Promise { + return Array.from(this.members.values()).filter((m) => m.guildId === guildId); + } + + async query(guildId: string, options: MemberListQuery = {}): Promise> { + const filtered = filterMembers(await this.getAll(guildId), options); return paginateItems(filtered, options); } - async getById(id: string): Promise { - return this.members.get(id) ?? null; + async getById(guildId: string, id: string): Promise { + return this.getScoped(guildId, id); } - async getByWallet(wallet: string): Promise { - const id = this.walletIndex.get(wallet); - return id ? this.members.get(id) ?? null : null; + async getByWallet(guildId: string, wallet: string): Promise { + const id = this.walletIndex.get(this.walletKey(guildId, wallet)); + return id ? this.getScoped(guildId, id) : null; } - async create(member: Omit): Promise { + async create(guildId: string, member: MemberCreateData): Promise { const id = String(this.nextId++); - const newMember: Member = { ...member, id }; + const now = new Date().toISOString(); + const newMember: Member = { + ...member, + status: member.status ?? "pending", + roles: member.roles ?? [], + joinedAt: member.joinedAt ?? now, + lastActive: member.lastActive ?? now, + id, + guildId, + version: 1, + }; this.members.set(id, newMember); - this.walletIndex.set(member.wallet, id); + this.walletIndex.set(this.walletKey(guildId, member.wallet), id); const changes = computeDiff({} as Record, newMember as unknown as Record); await this.recordActivity("member.joined", `${newMember.name} joined`, newMember, changes); @@ -238,14 +286,33 @@ export class MockMemberRepository implements IMemberRepository { return newMember; } - async update(id: string, member: Partial): Promise { - const existing = this.members.get(id); + async update( + guildId: string, + id: string, + member: MemberUpdateData, + expectedVersion?: number, + ): Promise { + const existing = this.getScoped(guildId, id); if (!existing) return null; - const updated = { ...existing, ...member, id }; + + // Optimistic concurrency control: reject if version doesn't match + if (expectedVersion !== undefined && existing.version !== expectedVersion) { + throw new ConflictError( + "This member was updated elsewhere โ€” refresh and retry.", + ); + } + + const updated: Member = { + ...existing, + ...member, + id, + guildId: existing.guildId, + version: existing.version + 1, + }; this.members.set(id, updated); if (member.wallet && member.wallet !== existing.wallet) { - this.walletIndex.delete(existing.wallet); - this.walletIndex.set(member.wallet, id); + this.walletIndex.delete(this.walletKey(existing.guildId, existing.wallet)); + this.walletIndex.set(this.walletKey(existing.guildId, member.wallet), id); } // Compute diff to determine what changed and what event type to emit @@ -267,18 +334,27 @@ export class MockMemberRepository implements IMemberRepository { return updated; } - async delete(id: string): Promise { - const existing = this.members.get(id); - if (existing) { - this.walletIndex.delete(existing.wallet); - } + async delete(guildId: string, id: string): Promise { + const existing = this.getScoped(guildId, id); + if (!existing) return false; + this.walletIndex.delete(this.walletKey(existing.guildId, existing.wallet)); const deleted = this.members.delete(id); - if (deleted && existing) { + if (deleted) { await this.recordActivity("member.left", `${existing.name} left`, existing); } return deleted; } + async *streamAll(guildId: string, chunkSize = 500): AsyncIterable { + const members = Array.from(this.members.values()).filter( + (m) => m.guildId === guildId, + ); + + for (let i = 0; i < members.length; i += chunkSize) { + yield members.slice(i, i + chunkSize); + } + } + private async recordActivity( type: ActivityEvent["type"], description: string, @@ -305,7 +381,7 @@ export class MockActivityRepository implements IActivityRepository { private events: ActivityEvent[] = []; private processedIds: Set = new Set(); - async append(event: Omit & Partial>): Promise { + async append(event: Omit & Partial>): Promise { const fullEvent: ActivityEvent = { ...event, id: `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, diff --git a/apps/dashboard/lib/repositories/types.ts b/apps/dashboard/lib/repositories/types.ts index 1dfc0df..f40acb3 100644 --- a/apps/dashboard/lib/repositories/types.ts +++ b/apps/dashboard/lib/repositories/types.ts @@ -12,7 +12,7 @@ import type { PaginatedResponse } from "../api-contracts"; * Input type for appending an activity event. * `schemaVersion` defaults to the current version when omitted. */ -export type ActivityEventInput = Omit & +export type ActivityEventInput = Omit & Partial>; export interface PaginationOptions { @@ -35,13 +35,15 @@ export interface MemberListQuery extends PaginationOptions { } /** -<<<<<<< HEAD -======= * Create input for a pass. `guildId` is intentionally excluded: the owning * guild comes only from the explicit `guildId` scope parameter, so a payload - * can never assign a record to a different tenant. + * can never assign a record to a different tenant. Adapters fill defaults for + * omitted fields (`status` defaults to "draft", `currentSupply` to 0). */ -export type PassCreateData = Omit; +export type PassCreateData = Omit & { + status?: Pass["status"]; + currentSupply?: number; +}; /** * Update input for a pass. `id` and `guildId` are excluded so a patch can @@ -50,46 +52,62 @@ export type PassCreateData = Omit; export type PassUpdateData = Partial>; /** Create input for a member. See {@link PassCreateData} for the rationale. - * `version` is excluded โ€” the server always initializes it to 1. */ -export type MemberCreateData = Omit; + * `version` is excluded โ€” the server always initializes it to 1. Adapters + * fill defaults for omitted fields (`status` defaults to "pending", `roles` + * to [], `joinedAt`/`lastActive` to the creation time). */ +export type MemberCreateData = Omit & { + status?: Member["status"]; + roles?: string[]; + joinedAt?: string; + lastActive?: string; +}; /** Update input for a member. See {@link PassUpdateData} for the rationale. */ export type MemberUpdateData = Partial>; /** ->>>>>>> main * Repository for managing passes. + * + * Multi-tenant isolation guarantee: every method requires an explicit + * `guildId` scope as its first parameter โ€” omitting it is a compile error, + * not a runtime possibility. Implementations MUST guarantee that a call + * scoped to guild A can never read, modify, or delete guild B's data, even + * when given an ID that exists in another guild (such calls behave exactly + * as if the record does not exist). See docs/multi-tenancy.md. */ export interface IPassRepository { /** - * Get all passes. + * Get all passes belonging to the given guild. */ - getAll(): Promise; + getAll(guildId: string): Promise; /** - * Query passes with filtering and bounded pagination. + * Query the guild's passes with filtering and bounded pagination. */ - query(options?: PassListQuery): Promise>; + query(guildId: string, options?: PassListQuery): Promise>; /** - * Get a pass by ID. + * Get a pass by ID. Returns null when the pass does not exist + * or belongs to a different guild. */ - getById(id: string): Promise; + getById(guildId: string, id: string): Promise; /** - * Create a new pass. + * Create a new pass owned by the given guild. */ - create(pass: Omit): Promise; + create(guildId: string, pass: PassCreateData): Promise; /** - * Update an existing pass. + * Update an existing pass. Returns null when the pass does not exist + * or belongs to a different guild. The owning guild can never change. */ - update(id: string, pass: Partial): Promise; + update(guildId: string, id: string, pass: PassUpdateData): Promise; /** - * Delete a pass. + * Delete a pass. Returns false when the pass does not exist + * or belongs to a different guild. */ - delete(id: string): Promise; + delete(guildId: string, id: string): Promise; } /** @@ -124,39 +142,44 @@ export interface IGuildRepository { /** * Repository for managing members. + * + * Multi-tenant isolation guarantee: every method requires an explicit + * `guildId` scope as its first parameter โ€” omitting it is a compile error, + * not a runtime possibility. Implementations MUST guarantee that a call + * scoped to guild A can never read, modify, or delete guild B's data, even + * when given an ID or wallet that exists in another guild (such calls behave + * exactly as if the record does not exist). See docs/multi-tenancy.md. */ export interface IMemberRepository { /** - * Get all members. + * Get all members belonging to the given guild. */ - getAll(): Promise; + getAll(guildId: string): Promise; /** - * Query members with filtering and bounded pagination. + * Query the guild's members with filtering and bounded pagination. */ - query(options?: MemberListQuery): Promise>; + query(guildId: string, options?: MemberListQuery): Promise>; /** - * Get a member by ID. + * Get a member by ID. Returns null when the member does not exist + * or belongs to a different guild. */ - getById(id: string): Promise; + getById(guildId: string, id: string): Promise; /** - * Get a member by wallet address. + * Get a member of the given guild by wallet address. Returns null when no + * member with that wallet exists in this guild, even if the same wallet is + * a member of another guild. */ - getByWallet(wallet: string): Promise; + getByWallet(guildId: string, wallet: string): Promise; /** - * Create a new member. + * Create a new member owned by the given guild. */ - create(member: Omit): Promise; + create(guildId: string, member: MemberCreateData): Promise; /** -<<<<<<< HEAD - * Update a member. - */ - update(id: string, member: Partial): Promise; -======= * Update a member. Returns null when the member does not exist * or belongs to a different guild. The owning guild can never change. * @@ -165,14 +188,11 @@ export interface IMemberRepository { * rather than a silent overwrite. */ update(guildId: string, id: string, member: MemberUpdateData, expectedVersion?: number): Promise; ->>>>>>> main /** - * Delete a member. + * Delete a member. Returns false when the member does not exist + * or belongs to a different guild. */ -<<<<<<< HEAD - delete(id: string): Promise; -======= delete(guildId: string, id: string): Promise; /** @@ -184,7 +204,6 @@ export interface IMemberRepository { * memory usage stays proportional to `chunkSize`, not the total count. */ streamAll(guildId: string, chunkSize?: number): AsyncIterable; ->>>>>>> main } /** diff --git a/apps/dashboard/lib/settings.ts b/apps/dashboard/lib/settings.ts index e1ef6f9..94118d0 100644 --- a/apps/dashboard/lib/settings.ts +++ b/apps/dashboard/lib/settings.ts @@ -7,10 +7,6 @@ * server-side if added later (see issue #80 notes). */ -<<<<<<< HEAD -======= -import { SUPPORTED_TIMEZONES } from "@/lib/timezones"; - export const SECRET_MASK = "โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข"; export interface WriteOnlySecret { @@ -19,7 +15,6 @@ export interface WriteOnlySecret { readonly __secretBrand?: never; } ->>>>>>> main export interface DashboardSettings { /** Display name of the workspace. */ workspaceName: string; @@ -29,6 +24,12 @@ export interface DashboardSettings { displayName: string; /** Contact email for the workspace profile. */ email: string; + /** + * Write-only secret, never readable in plaintext. Present on reads only as + * a set/unset flag with a masked value; writes go through the settings + * patch flow and are stored encrypted (see DurableSettingsRepository). + */ + webhookForwardingSecret?: WriteOnlySecret; } /** diff --git a/apps/dashboard/lib/validation/mutations.ts b/apps/dashboard/lib/validation/mutations.ts index bea813c..bfff8b2 100644 --- a/apps/dashboard/lib/validation/mutations.ts +++ b/apps/dashboard/lib/validation/mutations.ts @@ -18,7 +18,9 @@ export type MemberUpdateInput = z.infer; const PASS_STATUSES = ["active", "inactive", "draft"] as const; const MEMBER_STATUSES = ["active", "inactive", "pending"] as const; -const SERVER_OWNED_FIELDS = ["id", "createdAt"] as const; +// guildId is server-owned: the tenant scope comes from the server-side guild +// context, never from the payload (see docs/multi-tenancy.md). +const SERVER_OWNED_FIELDS = ["id", "createdAt", "guildId"] as const; const WALLET_ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/; function isPlainObject(value: unknown): value is Record { @@ -41,11 +43,12 @@ function validateServerOwnedFields( function flattenZodIssues(issues: z.ZodIssue[]): z.ZodIssue[] { return issues.flatMap((issue) => { - if (issue.code === "invalid_union") { - return issue.unionErrors.flatMap((error) => flattenZodIssues(error.issues)); - } - if (issue.code === "invalid_union_discriminator") { - return issue.unionErrors.flatMap((error) => flattenZodIssues(error.issues)); + // zod v4: an invalid_union issue carries `errors`, an array of per-option + // issue arrays. + if (issue.code === "invalid_union" && "errors" in issue && Array.isArray(issue.errors)) { + return (issue.errors as z.ZodIssue[][]).flatMap((optionIssues) => + flattenZodIssues(optionIssues) + ); } return issue; }); @@ -100,7 +103,7 @@ const optionalNonEmptyStringField = (name: string) => z.string().trim().min(1, { message: `${name} must be a non-empty string` }); const optionalNumberField = (name: string) => - z.number({ invalid_type_error: `${name} must be a number` }) + z.number({ error: `${name} must be a number` }) .finite({ message: `${name} must be a number` }) .nonnegative({ message: `${name} must be greater than or equal to 0` }); @@ -130,7 +133,7 @@ const optionalWalletField = z const rolesField = z .array( - z.string().refine((value) => MEMBER_ROLES.includes(value), { + z.string().refine((value) => (MEMBER_ROLES as readonly string[]).includes(value), { message: `role must be one of: ${MEMBER_ROLES.join(", ")}`, }) ) @@ -144,12 +147,10 @@ export const passCreateSchema = z.object({ currentSupply: optionalIntegerField("currentSupply").optional(), status: z .enum(PASS_STATUSES, { - errorMap: () => ({ - message: `status must be one of: ${PASS_STATUSES.join(", ")}`, - }), + error: `status must be one of: ${PASS_STATUSES.join(", ")}`, }) .optional(), -}).passthrough(); +}); export const passUpdateSchema = z.object({ name: optionalNonEmptyStringField("name").optional(), @@ -158,11 +159,9 @@ export const passUpdateSchema = z.object({ maxSupply: z.union([optionalIntegerField("maxSupply"), z.null()]).optional(), currentSupply: optionalIntegerField("currentSupply").optional(), status: z.enum(PASS_STATUSES, { - errorMap: () => ({ - message: `status must be one of: ${PASS_STATUSES.join(", ")}`, - }), + error: `status must be one of: ${PASS_STATUSES.join(", ")}`, }).optional(), -}).passthrough(); +}); export const memberCreateSchema = z.object({ name: requiredStringField("name"), @@ -170,29 +169,25 @@ export const memberCreateSchema = z.object({ roles: rolesField, status: z .enum(MEMBER_STATUSES, { - errorMap: () => ({ - message: `status must be one of: ${MEMBER_STATUSES.join(", ")}`, - }), + error: `status must be one of: ${MEMBER_STATUSES.join(", ")}`, }) .optional(), joinedAt: isoDateField("joinedAt").optional(), lastActive: isoDateField("lastActive").optional(), -}).passthrough(); +}); export const memberUpdateSchema = z.object({ name: optionalNonEmptyStringField("name").optional(), wallet: optionalWalletField.optional(), roles: rolesField, status: z.enum(MEMBER_STATUSES, { - errorMap: () => ({ - message: `status must be one of: ${MEMBER_STATUSES.join(", ")}`, - }), + error: `status must be one of: ${MEMBER_STATUSES.join(", ")}`, }).optional(), joinedAt: isoDateField("joinedAt").optional(), lastActive: isoDateField("lastActive").optional(), /** Expected version for optimistic concurrency control. */ version: z.number().int().positive().optional(), -}).passthrough(); +}); export function malformedPayloadError(): FieldValidationError[] { return [{ field: "body", message: "Request body must be a valid JSON object" }]; @@ -428,12 +423,21 @@ export function validateMemberUpdatePayload(payload: unknown): ValidationResult< `status must be one of: ${MEMBER_STATUSES.join(", ")}` ); + const version = parseField( + memberUpdateSchema.shape.version, + payload.version, + "version", + errors, + "version must be a positive integer" + ); + if (name !== undefined) data.name = name; if (wallet !== undefined) data.wallet = normalizedWallet ?? wallet; if (roles !== undefined) data.roles = roles ? [...new Set(roles)] : []; if (joinedAt !== undefined) data.joinedAt = joinedAt; if (lastActive !== undefined) data.lastActive = lastActive; if (status !== undefined) data.status = status; + if (version !== undefined) data.version = version; if (errors.length > 0) return { valid: false, errors }; return { valid: true, data }; diff --git a/apps/dashboard/lib/validation/settings.ts b/apps/dashboard/lib/validation/settings.ts index 86e679e..15d2f4e 100644 --- a/apps/dashboard/lib/validation/settings.ts +++ b/apps/dashboard/lib/validation/settings.ts @@ -19,14 +19,24 @@ export interface FieldError { message: string; } +/** + * Accepted patch shape for settings updates. Public settings fields plus the + * write-only `webhookForwardingSecret` (a plaintext string on write, `null` + * or "" to clear). Secret values are never returned on reads โ€” see + * lib/settings.ts `WriteOnlySecret`. + */ +export type SettingsPatchPayload = Partial & { + webhookForwardingSecret?: string | null; +}; + export type SettingsValidationResult = - | { ok: true; value: Partial } + | { ok: true; value: SettingsPatchPayload } | { ok: false; errors: FieldError[] }; const settingsPatchSchema = z .object({ workspaceName: z - .string({ invalid_type_error: "Workspace name is required." }) + .string({ error: "Workspace name is required." }) .trim() .min(1, { message: "Workspace name is required." }) .max(MAX_TEXT_LENGTH, { @@ -34,7 +44,7 @@ const settingsPatchSchema = z }) .optional(), displayName: z - .string({ invalid_type_error: "Display name is required." }) + .string({ error: "Display name is required." }) .trim() .min(1, { message: "Display name is required." }) .max(MAX_TEXT_LENGTH, { @@ -42,12 +52,10 @@ const settingsPatchSchema = z }) .optional(), timezone: z.enum(ALLOWED_TIMEZONES, { - errorMap: () => ({ - message: `Timezone must be one of: ${ALLOWED_TIMEZONES.join(", ")}.`, - }), + error: `Timezone must be one of: ${ALLOWED_TIMEZONES.join(", ")}.`, }).optional(), email: z - .string({ invalid_type_error: "A valid email address is required." }) + .string({ error: "A valid email address is required." }) .trim() .email({ message: "A valid email address is required." }) .optional(), @@ -74,8 +82,7 @@ export function validateSettingsPatch(input: unknown): SettingsValidationResult return { ok: false, errors: mapZodErrors(result.error.issues) }; } - const patch = result.data as SettingsPatchPayload; - const supportedKeys = ["workspaceName", "displayName", "timezone", "email", "webhookForwardingSecret"]; + const supportedKeys = ["workspaceName", "displayName", "timezone", "email", "webhookForwardingSecret"] as const; const providedSupportedFields = supportedKeys.filter((key) => Object.prototype.hasOwnProperty.call(input, key) ); @@ -87,6 +94,27 @@ export function validateSettingsPatch(input: unknown): SettingsValidationResult }; } + // Only supported keys reach the caller: the schema uses .passthrough() so + // unknown keys parse successfully, but they must never be merged into the + // persisted document. + const raw = result.data as Record; + const rawInput = input as Record; + const patch: SettingsPatchPayload = {}; + for (const key of providedSupportedFields) { + (patch as Record)[key] = key === "webhookForwardingSecret" ? rawInput[key] : raw[key]; + } + + if ( + "webhookForwardingSecret" in patch && + patch.webhookForwardingSecret !== null && + typeof patch.webhookForwardingSecret !== "string" + ) { + return { + ok: false, + errors: [{ field: "webhookForwardingSecret", message: "webhookForwardingSecret must be a string." }], + }; + } + return { ok: true, value: patch }; } diff --git a/apps/dashboard/scripts/reconcile.ts b/apps/dashboard/scripts/reconcile.ts index 62ef441..1a671ba 100644 --- a/apps/dashboard/scripts/reconcile.ts +++ b/apps/dashboard/scripts/reconcile.ts @@ -48,17 +48,15 @@ if (!mode) { async function defaultCountMembers(guildId: string): Promise { const memberRepo = getMemberRepository(); - const all = await memberRepo.getAll(); - // In mock mode, members are global (no guildId field). - // In production/durable mode, filter by guildId when the schema supports it. - // For now, return total count as a reasonable default. + // Member repositories are tenant-scoped (docs/multi-tenancy.md): counts are + // per guild by construction. + const all = await memberRepo.getAll(guildId); return all.length; } async function defaultCountPasses(guildId: string): Promise { const passRepo = getPassRepository(); - const all = await passRepo.getAll(); - // Same rationale as countMembers โ€” global in mock, filterable in production. + const all = await passRepo.getAll(guildId); return all.length; } @@ -69,7 +67,7 @@ async function main(): Promise { console.log("โ•".repeat(60)); const options: ReconcileOptions = { - mode, + mode: mode!, countMembers: defaultCountMembers, countPasses: defaultCountPasses, }; diff --git a/apps/dashboard/test/activity-query.test.ts b/apps/dashboard/test/activity-query.test.ts index 44a7796..e19b515 100644 --- a/apps/dashboard/test/activity-query.test.ts +++ b/apps/dashboard/test/activity-query.test.ts @@ -121,7 +121,8 @@ describe("activity query contract", () => { assert.equal(parsed.value.severity, "error"); assert.equal(parsed.value.entityType, "member"); assert.equal(parsed.value.actor, "alice"); - assert.equal(parsed.value.from, "2025-01-01T00:00:00.000Z");`n assert.equal(parsed.value.sort, "oldest"); + assert.equal(parsed.value.from, "2025-01-01T00:00:00.000Z"); + assert.equal(parsed.value.sort, "oldest"); }); test("rejects invalid query parameters with field-specific errors", () => { diff --git a/apps/dashboard/test/activity-stream.test.ts b/apps/dashboard/test/activity-stream.test.ts index 2d11da5..0348f3f 100644 --- a/apps/dashboard/test/activity-stream.test.ts +++ b/apps/dashboard/test/activity-stream.test.ts @@ -8,9 +8,11 @@ import { } from "../lib/activity/client-stream"; import { getActivitySubscriberCount, + getEventsAfterCursor, publishActivityEvent, subscribeToActivityEvents, } from "../lib/activity/stream"; +import { activityStorage } from "../lib/activity/storage"; import { GET as streamActivity } from "../app/api/activity/stream/route"; import { POST as receiveWebhook } from "../app/api/webhooks/route"; import { scheduleActivityReconciliation } from "../lib/hooks/useActivityFeed"; @@ -75,32 +77,107 @@ describe("activity SSE delivery", () => { assert.equal(getActivitySubscriberCount(), initialSubscribers); }); - test("client connector accepts activity and falls back exactly once on stream error", () => { - const source = new FakeEventSource(); + test("client connector accepts activity and reconnects with backoff on stream error", async () => { + const sources: FakeEventSource[] = []; const received: string[] = []; let fallbackCount = 0; const event = makeActivityEvent({ id: "evt_client_stream_001" }); const disconnect = connectActivityStream({ - createEventSource: () => source, + createEventSource: () => { + const source = new FakeEventSource(); + sources.push(source); + return source; + }, onEvent: (activity) => received.push(activity.id), onFallback: () => { fallbackCount += 1; }, + reconnectBaseMs: 40, }); - source.emit("ready", "{}"); - source.emit("activity", JSON.stringify(event)); - source.emit("activity", "not-json"); - source.emit("error"); - source.emit("error"); + sources[0].emit("ready", "{}"); + sources[0].emit("activity", JSON.stringify(event)); + sources[0].emit("activity", "not-json"); + sources[0].emit("error"); + sources[0].emit("error"); assert.deepEqual(received, [event.id]); - assert.equal(fallbackCount, 1); - assert.equal(source.closeCount, 1); + assert.equal(fallbackCount, 0); + assert.equal(sources[0].closeCount, 1); + assert.equal(sources.length, 1, "reconnect must not happen synchronously"); + + await delay(120); + assert.equal(sources.length, 2, "reconnect should have fired once after backoff"); + assert.equal(fallbackCount, 0); + + disconnect(); + assert.equal(sources[1].closeCount, 1); + }); + + test("reconnect carries the last event id and reports the cursor for backfill", async () => { + const urls: string[] = []; + const sources: FakeEventSource[] = []; + const cursors: Array<{ lastEventId: string | null; lastEventTimestamp: string | null }> = []; + const first = makeActivityEvent({ id: "evt_cursor_001" }); + + const disconnect = connectActivityStream({ + createEventSource: (url) => { + urls.push(url); + const source = new FakeEventSource(); + sources.push(source); + return source; + }, + onEvent: () => {}, + onFallback: () => assert.fail("stream should recover before fallback"), + onReconnect: (cursor) => cursors.push(cursor), + reconnectBaseMs: 10, + random: () => 0.5, + }); + + assert.equal(urls[0], "/api/activity/stream"); + sources[0].emit("ready", "{}"); + sources[0].emit("activity", JSON.stringify(first)); + sources[0].emit("error"); + + await delay(80); + assert.equal(sources.length, 2); + assert.match(urls[1], /lastEventId=evt_cursor_001/); + + sources[1].emit("ready", "{}"); + assert.deepEqual(cursors, [ + { lastEventId: "evt_cursor_001", lastEventTimestamp: first.timestamp }, + ]); + disconnect(); + }); + + test("client falls back to polling after exhausting reconnect attempts", async () => { + const sources: FakeEventSource[] = []; + let fallbackCount = 0; + + const disconnect = connectActivityStream({ + createEventSource: () => { + const source = new FakeEventSource(); + sources.push(source); + return source; + }, + onEvent: () => {}, + onFallback: () => { + fallbackCount += 1; + }, + maxReconnectAttempts: 2, + reconnectBaseMs: 10, + random: () => 0.5, + }); + + for (let attempt = 0; attempt < 3; attempt += 1) { + sources[sources.length - 1].emit("error"); + await delay(60); + } + assert.equal(sources.length, 3, "initial connect plus two reconnect attempts"); + assert.equal(fallbackCount, 1); disconnect(); - assert.equal(source.closeCount, 1); }); test("ready handshake reconciles the REST snapshot after subscription", () => { @@ -128,42 +205,104 @@ describe("activity SSE delivery", () => { }); test("client falls back when the stream never becomes ready", async () => { - const source = new FakeEventSource(); + const sources: FakeEventSource[] = []; let fallbackCount = 0; connectActivityStream({ connectionTimeoutMs: 10, - createEventSource: () => source, + createEventSource: () => { + const source = new FakeEventSource(); + sources.push(source); + return source; + }, heartbeatTimeoutMs: 100, onEvent: () => {}, onFallback: () => { fallbackCount += 1; }, + maxReconnectAttempts: 1, + reconnectBaseMs: 10, + random: () => 0.5, }); - await delay(30); + await delay(150); + assert.equal(sources.length, 2, "initial connect plus one reconnect attempt"); assert.equal(fallbackCount, 1); - assert.equal(source.closeCount, 1); + assert.equal(sources[sources.length - 1].closeCount, 1); }); - test("client falls back when a ready stream stops sending heartbeats", async () => { - const source = new FakeEventSource(); + test("client reconnects when a ready stream stops sending heartbeats", async () => { + const sources: FakeEventSource[] = []; let fallbackCount = 0; connectActivityStream({ connectionTimeoutMs: 100, - createEventSource: () => source, - heartbeatTimeoutMs: 10, + createEventSource: () => { + const source = new FakeEventSource(); + sources.push(source); + return source; + }, + heartbeatTimeoutMs: 20, onEvent: () => {}, onFallback: () => { fallbackCount += 1; }, + maxReconnectAttempts: 1, + reconnectBaseMs: 10, + random: () => 0.5, }); - source.emit("ready", "{}"); - await delay(30); - assert.equal(fallbackCount, 1); - assert.equal(source.closeCount, 1); + sources[0].emit("ready", "{}"); + await delay(60); + assert.equal(fallbackCount, 0, "heartbeat loss triggers reconnect, not fallback"); + assert.equal(sources[0].closeCount, 1); + + await delay(150); + assert.equal(sources.length, 2); + assert.equal(fallbackCount, 1, "silent reconnect attempt exhausts into fallback"); + }); + + test("server frames carry an id line and replay events missed since Last-Event-ID", async () => { + const anchor = makeActivityEvent({ id: `evt_replay_anchor_${Date.now()}` }); + const missed = makeActivityEvent({ id: `evt_replay_missed_${Date.now()}` }); + await activityStorage.recordActivityEvent(anchor); + await activityStorage.recordActivityEvent(missed); + + const response = await streamActivity( + new Request(`https://example.test/api/activity/stream?lastEventId=${anchor.id}`) + ); + assert.equal(response.status, 200); + assert.ok(response.body); + + const reader = response.body.getReader(); + try { + const decoder = new TextDecoder(); + const ready = await readWithTimeout(reader, 500); + assert.match(decoder.decode(ready.value), /event: ready/); + + const replay = await readWithTimeout(reader, 500); + const frame = decoder.decode(replay.value); + assert.match(frame, new RegExp(`id: ${missed.id}`)); + assert.match(frame, /event: activity/); + assert.doesNotMatch(frame, new RegExp(anchor.id), "cursor event itself is not replayed"); + } finally { + await reader.cancel(); + } + }); + + test("getEventsAfterCursor returns newer events oldest-first and [] for unknown cursors", async () => { + const base = Date.now(); + const oldest = makeActivityEvent({ id: `evt_cursor_a_${base}` }); + const middle = makeActivityEvent({ id: `evt_cursor_b_${base}` }); + const newest = makeActivityEvent({ id: `evt_cursor_c_${base}` }); + const newestFirst = [newest, middle, oldest]; + + assert.deepEqual( + getEventsAfterCursor(newestFirst, oldest.id).map((event) => event.id), + [middle.id, newest.id] + ); + assert.deepEqual(getEventsAfterCursor(newestFirst, newest.id), []); + assert.deepEqual(getEventsAfterCursor(newestFirst, "evt_not_stored"), []); }); test("server disconnects a stream whose bounded output queue fills", async () => { diff --git a/apps/dashboard/test/api-error-handling.test.ts b/apps/dashboard/test/api-error-handling.test.ts index 1cb397d..a6399ee 100644 --- a/apps/dashboard/test/api-error-handling.test.ts +++ b/apps/dashboard/test/api-error-handling.test.ts @@ -85,7 +85,7 @@ describe("handleApiError โ€” internal error leakage", () => { test("a PermissionDeniedError is treated as a client-safe 403", async () => { const response = await handleApiError(async () => { - throw new PermissionDeniedError("members:write"); + throw new PermissionDeniedError("members:write", "1"); }); const body = await response.json(); diff --git a/apps/dashboard/test/audit-diff.test.ts b/apps/dashboard/test/audit-diff.test.ts index 0157f2a..8109e38 100644 --- a/apps/dashboard/test/audit-diff.test.ts +++ b/apps/dashboard/test/audit-diff.test.ts @@ -223,7 +223,7 @@ describe("MockMemberRepository diff recording", () => { const memberRepo = new MockMemberRepository(activityRepo); // Charlie (id="3") has roles=[] - const updated = await memberRepo.update("3", { roles: ["contributor"], status: "active" }); + const updated = await memberRepo.update("1", "3", { roles: ["contributor"], status: "active" }); assert.ok(updated); assert.deepEqual(updated.roles, ["contributor"]); @@ -243,7 +243,7 @@ describe("MockMemberRepository diff recording", () => { const memberRepo = new MockMemberRepository(activityRepo); // Diana (id="4") is inactive โ€” change to active - const updated = await memberRepo.update("4", { status: "active" }); + const updated = await memberRepo.update("1", "4", { status: "active" }); assert.ok(updated); assert.equal(updated.status, "active"); diff --git a/apps/dashboard/test/dashboard-activity.test.ts b/apps/dashboard/test/dashboard-activity.test.ts index f736281..64d1f8c 100644 --- a/apps/dashboard/test/dashboard-activity.test.ts +++ b/apps/dashboard/test/dashboard-activity.test.ts @@ -13,6 +13,9 @@ import type { ActivityEvent } from "../lib/activity/types"; process.env.DASHBOARD_STORAGE_MODE = "mock"; process.env.DASHBOARD_API_MODE = "mock"; +// Guild scope for pass/member repository calls (seeded mock guild). +const GUILD = "1"; + describe("recordDashboardActivity", () => { beforeEach(() => clearRepositories()); @@ -197,7 +200,7 @@ describe("recordDashboardActivity", () => { const before = await getActivityRepository().query({}); const repo = getPassRepository(); - const result = await repo.update("nonexistent", { name: "test" }); + const result = await repo.update(GUILD, "nonexistent", { name: "test" }); assert.equal(result, null, "update of non-existent pass returns null"); const after = await getActivityRepository().query({}); @@ -206,7 +209,7 @@ describe("recordDashboardActivity", () => { test("route handler pass creation flow records activity", async () => { const repo = getPassRepository(); - const created = await repo.create({ + const created = await repo.create(GUILD, { name: "Integration Pass", description: "Test", status: "active", @@ -227,14 +230,14 @@ describe("recordDashboardActivity", () => { test("route handler pass update flow records activity", async () => { const repo = getPassRepository(); - const created = await repo.create({ + const created = await repo.create(GUILD, { name: "Update Pass", description: "Test", status: "draft", currentSupply: 0, }); - const updated = await repo.update(created.id, { status: "active" }); + const updated = await repo.update(GUILD, created.id, { status: "active" }); assert.ok(updated); await recordDashboardActivity({ @@ -250,14 +253,14 @@ describe("recordDashboardActivity", () => { test("route handler pass deactivation flow records activity", async () => { const repo = getPassRepository(); - const created = await repo.create({ + const created = await repo.create(GUILD, { name: "Deactivate Pass", description: "Test", status: "active", currentSupply: 0, }); - const deleted = await repo.delete(created.id); + const deleted = await repo.delete(GUILD, created.id); assert.equal(deleted, true); await recordDashboardActivity({ @@ -273,7 +276,7 @@ describe("recordDashboardActivity", () => { test("route handler member role change flow records activity", async () => { const repo = getMemberRepository(); - const created = await repo.create({ + const created = await repo.create(GUILD, { wallet: "0xrole_test", name: "Role Test", status: "active", @@ -282,7 +285,7 @@ describe("recordDashboardActivity", () => { lastActive: new Date().toISOString(), }); - const updated = await repo.update(created.id, { roles: ["member", "contributor"] }); + const updated = await repo.update(GUILD, created.id, { roles: ["member", "contributor"] }); assert.ok(updated); await recordDashboardActivity({ @@ -298,7 +301,7 @@ describe("recordDashboardActivity", () => { test("route handler member removal flow records activity", async () => { const repo = getMemberRepository(); - const created = await repo.create({ + const created = await repo.create(GUILD, { wallet: "0xremoval_test", name: "Remove Test", status: "active", @@ -307,7 +310,7 @@ describe("recordDashboardActivity", () => { lastActive: new Date().toISOString(), }); - const deleted = await repo.delete(created.id); + const deleted = await repo.delete(GUILD, created.id); assert.equal(deleted, true); await recordDashboardActivity({ diff --git a/apps/dashboard/test/durable-guild-repository.test.ts b/apps/dashboard/test/durable-guild-repository.test.ts index 5cb0b13..98a775a 100644 --- a/apps/dashboard/test/durable-guild-repository.test.ts +++ b/apps/dashboard/test/durable-guild-repository.test.ts @@ -16,6 +16,7 @@ import type { IMemberRepository, IPassRepository } from "../lib/repositories/typ function fakeMemberRepo(members: Member[]): IMemberRepository { return { async getAll() { return members; }, + async *streamAll() { yield members; }, async query() { throw new Error("not used"); }, async getById() { return null; }, async getByWallet() { return null; }, diff --git a/apps/dashboard/test/fixtures.ts b/apps/dashboard/test/fixtures.ts index f9c9d59..0fdf74f 100644 --- a/apps/dashboard/test/fixtures.ts +++ b/apps/dashboard/test/fixtures.ts @@ -20,7 +20,7 @@ export function makeActivityEvent(overrides: Partial = {}): Activ type: "member.joined", source: "dashboard", severity: "info", - actor: { name: "Alice", wallet: "0xabc" }, + actor: { name: "Alice", wallet: "0x742d35cC6634c0532925a3B8879539d43374E290" }, timestamp: FIXED_TIMESTAMP, description: "Alice joined the guild", schemaVersion: CURRENT_ACTIVITY_EVENT_SCHEMA_VERSION, @@ -47,9 +47,9 @@ export const FIXTURE_VERIFICATION_EVENT: ActivityEvent = makeActivityEvent({ id: "evt_fixture_verify_001", type: "verification.completed", description: "Verification completed for 0xabc", - actor: { wallet: "0xabc" }, - entity: { type: "verification", id: "0xabc" }, - metadata: { wallet: "0xabc" }, + actor: { wallet: "0x742d35cC6634c0532925a3B8879539d43374E290" }, + entity: { type: "verification", id: "0x742d35cC6634c0532925a3B8879539d43374E290" }, + metadata: { wallet: "0x742d35cC6634c0532925a3B8879539d43374E290" }, }); export function makeWebhookPayload(overrides: Partial = {}): WebhookPayload { @@ -57,7 +57,7 @@ export function makeWebhookPayload(overrides: Partial = {}): Web id: "whk_fixture_001", type: "membership.created", created: FIXED_UNIX, - data: { id: "member_001", name: "Alice", wallet: "0xabc" }, + data: { id: "member_001", name: "Alice", wallet: "0x742d35cC6634c0532925a3B8879539d43374E290" }, ...overrides, }; } @@ -66,12 +66,12 @@ export const WEBHOOK_FIXTURES: Record = { "membership.created": makeWebhookPayload({ id: "whk_mc_001", type: "membership.created", - data: { id: "member_001", name: "Alice", wallet: "0xabc" }, + data: { id: "member_001", name: "Alice", wallet: "0x742d35cC6634c0532925a3B8879539d43374E290" }, }), "membership.updated": makeWebhookPayload({ id: "whk_mu_001", type: "membership.updated", - data: { id: "member_001", name: "Alice", wallet: "0xabc" }, + data: { id: "member_001", name: "Alice", wallet: "0x742d35cC6634c0532925a3B8879539d43374E290" }, }), "pass.created": makeWebhookPayload({ id: "whk_pc_001", @@ -91,7 +91,7 @@ export const WEBHOOK_FIXTURES: Record = { "verification.completed": makeWebhookPayload({ id: "whk_vc_001", type: "verification.completed", - data: { wallet: "0xabc" }, + data: { wallet: "0x742d35cC6634c0532925a3B8879539d43374E290" }, }), }; @@ -101,7 +101,6 @@ export const SESSION_ADMIN: Session = { roles: { [DEFAULT_GUILD_ID]: "admin" }, activeGuildId: DEFAULT_GUILD_ID, role: "admin", - permissions: ["passes:read", "passes:write", "members:read", "members:write", "guilds:read", "guilds:write", "settings:read", "settings:write"], csrfToken: "mock-csrf-token-for-development-only", permissions: ["passes:read", "passes:write", "members:read", "members:write", "guilds:read", "guilds:write", "activity:read", "settings:read", "settings:write"], }; @@ -130,7 +129,6 @@ export const SESSION_OWNER: Session = { roles: { [DEFAULT_GUILD_ID]: "owner" }, activeGuildId: DEFAULT_GUILD_ID, role: "owner", - permissions: ["passes:read", "passes:write", "members:read", "members:write", "guilds:read", "guilds:write", "settings:read", "settings:write"], csrfToken: "mock-csrf-token-for-development-only", permissions: ["passes:read", "passes:write", "members:read", "members:write", "guilds:read", "guilds:write", "activity:read", "settings:read", "settings:write"], }; diff --git a/apps/dashboard/test/live-members-mockclient.test.ts b/apps/dashboard/test/live-members-mockclient.test.ts index 5c57fca..964a785 100644 --- a/apps/dashboard/test/live-members-mockclient.test.ts +++ b/apps/dashboard/test/live-members-mockclient.test.ts @@ -3,7 +3,13 @@ import assert from "node:assert"; test("GET /api/members uses injected IntegrationClient in live mode via mock client", async () => { const previousMode = process.env.DASHBOARD_API_MODE; + const previousApiKey = process.env.GUILD_PASS_CORE_API_KEY; + const previousWebhookSecret = process.env.WEBHOOK_SECRET; + const previousCoreUrl = process.env.GUILD_PASS_CORE_URL; process.env.DASHBOARD_API_MODE = "live"; + process.env.GUILD_PASS_CORE_API_KEY = "test-core-api-key"; + process.env.WEBHOOK_SECRET = "test-webhook-secret"; + process.env.GUILD_PASS_CORE_URL = "http://127.0.0.1:1"; try { // inject a fake client @@ -42,5 +48,23 @@ test("GET /api/members uses injected IntegrationClient in live mode via mock cli } else { process.env.DASHBOARD_API_MODE = previousMode; } + + if (previousApiKey === undefined) { + delete process.env.GUILD_PASS_CORE_API_KEY; + } else { + process.env.GUILD_PASS_CORE_API_KEY = previousApiKey; + } + + if (previousWebhookSecret === undefined) { + delete process.env.WEBHOOK_SECRET; + } else { + process.env.WEBHOOK_SECRET = previousWebhookSecret; + } + + if (previousCoreUrl === undefined) { + delete process.env.GUILD_PASS_CORE_URL; + } else { + process.env.GUILD_PASS_CORE_URL = previousCoreUrl; + } } }); diff --git a/apps/dashboard/test/live-members.test.ts b/apps/dashboard/test/live-members.test.ts index 4baed9a..efb3d8b 100644 --- a/apps/dashboard/test/live-members.test.ts +++ b/apps/dashboard/test/live-members.test.ts @@ -5,7 +5,11 @@ import http from "node:http"; test("GET /api/members performs live wallet lookup via core API", async () => { const previousMode = process.env.DASHBOARD_API_MODE; const previousCoreUrl = process.env.GUILD_PASS_CORE_URL; + const previousApiKey = process.env.GUILD_PASS_CORE_API_KEY; + const previousWebhookSecret = process.env.WEBHOOK_SECRET; process.env.DASHBOARD_API_MODE = "live"; + process.env.GUILD_PASS_CORE_API_KEY = "test-core-api-key"; + process.env.WEBHOOK_SECRET = "test-webhook-secret"; // Note: this test requires a mock HTTP server; see live-members-mockclient for injected version // Start a tiny HTTP server that mimics the core API @@ -79,5 +83,17 @@ test("GET /api/members performs live wallet lookup via core API", async () => { } else { process.env.GUILD_PASS_CORE_URL = previousCoreUrl; } + + if (previousApiKey === undefined) { + delete process.env.GUILD_PASS_CORE_API_KEY; + } else { + process.env.GUILD_PASS_CORE_API_KEY = previousApiKey; + } + + if (previousWebhookSecret === undefined) { + delete process.env.WEBHOOK_SECRET; + } else { + process.env.WEBHOOK_SECRET = previousWebhookSecret; + } } }); diff --git a/apps/dashboard/test/live-verify-mockclient.test.ts b/apps/dashboard/test/live-verify-mockclient.test.ts index 19583b1..9744966 100644 --- a/apps/dashboard/test/live-verify-mockclient.test.ts +++ b/apps/dashboard/test/live-verify-mockclient.test.ts @@ -3,7 +3,13 @@ import assert from "node:assert"; test("POST /api/verify uses injected IntegrationClient in live mode via mock client", async () => { const previousMode = process.env.DASHBOARD_API_MODE; + const previousApiKey = process.env.GUILD_PASS_CORE_API_KEY; + const previousWebhookSecret = process.env.WEBHOOK_SECRET; + const previousCoreUrl = process.env.GUILD_PASS_CORE_URL; process.env.DASHBOARD_API_MODE = "live"; + process.env.GUILD_PASS_CORE_API_KEY = "test-core-api-key"; + process.env.WEBHOOK_SECRET = "test-webhook-secret"; + process.env.GUILD_PASS_CORE_URL = "http://127.0.0.1:1"; try { (globalThis as any).__TEST_INTEGRATION_CLIENT = { @@ -17,7 +23,7 @@ test("POST /api/verify uses injected IntegrationClient in live mode via mock cli const { POST } = await import("../app/api/verify/route.js"); - const payload = { discordUserId: "u_inj", wallet: "0xfeed" }; + const payload = { discordUserId: "u_inj", wallet: "0x742d35cC6634c0532925a3B8879539d43374E290" }; const req = new Request("http://localhost/api/verify", { method: "POST", headers: { "content-type": "application/json" }, @@ -40,5 +46,23 @@ test("POST /api/verify uses injected IntegrationClient in live mode via mock cli } else { process.env.DASHBOARD_API_MODE = previousMode; } + + if (previousApiKey === undefined) { + delete process.env.GUILD_PASS_CORE_API_KEY; + } else { + process.env.GUILD_PASS_CORE_API_KEY = previousApiKey; + } + + if (previousWebhookSecret === undefined) { + delete process.env.WEBHOOK_SECRET; + } else { + process.env.WEBHOOK_SECRET = previousWebhookSecret; + } + + if (previousCoreUrl === undefined) { + delete process.env.GUILD_PASS_CORE_URL; + } else { + process.env.GUILD_PASS_CORE_URL = previousCoreUrl; + } } }); diff --git a/apps/dashboard/test/live-verify.test.ts b/apps/dashboard/test/live-verify.test.ts index 22a7a6d..5c9730b 100644 --- a/apps/dashboard/test/live-verify.test.ts +++ b/apps/dashboard/test/live-verify.test.ts @@ -5,7 +5,11 @@ import http from "node:http"; test("POST /api/verify forwards to core API in live mode", async () => { const previousMode = process.env.DASHBOARD_API_MODE; const previousCoreUrl = process.env.GUILD_PASS_CORE_URL; + const previousApiKey = process.env.GUILD_PASS_CORE_API_KEY; + const previousWebhookSecret = process.env.WEBHOOK_SECRET; process.env.DASHBOARD_API_MODE = "live"; + process.env.GUILD_PASS_CORE_API_KEY = "test-core-api-key"; + process.env.WEBHOOK_SECRET = "test-webhook-secret"; // Note: this test requires a mock HTTP server; see live-verify-mockclient for injected version const server = http.createServer((req, res) => { @@ -43,7 +47,7 @@ test("POST /api/verify forwards to core API in live mode", async () => { try { const { POST } = await import("../app/api/verify/route.js"); - const payload = { discordUserId: "u_live", wallet: "0xfeed" }; + const payload = { discordUserId: "u_live", wallet: "0x742d35cC6634c0532925a3B8879539d43374E290" }; const req = new Request("http://localhost/api/verify", { method: "POST", headers: { "content-type": "application/json" }, @@ -74,5 +78,17 @@ test("POST /api/verify forwards to core API in live mode", async () => { } else { process.env.GUILD_PASS_CORE_URL = previousCoreUrl; } + + if (previousApiKey === undefined) { + delete process.env.GUILD_PASS_CORE_API_KEY; + } else { + process.env.GUILD_PASS_CORE_API_KEY = previousApiKey; + } + + if (previousWebhookSecret === undefined) { + delete process.env.WEBHOOK_SECRET; + } else { + process.env.WEBHOOK_SECRET = previousWebhookSecret; + } } }); diff --git a/apps/dashboard/test/members-csv.test.ts b/apps/dashboard/test/members-csv.test.ts index 641b367..62bb218 100644 --- a/apps/dashboard/test/members-csv.test.ts +++ b/apps/dashboard/test/members-csv.test.ts @@ -5,6 +5,8 @@ import type { Member } from "../lib/mock-data"; const baseMember: Member = { id: "member-1", + guildId: "1", + version: 1, name: "Alice", wallet: "0xabc", status: "active", diff --git a/apps/dashboard/test/members-export.test.ts b/apps/dashboard/test/members-export.test.ts index f83e626..ade857d 100644 --- a/apps/dashboard/test/members-export.test.ts +++ b/apps/dashboard/test/members-export.test.ts @@ -31,6 +31,7 @@ import { DEFAULT_GUILD_ID } from "../lib/mock-data"; function makeTestMember(index: number): Omit { return { + version: 1, wallet: `0x${String(index).padStart(40, "0")}`, name: `Member ${index}`, status: index % 3 === 0 ? "pending" : index % 5 === 0 ? "inactive" : "active", @@ -45,6 +46,8 @@ test("streamAll yields members in bounded-size chunks without materializing all" const repo = getMemberRepository(); const COUNT = 150; const CHUNK = 50; + // The mock adapter is seeded; streamAll returns seeded + created members. + const seeded = (await repo.getAll(DEFAULT_GUILD_ID)).length; for (let i = 0; i < COUNT; i++) { await repo.create(DEFAULT_GUILD_ID, makeTestMember(i)); @@ -56,10 +59,10 @@ test("streamAll yields members in bounded-size chunks without materializing all" assert.ok(chunk.length <= CHUNK, `chunk size ${chunk.length} > ${CHUNK}`); } - // 150 members @ 50/chunk = 3 chunks - assert.equal(chunks.length, Math.ceil(COUNT / CHUNK)); + const expected = COUNT + seeded; + assert.equal(chunks.length, Math.ceil(expected / CHUNK)); const total = chunks.reduce((sum, c) => sum + c.length, 0); - assert.equal(total, COUNT); + assert.equal(total, expected); }); test("streamAll respects guild isolation", async () => { @@ -83,6 +86,7 @@ test("streamAll with 10k+ members yields correct total without OOM", async () => const repo = getMemberRepository(); const COUNT = 10_000; const CHUNK = 500; + const seeded = (await repo.getAll(DEFAULT_GUILD_ID)).length; for (let i = 0; i < COUNT; i++) { await repo.create(DEFAULT_GUILD_ID, makeTestMember(i)); @@ -96,8 +100,9 @@ test("streamAll with 10k+ members yields correct total without OOM", async () => assert.ok(chunk.length <= CHUNK, `chunk ${chunkCount} size ${chunk.length} > ${CHUNK}`); } - assert.equal(total, COUNT); - assert.equal(chunkCount, Math.ceil(COUNT / CHUNK)); + const expected = COUNT + seeded; + assert.equal(total, expected); + assert.equal(chunkCount, Math.ceil(expected / CHUNK)); }); test("memberToCsvRow produces correctly escaped CSV", () => { @@ -108,6 +113,7 @@ test("memberToCsvRow produces correctly escaped CSV", () => { name: 'Alice "The Great", Esq.', status: "active", roles: ["admin", "member"], + version: 1, joinedAt: "2025-01-01T00:00:00Z", lastActive: "2025-01-02T00:00:00Z", }; @@ -120,8 +126,8 @@ test("memberToCsvRow produces correctly escaped CSV", () => { test("toMembersCsv includes headers and all rows", () => { const members: Member[] = [ - { id: "1", guildId: DEFAULT_GUILD_ID, wallet: "0xa", name: "Alice", status: "active", roles: ["member"], joinedAt: "2025-01-01T00:00:00Z", lastActive: "2025-01-02T00:00:00Z" }, - { id: "2", guildId: DEFAULT_GUILD_ID, wallet: "0xb", name: "Bob", status: "inactive", roles: [], joinedAt: "2025-02-01T00:00:00Z", lastActive: "2025-02-02T00:00:00Z" }, + { id: "1", guildId: DEFAULT_GUILD_ID, version: 1, wallet: "0xa", name: "Alice", status: "active", roles: ["member"], joinedAt: "2025-01-01T00:00:00Z", lastActive: "2025-01-02T00:00:00Z" }, + { id: "2", guildId: DEFAULT_GUILD_ID, version: 1, wallet: "0xb", name: "Bob", status: "inactive", roles: [], joinedAt: "2025-02-01T00:00:00Z", lastActive: "2025-02-02T00:00:00Z" }, ]; const csv = toMembersCsv(members); @@ -138,6 +144,7 @@ test("streamAll + memberToCsvRow compose into full CSV without buffering all", a const repo = getMemberRepository(); const COUNT = 500; const CHUNK = 100; + const seeded = (await repo.getAll(DEFAULT_GUILD_ID)).length; for (let i = 0; i < COUNT; i++) { await repo.create(DEFAULT_GUILD_ID, makeTestMember(i)); @@ -151,9 +158,9 @@ test("streamAll + memberToCsvRow compose into full CSV without buffering all", a for (const m of chunk) rows.push(memberToCsvRow(m)); } - assert.equal(totalMembers, COUNT); - assert.equal(rows.length, COUNT); - // Verify first and last members are present - assert.ok(rows[0].includes("Member 0")); - assert.ok(rows[COUNT - 1].includes(`Member ${COUNT - 1}`)); + assert.equal(totalMembers, COUNT + seeded); + assert.equal(rows.length, COUNT + seeded); + // Verify first and last created members are present + assert.ok(rows.some((r) => r.includes("Member 0"))); + assert.ok(rows.some((r) => r.includes(`Member ${COUNT - 1}`))); }); diff --git a/apps/dashboard/test/mutation-validation.test.ts b/apps/dashboard/test/mutation-validation.test.ts index 268cae6..4463d4b 100644 --- a/apps/dashboard/test/mutation-validation.test.ts +++ b/apps/dashboard/test/mutation-validation.test.ts @@ -16,7 +16,7 @@ import { process.env.DASHBOARD_API_MODE = "mock"; process.env.DASHBOARD_STORAGE_MODE = "mock"; -const VALID_WALLET = "0x742d35Cc6634C0532925a3b8879539d43374e290"; +const VALID_WALLET = "0x742d35cC6634c0532925a3B8879539d43374E290"; type MutationValidationResult = | ReturnType @@ -50,7 +50,7 @@ describe("pass mutation validation", () => { throw new Error("Expected validation to pass"); } - const pass = await getPassRepository().create(validation.data); + const pass = await getPassRepository().create("1", validation.data); assert.equal(pass.name, "Season Pass"); assert.equal(pass.status, "draft"); assert.equal(pass.currentSupply, 0); @@ -108,7 +108,7 @@ describe("member mutation validation", () => { throw new Error("Expected validation to pass"); } - const member = await getMemberRepository().create(validation.data); + const member = await getMemberRepository().create("1", validation.data); assert.equal(member.name, "Ada"); assert.equal(member.wallet, VALID_WALLET); assert.equal(member.status, "pending"); diff --git a/apps/dashboard/test/optimistic.test.ts b/apps/dashboard/test/optimistic.test.ts index 2106932..a7bf867 100644 --- a/apps/dashboard/test/optimistic.test.ts +++ b/apps/dashboard/test/optimistic.test.ts @@ -2,6 +2,9 @@ import { test, describe, beforeEach } from "node:test"; import assert from "node:assert/strict"; import { getPassRepository, getMemberRepository, clearRepositories } from "../lib/repositories/factory"; +// Guild scope for pass/member repository calls (seeded mock guild). +const GUILD = "1"; + describe("Optimistic UI Backend Support", () => { beforeEach(() => { clearRepositories(); @@ -9,48 +12,48 @@ describe("Optimistic UI Backend Support", () => { test("PassRepository - update should persist changes", async () => { const repo = getPassRepository(); - const passes = await repo.getAll(); + const passes = await repo.getAll(GUILD); const pass = passes[0]; assert.ok(pass, "Should have at least one pass"); - const updated = await repo.update(pass.id, { status: "inactive" }); + const updated = await repo.update(GUILD, pass.id, { status: "inactive" }); assert.ok(updated, "Update should return updated pass"); assert.equal(updated.status, "inactive"); - const fetched = await repo.getById(pass.id); + const fetched = await repo.getById(GUILD, pass.id); assert.equal(fetched?.status, "inactive"); }); test("PassRepository - update should return null for non-existent pass", async () => { const repo = getPassRepository(); - const updated = await repo.update("999", { status: "inactive" }); + const updated = await repo.update(GUILD, "999", { status: "inactive" }); assert.equal(updated, null); }); test("MemberRepository - update should persist changes", async () => { const repo = getMemberRepository(); - const members = await repo.getAll(); + const members = await repo.getAll(GUILD); const member = members[0]; assert.ok(member, "Should have at least one member"); - const updated = await repo.update(member.id, { status: "inactive" }); + const updated = await repo.update(GUILD, member.id, { status: "inactive" }); assert.ok(updated, "Update should return updated member"); assert.equal(updated.status, "inactive"); - const fetched = await repo.getById(member.id); + const fetched = await repo.getById(GUILD, member.id); assert.equal(fetched?.status, "inactive"); }); test("MemberRepository - delete should remove member", async () => { const repo = getMemberRepository(); - const members = await repo.getAll(); + const members = await repo.getAll(GUILD); const member = members[0]; assert.ok(member, "Should have at least one member"); - const success = await repo.delete(member.id); + const success = await repo.delete(GUILD, member.id); assert.equal(success, true); - const fetched = await repo.getById(member.id); + const fetched = await repo.getById(GUILD, member.id); assert.equal(fetched, null); }); }); diff --git a/apps/dashboard/test/pass-sort.test.ts b/apps/dashboard/test/pass-sort.test.ts index 3f0d8e5..8826d30 100644 --- a/apps/dashboard/test/pass-sort.test.ts +++ b/apps/dashboard/test/pass-sort.test.ts @@ -7,6 +7,7 @@ import { sortPasses, type PassSortState } from "../lib/pass-sort"; const passes: Pass[] = [ { id: "free", + guildId: "1", name: "Free", description: "No price or supply cap", status: "draft", @@ -16,6 +17,7 @@ const passes: Pass[] = [ }, { id: "nearly-full", + guildId: "1", name: "Nearly full", description: "Almost sold out", status: "active", @@ -26,6 +28,7 @@ const passes: Pass[] = [ }, { id: "half-full", + guildId: "1", name: "Half full", description: "Half sold", status: "inactive", diff --git a/apps/dashboard/test/reconciliation.test.ts b/apps/dashboard/test/reconciliation.test.ts index b6856af..2c5d574 100644 --- a/apps/dashboard/test/reconciliation.test.ts +++ b/apps/dashboard/test/reconciliation.test.ts @@ -387,12 +387,13 @@ describe("Reconciliation", () => { ); assert.ok(reconcileEvent, "Should find a reconciliation audit event"); - assert.equal(reconcileEvent.metadata.mode, "fix"); + const metadata = reconcileEvent.metadata!; + assert.equal(metadata.mode, "fix"); assert.equal(reconcileEvent.severity, "warning"); assert.equal(reconcileEvent.actor.name, "Reconciliation Job"); - assert.ok(Array.isArray(reconcileEvent.metadata.discrepancies)); - assert.equal(reconcileEvent.metadata.discrepancies.length, 1); - assert.equal(reconcileEvent.metadata.discrepancies[0].field, "memberCount"); + assert.ok(Array.isArray(metadata.discrepancies)); + assert.equal(metadata.discrepancies.length, 1); + assert.equal(metadata.discrepancies[0].field, "memberCount"); }); }); diff --git a/apps/dashboard/test/repositories.test.ts b/apps/dashboard/test/repositories.test.ts index ad86af0..f54031a 100644 --- a/apps/dashboard/test/repositories.test.ts +++ b/apps/dashboard/test/repositories.test.ts @@ -18,17 +18,21 @@ import { process.env.DASHBOARD_STORAGE_MODE = "mock"; process.env.DASHBOARD_API_MODE = "mock"; +// Pass/member repositories are guild-scoped; the seeded mock data belongs to +// guild "1" (DEFAULT_GUILD_ID). +const GUILD = "1"; + test("Repository Factory: MockPassRepository", async () => { clearRepositories(); const repo = getPassRepository(); assert.ok(repo, "Pass repository should be created"); - const passes = await repo.getAll(); + const passes = await repo.getAll(GUILD); assert.ok(Array.isArray(passes), "Should return array of passes"); // Test create - const newPass = await repo.create({ + const newPass = await repo.create(GUILD, { name: "Test Pass", price: 1.0, description: "Test description", @@ -39,19 +43,19 @@ test("Repository Factory: MockPassRepository", async () => { assert.strictEqual(newPass.name, "Test Pass", "Pass name should match"); // Test getById - const retrieved = await repo.getById(newPass.id); + const retrieved = await repo.getById(GUILD, newPass.id); assert.ok(retrieved, "Should retrieve created pass"); assert.strictEqual(retrieved.id, newPass.id, "Retrieved pass id should match"); // Test update - const updated = await repo.update(newPass.id, { price: 2.0 }); + const updated = await repo.update(GUILD, newPass.id, { price: 2.0 }); assert.strictEqual(updated?.price, 2.0, "Updated price should reflect"); // Test delete - const deleted = await repo.delete(newPass.id); + const deleted = await repo.delete(GUILD, newPass.id); assert.strictEqual(deleted, true, "Delete should return true"); - const notFound = await repo.getById(newPass.id); + const notFound = await repo.getById(GUILD, newPass.id); assert.strictEqual(notFound, null, "Deleted pass should not be found"); }); @@ -60,7 +64,7 @@ test("Repository Factory: MockPassRepository returns consistent pagination at bo const repo = getPassRepository(); - const outOfRangePage = await repo.query({ limit: 2, page: 3 }); + const outOfRangePage = await repo.query(GUILD, { limit: 2, page: 3 }); assert.deepStrictEqual( { itemIds: outOfRangePage.items.map((pass) => pass.id), @@ -83,7 +87,7 @@ test("Repository Factory: MockPassRepository returns consistent pagination at bo "Out-of-range pass pages should keep pagination metadata consistent" ); - const oversizedLimit = await repo.query({ limit: 99, page: 1 }); + const oversizedLimit = await repo.query(GUILD, { limit: 99, page: 1 }); assert.deepStrictEqual( { itemIds: oversizedLimit.items.map((pass) => pass.id), @@ -136,11 +140,11 @@ test("Repository Factory: MockMemberRepository", async () => { const repo = getMemberRepository(); assert.ok(repo, "Member repository should be created"); - const members = await repo.getAll(); + const members = await repo.getAll(GUILD); assert.ok(Array.isArray(members), "Should return array of members"); // Test create - const newMember = await repo.create({ + const newMember = await repo.create(GUILD, { wallet: "0x999zzz", name: "Charlie", status: "active", @@ -152,16 +156,16 @@ test("Repository Factory: MockMemberRepository", async () => { assert.strictEqual(newMember.wallet, "0x999zzz", "Member wallet should match"); // Test getByWallet - const byWallet = await repo.getByWallet("0x999zzz"); + const byWallet = await repo.getByWallet(GUILD, "0x999zzz"); assert.ok(byWallet, "Should find member by wallet"); assert.strictEqual(byWallet.id, newMember.id, "Located member should match created"); // Test update - const updated = await repo.update(newMember.id, { status: "inactive" }); + const updated = await repo.update(GUILD, newMember.id, { status: "inactive" }); assert.strictEqual(updated?.status, "inactive", "Updated status should reflect"); // Wallet index should still work after update - const stillFound = await repo.getByWallet("0x999zzz"); + const stillFound = await repo.getByWallet(GUILD, "0x999zzz"); assert.ok(stillFound, "Member should still be findable by wallet after update"); }); @@ -169,7 +173,7 @@ test("Repository Factory: MockMemberRepository returns consistent pagination for clearRepositories(); const repo = getMemberRepository(); - const result = await repo.query({ search: "no-such-wallet", limit: 2, page: 1 }); + const result = await repo.query(GUILD, { search: "no-such-wallet", limit: 2, page: 1 }); assert.deepStrictEqual( { @@ -267,7 +271,7 @@ test("Repository Factory: Data persistence across calls", async () => { clearRepositories(); // Create a pass - const pass1 = await getPassRepository().create({ + const pass1 = await getPassRepository().create(GUILD, { name: "Persistent Pass", price: 5.0, description: "Should persist", @@ -276,7 +280,7 @@ test("Repository Factory: Data persistence across calls", async () => { }); // Create a member - await getMemberRepository().create({ + await getMemberRepository().create(GUILD, { wallet: "0xpersist", name: "Persistent Member", status: "active", @@ -286,23 +290,23 @@ test("Repository Factory: Data persistence across calls", async () => { }); // Retrieve and verify - const retrieved = await getPassRepository().getById(pass1.id); + const retrieved = await getPassRepository().getById(GUILD, pass1.id); assert.strictEqual(retrieved?.id, pass1.id, "Pass should persist"); - const memberRetrieved = await getMemberRepository().getByWallet("0xpersist"); + const memberRetrieved = await getMemberRepository().getByWallet(GUILD, "0xpersist"); assert.strictEqual(memberRetrieved?.wallet, "0xpersist", "Member should persist"); // Verify all instances share the same data - const allPasses = await getPassRepository().getAll(); + const allPasses = await getPassRepository().getAll(GUILD); assert.ok(allPasses.some((p) => p.id === pass1.id), "New pass should appear in getAll"); - const allMembers = await getMemberRepository().getAll(); + const allMembers = await getMemberRepository().getAll(GUILD); assert.ok(allMembers.some((m) => m.wallet === "0xpersist"), "New member should appear in getAll"); }); test("Repository Factory: Clear repositories", async () => { // Create a pass - await getPassRepository().create({ + await getPassRepository().create(GUILD, { name: "Will be cleared", price: 1.0, description: "Test", @@ -314,7 +318,7 @@ test("Repository Factory: Clear repositories", async () => { clearRepositories(); // Create new factory and verify fresh state - await getPassRepository().create({ + await getPassRepository().create(GUILD, { name: "After clear", price: 2.0, description: "Should be fresh", @@ -323,7 +327,7 @@ test("Repository Factory: Clear repositories", async () => { }); // The cleared repository should have fresh mock data - const allPasses = await getPassRepository().getAll(); + const allPasses = await getPassRepository().getAll(GUILD); assert.ok(allPasses.some((p) => p.name === "After clear"), "Should have new pass"); }); @@ -338,7 +342,7 @@ test("Repository Factory: Error handling in durable mode stub", async () => { try { const repo = getPassRepository(); - await repo.getAll(); + await repo.getAll(GUILD); assert.fail("Should throw 'not yet implemented'"); } catch (error: any) { assert.ok(error.message.includes("not yet implemented"), "Durable adapter should throw informative error"); diff --git a/apps/dashboard/test/repositories/contracts.ts b/apps/dashboard/test/repositories/contracts.ts index 5527bd6..edd2fc0 100644 --- a/apps/dashboard/test/repositories/contracts.ts +++ b/apps/dashboard/test/repositories/contracts.ts @@ -19,31 +19,62 @@ import type { IGuildRepository, IMemberRepository, IActivityRepository, + MemberCreateData, + MemberUpdateData, + PassCreateData, + PassUpdateData, } from "../../lib/repositories/types"; +/** + * Guild (tenant) scope used by the contract suites. Adapters under test must + * treat this as the guild that holds their seed data. + */ +export const DEFAULT_CONTRACT_GUILD = "1"; + +/** + * Secondary guild used by the cross-tenant isolation suites. Must be distinct + * from the primary guild; records are created in it during the tests. + */ +export const SECONDARY_CONTRACT_GUILD = "2"; + +export interface RepositoryContractOptions { + /** Guild scope for the standard behavioural suites (default "1"). */ + guildId?: string; +} + +export interface IsolationContractOptions { + /** Primary guild (default "1"). */ + guildA?: string; + /** Adversary guild (default "2"). */ + guildB?: string; +} + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Pass repository contract // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export function passRepositoryContract( createRepo: () => IPassRepository, + options: RepositoryContractOptions = {}, ): void { + const guildId = options.guildId ?? DEFAULT_CONTRACT_GUILD; + test("PassRepository: getAll returns initial data", async () => { const repo = createRepo(); - const passes = await repo.getAll(); + const passes = await repo.getAll(guildId); assert.ok(Array.isArray(passes), "getAll should return an array"); assert.ok(passes.length > 0, "should have seed data"); }); test("PassRepository: getById returns null for missing record", async () => { const repo = createRepo(); - const result = await repo.getById("non-existent-id"); + const result = await repo.getById(guildId, "non-existent-id"); assert.strictEqual(result, null); }); test("PassRepository: create returns a pass with id and createdAt", async () => { const repo = createRepo(); - const pass = await repo.create({ + const pass = await repo.create(guildId, { name: "Contract Test Pass", description: "Created by contract test", status: "active", @@ -63,14 +94,14 @@ export function passRepositoryContract( test("PassRepository: create persists so getAll includes it", async () => { const repo = createRepo(); - const pass = await repo.create({ + const pass = await repo.create(guildId, { name: "Persist Test Pass", description: "Should appear in getAll", status: "draft", currentSupply: 0, }); - const all = await repo.getAll(); + const all = await repo.getAll(guildId); const found = all.find((p) => p.id === pass.id); assert.ok(found, "created pass should be in getAll results"); assert.strictEqual(found?.name, "Persist Test Pass"); @@ -78,7 +109,7 @@ export function passRepositoryContract( test("PassRepository: getById retrieves a created pass", async () => { const repo = createRepo(); - const pass = await repo.create({ + const pass = await repo.create(guildId, { name: "Retrieval Test", description: "Should be retrievable by id", status: "active", @@ -86,7 +117,7 @@ export function passRepositoryContract( currentSupply: 10, }); - const found = await repo.getById(pass.id); + const found = await repo.getById(guildId, pass.id); assert.ok(found, "should retrieve created pass"); assert.strictEqual(found?.id, pass.id); assert.strictEqual(found?.name, "Retrieval Test"); @@ -95,7 +126,7 @@ export function passRepositoryContract( test("PassRepository: update modifies fields", async () => { const repo = createRepo(); - const pass = await repo.create({ + const pass = await repo.create(guildId, { name: "Update Test", description: "Will be updated", status: "draft", @@ -103,7 +134,7 @@ export function passRepositoryContract( currentSupply: 5, }); - const updated = await repo.update(pass.id, { + const updated = await repo.update(guildId, pass.id, { name: "Updated Name", price: 0.2, status: "active", @@ -120,43 +151,43 @@ export function passRepositoryContract( test("PassRepository: update returns null for missing record", async () => { const repo = createRepo(); - const result = await repo.update("non-existent-id", { name: "Nope" }); + const result = await repo.update(guildId, "non-existent-id", { name: "Nope" }); assert.strictEqual(result, null); }); test("PassRepository: delete removes a pass", async () => { const repo = createRepo(); - const pass = await repo.create({ + const pass = await repo.create(guildId, { name: "Delete Test", description: "Will be deleted", status: "active", currentSupply: 0, }); - const deleted = await repo.delete(pass.id); + const deleted = await repo.delete(guildId, pass.id); assert.strictEqual(deleted, true, "delete should return true"); - const found = await repo.getById(pass.id); + const found = await repo.getById(guildId, pass.id); assert.strictEqual(found, null, "deleted pass should not be found"); }); test("PassRepository: delete returns false for missing record", async () => { const repo = createRepo(); - const result = await repo.delete("non-existent-id"); + const result = await repo.delete(guildId, "non-existent-id"); assert.strictEqual(result, false); }); test("PassRepository: delete removes from getAll", async () => { const repo = createRepo(); - const pass = await repo.create({ + const pass = await repo.create(guildId, { name: "Gone Soon", description: "Will disappear from getAll", status: "active", currentSupply: 1, }); - await repo.delete(pass.id); - const all = await repo.getAll(); + await repo.delete(guildId, pass.id); + const all = await repo.getAll(guildId); const found = all.find((p) => p.id === pass.id); assert.ok(!found, "deleted pass should not appear in getAll"); }); @@ -301,29 +332,32 @@ export function guildRepositoryContract( export function memberRepositoryContract( createRepo: () => IMemberRepository, + options: RepositoryContractOptions = {}, ): void { + const guildId = options.guildId ?? DEFAULT_CONTRACT_GUILD; + test("MemberRepository: getAll returns initial data", async () => { const repo = createRepo(); - const members = await repo.getAll(); + const members = await repo.getAll(guildId); assert.ok(Array.isArray(members), "getAll should return an array"); assert.ok(members.length > 0, "should have seed data"); }); test("MemberRepository: getById returns null for missing record", async () => { const repo = createRepo(); - const result = await repo.getById("non-existent-id"); + const result = await repo.getById(guildId, "non-existent-id"); assert.strictEqual(result, null); }); test("MemberRepository: getByWallet returns null for missing wallet", async () => { const repo = createRepo(); - const result = await repo.getByWallet("0xnonexistent"); + const result = await repo.getByWallet(guildId, "0xnonexistent"); assert.strictEqual(result, null); }); test("MemberRepository: create returns a member with id", async () => { const repo = createRepo(); - const member = await repo.create({ + const member = await repo.create(guildId, { wallet: "0xcontract-test-wallet-001", name: "Contract Test Member", status: "active", @@ -341,7 +375,7 @@ export function memberRepositoryContract( test("MemberRepository: getByWallet finds created member by wallet", async () => { const repo = createRepo(); - const member = await repo.create({ + const member = await repo.create(guildId, { wallet: "0xwallet-lookup-test", name: "Wallet Lookup", status: "active", @@ -350,7 +384,7 @@ export function memberRepositoryContract( lastActive: "2025-06-28T00:00:00Z", }); - const found = await repo.getByWallet("0xwallet-lookup-test"); + const found = await repo.getByWallet(guildId, "0xwallet-lookup-test"); assert.ok(found, "should find member by wallet"); assert.strictEqual(found?.id, member.id); assert.strictEqual(found?.name, "Wallet Lookup"); @@ -358,7 +392,7 @@ export function memberRepositoryContract( test("MemberRepository: getByWallet returns null after member deleted", async () => { const repo = createRepo(); - const member = await repo.create({ + const member = await repo.create(guildId, { wallet: "0xdelete-wallet", name: "Delete Wallet Test", status: "active", @@ -367,15 +401,15 @@ export function memberRepositoryContract( lastActive: "2025-06-28T00:00:00Z", }); - await repo.delete(member.id); + await repo.delete(guildId, member.id); - const found = await repo.getByWallet("0xdelete-wallet"); + const found = await repo.getByWallet(guildId, "0xdelete-wallet"); assert.strictEqual(found, null, "deleted member should not be found by wallet"); }); test("MemberRepository: create persists so getAll includes it", async () => { const repo = createRepo(); - const member = await repo.create({ + const member = await repo.create(guildId, { wallet: "0xpersist-wallet", name: "Persist Test", status: "pending", @@ -384,7 +418,7 @@ export function memberRepositoryContract( lastActive: "2025-06-28T00:00:00Z", }); - const all = await repo.getAll(); + const all = await repo.getAll(guildId); const found = all.find((m) => m.id === member.id); assert.ok(found, "created member should be in getAll results"); assert.strictEqual(found?.wallet, "0xpersist-wallet"); @@ -392,7 +426,7 @@ export function memberRepositoryContract( test("MemberRepository: update modifies fields and maintains wallet index", async () => { const repo = createRepo(); - const member = await repo.create({ + const member = await repo.create(guildId, { wallet: "0xoriginal-wallet", name: "Original Name", status: "active", @@ -401,7 +435,7 @@ export function memberRepositoryContract( lastActive: "2025-06-28T00:00:00Z", }); - const updated = await repo.update(member.id, { + const updated = await repo.update(guildId, member.id, { name: "Updated Name", status: "inactive", roles: ["member", "contributor"], @@ -415,14 +449,14 @@ export function memberRepositoryContract( assert.strictEqual(updated?.wallet, "0xoriginal-wallet", "wallet should not change"); // Wallet index should still work with original wallet - const byWallet = await repo.getByWallet("0xoriginal-wallet"); + const byWallet = await repo.getByWallet(guildId, "0xoriginal-wallet"); assert.ok(byWallet, "should still find member by original wallet"); assert.strictEqual(byWallet?.name, "Updated Name"); }); test("MemberRepository: update with wallet change re-indexes lookup", async () => { const repo = createRepo(); - const member = await repo.create({ + const member = await repo.create(guildId, { wallet: "0xold-wallet", name: "Wallet Change", status: "active", @@ -431,27 +465,27 @@ export function memberRepositoryContract( lastActive: "2025-06-28T00:00:00Z", }); - await repo.update(member.id, { wallet: "0xnew-wallet" }); + await repo.update(guildId, member.id, { wallet: "0xnew-wallet" }); // Old wallet should no longer resolve - const oldLookup = await repo.getByWallet("0xold-wallet"); + const oldLookup = await repo.getByWallet(guildId, "0xold-wallet"); assert.strictEqual(oldLookup, null, "old wallet should no longer resolve"); // New wallet should resolve - const newLookup = await repo.getByWallet("0xnew-wallet"); + const newLookup = await repo.getByWallet(guildId, "0xnew-wallet"); assert.ok(newLookup, "new wallet should resolve"); assert.strictEqual(newLookup?.id, member.id); }); test("MemberRepository: update returns null for missing record", async () => { const repo = createRepo(); - const result = await repo.update("non-existent-id", { name: "Nope" }); + const result = await repo.update(guildId, "non-existent-id", { name: "Nope" }); assert.strictEqual(result, null); }); test("MemberRepository: delete removes a member", async () => { const repo = createRepo(); - const member = await repo.create({ + const member = await repo.create(guildId, { wallet: "0xdelete-member", name: "Delete Test", status: "active", @@ -460,22 +494,22 @@ export function memberRepositoryContract( lastActive: "2025-06-28T00:00:00Z", }); - const deleted = await repo.delete(member.id); + const deleted = await repo.delete(guildId, member.id); assert.strictEqual(deleted, true, "delete should return true"); - const found = await repo.getById(member.id); + const found = await repo.getById(guildId, member.id); assert.strictEqual(found, null, "deleted member should not be found"); }); test("MemberRepository: delete returns false for missing record", async () => { const repo = createRepo(); - const result = await repo.delete("non-existent-id"); + const result = await repo.delete(guildId, "non-existent-id"); assert.strictEqual(result, false); }); test("MemberRepository: delete removes from getAll", async () => { const repo = createRepo(); - const member = await repo.create({ + const member = await repo.create(guildId, { wallet: "0xgone-member", name: "Gone Member", status: "active", @@ -484,15 +518,15 @@ export function memberRepositoryContract( lastActive: "2025-06-28T00:00:00Z", }); - await repo.delete(member.id); - const all = await repo.getAll(); + await repo.delete(guildId, member.id); + const all = await repo.getAll(guildId); const found = all.find((m) => m.id === member.id); assert.ok(!found, "deleted member should not appear in getAll"); }); test("MemberRepository: getById retrieves a created member", async () => { const repo = createRepo(); - const member = await repo.create({ + const member = await repo.create(guildId, { wallet: "0xretrieval-member", name: "Retrieval Test", status: "active", @@ -501,7 +535,7 @@ export function memberRepositoryContract( lastActive: "2025-06-28T00:00:00Z", }); - const found = await repo.getById(member.id); + const found = await repo.getById(guildId, member.id); assert.ok(found, "should retrieve created member"); assert.strictEqual(found?.id, member.id); assert.strictEqual(found?.name, "Retrieval Test"); @@ -744,3 +778,278 @@ export function activityRepositoryContract( assert.strictEqual(result.length, 0, "should be empty when no events match"); }); } + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Cross-tenant isolation contracts +// +// These suites prove the multi-tenant isolation guarantee documented in +// docs/multi-tenancy.md: a repository call scoped to guild A structurally +// cannot read, modify, or delete guild B's data โ€” including adversarial +// attempts that pass another guild's record ID, reuse of the same wallet +// across guilds, and payloads that try to smuggle a foreign guildId past the +// type system. Every conforming adapter (mock included) must pass them. +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const isolationPass = (name: string): PassCreateData => ({ + name, + description: `Isolation fixture: ${name}`, + status: "active", + currentSupply: 0, +}); + +const isolationMember = (name: string, wallet: string): MemberCreateData => ({ + name, + wallet, + status: "active", + roles: ["member"], + joinedAt: "2025-06-01T00:00:00Z", + lastActive: "2025-06-28T00:00:00Z", +}); + +export function passRepositoryIsolationContract( + createRepo: () => IPassRepository, + options: IsolationContractOptions = {}, +): void { + const guildA = options.guildA ?? DEFAULT_CONTRACT_GUILD; + const guildB = options.guildB ?? SECONDARY_CONTRACT_GUILD; + + test("PassRepository isolation: getAll never returns another guild's passes", async () => { + const repo = createRepo(); + const passA = await repo.create(guildA, isolationPass("Guild A Pass")); + const passB = await repo.create(guildB, isolationPass("Guild B Pass")); + + const allA = await repo.getAll(guildA); + const allB = await repo.getAll(guildB); + + assert.ok(allA.some((p) => p.id === passA.id), "guild A should see its own pass"); + assert.ok(!allA.some((p) => p.id === passB.id), "guild A must not see guild B's pass"); + assert.ok(allB.some((p) => p.id === passB.id), "guild B should see its own pass"); + assert.ok(!allB.some((p) => p.id === passA.id), "guild B must not see guild A's pass"); + assert.ok(allA.every((p) => p.guildId === guildA), "every pass in guild A's view is stamped with guild A"); + assert.ok(allB.every((p) => p.guildId === guildB), "every pass in guild B's view is stamped with guild B"); + }); + + test("PassRepository isolation: query never returns another guild's passes", async () => { + const repo = createRepo(); + await repo.create(guildA, isolationPass("Unique Isolation Needle")); + + const result = await repo.query(guildB, { search: "Unique Isolation Needle" }); + assert.strictEqual(result.items.length, 0, "guild B's search must not match guild A's pass"); + assert.strictEqual(result.total, 0, "guild B's total must not count guild A's pass"); + + const unfiltered = await repo.query(guildB); + assert.ok( + unfiltered.items.every((p) => p.guildId === guildB), + "every queried pass must belong to the requested guild", + ); + }); + + test("PassRepository isolation: getById with another guild's pass ID returns null", async () => { + const repo = createRepo(); + const passA = await repo.create(guildA, isolationPass("Lookup Target")); + + assert.strictEqual( + await repo.getById(guildB, passA.id), + null, + "guild B must not resolve guild A's pass by ID", + ); + assert.ok(await repo.getById(guildA, passA.id), "guild A can still resolve its own pass"); + }); + + test("PassRepository isolation: update with another guild's pass ID is a no-op returning null", async () => { + const repo = createRepo(); + const passA = await repo.create(guildA, isolationPass("Update Target")); + + const hijack = await repo.update(guildB, passA.id, { name: "Hijacked" }); + assert.strictEqual(hijack, null, "cross-guild update must return null"); + + const intact = await repo.getById(guildA, passA.id); + assert.strictEqual(intact?.name, "Update Target", "guild A's pass must be unmodified"); + }); + + test("PassRepository isolation: delete with another guild's pass ID is a no-op returning false", async () => { + const repo = createRepo(); + const passA = await repo.create(guildA, isolationPass("Delete Target")); + + const hijack = await repo.delete(guildB, passA.id); + assert.strictEqual(hijack, false, "cross-guild delete must return false"); + assert.ok(await repo.getById(guildA, passA.id), "guild A's pass must still exist"); + }); + + test("PassRepository isolation: create ignores a smuggled guildId in the payload", async () => { + const repo = createRepo(); + // Simulate an adversarial JS caller bypassing the type system. + const smuggled = { ...isolationPass("Smuggled Pass"), guildId: guildB } as PassCreateData; + const created = await repo.create(guildA, smuggled); + + assert.strictEqual(created.guildId, guildA, "created pass must belong to the scope guild"); + assert.strictEqual( + await repo.getById(guildB, created.id), + null, + "the pass must not be visible to the smuggled guild", + ); + }); + + test("PassRepository isolation: update cannot reassign a pass to another guild", async () => { + const repo = createRepo(); + const passA = await repo.create(guildA, isolationPass("Reassign Target")); + + // Simulate an adversarial JS caller bypassing the type system. + const smuggled = { guildId: guildB } as PassUpdateData; + const updated = await repo.update(guildA, passA.id, smuggled); + + assert.strictEqual(updated?.guildId, guildA, "the owning guild must be immutable"); + assert.strictEqual(await repo.getById(guildB, passA.id), null, "guild B must not gain access"); + assert.ok(await repo.getById(guildA, passA.id), "guild A must retain access"); + }); +} + +export function memberRepositoryIsolationContract( + createRepo: () => IMemberRepository, + options: IsolationContractOptions = {}, +): void { + const guildA = options.guildA ?? DEFAULT_CONTRACT_GUILD; + const guildB = options.guildB ?? SECONDARY_CONTRACT_GUILD; + + test("MemberRepository isolation: getAll never returns another guild's members", async () => { + const repo = createRepo(); + const memberA = await repo.create(guildA, isolationMember("Ana", "0xiso-a-getall")); + const memberB = await repo.create(guildB, isolationMember("Ben", "0xiso-b-getall")); + + const allA = await repo.getAll(guildA); + const allB = await repo.getAll(guildB); + + assert.ok(allA.some((m) => m.id === memberA.id), "guild A should see its own member"); + assert.ok(!allA.some((m) => m.id === memberB.id), "guild A must not see guild B's member"); + assert.ok(allB.some((m) => m.id === memberB.id), "guild B should see its own member"); + assert.ok(!allB.some((m) => m.id === memberA.id), "guild B must not see guild A's member"); + assert.ok(allA.every((m) => m.guildId === guildA), "every member in guild A's view is stamped with guild A"); + assert.ok(allB.every((m) => m.guildId === guildB), "every member in guild B's view is stamped with guild B"); + }); + + test("MemberRepository isolation: query never returns another guild's members", async () => { + const repo = createRepo(); + await repo.create(guildA, isolationMember("Needle Member", "0xiso-a-query")); + + const result = await repo.query(guildB, { search: "Needle Member" }); + assert.strictEqual(result.items.length, 0, "guild B's search must not match guild A's member"); + assert.strictEqual(result.total, 0, "guild B's total must not count guild A's member"); + + const unfiltered = await repo.query(guildB); + assert.ok( + unfiltered.items.every((m) => m.guildId === guildB), + "every queried member must belong to the requested guild", + ); + }); + + test("MemberRepository isolation: getById with another guild's member ID returns null", async () => { + const repo = createRepo(); + const memberA = await repo.create(guildA, isolationMember("Lookup", "0xiso-a-byid")); + + assert.strictEqual( + await repo.getById(guildB, memberA.id), + null, + "guild B must not resolve guild A's member by ID", + ); + assert.ok(await repo.getById(guildA, memberA.id), "guild A can still resolve its own member"); + }); + + test("MemberRepository isolation: getByWallet is guild-scoped", async () => { + const repo = createRepo(); + await repo.create(guildA, isolationMember("Wallet Holder", "0xiso-shared-wallet")); + + assert.strictEqual( + await repo.getByWallet(guildB, "0xiso-shared-wallet"), + null, + "guild B must not resolve guild A's member by wallet", + ); + assert.ok( + await repo.getByWallet(guildA, "0xiso-shared-wallet"), + "guild A can still resolve its own member by wallet", + ); + }); + + test("MemberRepository isolation: the same wallet can exist independently in two guilds", async () => { + const repo = createRepo(); + await repo.create(guildA, isolationMember("Ana", "0xiso-dual-wallet")); + await repo.create(guildB, isolationMember("Ben", "0xiso-dual-wallet")); + + const inA = await repo.getByWallet(guildA, "0xiso-dual-wallet"); + const inB = await repo.getByWallet(guildB, "0xiso-dual-wallet"); + assert.strictEqual(inA?.name, "Ana", "guild A resolves its own record for the wallet"); + assert.strictEqual(inB?.name, "Ben", "guild B resolves its own record for the wallet"); + + // Deleting the wallet's member in one guild must not affect the other. + assert.strictEqual(await repo.delete(guildA, inA!.id), true); + assert.strictEqual(await repo.getByWallet(guildA, "0xiso-dual-wallet"), null); + assert.strictEqual( + (await repo.getByWallet(guildB, "0xiso-dual-wallet"))?.name, + "Ben", + "guild B's record must survive guild A's delete", + ); + }); + + test("MemberRepository isolation: update with another guild's member ID is a no-op returning null", async () => { + const repo = createRepo(); + const memberA = await repo.create(guildA, isolationMember("Update Target", "0xiso-a-update")); + + const hijack = await repo.update(guildB, memberA.id, { name: "Hijacked", roles: ["admin"] }); + assert.strictEqual(hijack, null, "cross-guild update must return null"); + + const intact = await repo.getById(guildA, memberA.id); + assert.strictEqual(intact?.name, "Update Target", "guild A's member must be unmodified"); + assert.deepStrictEqual(intact?.roles, ["member"], "guild A's member roles must be unmodified"); + }); + + test("MemberRepository isolation: delete with another guild's member ID is a no-op returning false", async () => { + const repo = createRepo(); + const memberA = await repo.create(guildA, isolationMember("Delete Target", "0xiso-a-delete")); + + const hijack = await repo.delete(guildB, memberA.id); + assert.strictEqual(hijack, false, "cross-guild delete must return false"); + assert.ok(await repo.getById(guildA, memberA.id), "guild A's member must still exist"); + assert.ok( + await repo.getByWallet(guildA, "0xiso-a-delete"), + "guild A's wallet lookup must be unaffected", + ); + }); + + test("MemberRepository isolation: create ignores a smuggled guildId in the payload", async () => { + const repo = createRepo(); + // Simulate an adversarial JS caller bypassing the type system. + const smuggled = { + ...isolationMember("Smuggler", "0xiso-smuggle-create"), + guildId: guildB, + } as MemberCreateData; + const created = await repo.create(guildA, smuggled); + + assert.strictEqual(created.guildId, guildA, "created member must belong to the scope guild"); + assert.strictEqual( + await repo.getById(guildB, created.id), + null, + "the member must not be visible to the smuggled guild", + ); + assert.strictEqual( + await repo.getByWallet(guildB, "0xiso-smuggle-create"), + null, + "the wallet must not resolve in the smuggled guild", + ); + }); + + test("MemberRepository isolation: update cannot reassign a member to another guild", async () => { + const repo = createRepo(); + const memberA = await repo.create(guildA, isolationMember("Reassign", "0xiso-smuggle-update")); + + // Simulate an adversarial JS caller bypassing the type system. + const smuggled = { guildId: guildB } as MemberUpdateData; + const updated = await repo.update(guildA, memberA.id, smuggled); + + assert.strictEqual(updated?.guildId, guildA, "the owning guild must be immutable"); + assert.strictEqual(await repo.getById(guildB, memberA.id), null, "guild B must not gain access"); + assert.ok(await repo.getById(guildA, memberA.id), "guild A must retain access"); + assert.ok( + await repo.getByWallet(guildA, "0xiso-smuggle-update"), + "guild A's wallet lookup must be unaffected", + ); + }); +} diff --git a/apps/dashboard/test/repositories/mock-contract.test.ts b/apps/dashboard/test/repositories/mock-contract.test.ts index 71c3426..0464777 100644 --- a/apps/dashboard/test/repositories/mock-contract.test.ts +++ b/apps/dashboard/test/repositories/mock-contract.test.ts @@ -13,6 +13,8 @@ import { guildRepositoryContract, memberRepositoryContract, activityRepositoryContract, + passRepositoryIsolationContract, + memberRepositoryIsolationContract, } from "./contracts"; import { MockPassRepository, @@ -25,3 +27,8 @@ passRepositoryContract(() => new MockPassRepository()); guildRepositoryContract(() => new MockGuildRepository()); memberRepositoryContract(() => new MockMemberRepository()); activityRepositoryContract(() => new MockActivityRepository()); + +// Cross-tenant isolation guarantee (docs/multi-tenancy.md): required of every +// conforming repository implementation, mock included. +passRepositoryIsolationContract(() => new MockPassRepository()); +memberRepositoryIsolationContract(() => new MockMemberRepository()); diff --git a/apps/dashboard/test/settings-api.test.ts b/apps/dashboard/test/settings-api.test.ts index 98bb6df..fa54fe4 100644 --- a/apps/dashboard/test/settings-api.test.ts +++ b/apps/dashboard/test/settings-api.test.ts @@ -14,7 +14,7 @@ describe("GET /api/settings", () => { beforeEach(() => clearRepositories()); test("returns typed dashboard settings (settings:read is held by readonly)", async () => { - const res = await GET(); + const res = await GET(new Request("http://localhost/api/settings")); const body = await res.json(); assert.equal(res.status, 200); @@ -27,7 +27,7 @@ describe("GET /api/settings", () => { test("reflects a persisted update from the mock repository", async () => { await getSettingsRepository().update({ workspaceName: "Persisted DAO" }); - const res = await GET(); + const res = await GET(new Request("http://localhost/api/settings")); const body = await res.json(); assert.equal(body.ok, true); assert.equal(body.data.workspaceName, "Persisted DAO"); diff --git a/apps/dashboard/test/unsupported-mode.test.ts b/apps/dashboard/test/unsupported-mode.test.ts index 2c991ff..8b45d59 100644 --- a/apps/dashboard/test/unsupported-mode.test.ts +++ b/apps/dashboard/test/unsupported-mode.test.ts @@ -25,13 +25,15 @@ test("GET /api/passes returns paginated mock data in mock mode", async () => { try { const { GET } = await import("../app/api/passes/route.js"); - const { mockPasses } = await import("../lib/mock-data.js"); + const { mockPasses, DEFAULT_GUILD_ID } = await import("../lib/mock-data.js"); const res: Response = await GET(new Request("http://localhost/api/passes")); const body = await res.json(); assert.strictEqual(body.ok, true); assert.ok(Array.isArray(body.data.items), "response should include items"); - assert.strictEqual(body.data.total, mockPasses.length); + // Listing is tenant-scoped: only the active guild's passes are returned. + const expected = mockPasses.filter((p) => p.guildId === DEFAULT_GUILD_ID).length; + assert.strictEqual(body.data.total, expected); } finally { restoreEnv("DASHBOARD_API_MODE", previousMode); } @@ -129,13 +131,15 @@ test("GET /api/members returns paginated mock data in mock mode", async () => { try { const { GET } = await import("../app/api/members/route.js"); - const { mockMembers } = await import("../lib/mock-data.js"); + const { mockMembers, DEFAULT_GUILD_ID } = await import("../lib/mock-data.js"); const res: Response = await GET(new Request("http://localhost/api/members")); const body = await res.json(); assert.strictEqual(body.ok, true); assert.ok(Array.isArray(body.data.items), "response should include items"); - assert.strictEqual(body.data.total, mockMembers.length); + // Listing is tenant-scoped: only the active guild's members are returned. + const expected = mockMembers.filter((m) => m.guildId === DEFAULT_GUILD_ID).length; + assert.strictEqual(body.data.total, expected); } finally { restoreEnv("DASHBOARD_API_MODE", previousMode); } diff --git a/apps/dashboard/test/verify.test.ts b/apps/dashboard/test/verify.test.ts index 6c64222..580b576 100644 --- a/apps/dashboard/test/verify.test.ts +++ b/apps/dashboard/test/verify.test.ts @@ -8,7 +8,7 @@ test("POST /api/verify returns mock verification in mock mode", async () => { try { const { POST } = await import("../app/api/verify/route.js"); - const payload = { discordUserId: "user_123", wallet: "0xabc" }; + const payload = { discordUserId: "user_123", wallet: "0x742d35cC6634c0532925a3B8879539d43374E290" }; const req = new Request("http://localhost/api/verify", { method: "POST", headers: { "content-type": "application/json" }, diff --git a/apps/dashboard/test/webhook mapper.test.ts b/apps/dashboard/test/webhook mapper.test.ts index 7e04743..c207f2c 100644 --- a/apps/dashboard/test/webhook mapper.test.ts +++ b/apps/dashboard/test/webhook mapper.test.ts @@ -1,12 +1,8 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { WEBHOOK_FIXTURES, FIXED_UNIX, makeWebhookPayload } from "./fixtures"; -import type { ActivityEvent } from "../lib/activity/types"; -import type { WebhookPayload } from "../lib/activity/types"; -import { WEBHOOK_FIXTURES, makeWebhookPayload } from "./fixtures.ts"; -import type { ActivityEvent } from "../lib/activity/types.ts"; +import { WEBHOOK_FIXTURES, makeWebhookPayload } from "./fixtures"; +import type { ActivityEvent, WebhookPayload } from "../lib/activity/types"; import { CURRENT_ACTIVITY_EVENT_SCHEMA_VERSION } from "@guildpass/integration-client"; -import type { WebhookPayload } from "../lib/activity/types.ts"; /** * webhook-mapper.test.ts diff --git a/apps/dashboard/test/webhook-validation.test.ts b/apps/dashboard/test/webhook-validation.test.ts index 7249bae..75569fb 100644 --- a/apps/dashboard/test/webhook-validation.test.ts +++ b/apps/dashboard/test/webhook-validation.test.ts @@ -18,7 +18,7 @@ test("validateWebhookPayload accepts a valid payload", () => { id: "evt_123", type: "membership.created", created: 1715000000, - data: { name: "Alice", wallet: "0xabc" }, + data: { name: "Alice", wallet: "0x742d35cC6634c0532925a3B8879539d43374E290" }, }; const result = validateWebhookPayload(JSON.stringify(validPayload)); assert.strictEqual(result.valid, true); diff --git a/apps/dashboard/test/webhook.test.ts b/apps/dashboard/test/webhook.test.ts index a45084c..270f0a3 100644 --- a/apps/dashboard/test/webhook.test.ts +++ b/apps/dashboard/test/webhook.test.ts @@ -144,7 +144,7 @@ function validPayload(overrides: Record = {}): string { id: "evt_val_1", type: "membership.created", created: Math.floor(Date.now() / 1000), - data: { wallet: "0xabc", name: "Bob" }, + data: { wallet: "0x742d35cC6634c0532925a3B8879539d43374E290", name: "Bob" }, ...overrides, }); } diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index f60c359..bc1e53f 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -5,7 +5,7 @@ * at startup. This keeps validation in one place and avoids drift between apps. */ -export { dashboardEnvSchema, type DashboardEnv } from "./schemas/dashboard.js"; +export { dashboardEnvSchema, dashboardEnvBaseSchema, type DashboardEnv } from "./schemas/dashboard.js"; export { accessApiEnvSchema, type AccessApiEnv } from "./schemas/access-api.js"; export { discordBotEnvSchema, type DiscordBotEnv } from "./schemas/discord-bot.js"; export { EnvValidationError, validateEnv } from "./validate.js"; \ No newline at end of file diff --git a/packages/env/src/schemas/dashboard.ts b/packages/env/src/schemas/dashboard.ts index 2fc0ce1..1a8d16b 100644 --- a/packages/env/src/schemas/dashboard.ts +++ b/packages/env/src/schemas/dashboard.ts @@ -18,9 +18,13 @@ import { z } from "zod"; * DATABASE_URL */ -const nonEmptyString = z.string().min(1, "Must not be empty"); - -export const dashboardEnvSchema = z +/** + * Field-level schema without the cross-field, mode-dependent requirements. + * Used for plain runtime reads (e.g. `getApiMode()`), where a live-mode + * deployment missing its secrets should fail at the point the secrets are + * actually needed (client construction / startup probe), not on every read. + */ +export const dashboardEnvBaseSchema = z .object({ // โ”€โ”€ Mode selectors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ DASHBOARD_API_MODE: z @@ -50,46 +54,52 @@ export const dashboardEnvSchema = z // โ”€โ”€โ”€ Database โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ DATABASE_URL: z.string().optional(), - }) - .superRefine((data, ctx) => { - // Live API mode requires core connection + webhook secret - if (data.DASHBOARD_API_MODE === "live") { - if (!data.GUILD_PASS_CORE_URL) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "GUILD_PASS_CORE_URL is required when DASHBOARD_API_MODE=live", - path: ["GUILD_PASS_CORE_URL"], - }); - } - if (!data.GUILD_PASS_CORE_API_KEY) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "GUILD_PASS_CORE_API_KEY is required when DASHBOARD_API_MODE=live", - path: ["GUILD_PASS_CORE_API_KEY"], - }); - } - if (!data.WEBHOOK_SECRET) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "WEBHOOK_SECRET is required when DASHBOARD_API_MODE=live", - path: ["WEBHOOK_SECRET"], - }); - } + }); + +/** + * Full dashboard env schema: field-level validation plus the cross-field, + * mode-dependent requirements documented above. Use this for startup + * validation and anywhere strict configuration checking is intended. + */ +export const dashboardEnvSchema = dashboardEnvBaseSchema.superRefine((data, ctx) => { + // Live API mode requires core connection + webhook secret + if (data.DASHBOARD_API_MODE === "live") { + if (!data.GUILD_PASS_CORE_URL) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "GUILD_PASS_CORE_URL is required when DASHBOARD_API_MODE=live", + path: ["GUILD_PASS_CORE_URL"], + }); + } + if (!data.GUILD_PASS_CORE_API_KEY) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "GUILD_PASS_CORE_API_KEY is required when DASHBOARD_API_MODE=live", + path: ["GUILD_PASS_CORE_API_KEY"], + }); } + if (!data.WEBHOOK_SECRET) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "WEBHOOK_SECRET is required when DASHBOARD_API_MODE=live", + path: ["WEBHOOK_SECRET"], + }); + } + } - // Durable storage mode requires DATABASE_URL - if (data.DASHBOARD_STORAGE_MODE === "durable") { - if (!data.DATABASE_URL) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "DATABASE_URL is required when DASHBOARD_STORAGE_MODE=durable", - path: ["DATABASE_URL"], - }); - } + // Durable storage mode requires DATABASE_URL + if (data.DASHBOARD_STORAGE_MODE === "durable") { + if (!data.DATABASE_URL) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "DATABASE_URL is required when DASHBOARD_STORAGE_MODE=durable", + path: ["DATABASE_URL"], + }); } - }); + } +}); export type DashboardEnv = z.infer; \ No newline at end of file diff --git a/packages/integration-client/src/types.ts b/packages/integration-client/src/types.ts index e26bc43..782ae26 100644 --- a/packages/integration-client/src/types.ts +++ b/packages/integration-client/src/types.ts @@ -52,6 +52,31 @@ export type ActivityEventEntity = { name?: string; }; +/** + * A single field-level change captured in an audit diff + * (before/after snapshot of one top-level field). + */ +export type ActivityChange = { + field: string; + before?: unknown; + after?: unknown; +}; + +/** + * Field names that must never appear in audit diffs. These are write-only + * secrets: capturing their before/after values in an activity event would + * leak them into the (readable) audit trail. + */ +export const SENSITIVE_AUDIT_FIELDS: ReadonlySet = new Set([ + "apiKey", + "clientSecret", + "password", + "privateKey", + "secret", + "token", + "webhookSecret", +]); + /** * The current schema version for ActivityEvent. * Bump this when adding/removing/renaming fields on ActivityEvent. @@ -72,6 +97,8 @@ export type ActivityEvent = { description: string; entity?: ActivityEventEntity; metadata?: Record; + /** Field-level audit diff for mutation events. */ + changes?: ActivityChange[]; /** * Explicit schema version for backward-compatible migration. * Legacy events stored without this field are treated as version 1. diff --git a/packages/webhook-utils/test/verify.test.js b/packages/webhook-utils/test/verify.test.js index 7cd3a69..05a66cc 100644 --- a/packages/webhook-utils/test/verify.test.js +++ b/packages/webhook-utils/test/verify.test.js @@ -1,4 +1,8 @@ -import { test, describe } from "node:test";`nimport assert from "node:assert";`nimport { readFileSync } from "node:fs"; +import { test, describe } from "node:test"; +import assert from "node:assert"; +import { Buffer } from "node:buffer"; +import { URL } from "node:url"; +import { readFileSync } from "node:fs"; import { verifySignature, generateSignature } from "../dist/index.js"; describe("verifySignature", () => { @@ -257,6 +261,10 @@ describe("verifySignature", () => { payload: PAYLOAD, }); + assert.strictEqual(result.valid, false); + assert.ok(result.error.includes("timestamp")); + }); + test("should reject empty v1 signature value", () => { const timestamp = Math.floor(Date.now() / 1000); const result = verifySignature({ @@ -303,9 +311,6 @@ describe("verifySignature", () => { assert.strictEqual(result.valid, false); assert.strictEqual(result.error, "Invalid signature"); }); - assert.strictEqual(result.valid, false); - assert.ok(result.error.includes("timestamp")); - }); }); describe("Missing or invalid inputs", () => {