diff --git a/apps/dashboard/app/activity/page.tsx b/apps/dashboard/app/activity/page.tsx index 977a9e0..d08950f 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": "πŸ‘€", @@ -68,6 +70,7 @@ const SOURCE_FILTERS: { label: string; value: ActivityEventSource | "" }[] = [ { label: "Dashboard", value: "dashboard" }, { label: "Webhook", value: "webhook" }, { label: "Core API", value: "core_api" }, + { label: "Reconciliation", value: "reconciliation" }, ]; const SEVERITY_FILTERS: { label: string; value: ActivityEventSeverity | "" }[] = [ @@ -94,7 +97,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 +121,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 +135,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 +196,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 +210,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 ( {formatRelativeTime(activity.timestamp)} - + {activity.source} 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/integrations/reconcile/route.ts b/apps/dashboard/app/api/integrations/reconcile/route.ts new file mode 100644 index 0000000..1fad56f --- /dev/null +++ b/apps/dashboard/app/api/integrations/reconcile/route.ts @@ -0,0 +1,117 @@ +/** + * POST /api/integrations/reconcile + * + * Admin-gated manual trigger for core-state reconciliation (issue #262): + * pulls an authoritative snapshot from GuildPass core, diffs it against + * local state, and reports (dry-run) or applies (apply) corrections. + * + * Permissions: Requires "settings:write" (admin/owner roles). + * + * Request body: + * { "mode": "dry-run" | "apply" } + * + * Response (200): + * The CoreSyncReport (see lib/reconciliation/core-sync-types.ts). When the + * deployment has no core configured (pure mock mode) or core does not + * implement the snapshot endpoint, the report has `supported: false` with + * a human-readable reason β€” this is not an error. + * + * Response (400): Invalid or missing mode. + * Response (401): No dashboard session. + * Response (403): Caller lacks settings:write. + */ + +import { NextResponse } from "next/server"; +import { IntegrationClient } from "@guildpass/integration-client"; +import { apiError, apiResponse, apiValidationError, handleApiError } from "@/lib/api-helpers"; +import { requireDashboardSession, UnauthorizedError } from "@/lib/auth/server-session"; +import { assertPermission, PermissionDeniedError } from "@/lib/permissions"; +import { getActiveGuildId } from "@/lib/guild-context"; +import { getEnv } from "@/lib/env"; +import { reconcileGuildWithCore } from "@/lib/reconciliation/core-sync"; +import type { CoreSyncMode, SnapshotClient } from "@/lib/reconciliation/core-sync-types"; +import type { ApiFieldError } from "@/lib/api-contracts"; + +export async function POST(request: Request): Promise { + // ── Auth guard ────────────────────────────────────────────────────────── + try { + const session = await requireDashboardSession(request); + assertPermission(session, getActiveGuildId(request), "settings:write"); + } catch (err) { + if (err instanceof PermissionDeniedError) { + return apiError(err.message, 403); + } + if (err instanceof UnauthorizedError) { + return apiError(err.message, 401); + } + throw err; + } + + return handleApiError(async () => { + // ── Parse & validate body ───────────────────────────────────────────── + let body: unknown; + try { + body = await request.json(); + } catch { + return apiValidationError("Invalid reconciliation request", [ + { field: "body", message: "Request body must be a JSON object" }, + ]); + } + + const errors = validateBody(body); + if (errors.length > 0) { + return apiValidationError("Invalid reconciliation request", errors); + } + + const { mode } = body as { mode: CoreSyncMode }; + const guildId = getActiveGuildId(request); + + const client = resolveSnapshotClient(); + if (!client) { + return apiResponse({ + guildId, + mode, + supported: false, + reason: + "No GuildPass core is configured for this deployment " + + "(GUILD_PASS_CORE_URL is unset). Reconciliation needs a core " + + "that serves GET /v1/guilds/:guildId/snapshot.", + changes: [], + totals: { added: 0, updated: 0, deactivated: 0, unchanged: 0 }, + applied: 0, + summary: "Reconciliation unavailable: no core configured.", + }); + } + + const report = await reconcileGuildWithCore({ guildId, mode, client }); + return apiResponse(report); + }); +} + +/** + * Pick the snapshot source: test injection first, then a real core client + * when a core URL is configured. Returns null in pure mock mode. + */ +function resolveSnapshotClient(): SnapshotClient | null { + const testClient = (globalThis as Record).__TEST_INTEGRATION_CLIENT; + if (testClient) return testClient as SnapshotClient; + + const env = getEnv(); + if (!env.GUILD_PASS_CORE_URL) return null; + + return new IntegrationClient({ + baseUrl: env.GUILD_PASS_CORE_URL, + apiKey: env.GUILD_PASS_CORE_API_KEY, + }); +} + +function validateBody(body: unknown): ApiFieldError[] { + if (!body || typeof body !== "object") { + return [{ field: "body", message: "Request body must be a JSON object" }]; + } + const { mode } = body as Record; + if (mode !== "dry-run" && mode !== "apply") { + return [{ field: "mode", message: 'mode must be either "dry-run" or "apply"' }]; + } + return []; +} 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/integrations/page.tsx b/apps/dashboard/app/integrations/page.tsx index a0b84d1..d67d42a 100644 --- a/apps/dashboard/app/integrations/page.tsx +++ b/apps/dashboard/app/integrations/page.tsx @@ -2,6 +2,7 @@ import DashboardLayout from "@/components/DashboardLayout"; import { getServerComponentSession } from "@/lib/auth/server-session"; import { getIntegrationsList, IntegrationStatus } from "@/lib/integrations"; import AccessDenied from "@/components/AccessDenied"; +import ReconcilePanel from "@/components/ReconcilePanel"; import { hasRole } from "@/lib/permissions"; import { formatRelativeTime } from "@/lib/format-relative-time"; @@ -57,6 +58,7 @@ export default async function IntegrationsPage() {
+ {integrations.map((integration) => (
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/ReconcilePanel.tsx b/apps/dashboard/components/ReconcilePanel.tsx new file mode 100644 index 0000000..acb97be --- /dev/null +++ b/apps/dashboard/components/ReconcilePanel.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState } from "react"; +import { readApiResult } from "@/lib/api-client"; +import type { CoreSyncReport } from "@/lib/reconciliation/core-sync-types"; + +type RunState = + | { status: "idle" } + | { status: "running"; mode: "dry-run" | "apply" } + | { status: "done"; report: CoreSyncReport } + | { status: "error"; message: string }; + +/** + * Manual trigger for core-state reconciliation (issue #262). Dry-run first + * shows what would change; apply writes corrections and tags them as + * `source: "reconciliation"` in the activity feed. + */ +export default function ReconcilePanel() { + const [state, setState] = useState({ status: "idle" }); + + async function run(mode: "dry-run" | "apply") { + setState({ status: "running", mode }); + try { + const response = await fetch("/api/integrations/reconcile", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode }), + }); + const report = await readApiResult(response); + setState({ status: "done", report }); + } catch (err) { + setState({ status: "error", message: err instanceof Error ? err.message : "Reconciliation failed" }); + } + } + + const running = state.status === "running"; + + return ( +
+
+
+

Core reconciliation

+
+

+ Recover from webhook delivery gaps by diffing local state against GuildPass core's + authoritative snapshot. Corrections appear in the activity feed tagged as + "reconciliation". +

+ +
+ + +
+ + {state.status === "error" && ( +
+ {state.message} +
+ )} + + {state.status === "done" && ( +
+

{state.report.summary}

+ {!state.report.supported && state.report.reason && ( +

{state.report.reason}

+ )} + {state.report.changes.length > 0 && ( +
    + {state.report.changes.map((change, i) => ( +
  • {change.summary}
  • + ))} +
+ )} +
+ )} +
+
+ ); +} 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/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..980a3fa 100644 --- a/apps/dashboard/lib/activity/query.ts +++ b/apps/dashboard/lib/activity/query.ts @@ -31,6 +31,7 @@ const EVENT_SOURCES = new Set([ "dashboard", "webhook", "core_api", + "reconciliation", ]); const EVENT_SEVERITIES = new Set([ @@ -46,9 +47,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 +61,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 +155,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 +194,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/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..60c3994 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, @@ -97,7 +99,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,7 +113,9 @@ 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); @@ -118,7 +124,7 @@ export function useActivityFeed({ const replaceEvents = useCallback((incoming: ActivityEvent[]) => { 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)); 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/reconciliation/core-sync-types.ts b/apps/dashboard/lib/reconciliation/core-sync-types.ts new file mode 100644 index 0000000..a2f598c --- /dev/null +++ b/apps/dashboard/lib/reconciliation/core-sync-types.ts @@ -0,0 +1,74 @@ +/** + * lib/reconciliation/core-sync-types.ts + * + * Types for core-state reconciliation (issue #262). Kept separate from + * types.ts, which belongs to the older counter-drift reconciler, so each + * reconciler's contract is self-contained. + */ + +import type { ActivityChange, ActivityEvent, CoreGuildInfo, CorePassSnapshot, GuildSnapshot, Membership } from "@guildpass/integration-client"; +import type { Member, Pass } from "../mock-data"; +import type { IGuildRepository, IMemberRepository, IPassRepository } from "../repositories/types"; +import type { IActivityStorage } from "../activity/storage"; + +export type CoreSyncMode = "dry-run" | "apply"; + +/** + * Minimal client contract the job needs β€” satisfied by IntegrationClient, + * trivially stubbed in tests. + */ +export interface SnapshotClient { + getGuildSnapshot(guildId: string): Promise; +} + +/** A single drifted entity detected by the diff. */ +export interface CoreSyncChange { + entity: "member" | "pass" | "guild"; + action: "add" | "update" | "deactivate"; + /** + * Identifier used in the deterministic activity event id: local record id + * when one exists, otherwise the core-side key (normalized wallet / pass id). + */ + id: string; + /** Human-readable one-liner for the report and activity description. */ + summary: string; + /** Field-level drift (before = local, after = core). */ + changes: ActivityChange[]; + /** Context used by the apply step. Not part of the API report payload. */ + localMember?: Member; + snapshotMember?: Membership; + localPass?: Pass; + snapshotPass?: CorePassSnapshot; + snapshotGuild?: CoreGuildInfo; +} + +export interface CoreSyncReport { + guildId: string; + mode: CoreSyncMode; + /** False when core does not implement the snapshot endpoint (404). */ + supported: boolean; + /** Why reconciliation could not run (only when supported === false). */ + reason?: string; + /** Snapshot generation time reported by core. */ + snapshotAt?: string; + changes: CoreSyncChange[]; + totals: { + added: number; + updated: number; + deactivated: number; + unchanged: number; + }; + /** Activity events newly recorded (apply mode only; 0 for dry-run). */ + applied: number; + summary: string; +} + +/** Injectable seams for tests. Defaults wire the app's real singletons. */ +export interface CoreSyncDeps { + memberRepo?: IMemberRepository; + passRepo?: IPassRepository; + guildRepo?: IGuildRepository; + activitySink?: Pick; + publish?: (event: ActivityEvent) => void; + now?: () => string; +} diff --git a/apps/dashboard/lib/reconciliation/core-sync.ts b/apps/dashboard/lib/reconciliation/core-sync.ts new file mode 100644 index 0000000..9433247 --- /dev/null +++ b/apps/dashboard/lib/reconciliation/core-sync.ts @@ -0,0 +1,421 @@ +/** + * lib/reconciliation/core-sync.ts + * + * Core-state reconciliation ("backfill") for issue #262: detect and correct + * drift between the dashboard's local state and GuildPass core's + * authoritative state after webhook delivery gaps (downtime, deploys, + * idempotency-store incidents). + * + * Contract with core (see docs/core-reconciliation.md): + * - The dashboard asks core for a point-in-time snapshot per guild via + * `IntegrationClient.getGuildSnapshot()` (GET /v1/guilds/:id/snapshot). + * - `members` in the snapshot is the COMPLETE membership list: a local + * member with a wallet absent from the snapshot is deactivated. + * - `passes` in the snapshot is the complete core-managed pass list. + * Local-only passes (dashboard drafts core has never seen) are left + * untouched, because core cannot distinguish them from deleted ones. + * - Core may not implement the endpoint yet (separate repo) β€” a 404 comes + * back as `null` and the job reports `supported: false` instead of failing. + * + * Matching rules: + * - Members match on wallet (case-insensitive). Snapshot members without a + * wallet are skipped: there is no safe join key. + * - Passes match on id first, then on exact name (local pass ids are + * repo-generated, so a pass created locally from a previous + * reconciliation run carries a different id than core's). + * + * Idempotency: + * - A run with no drift performs zero writes and zero activity entries. + * - Every applied change is recorded through the same idempotent write path + * webhooks use (`activityStorage.recordActivityEvent`) with a + * deterministic event id, so a retried run never double-records. + */ + +import type { ActivityChange, ActivityEvent, GuildSnapshot, Membership } from "@guildpass/integration-client"; +import { CURRENT_ACTIVITY_EVENT_SCHEMA_VERSION } from "@guildpass/integration-client"; +import type { Guild, Member, Pass } from "../mock-data"; +import type { IGuildRepository, IMemberRepository, IPassRepository } from "../repositories/types"; +import { getGuildRepository, getMemberRepository, getPassRepository } from "../repositories/factory"; +import { activityStorage, type IActivityStorage } from "../activity/storage"; +import { publishActivityEvent } from "../activity/stream"; +import type { + CoreSyncChange, + CoreSyncDeps, + CoreSyncMode, + CoreSyncReport, + SnapshotClient, +} from "./core-sync-types"; + +export type { CoreSyncChange, CoreSyncDeps, CoreSyncMode, CoreSyncReport, SnapshotClient }; + +/** Map core membership status to the dashboard's member vocabulary. */ +function mapMemberStatus(status: Membership["status"]): Member["status"] { + return status === "unknown" ? "pending" : status; +} + +function sameRoles(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((role, i) => role === sortedB[i]); +} + +function normalizeWallet(wallet: string): string { + return wallet.trim().toLowerCase(); +} + +/** + * Run a reconciliation pass for one guild against core's authoritative state. + * + * @param options.guildId - Tenant scope. Every read/write stays inside it. + * @param options.mode - "dry-run" reports drift without writing; "apply" + * writes corrections and records reconciliation-tagged activity. + * @param options.client - Anything with `getGuildSnapshot` β€” the real + * IntegrationClient or a stub in tests / mock mode. + */ +export async function reconcileGuildWithCore(options: { + guildId: string; + mode: CoreSyncMode; + client: SnapshotClient; + deps?: CoreSyncDeps; +}): Promise { + const { guildId, mode, client } = options; + const memberRepo = options.deps?.memberRepo ?? getMemberRepository(); + const passRepo = options.deps?.passRepo ?? getPassRepository(); + const guildRepo = options.deps?.guildRepo ?? getGuildRepository(); + const sink = options.deps?.activitySink ?? activityStorage; + const publish = options.deps?.publish ?? publishActivityEvent; + const now = options.deps?.now ?? (() => new Date().toISOString()); + + const snapshot = await client.getGuildSnapshot(guildId); + + if (!snapshot) { + return { + guildId, + mode, + supported: false, + reason: + "GuildPass core does not expose a guild snapshot endpoint " + + "(GET /v1/guilds/:guildId/snapshot returned 404). Reconciliation " + + "requires core-side support; see docs/core-reconciliation.md.", + changes: [], + totals: { added: 0, updated: 0, deactivated: 0, unchanged: 0 }, + applied: 0, + summary: "Reconciliation unavailable: core snapshot endpoint not supported.", + }; + } + + const [localGuild, localMembers, localPasses] = await Promise.all([ + guildRepo.getById(guildId), + memberRepo.getAll(guildId), + passRepo.getAll(guildId), + ]); + + const changes = diffSnapshot(guildId, snapshot, localGuild, localMembers, localPasses); + + const totals = { + added: changes.filter((c) => c.action === "add").length, + updated: changes.filter((c) => c.action === "update").length, + deactivated: changes.filter((c) => c.action === "deactivate").length, + unchanged: + snapshot.members.length + + snapshot.passes.length - + changes.filter((c) => c.entity !== "guild").length, + }; + + let applied = 0; + if (mode === "apply") { + for (const change of changes) { + await applyChange(guildId, change, { memberRepo, passRepo, guildRepo }); + const recorded = await recordChange(guildId, change, snapshot, mode, sink, publish, now); + if (recorded) applied += 1; + } + } + + return { + guildId, + mode, + supported: true, + snapshotAt: snapshot.generatedAt, + changes, + totals, + applied, + summary: buildSummary(mode, guildId, totals, applied, changes.length), + }; +} + +// ── Diff ────────────────────────────────────────────────────────────────────── + +function diffSnapshot( + guildId: string, + snapshot: GuildSnapshot, + localGuild: Guild | null, + localMembers: Member[], + localPasses: Pass[], +): CoreSyncChange[] { + const changes: CoreSyncChange[] = []; + + // ── Members ───────────────────────────────────────────────────────────── + const localByWallet = new Map(); + for (const m of localMembers) { + if (m.wallet) localByWallet.set(normalizeWallet(m.wallet), m); + } + + const snapshotWallets = new Set(); + for (const sm of snapshot.members) { + if (!sm.wallet) continue; // no safe join key β€” documented contract + const key = normalizeWallet(sm.wallet); + snapshotWallets.add(key); + const local = localByWallet.get(key); + + if (!local) { + changes.push({ + entity: "member", + action: "add", + id: key, + summary: `Add member ${sm.userId} (${sm.wallet}) β€” present in core, missing locally`, + changes: [ + { field: "status", before: undefined, after: mapMemberStatus(sm.status) }, + { field: "roles", before: undefined, after: sm.roles ?? [] }, + ], + snapshotMember: sm, + }); + continue; + } + + const fieldChanges: ActivityChange[] = []; + const coreStatus = mapMemberStatus(sm.status); + if (local.status !== coreStatus) { + fieldChanges.push({ field: "status", before: local.status, after: coreStatus }); + } + if (!sameRoles(local.roles ?? [], sm.roles ?? [])) { + fieldChanges.push({ field: "roles", before: local.roles, after: sm.roles ?? [] }); + } + if (fieldChanges.length > 0) { + changes.push({ + entity: "member", + action: "update", + id: local.id, + summary: `Update member ${local.name} β€” ${fieldChanges.map((c) => c.field).join(", ")} drifted from core`, + changes: fieldChanges, + localMember: local, + snapshotMember: sm, + }); + } + } + + // Local wallet-members absent from the snapshot are no longer members. + for (const m of localMembers) { + if (!m.wallet) continue; + if (snapshotWallets.has(normalizeWallet(m.wallet))) continue; + if (m.status === "inactive") continue; // already reflected + changes.push({ + entity: "member", + action: "deactivate", + id: m.id, + summary: `Deactivate member ${m.name} β€” absent from core snapshot`, + changes: [{ field: "status", before: m.status, after: "inactive" }], + localMember: m, + }); + } + + // ── Passes ────────────────────────────────────────────────────────────── + const localPassById = new Map(localPasses.map((p) => [p.id, p])); + const localPassByName = new Map(localPasses.map((p) => [p.name, p])); + + for (const sp of snapshot.passes) { + const local = localPassById.get(sp.id) ?? localPassByName.get(sp.name); + + if (!local) { + changes.push({ + entity: "pass", + action: "add", + id: sp.id, + summary: `Add pass "${sp.name}" β€” present in core, missing locally`, + changes: [{ field: "status", before: undefined, after: sp.status }], + snapshotPass: sp, + }); + continue; + } + + const fieldChanges: ActivityChange[] = []; + if (local.name !== sp.name) fieldChanges.push({ field: "name", before: local.name, after: sp.name }); + if (local.status !== sp.status) fieldChanges.push({ field: "status", before: local.status, after: sp.status }); + if (local.price !== sp.price) fieldChanges.push({ field: "price", before: local.price, after: sp.price }); + if ((local.maxSupply ?? null) !== (sp.maxSupply ?? null)) { + fieldChanges.push({ field: "maxSupply", before: local.maxSupply ?? null, after: sp.maxSupply ?? null }); + } + if (sp.currentSupply !== undefined && local.currentSupply !== sp.currentSupply) { + fieldChanges.push({ field: "currentSupply", before: local.currentSupply, after: sp.currentSupply }); + } + if (fieldChanges.length > 0) { + changes.push({ + entity: "pass", + action: "update", + id: local.id, + summary: `Update pass "${local.name}" β€” ${fieldChanges.map((c) => c.field).join(", ")} drifted from core`, + changes: fieldChanges, + localPass: local, + snapshotPass: sp, + }); + } + } + + // ── Guild metadata ────────────────────────────────────────────────────── + if (localGuild && snapshot.guild) { + const fieldChanges: ActivityChange[] = []; + if (snapshot.guild.name !== undefined && localGuild.name !== snapshot.guild.name) { + fieldChanges.push({ field: "name", before: localGuild.name, after: snapshot.guild.name }); + } + if (snapshot.guild.description !== undefined && localGuild.description !== snapshot.guild.description) { + fieldChanges.push({ field: "description", before: localGuild.description, after: snapshot.guild.description }); + } + if (fieldChanges.length > 0) { + changes.push({ + entity: "guild", + action: "update", + id: guildId, + summary: `Update guild β€” ${fieldChanges.map((c) => c.field).join(", ")} drifted from core`, + changes: fieldChanges, + snapshotGuild: snapshot.guild, + }); + } + } + + return changes; +} + +// ── Apply ───────────────────────────────────────────────────────────────────── + +async function applyChange( + guildId: string, + change: CoreSyncChange, + repos: { + memberRepo: IMemberRepository; + passRepo: IPassRepository; + guildRepo: IGuildRepository; + }, +): Promise { + if (change.entity === "member") { + if (change.action === "add" && change.snapshotMember) { + const sm = change.snapshotMember; + await repos.memberRepo.create(guildId, { + wallet: sm.wallet ?? "", + name: sm.userId, + status: mapMemberStatus(sm.status), + roles: sm.roles ?? [], + joinedAt: sm.updatedAt, + lastActive: sm.updatedAt, + }); + } else if (change.action === "update" && change.localMember && change.snapshotMember) { + const sm = change.snapshotMember; + await repos.memberRepo.update( + guildId, + change.localMember.id, + { status: mapMemberStatus(sm.status), roles: sm.roles ?? [] }, + change.localMember.version, + ); + } else if (change.action === "deactivate" && change.localMember) { + await repos.memberRepo.update( + guildId, + change.localMember.id, + { status: "inactive" }, + change.localMember.version, + ); + } + return; + } + + if (change.entity === "pass" && change.snapshotPass) { + const sp = change.snapshotPass; + if (change.action === "add") { + await repos.passRepo.create(guildId, { + name: sp.name, + description: sp.description ?? "", + status: sp.status, + price: sp.price, + maxSupply: sp.maxSupply ?? null, + currentSupply: sp.currentSupply ?? 0, + }); + } else if (change.action === "update" && change.localPass) { + const patch: Record = {}; + for (const c of change.changes) patch[c.field] = c.after; + await repos.passRepo.update(guildId, change.localPass.id, patch); + } + return; + } + + if (change.entity === "guild" && change.snapshotGuild) { + const patch: Record = {}; + for (const c of change.changes) patch[c.field] = c.after; + await repos.guildRepo.update(guildId, patch); + } +} + +// ── Activity ────────────────────────────────────────────────────────────────── + +const ACTIVITY_TYPE: Record>> = { + member: { add: "member.joined", update: "member.roles_changed", deactivate: "member.left" }, + pass: { add: "pass.created", update: "pass.updated" }, + guild: { update: "guild.updated" }, +}; + +/** + * Record one reconciliation-tagged activity event for an applied change. + * Uses the webhook idempotent write path with a deterministic event id, so + * retries of the same run never produce duplicates. Returns true when the + * event was newly recorded. + */ +async function recordChange( + guildId: string, + change: CoreSyncChange, + snapshot: GuildSnapshot, + mode: CoreSyncMode, + sink: Pick, + publish: (event: ActivityEvent) => void, + now: () => string, +): Promise { + const type = ACTIVITY_TYPE[change.entity][change.action]; + if (!type) return false; + + const event: ActivityEvent = { + id: `reconcile:${guildId}:${change.entity}:${change.action}:${change.id}`, + type, + source: "reconciliation", + severity: change.action === "deactivate" ? "warning" : "info", + actor: { name: "Reconciliation Job" }, + timestamp: now(), + description: `[RECONCILE] ${change.summary}`, + entity: { type: change.entity, id: change.id }, + metadata: { + reconciliation: true, + mode, + snapshotAt: snapshot.generatedAt, + }, + changes: change.changes, + schemaVersion: CURRENT_ACTIVITY_EVENT_SCHEMA_VERSION, + }; + + const result = await sink.recordActivityEvent(event); + if (result === "duplicate") return false; + publish(event); + return true; +} + +// ── Summary ─────────────────────────────────────────────────────────────────── + +function buildSummary( + mode: CoreSyncMode, + guildId: string, + totals: CoreSyncReport["totals"], + applied: number, + totalChanges: number, +): string { + const drift = + totalChanges === 0 + ? "no drift" + : `${totalChanges} change(s): ${totals.added} add, ${totals.updated} update, ${totals.deactivated} deactivate`; + return mode === "apply" + ? `Reconciliation (apply) for guild ${guildId}: ${drift}; ${applied} activity event(s) recorded.` + : `Reconciliation (dry-run) for guild ${guildId}: ${drift}.`; +} diff --git a/apps/dashboard/lib/reconciliation/index.ts b/apps/dashboard/lib/reconciliation/index.ts index 43570ee..8494c4e 100644 --- a/apps/dashboard/lib/reconciliation/index.ts +++ b/apps/dashboard/lib/reconciliation/index.ts @@ -16,7 +16,6 @@ import type { ReconciliationReport, GuildDiscrepancy, ReconcileOptions, - DriftedField, } from "./types"; import type { Guild } from "../mock-data"; import type { ActivityChange } from "@guildpass/integration-client"; diff --git a/apps/dashboard/lib/reconciliation/types.ts b/apps/dashboard/lib/reconciliation/types.ts index fd39db6..7c4c979 100644 --- a/apps/dashboard/lib/reconciliation/types.ts +++ b/apps/dashboard/lib/reconciliation/types.ts @@ -8,8 +8,6 @@ * tables (Member / Pass). */ -import type { ActivityChange } from "@guildpass/integration-client"; - /** The field that was found to be inconsistent. */ export type DriftedField = "memberCount" | "passCount"; 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/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/core-reconciliation.test.ts b/apps/dashboard/test/core-reconciliation.test.ts new file mode 100644 index 0000000..bd3dd15 --- /dev/null +++ b/apps/dashboard/test/core-reconciliation.test.ts @@ -0,0 +1,314 @@ +/** + * test/core-reconciliation.test.ts + * + * Tests for core-state reconciliation (issue #262): the dashboard diffs its + * local state against an authoritative snapshot from GuildPass core and + * optionally applies corrections. + * + * Covers the acceptance criteria: + * - no-drift runs are pure no-ops (no writes, no activity), + * - partial drift is reported by dry-run without side effects and fixed by + * apply, with every correction tagged source: "reconciliation", + * - full resync rebuilds an empty guild from a snapshot, + * - re-running apply after a successful pass produces no further changes + * or duplicate activity entries, + * - cores without a snapshot endpoint degrade to supported: false. + */ + +import { describe, test, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import type { ActivityEvent, GuildSnapshot } from "@guildpass/integration-client"; +import { reconcileGuildWithCore } from "../lib/reconciliation/core-sync"; +import type { SnapshotClient } from "../lib/reconciliation/core-sync-types"; +import { POST as reconcileRoutePOST } from "../app/api/integrations/reconcile/route"; +import { + clearRepositories, + getGuildRepository, + getMemberRepository, + getPassRepository, +} from "../lib/repositories/factory"; + +process.env.DASHBOARD_STORAGE_MODE = "mock"; +process.env.DASHBOARD_API_MODE = "mock"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function stubClient(snapshot: GuildSnapshot | null): SnapshotClient { + return { getGuildSnapshot: async () => snapshot }; +} + +/** In-memory activity sink with the same id-dedupe contract as the real one. */ +function fakeSink() { + const events: ActivityEvent[] = []; + const ids = new Set(); + return { + events, + sink: { + recordActivityEvent: async (event: ActivityEvent) => { + if (ids.has(event.id)) return "duplicate" as const; + ids.add(event.id); + events.push(event); + return "recorded" as const; + }, + }, + }; +} + +const noopPublish = () => {}; + +async function freshGuild(name = "Recon Guild") { + const guildRepo = getGuildRepository(); + return guildRepo.create({ name, description: "test guild", memberCount: 0, passCount: 0 }); +} + +function snapshotFor(guildId: string, over: Partial = {}): GuildSnapshot { + return { + guildId, + generatedAt: "2026-07-24T00:00:00.000Z", + members: [], + passes: [], + ...over, + }; +} + +beforeEach(() => { + clearRepositories(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("reconcileGuildWithCore", () => { + test("reports supported: false when core has no snapshot endpoint", async () => { + const guild = await freshGuild(); + const { sink, events } = fakeSink(); + + const report = await reconcileGuildWithCore({ + guildId: guild.id, + mode: "apply", + client: stubClient(null), + deps: { activitySink: sink, publish: noopPublish }, + }); + + assert.equal(report.supported, false); + assert.match(report.reason ?? "", /snapshot/); + assert.equal(report.changes.length, 0); + assert.equal(events.length, 0); + }); + + test("no-drift run is a pure no-op, even in apply mode", async () => { + const guild = await freshGuild(); + const memberRepo = getMemberRepository(); + const passRepo = getPassRepository(); + + await memberRepo.create(guild.id, { + wallet: "0xAAA", + name: "alice", + status: "active", + roles: ["admin"], + }); + await passRepo.create(guild.id, { + name: "Gold", + description: "gold pass", + status: "active", + price: 10, + maxSupply: 100, + currentSupply: 3, + }); + + const snapshot = snapshotFor(guild.id, { + members: [ + { userId: "alice", wallet: "0xaaa", status: "active", roles: ["admin"], updatedAt: "2026-07-24T00:00:00.000Z" }, + ], + // Local pass ids are repo-generated, so core matches by name. + passes: [{ id: "core-pass-1", name: "Gold", status: "active", price: 10, maxSupply: 100, currentSupply: 3 }], + }); + + const { sink, events } = fakeSink(); + const report = await reconcileGuildWithCore({ + guildId: guild.id, + mode: "apply", + client: stubClient(snapshot), + deps: { activitySink: sink, publish: noopPublish }, + }); + + assert.equal(report.supported, true); + assert.equal(report.changes.length, 0); + assert.equal(report.applied, 0); + assert.equal(events.length, 0); + assert.match(report.summary, /no drift/); + }); + + test("dry-run reports partial drift without writing anything", async () => { + const guild = await freshGuild(); + const memberRepo = getMemberRepository(); + + await memberRepo.create(guild.id, { + wallet: "0xbbb", + name: "bob", + status: "active", + roles: [], + }); + + const snapshot = snapshotFor(guild.id, { + members: [ + // Bob's status drifted in core. + { userId: "bob", wallet: "0xBBB", status: "inactive", roles: [], updatedAt: "2026-07-24T00:00:00.000Z" }, + // Carol exists in core but not locally. + { userId: "carol", wallet: "0xccc", status: "active", roles: ["contributor"], updatedAt: "2026-07-24T00:00:00.000Z" }, + ], + passes: [{ id: "core-pass-9", name: "Silver", status: "active", currentSupply: 1 }], + }); + + const { sink, events } = fakeSink(); + const report = await reconcileGuildWithCore({ + guildId: guild.id, + mode: "dry-run", + client: stubClient(snapshot), + deps: { activitySink: sink, publish: noopPublish }, + }); + + assert.equal(report.changes.length, 3); + assert.equal(report.totals.added, 2); // carol + Silver pass + assert.equal(report.totals.updated, 1); // bob's status + assert.equal(report.applied, 0); + assert.equal(events.length, 0); + + // Nothing was written. + const bob = await memberRepo.getByWallet(guild.id, "0xbbb"); + assert.equal(bob?.status, "active"); + assert.equal(await memberRepo.getByWallet(guild.id, "0xccc"), null); + }); + + test("apply corrects drift, tags activity as reconciliation, and is idempotent", async () => { + const guild = await freshGuild(); + const memberRepo = getMemberRepository(); + const passRepo = getPassRepository(); + + await memberRepo.create(guild.id, { wallet: "0xbbb", name: "bob", status: "active", roles: [] }); + // Dan left while the dashboard was down: absent from the snapshot. + await memberRepo.create(guild.id, { wallet: "0xddd", name: "dan", status: "active", roles: [] }); + + const snapshot = snapshotFor(guild.id, { + members: [ + { userId: "bob", wallet: "0xbbb", status: "active", roles: ["contributor"], updatedAt: "2026-07-24T00:00:00.000Z" }, + { userId: "carol", wallet: "0xccc", status: "active", roles: [], updatedAt: "2026-07-24T00:00:00.000Z" }, + ], + passes: [{ id: "core-pass-9", name: "Silver", status: "active", currentSupply: 1 }], + }); + + const { sink, events } = fakeSink(); + const deps = { activitySink: sink, publish: noopPublish, now: () => "2026-07-24T01:00:00.000Z" }; + + const report = await reconcileGuildWithCore({ + guildId: guild.id, + mode: "apply", + client: stubClient(snapshot), + deps, + }); + + // bob roles update + dan deactivate + carol add + pass add + assert.equal(report.changes.length, 4); + assert.equal(report.applied, 4); + assert.equal(events.length, 4); + + for (const event of events) { + assert.equal(event.source, "reconciliation"); + assert.equal(event.actor.name, "Reconciliation Job"); + assert.equal(event.metadata?.reconciliation, true); + assert.match(event.id, /^reconcile:/); + } + + // State actually changed. + assert.deepEqual((await memberRepo.getByWallet(guild.id, "0xbbb"))?.roles, ["contributor"]); + assert.equal((await memberRepo.getByWallet(guild.id, "0xddd"))?.status, "inactive"); + assert.notEqual(await memberRepo.getByWallet(guild.id, "0xccc"), null); + const passes = await passRepo.getAll(guild.id); + assert.equal(passes.length, 1); + assert.equal(passes[0].name, "Silver"); + + // Second run: no drift, no writes, no duplicate activity. + const second = await reconcileGuildWithCore({ + guildId: guild.id, + mode: "apply", + client: stubClient(snapshot), + deps, + }); + assert.equal(second.changes.length, 0); + assert.equal(second.applied, 0); + assert.equal(events.length, 4); + }); + + test("full resync rebuilds an empty guild from a snapshot", async () => { + const guild = await freshGuild(); + const memberRepo = getMemberRepository(); + + const snapshot = snapshotFor(guild.id, { + guild: { name: "Renamed Guild", description: "from core" }, + members: [ + { userId: "u1", wallet: "0x1", status: "active", roles: ["admin"], updatedAt: "2026-07-24T00:00:00.000Z" }, + { userId: "u2", wallet: "0x2", status: "unknown", roles: [], updatedAt: "2026-07-24T00:00:00.000Z" }, + ], + passes: [ + { id: "p1", name: "Founder", status: "active", price: 100, maxSupply: null, currentSupply: 12 }, + { id: "p2", name: "Retired", status: "inactive" }, + ], + }); + + const { sink, events } = fakeSink(); + const report = await reconcileGuildWithCore({ + guildId: guild.id, + mode: "apply", + client: stubClient(snapshot), + deps: { activitySink: sink, publish: noopPublish }, + }); + + assert.equal(report.totals.added, 4); + assert.equal(report.totals.updated, 1); // guild metadata + assert.equal(events.length, 5); + + const members = await memberRepo.getAll(guild.id); + assert.equal(members.length, 2); + // core "unknown" maps to dashboard "pending" (same mapping as live lookups). + assert.equal(members.find((m) => m.wallet === "0x2")?.status, "pending"); + + const guildRepo = getGuildRepository(); + assert.equal((await guildRepo.getById(guild.id))?.name, "Renamed Guild"); + }); + + test("snapshot members without a wallet are skipped, not deleted locally", async () => { + const guild = await freshGuild(); + const memberRepo = getMemberRepository(); + await memberRepo.create(guild.id, { wallet: "0xeee", name: "erin", status: "active", roles: [] }); + + const snapshot = snapshotFor(guild.id, { + members: [ + { userId: "walletless", status: "active", roles: [], updatedAt: "2026-07-24T00:00:00.000Z" }, + { userId: "erin", wallet: "0xeee", status: "active", roles: [], updatedAt: "2026-07-24T00:00:00.000Z" }, + ], + }); + + const { sink } = fakeSink(); + const report = await reconcileGuildWithCore({ + guildId: guild.id, + mode: "apply", + client: stubClient(snapshot), + deps: { activitySink: sink, publish: noopPublish }, + }); + + assert.equal(report.changes.length, 0); + assert.equal((await memberRepo.getByWallet(guild.id, "0xeee"))?.status, "active"); + }); +}); + +describe("POST /api/integrations/reconcile", () => { + test("rejects callers without settings:write (default API session is readonly)", async () => { + const res = await reconcileRoutePOST( + new Request("http://localhost/api/integrations/reconcile", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mode: "dry-run" }), + }), + ); + assert.equal(res.status, 403); + }); +}); 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/docs/core-reconciliation.md b/docs/core-reconciliation.md new file mode 100644 index 0000000..4086090 --- /dev/null +++ b/docs/core-reconciliation.md @@ -0,0 +1,107 @@ +# Core-State Reconciliation (Webhook Backfill) + +Issue: [#262](https://github.com/Adamantine-guild/guildpass-app/issues/262) + +Webhooks are best-effort. When the dashboard is down, mid-deploy, or its +idempotency store is unavailable, core events can be permanently missed and +local state silently drifts from reality. This document describes the +recovery path: a reconciliation pass that diffs local state against +GuildPass core's authoritative state and applies corrections. + +## The core contract (needs core-side support) + +The dashboard asks core for a **point-in-time snapshot** per guild: + +``` +GET /v1/guilds/:guildId/snapshot +``` + +```json +{ + "guildId": "1", + "generatedAt": "2026-07-24T00:00:00.000Z", + "guild": { "name": "...", "description": "..." }, + "members": [{ "userId": "...", "wallet": "0x...", "status": "active", "roles": ["admin"], "updatedAt": "..." }], + "passes": [{ "id": "...", "name": "...", "status": "active", "price": 10, "maxSupply": 100, "currentSupply": 3 }] +} +``` + +Semantics: + +- `members` is the **complete** membership list at `generatedAt`. A local + wallet-member absent from the snapshot is treated as no longer a member + and deactivated locally. +- `passes` is the complete list of **core-managed** passes. +- A snapshot is a full-state pull, not an event log. It cannot tell us + *what happened* while we were down (no history of joins/leaves), only + *what is true now*. If core later exposes an event-log range endpoint, + the same job can be extended to replay it; the snapshot diff remains the + fallback. +- `guildpass-core` is a separate repository (see SECURITY.md scope notes) + and does not implement this endpoint yet. Core responds 404, the client + surfaces `null`, and the job reports `supported: false` β€” the dashboard + side is fully implemented and tested against stubbed snapshots, ready to + light up the moment core ships the endpoint. + +## Matching rules + +- **Members** match on wallet, case-insensitive. Snapshot members without a + wallet are skipped β€” there is no safe join key, and guessing could + deactivate the wrong local record. +- **Passes** match on `id` first, then on exact `name`. Local pass ids are + repository-generated, so a pass created locally by a previous + reconciliation run has a different id than core's; the name fallback + keeps repeat runs idempotent. +- **Local-only passes are never touched.** Core cannot distinguish a + dashboard-created draft from a pass deleted in core, so reconciliation + conservatively leaves local passes that are absent from the snapshot + alone. (Members do not get this treatment because the snapshot's member + list is explicitly complete.) +- Local member `name` is never overwritten β€” core only knows `userId`, and + names may be human-curated. + +## Running a pass + +Manual trigger (admin/owner, `settings:write`): + +``` +POST /api/integrations/reconcile +{ "mode": "dry-run" } # report only, zero writes +{ "mode": "apply" } # writes corrections + activity entries +``` + +Or from the UI: **Integrations β†’ Core reconciliation** has dry-run and +apply buttons and renders the resulting report. + +Every run returns a `CoreSyncReport`: the full change list (entity, action, +field-level before/after), totals, and a summary line. Dry-run and apply +produce identical diffs; apply additionally writes and counts recorded +activity events. + +Interval-based triggering is intentionally not built yet: the manual +trigger plus dry-run covers the operational need (post-incident recovery), +and an interval is a one-line cron once core's endpoint exists. + +## Activity tagging and idempotency + +- Every applied change produces exactly one activity event with + `source: "reconciliation"`, actor `Reconciliation Job`, and + `metadata.reconciliation: true`. The activity feed has a + "Reconciliation" source filter and a distinct badge, so admins can tell + corrected data apart from live webhooks. +- Events go through the same idempotent write path as webhooks + (`activityStorage.recordActivityEvent`) with deterministic ids + (`reconcile::::`), so a retried run never + double-records. +- A run with no drift performs **zero** writes and **zero** activity + entries. Running apply twice in a row is a no-op the second time. +- Repository mutations themselves still emit their normal activity entries + (unchanged existing behavior); the reconciliation event is the audit + marker that ties a correction to the pass that caused it. + +## Tests + +`apps/dashboard/test/core-reconciliation.test.ts` covers, against a stubbed +core client: no-drift no-op, partial drift (dry-run purity + apply + +re-run idempotency), full resync of an empty guild, wallet-less snapshot +members, and cores without snapshot support. 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/client.ts b/packages/integration-client/src/client.ts index e93295c..e5594e0 100644 --- a/packages/integration-client/src/client.ts +++ b/packages/integration-client/src/client.ts @@ -1,4 +1,4 @@ -import type { IntegrationClientOptions, Membership, VerificationResult } from "./types.js"; // IC: 71 +import type { GuildSnapshot, IntegrationClientOptions, Membership, VerificationResult } from "./types.js"; // IC: 71 import { HttpClient } from "./http/httpClient.js"; import { ContractClient } from "./contracts/contractClient.js"; import type { HttpRequestOptions } from "./http/http.types.js"; @@ -104,6 +104,30 @@ export class IntegrationClient { return data as Membership; // IC: 90 } + /** + * Fetch a point-in-time authoritative snapshot of a guild's state from core. + * + * GETs `/v1/guilds/:guildId/snapshot`. Used by the dashboard's + * reconciliation job to recover state drift after webhook delivery gaps. + * + * @param guildId - The guild to snapshot. + * @param options - Per-request {@link HttpRequestOptions} (timeout/retry/headers). + * @returns The {@link GuildSnapshot}, or `null` when core does not expose a + * snapshot endpoint or has no such guild (HTTP 404). Throws + * `Error("core:")` on any other non-OK response. + */ + async getGuildSnapshot(guildId: string, options: HttpRequestOptions = {}): Promise { + const url = `${this.baseUrl}/v1/guilds/${encodeURIComponent(guildId)}/snapshot`; + const res = await this.httpClient.request(url, { + ...options, + headers: { ...headers(this.apiKey), ...options.headers } + }); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`core:${res.status}`); + const data = await res.json(); + return data as GuildSnapshot; + } + /** * Verify that a Discord user controls a given wallet and return the result. * diff --git a/packages/integration-client/src/types.ts b/packages/integration-client/src/types.ts index e26bc43..ea0671a 100644 --- a/packages/integration-client/src/types.ts +++ b/packages/integration-client/src/types.ts @@ -14,6 +14,52 @@ export type IntegrationClientOptions = { apiKey?: string; // IC: 107 transport?: TransportConfig; }; // IC: 108 + +/** + * A pass as reported by GuildPass core in a guild snapshot. + * + * Core is the authority for pass lifecycle state; the dashboard reconciles + * its local copy against these records. `status` uses the dashboard's + * vocabulary ("draft" never appears β€” drafts are dashboard-local). + */ +export type CorePassSnapshot = { + id: string; + name: string; + status: "active" | "inactive"; + description?: string; + price?: number; + maxSupply?: number | null; + currentSupply?: number; +}; + +/** Guild-level metadata as reported by core in a snapshot. */ +export type CoreGuildInfo = { + name?: string; + description?: string; +}; + +/** + * Point-in-time authoritative state for one guild, served by GuildPass core + * at `GET /v1/guilds/:guildId/snapshot`. + * + * Reconciliation contract (see docs/core-reconciliation.md): + * - `members` is the COMPLETE active membership list for the guild at + * `generatedAt`. A member absent from the list is treated as no longer + * a member (dashboard deactivates its local copy). + * - `passes` is the complete list of core-managed passes. Passes that only + * exist locally (e.g. dashboard-created drafts) are NOT touched by + * reconciliation, since core has no knowledge of them. + * - Core implementations that do not support snapshots respond 404, which + * the client surfaces as `null` so callers can degrade gracefully. + */ +export type GuildSnapshot = { + guildId: string; + /** ISO timestamp at which core generated the snapshot. */ + generatedAt: string; + guild?: CoreGuildInfo; + members: Membership[]; + passes: CorePassSnapshot[]; +}; export type VerificationResult = { userId: string; // IC: 109 wallet: string; // IC: 110 @@ -42,7 +88,7 @@ export type ActivityEventType = | "webhook.received" | "activity.permission_denied"; -export type ActivityEventSource = "dashboard" | "webhook" | "core_api"; +export type ActivityEventSource = "dashboard" | "webhook" | "core_api" | "reconciliation"; export type ActivityEventSeverity = "info" | "warning" | "error" | "critical"; @@ -52,6 +98,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 +143,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/integration-client/test/snapshot.test.js b/packages/integration-client/test/snapshot.test.js new file mode 100644 index 0000000..289ecd9 --- /dev/null +++ b/packages/integration-client/test/snapshot.test.js @@ -0,0 +1,65 @@ +import { test, describe } from "node:test"; +import assert from "node:assert"; +import { IntegrationClient } from "../dist/client.js"; + +function jsonResponse(status, body) { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function clientWithFetch(fetchImpl) { + return new IntegrationClient({ + baseUrl: "https://core.example", + apiKey: "test-key", + transport: { fetch: fetchImpl, retry: { maxAttempts: 1 } }, + }); +} + +describe("IntegrationClient.getGuildSnapshot", () => { + test("returns the parsed snapshot on 200", async () => { + const snapshot = { + guildId: "guild-1", + generatedAt: "2026-07-24T00:00:00.000Z", + guild: { name: "Core Guild" }, + members: [ + { userId: "u1", wallet: "0xabc", status: "active", roles: ["admin"], updatedAt: "2026-07-24T00:00:00.000Z" }, + ], + passes: [{ id: "p1", name: "Gold", status: "active", currentSupply: 3 }], + }; + let seenUrl; + let seenAuth; + const client = clientWithFetch(async (url, init) => { + seenUrl = url; + seenAuth = init?.headers?.authorization; + return jsonResponse(200, snapshot); + }); + + const result = await client.getGuildSnapshot("guild-1"); + assert.deepStrictEqual(result, snapshot); + assert.strictEqual(seenUrl, "https://core.example/v1/guilds/guild-1/snapshot"); + assert.strictEqual(seenAuth, "Bearer test-key"); + }); + + test("URL-encodes the guild id", async () => { + let seenUrl; + const client = clientWithFetch(async (url) => { + seenUrl = url; + return jsonResponse(200, { guildId: "a/b", generatedAt: "x", members: [], passes: [] }); + }); + await client.getGuildSnapshot("a/b"); + assert.strictEqual(seenUrl, "https://core.example/v1/guilds/a%2Fb/snapshot"); + }); + + test("returns null on 404 (core does not support snapshots)", async () => { + const client = clientWithFetch(async () => jsonResponse(404, { error: "not found" })); + const result = await client.getGuildSnapshot("guild-1"); + assert.strictEqual(result, null); + }); + + test("throws core: on other non-OK responses", async () => { + const client = clientWithFetch(async () => jsonResponse(500, { error: "boom" })); + await assert.rejects(() => client.getGuildSnapshot("guild-1"), /core:500/); + }); +}); 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", () => {