diff --git a/.gitignore b/.gitignore index 9c025063..f5cb95b3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ dist/ # ApplyFlow extension — local Chrome / production zip (not CWS publish artifacts) .tmp/ +apps/applyflow/.tmp-validation/ apps/applyflow-extension/dist-production/ apps/applyflow-extension/applyflow-extension-*-production.zip next-env.d.ts diff --git a/apps/applyflow/.gitignore b/apps/applyflow/.gitignore index ae73ffaf..6b0b5568 100644 --- a/apps/applyflow/.gitignore +++ b/apps/applyflow/.gitignore @@ -1,6 +1,7 @@ .vercel .env* .tmp/ +.tmp-validation/ test-results/ playwright-report/ blob-report/ diff --git a/apps/applyflow/src/app/api/applyflow/v2/applications/[id]/route.test.ts b/apps/applyflow/src/app/api/applyflow/v2/applications/[id]/route.test.ts index b5e35076..286c9085 100644 --- a/apps/applyflow/src/app/api/applyflow/v2/applications/[id]/route.test.ts +++ b/apps/applyflow/src/app/api/applyflow/v2/applications/[id]/route.test.ts @@ -12,11 +12,9 @@ vi.mock("@/lib/persistence-v2/require-applyflow-account", () => { this.code = code; } } - class ApplyFlowPersistenceDisabledError extends Error {} return { requireApplyFlowAccount: vi.fn(), ApplyFlowAuthError, - ApplyFlowPersistenceDisabledError, }; }); diff --git a/apps/applyflow/src/app/api/applyflow/v2/applications/route.test.ts b/apps/applyflow/src/app/api/applyflow/v2/applications/route.test.ts index aa33c745..d022b26b 100644 --- a/apps/applyflow/src/app/api/applyflow/v2/applications/route.test.ts +++ b/apps/applyflow/src/app/api/applyflow/v2/applications/route.test.ts @@ -12,11 +12,9 @@ vi.mock("@/lib/persistence-v2/require-applyflow-account", () => { this.code = code; } } - class ApplyFlowPersistenceDisabledError extends Error {} return { requireApplyFlowAccount: vi.fn(), ApplyFlowAuthError, - ApplyFlowPersistenceDisabledError, }; }); diff --git a/apps/applyflow/src/app/api/applyflow/v2/extension/session/route.test.ts b/apps/applyflow/src/app/api/applyflow/v2/extension/session/route.test.ts new file mode 100644 index 00000000..55f38a26 --- /dev/null +++ b/apps/applyflow/src/app/api/applyflow/v2/extension/session/route.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mint } = vi.hoisted(() => ({ mint: vi.fn() })); + +vi.mock("@/lib/http/same-origin-guard", () => ({ + enforceSameOriginMutatingRequest: vi.fn(() => null), +})); + +vi.mock("@/lib/persistence-v2/auth/resolve-account-from-request", () => ({ + resolveApplyFlowAccountFromRequest: vi.fn(), +})); + +vi.mock("@/lib/persistence-v2/require-applyflow-account", () => { + class ApplyFlowAuthError extends Error {} + return { + requireApplyFlowAccount: vi.fn(async () => ({ + id: "11111111-1111-4111-8111-111111111111", + })), + ApplyFlowAuthError, + }; +}); + +vi.mock("@/lib/persistence-v2/http-access", () => ({ + applyFlowAuthErrorResponse: vi.fn(() => null), + applyFlowV2HttpAccessErrorResponse: vi.fn(() => null), + assertApplyFlowExtensionGrantMint: vi.fn(), +})); + +vi.mock("@/lib/persistence-v2/resolve-persistence-access", () => ({ + resolveApplyFlowPersistenceAccess: vi.fn(() => ({ mode: "v2_active" })), +})); + +vi.mock("@/lib/persistence-v2/personal/extension-grants", () => ({ + applyFlowExtensionGrants: { mint }, +})); + +import { EXTENSION_GRANT_LEGACY_SCOPES } from "@/lib/persistence-v2/personal/extension-grant-scopes"; +import { POST } from "./route"; + +describe("POST /api/applyflow/v2/extension/session", () => { + beforeEach(() => { + mint.mockReset(); + }); + + it("rejects a scope outside the requestable catalog and does not mint", async () => { + const response = await POST( + new Request("http://127.0.0.1/api/applyflow/v2/extension/session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ scopes: ["contacts:admin", "profile:read"] }), + }), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: "scopes_required" }); + expect(mint).not.toHaveBeenCalled(); + }); + + it("mints only the confirmed legacy scope list", async () => { + mint.mockResolvedValue({ + accountId: "11111111-1111-4111-8111-111111111111", + token: "opaque-token", + expiresAt: "2026-10-28T00:00:00.000Z", + scopes: [...EXTENSION_GRANT_LEGACY_SCOPES], + }); + + const response = await POST( + new Request("http://127.0.0.1/api/applyflow/v2/extension/session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ scopes: [...EXTENSION_GRANT_LEGACY_SCOPES] }), + }), + ); + + expect(response.status).toBe(200); + expect(mint).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + [...EXTENSION_GRANT_LEGACY_SCOPES], + ); + const body = (await response.json()) as { scopes: string[]; token: string }; + expect(body.scopes).toEqual([...EXTENSION_GRANT_LEGACY_SCOPES]); + expect(body.scopes).not.toContain("contacts:read"); + expect(body.token).toBe("opaque-token"); + }); +}); diff --git a/apps/applyflow/src/app/api/applyflow/v2/extension/session/route.ts b/apps/applyflow/src/app/api/applyflow/v2/extension/session/route.ts index b5e5eb32..81dd9c3a 100644 --- a/apps/applyflow/src/app/api/applyflow/v2/extension/session/route.ts +++ b/apps/applyflow/src/app/api/applyflow/v2/extension/session/route.ts @@ -48,11 +48,6 @@ export async function POST(request: Request) { { status: 400 }, ); } - for (const scope of scopes) { - if (!EXTENSION_GRANT_REQUESTABLE_SCOPES.includes(scope)) { - return NextResponse.json({ error: "scopes_invalid" }, { status: 400 }); - } - } const minted = await applyFlowExtensionGrants.mint(account.id, scopes); return NextResponse.json({ diff --git a/apps/applyflow/src/app/api/applyflow/v2/jobs/[id]/route.test.ts b/apps/applyflow/src/app/api/applyflow/v2/jobs/[id]/route.test.ts index f2c57bfe..f95b8718 100644 --- a/apps/applyflow/src/app/api/applyflow/v2/jobs/[id]/route.test.ts +++ b/apps/applyflow/src/app/api/applyflow/v2/jobs/[id]/route.test.ts @@ -12,11 +12,9 @@ vi.mock("@/lib/persistence-v2/require-applyflow-account", () => { this.code = code; } } - class ApplyFlowPersistenceDisabledError extends Error {} return { requireApplyFlowAccount: vi.fn(), ApplyFlowAuthError, - ApplyFlowPersistenceDisabledError, }; }); diff --git a/apps/applyflow/src/app/api/applyflow/v2/jobs/route.test.ts b/apps/applyflow/src/app/api/applyflow/v2/jobs/route.test.ts index 8df3c2a4..60fb4fb0 100644 --- a/apps/applyflow/src/app/api/applyflow/v2/jobs/route.test.ts +++ b/apps/applyflow/src/app/api/applyflow/v2/jobs/route.test.ts @@ -12,11 +12,9 @@ vi.mock("@/lib/persistence-v2/require-applyflow-account", () => { this.code = code; } } - class ApplyFlowPersistenceDisabledError extends Error {} return { requireApplyFlowAccount: vi.fn(), ApplyFlowAuthError, - ApplyFlowPersistenceDisabledError, }; }); diff --git a/apps/applyflow/src/app/api/applyflow/v2/migration/[sessionId]/route.test.ts b/apps/applyflow/src/app/api/applyflow/v2/migration/[sessionId]/route.test.ts index 3805164d..d211d4e8 100644 --- a/apps/applyflow/src/app/api/applyflow/v2/migration/[sessionId]/route.test.ts +++ b/apps/applyflow/src/app/api/applyflow/v2/migration/[sessionId]/route.test.ts @@ -12,11 +12,9 @@ vi.mock("@/lib/persistence-v2/require-applyflow-account", () => { this.code = code; } } - class ApplyFlowPersistenceDisabledError extends Error {} return { requireApplyFlowAccount: vi.fn(), ApplyFlowAuthError, - ApplyFlowPersistenceDisabledError, }; }); diff --git a/apps/applyflow/src/app/api/applyflow/v2/migration/route.test.ts b/apps/applyflow/src/app/api/applyflow/v2/migration/route.test.ts index 6739256e..b52502e3 100644 --- a/apps/applyflow/src/app/api/applyflow/v2/migration/route.test.ts +++ b/apps/applyflow/src/app/api/applyflow/v2/migration/route.test.ts @@ -12,11 +12,9 @@ vi.mock("@/lib/persistence-v2/require-applyflow-account", () => { this.code = code; } } - class ApplyFlowPersistenceDisabledError extends Error {} return { requireApplyFlowAccount: vi.fn(), ApplyFlowAuthError, - ApplyFlowPersistenceDisabledError, }; }); diff --git a/apps/applyflow/src/components/dashboard/dashboard-career-export-content.ts b/apps/applyflow/src/components/dashboard/dashboard-career-export-content.ts index 7ce8580c..a01078be 100644 --- a/apps/applyflow/src/components/dashboard/dashboard-career-export-content.ts +++ b/apps/applyflow/src/components/dashboard/dashboard-career-export-content.ts @@ -13,17 +13,5 @@ export const DASHBOARD_CAREER_EXPORT_SOURCE_DESCRIPTIONS = { export const DASHBOARD_CAREER_EXPORT_COMPOSITION_TRANSIENT_NOTICE = "A composição é temporária e usada apenas para prévia, handoff e exportação explícita. Nenhuma alteração foi aplicada ao perfil. Nenhuma candidatura foi modificada."; -/** @deprecated Use deriveExportCompositionSourceViewModel for UI copy. */ -export const DASHBOARD_CAREER_EXPORT_ENRICHMENT_SOURCE_NONE = - DASHBOARD_CAREER_EXPORT_SOURCE_DESCRIPTIONS.none; - -/** @deprecated Use deriveExportCompositionSourceViewModel for UI copy. */ -export const DASHBOARD_CAREER_EXPORT_ENRICHMENT_SOURCE_DEMO = - DASHBOARD_CAREER_EXPORT_SOURCE_DESCRIPTIONS.demo; - -/** @deprecated Use deriveExportCompositionSourceViewModel for UI copy. */ -export const DASHBOARD_CAREER_EXPORT_ENRICHMENT_SOURCE_PROVIDER_DERIVED = - DASHBOARD_CAREER_EXPORT_SOURCE_DESCRIPTIONS["provider-derived-proposal"]; - export const DASHBOARD_CAREER_EXPORT_READ_ONLY_NOTICE = "Nada foi aplicado ao perfil ou às candidaturas. A exportação ocorre somente pelas ações explícitas abaixo."; diff --git a/apps/applyflow/src/lib/import-opportunity-pipeline-local.ts b/apps/applyflow/src/lib/import-opportunity-pipeline-local.ts index ebd18e3d..df7a3523 100644 --- a/apps/applyflow/src/lib/import-opportunity-pipeline-local.ts +++ b/apps/applyflow/src/lib/import-opportunity-pipeline-local.ts @@ -283,22 +283,4 @@ export async function applyOpportunityPipelineJson( } } -/** @deprecated Prefer {@link applyOpportunityPipelineJson} (authority-aware). */ -export function applyOpportunityPipelineLocalJson( - text: string, - options: { now?: Date } = {}, -): ReturnType { - try { - return applyOpportunityPipelineLocal(JSON.parse(text) as unknown, options); - } catch { - return { - ok: false, - error: "invalid_json", - jobs: [], - contacts: [], - ignoredCount: 0, - }; - } -} - export type { Contact }; diff --git a/apps/applyflow/src/lib/job-sources/criteria.ts b/apps/applyflow/src/lib/job-sources/criteria.ts index dfe72f68..4f3b5146 100644 --- a/apps/applyflow/src/lib/job-sources/criteria.ts +++ b/apps/applyflow/src/lib/job-sources/criteria.ts @@ -98,6 +98,3 @@ export function parseJobSearchCriteria(raw: unknown): JobSearchCriteria | null { ...(parsed.data.sort ? { sort: parsed.data.sort } : {}), }; } - -/** @deprecated Prefer parseJobSearchCriteria — retained name for existing imports. */ -export const jobSearchCriteriaSchema = jobSearchRequestSchema; diff --git a/apps/applyflow/src/lib/job-sources/types.ts b/apps/applyflow/src/lib/job-sources/types.ts index b0db78f5..d52b3125 100644 --- a/apps/applyflow/src/lib/job-sources/types.ts +++ b/apps/applyflow/src/lib/job-sources/types.ts @@ -36,8 +36,6 @@ export const JOB_SEARCH_MAX_PAGE = 10; /** Absolute Zod ceiling; provider adapters enforce tighter limits server-side. */ export const JOB_SEARCH_ABSOLUTE_MAX_LIMIT = 25; -/** @deprecated Prefer providerMaxLimit(provider) — kept for existing imports. */ -export const JOB_SEARCH_MAX_LIMIT = JOB_SEARCH_ABSOLUTE_MAX_LIMIT; export const JOBGETHER_DEFAULT_LIMIT = 10; export const JOBGETHER_MAX_LIMIT = 25; diff --git a/apps/applyflow/src/lib/persistence-v2/personal/extension-grant-scopes-default.local.test.ts b/apps/applyflow/src/lib/persistence-v2/personal/extension-grant-scopes-default.local.test.ts index e71669a2..de2f5d0d 100644 --- a/apps/applyflow/src/lib/persistence-v2/personal/extension-grant-scopes-default.local.test.ts +++ b/apps/applyflow/src/lib/persistence-v2/personal/extension-grant-scopes-default.local.test.ts @@ -211,7 +211,7 @@ describe.skipIf(!ENABLED)("extension grant scopes DEFAULT on disposable Postgres ); expect(response.status).toBe(400); const body = (await response.json()) as { error?: string }; - expect(body.error === "scopes_invalid" || body.error === "scopes_required").toBe(true); + expect(body.error).toBe("scopes_required"); const afterCount = psql( `SELECT count(*)::text FROM applyflow_extension_grants WHERE account_id = '${ACCOUNT_ID}'`, diff --git a/apps/applyflow/src/lib/persistence-v2/require-applyflow-account.ts b/apps/applyflow/src/lib/persistence-v2/require-applyflow-account.ts index 86465c63..69a135c9 100644 --- a/apps/applyflow/src/lib/persistence-v2/require-applyflow-account.ts +++ b/apps/applyflow/src/lib/persistence-v2/require-applyflow-account.ts @@ -5,13 +5,6 @@ import { applyflowPrisma } from "./db"; export { ApplyFlowAuthError }; -/** @deprecated Prefer ApplyFlowV2HttpAccessError from http-access.ts (R2.2.3). */ -export class ApplyFlowPersistenceDisabledError extends Error { - constructor() { - super("ApplyFlow Persistence V2 is disabled."); - } -} - export type ApplyFlowAccountRecord = Pick< ApplyFlowAccount, | "id" diff --git a/docs/applyflow/PERSISTENCE_V2_MIGRATION_RUNBOOK.md b/docs/applyflow/PERSISTENCE_V2_MIGRATION_RUNBOOK.md index 7e169f04..6794738c 100644 --- a/docs/applyflow/PERSISTENCE_V2_MIGRATION_RUNBOOK.md +++ b/docs/applyflow/PERSISTENCE_V2_MIGRATION_RUNBOOK.md @@ -205,3 +205,65 @@ Production migration / Auth URL / Vercel env changes belong to **explicit produc ## Production backup (pre-rollout) Supabase Free does not imply PITR. Before Production schema/ops windows, take a logical dump with operator credentials held outside `.env.local`, e.g. `pg_dump` against Production `DIRECT_URL` — never commit dump files. + +### Extension grant scopes — catalog read 2026-10-21 + +Read-only session on the Production pooler (`aws-0-sa-east-1.pooler.supabase.com`, host fingerprint `3c193d95207920e0`, database `postgres`, Postgres 17.6), transaction `READ ONLY`. No migration, resolve, or write was run. + +| Fact | State | +| --- | --- | +| `20261020140000_extension_grant_scopes` | Pending. No finished, failed, or rolled-back row in `_prisma_migrations`. | +| `20261020210000_extension_grant_scopes_legacy_default` | Pending. No finished, failed, or rolled-back row. | +| Last finished migration | `20261005170000_account_personal_persistence` (`finished`, not rolled back, `applied_steps_count` 1). | +| `applyflow_extension_grants.scopes` | Column absent. Nullability and DEFAULT do not exist yet. | + +`migrate resolve` stays a separate human decision after a later catalog check. Do not run it while both migrations are absent. + +### Backup evidence 2026-10-21 + +“Backup mechanism present” is not “recovery tested”. The 2026-10-02 Phase 12C note in [`BETA_EVIDENCE.md`](./BETA_EVIDENCE.md) does not protect schema or data changes after that date. + +Checked, without creating or restoring a backup: + +- **WAL archiving is on.** `archive_mode=on`, `wal_level=logical`, `archive_timeout=120`, `archive_command` runs `wal-g` `wal-push`. `pg_stat_archiver`: `archived_count=7`, `failed_count=0`, `last_archived_time=2026-10-21T18:51:19.889Z`, `last_failed_time` null. The count can reset when the archiver restarts, so it is not a retention window. No Supabase management token was available, so the last restorable point, retention, and the console restore procedure were not read. PITR is not verified. +- **Named Production dump was not found.** `applyflow-production-clean-20260927-195051.dump` (recorded SHA-256 `c6475f78e41dd4141fb48779ce8902421ef25d2f79619fa5cc8c99cb6d1f25ea`) is not in the repo, `apps/applyflow/.tmp`, Desktop, Documents, or Downloads. The Phase 12C custom dump (299388 bytes, SHA-256 not stored in git) was also not found. Neither file is available evidence for this window. +- **Local drill is not a Production backup.** `apps/applyflow/.tmp/backup-drill/applyflow-local-drill.dump` (13194 bytes, 2026-10-01T17:19:20Z, SHA-256 `538384758b2f6f9f9bcb1cdb05ffd6fba4015082c53574ab2da257a61f85e803`) matches `drill-report.json`. `pg_restore -l` exits 0. The report records a restore into local `127.0.0.1` / `applyflow` with `ok: true` and `restoreExitStatus` 1, which the drill script accepts for ignorable version warnings. That tested a local database from 2026-10-01. It does not include Production data and predates `20261005170000`. + +The backup gate below was executed on 2026-10-21 after explicit approval. Merge, containment, migration, resolve, deploy, and flag changes were not part of that approval and were not run. + +### Current backup and isolated restore — executed 2026-10-21 + +| Field | Value | +| --- | --- | +| File | `applyflow-production-pre-scopes-20261021T193045Z.dump` | +| Directory | `C:\Users\Xakep\devflow-applyflow-backups\` (outside git; ACL limited to the operator account) | +| Written (UTC) | 2026-10-21T19:30:49Z | +| Bytes | 408023 | +| SHA-256 | `b97a419cf4d7507c33e83fb7a9ce034522171b29b0b80de437336418f3eff604` | +| `pg_restore -l` | exit 0, 664 TOC lines | +| Source | Production session pooler, host fingerprint `3c193d95207920e0`, database `postgres`. `pg_dump` exited 0. Production was not written. | + +Isolated restore: disposable Postgres 17 on `127.0.0.1:5441`, database `applyflow_restore_check`, container `applyflow-pg-restore-check`. `pg_restore --no-owner --no-acl` exited 1 with two errors only: extension `supabase_vault` does not exist, and relation `vault.secrets` does not exist. Those are Supabase platform objects, not ApplyFlow tables. The ApplyFlow catalog in that database matched the pre-scopes Production state: + +- `20261005170000_account_personal_persistence` finished, not rolled back, `applied_steps_count` 1 +- both scopes migrations absent +- `scopes` column absent +- 11 `applyflow_%` tables in `public` + +The container was removed after those checks. The local database on port 5434 was not used. The dump file was kept. It is a copy of Production personal data and must not be committed, logged, or pasted. Deleting it requires a separate retention decision. + +### Scopes launch — executed 2026-10-21 + +The catalog table above is the pre-migration read. After explicit approval, `prisma migrate deploy` (Prisma 6.19.2) applied both scopes migrations on the Production session pooler (host fingerprint `3c193d95207920e0`, port 5432, database `postgres`). `migrate resolve` was not used. A following `READ ONLY` check recorded: + +| Fact | State | +| --- | --- | +| `20261020140000_extension_grant_scopes` | Finished, not rolled back, `applied_steps_count` 1. | +| `20261020210000_extension_grant_scopes_legacy_default` | Finished, not rolled back, `applied_steps_count` 1. | +| `applyflow_extension_grants.scopes` | `NOT NULL`. DEFAULT is exactly `profile:read`, `profile:write`, `jobs:read`, `jobs:write`, `applications:read`, `applications:write`. No `contacts` scope. | + +PR #262 was merged to `main` as `2e7fb7509462accda468a9f6a95075795a7595b2`. Git branch `production` was fast-forwarded to that commit. The public alias `devflow-applyflow.vercel.app` serves production deployment `dpl_EkYCrFVa7gbri1DJbJtPfukZ2jJ2`, SHA `2e7fb7509462accda468a9f6a95075795a7595b2`, `READY`. `GET /api/applyflow/v2/extension/session` on that alias returned HTTP 401 `{"error":"unauthenticated"}`. + +`APPLYFLOW_PERSISTENCE_V2` stayed `false`. The temporary empty `APPLYFLOW_PERSISTENCE_V2_ROLLOUT_ACCOUNTS` was removed after the merged SHA was serving, then that SHA was redeployed so the running snapshot no longer has the variable. Absent and empty are equivalent while the flag is not exactly `true`; this did not turn persistence v2 on and did not add account ids. The project ignore command is `cd ../.. && node scripts/applyflow-vercel-ignore.mjs`. Old deployments were not deleted. The pre-scopes dump was kept. + +The same merge did not appear as a portal production deployment on its own. Portal project `devflow` (`production` branch `main`, no ignore command) was then deployed from git SHA `2e7fb7509462` as `dpl_DrZPQ6p5n2dprK5ohWuzvvQovg1F`. WhatsApp recorded a production deployment of that SHA as `CANCELED` (ignore step). Financeiro had no deployment of that SHA. diff --git a/scripts/applyflow-vercel-ignore.mjs b/scripts/applyflow-vercel-ignore.mjs index 438ebeb7..05105e39 100644 --- a/scripts/applyflow-vercel-ignore.mjs +++ b/scripts/applyflow-vercel-ignore.mjs @@ -12,8 +12,7 @@ * Exit 0 skips the deployment. Any other status builds. * This file does not set `TURBO_FORCE`. * - * The Vercel project command is still `cd ../.. && npx turbo-ignore applyflow`. - * Switching it is a separate production-config change: + * Live Vercel project command: * cd ../.. && node scripts/applyflow-vercel-ignore.mjs */ import { execFileSync } from "node:child_process";