diff --git a/package.json b/package.json index 0813817..eb1ff1e 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,11 @@ "phase2:domain-smoke": "node scripts/phase2-domain-smoke.mjs", "phase3:storage-smoke": "node scripts/phase3-storage-smoke.mjs", "phase2:smoke": "node scripts/phase2-auth-smoke.mjs", - "phase4:ui-boundary": "node scripts/phase4-ui-boundary.mjs" + "phase4:ui-boundary": "node scripts/phase4-ui-boundary.mjs", + "phase5:backend-boundary": "node scripts/phase5-backend-boundary.mjs", + "phase5:smoke": "node scripts/phase5-freelancer-smoke.mjs", + "phase6:portal-boundary": "node scripts/phase6-portal-boundary.mjs", + "phase6:smoke": "node scripts/phase6-portal-smoke.mjs" }, "dependencies": { "@ai-sdk/google": "^3.0.80", diff --git a/scripts/phase1-auth-smoke.mjs b/scripts/phase1-auth-smoke.mjs index c83c50b..e0f28b7 100644 --- a/scripts/phase1-auth-smoke.mjs +++ b/scripts/phase1-auth-smoke.mjs @@ -96,6 +96,19 @@ try { db.prepare( "insert into projects (id, owner_user_id, client_id, name, status) values (?, ?, ?, ?, ?)", ).run("project-alpha", ownerUserId, "client-alpha", "Alpha Project", "active"); + db.prepare("update projects set revision_quota = ? where id = ?").run(2, "project-alpha"); + db.prepare( + "insert into projects (id, owner_user_id, client_id, name, status) values (?, ?, ?, ?, ?)", + ).run("project-foreign", ownerUserId, "client-expired", "Foreign Project", "active"); + db.prepare( + "insert into tasks (id, owner_user_id, client_id, project_id, title, status, priority, is_public_to_client) values (?, ?, ?, ?, ?, ?, ?, ?)", + ).run("task-portal-public", ownerUserId, "client-alpha", "project-alpha", "Portal Public Task", "todo", "medium", 1); + db.prepare( + "insert into tasks (id, owner_user_id, client_id, project_id, title, status, priority, is_public_to_client) values (?, ?, ?, ?, ?, ?, ?, ?)", + ).run("task-portal-private", ownerUserId, "client-alpha", "project-alpha", "Portal Private Task", "todo", "medium", 0); + db.prepare( + "insert into project_planning_sections (id, owner_user_id, project_id, category, title, content, metadata, sort_order) values (?, ?, ?, ?, ?, ?, ?, ?)", + ).run("planning-portal", ownerUserId, "project-alpha", "overview", "Portal Plan", "Visible planning content", "{}", 0); const rejectedRegistration = await authPost("/api/auth/sign-up/email", { name: "Public Attacker", @@ -116,6 +129,27 @@ try { } assert.ok(ownerCookie, "Owner session cookie must be issued"); + for (const pathname of [ + "/", + "/clients", + "/clients/client-alpha", + "/projects", + "/projects/project-alpha", + "/tasks", + "/calendar", + "/finance", + "/journal", + "/analytics", + "/settings", + ]) { + const page = await fetch(`${baseUrl}${pathname}`, { + headers: { cookie: ownerCookie }, + redirect: "manual", + }); + assert.equal(page.status, 200, `Freelancer SSR route failed: ${pathname}`); + assert.doesNotMatch(await page.text(), /lib\/supabase|supabase\.co/i, `SSR output leaked Supabase: ${pathname}`); + } + const anonymousUpload = await uploadFile("avatar", { fileName: "anonymous.png" }); assert.equal(anonymousUpload.response.status, 401, "Anonymous file upload must fail"); assert.deepEqual( @@ -241,6 +275,17 @@ try { "Client-Password-123", "Client password must be hashed", ); + db.prepare( + "insert into project_revisions (id, owner_user_id, project_id, client_id, requested_by_user_id, description, status) values (?, ?, ?, ?, ?, ?, ?)", + ).run( + "revision-portal", + ownerUserId, + "project-alpha", + "client-alpha", + clientAuthUserId, + "Portal Revision Request", + "pending", + ); const replayed = await acceptInvite(rawClientToken); assert.equal(replayed.response.status, 409, "Accepted invitation must be single-use"); @@ -252,6 +297,39 @@ try { assert.equal(clientSignIn.response.ok, true, JSON.stringify(clientSignIn.payload)); const clientCookie = cookieHeader(clientSignIn.response); + for (const pathname of [ + "/portal", + "/portal/projects", + "/portal/projects/project-alpha", + "/portal/tasks", + "/portal/revisions", + ]) { + const page = await fetch(`${baseUrl}${pathname}`, { + headers: { cookie: clientCookie }, + redirect: "manual", + }); + assert.equal(page.status, 200, `Portal SSR route failed: ${pathname}`); + const html = await page.text(); + assert.doesNotMatch(html, /lib\/supabase|supabase\.co/i, `Portal SSR output leaked Supabase: ${pathname}`); + if (pathname === "/portal") assert.match(html, /Neta Smoke Studio/, "Portal must render local branding"); + if (pathname === "/portal/tasks" || pathname === "/portal/projects/project-alpha") { + assert.match(html, /Portal Public Task/, `Public task missing from ${pathname}`); + assert.doesNotMatch(html, /Portal Private Task/, `Private task leaked from ${pathname}`); + } + if (pathname === "/portal/projects/project-alpha") { + assert.match(html, /Visible planning content/, "Portal planning section must be visible"); + } + if (pathname === "/portal/revisions") { + assert.match(html, /Portal Revision Request/, "Portal revision history must be visible"); + } + } + + const foreignProject = await fetch(`${baseUrl}/portal/projects/project-foreign`, { + headers: { cookie: clientCookie }, + redirect: "manual", + }); + assert.equal(foreignProject.status, 404, "Client must not open another client's project"); + const clientPortalAsset = await fetch(`${baseUrl}/api/files/${portalAssetFileId}`, { headers: { cookie: clientCookie }, }); diff --git a/scripts/phase2-domain-smoke.ts b/scripts/phase2-domain-smoke.ts index 9b62f59..aaddda6 100644 --- a/scripts/phase2-domain-smoke.ts +++ b/scripts/phase2-domain-smoke.ts @@ -20,6 +20,7 @@ const ownerOne: DomainActor = { authUserId: "owner-1", role: "freelancer", clien const ownerTwo: DomainActor = { authUserId: "owner-2", role: "freelancer", clientId: null, disabled: false }; const clientOne: DomainActor = { authUserId: "client-user-1", role: "client", clientId: "client-1", disabled: false }; const clientTwo: DomainActor = { authUserId: "client-user-2", role: "client", clientId: "client-2", disabled: false }; +const spoofedClient: DomainActor = { authUserId: "client-user-2", role: "client", clientId: "client-1", disabled: false }; try { for (const actor of [ownerOne, ownerTwo, clientOne, clientTwo]) { @@ -58,6 +59,22 @@ try { }); service.createProject(ownerOne, { id: "project-2", name: "Second Client", clientId: "client-2" }); service.createProject(ownerTwo, { id: "project-other", name: "Other Project", clientId: "client-other" }); + assert.deepEqual(service.listProjects(clientOne).map((project) => project.id), ["project-1"]); + assert.deepEqual(service.listProjects(spoofedClient), []); + assertDomainError(() => service.getProject(spoofedClient, "project-1"), "NOT_FOUND"); + service.addClientActivity(ownerOne, { + id: "activity-1", + clientId: "client-1", + type: "meeting", + title: "Kickoff", + activityDate: new Date("2026-07-15T09:00:00.000Z"), + }); + assert.deepEqual(service.listClientActivities(ownerOne, "client-1").map((item) => item.id), ["activity-1"]); + assert.deepEqual(service.listAllClientActivities(ownerOne).map((item) => item.id), ["activity-1"]); + assertDomainError(() => service.listClientActivities(ownerTwo, "client-1"), "NOT_FOUND"); + service.updateClient(ownerOne, "client-1", { pipelineStage: "contacted" }); + service.updateClient(ownerOne, "client-1", { status: "paused" }); + assert.equal(service.getClient(ownerOne, "client-1").pipelineStage, "contacted"); service.createTask(ownerOne, { id: "task-public", @@ -78,11 +95,32 @@ try { assert.equal(service.getProject(ownerOne, "project-1").progress, 50, "Auto progress must aggregate active tasks"); assert.deepEqual(service.listTasks(clientOne).map((task) => task.id), ["task-public"]); assertDomainError(() => service.getProject(clientOne, "project-2"), "NOT_FOUND"); + assertDomainError(() => service.listTasks(clientOne, "project-2"), "NOT_FOUND"); + assertDomainError(() => service.listTasks(spoofedClient), "NOT_FOUND"); assertDomainError(() => service.listFinanceTransactions(clientOne), "FORBIDDEN"); assertDomainError(() => service.updateTask(ownerTwo, "task-public", { status: "done" }), "NOT_FOUND"); service.updateTask(ownerOne, "task-private", { status: "done" }); + service.updateTask(ownerOne, "task-public", { description: "Patch without default resets" }); + assert.equal(service.listTasks(clientOne)[0]?.isPublicToClient, true); assert.equal(service.getProject(ownerOne, "project-1").progress, 100); + service.updateProject(ownerOne, "project-1", { progress: 10 }); + assert.equal(service.getProject(ownerOne, "project-1").progress, 100, "Auto progress must ignore manual overwrite"); + + service.createCalendarEvent(ownerOne, { + id: "calendar-1", + clientId: "client-1", + projectId: "project-1", + taskId: "task-public", + title: "Review", + type: "meeting", + startsAt: new Date("2026-07-16T10:00:00.000Z"), + }); + assert.equal(service.listCalendarEvents(ownerOne)[0]?.title, "Review"); + service.updateCalendarEvent(ownerOne, "calendar-1", { title: "Final review" }); + assert.equal(service.listCalendarEvents(ownerOne)[0]?.title, "Final review"); + assert.equal(service.listCalendarEvents(ownerOne)[0]?.type, "meeting"); + assertDomainError(() => service.deleteCalendarEvent(ownerTwo, "calendar-1"), "NOT_FOUND"); service.addPlanningSection(ownerOne, { id: "planning-1", @@ -90,11 +128,26 @@ try { category: "overview", title: "Overview", content: "Visible project context", + sortOrder: 3, }); + service.updatePlanningSection(ownerOne, "planning-1", { title: "Updated overview" }); + assert.equal(service.listPlanningSections(ownerOne, "project-1")[0]?.sortOrder, 3); assert.equal(service.listPlanningSections(clientOne, "project-1").length, 1); assertDomainError(() => service.listPlanningSections(clientTwo, "project-1"), "NOT_FOUND"); + assert.deepEqual(service.getRevisionAllowance(clientOne, "project-1"), { + quota: 1, + used: 0, + remaining: 1, + canRequest: true, + }); assert.equal(service.requestRevision(clientOne, { id: "revision-1", projectId: "project-1", description: "Please revise" }).status, "pending"); + assert.deepEqual(service.getRevisionAllowance(clientOne, "project-1"), { + quota: 1, + used: 1, + remaining: 0, + canRequest: false, + }); assertDomainError( () => service.requestRevision(clientOne, { id: "revision-2", projectId: "project-1", description: "Quota overflow" }), "CONFLICT", @@ -103,8 +156,22 @@ try { () => service.requestRevision(clientTwo, { id: "revision-3", projectId: "project-1", description: "Wrong client" }), "NOT_FOUND", ); - assert.equal(service.updateRevisionStatus(ownerOne, "revision-1", "completed").status, "completed"); + assertDomainError( + () => service.requestRevision(spoofedClient, { id: "revision-spoof", projectId: "project-1", description: "Spoofed link" }), + "NOT_FOUND", + ); + assertDomainError( + () => service.requestRevision(clientTwo, { id: "revision-inactive", projectId: "project-2", description: "Inactive project" }), + "INVARIANT_VIOLATION", + ); + assertDomainError( + () => service.updateRevisionStatus(ownerOne, "revision-1", "completed", "project-2"), + "NOT_FOUND", + ); + assert.equal(service.updateRevisionStatus(ownerOne, "revision-1", "completed", "project-1").status, "completed"); assert.deepEqual(service.listRevisions(clientOne, "project-1").map((revision) => revision.id), ["revision-1"]); + assert.deepEqual(service.listPortalRevisions(clientOne).map((revision) => revision.id), ["revision-1"]); + assertDomainError(() => service.listRevisions(clientTwo, "project-1"), "NOT_FOUND"); service.createFinanceTransaction(ownerOne, { id: "income-1", type: "income", amountMinor: 150_00, currency: "try", transactionDate: "2026-07-16", paymentStatus: "paid", @@ -124,11 +191,33 @@ try { plannedMinor: 75_00, netMinor: 110_00, }); + service.createFinanceTransaction(ownerOne, { + id: "project-income", + clientId: "client-1", + projectId: "project-1", + type: "income", + amountMinor: 10_00, + currency: "TRY", + transactionDate: "2026-07-16", + paymentStatus: "paid", + }); + service.updateFinanceTransaction(ownerOne, "project-income", { description: "Patched" }); + assert.equal( + service.listFinanceTransactions(ownerOne).find((item) => item.id === "project-income")?.paymentStatus, + "paid", + ); service.saveJournalEntry(ownerOne, { id: "journal-1", entryDate: "2026-07-16", moodScore: 3, note: "First" }); service.saveJournalEntry(ownerOne, { entryDate: "2026-07-16", moodScore: 5, note: "Updated" }); assert.equal(service.listJournalEntries(ownerOne).length, 1, "Journal date must upsert per owner"); assert.equal(service.listJournalEntries(ownerOne)[0]?.moodScore, 5); + service.updateJournalEntry(ownerOne, "journal-1", { + entryDate: "2026-07-16", + moodScore: 4, + energyScore: 3, + note: "Edited", + }); + assert.equal(service.listJournalEntries(ownerOne)[0]?.note, "Edited"); assertDomainError( () => service.createTask(ownerTwo, { title: "Foreign journal", sourceJournalEntryId: "journal-1" }), "NOT_FOUND", @@ -143,6 +232,23 @@ try { service.createInvoice(ownerOne, { id: "invoice-1", clientId: "client-1", projectId: "project-1", invoiceNumber: "INV-001", amountMinor: 100_00, issueDate: "2026-07-16" }); service.createSubscription(ownerOne, { id: "subscription-1", name: "Hosting", amountMinor: 500_00 }); + const analyticsRange = { + startDate: "2026-01-01", + endDate: "2026-12-31", + startAt: new Date("2026-01-01T00:00:00.000Z"), + endAt: new Date("2026-12-31T23:59:59.999Z"), + }; + const dashboard = service.getFreelancerDashboard(ownerOne, analyticsRange); + assert.equal(dashboard.metrics.netProfit, 120); + assert.equal(dashboard.metrics.avgMood, "4.0"); + assert.deepEqual( + new Set(dashboard.projects.map((project) => project.id)), + new Set(["project-1", "project-2"]), + ); + const rangedAnalytics = service.getFreelancerAnalytics(ownerOne, analyticsRange); + assert.deepEqual(rangedAnalytics.projectIncomeData, [{ name: "Client Project", value: 10 }]); + assert.equal(rangedAnalytics.completedTasks, 2); + assert.throws( () => sqlite.prepare("insert into finance_transactions (id, owner_user_id, type, amount_minor, currency, transaction_date, payment_status) values (?, ?, ?, ?, ?, ?, ?)").run("invalid-finance", ownerOne.authUserId, "income", -1, "TRY", "2026-07-16", "paid"), /CHECK constraint failed/, diff --git a/scripts/phase3-storage-smoke.ts b/scripts/phase3-storage-smoke.ts index 38d2104..048d9af 100644 --- a/scripts/phase3-storage-smoke.ts +++ b/scripts/phase3-storage-smoke.ts @@ -30,6 +30,7 @@ const ownerOne: DomainActor = { authUserId: "owner-1", role: "freelancer", clien const ownerTwo: DomainActor = { authUserId: "owner-2", role: "freelancer", clientId: null, disabled: false }; const clientOne: DomainActor = { authUserId: "client-user-1", role: "client", clientId: "client-1", disabled: false }; const clientTwo: DomainActor = { authUserId: "client-user-2", role: "client", clientId: "client-2", disabled: false }; +const spoofedClient: DomainActor = { authUserId: "client-user-2", role: "client", clientId: "client-1", disabled: false }; const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]); try { @@ -104,6 +105,7 @@ try { assert.equal(fileService.read(clientOne, portalAsset.id).metadata.id, portalAsset.id); assertDomainError(() => fileService.read(clientOne, privateAsset.id), "NOT_FOUND"); assertDomainError(() => fileService.read(clientTwo, portalAsset.id), "NOT_FOUND"); + assertDomainError(() => fileService.read(spoofedClient, portalAsset.id), "FORBIDDEN"); assertDomainError( () => fileService.upload(clientOne, { ...imageInput("project_asset", "attack.png"), projectId: "project-1" }), "FORBIDDEN", diff --git a/scripts/phase5-backend-boundary.mjs b/scripts/phase5-backend-boundary.mjs new file mode 100644 index 0000000..4e9f9dc --- /dev/null +++ b/scripts/phase5-backend-boundary.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const roots = [ + "app/(dashboard)/settings", + "app/(dashboard)/clients", + "app/(dashboard)/projects", + "app/(dashboard)/tasks", + "app/(dashboard)/calendar", + "app/(dashboard)/finance", + "app/(dashboard)/journal", +]; +const files = [ + ...roots.flatMap(walk), + "app/(dashboard)/page.tsx", + "app/(dashboard)/analytics/page.tsx", +].filter((file) => /\.(ts|tsx)$/.test(file)); + +const violations = []; +for (const file of files) { + const content = fs.readFileSync(path.join(process.cwd(), file), "utf8"); + if (/[@/]lib\/supabase|createServiceRoleClient|\bsupabase\b/i.test(content)) { + violations.push(`${file}: Supabase runtime reference`); + } + if (/NEXT_PUBLIC_SUPABASE|SUPABASE_SERVICE_ROLE/.test(content)) { + violations.push(`${file}: Supabase environment dependency`); + } +} + +assert.deepEqual( + violations, + [], + `Phase 5 freelancer backend boundary violations:\n${violations.join("\n")}`, +); + +for (const required of [ + "server/web/freelancer.ts", + "server/services/analytics-range.ts", + "server/settings/ai.ts", + "server/db/migrations/0005_brief_black_bolt.sql", +]) { + assert.ok(fs.existsSync(path.join(process.cwd(), required)), `Missing Phase 5 backend artifact: ${required}`); +} + +console.log(`Phase 5 backend boundary passed (${files.length} files scanned).`); + +function walk(relativePath) { + const absolutePath = path.join(process.cwd(), relativePath); + if (!fs.existsSync(absolutePath)) return []; + const stat = fs.statSync(absolutePath); + if (stat.isFile()) return [relativePath]; + return fs.readdirSync(absolutePath, { withFileTypes: true }).flatMap((entry) => { + const child = path.join(relativePath, entry.name); + return entry.isDirectory() ? walk(child) : [child]; + }); +} diff --git a/scripts/phase5-freelancer-smoke.mjs b/scripts/phase5-freelancer-smoke.mjs new file mode 100644 index 0000000..628f087 --- /dev/null +++ b/scripts/phase5-freelancer-smoke.mjs @@ -0,0 +1,11 @@ +import { execFileSync } from "node:child_process"; + +for (const [command, args] of [ + [process.execPath, ["scripts/phase5-backend-boundary.mjs"]], + [process.execPath, ["scripts/phase2-domain-smoke.mjs"]], + [process.execPath, ["scripts/phase1-auth-smoke.mjs"]], +]) { + execFileSync(command, args, { cwd: process.cwd(), stdio: "inherit" }); +} + +console.log("Phase 5 freelancer backend smoke passed."); diff --git a/scripts/phase6-portal-boundary.mjs b/scripts/phase6-portal-boundary.mjs new file mode 100644 index 0000000..fc78642 --- /dev/null +++ b/scripts/phase6-portal-boundary.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const files = walk("app/portal").filter((file) => /\.(ts|tsx)$/.test(file)); +const violations = []; + +for (const file of files) { + const content = fs.readFileSync(path.join(process.cwd(), file), "utf8"); + if (/[@/]lib\/supabase|createServiceRoleClient|\bsupabase\b/i.test(content)) { + violations.push(`${file}: Supabase runtime reference`); + } + if (/NEXT_PUBLIC_SUPABASE|SUPABASE_SERVICE_ROLE/.test(content)) { + violations.push(`${file}: Supabase environment dependency`); + } +} + +assert.deepEqual( + violations, + [], + `Phase 6 portal boundary violations:\n${violations.join("\n")}`, +); + +for (const file of [ + "app/portal/layout.tsx", + "app/portal/page.tsx", + "app/portal/projects/page.tsx", + "app/portal/projects/[id]/page.tsx", + "app/portal/projects/[id]/actions.ts", + "app/portal/tasks/page.tsx", + "app/portal/revisions/page.tsx", +]) { + const content = fs.readFileSync(path.join(process.cwd(), file), "utf8"); + assert.match(content, /requirePortalBackend/, `${file} must derive its actor from the portal session adapter`); +} + +assert.ok(fs.existsSync(path.join(process.cwd(), "server/web/portal.ts")), "Missing portal session adapter"); +console.log(`Phase 6 portal boundary passed (${files.length} files scanned).`); + +function walk(relativePath) { + const absolutePath = path.join(process.cwd(), relativePath); + return fs.readdirSync(absolutePath, { withFileTypes: true }).flatMap((entry) => { + const child = path.join(relativePath, entry.name); + return entry.isDirectory() ? walk(child) : [child]; + }); +} diff --git a/scripts/phase6-portal-smoke.mjs b/scripts/phase6-portal-smoke.mjs new file mode 100644 index 0000000..3c89f8b --- /dev/null +++ b/scripts/phase6-portal-smoke.mjs @@ -0,0 +1,12 @@ +import { execFileSync } from "node:child_process"; + +for (const [command, args] of [ + [process.execPath, ["scripts/phase6-portal-boundary.mjs"]], + [process.execPath, ["scripts/phase2-domain-smoke.mjs"]], + [process.execPath, ["scripts/phase3-storage-smoke.mjs"]], + [process.execPath, ["scripts/phase1-auth-smoke.mjs"]], +]) { + execFileSync(command, args, { cwd: process.cwd(), stdio: "inherit" }); +} + +console.log("Phase 6 portal backend smoke passed.");