diff --git a/.agents/skills/effect-house/SKILL.md b/.agents/skills/effect-house/SKILL.md index 3ba6f1fcd..d699916ee 100644 --- a/.agents/skills/effect-house/SKILL.md +++ b/.agents/skills/effect-house/SKILL.md @@ -1,6 +1,6 @@ --- name: effect-house -description: House conventions for Effect v4 TypeScript in the vektorprogrammet mono-web repository. It names the shared constructs, the composition roots that select platform layers, the test platform and @effect/vitest conventions, the runtime bridges, typed HTTP problems, Effect-returning services, the lint configuration and the project rules with their FX ids, the module guides, and the Effect exception registry and its check, and it links to the file that owns each fact. Use when you write, review, or test TypeScript in mono-web, and before you choose a construct, a platform or test layer, a Promise bridge, or a lint suppression. It adds to the portable effect-* skills and the installed Effect guidance and does not restate them. +description: House conventions for Effect v4 TypeScript in the vektorprogrammet mono-web repository. It names the shared constructs, the composition roots that select platform layers, the test platform and @effect/vitest conventions, the runtime bridges, typed RPC problems, Effect-returning services, the lint configuration and the project rules with their FX ids, the module guides, and the Effect exception registry and its check, and it links to the file that owns each fact. Use when you write, review, or test TypeScript in mono-web, and before you choose a construct, a platform or test layer, a Promise bridge, or a lint suppression. It adds to the portable effect-* skills and the installed Effect guidance and does not restate them. --- # effect-house: mono-web @@ -17,7 +17,7 @@ The links are relative to this file, so the file is a complete entry point also 1. Read the `AGENTS.md` guide of the folder. Every app, package, and bounded-context folder has one. `just guides write` renders its first part from [docs/model/contexts.cml](../../../docs/model/contexts.cml), the `@construct` tags, and the package exports. Local invariants go below that part. 2. Search the one-line index [docs/constructs.md](../../../docs/constructs.md) for a construct that already owns the logic, read its contract on the page of its category, and use it. 3. Find the platform and test layers of the file's role: [references/composition-and-tests.md](references/composition-and-tests.md). -4. At an HTTP boundary, a service interface, or a Promise callback, read [references/boundaries.md](references/boundaries.md). +4. At an RPC boundary, a service interface, or a Promise callback, read [references/boundaries.md](references/boundaries.md). 5. If a rule cannot be followed, register an exception: [references/exceptions.md](references/exceptions.md). ## Constructs (FX002) @@ -30,14 +30,14 @@ Reading about a construct discloses in three steps, each only when you need it: | Ports for the servers of a journey | `reserveLoopbackPorts`, `loopbackPortFree` ([test-harness][test-harness]) | `anti-slop/no-port-probe` | | Instants in journey fixtures | `journeyClock`, `admissionJourneyClock` ([test-harness][test-harness]) | `anti-slop/no-literal-window-instant` | | Canonical JSON, digests, SQL JSON values | `canonicalJson`, `canonicalJsonBytes`, `sha256Hex`, `canonicalJsonValue` ([digest]) | `anti-slop/no-json-text-parameter` | -| The JSON text of an HTTP representation | `jsonText` ([http-problem]) | `effecttsgo/prefer-schema-over-json` | +| The JSON text of a representation | `jsonText` ([rpc-problem]) | `effecttsgo/prefer-schema-over-json` | | An advisory lock | `lockAdvisory` with a registered `AdvisoryLockKey` ([sql-lock]) | `anti-slop/no-raw-advisory-lock-sql` | | The Promise callbacks of a library | The pattern of `makeBetterAuthCallbackRunner`, until a second runner makes it a construct ([runtime bridges][runtime-bridges]) | review-only | | The class of a request that a journey saw | `isNativeRequest`, `addressesAnyRoute` ([request-ledger]) | review-only | [test-harness]: ../../../docs/constructs/test-harness.md [digest]: ../../../docs/constructs/digest.md -[http-problem]: ../../../docs/constructs/http-problem.md +[rpc-problem]: ../../../docs/constructs/rpc-problem.md [sql-lock]: ../../../docs/constructs/sql-lock.md [runtime-bridges]: references/boundaries.md#runtime-bridges-fx003-fx006 [request-ledger]: ../../../docs/constructs/request-ledger.md @@ -47,7 +47,7 @@ Reading about a construct discloses in three steps, each only when you need it: - **Composition roots select the platform (FX003).** Only a composition root provides `BunServices.layer` or another platform layer; packages and adapters require platform services and stay portable. Enforcement: `effect/no-cross-runtime` against the roles and platforms that [oxlint.config.ts](../../../oxlint.config.ts) declares. Details: [references/composition-and-tests.md](references/composition-and-tests.md). - **Tests are Effect programs (FX015).** `@effect/vitest`, one Effect per test, suite resources as `layer(L, { excludeTestServices: true, timeout })`, and the test platform of the package (`TestPlatform`, and `DatabaseTestLive` in database suites). Enforcement: `effecttsgo/async-function` in core code, `anti-slop/no-module-mocking`. Details: [references/composition-and-tests.md](references/composition-and-tests.md). - **Services return Effects (FX003).** No Promise interface between Effect layers. A program runs only at a composition root or a runtime bridge. Enforcement: `effecttsgo/async-function` and `effecttsgo/new-promise` in core code, `effect/no-premature-execution`. Details: [references/boundaries.md](references/boundaries.md). -- **HTTP failures are typed problems (FX004, FX005).** A handler decodes with the contract schemas and answers with a problem that the endpoint declares; `ProblemBoundaryLive` is the only consumer of a Cause. Enforcement: `just check-types`, which checks the endpoint unions and asserts the HTTP contract. Details: [references/boundaries.md](references/boundaries.md). +- **RPC failures are typed problems (FX004, FX005).** The RPC server decodes each payload with the contract schema, and a handler answers with a problem that its RPC declares; `ProblemBoundaryLive` is the only consumer of a Cause. Enforcement: `just check-types`, which checks the declared unions, and [packages/rpc/test/contract.test.ts](../../../packages/rpc/test/contract.test.ts), which checks that every RPC runs inside the defect boundary. Details: [references/boundaries.md](references/boundaries.md). - **Every exception is registered (FX012).** A suppression of an Effect rule names its entry in [docs/effect-exceptions.json](../../../docs/effect-exceptions.json). Enforcement: `just exceptions`. Details: [references/exceptions.md](references/exceptions.md). ## Lint configuration @@ -58,7 +58,7 @@ Reading about a construct discloses in three steps, each only when you need it: - The last group that matches a file decides every `effect/*` rule for it, so a test file does not inherit the rules of its source group. Groups use plain globs, because Oxlint has no extglob, and run from broad sources to narrow exceptions. [tools/conventions/tests/oxlint-groups.test.ts](../../../tools/conventions/tests/oxlint-groups.test.ts) guards that shape; keep it when you change a group. - A new composition root or adapter joins the group of its role and platform, such as the Bun composition-root group; it gets no suppression and no `off` override. The Bun groups admit the Node modules that Bun implements (`bunNodeModules`), and their files import `process` from `node:process` and `Buffer` from `node:buffer`, because the rule admits no Node globals on `bun`. - The `effecttsgo/*` rules of the Effect language service come from the `recommended` and `correctness` presets of `@effect/tsgo`, and they make `just lint` type-aware. Every rule is an error, and so is every rule of Oxlint's correctness category, the type-aware ones included. The `effectNative` rules replace platform APIs with Effect services, so they run only in the core Effect packages that `coreEffectFiles` lists; the other apps, packages, and tools are not Effect programs. `oxlint-groups.test.ts` guards these severities and that scope too. The language-service plugin in `tsconfig.json` keeps refactors and quick info in the editor and emits no diagnostics, so `tsc` reports none. Count sites with `just lint`, never with grep or `tsc`. -- Fix a finding with the rule's own fix, in the house form where one exists: `EffectSdkFailure` of `@vektorprogrammet/sdk/effect` is the failure channel of a generated SDK operation, `formText` ([apps/dashboard/app/lib/form-text.ts](../../../apps/dashboard/app/lib/form-text.ts)) reads a form field, and `DateTime` with `normalizeRfc3339Instant` ([packages/domain/src/time.ts](../../../packages/domain/src/time.ts)) handles instants. JSON goes through the constructs above. +- Fix a finding with the rule's own fix, in the house form where one exists: the declared `Problem` union of an RPC is the failure channel of its client call, and `callBrowserNative` ([apps/dashboard/app/lib/browser-native.ts](../../../apps/dashboard/app/lib/browser-native.ts)) adds `NativeAnswerInvalid` for an answer outside the contract, `formText` ([apps/dashboard/app/lib/form-text.ts](../../../apps/dashboard/app/lib/form-text.ts)) reads a form field, and `DateTime` with `normalizeRfc3339Instant` ([packages/domain/src/time.ts](../../../packages/domain/src/time.ts)) handles instants. JSON goes through the constructs above. ## Project rules and checks @@ -81,7 +81,7 @@ The portable skills cite the `effecttsgo/*` rules. The rules and checks of this | The rules of the constructs table | FX002 | Logic that a listed construct owns | | `just exceptions` | FX012 | A suppression without a registered exception, and an entry that no longer matches its sites or versions | | `just constructs` | FX002 | A construct page that differs from the `@construct` tags and their JSDoc, a construct without its contract tags or annotations, and a construct that fewer than two modules share | -| `just check-types` | FX004 | A type error, and an HTTP contract that differs from the one that it regenerates | +| `just check-types` | FX004 | A type error, such as a failure that an RPC does not declare | | `packages/database/src/migration-registry.test.ts`, `just migration-hashes` | FX010 | An applied migration whose checksum changed, and a migration id, position, or file that the registry lacks | `effect/no-native-promise-control-flow` and `effect/no-untyped-throw` are strict rules, so they run in every group whose role they cover. `effect/no-raw-json-parse` runs where a group declares the `external-data` boundary: the runtime adapters of `apps/backend` and `packages/database`. @@ -97,8 +97,8 @@ The portable skills leave these facts to the overlay: - **Composition roots, test platform, and runtime bridges:** [references/composition-and-tests.md](references/composition-and-tests.md) and [references/boundaries.md](references/boundaries.md). - **Layer names:** `Live` for a live layer, such as `DatabaseLive`, `ContentManagementLive`, and `ReceiptFileStoreLive`, and `DatabaseTest()` for the PGlite database of a test. Services have no `layer` or `layerTest` statics. - **Migrations and the test database:** `packages/database/migrations/NNNN-.sql`, frozen by `packages/database/migrations/checksums.json`; `just migration-hashes write` records a new one. A test takes `DatabaseTest()` or a disposable cluster (`withDisposablePostgres`). -- **Drift check of generated artifacts:** `just check-types` runs the `generate` scripts of `packages/http-api` and `packages/sdk` before the type checks, and asserts the HTTP contract. -- **Test command and type-test tool:** `bun run --cwd vitest run --no-file-parallelism --maxWorkers=1`. Type tests use `expectTypeOf` of Vitest, as [`packages/sdk/src/__tests__/generated-native-client.test.ts`](../../../packages/sdk/src/__tests__/generated-native-client.test.ts) does. +- **Drift check of generated artifacts:** none. Nothing is generated from the RPC contract: handlers and clients derive from its groups, and [packages/rpc/test/contract.test.ts](../../../packages/rpc/test/contract.test.ts) checks the operation IDs, AccessSpecs, and defect boundary of every RPC. +- **Test command and type-test tool:** `bun run --cwd vitest run --no-file-parallelism --maxWorkers=1`. Type tests use `expectTypeOf` of Vitest, as [`packages/domain/src/organization/authority-evidence.test.ts`](../../../packages/domain/src/organization/authority-evidence.test.ts) does. - **Foldkit house form:** one folder per bounded context in `apps/dashboard/app/foldkit`, each with its guide, and one Model per stateful workflow. Tests are `update.test.ts`, `view.test.ts` with `Scene` of `foldkit/test`, and `update.property.test.ts`. `anti-slop/no-unkeyed-command-row` runs there. `foldkit/prefer-command-mapmessage` does not, because `oxlint.config.ts` configures no Foldkit plugin. ## Done means diff --git a/.agents/skills/effect-house/references/boundaries.md b/.agents/skills/effect-house/references/boundaries.md index e5cd7667d..5db7c2e61 100644 --- a/.agents/skills/effect-house/references/boundaries.md +++ b/.agents/skills/effect-house/references/boundaries.md @@ -1,4 +1,4 @@ -# Services, HTTP boundaries, and runtime bridges +# Services, RPC boundaries, and runtime bridges ## Services return Effects (FX003) @@ -22,12 +22,12 @@ The pattern to copy is `makeBetterAuthCallbackRunner` in [packages/database/src/ A new library of this kind gets its own runner, built the same way in the layer that owns the library. Do not run a program with `Effect.runPromise` inside a callback. The runner is no tagged construct while it is the only one, because a construct needs two consumers outside its own module. The second runner makes it one: move the logic that both share into one runner, tag it `@construct runtime-bridge`, and declare that category again in [tools/conventions/src/constructs.ts](../../../../tools/conventions/src/constructs.ts). -## HTTP boundaries (FX004, FX005) +## RPC boundaries (FX004, FX005) -[packages/http-api](../../../../packages/http-api) owns the contracts; the backend derives its transport from them, and `just check-types` asserts the generated HTTP contract. +[packages/rpc](../../../../packages/rpc) owns the contract ([docs/architecture.md](../../../../docs/architecture.md#rpc-contract-and-ingress)); the backend implements each context's group, and every client derives from the same group. Nothing is generated. -- A handler decodes its input with the contract schemas: `readJsonBody`, `decodeRequest`, and `strictOutput` ([docs/constructs/http-problem.md](../../../../docs/constructs/http-problem.md)). -- A failure is a `Problem`. `Problem.make(code)` takes only the code; `NativeProblemRegistry` in [packages/http-api/src/http-semantics.ts](../../../../packages/http-api/src/http-semantics.ts) owns its status and body. An endpoint declares its closed union with `problemUnion`. +- The RPC server decodes a payload with the contract schema before the handler runs. A handler decodes its success value with `strictOutput` ([docs/constructs/rpc-problem.md](../../../../docs/constructs/rpc-problem.md)). +- A failure is a `Problem`. `Problem.make(code)` takes only the code; `NativeProblemRegistry` in [packages/rpc/src/problem.ts](../../../../packages/rpc/src/problem.ts) owns its status and body. An RPC declares its closed union with `problemUnion` and takes it as `error: rpcProblems(...)`. - Each context maps its domain failures once with `problemMapper`, as `contentProblems` and `receiptProblems` do, and declares the mapper's type as `ProblemMapper`, where `Cases` is the type of its cases. A failure that an operation cannot produce is marked with `unreachable`. - `ProblemBoundaryLive` is the only consumer of a Cause. A handler does not catch defects or render causes itself. - A JSON representation is written with `jsonText`, which encodes through Schema and writes the bytes that `JSON.stringify` wrote. diff --git a/.claude/rules/constitution.md b/.claude/rules/constitution.md index 692aeafd4..ab8d55239 100644 --- a/.claude/rules/constitution.md +++ b/.claude/rules/constitution.md @@ -1,13 +1,13 @@ # Monoweb Project Conventions -## SDK Conventions +## RPC Contract Conventions -When working on `packages/sdk/`: +When working on `packages/rpc/`: -- Domain methods speak the ubiquitous language: `approve()` not `updateStatus("refunded")` -- Types are Schema.Class — inferred via `Schema.Schema.Type`, never hand-written interfaces -- Effect is an internal detail — consumers see plain promises via `createClient()` -- Every new domain type needs a Schema round-trip test (`encode → decode === original`) -- Adapter transforms (Hydra unwrap, status mapping, date parsing) live in `src/adapter/` -- `src/domains/` contain the domain method factories — one file per domain -- Dual export: `@vektorprogrammet/sdk` (promises) + `@vektorprogrammet/sdk/effect` +- The RPC tag is the operation ID (`social-events.create`). Command receipts store it +- Payload and success are Schemas; the error is `rpcProblems(Problem)`, a closed `problemUnion` of registry codes +- Types are inferred from the Schemas, never hand-written interfaces +- Each RPC takes at most one credential middleware (none for anonymous access) and exactly one `withAccessSpec(...)`; `packages/rpc/test/contract.test.ts` checks the AccessSpec, the tag grammar, and the defect boundary +- A contract re-exports only the schemas its own RPCs use; clients import other domain values from `@vektorprogrammet/domain` +- Every client derives from the group: `RpcClient` in Effect code, `callNative`, `callBrowserNative`, and `callHomepageNative` in the apps, and `nativeScriptClient` from `@vektorprogrammet/rpc/script` in journeys and probes. No client is generated or written by hand +- `packages/rpc/src/social-events.ts` is the precedent to copy diff --git a/.claude/settings.json b/.claude/settings.json index a422dbb2e..86e09e97f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -11,9 +11,9 @@ }, { "type": "command", - "command": "jq -r '.tool_input.file_path' | { read -r f; if [[ \"$f\" == *.ts && \"$f\" == */packages/sdk/* ]]; then cd packages/sdk && bunx tsc --noEmit 2>/dev/null | tail -3; fi; }", + "command": "jq -r '.tool_input.file_path' | { read -r f; if [[ \"$f\" == *.ts && \"$f\" == */packages/rpc/* ]]; then cd packages/rpc && bunx tsc --noEmit 2>/dev/null | tail -3; fi; }", "asyncRewake": true, - "statusMessage": "Checking SDK types..." + "statusMessage": "Checking RPC contract types..." } ] } diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 57220a720..c3f33c6a3 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -40,8 +40,7 @@ jobs: just constructs just guides just exceptions - # Type checks run the Turbo `generate` tasks, which derive and assert the HTTP contract - # artifacts. `just check` repeats the fast checks above, as the pre-push hook runs it. - - name: Check layout, constructs, guides, Effect exceptions, source safety, format, lint, types, and HTTP contract + # `just check` repeats the fast checks above, as the pre-push hook runs it. + - name: Check layout, constructs, guides, Effect exceptions, source safety, format, lint, and types timeout-minutes: 12 run: just check diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index db6d18a57..4e250cab6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -59,7 +59,7 @@ jobs: run: bun install --frozen-lockfile --ignore-scripts - name: Check retained-artifact rejection rules timeout-minutes: 1 - run: bun test tools/placements-docs/placements-artifact.test.mjs + run: bun test tools/placements-docs/placements-artifact.test.ts - name: Check examples and generate exact-source reference id: docs timeout-minutes: 4 @@ -105,7 +105,7 @@ jobs: run: bun install --frozen-lockfile - name: Reject unsafe golden evidence regressions timeout-minutes: 1 - run: bun test tools/e2e/golden-school-service-evidence.test.mjs + run: bun test tools/e2e/golden-school-service-evidence.test.ts - name: Install diagnostic tools timeout-minutes: 2 shell: bash diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 80b61b3e2..93145a1f7 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -34,16 +34,11 @@ "!/packages/domain/package.json", "!/packages/domain/src/", "!/packages/domain/examples/", - "!/packages/http-api/", - "/packages/http-api/*", - "!/packages/http-api/package.json", - "!/packages/http-api/src/", - "!/packages/http-api/scripts/", - "!/packages/sdk/", - "/packages/sdk/*", - "!/packages/sdk/package.json", - "!/packages/sdk/src/", - "!/packages/sdk/scripts/", + "!/packages/rpc/", + "/packages/rpc/*", + "!/packages/rpc/package.json", + "!/packages/rpc/src/", + "!/packages/rpc/test/", "!/tools/", "/tools/*", "!/tools/conventions/", diff --git a/AGENTS.md b/AGENTS.md index 6f3a8e37a..ce1a8786c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ Keep one authoritative contract and derive its transport interfaces. Enforce boundaries with types first, then compiler or lint checks, rather than prose alone. Report violations with their source location, preserved behavior, proposed deletion, and enforcing check. -Keep the product boundaries below, including Bun, Foldkit, and generated HTTP clients. +Keep the product boundaries below, including Bun, Foldkit, and the Effect RPC contract as the only native API. Check peer compatibility before adopting reference dependencies, including XState and its Effect integration. Reference examples do not authorize production actions or replace the active journey contract. @@ -76,8 +76,8 @@ Fix a finding at its site with the rule's own fix. Where that fix would change b A suppression of an Effect rule, or a non-native substitute, names its entry in [docs/effect-exceptions.json](docs/effect-exceptions.json). `just exceptions` rejects one that does not. When the Effect skill tier of `/srv/share/projects` is installed, delegate Effect work to its profiles. -Use `effect-backend-engineering` for `apps/backend` and the domain, database, and HTTP packages, and `effect-ui-development` for the Foldkit code of `apps/dashboard`. -Use `effect-library-development` for `packages/sdk` and shared constructs, and `effect-engineering` for other work. +Use `effect-backend-engineering` for `apps/backend` and the domain, database, and RPC packages, and `effect-ui-development` for the Foldkit code of `apps/dashboard`. +Use `effect-library-development` for `packages/rpc` and shared constructs, and `effect-engineering` for other work. Each profile reads the overlay above by its path, also when its session starts outside this repository. ## Commands @@ -92,8 +92,8 @@ Package manifests own the per-package scripts that recipes and Turbo run. Use `b | Group | Recipe | Does | | --------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| check | `just check [args...]` | Check layout, constructs, guides, Effect exceptions, source safety, format, lint, types, and the HTTP contract. Arguments go to Turbo. | -| check | `just check-types [args...]` | Type check every package and assert the HTTP contract. Arguments go to Turbo. | +| check | `just check [args...]` | Check layout, constructs, guides, Effect exceptions, source safety, format, lint, and types. Arguments go to Turbo. | +| check | `just check-types [args...]` | Type check every package. Arguments go to Turbo. | | check | `just constructs [args...]` | Check the construct index and contract pages against the @construct tags and their JSDoc, each construct's contract tags and annotations, and that two modules share it; `just constructs write` renders the pages, and `just constructs consumers [name]` prints the modules that import a construct. | | check | `just exceptions [args...]` | Check that every suppression of an Effect rule names its entry in docs/effect-exceptions.json, and every entry its current sites and versions. | | check | `just format [args...]` | Format with Oxfmt, or check with `just format --check`. Oxfmt has no config key for threads. | @@ -127,8 +127,7 @@ For focused Vitest checks, invoke Vitest directly through the package: ```bash bun run --cwd packages/domain vitest run src/receipt/update.property.test.ts --no-file-parallelism --maxWorkers=1 -bun run --cwd apps/backend vitest run src/http-api/receipt-transaction.test.ts --no-file-parallelism --maxWorkers=1 -bun run --cwd packages/http-api generate +bun run --cwd apps/backend vitest run src/rpc/receipt-transaction.test.ts --no-file-parallelism --maxWorkers=1 ``` The domain aggregate `test` script also runs fixture programs and D1 proofs. @@ -147,8 +146,7 @@ Focused Vitest does not prove those additional gates or the dashboard bundle gat | `apps/homepage` | Public React application | | `packages/domain` | Business values, transitions, failures, and authority | | `packages/database` | PostgreSQL schema, persistence, locks, audit, and outbox | -| `packages/http-api` | HTTP contracts, middleware declarations, and OpenAPI | -| `packages/sdk` | Generated native API client | +| `packages/rpc` | The native RPC contract, its credential middlewares, problems, and client | | `tools/acceptance` | Local API and browser acceptance probes of single journeys | | `tools/conventions` | Layout, guide, construct, and Effect exception checks and their generated files | | `tools/e2e` | Golden journeys, local journey drivers, and legacy migration commands | @@ -160,7 +158,6 @@ Focused Vitest does not prove those additional gates or the dashboard bundle gat | `tools/verification` | Cross-application PostgreSQL proofs and migration rehearsals | | `infra` | Worker preview deployment configuration | | `docs` | Intended system, architecture, operations, and active specifications | -| `patches` | Dependency patches that `patchedDependencies` in package.json applies | | `.github` | Checks, Tests, Docs, and preview workflows and their actions | | `.claude` | Claude Code settings and project rules | | `.agents` | Agent skills of the repository: the Effect house overlay | @@ -187,19 +184,19 @@ Product packages must not import application source. - Use Schema at external, persistence, and transport boundaries. - Infer types from schemas. Do not duplicate interfaces. - Use Oxfmt and Oxlint. Do not add another formatter or linter. -- Use generated SDK operations for frontend-to-backend communication. +- Call the backend through the RPC groups of `packages/rpc`: `RpcClient` in Effect code, `callNative` in the dashboard server, `nativeScriptClient` in scripts. - Model stateful dashboard workflows with one Foldkit Model. - Treat UI roles and navigation as projections, not authority. ## Change rule -Implement one complete operational journey at a time. A route, schema, unit -test, or generated SDK method is not migration completion. +Implement one complete operational journey at a time. An RPC, schema, or unit +test is not migration completion. For a permanent behavior change: 1. Define the observable outcome and authority boundary. -2. Update domain, persistence, HTTP, SDK, and UI callers as one cutover. +2. Update domain, persistence, RPC contract and handlers, and UI callers as one cutover. 3. Exercise the real UI, API, and PostgreSQL path. 4. Observe denial, concurrency, replay, and recovery where applicable. 5. Remove temporary scripts and the completed specification. @@ -234,12 +231,12 @@ legacy shutdown require explicit operator authority. - Expose complete business commands through the existing domain service. Avoid generic CRUD and additional repository layers. - Resolve authority inside the committing transaction. Never reuse an authorization result across transactions or retries. - Preserve state, revision, command receipts, audit, and outbox writes in one transaction. Keep provider I/O after commit. -- Keep HTTP response receipts and preconditions in the transport layer. A revision preflight grants no write authority. +- Keep command receipts and preconditions in the transport layer. A revision preflight grants no write authority. - Reuse domain field schemas with `SqlSchema`. Keep SQL projections, joins, ordering, scope, and storage codecs in database adapters. - Use Model variants for useful representations, not automatic business commands or partial PATCH schemas. - Encode through the explicit public schema. Raw `JSON.stringify(model)` does not enforce private-field omission. - Make absence explicit in schemas. For canonical command encoding, use absent keys rather than present `undefined` values. -- Generate HTTP, OpenAPI, and SDK artifacts from the existing contract. Never maintain parallel operation lists. +- Derive every client, handler, and test harness from the one RPC contract. Never maintain parallel operation lists. - Keep lifecycle rules in the owning domain and UI workflow state in one Foldkit Model. The Economy query and settlement command in [architecture.md](docs/architecture.md#domain-services) are the current boundary precedent. @@ -256,7 +253,7 @@ Record instances you cannot fix in `STATE.md` with their location. Remove the re | Trusted by convention | Construction | Precedent on `main` | | ----------------------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A string names a closed set, and a second value repeats a fact about it | Derive the type and every related fact from one registry | `NativeProblemRegistry` in `packages/http-api/src/http-semantics.ts` owns code, status, and body; `Problem.make(code)` takes no status. Counter-example: `PlacementFailure` carries a `status` beside its registry `code`. | +| A string names a closed set, and a second value repeats a fact about it | Derive the type and every related fact from one registry | `NativeProblemRegistry` in `packages/rpc/src/problem.ts` owns code, status, and body; `Problem.make(code)` takes no status. Counter-example: `PlacementFailure` carries a `status` beside its registry `code`. | | A value is validated at the edge but travels as a plain string | Decode once to the domain type at the boundary | Instants belong in `DateTime.Utc`. Counter-example: `compareRfc3339Instants` parses both strings at each call. | | A copy of a derived value is kept in sync by hand | Generate it, or check it against its source | `devenv.nix` reads tool versions from `package.json` and `bun.lock`. It and `.oxfmtrc.json` hold the only hook and formatter definitions. | | A test pins the observed output | Decode the response with the contract schema | `apps/dashboard/e2e/receipt-approval.spec.ts` decodes with the exported receipt schemas. Counter-example: suites that re-pinned `credential.invalid` after 042e808d. | @@ -272,13 +269,15 @@ Record instances you cannot fix in `STATE.md` with their location. Remove the re | A journey learns a free port with a probe and releases it | Reserve ports through the construct and reject probes | `reserveLoopbackPorts` in `tools/postgres/index.ts` reserves distinct ports below the kernel's ephemeral range; `loopbackPortFree` checks a named port; `anti-slop/no-port-probe` rejects a listen, `address()`, and `close` probe in journey code. Counter-example: run 2 of a batch at aa83bde9, where the backend met `EADDRINUSE` on a probed port. | | A check serves a Vite dev server | Serve a build of the current source and reject dev servers | The dashboard Playwright webServer builds and runs `server.mjs`; `anti-slop/no-dev-server` rejects `vite dev`, `react-router dev`, and `run dev` in journey code and Playwright configs. Counter-example: CI runs 36239270833 and 36239753596, where a cold optimizer cache reloaded the page under `page.goto`. | | A view renders an entry's command controls without a key | Key the element by its entry and reject unkeyed command rows | `anti-slop/no-unkeyed-command-row` requires `h.Key` on a Foldkit element that renders one entry and dispatches messages built from it, whether a `.map` callback or a same-file helper returns it. Counter-example: CI run 36240534206, where a reload reordered the article rows and the "Publiser" control of one article published another. | +| A check matches one spelling of a path the client varies | Ask the contract's predicate and reject the comparison | `isNativeRpcPath` in `packages/rpc/src/api.ts` accepts `/api/rpc` and the `/api/rpc/` that the RPC client posts; `anti-slop/no-rpc-path-comparison` rejects comparing a path with an endpoint constant or literal. Counter-example: on 2026-09-28 five journey recorders compared with `/api/rpc` and recorded no RPC of the dashboard server. | +| An accessibility audit runs while the page still animates | Audit through the construct that audits the settled page | `auditSettledPage` in `apps/dashboard/e2e/settled-axe.ts` waits for finite animations before axe runs; `anti-slop/no-unsettled-axe` rejects `new AxeBuilder` and loads of `@axe-core/playwright` elsewhere. Counter-example: the onboarding and golden reimbursement runs of 2026-09-28, where axe read a button mid-transition as `color-contrast`. | | An exception to an Effect rule is explained only by its suppression | Register it and check the registry against its sites | `just exceptions` checks each suppression of an Effect rule against `docs/effect-exceptions.json` in both directions and reopens an entry when an examined package changes version. Counter-example: the hand-kept list of the Effect diagnostics specification missed both `effect/no-cross-runtime` suppressions of `real-interview-response.spec.ts`. | | A check reports findings without failing | Report each finding as an error and guard the severities | `oxlint.config.ts` reports every Effect rule as an error, type-aware; `tools/conventions/tests/oxlint-groups.test.ts` rejects an override that relaxes one. Counter-example: CI run 36240534206, where `tsc` printed 926 Effect suggestion lines that nobody acted on. | ## Verification and resources Use the configured rules in `oxlint.config.ts`. Do not copy their rule inventory into prose or suppress a failure. -Use existing typed test Layers or disposable infrastructure. Do not substitute module mocks or SDK echoes for a real boundary. +Use existing typed test Layers or disposable infrastructure. Do not substitute module mocks or client echoes for a real boundary. Keep regression tests for plausible behavior failures, not field forwarding, source text, or incidental wording. Use disposable probes for other implementation observations. diff --git a/README.md b/README.md index 783110a4f..9cffabe18 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Create one file in `docs/specs/` only while a non-trivial journey is active. Remove the completed specification after its durable intent is present in the system document, code, and observable checks. -Code and generated contracts are authoritative for current implementation. +Code and executable contracts are authoritative for current implementation. Do not keep generated code reference, runtime evidence, screenshots, logs, or dated migration reports in the repository. @@ -48,8 +48,7 @@ The local development instructions below do not establish migration completion. | `apps/homepage` | Public React application | | `packages/domain` | Business values, transitions, failures, and authority | | `packages/database` | PostgreSQL schema, persistence, locks, audit, and outbox | -| `packages/http-api` | HTTP contracts, middleware declarations, and OpenAPI | -| `packages/sdk` | Generated native API client | +| `packages/rpc` | The native RPC contract, its credential middlewares, problems, and client | | `tools/acceptance` | Local API and browser acceptance probes of single journeys | | `tools/conventions` | Layout, guide, construct, and Effect exception checks and their generated files | | `tools/e2e` | Golden journeys, local journey drivers, and legacy migration commands | @@ -61,7 +60,6 @@ The local development instructions below do not establish migration completion. | `tools/verification` | Cross-application PostgreSQL proofs and migration rehearsals | | `infra` | Worker preview deployment configuration | | `docs` | Intended system, architecture, operations, and active specifications | -| `patches` | Dependency patches that `patchedDependencies` in package.json applies | | `.github` | Checks, Tests, Docs, and preview workflows and their actions | | `.claude` | Claude Code settings and project rules | | `.agents` | Agent skills of the repository: the Effect house overlay | @@ -111,11 +109,6 @@ Pre-commit checks only staged paths: one serial, two-thread Oxfmt process, one b Pre-push runs `just check` and tests of packages changed from `main`; push only from a clean worktree. Prek temporarily moves unstaged changes aside while hooks run. Commit with no unstaged tracked changes to avoid a killed hook leaving its parking patch behind. Every workspace that invokes Vitest merges [vitest.shared.ts](vitest.shared.ts), which bounds workers and admits standalone runs through the same machine-wide locks. Run hooks by hand with `just hooks` or `just hooks --hook-stage pre-push`. -The root manifest declares a type-only Effect patch. It preserves union-command -requests and callable Fetch inputs across runtimes. SDK type checks cover both -contracts, including Bun types. Remove the patch when upstream declarations pass -those checks without it. - `engines.postgresql` lists the supported PostgreSQL majors, `"17 || 18"`. The hosted Supabase database runs 17. The highest major, 18, is the default. `VEKTOR_POSTGRES_MAJOR` selects another supported major for one environment, for example `VEKTOR_POSTGRES_MAJOR=17 devenv shell`. devenv re-evaluates the shell when the variable changes. @@ -134,8 +127,8 @@ Run commands inside `devenv shell`, from this repository root. The root [justfil | Group | Recipe | Does | | --------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| check | `just check [args...]` | Check layout, constructs, guides, Effect exceptions, source safety, format, lint, types, and the HTTP contract. Arguments go to Turbo. | -| check | `just check-types [args...]` | Type check every package and assert the HTTP contract. Arguments go to Turbo. | +| check | `just check [args...]` | Check layout, constructs, guides, Effect exceptions, source safety, format, lint, and types. Arguments go to Turbo. | +| check | `just check-types [args...]` | Type check every package. Arguments go to Turbo. | | check | `just constructs [args...]` | Check the construct index and contract pages against the @construct tags and their JSDoc, each construct's contract tags and annotations, and that two modules share it; `just constructs write` renders the pages, and `just constructs consumers [name]` prints the modules that import a construct. | | check | `just exceptions [args...]` | Check that every suppression of an Effect rule names its entry in docs/effect-exceptions.json, and every entry its current sites and versions. | | check | `just format [args...]` | Format with Oxfmt, or check with `just format --check`. Oxfmt has no config key for threads. | @@ -186,7 +179,6 @@ An affected package graph can run separately: ```bash just check-types -F @vektorprogrammet/backend --concurrency=1 -bun run --cwd packages/http-api generate ``` Homepage builds require a clean committed source artifact. Do not weaken that provenance guard for a dirty operator tree. diff --git a/STATE.md b/STATE.md index 4b3343092..056e1fdd1 100644 --- a/STATE.md +++ b/STATE.md @@ -20,7 +20,9 @@ Operator decisions: - The native target is PostgreSQL 17 or 18 (default 18); hosted Supabase runs 17 (operator decision, 2026-09-25). The root manifest declares the set once as `engines.postgresql`; `VEKTOR_POSTGRES_MAJOR` selects a major per environment. - `main` is pushed to `origin` (2026-09-25), so hosted CI runs. - The SDK is not published (operator decision, 2026-09-25). `@vektorprogrammet/sdk` is private; the Release SDK workflow and Changesets are removed. +- The native API is Effect RPC only (operator decision, 2026-09-28). The HttpApi contract, the generated SDK, and OpenAPI are deleted; + `packages/rpc` holds the contract and its clients, and [docs/architecture.md](docs/architecture.md#rpc-contract-and-ingress) lists what RPC does not carry. + Until every journey passes on the RPC path, [docs/specs/rpc-only.md](docs/specs/rpc-only.md) records the port and its checklist. - Migrations are frozen (2026-09-27). Nothing runs in production; every database is disposable; the legacy database is reached only through import commands. The numbered migrations, the checksum registry, `just migration-hashes` and upgrade proofs will be replaced by one schema declaration @@ -108,8 +110,15 @@ Fix an instance when a change touches it (see [AGENTS.md](AGENTS.md#construction inside the content, school, or recruitment-maintenance domain answers 503, not 409 (content errors keep only the cause's text); the dashboard maps a command's `transaction.conflict` to its unknown-error branch (`receipt-view.ts`). - `apps/homepage/src/lib/public-application.ts` lists its problem codes by hand and omits `header.malformed`. - The homepage problem mappers (`mapPublicApplicationError`, `publicTeamApplicationPageFailure`, `failedPublicTeamApplication`) still accept a plain problem-shaped object besides the SDK's `Problem`; the dashboard reads problems only through `nativeProblemFrom`. + The homepage problem mappers (`mapPublicApplicationError`, `publicTeamApplicationPageFailure`, `failedPublicTeamApplication`) still accept a plain problem-shaped object besides `Problem`; the dashboard reads problems only through `nativeProblemFrom`. +- RPC transport gaps. A payload that fails its schema is answered by the RPC server as a defect before any middleware, so `ProblemBoundary` neither reports it nor answers a problem code (`effect/unstable/rpc/RpcServer.ts`, `sendRequestDefect`). + A route that returns one web `Response` built outside the request reads a locked body on its second answer and crashes the process (the health route did until 0c52d63); no rule rejects the form. + `makeBackendTestRpc` (`apps/backend/src/test/native-rpc.ts`) builds a router per request, unlike `apps/backend/src/main.ts`, so a defect of state shared across requests passes its tests; `backendTestRouterLayer` serves several requests on one router. + `nativeUserChallenges` and `nativeCookieChallenge` in `apps/backend/src/rpc/credential.ts` build `WWW-Authenticate` challenges that no RPC answer carries. - Instants: domain fields still use `Rfc3339InstantSchema`, not `Instant` (`packages/domain/src/time.ts`). +- Authorization evidence ([specification](docs/specs/authz-evidence.md)): nine branded evidence types, each with one constructor, gate the commands of Organization, onboarding, admission outcomes, placements, schools, recruitment maintenance, certificates, team applications, and social events. + Receipts (`packages/database/src/receipt/postgres.ts`, `authorizeReceiptMutation`) still export a constructible `ReceiptMutationAuthorization`, and content (`apps/backend/src/content/http-access.ts`) keeps a handler gate in front of the adapter's own check. + `OrganizationPersonAuthority` is not branded at its reader, so a fabricated authority can still mint evidence. The authority instant (M2), per-slice cutovers (M3), and deletion of the string helpers such as `compareRfc3339Instants` (M4) remain. - Receipt keyset cursors carry microsecond text (`packages/database/src/receipt/cursor.ts`). They stay exact because storage is millisecond; M3 moves them to `Instant`. - `packages/database/runtime/schema-boundary-postgres-proof-main.ts` (`proof:schema-boundary-postgres`, run by no `just` set) failed at `65e98276`, before and after its move to an Effect program, with "New interview conduct requires an explicit recommendation": its seed inserts an interview conduct without the recommendation that migration 0037 requires of every new conduct. @@ -125,7 +134,7 @@ Fix an instance when a change touches it (see [AGENTS.md](AGENTS.md#construction - Hand-written operation ids outside content, hand-written dashboard navigation paths, a fixed admissions `retry-after`, and fixed ports in older browser runners. - Twenty test files of the dashboard and homepage answer API and provider calls with a stubbed global `fetch` (`vi.stubGlobal("fetch", …)`) or a recording fetch, not a contract-served boundary: the `*.test.ts` files that stub `fetch` under `apps/dashboard/app`, `apps/dashboard/test`, and `apps/homepage/test`, and `apps/dashboard/app/foldkit/team-applications/update.test.ts`. - `packages/sdk/src/__tests__/generated-native-client.test.ts` shows the replacement for native API calls: it serves the contract's endpoints with `HttpApiBuilder` and records what the server decoded. + The RPC fakes `apps/dashboard/test/native-rpc.ts` and `apps/homepage/test/native-rpc.ts` answer the wire by hand. The replacement serves the contract's groups with test handler Layers through `RpcTest.makeClient` (`effect/unstable/rpc/RpcTest`), as `makeBackendTestRpc` serves the real handlers for backend tests. - PR previews (operator decision, 2026-09-25): Cloudflare Worker Previews of the homepage and dashboard only, as `vektor-preview-homepage` and `vektor-preview-dashboard`, which `wrangler preview` creates on first use ([contract](docs/specs/worker-pr-previews.md)). Their Cloudflare token (Workers Editor on the two preview Workers only) and account id live in Bitwarden Secrets Manager; `secretspec.toml` profile `preview` gives them to the deploy and delete steps, and GitHub holds only the read-only `BWS_ACCESS_TOKEN`. The parent Workers were created on 2026-09-26 and hold no version. Draft PR #24 proved the path on 2026-09-26: run `36242120400` read both secrets from Bitwarden (US cloud) through secretspec, deployed both previews, passed the document and asset probes, and posted the review comment; closing the PR ran the cleanup, and the Cloudflare API then listed no previews or versions on either Worker. The homepage preview hostname answered 404 at once, but `pr-24-vektor-preview-dashboard` still served the app 7 minutes later. `wrangler preview delete` is an open beta; recheck a closed PR's dashboard hostname and report upstream if it keeps serving. They have no backend, so pages that read the API show the unavailable state. diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 8e68f4efe..e15ea3d4a 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -17,7 +17,6 @@ Each folder of `src` holds a bounded context of [docs/model/contexts.cml](../../ | `content` | Content | [AGENTS.md](src/content/AGENTS.md) | | `delivery` | Delivery | [AGENTS.md](src/delivery/AGENTS.md) | | `directory` | People | [AGENTS.md](src/directory/AGENTS.md) | -| `http-api` | none | [AGENTS.md](src/http-api/AGENTS.md) | | `mail` | Delivery | [AGENTS.md](src/mail/AGENTS.md) | | `onboarding` | Recruitment | [AGENTS.md](src/onboarding/AGENTS.md) | | `organization` | Organization | [AGENTS.md](src/organization/AGENTS.md) | @@ -26,7 +25,7 @@ Each folder of `src` holds a bounded context of [docs/model/contexts.cml](../../ | `profile` | People | [AGENTS.md](src/profile/AGENTS.md) | | `receipt` | Economy | [AGENTS.md](src/receipt/AGENTS.md) | | `recruitment` | Recruitment | [AGENTS.md](src/recruitment/AGENTS.md) | -| `schools` | Schools | [AGENTS.md](src/schools/AGENTS.md) | +| `rpc` | none | [AGENTS.md](src/rpc/AGENTS.md) | | `social-events` | SocialEvents | [AGENTS.md](src/social-events/AGENTS.md) | | `team-application` | TeamApplications | [AGENTS.md](src/team-application/AGENTS.md) | | `test` | none | [AGENTS.md](src/test/AGENTS.md) | diff --git a/apps/backend/package.json b/apps/backend/package.json index 7dcb13d4e..e9168e43c 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -29,7 +29,7 @@ "@effect/platform-bun": "catalog:effect-v4", "@vektorprogrammet/database": "workspace:*", "@vektorprogrammet/domain": "workspace:*", - "@vektorprogrammet/http-api": "workspace:*", + "@vektorprogrammet/rpc": "workspace:*", "effect": "catalog:effect-v4" }, "devDependencies": { diff --git a/apps/backend/src/admission/AGENTS.md b/apps/backend/src/admission/AGENTS.md index f4b1c1474..7dfc00e45 100644 --- a/apps/backend/src/admission/AGENTS.md +++ b/apps/backend/src/admission/AGENTS.md @@ -2,8 +2,8 @@ # apps/backend/src/admission -This folder holds Admissions code under another name. HTTP handlers of admission periods and applications. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +This folder holds Admissions code under another name. RPC handlers of admission periods and applications. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The Admissions context in other folders: @@ -68,10 +68,10 @@ No `exports` entry of [apps/backend/package.json](../../package.json) points int The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../../../docs/constructs.md) indexes them all. -- [`authorizeAdmissionPerson`](../../../../docs/constructs/http-problem.md#authorizeadmissionperson) (http-problem): Evaluates one admission person AccessSpec. -- [`returningAuthorization`](../../../../docs/constructs/http-problem.md#returningauthorization) (http-problem): Resolves the current person and authorizes one returning-assistant operation on that person's own profile. -- [`admissionActorForAuthority`](../../../../docs/constructs/http-problem.md#admissionactorforauthority) (http-problem): The admission actor of one department scope. -- [`admissionProblems`](../../../../docs/constructs/http-problem.md#admissionproblems) (http-problem): The one answer for every admission failure. +- [`authorizeAdmissionPerson`](../../../../docs/constructs/rpc-problem.md#authorizeadmissionperson) (rpc-problem): Evaluates one admission person AccessSpec. +- [`returningAuthorization`](../../../../docs/constructs/rpc-problem.md#returningauthorization) (rpc-problem): Resolves the current person and authorizes one returning-assistant operation on that person's own profile. +- [`admissionActorForAuthority`](../../../../docs/constructs/rpc-problem.md#admissionactorforauthority) (rpc-problem): The admission actor of one department scope. +- [`admissionProblems`](../../../../docs/constructs/rpc-problem.md#admissionproblems) (rpc-problem): The one answer for every admission failure. Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. diff --git a/apps/backend/src/admission/access.ts b/apps/backend/src/admission/access.ts new file mode 100644 index 000000000..ab2d8ffc7 --- /dev/null +++ b/apps/backend/src/admission/access.ts @@ -0,0 +1,192 @@ +/** Admission authorization helpers shared by the read and command handlers. */ +import type { + Database, + IdentitySnapshot, + OAuthCredentialAuthority, +} from "@vektorprogrammet/database"; +import type { + AdmissionPeriodActor, + UnauthenticatedActor, +} from "@vektorprogrammet/domain/admission-period"; +import { ResourceId, ResourceKind, Scope } from "@vektorprogrammet/domain/authz"; +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import type { Organization } from "@vektorprogrammet/domain/organization"; +import { + ReadReturningAssistantOptions, + RegisterReturningAssistant, + reflectAccessSpec, +} from "@vektorprogrammet/rpc"; +import type { Problem } from "@vektorprogrammet/rpc/problem"; +import { Effect, Option, Predicate } from "effect"; +import type { Headers } from "effect/unstable/http"; +import { + type OrganizationResolutionError, + resolveRequestPersonAuthorityInTransaction, + type TransactionPersonAuthority, +} from "../authority.js"; +import { genericContext, type NativePersonAuthorization } from "../native-operation.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { authorizePerson, personPresentation, unreachable } from "../rpc/problem.js"; +import { dual } from "effect/Function"; + +export const admissionGrantScopes = (actor: AdmissionPeriodActor) => + Predicate.isTagged(actor, "GlobalAdmin") + ? ([Scope.Global()] as const) + : Predicate.isTagged(actor, "DepartmentAdministrator") + ? ([Scope.Department({ departmentId: actor.departmentId })] as const) + : []; + +export const returningPersonResource = (personId: string) => + Scope.Resource({ + resource: { + kind: ResourceKind.make("person-profile"), + id: ResourceId.make(personId), + }, + }); + +/** + * Evaluates one admission person AccessSpec. + * + * @remarks + * It runs `authorizePerson` with the credential headers that the request presented, then + * `unreachable("resource.not-found")`: every admission AccessSpec reveals its denials, so + * authorization never answers resource.not-found, and a concealment would be a defect. + * + * @sideEffects none + * + * @example + * ```ts + * yield* authorizeAdmissionPerson(headers, { spec, credential, personId, resolution, grantScopes, now }); + * ``` + * + * @avoid Calling `authorizePerson` in an admission handler and declaring resource.not-found on the + * RPC: admission specs reveal their denials, so that problem never occurs. Call this. + * + * @construct rpc-problem + */ +export const authorizeAdmissionPerson: { + ( + input: NativePersonAuthorization, + ): ( + headers: Headers.Headers, + ) => Effect.Effect< + void, + Problem<"authority.denied"> | Problem<"credential.invalid"> | Problem<"credential.missing"> + >; + ( + headers: Headers.Headers, + input: NativePersonAuthorization, + ): Effect.Effect< + void, + Problem<"authority.denied"> | Problem<"credential.invalid"> | Problem<"credential.missing"> + >; +} = dual( + 2, + ( + headers: Headers.Headers, + input: NativePersonAuthorization, + ): Effect.Effect< + void, + Problem<"authority.denied"> | Problem<"credential.invalid"> | Problem<"credential.missing"> + > => authorizePerson(input, personPresentation(headers)).pipe(unreachable("resource.not-found")), +); + +/** + * Resolves the current person and authorizes one returning-assistant operation on that person's + * own profile. + * + * @remarks + * It resolves the request's person credential and organization authority in the caller's + * transaction at one instant, then evaluates the RPC's AccessSpec for that person over their own + * `person-profile` resource, with the resource as the grant scope. It answers the resolved + * authority, whose instant the command uses for its own reads. + * + * @sideEffects Reads the person's session or token and organization authority in the caller's + * transaction. + * + * @example + * ```ts + * const authorization = yield* returningAuthorization(headers, options, RegisterReturningAssistant); + * ``` + * + * @avoid Authorizing a returning-assistant operation with the actor of an admission period: the + * operation acts on the person's own profile, not on a department. Resolve it with this. + * + * @construct rpc-problem + */ +export const returningAuthorization: { + ( + options: NativeRpcOptions, + rpc: typeof ReadReturningAssistantOptions | typeof RegisterReturningAssistant, + ): ( + headers: Headers.Headers, + ) => Effect.Effect< + TransactionPersonAuthority, + | IdentityEngineError + | UnauthenticatedActor + | OrganizationResolutionError + | Problem<"authority.denied"> + | Problem<"credential.invalid"> + | Problem<"credential.missing">, + Database | Organization | IdentitySnapshot | OAuthCredentialAuthority + >; + ( + headers: Headers.Headers, + options: NativeRpcOptions, + rpc: typeof ReadReturningAssistantOptions | typeof RegisterReturningAssistant, + ): Effect.Effect< + TransactionPersonAuthority, + | IdentityEngineError + | UnauthenticatedActor + | OrganizationResolutionError + | Problem<"authority.denied"> + | Problem<"credential.invalid"> + | Problem<"credential.missing">, + Database | Organization | IdentitySnapshot | OAuthCredentialAuthority + >; +} = dual( + 3, + ( + headers: Headers.Headers, + options: NativeRpcOptions, + rpc: typeof ReadReturningAssistantOptions | typeof RegisterReturningAssistant, + ): Effect.Effect< + TransactionPersonAuthority, + | IdentityEngineError + | UnauthenticatedActor + | OrganizationResolutionError + | Problem<"authority.denied"> + | Problem<"credential.invalid"> + | Problem<"credential.missing">, + Database | Organization | IdentitySnapshot | OAuthCredentialAuthority + > => + Effect.gen(function* () { + const authorization = yield* resolveRequestPersonAuthorityInTransaction( + credentialRequestOf(headers), + { now: options.config.admission.now }, + ); + + yield* authorizeAdmissionPerson(headers, { + spec: Option.getOrThrow(reflectAccessSpec(rpc)), + credential: authorization.credential, + personId: authorization.authority.personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "admissions", + resourceKind: "person-profile", + resourceId: authorization.authority.personId, + facts: { ownerPersonId: authorization.authority.personId }, + authorityVersion: "admissions:returning-assistant", + }), + ], + }, + grantScopes: [returningPersonResource(authorization.authority.personId)], + now: authorization.authorizationInstant, + }); + + return authorization; + }), +); diff --git a/apps/backend/src/admission/admission-period.http.test.ts b/apps/backend/src/admission/admission-period.rpc.test.ts similarity index 60% rename from apps/backend/src/admission/admission-period.http.test.ts rename to apps/backend/src/admission/admission-period.rpc.test.ts index 031adb94d..53ef77844 100644 --- a/apps/backend/src/admission/admission-period.http.test.ts +++ b/apps/backend/src/admission/admission-period.rpc.test.ts @@ -2,27 +2,27 @@ import { Database, IdentitySnapshot, type DatabaseOperations } from "@vektorprog import { AdmissionsLive } from "@vektorprogrammet/database/admissions"; import { OrganizationLive } from "@vektorprogrammet/database/organization"; import { DatabaseRuntimeLive } from "@vektorprogrammet/database/runtime"; +import { AdmissionPeriodId } from "@vektorprogrammet/domain/admission-period"; import { Identity, IdentityActor, IdentitySessionNotFound, type IdentityOperations, } from "@vektorprogrammet/domain/identity"; -import { PersonId } from "@vektorprogrammet/domain/organization"; +import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; +import { SemesterId } from "@vektorprogrammet/domain"; import { - AdmissionPeriodManagementItem, - AdmissionPeriodManagementListResponse, - AdmissionsCreateAdmissionPeriodProblem, - AdmissionsReviseAdmissionPeriodProblem, -} from "@vektorprogrammet/http-api"; -import { makeNativeValidationError } from "@vektorprogrammet/http-api/http-semantics"; -import { DateTime, Deferred, Effect, Fiber, Layer, Schedule, Schema } from "effect"; + IdempotencyKey, + isProblem, + makeNativeValidationError, +} from "@vektorprogrammet/rpc/problem"; +import { DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; import type { SqlError } from "effect/unstable/sql/SqlError"; import { describe, expect, it } from "@effect/vitest"; import { backendDatabase } from "../../test/database.js"; import { decodeBackendConfig } from "../config.js"; -import { jsonText } from "../http-api/problem.js"; -import { makeBackendTestHttp } from "../test/native-http.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; const environment = { BACKEND_PG_URL: "postgres://test.invalid/vektorprogrammet", @@ -76,7 +76,7 @@ const sessionActor = (cookie: string | undefined) => { return person === undefined ? undefined - : new IdentityActor({ + : IdentityActor.make({ personId: PersonId.make(person), sessionId: `session-${person}`, expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), @@ -87,7 +87,9 @@ const identitySnapshot = IdentitySnapshot.of({ resolveSession: (cookie) => { const actor = sessionActor(cookie); - return actor === undefined ? Effect.fail(new IdentitySessionNotFound()) : Effect.succeed(actor); + return actor === undefined + ? Effect.fail(IdentitySessionNotFound.make()) + : Effect.succeed(actor); }, revokeCurrentSession: () => Effect.die("unexpected session mutation"), revokeSession: () => Effect.die("unexpected session mutation"), @@ -100,7 +102,9 @@ const identity = Identity.of({ resolveSession: (cookie: string | undefined) => { const actor = sessionActor(cookie); - return actor === undefined ? Effect.fail(new IdentitySessionNotFound()) : Effect.succeed(actor); + return actor === undefined + ? Effect.fail(IdentitySessionNotFound.make()) + : Effect.succeed(actor); }, readCurrentSession: () => Effect.die("unexpected session read"), listSessions: () => Effect.die("unexpected session list"), @@ -112,11 +116,6 @@ const identity = Identity.of({ signOut: () => Effect.succeed({ setCookies: [] }), } satisfies IdentityOperations); -const unavailableAuthHandler = { - handler: () => Effect.succeed(new Response(null, { status: 404 })), - recordTrustedOriginRejection: () => Effect.void, -}; - /** Requests that queue behind a lock in the fixture database. */ const waitingRequests = (sql: DatabaseOperations, expected: number) => sql<{ readonly waiting: number }>` @@ -132,9 +131,18 @@ const waitingRequests = (sql: DatabaseOperations, expected: number) => }), ); +/** The problem code of a failed call, or `ok`. */ +const codeOf = (exit: Exit.Exit): string => + Exit.isSuccess(exit) + ? "ok" + : Option.match(Exit.findErrorOption(exit), { + onNone: () => "defect", + onSome: (error) => (isProblem(error) ? error.code : "defect"), + }); + /** - * Each request opens its own PostgreSQL connection, as separate backend requests do, - * so two requests can run their transactions at the same time. + * Each request opens its own PostgreSQL connection, as separate backend requests do, so two + * requests can run their transactions at the same time. */ const fixture = () => Effect.gen(function* () { @@ -157,27 +165,21 @@ const fixture = () => const connection = () => DatabaseRuntimeLive({ ...target, maxConnections: 1 }).pipe(Layer.orDie); - const http = makeBackendTestHttp( - yield* decodeBackendConfig(environment), - Layer.mergeAll( + const backend = makeBackendTestRpc({ + config: yield* decodeBackendConfig(environment), + services: Layer.mergeAll( AdmissionsLive, OrganizationLive, Layer.succeed(IdentitySnapshot, identitySnapshot), Layer.succeed(Identity, identity), ).pipe(Layer.provideMerge(connection())), - unavailableAuthHandler, - ); - - const manage = (path: string, init: RequestInit = {}, personId = leaderPersonId) => { - const headers = new Headers(init.headers); - headers.set("cookie", `better-auth.session_token=${personId}`); + }); - if (init.method !== undefined && init.method !== "GET") { - headers.set("origin", "http://127.0.0.1:5174"); - } + const client = yield* backend.client; - return http.fetch(new Request(`http://backend.test${path}`, { ...init, headers })); - }; + /** Runs one call as `personId`, with that person's session cookie. */ + const as = (effect: Effect.Effect, personId = leaderPersonId) => + RpcClient.withHeaders(effect, { cookie: `better-auth.session_token=${personId}` }); /** * Holds `lock` on a separate connection until `waiters` requests queue behind it, then @@ -213,42 +215,29 @@ const fixture = () => ), ); - return { manage, holdUntilQueued, count }; + return { client, as, holdUntilQueued, count }; }); -const decodeStrict = >( - schema: S, - response: Response, -) => - Effect.promise(() => response.json()).pipe( - Effect.flatMap((body) => - Schema.decodeUnknownEffect(schema)(body, { onExcessProperty: "error" }), - ), - ); +const key = (value: string) => IdempotencyKey.make(value.padEnd(22, "0")); -describe("admission period management over HTTP and PostgreSQL", () => { - it.live("answers the loser of a concurrent revision with 412 precondition.failed", () => +describe("admission period management over RPC and PostgreSQL", () => { + it.live("answers the loser of a concurrent revision with precondition.failed", () => Effect.gen(function* () { - const { manage, holdUntilQueued, count } = yield* fixture(); - const list = yield* manage("/api/admission-periods"); - - expect(list.status).toBe(200); - - const listed = yield* decodeStrict(AdmissionPeriodManagementListResponse, list); + const { client, as, holdUntilQueued, count } = yield* fixture(); + const listed = yield* as(client["admissions.listAdmissionPeriods"]()); const current = listed.items.find((item) => item.id === "period-autumn"); if (current === undefined) throw new Error("The seeded period is not listed"); - const revise = (key: string) => - manage("/api/admission-periods/period-autumn", { - method: "PATCH", - headers: { - "content-type": "application/merge-patch+json", - "if-match": current.etag, - "idempotency-key": key.padEnd(22, "0"), - }, - body: JSON.stringify({ endAt: "2031-10-02T20:00:00.000Z" }), - }); + const revise = (idempotencyKey: string) => + as( + client["admissions.reviseAdmissionPeriod"]({ + admissionPeriodId: AdmissionPeriodId.make("period-autumn"), + idempotencyKey: key(idempotencyKey), + ifMatch: current.etag, + request: { endAt: "2031-10-02T20:00:00.000Z" }, + }), + ).pipe(Effect.exit); // Both revisions read revision 0 and pass If-Match before either may lock the row. const { released } = yield* holdUntilQueued( @@ -257,27 +246,20 @@ describe("admission period management over HTTP and PostgreSQL", () => { 2, ); - const responses = yield* Effect.all([revise("reviseRaceA"), revise("reviseRaceB")], { + const exits = yield* Effect.all([revise("reviseRaceA"), revise("reviseRaceB")], { concurrency: "unbounded", }); expect(yield* released).toBe(2); - expect(responses.map((response) => response.status).sort((a, b) => a - b)).toEqual([ - 200, 412, - ]); + expect(exits.map(codeOf).sort()).toEqual(["ok", "precondition.failed"]); - const winner = responses.find((response) => response.status === 200)!; - const loser = responses.find((response) => response.status === 412)!; + const winner = exits.find(Exit.isSuccess); - expect(yield* decodeStrict(AdmissionPeriodManagementItem, winner)).toMatchObject({ + expect(winner?.value).toMatchObject({ id: "period-autumn", endAt: "2031-10-02T20:00:00.000Z", revision: 1, }); - expect(loser.headers.get("content-type")).toBe("application/problem+json"); - expect(yield* decodeStrict(AdmissionsReviseAdmissionPeriodProblem, loser)).toMatchObject({ - code: "precondition.failed", - }); expect( yield* count( "SELECT revision AS count FROM admission_periods WHERE admission_period_id = 'period-autumn'", @@ -292,24 +274,22 @@ describe("admission period management over HTTP and PostgreSQL", () => { }), ); - it.live("answers the loser of a concurrent create with 409 admission-period.already-exists", () => + it.live("answers the loser of a concurrent create with admission-period.already-exists", () => Effect.gen(function* () { - const { manage, holdUntilQueued, count } = yield* fixture(); - - const create = (key: string) => - manage("/api/admission-periods", { - method: "POST", - headers: { - "content-type": "application/json", - "idempotency-key": key.padEnd(22, "0"), - }, - body: JSON.stringify({ - semesterId: "semester-spring", - startAt: "2032-01-10T08:00:00.000Z", - endAt: "2032-02-01T20:00:00.000Z", - departmentId, + const { client, as, holdUntilQueued, count } = yield* fixture(); + + const create = (idempotencyKey: string) => + as( + client["admissions.createAdmissionPeriod"]({ + idempotencyKey: key(idempotencyKey), + request: { + semesterId: SemesterId.make("semester-spring"), + startAt: "2032-01-10T08:00:00.000Z", + endAt: "2032-02-01T20:00:00.000Z", + departmentId: DepartmentId.make(departmentId), + }, }), - }); + ).pipe(Effect.exit); // Both creates read the spring semester without a period before either may write one. const { released } = yield* holdUntilQueued( @@ -317,25 +297,16 @@ describe("admission period management over HTTP and PostgreSQL", () => { 2, ); - const responses = yield* Effect.all([create("createRaceA"), create("createRaceB")], { + const exits = yield* Effect.all([create("createRaceA"), create("createRaceB")], { concurrency: "unbounded", }); expect(yield* released).toBe(2); - expect(responses.map((response) => response.status).sort((a, b) => a - b)).toEqual([ - 201, 409, - ]); - - const winner = responses.find((response) => response.status === 201)!; - const loser = responses.find((response) => response.status === 409)!; - - expect(yield* decodeStrict(AdmissionPeriodManagementItem, winner)).toMatchObject({ + expect(exits.map(codeOf).sort()).toEqual(["admission-period.already-exists", "ok"]); + expect(exits.find(Exit.isSuccess)?.value).toMatchObject({ departmentId, semesterId: "semester-spring", }); - expect(yield* decodeStrict(AdmissionsCreateAdmissionPeriodProblem, loser)).toMatchObject({ - code: "admission-period.already-exists", - }); expect( yield* count( "SELECT count(*)::integer AS count FROM admission_periods WHERE semester_id = 'semester-spring'", @@ -344,71 +315,66 @@ describe("admission period management over HTTP and PostgreSQL", () => { }), ); - it.live("names the rejected members of a malformed create and patch", () => + it.live("replays a create by its idempotency key with the first answer", () => Effect.gen(function* () { - const { manage, count } = yield* fixture(); + const { client, as, count } = yield* fixture(); - const listed = yield* decodeStrict( - AdmissionPeriodManagementListResponse, - yield* manage("/api/admission-periods"), + const create = as( + client["admissions.createAdmissionPeriod"]({ + idempotencyKey: key("replayedCreate"), + request: { + semesterId: SemesterId.make("semester-spring"), + startAt: "2032-01-10T08:00:00.000Z", + endAt: "2032-02-01T20:00:00.000Z", + departmentId: DepartmentId.make(departmentId), + }, + }), ); + const first = yield* create; + const replayed = yield* create; + + expect(replayed).toEqual(first); + expect( + yield* count( + "SELECT count(*)::integer AS count FROM admission_periods WHERE semester_id = 'semester-spring'", + ), + ).toBe(1); + }), + ); + + it.live("answers an empty and a deleting patch with their merge-patch problems", () => + Effect.gen(function* () { + const { client, as, count } = yield* fixture(); + const listed = yield* as(client["admissions.listAdmissionPeriods"]()); const current = listed.items.find((item) => item.id === "period-autumn"); if (current === undefined) throw new Error("The seeded period is not listed"); - const created = yield* manage("/api/admission-periods", { - method: "POST", - headers: { - "content-type": "application/json", - "idempotency-key": "malformedCreate".padEnd(22, "0"), - }, - body: yield* jsonText({ - semesterId: "semester-spring", - startAt: "2032-01-10T08:00:00.000Z", - endAt: "2032-02-01T20:00:00.000Z", - departmentId, - color: "blue", - }), - }); - - const patch = (key: string, body: string) => - manage("/api/admission-periods/period-autumn", { - method: "PATCH", - headers: { - "content-type": "application/merge-patch+json", - "if-match": current.etag, - "idempotency-key": key.padEnd(22, "0"), - }, - body, - }); + const patch = (idempotencyKey: string, request: { readonly endAt?: string | null }) => + as( + client["admissions.reviseAdmissionPeriod"]({ + admissionPeriodId: AdmissionPeriodId.make("period-autumn"), + idempotencyKey: key(idempotencyKey), + ifMatch: current.etag, + request, + }), + ).pipe(Effect.flip); - const empty = yield* patch("emptyPatch", "{}"); - const deleted = yield* patch("deletingPatch", yield* jsonText({ endAt: null })); - const invalid = yield* patch("invalidPatch", yield* jsonText({ endAt: "soon" })); + const empty = yield* patch("emptyPatch", {}); + const deleted = yield* patch("deletingPatch", { endAt: null }); - expect([created.status, empty.status, deleted.status, invalid.status]).toEqual([ - 422, 422, 422, 422, - ]); - expect(yield* decodeStrict(AdmissionsCreateAdmissionPeriodProblem, created)).toMatchObject({ - code: "validation.failed", - validation: { errors: [makeNativeValidationError("/color", "unknown")], truncated: false }, - }); - expect(yield* decodeStrict(AdmissionsReviseAdmissionPeriodProblem, empty)).toMatchObject({ + expect(empty).toMatchObject({ code: "validation.no-change", validation: { errors: [makeNativeValidationError("", "no-change")], truncated: false }, }); - expect(yield* decodeStrict(AdmissionsReviseAdmissionPeriodProblem, deleted)).toMatchObject({ + expect(deleted).toMatchObject({ code: "validation.field-not-deletable", validation: { errors: [makeNativeValidationError("/endAt", "field-not-deletable")], truncated: false, }, }); - expect(yield* decodeStrict(AdmissionsReviseAdmissionPeriodProblem, invalid)).toMatchObject({ - code: "validation.failed", - validation: { errors: [makeNativeValidationError("/endAt", "invalid")], truncated: false }, - }); expect( yield* count("SELECT count(*)::integer AS count FROM admission_period_command_receipts"), ).toBe(0); @@ -417,25 +383,20 @@ describe("admission period management over HTTP and PostgreSQL", () => { it.live("names the semester or department that a create cannot resolve", () => Effect.gen(function* () { - const { manage, count } = yield* fixture(); - - const create = (key: string, semesterId: string, personId?: string) => - manage( - "/api/admission-periods", - { - method: "POST", - headers: { - "content-type": "application/json", - "idempotency-key": key.padEnd(22, "0"), - }, - body: JSON.stringify({ - semesterId, + const { client, as, count } = yield* fixture(); + + const create = (idempotencyKey: string, semesterId: string, personId?: string) => + as( + client["admissions.createAdmissionPeriod"]({ + idempotencyKey: key(idempotencyKey), + request: { + semesterId: SemesterId.make(semesterId), startAt: "2032-01-10T08:00:00.000Z", endAt: "2032-02-01T20:00:00.000Z", - }), - }, + }, + }), personId, - ); + ).pipe(Effect.flip); const unknownSemester = yield* create("unknownSemester", "semester-unknown"); @@ -446,19 +407,14 @@ describe("admission period management over HTTP and PostgreSQL", () => { adminPersonId, ); - expect([unknownSemester.status, withoutDepartment.status]).toEqual([422, 422]); - expect( - yield* decodeStrict(AdmissionsCreateAdmissionPeriodProblem, unknownSemester), - ).toMatchObject({ + expect(unknownSemester).toMatchObject({ code: "validation.failed", validation: { errors: [makeNativeValidationError("/semesterId", "invalid")], truncated: false, }, }); - expect( - yield* decodeStrict(AdmissionsCreateAdmissionPeriodProblem, withoutDepartment), - ).toMatchObject({ + expect(withoutDepartment).toMatchObject({ code: "validation.failed", validation: { errors: [makeNativeValidationError("/departmentId", "missing")], @@ -470,4 +426,17 @@ describe("admission period management over HTTP and PostgreSQL", () => { ).toBe(0); }), ); + + it.live("denies a person without admission authority", () => + Effect.gen(function* () { + const { client, as } = yield* fixture(); + + const denied = yield* as( + client["admissions.listAdmissionPeriods"](), + "periods-stranger", + ).pipe(Effect.flip); + + expect(isProblem(denied) && denied.code).toBe("authority.denied"); + }), + ); }); diff --git a/apps/backend/src/admission/commands.ts b/apps/backend/src/admission/commands.ts new file mode 100644 index 000000000..077df9b21 --- /dev/null +++ b/apps/backend/src/admission/commands.ts @@ -0,0 +1,556 @@ +/** Admission command handlers: returning assistants, admission periods, and public applications. */ +import { + AdmissionPeriodCommandId, + AdmissionPeriodCommandSchema, + AdmissionPeriodNotFound, + type AdmissionPeriodId, +} from "@vektorprogrammet/domain/admission-period"; +import { Admissions } from "@vektorprogrammet/domain/admissions"; +import { + PublicApplicationCommandIdSchema, + PublicApplicationRateLimitExceeded, + ReturningAssistantRegistrationResponseSchema, + ReturningAssistants, + ReturningCommandIdSchema, + type ReturningAssistantRegistrationInput, +} from "@vektorprogrammet/domain/application"; +import { + AdmissionPeriodManagementItem, + type AdmissionPeriodMergePatch, + CreateAdmissionPeriod, + type CreateAdmissionPeriodRequest, + PublicApplicationConfirmationSchema, + RegisterReturningAssistant, + ReviseAdmissionPeriod, + SubmitApplication, + type SubmitApplicationRequest, + reflectAccessSpec, +} from "@vektorprogrammet/rpc"; +import { + type IdempotencyKey, + makeNativeValidationError, + Problem, + type StrongETag, +} from "@vektorprogrammet/rpc/problem"; +import { Clock, DateTime, Effect, Option, Predicate } from "effect"; +import type { Headers } from "effect/unstable/http"; +import { currentInstant, resolveRequestPersonAuthorityInTransaction } from "../authority.js"; +import { + deriveStrongETag, + interpretAdmissionPeriodMergePatchSource, + normalizeTarget, + semanticMutationRequest, + semanticRequestDigest, +} from "../http-semantics.js"; +import { genericContext } from "../native-operation.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { + authorizeAnonymous, + commandIdentity, + commandOutcome, + commandReceiptProblems, + requireCurrentETag, + semanticProblem, + strictOutput, + unreachable, +} from "../rpc/problem.js"; +import { publicRateLimitKey } from "../rpc/public-rate-limit.js"; +import { executeNativeHttpCommandPostgres, successCapsule } from "../rpc/receipt-transaction.js"; +import { + admissionGrantScopes, + authorizeAdmissionPerson, + returningAuthorization, +} from "./access.js"; +import { admissionActorForAuthority } from "./context.js"; +import { admissionProblems } from "./problem.js"; +import { dual } from "effect/Function"; + +/** The management representation of one admission period, with the tag that a revision names. */ +type ManagementItem = typeof AdmissionPeriodManagementItem.Type; + +const managementItem = (period: Omit): ManagementItem => ({ + id: period.id, + departmentId: period.departmentId, + semesterId: period.semesterId, + startAt: period.startAt, + endAt: period.endAt, + revision: period.revision, + etag: deriveStrongETag({ + representationKind: "AdmissionPeriodManagementItem", + resourceIdentity: period.id, + version: period.revision, + }), +}); + +const registerReturningAssistantEffect = ( + headers: Headers.Headers, + payload: { + readonly idempotencyKey: IdempotencyKey; + readonly request: ReturningAssistantRegistrationInput; + }, + options: NativeRpcOptions, +) => + Effect.gen(function* () { + const { idempotencyKey, request } = payload; + const operationId = "admissions.registerReturningAssistant"; + + // Domain failures are mapped after the executor, whose retry reads their causes. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const clock = yield* Clock.Clock; + + // Returning-assistant persistence reads the instant after it takes its locks. + const now = + options.config.admission.now ?? + (() => DateTime.formatIso(DateTime.makeUnsafe(clock.currentTimeMillisUnsafe()))); + + const authorization = yield* returningAuthorization( + headers, + options, + RegisterReturningAssistant, + ); + + yield* ReturningAssistants.use(({ preflight }) => + preflight( + { + admissionPeriodId: request.admissionPeriodId, + teamIds: request.teamIds, + }, + { + personId: authorization.authority.personId, + now, + }, + ), + ); + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: `Person:${authorization.authority.personId}`, + qualifiedOperationId: operationId, + normalizedTarget: "/api/returning-assistant/registrations", + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest({ body: request }), + operationId, + }, + execute: ReturningAssistants.use((returning) => + returning.register( + { ...request, commandId: ReturningCommandIdSchema.make(identity.commandId) }, + { personId: authorization.authority.personId, now }, + ), + ).pipe( + Effect.flatMap(strictOutput(ReturningAssistantRegistrationResponseSchema)), + Effect.flatMap(successCapsule(ReturningAssistantRegistrationResponseSchema)), + ), + }; + }), + { retry: "serialization-once" }, + ); + + return yield* commandOutcome(ReturningAssistantRegistrationResponseSchema)(outcome); + }).pipe(admissionProblems(headers, "returning.unavailable"), commandReceiptProblems); + +export const registerReturningAssistant: { + ( + payload: { + readonly idempotencyKey: IdempotencyKey; + readonly request: ReturningAssistantRegistrationInput; + }, + options: NativeRpcOptions, + ): (headers: Headers.Headers) => ReturnType; + ( + headers: Headers.Headers, + payload: { + readonly idempotencyKey: IdempotencyKey; + readonly request: ReturningAssistantRegistrationInput; + }, + options: NativeRpcOptions, + ): ReturnType; +} = dual(3, registerReturningAssistantEffect); + +const createAdmissionPeriodEffect = ( + headers: Headers.Headers, + payload: { + readonly idempotencyKey: IdempotencyKey; + readonly request: CreateAdmissionPeriodRequest; + }, + options: NativeRpcOptions, +) => + Effect.gen(function* () { + const { idempotencyKey, request } = payload; + const admissionPeriodId = options.config.admission.nextAdmissionPeriodId(); + const operationId = "admissions.createAdmissionPeriod"; + + // Domain failures are mapped after the executor, whose retry reads their causes. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const authorization = yield* resolveRequestPersonAuthorityInTransaction( + credentialRequestOf(headers), + { now: options.config.admission.now }, + ); + + const actor = yield* admissionActorForAuthority( + authorization.authority, + request.departmentId, + ); + + const now = authorization.authorizationInstant; + + yield* authorizeAdmissionPerson(headers, { + spec: Option.getOrThrow(reflectAccessSpec(CreateAdmissionPeriod)), + credential: authorization.credential, + personId: actor.personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "admissions", + departmentId: Predicate.isTagged(actor, "DepartmentAdministrator") + ? actor.departmentId + : (request.departmentId ?? null), + resourceKind: "admission-period", + resourceId: admissionPeriodId, + authorityVersion: `admissions:${actor._tag}`, + }), + ], + }, + grantScopes: admissionGrantScopes(actor), + now, + }); + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: `Person:${actor.personId}`, + qualifiedOperationId: operationId, + normalizedTarget: "/api/admission-periods", + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest({ body: request }), + operationId, + }, + execute: Admissions.use((admissions) => + admissions.executeAdmissionPeriod( + AdmissionPeriodCommandSchema.cases.CreateAdmissionPeriod.make({ + commandId: AdmissionPeriodCommandId.make(identity.commandId), + ...request, + }), + { actor, now, admissionPeriodId }, + ), + ).pipe( + Effect.map((created) => managementItem(created.period)), + Effect.flatMap(strictOutput(AdmissionPeriodManagementItem)), + Effect.flatMap(successCapsule(AdmissionPeriodManagementItem)), + ), + }; + }), + // A concurrent create for the same department and semester commits after this + // snapshot; the restarted transaction sees it and answers that the period exists. + { retry: "serialization-once" }, + ); + + return yield* commandOutcome(AdmissionPeriodManagementItem)(outcome); + }).pipe( + admissionProblems(headers, "dependency.unavailable"), + commandReceiptProblems, + // A create looks up no period by id, and the period it writes has no earlier revision. + unreachable("admission-period.not-found", "precondition.failed"), + ); + +export const createAdmissionPeriod: { + ( + payload: { + readonly idempotencyKey: IdempotencyKey; + readonly request: CreateAdmissionPeriodRequest; + }, + options: NativeRpcOptions, + ): (headers: Headers.Headers) => ReturnType; + ( + headers: Headers.Headers, + payload: { + readonly idempotencyKey: IdempotencyKey; + readonly request: CreateAdmissionPeriodRequest; + }, + options: NativeRpcOptions, + ): ReturnType; +} = dual(3, createAdmissionPeriodEffect); + +/** + * Judges the merge patch before the command: an empty patch changes nothing, and a null member + * would delete a field that an admission period requires. + */ +const interpretPatch = (patch: AdmissionPeriodMergePatch) => + Effect.gen(function* () { + // The payload schema decoded JSON, so each present member holds a string or null. + const source: { [member: string]: string | null } = {}; + + if (patch.startAt !== undefined) source.startAt = patch.startAt; + + if (patch.endAt !== undefined) source.endAt = patch.endAt; + + const interpretation = yield* Effect.sync(() => + interpretAdmissionPeriodMergePatchSource(source), + ); + + if (Predicate.isTagged(interpretation, "Rejected")) { + return yield* Problem.validation(interpretation.code, interpretation.errors); + } + + return { source, startAt: patch.startAt ?? undefined, endAt: patch.endAt ?? undefined }; + }); + +const reviseAdmissionPeriodEffect = ( + headers: Headers.Headers, + payload: { + readonly admissionPeriodId: AdmissionPeriodId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; + readonly request: AdmissionPeriodMergePatch; + }, + options: NativeRpcOptions, +) => + Effect.gen(function* () { + const { admissionPeriodId, idempotencyKey, ifMatch } = payload; + + // An identifier that no path can spell names no admission period. + const normalizedTarget = yield* semanticProblem( + () => normalizeTarget("/api/admission-periods/{admissionPeriodId}", { admissionPeriodId }), + ["request.malformed"], + ).pipe( + Effect.mapError(() => + Problem.validation("validation.failed", [ + makeNativeValidationError("/admissionPeriodId", "invalid"), + ]), + ), + ); + + const patch = yield* interpretPatch(payload.request); + const operationId = "admissions.reviseAdmissionPeriod"; + + // Domain failures are mapped after the executor, whose retry reads their causes. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const authorization = yield* resolveRequestPersonAuthorityInTransaction( + credentialRequestOf(headers), + { now: options.config.admission.now }, + ); + + const actor = yield* admissionActorForAuthority(authorization.authority); + const now = authorization.authorizationInstant; + + const periods = yield* Admissions.use(({ listAdmissionPeriodsForManagement }) => + listAdmissionPeriodsForManagement({ actor, now }), + ); + + const current = periods.find((period) => period.id === admissionPeriodId); + + if (current === undefined) { + return yield* AdmissionPeriodNotFound.make({ admissionPeriodId }); + } + + yield* authorizeAdmissionPerson(headers, { + spec: Option.getOrThrow(reflectAccessSpec(ReviseAdmissionPeriod)), + credential: authorization.credential, + personId: actor.personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "admissions", + departmentId: current.departmentId, + resourceKind: "admission-period", + resourceId: current.id, + authorityVersion: `admissions:${actor._tag}`, + }), + ], + }, + grantScopes: admissionGrantScopes(actor), + now, + }); + + yield* requireCurrentETag(managementItem(current).etag, ifMatch); + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: `Person:${actor.personId}`, + qualifiedOperationId: operationId, + normalizedTarget, + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest(semanticMutationRequest(patch.source, ifMatch)), + operationId, + }, + execute: Admissions.use((admissions) => + admissions.executeAdmissionPeriod( + AdmissionPeriodCommandSchema.cases.ReviseAdmissionPeriod.make({ + commandId: AdmissionPeriodCommandId.make(identity.commandId), + admissionPeriodId, + expectedRevision: current.revision, + startAt: patch.startAt ?? current.startAt, + endAt: patch.endAt ?? current.endAt, + }), + { actor, now, admissionPeriodId }, + ), + ).pipe( + Effect.map((revised) => managementItem(revised.period)), + Effect.flatMap(strictOutput(AdmissionPeriodManagementItem)), + Effect.flatMap(successCapsule(AdmissionPeriodManagementItem)), + ), + }; + }), + // A concurrent revision commits after this snapshot; the restarted transaction reads + // the new revision, so the stale If-Match answers precondition.failed. + { retry: "serialization-once" }, + ); + + return yield* commandOutcome(AdmissionPeriodManagementItem)(outcome); + }).pipe( + admissionProblems(headers, "dependency.unavailable"), + commandReceiptProblems, + // A revision never creates a period. + unreachable("admission-period.already-exists"), + ); + +export const reviseAdmissionPeriod: { + ( + payload: { + readonly admissionPeriodId: AdmissionPeriodId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; + readonly request: AdmissionPeriodMergePatch; + }, + options: NativeRpcOptions, + ): (headers: Headers.Headers) => ReturnType; + ( + headers: Headers.Headers, + payload: { + readonly admissionPeriodId: AdmissionPeriodId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; + readonly request: AdmissionPeriodMergePatch; + }, + options: NativeRpcOptions, + ): ReturnType; +} = dual(3, reviseAdmissionPeriodEffect); + +const submitApplicationEffect = ( + headers: Headers.Headers, + payload: { + readonly idempotencyKey: IdempotencyKey; + readonly request: SubmitApplicationRequest; + }, + options: NativeRpcOptions, +) => + Effect.gen(function* () { + const { idempotencyKey, request } = payload; + const config = options.config.admission; + const now = yield* currentInstant(config.now); + + if (!config.rateLimit.consume(publicRateLimitKey(credentialRequestOf(headers)), now)) { + return yield* PublicApplicationRateLimitExceeded.make({}); + } + + const operationId = "admissions.submitApplication"; + + // Domain failures are mapped after the executor, whose retry reads their causes. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + yield* authorizeAnonymous( + Option.getOrThrow(reflectAccessSpec(SubmitApplication)), + { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "admissions", + authorityVersion: `admissions-application-create:${now}`, + }), + ], + }, + now, + ); + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: "Anonymous", + qualifiedOperationId: operationId, + normalizedTarget: "/api/applications", + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest({ body: request }), + operationId, + }, + execute: Admissions.use((admissions) => + admissions.executePublicApplication( + { + commandId: PublicApplicationCommandIdSchema.make(identity.commandId), + ...request, + }, + { + now, + applicationId: config.nextApplicationId(), + applicantId: config.nextApplicantId(), + activationToken: config.nextActivationToken(), + }, + ), + ).pipe( + Effect.map((submitted) => + PublicApplicationConfirmationSchema.make({ + applicationId: submitted.observation.applicationId, + }), + ), + Effect.flatMap(successCapsule(PublicApplicationConfirmationSchema)), + ), + }; + }), + // A concurrent submission for the same normalized email commits after this snapshot; + // the restarted transaction sees it and answers the duplicate instead of a write failure. + { + retry: "serialization-or-unique-once", + retryUniqueConstraints: [ + "admission_applicants_normalized_email_key", + "native_http_idempotency_receipts_pkey", + ], + }, + ); + + return yield* commandOutcome(PublicApplicationConfirmationSchema)(outcome); + }).pipe( + admissionProblems(headers, "dependency.unavailable"), + commandReceiptProblems, + // A submission looks up no confirmation, and the payload schema already bounded the request. + unreachable("application.not-found", "request.too-large"), + ); + +export const submitApplication: { + ( + payload: { + readonly idempotencyKey: IdempotencyKey; + readonly request: SubmitApplicationRequest; + }, + options: NativeRpcOptions, + ): (headers: Headers.Headers) => ReturnType; + ( + headers: Headers.Headers, + payload: { + readonly idempotencyKey: IdempotencyKey; + readonly request: SubmitApplicationRequest; + }, + options: NativeRpcOptions, + ): ReturnType; +} = dual(3, submitApplicationEffect); diff --git a/apps/backend/src/admission/config.ts b/apps/backend/src/admission/config.ts index c08b54be6..f4a739443 100644 --- a/apps/backend/src/admission/config.ts +++ b/apps/backend/src/admission/config.ts @@ -10,7 +10,7 @@ import { type PublicApplicationId, } from "@vektorprogrammet/domain/application"; import { AdmissionPeriodId as AdmissionPeriodIdSchema } from "@vektorprogrammet/domain/admission-period"; -import { publicRateLimit, type PublicRateLimit } from "../http-api/public-rate-limit.js"; +import { publicRateLimit, type PublicRateLimit } from "../rpc/public-rate-limit.js"; export interface AdmissionApiConfig { readonly maxBodyBytes: number; diff --git a/apps/backend/src/admission/context.ts b/apps/backend/src/admission/context.ts new file mode 100644 index 000000000..6f6713989 --- /dev/null +++ b/apps/backend/src/admission/context.ts @@ -0,0 +1,81 @@ +/** Admission actors: the department-scoped actor of one person's organization authority. */ +import { + AdmissionRoleDenied, + AdmissionScopeDenied, + InactiveActor, + type AdmissionPeriodActor, +} from "@vektorprogrammet/domain/admission-period"; +import { + DepartmentId, + type OrganizationPersonAuthority, +} from "@vektorprogrammet/domain/organization"; +import { Effect, Predicate, Schema } from "effect"; +import { admissionActorForDepartment, unscopedAdmissionActorFrom } from "../authority.js"; +import { dual } from "effect/Function"; + +export const requireActive = (actor: AdmissionPeriodActor) => + actor.active + ? Effect.succeed(actor) + : Effect.fail(InactiveActor.make({ personId: actor.personId })); + +const isAdmissionActorDenial = Schema.is( + Schema.Union([InactiveActor, AdmissionScopeDenied, AdmissionRoleDenied]), +); + +/** + * The admission actor of one department scope. + * + * @remarks + * It maps the organization authority of a person to the admission actor of `departmentScope`, + * or to the person's own scope when none is given, and requires that actor to be active. The + * mapping throws only its three denials, which become failures; anything else that it throws is a + * defect. + * + * @sideEffects none + * + * @example + * ```ts + * const actor = yield* admissionActorForAuthority(authorization.authority, request.departmentId); + * ``` + * + * @avoid Mapping the authority with `admissionActorForDepartment` in a handler: its denials are + * throws, which escape the error channel as defects. Map it with this. + * + * @construct rpc-problem + */ +export const admissionActorForAuthority: { + ( + authority: OrganizationPersonAuthority, + departmentScope?: string, + ): Effect.Effect< + AdmissionPeriodActor, + AdmissionRoleDenied | AdmissionScopeDenied | InactiveActor + >; + ( + departmentScope?: string, + ): ( + authority: OrganizationPersonAuthority, + ) => Effect.Effect< + AdmissionPeriodActor, + AdmissionRoleDenied | AdmissionScopeDenied | InactiveActor + >; +} = dual( + (args) => Predicate.isObject(args[0]), + ( + authority: OrganizationPersonAuthority, + departmentScope?: string, + ): Effect.Effect< + AdmissionPeriodActor, + AdmissionRoleDenied | AdmissionScopeDenied | InactiveActor + > => + Effect.try({ + try: () => + departmentScope === undefined + ? unscopedAdmissionActorFrom(authority) + : admissionActorForDepartment(authority, DepartmentId.make(departmentScope)), + catch: (cause) => { + if (isAdmissionActorDenial(cause)) return cause; + throw cause; + }, + }).pipe(Effect.flatMap(requireActive)), +); diff --git a/apps/backend/src/admission/http-access.ts b/apps/backend/src/admission/http-access.ts deleted file mode 100644 index 225c28432..000000000 --- a/apps/backend/src/admission/http-access.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** Admission authorization helpers shared by read and command handlers. */ -import type { - Database, - IdentitySnapshot, - OAuthCredentialAuthority, -} from "@vektorprogrammet/database"; -import type { - AdmissionPeriodActor, - UnauthenticatedActor, -} from "@vektorprogrammet/domain/admission-period"; -import { ResourceId, ResourceKind, Scope } from "@vektorprogrammet/domain/authz"; -import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import type { Organization } from "@vektorprogrammet/domain/organization"; -import { - ReadReturningAssistantOptionsEndpoint, - RegisterReturningAssistantEndpoint, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import type { Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Option, Predicate } from "effect"; -import { - type OrganizationResolutionError, - resolveRequestPersonAuthorityInTransaction, - type TransactionPersonAuthority, -} from "../authority.js"; -import { authorizePerson, personPresentation, unreachable } from "../http-api/problem.js"; -import { genericContext, type NativePersonAuthorization } from "../native-operation.js"; -import type { AdmissionApiHttpOptions } from "./http-context.js"; - -export const admissionGrantScopes = (actor: AdmissionPeriodActor) => - Predicate.isTagged(actor, "GlobalAdmin") - ? ([Scope.Global()] as const) - : Predicate.isTagged(actor, "DepartmentAdministrator") - ? ([Scope.Department({ departmentId: actor.departmentId })] as const) - : []; - -export const returningPersonResource = (personId: string) => - Scope.Resource({ - resource: { - kind: ResourceKind.make("person-profile"), - id: ResourceId.make(personId), - }, - }); - -/** - * Evaluates one admission person AccessSpec. - * - * @remarks - * It runs `authorizePerson` with the credential that the request presented, then - * `unreachable("resource.not-found")`: every admission AccessSpec reveals its denials, so - * authorization never answers 404, and a concealment would be a defect. - * - * @sideEffects none - * - * @example - * ```ts - * yield* authorizeAdmissionPerson(request, { spec, credential, personId, resolution, grantScopes, now }); - * ``` - * - * @avoid Calling `authorizePerson` in an admission handler and declaring resource.not-found on the - * endpoint: admission specs reveal their denials, so that problem never occurs. Call this. - * - * @construct http-problem - */ -export const authorizeAdmissionPerson = ( - request: Request, - input: NativePersonAuthorization, -): Effect.Effect< - void, - Problem<"authority.denied"> | Problem<"credential.invalid"> | Problem<"credential.missing"> -> => authorizePerson(input, personPresentation(request)).pipe(unreachable("resource.not-found")); - -/** - * Resolves the current person and authorizes one returning-assistant operation on that person's - * own profile. - * - * @remarks - * It resolves the request's person credential and organization authority in the caller's - * transaction at one instant, then evaluates the endpoint's AccessSpec for that person over their - * own `person-profile` resource, with the resource as the grant scope. It answers the resolved - * authority, whose instant the command uses for its own reads. - * - * @sideEffects Reads the person's session or token and organization authority in the caller's - * transaction. - * - * @example - * ```ts - * const authorization = yield* returningAuthorization(request, input, RegisterReturningAssistantEndpoint); - * ``` - * - * @avoid Authorizing a returning-assistant operation with the actor of an admission period: the - * operation acts on the person's own profile, not on a department. Resolve it with this. - * - * @construct http-problem - */ -export const returningAuthorization = ( - request: Request, - input: AdmissionApiHttpOptions, - endpoint: - | typeof ReadReturningAssistantOptionsEndpoint - | typeof RegisterReturningAssistantEndpoint, -): Effect.Effect< - TransactionPersonAuthority, - | IdentityEngineError - | UnauthenticatedActor - | OrganizationResolutionError - | Problem<"authority.denied"> - | Problem<"credential.invalid"> - | Problem<"credential.missing">, - Database | Organization | IdentitySnapshot | OAuthCredentialAuthority -> => - Effect.gen(function* () { - const authorization = yield* resolveRequestPersonAuthorityInTransaction(request, { - now: input.config.now, - }); - - yield* authorizeAdmissionPerson(request, { - spec: Option.getOrThrow(reflectAccessSpec(endpoint)), - credential: authorization.credential, - personId: authorization.authority.personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "admissions", - resourceKind: "person-profile", - resourceId: authorization.authority.personId, - facts: { ownerPersonId: authorization.authority.personId }, - authorityVersion: "admissions:returning-assistant", - }), - ], - }, - grantScopes: [returningPersonResource(authorization.authority.personId)], - now: authorization.authorizationInstant, - }); - - return authorization; - }); diff --git a/apps/backend/src/admission/http-commands.ts b/apps/backend/src/admission/http-commands.ts deleted file mode 100644 index b00a4dbb4..000000000 --- a/apps/backend/src/admission/http-commands.ts +++ /dev/null @@ -1,504 +0,0 @@ -/** Admission command handlers: returning assistants, admission periods, and public applications. */ -import { - AdmissionPeriodCommandId, - AdmissionPeriodCommandSchema, - AdmissionPeriodNotFound, - type AdmissionPeriodId, -} from "@vektorprogrammet/domain/admission-period"; -import { Admissions } from "@vektorprogrammet/domain/admissions"; -import { - PublicApplicationCommandIdSchema, - PublicApplicationRateLimitExceeded, - ReturningAssistantRegistrationInputSchema, - ReturningAssistants, - ReturningCommandIdSchema, -} from "@vektorprogrammet/domain/application"; -import { - CreateAdmissionPeriodEndpoint, - CreateAdmissionPeriodRequest, - RegisterReturningAssistantEndpoint, - ReviseAdmissionPeriodEndpoint, - SubmitApplicationEndpoint, - SubmitApplicationRequest, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { Clock, DateTime, Effect, Option, Predicate } from "effect"; -import { currentInstant, resolveRequestPersonAuthorityInTransaction } from "../authority.js"; -import { - authorizeAnonymous, - commandOutcomeResponse, - commandReceiptProblems, - httpIdentity, - idempotencyKeyOf, - requireCurrentETag, - requiredIfMatchOf, - requireNoQuery, - semanticProblem, - unreachable, -} from "../http-api/problem.js"; -import { publicRateLimitKey } from "../http-api/public-rate-limit.js"; -import { executeNativeHttpCommandPostgres } from "../http-api/receipt-transaction.js"; -import { - deriveStrongETag, - encodePathIdentity, - jsonBodyBytes, - normalizeTarget, - semanticMutationRequest, - semanticRequestDigest, -} from "../http-semantics.js"; -import { genericContext } from "../native-operation.js"; -import { - admissionGrantScopes, - authorizeAdmissionPerson, - returningAuthorization, -} from "./http-access.js"; -import { admissionActorForAuthority, type AdmissionApiHttpOptions } from "./http-context.js"; -import { decodeAdmissionPeriodPatch, decodeJson } from "./http-decode.js"; -import { admissionProblems } from "./http-problem.js"; - -export const registerReturningAssistant = (request: Request, input: AdmissionApiHttpOptions) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - const payload = yield* decodeJson( - request, - ReturningAssistantRegistrationInputSchema, - input.config.maxBodyBytes, - ); - - const idempotencyKey = yield* idempotencyKeyOf(request); - - const operationId = "admissions.registerReturningAssistant"; - - // Domain failures are mapped after the executor, whose retry reads their causes. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const clock = yield* Clock.Clock; - - // Returning-assistant persistence reads the instant after it takes its locks. - const now = - input.config.now ?? - (() => DateTime.formatIso(DateTime.makeUnsafe(clock.currentTimeMillisUnsafe()))); - - const authorization = yield* returningAuthorization( - request, - input, - RegisterReturningAssistantEndpoint, - ); - - yield* ReturningAssistants.use(({ preflight }) => - preflight( - { - admissionPeriodId: payload.admissionPeriodId, - teamIds: payload.teamIds, - }, - { - personId: authorization.authority.personId, - now, - }, - ), - ); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${authorization.authority.personId}`, - qualifiedOperationId: operationId, - normalizedTarget: "/api/returning-assistant/registrations", - idempotencyKey, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({ body: payload }), - operationId, - }, - execute: ReturningAssistants.use((returning) => - Effect.gen(function* () { - const registered = yield* returning.register( - { - ...payload, - commandId: ReturningCommandIdSchema.make(identity.commandId), - }, - { - personId: authorization.authority.personId, - now, - }, - ); - - return { - status: 201, - mediaType: "application/json", - headers: { - "content-type": "application/json", - location: `/api/returning-assistant/registrations/${encodePathIdentity(registered.observation.registrationId)}`, - etag: deriveStrongETag({ - representationKind: "ReturningAssistantRegistration", - resourceIdentity: registered.observation.registrationId, - version: registered.observation.revision, - }), - }, - bodyBytes: jsonBodyBytes(registered), - }; - }), - ), - }; - }), - { retry: "serialization-once" }, - ); - - return yield* commandOutcomeResponse(outcome); - }).pipe(admissionProblems(request, "returning.unavailable"), commandReceiptProblems); - -export const createAdmissionPeriod = (request: Request, input: AdmissionApiHttpOptions) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - const payload = yield* decodeJson( - request, - CreateAdmissionPeriodRequest, - input.config.maxBodyBytes, - ); - - const admissionPeriodId = input.config.nextAdmissionPeriodId(); - - const idempotencyKey = yield* idempotencyKeyOf(request); - - const operationId = "admissions.createAdmissionPeriod"; - - // Domain failures are mapped after the executor, whose retry reads their causes. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const authorization = yield* resolveRequestPersonAuthorityInTransaction(request, { - now: input.config.now, - }); - - const actor = yield* admissionActorForAuthority( - authorization.authority, - payload.departmentId, - ); - - const now = authorization.authorizationInstant; - yield* authorizeAdmissionPerson(request, { - spec: Option.getOrThrow(reflectAccessSpec(CreateAdmissionPeriodEndpoint)), - credential: authorization.credential, - personId: actor.personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "admissions", - departmentId: Predicate.isTagged(actor, "DepartmentAdministrator") - ? actor.departmentId - : (payload.departmentId ?? null), - resourceKind: "admission-period", - resourceId: admissionPeriodId, - authorityVersion: `admissions:${actor._tag}`, - }), - ], - }, - grantScopes: admissionGrantScopes(actor), - now, - }); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${actor.personId}`, - qualifiedOperationId: operationId, - normalizedTarget: "/api/admission-periods", - idempotencyKey, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({ body: payload }), - operationId, - }, - execute: Admissions.use((admissions) => - Effect.gen(function* () { - const created = yield* admissions.executeAdmissionPeriod( - AdmissionPeriodCommandSchema.cases.CreateAdmissionPeriod.make({ - commandId: AdmissionPeriodCommandId.make(identity.commandId), - ...payload, - }), - { actor, now, admissionPeriodId }, - ); - - const period = created.period; - - const etag = deriveStrongETag({ - representationKind: "AdmissionPeriodManagementItem", - resourceIdentity: period.id, - version: period.revision, - }); - - return { - status: 201, - mediaType: "application/json", - headers: { - "content-type": "application/json", - location: `/api/admission-periods/${encodePathIdentity(period.id)}`, - etag, - }, - bodyBytes: jsonBodyBytes({ - id: period.id, - departmentId: period.departmentId, - semesterId: period.semesterId, - startAt: period.startAt, - endAt: period.endAt, - revision: period.revision, - etag, - }), - }; - }), - ), - }; - }), - // A concurrent create for the same department and semester commits after this - // snapshot; the restarted transaction sees it and answers that the period exists. - { retry: "serialization-once" }, - ); - - return yield* commandOutcomeResponse(outcome); - }).pipe( - admissionProblems(request, "dependency.unavailable"), - commandReceiptProblems, - // A create looks up no period by id, and the period it writes has no earlier revision. - unreachable("admission-period.not-found", "precondition.failed"), - ); - -export const reviseAdmissionPeriod = ( - request: Request, - admissionPeriodId: AdmissionPeriodId, - input: AdmissionApiHttpOptions, -) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - const ifMatch = yield* requiredIfMatchOf(request); - const idempotencyKey = yield* idempotencyKeyOf(request); - - const normalizedTarget = yield* semanticProblem( - () => normalizeTarget("/api/admission-periods/{admissionPeriodId}", { admissionPeriodId }), - ["request.malformed"], - ); - - const patch = yield* decodeAdmissionPeriodPatch(request, input.config.maxBodyBytes); - const operationId = "admissions.reviseAdmissionPeriod"; - - // Domain failures are mapped after the executor, whose retry reads their causes. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const authorization = yield* resolveRequestPersonAuthorityInTransaction(request, { - now: input.config.now, - }); - - const actor = yield* admissionActorForAuthority(authorization.authority); - const now = authorization.authorizationInstant; - - const periods = yield* Admissions.use(({ listAdmissionPeriodsForManagement }) => - listAdmissionPeriodsForManagement({ actor, now }), - ); - - const current = periods.find((period) => period.id === admissionPeriodId); - - if (current === undefined) { - return yield* new AdmissionPeriodNotFound({ admissionPeriodId }); - } - - yield* authorizeAdmissionPerson(request, { - spec: Option.getOrThrow(reflectAccessSpec(ReviseAdmissionPeriodEndpoint)), - credential: authorization.credential, - personId: actor.personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "admissions", - departmentId: current.departmentId, - resourceKind: "admission-period", - resourceId: current.id, - authorityVersion: `admissions:${actor._tag}`, - }), - ], - }, - grantScopes: admissionGrantScopes(actor), - now, - }); - - yield* requireCurrentETag( - deriveStrongETag({ - representationKind: "AdmissionPeriodManagementItem", - resourceIdentity: current.id, - version: current.revision, - }), - ifMatch, - ); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${actor.personId}`, - qualifiedOperationId: operationId, - normalizedTarget, - idempotencyKey, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest(semanticMutationRequest(patch, ifMatch)), - operationId, - }, - execute: Admissions.use((admissions) => - Effect.gen(function* () { - const revised = yield* admissions.executeAdmissionPeriod( - AdmissionPeriodCommandSchema.cases.ReviseAdmissionPeriod.make({ - commandId: AdmissionPeriodCommandId.make(identity.commandId), - admissionPeriodId, - expectedRevision: current.revision, - startAt: patch.startAt ?? current.startAt, - endAt: patch.endAt ?? current.endAt, - }), - { actor, now, admissionPeriodId }, - ); - - const period = revised.period; - - const etag = deriveStrongETag({ - representationKind: "AdmissionPeriodManagementItem", - resourceIdentity: period.id, - version: period.revision, - }); - - const body = { - id: period.id, - departmentId: period.departmentId, - semesterId: period.semesterId, - startAt: period.startAt, - endAt: period.endAt, - revision: period.revision, - etag, - }; - - return { - status: 200, - mediaType: "application/json", - headers: { "content-type": "application/json", etag }, - bodyBytes: jsonBodyBytes(body), - }; - }), - ), - }; - }), - // A concurrent revision commits after this snapshot; the restarted transaction reads - // the new revision, so the stale If-Match answers precondition.failed. - { retry: "serialization-once" }, - ); - - return yield* commandOutcomeResponse(outcome); - }).pipe( - admissionProblems(request, "dependency.unavailable"), - commandReceiptProblems, - // A revision never creates a period. - unreachable("admission-period.already-exists"), - ); - -export const submitApplication = (request: Request, input: AdmissionApiHttpOptions) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const now = yield* currentInstant(input.config.now); - - if (!input.config.rateLimit.consume(publicRateLimitKey(request), now)) { - return yield* new PublicApplicationRateLimitExceeded({}); - } - - const payload = yield* decodeJson(request, SubmitApplicationRequest, input.config.maxBodyBytes); - - const idempotencyKey = yield* idempotencyKeyOf(request); - - const operationId = "admissions.submitApplication"; - - // Domain failures are mapped after the executor, whose retry reads their causes. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(SubmitApplicationEndpoint)), - { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "admissions", - authorityVersion: `admissions-application-create:${now}`, - }), - ], - }, - now, - ); - - const identity = yield* httpIdentity({ - credentialSubject: "Anonymous", - qualifiedOperationId: operationId, - normalizedTarget: "/api/applications", - idempotencyKey, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({ body: payload }), - operationId, - }, - execute: Admissions.use((admissions) => - Effect.gen(function* () { - const submitted = yield* admissions.executePublicApplication( - { - commandId: PublicApplicationCommandIdSchema.make(identity.commandId), - ...payload, - }, - { - now, - applicationId: input.config.nextApplicationId(), - applicantId: input.config.nextApplicantId(), - activationToken: input.config.nextActivationToken(), - }, - ); - - const confirmation = { - _tag: "ApplicationConfirmed" as const, - applicationId: submitted.observation.applicationId, - }; - - return { - status: 201, - mediaType: "application/json", - headers: { - "content-type": "application/json", - location: `/api/applications/${encodePathIdentity(confirmation.applicationId)}`, - etag: deriveStrongETag({ - representationKind: "PublicApplicationConfirmation", - resourceIdentity: confirmation.applicationId, - version: 0, - }), - }, - bodyBytes: jsonBodyBytes(confirmation), - }; - }), - ), - }; - }), - // A concurrent submission for the same normalized email commits after this snapshot; - // the restarted transaction sees it and answers the duplicate instead of a write failure. - { - retry: "serialization-or-unique-once", - retryUniqueConstraints: [ - "admission_applicants_normalized_email_key", - "native_http_idempotency_receipts_pkey", - ], - }, - ); - - return yield* commandOutcomeResponse(outcome); - }).pipe( - admissionProblems(request, "dependency.unavailable"), - commandReceiptProblems, - // A submission looks up no confirmation. - unreachable("application.not-found"), - ); diff --git a/apps/backend/src/admission/http-context.ts b/apps/backend/src/admission/http-context.ts deleted file mode 100644 index 0319458c8..000000000 --- a/apps/backend/src/admission/http-context.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** Admission HTTP composition options and request actor resolution. */ -import type { OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { - AdmissionRoleDenied, - AdmissionScopeDenied, - InactiveActor, - type AdmissionPeriodActor, - type UnauthenticatedActor, -} from "@vektorprogrammet/domain/admission-period"; -import type { Identity, IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import { - DepartmentId, - type Organization, - type OrganizationPersonAuthority, -} from "@vektorprogrammet/domain/organization"; -import { Effect, Schema } from "effect"; -import { - admissionActorForDepartment, - unscopedAdmissionActorFrom, - type OrganizationResolutionError, -} from "../authority.js"; -import type { AdmissionApiConfig } from "./config.js"; - -export interface AdmissionApiHttpOptions { - readonly config: AdmissionApiConfig; - /** - * Resolves the session cookie into a department-scoped actor (spec 0055). - * `departmentScope` carries canonical request state (payload department or - * the period's immutable department); undefined resolves the person's own scope. - */ - readonly resolveActor: ( - request: Request, - departmentScope?: string, - ) => Effect.Effect< - AdmissionPeriodActor, - | IdentityEngineError - | UnauthenticatedActor - | InactiveActor - | AdmissionScopeDenied - | AdmissionRoleDenied - | OrganizationResolutionError, - Identity | OAuthCredentialAuthority | Organization - >; -} - -export const requireActive = (actor: AdmissionPeriodActor) => - actor.active - ? Effect.succeed(actor) - : Effect.fail(new InactiveActor({ personId: actor.personId })); - -const isAdmissionActorDenial = Schema.is( - Schema.Union([InactiveActor, AdmissionScopeDenied, AdmissionRoleDenied]), -); - -/** - * The admission actor of one department scope. - * - * @remarks - * It maps the organization authority of a person to the admission actor of `departmentScope`, - * or to the person's own scope when none is given, and requires that actor to be active. The - * mapping throws only its three denials, which become failures; anything else that it throws is a - * defect. - * - * @sideEffects none - * - * @example - * ```ts - * const actor = yield* admissionActorForAuthority(authorization.authority, payload.departmentId); - * ``` - * - * @avoid Mapping the authority with `admissionActorForDepartment` in a handler: its denials are - * throws, which escape the error channel as defects. Map it with this. - * - * @construct http-problem - */ -export const admissionActorForAuthority = ( - authority: OrganizationPersonAuthority, - departmentScope?: string, -): Effect.Effect< - AdmissionPeriodActor, - AdmissionRoleDenied | AdmissionScopeDenied | InactiveActor -> => - Effect.try({ - try: () => - departmentScope === undefined - ? unscopedAdmissionActorFrom(authority) - : admissionActorForDepartment(authority, DepartmentId.make(departmentScope)), - catch: (cause) => { - if (isAdmissionActorDenial(cause)) return cause; - throw cause; - }, - }).pipe(Effect.flatMap(requireActive)); diff --git a/apps/backend/src/admission/http-decode.ts b/apps/backend/src/admission/http-decode.ts deleted file mode 100644 index 4084e10c8..000000000 --- a/apps/backend/src/admission/http-decode.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** Admission request decoding: JSON bodies and admission period merge patches. */ -import { AdmissionPeriodMergePatch } from "@vektorprogrammet/http-api"; -import { - makeNativeValidationError, - type NativeValidationError, - Problem, -} from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Match, Predicate, Schema, type SchemaIssue } from "effect"; -import { readJsonBody, semanticProblem } from "../http-api/problem.js"; -import { interpretAdmissionPeriodMergePatchSource } from "../http-semantics.js"; - -const pointerOf = (path: ReadonlyArray) => - path.map((segment) => `/${String(segment).replaceAll("~", "~0").replaceAll("/", "~1")}`).join(""); - -/** Every member a schema rejected, named by its RFC 6901 pointer and never by its value. */ -const rejectedMembers = ( - issue: SchemaIssue.Issue, - path: ReadonlyArray = [], -): ReadonlyArray => - Match.value(issue).pipe( - Match.tag("Pointer", (nested) => rejectedMembers(nested.issue, [...path, ...nested.path])), - Match.tag("Filter", "Encoding", (nested) => rejectedMembers(nested.issue, path)), - Match.tag("Composite", (nested) => - nested.issues.flatMap((member) => rejectedMembers(member, path)), - ), - Match.tag("MissingKey", () => [makeNativeValidationError(pointerOf(path), "missing")]), - Match.tag("UnexpectedKey", () => [makeNativeValidationError(pointerOf(path), "unknown")]), - Match.orElse(() => [makeNativeValidationError(pointerOf(path), "invalid")]), - ); - -/** - * Decodes one body exactly; a rejection names each rejected member once. - */ -const decodeBody = >( - schema: S, - body: Schema.Json, -) => - Schema.decodeEffect(schema)(body, { onExcessProperty: "error", errors: "all" }).pipe( - Effect.mapError((error) => { - const members = new Map( - rejectedMembers(error.issue).map((member) => [`${member.code} ${member.pointer}`, member]), - ); - - return Problem.validation("validation.failed", [...members.values()]); - }), - ); - -/** - * Reads and decodes one bounded JSON body. - */ -export const decodeJson = >( - request: Request, - schema: S, - maxBodyBytes: number, -) => - Effect.gen(function* () { - const body = yield* readJsonBody(request, /^application\/json(?:\s*;|$)/iu, maxBodyBytes); - - return yield* decodeBody(schema, body); - }); - -/** - * Reads and decodes one bounded admission period merge patch. - */ -export const decodeAdmissionPeriodPatch = (request: Request, maxBodyBytes: number) => - Effect.gen(function* () { - const body = yield* readJsonBody( - request, - /^application\/merge-patch\+json(?:\s*;|$)/iu, - maxBodyBytes, - ); - - // Unknown, absent, and deleted members are judged before the values are decoded. - const interpretation = yield* semanticProblem( - () => interpretAdmissionPeriodMergePatchSource(body), - ["request.malformed"], - ); - - if (Predicate.isTagged(interpretation, "Rejected")) { - return yield* Problem.validation(interpretation.code, interpretation.errors); - } - - return yield* decodeBody(AdmissionPeriodMergePatch, body); - }); diff --git a/apps/backend/src/admission/http-reads.ts b/apps/backend/src/admission/http-reads.ts deleted file mode 100644 index 4d2e1d82d..000000000 --- a/apps/backend/src/admission/http-reads.ts +++ /dev/null @@ -1,321 +0,0 @@ -/** Admission read handlers: admission periods, public applications, and returning assistants. */ -import { Database } from "@vektorprogrammet/database"; -import { Admissions } from "@vektorprogrammet/domain/admissions"; -import { - ApplicantProgressResponseSchema, - ReturningAssistantOptionsSchema, - ReturningAssistants, -} from "@vektorprogrammet/domain/application"; -import { - AdmissionPeriodManagementItem, - ListAdmissionPeriodsEndpoint, - ListOpenAdmissionPeriodsEndpoint, - ReadApplicantProgressEndpoint, - ReadApplicationCatalogEndpoint, - ReadApplicationConfirmationEndpoint, - ReadReturningAssistantOptionsEndpoint, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Option, Predicate, Schema } from "effect"; -import { currentInstant, resolveRequestPersonAuthorityInTransaction } from "../authority.js"; -import { - authorizeAnonymous, - requireNoQuery, - strictOutput, - unreachable, - jsonText, -} from "../http-api/problem.js"; -import { PRIVATE_NO_STORE, deriveStrongETag } from "../http-semantics.js"; -import { genericContext } from "../native-operation.js"; -import { - admissionGrantScopes, - authorizeAdmissionPerson, - returningAuthorization, - returningPersonResource, -} from "./http-access.js"; -import { requireActive, type AdmissionApiHttpOptions } from "./http-context.js"; -import { admissionProblems, periodCommandProblems, submissionProblems } from "./http-problem.js"; -import { - conditionalCollection, - dynamicAdmissionCache, - jsonResponse, -} from "./http-representation.js"; - -export const readReturningAssistantOptions = (request: Request, input: AdmissionApiHttpOptions) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - const authorization = yield* returningAuthorization( - request, - input, - ReadReturningAssistantOptionsEndpoint, - ); - - const options = yield* ReturningAssistants.use(({ readOptions }) => - readOptions({ - personId: authorization.authority.personId, - now: authorization.authorizationInstant, - }), - ); - - const body = yield* strictOutput(ReturningAssistantOptionsSchema)(options); - - return jsonResponse(body); - }).pipe( - admissionProblems(request, "returning.unavailable"), - // Reading the options selects no team and runs no command. - unreachable( - "returning.team-scope-denied", - "returning.revision-conflict", - "idempotency.digest-conflict", - ), - ); - -export const listAdmissionPeriods = (request: Request, input: AdmissionApiHttpOptions) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const actor = yield* input.resolveActor(request).pipe(Effect.flatMap(requireActive)); - const now = yield* currentInstant(input.config.now); - yield* authorizeAdmissionPerson(request, { - spec: Option.getOrThrow(reflectAccessSpec(ListAdmissionPeriodsEndpoint)), - request, - personId: actor.personId, - resolution: { - selection: "AllMatching", - contexts: [ - genericContext({ - domainId: "admissions", - departmentId: Predicate.isTagged(actor, "DepartmentAdministrator") - ? actor.departmentId - : null, - authorityVersion: `admissions:${actor._tag}`, - }), - ], - }, - grantScopes: admissionGrantScopes(actor), - now, - }); - - const rows = yield* Admissions.use(({ listAdmissionPeriodsForManagement }) => - listAdmissionPeriodsForManagement({ actor, now }), - ); - - const items = rows.map((row) => ({ - id: row.id, - departmentId: row.departmentId, - semesterId: row.semesterId, - startAt: row.startAt, - endAt: row.endAt, - revision: row.revision, - etag: deriveStrongETag({ - representationKind: "AdmissionPeriodManagementItem", - resourceIdentity: row.id, - version: row.revision, - }), - })); - - const body = yield* strictOutput( - Schema.Struct({ items: Schema.Array(AdmissionPeriodManagementItem), totalItems: Schema.Int }), - )({ items, totalItems: items.length }); - - return yield* conditionalCollection({ - request, - body, - representationKind: "AdmissionPeriodManagementListResponse", - version: rows.map((row) => [row.id, row.revision] as const), - cacheControl: PRIVATE_NO_STORE, - }); - }).pipe( - admissionProblems(request, "admissions.unavailable"), - unreachable(...periodCommandProblems), - ); - -export const listOpenAdmissionPeriods = (request: Request, input: AdmissionApiHttpOptions) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const now = yield* currentInstant(input.config.now); - yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(ListOpenAdmissionPeriodsEndpoint)), - { - selection: "AllMatching", - contexts: [ - genericContext({ - domainId: "admissions", - authorityVersion: `admissions-open:${now}`, - }), - ], - }, - now, - ); - - const rows = yield* Admissions.use(({ listOpenAdmissionPeriods }) => - listOpenAdmissionPeriods(now), - ); - - const body = { - items: rows.map((row) => ({ - id: row.id, - departmentId: row.departmentId, - semesterId: row.semesterId, - startAt: row.startAt, - endAt: row.endAt, - })), - totalItems: rows.length, - }; - - return yield* conditionalCollection({ - request, - body, - representationKind: "OpenAdmissionPeriodListResponse", - version: rows.map((row) => [row.id, row.revision] as const), - cacheControl: dynamicAdmissionCache( - now, - rows.flatMap((row) => [row.startAt, row.endAt]), - ), - }); - }).pipe( - admissionProblems(request, "admissions.unavailable"), - // The open listing resolves no actor. - unreachable( - ...periodCommandProblems, - "credential.missing", - "credential.invalid", - "authority.denied", - ), - ); - -export const listApplicationOptions = (request: Request, input: AdmissionApiHttpOptions) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const now = yield* currentInstant(input.config.now); - yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(ReadApplicationCatalogEndpoint)), - { - selection: "AllMatching", - contexts: [ - genericContext({ - domainId: "admissions", - authorityVersion: `admissions-catalog:${now}`, - }), - ], - }, - now, - ); - - const source = yield* Admissions.use(({ listPublicApplicationCatalog }) => - listPublicApplicationCatalog({ now }), - ); - - return yield* conditionalCollection({ - request, - body: source.catalog, - representationKind: "PublicApplicationCatalog", - version: { - intervalIdentity: source.validatorSource.intervalIdentity, - itemRevisions: source.validatorSource.itemRevisions, - }, - cacheControl: dynamicAdmissionCache( - now, - source.catalog.departments.map((department) => department.closesAt), - ), - }); - }).pipe( - admissionProblems(request, "admissions.unavailable"), - unreachable(...submissionProblems, "application.not-found"), - ); - -export const readApplicationConfirmation = ( - request: Request, - applicationId: string, - input: AdmissionApiHttpOptions, -) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const now = yield* currentInstant(input.config.now); - yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(ReadApplicationConfirmationEndpoint)), - { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "admissions", - resourceKind: "application", - resourceId: applicationId, - authorityVersion: `admissions-application:${applicationId}`, - }), - ], - }, - now, - ); - - const confirmation = yield* Admissions.use(({ findPublicApplicationConfirmation }) => - findPublicApplicationConfirmation(applicationId), - ); - - return jsonResponse(confirmation); - }).pipe( - admissionProblems(request, "admissions.unavailable"), - // The path already decoded the identifier the confirmation lookup decodes again. - unreachable(...submissionProblems), - ); - -export const readApplicantProgress = (request: Request, input: AdmissionApiHttpOptions) => - Database.use((sql) => - sql.withTransaction( - Effect.gen(function* () { - yield* requireNoQuery(request); - - yield* Database.use( - (transaction) => transaction`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`, - ); - - const authorization = yield* resolveRequestPersonAuthorityInTransaction(request, { - now: input.config.now, - }); - - yield* authorizeAdmissionPerson(request, { - spec: Option.getOrThrow(reflectAccessSpec(ReadApplicantProgressEndpoint)), - credential: authorization.credential, - personId: authorization.authority.personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "admissions", - resourceKind: "person-profile", - resourceId: authorization.authority.personId, - facts: { ownerPersonId: authorization.authority.personId }, - authorityVersion: "admissions:applicant-progress", - }), - ], - }, - grantScopes: [returningPersonResource(authorization.authority.personId)], - now: authorization.authorizationInstant, - }); - - const body = yield* Admissions.use(({ readApplicantProgress }) => - readApplicantProgress( - authorization.authority.personId, - authorization.authorizationInstant, - ), - ); - - const decoded = yield* strictOutput(ApplicantProgressResponseSchema)(body); - - return new Response(yield* jsonText(decoded), { - headers: { - "content-type": "application/json; charset=utf-8", - "cache-control": "private, no-store", - "referrer-policy": "no-referrer", - vary: "Origin", - }, - }); - }), - ), - ).pipe( - // A snapshot the database cannot open or commit leaves the service unavailable. - Effect.catchTag("SqlError", () => Effect.fail(Problem.make("admissions.unavailable"))), - admissionProblems(request, "admissions.unavailable"), - unreachable(...submissionProblems, "application.not-found"), - ); diff --git a/apps/backend/src/admission/http-representation.ts b/apps/backend/src/admission/http-representation.ts deleted file mode 100644 index 96784cd1a..000000000 --- a/apps/backend/src/admission/http-representation.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** Admission HTTP representations: JSON reads, conditional collections, and public cache lifetimes. */ -import type { Schema } from "effect"; -import { conditionalJson } from "../http-api/problem.js"; -import { deriveStrongETag, type ETagVersionSource } from "../http-semantics.js"; - -/** - * One JSON representation that no cache stores. - */ -export const jsonResponse = (body: Schema.Json): Response => - new Response(JSON.stringify(body), { - headers: { - "content-type": "application/json; charset=utf-8", - "cache-control": "no-store", - }, - }); - -/** - * Answers a conditional read of one admission collection, tagged by the - * versions of its items. - */ -export const conditionalCollection = (input: { - readonly request: Request; - readonly body: unknown; - readonly representationKind: string; - readonly version: ETagVersionSource; - readonly cacheControl: string; -}) => - conditionalJson({ - request: input.request, - body: input.body, - etag: deriveStrongETag({ - representationKind: input.representationKind, - resourceIdentity: "collection", - version: input.version, - }), - cacheControl: input.cacheControl, - contentType: "application/json; charset=utf-8", - }); - -export const dynamicAdmissionCache = (now: string, boundaries: ReadonlyArray): string => { - const nowMillis = Date.parse(now); - - const next = boundaries - .values() - .map(Date.parse) - .filter((boundary) => Number.isFinite(boundary) && boundary >= nowMillis) - .toArray() - .sort((left, right) => left - right)[0]; - - const ttl = - next === undefined ? 30 : Math.max(0, Math.min(30, Math.floor((next - nowMillis) / 1_000))); - - return `public, max-age=${ttl}, s-maxage=${ttl}, must-revalidate`; -}; diff --git a/apps/backend/src/admission/http.ts b/apps/backend/src/admission/http.ts deleted file mode 100644 index 78d4c3048..000000000 --- a/apps/backend/src/admission/http.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** Native HttpApi composition for admission and public application endpoints. */ -import { ExternalNativeApi } from "@vektorprogrammet/http-api"; -import { Effect } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { webHandler } from "../http-api/problem.js"; -import { - createAdmissionPeriod, - registerReturningAssistant, - reviseAdmissionPeriod, - submitApplication, -} from "./http-commands.js"; -import type { AdmissionApiHttpOptions } from "./http-context.js"; -import { - listAdmissionPeriods, - listApplicationOptions, - listOpenAdmissionPeriods, - readApplicantProgress, - readApplicationConfirmation, - readReturningAssistantOptions, -} from "./http-reads.js"; - -/** Native HttpApi implementations for admission and public application endpoints. */ -export const AdmissionsApiHandlers = (input: AdmissionApiHttpOptions) => - HttpApiBuilder.group(ExternalNativeApi, "admissions", (handlers) => - Effect.succeed( - handlers - .handleRaw("listAdmissionPeriods", ({ request }) => - webHandler(request, (webRequest) => listAdmissionPeriods(webRequest, input)), - ) - .handleRaw("createAdmissionPeriod", ({ request }) => - webHandler(request, (webRequest) => createAdmissionPeriod(webRequest, input)), - ) - .handleRaw("reviseAdmissionPeriod", ({ request, params }) => - webHandler(request, (webRequest) => - reviseAdmissionPeriod(webRequest, params.admissionPeriodId, input), - ), - ) - .handleRaw("listOpenAdmissionPeriods", ({ request }) => - webHandler(request, (webRequest) => listOpenAdmissionPeriods(webRequest, input)), - ) - .handleRaw("listApplicationOptions", ({ request }) => - webHandler(request, (webRequest) => listApplicationOptions(webRequest, input)), - ) - .handleRaw("submitApplication", ({ request }) => - webHandler(request, (webRequest) => submitApplication(webRequest, input)), - ) - .handleRaw("readApplicationConfirmation", ({ request, params }) => - webHandler(request, (webRequest) => - readApplicationConfirmation(webRequest, params.applicationId, input), - ), - ) - .handleRaw("readApplicantProgress", ({ request }) => - webHandler(request, (webRequest) => readApplicantProgress(webRequest, input)), - ) - .handleRaw("readReturningAssistantOptions", ({ request }) => - webHandler(request, (webRequest) => readReturningAssistantOptions(webRequest, input)), - ) - .handleRaw("registerReturningAssistant", ({ request }) => - webHandler(request, (webRequest) => registerReturningAssistant(webRequest, input)), - ), - ), - ); diff --git a/apps/backend/src/admission/outcome-http.ts b/apps/backend/src/admission/outcome-http.ts deleted file mode 100644 index f99762cf6..000000000 --- a/apps/backend/src/admission/outcome-http.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { Database } from "@vektorprogrammet/database"; -import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { - AdmissionOutcomeCommand, - AdmissionOutcomeScope, - AdmissionOutcomeScopes, - Admissions, - admissionOutcomePermission, - onCallSubstitutes, - type AdmissionOutcomeEntry, - type AdmissionOutcomeOperationFailure, -} from "@vektorprogrammet/domain/admissions"; -import { Scope } from "@vektorprogrammet/domain/authz"; -import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import type { OrganizationPersistenceError } from "@vektorprogrammet/domain/organization"; -import { - AdmissionOutcomeBoardResource, - AdmissionOutcomeResource, - ExternalNativeApi, - ListAdmissionOutcomeScopesEndpoint, - ReadAdmissionOutcomeEndpoint, - ReadAdmissionOutcomesEndpoint, - RecordAdmissionOutcomeEndpoint, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { type CredentialPresentation, Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Option, type Schema } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { isSqlError, type SqlError } from "effect/unstable/sql/SqlError"; -import { - resolveRequestPersonAuthorityInTransaction, - type OrganizationResolutionError, - type TransactionPersonAuthority, -} from "../authority.js"; -import { - authorizePerson, - commandOutcomeResponse, - commandReceiptProblems, - conditionalJson, - decodeRequest, - httpIdentity, - idempotencyKeyOf, - isSerializationConflict, - personPresentation, - problemMapper, - readJsonBody, - requireCurrentETag, - requiredIfMatchOf, - requireNoQuery, - strictOutput, - webHandler, - jsonText, -} from "../http-api/problem.js"; -import { executeNativeHttpCommandPostgres } from "../http-api/receipt-transaction.js"; -import { - deriveStrongETag, - normalizeTarget, - PRIVATE_NO_STORE, - semanticMutationRequest, - semanticRequestDigest, -} from "../http-semantics.js"; -import { genericContext } from "../native-operation.js"; - -const MAX_MUTATION_BYTES = 8192; - -const outcomeResource = (entry: AdmissionOutcomeEntry) => ({ - ...entry, - etag: deriveStrongETag({ - representationKind: "AdmissionOutcomeResource", - resourceIdentity: entry.applicationId, - version: JSON.stringify(entry), - }), -}); - -const json = (body: Schema.Json) => - new Response(JSON.stringify(body), { - headers: { - "content-type": "application/json", - "cache-control": PRIVATE_NO_STORE, - vary: "Origin", - }, - }); - -/** A lost serialization or deadlock race may be retried; any other storage failure may not. */ -const persistenceProblem = (failure: OrganizationPersistenceError | SqlError) => - isSerializationConflict(failure) - ? Problem.make("transaction.conflict") - : Problem.make("internal.error"); - -/** - * The one answer for every admission outcome and person-authority failure. A person - * credential rejected inside the transaction is answered from the request's evidence. - */ -const outcomeProblems = (presentation: CredentialPresentation) => - problemMapper< - | AdmissionOutcomeOperationFailure - | UnauthenticatedActor - | IdentityEngineError - | OrganizationResolutionError - >()({ - AdmissionOutcomeFailure: (failure) => Problem.make(failure.code), - AdmissionOutcomePersistenceError: (failure) => Problem.make(failure.code), - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - IdentityEngineError: () => Problem.make("internal.error"), - OrganizationDecodeError: () => Problem.make("internal.error"), - OrganizationPersistenceError: persistenceProblem, - }); - -type Endpoint = - | typeof ListAdmissionOutcomeScopesEndpoint - | typeof ReadAdmissionOutcomesEndpoint - | typeof ReadAdmissionOutcomeEndpoint - | typeof RecordAdmissionOutcomeEndpoint; - -/** - * Grants the department's admission outcome permission, then evaluates the declared AccessSpec. - */ -const authorize = ( - request: Request, - endpoint: Endpoint, - departmentId: AdmissionOutcomeEntry["departmentId"], - decide: boolean, - auth: TransactionPersonAuthority, -) => - Effect.gen(function* () { - const permission = admissionOutcomePermission(auth.authority, departmentId); - - if (permission === "Denied" || (decide && permission !== "Decide")) - return yield* Problem.make("authority.denied"); - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(endpoint)), - credential: auth.credential, - personId: auth.authority.personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "admissions", - departmentId, - authorityVersion: auth.authorizationInstant, - }), - ], - }, - grantScopes: [Scope.Department({ departmentId })], - now: auth.authorizationInstant, - }, - personPresentation(request), - ); - - return permission; - }); - -export const AdmissionOutcomesApiHandlers = (input: { now?: () => string }) => { - const personAuthority = (request: Request) => - resolveRequestPersonAuthorityInTransaction(request, { now: input.now }); - - /** - * Runs one read in a REPEATABLE READ snapshot, which resolves the credential - * and authority on the same connection, and answers its failures. - */ - const snapshotRead = (request: Request, effect: Effect.Effect) => - Database.use((sql) => - sql.withTransaction( - Effect.gen(function* () { - yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`; - - return yield* effect; - }), - ), - ).pipe( - Effect.catchIf(isSqlError, (failure) => Effect.fail(persistenceProblem(failure))), - outcomeProblems(personPresentation(request)), - ); - - const listScopes = (request: Request) => - snapshotRead( - request, - Effect.gen(function* () { - yield* requireNoQuery(request); - - const auth = yield* personAuthority(request); - - const scopes = yield* Admissions.use((admissions) => - admissions.listAdmissionOutcomeScopes(auth.authority), - ); - - for (const department of scopes.departments) - yield* authorize( - request, - ListAdmissionOutcomeScopesEndpoint, - department.departmentId, - false, - auth, - ); - - return json(yield* strictOutput(AdmissionOutcomeScopes)(scopes)); - }), - ); - - const readOutcomes = (request: Request) => - snapshotRead( - request, - Effect.gen(function* () { - const parameters = new URL(request.url).searchParams; - const keys = [...parameters.keys()]; - - if ( - keys.some((key) => key !== "departmentId" && key !== "semesterId") || - keys.some((key) => parameters.getAll(key).length !== 1) - ) - return yield* Problem.make("request.malformed"); - - const scope = yield* decodeRequest(AdmissionOutcomeScope)(Object.fromEntries(parameters)); - const auth = yield* personAuthority(request); - - const permission = yield* authorize( - request, - ReadAdmissionOutcomesEndpoint, - scope.departmentId, - false, - auth, - ); - - const { admissionPeriodId, entries } = yield* Admissions.use((admissions) => - admissions.readAdmissionOutcomes(scope), - ); - - return json( - yield* strictOutput(AdmissionOutcomeBoardResource)( - permission === "Decide" - ? AdmissionOutcomeBoardResource.cases.Decide.make({ - ...scope, - admissionPeriodId, - entries: entries.map(outcomeResource), - }) - : AdmissionOutcomeBoardResource.cases.ReadOnly.make({ - ...scope, - admissionPeriodId, - substitutes: onCallSubstitutes(entries), - }), - ), - ); - }), - ); - - const readOutcome = (request: Request, applicationId: AdmissionOutcomeEntry["applicationId"]) => - snapshotRead( - request, - Effect.gen(function* () { - yield* requireNoQuery(request); - - const entry = yield* Admissions.use((admissions) => - admissions.readAdmissionOutcome(applicationId), - ); - - const auth = yield* personAuthority(request); - yield* authorize(request, ReadAdmissionOutcomeEndpoint, entry.departmentId, true, auth); - const resource = yield* strictOutput(AdmissionOutcomeResource)(outcomeResource(entry)); - - return yield* conditionalJson({ - request, - body: resource, - etag: resource.etag, - cacheControl: PRIVATE_NO_STORE, - contentType: "application/json", - }); - }), - ); - - const recordOutcome = (request: Request, applicationId: AdmissionOutcomeEntry["applicationId"]) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - const body = yield* readJsonBody( - request, - /^application\/json(?:\s*;|$)/u, - MAX_MUTATION_BYTES, - ); - - const command = yield* decodeRequest(AdmissionOutcomeCommand)(body); - const ifMatch = yield* requiredIfMatchOf(request); - const idempotencyKey = yield* idempotencyKeyOf(request); - const operationId = "admissionOutcomes.recordOutcome"; - - // Failures are answered after the executor, which rolls the whole command back on any of them. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const selected = yield* Admissions.use((admissions) => - admissions.readAdmissionOutcome(applicationId), - ); - - const auth = yield* personAuthority(request); - - yield* authorize( - request, - RecordAdmissionOutcomeEndpoint, - selected.departmentId, - true, - auth, - ); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${auth.authority.personId}`, - qualifiedOperationId: operationId, - normalizedTarget: normalizeTarget("/api/admission-outcomes/{applicationId}:record", { - applicationId, - }), - idempotencyKey, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest(semanticMutationRequest(body, ifMatch)), - operationId, - }, - execute: Effect.gen(function* () { - const changed = yield* Admissions.use((admissions) => - admissions.recordAdmissionOutcome( - { - applicationId, - command, - actor: auth.authority.personId, - now: auth.authorizationInstant, - }, - (current) => requireCurrentETag(outcomeResource(current).etag, ifMatch), - ), - ); - - const resource = yield* strictOutput(AdmissionOutcomeResource)( - outcomeResource(changed), - ); - - return { - status: 200, - mediaType: "application/json", - headers: { "content-type": "application/json", etag: resource.etag }, - bodyBytes: new TextEncoder().encode(yield* jsonText(resource)), - }; - }), - }; - }), - ).pipe(outcomeProblems(personPresentation(request)), commandReceiptProblems); - - return yield* commandOutcomeResponse(outcome); - }); - - return HttpApiBuilder.group(ExternalNativeApi, "admissionOutcomes", (handlers) => - Effect.succeed( - handlers - .handleRaw("listScopes", ({ request }) => webHandler(request, listScopes)) - .handleRaw("readOutcomes", ({ request }) => webHandler(request, readOutcomes)) - .handleRaw("readOutcome", ({ request, params }) => - webHandler(request, (webRequest) => readOutcome(webRequest, params.applicationId)), - ) - .handleRaw("recordOutcome", ({ request, params }) => - webHandler(request, (webRequest) => recordOutcome(webRequest, params.applicationId)), - ), - ), - ); -}; diff --git a/apps/backend/src/admission/outcome-rpc.ts b/apps/backend/src/admission/outcome-rpc.ts new file mode 100644 index 000000000..cbda3001e --- /dev/null +++ b/apps/backend/src/admission/outcome-rpc.ts @@ -0,0 +1,305 @@ +/** + * The AdmissionOutcomesRpcs handlers. A read runs in one repeatable-read snapshot that also + * resolves the caller's credential and authority. A record resolves them inside the serializable + * transaction that commits it, takes the department's `admissions.outcomes` reach as evidence, and + * stores its success as a command receipt. + */ +import { Database } from "@vektorprogrammet/database"; +import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import { + Admissions, + admissionOutcomePermission, + onCallSubstitutes, + type AdmissionOutcomeEntry, + type AdmissionOutcomeOperationFailure, +} from "@vektorprogrammet/domain/admissions"; +import { requireDepartmentReach, Scope } from "@vektorprogrammet/domain/authz"; +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import type { OrganizationPersistenceError } from "@vektorprogrammet/domain/organization"; +import { + AdmissionOutcomeBoardResource, + AdmissionOutcomeResource, + AdmissionOutcomeScopes, + AdmissionOutcomesRpcs, + ListAdmissionOutcomeScopes, + ReadAdmissionOutcome, + ReadAdmissionOutcomes, + RecordAdmissionOutcome, + reflectAccessSpec, +} from "@vektorprogrammet/rpc"; +import { type CredentialPresentation, Problem } from "@vektorprogrammet/rpc/problem"; +import { Effect, Option } from "effect"; +import type { Headers } from "effect/unstable/http"; +import { isSqlError, type SqlError } from "effect/unstable/sql/SqlError"; +import { + resolveRequestPersonAuthorityInTransaction, + type OrganizationResolutionError, + type TransactionPersonAuthority, +} from "../authority.js"; +import { + deriveStrongETag, + normalizeTarget, + semanticMutationRequest, + semanticRequestDigest, +} from "../http-semantics.js"; +import { genericContext } from "../native-operation.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { + authorizePerson, + commandIdentity, + commandOutcome, + commandReceiptProblems, + isSerializationConflict, + jsonText, + personPresentation, + problemMapper, + requireCurrentETag, + strictOutput, +} from "../rpc/problem.js"; +import { executeNativeHttpCommandPostgres, successCapsule } from "../rpc/receipt-transaction.js"; + +/** One entry with its tag; the tag versions the whole entry, as the HTTP representation did. */ +const outcomeResource = (entry: AdmissionOutcomeEntry) => + Effect.map(jsonText(entry), (version) => ({ + ...entry, + etag: deriveStrongETag({ + representationKind: "AdmissionOutcomeResource", + resourceIdentity: entry.applicationId, + version, + }), + })); + +/** A lost serialization or deadlock race may be retried; any other storage failure may not. */ +const persistenceProblem = (failure: OrganizationPersistenceError | SqlError) => + isSerializationConflict(failure) + ? Problem.make("transaction.conflict") + : Problem.make("internal.error"); + +/** + * The one answer for every admission outcome and person-authority failure. A person credential + * rejected inside the transaction is answered from the request's credential headers. + */ +const outcomeProblems = (presentation: CredentialPresentation) => + problemMapper< + | AdmissionOutcomeOperationFailure + | UnauthenticatedActor + | IdentityEngineError + | OrganizationResolutionError + >()({ + AdmissionOutcomeFailure: (failure) => Problem.make(failure.code), + AdmissionOutcomePersistenceError: (failure) => Problem.make(failure.code), + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + IdentityEngineError: () => Problem.make("internal.error"), + OrganizationDecodeError: () => Problem.make("internal.error"), + OrganizationPersistenceError: persistenceProblem, + }); + +type OutcomeRpc = + | typeof ListAdmissionOutcomeScopes + | typeof ReadAdmissionOutcomes + | typeof ReadAdmissionOutcome + | typeof RecordAdmissionOutcome; + +/** + * Grants the department's admission outcome permission, then evaluates the declared AccessSpec. + */ +const authorize = ( + headers: Headers.Headers, + rpc: OutcomeRpc, + departmentId: AdmissionOutcomeEntry["departmentId"], + decide: boolean, + auth: TransactionPersonAuthority, +) => + Effect.gen(function* () { + const permission = admissionOutcomePermission(auth.authority, departmentId); + + if (permission === "Denied" || (decide && permission !== "Decide")) + return yield* Problem.make("authority.denied"); + + yield* authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(rpc)), + credential: auth.credential, + personId: auth.authority.personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "admissions", + departmentId, + authorityVersion: auth.authorizationInstant, + }), + ], + }, + grantScopes: [Scope.Department({ departmentId })], + now: auth.authorizationInstant, + }, + personPresentation(headers), + ); + + return permission; + }); + +/** The AdmissionOutcomesRpcs handlers. */ +export const AdmissionOutcomesRpcHandlers = (options: NativeRpcOptions) => { + const personAuthority = (headers: Headers.Headers) => + resolveRequestPersonAuthorityInTransaction(credentialRequestOf(headers), { + now: options.now, + }); + + /** + * Runs one read in a REPEATABLE READ snapshot, which resolves the credential and authority on + * the same connection, and answers its failures. + */ + const snapshotRead = (headers: Headers.Headers, effect: Effect.Effect) => + Database.use((sql) => + sql.withTransaction( + Effect.gen(function* () { + yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`; + + return yield* effect; + }), + ), + ).pipe( + Effect.catchIf(isSqlError, (failure) => Effect.fail(persistenceProblem(failure))), + outcomeProblems(personPresentation(headers)), + ); + + return AdmissionOutcomesRpcs.toLayer({ + "admissionOutcomes.listScopes": (_payload, { headers }) => + snapshotRead( + headers, + Effect.gen(function* () { + const auth = yield* personAuthority(headers); + + const scopes = yield* Admissions.use((admissions) => + admissions.listAdmissionOutcomeScopes(auth.authority), + ); + + for (const department of scopes.departments) + yield* authorize( + headers, + ListAdmissionOutcomeScopes, + department.departmentId, + false, + auth, + ); + + return yield* strictOutput(AdmissionOutcomeScopes)(scopes); + }), + ), + + "admissionOutcomes.readOutcomes": (scope, { headers }) => + snapshotRead( + headers, + Effect.gen(function* () { + const auth = yield* personAuthority(headers); + + const permission = yield* authorize( + headers, + ReadAdmissionOutcomes, + scope.departmentId, + false, + auth, + ); + + const { admissionPeriodId, entries } = yield* Admissions.use((admissions) => + admissions.readAdmissionOutcomes(scope), + ); + + return yield* strictOutput(AdmissionOutcomeBoardResource)( + permission === "Decide" + ? AdmissionOutcomeBoardResource.cases.Decide.make({ + departmentId: scope.departmentId, + semesterId: scope.semesterId, + admissionPeriodId, + entries: yield* Effect.forEach(entries, outcomeResource), + }) + : AdmissionOutcomeBoardResource.cases.ReadOnly.make({ + departmentId: scope.departmentId, + semesterId: scope.semesterId, + admissionPeriodId, + substitutes: onCallSubstitutes(entries), + }), + ); + }), + ), + + "admissionOutcomes.readOutcome": ({ applicationId }, { headers }) => + snapshotRead( + headers, + Effect.gen(function* () { + const entry = yield* Admissions.use((admissions) => + admissions.readAdmissionOutcome(applicationId), + ); + + const auth = yield* personAuthority(headers); + yield* authorize(headers, ReadAdmissionOutcome, entry.departmentId, true, auth); + + return yield* strictOutput(AdmissionOutcomeResource)(yield* outcomeResource(entry)); + }), + ), + + "admissionOutcomes.recordOutcome": ( + { applicationId, idempotencyKey, ifMatch, request: command }, + { headers }, + ) => + Effect.gen(function* () { + const operationId = "admissionOutcomes.recordOutcome"; + + // Failures are answered after the executor, which rolls the whole command back on any of them. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const selected = yield* Admissions.use((admissions) => + admissions.readAdmissionOutcome(applicationId), + ); + + const auth = yield* personAuthority(headers); + + // Deciding needs the department's reach; the command takes it as evidence. + const decider = yield* Effect.fromResult( + requireDepartmentReach(auth.authority, "admissions.outcomes", selected.departmentId), + ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); + + yield* authorize(headers, RecordAdmissionOutcome, selected.departmentId, false, auth); + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: `Person:${auth.authority.personId}`, + qualifiedOperationId: operationId, + normalizedTarget: normalizeTarget("/api/admission-outcomes/{applicationId}:record", { + applicationId, + }), + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest( + semanticMutationRequest({ outcome: command.outcome }, ifMatch), + ), + operationId, + }, + execute: Admissions.use((admissions) => + admissions.recordAdmissionOutcome( + { applicationId, command, decider, now: auth.authorizationInstant }, + (current) => + Effect.flatMap(outcomeResource(current), (resource) => + requireCurrentETag(resource.etag, ifMatch), + ), + ), + ).pipe( + Effect.flatMap(outcomeResource), + Effect.flatMap(strictOutput(AdmissionOutcomeResource)), + Effect.flatMap(successCapsule(AdmissionOutcomeResource)), + ), + }; + }), + ).pipe(outcomeProblems(personPresentation(headers)), commandReceiptProblems); + + return yield* commandOutcome(AdmissionOutcomeResource)(outcome); + }), + }); +}; diff --git a/apps/backend/src/admission/outcome.rpc.test.ts b/apps/backend/src/admission/outcome.rpc.test.ts new file mode 100644 index 000000000..026206506 --- /dev/null +++ b/apps/backend/src/admission/outcome.rpc.test.ts @@ -0,0 +1,256 @@ +import { Database, IdentitySnapshot } from "@vektorprogrammet/database"; +import { AdmissionsLive } from "@vektorprogrammet/database/admissions"; +import { OrganizationLive } from "@vektorprogrammet/database/organization"; +import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; +import { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; +import { DepartmentId, PersonId, SemesterId } from "@vektorprogrammet/domain/organization"; +import { IdempotencyKey, isProblem, type Problem } from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Layer, Predicate } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { describe, expect, it } from "@effect/vitest"; +import { backendDatabase } from "../../test/database.js"; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; + +const departmentA = DepartmentId.make("outcome-department-a"); + +const departmentB = DepartmentId.make("outcome-department-b"); + +const semesterId = SemesterId.make("outcome-semester"); + +const leaderA = "outcome-leader-a"; + +const memberA = "outcome-member-a"; + +const applicationA = PublicApplicationIdSchema.make("outcome-application-a"); + +const applicationB = PublicApplicationIdSchema.make("outcome-application-b"); + +/** + * Two independent departments with one application each. The leader of department A's board + * decides its outcomes; a member of another team of A only reads who is on call. + */ +const seed = Database.use((sql) => + Effect.gen(function* () { + yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES (${semesterId}, '2026-01-01T00:00:00.000Z', '2027-01-01T00:00:00.000Z')`; + + for (const [departmentId, suffix] of [ + [departmentA, "a"], + [departmentB, "b"], + ] as const) { + yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES (${departmentId}, ${`Outcomes ${suffix}`})`; + yield* sql`INSERT INTO organization_departments (department_id, name, short_name, email, city, independent) VALUES (${departmentId}, ${`Outcomes ${suffix}`}, ${`O${suffix}`}, ${`outcomes-${suffix}@example.invalid`}, 'Trondheim', TRUE)`; + yield* sql`INSERT INTO admission_periods (admission_period_id, department_id, semester_id, start_at, end_at, last_command_id) VALUES (${`outcome-period-${suffix}`}, ${departmentId}, ${semesterId}, '2026-01-01T00:00:00.000Z', '2027-01-01T00:00:00.000Z', 'seed')`; + yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name) VALUES (${`outcome-field-${suffix}`}, ${departmentId}, 'Matematikk')`; + yield* sql`INSERT INTO admission_applicants (applicant_id, normalized_email, email, first_name, last_name, phone, gender, field_of_study_id, year_of_study) VALUES (${`outcome-applicant-${suffix}`}, ${`candidate-${suffix}@example.invalid`}, ${`candidate-${suffix}@example.invalid`}, 'Ada', 'Candidate', '12345678', 0, ${`outcome-field-${suffix}`}, 2)`; + yield* sql`INSERT INTO admission_applications (application_id, applicant_id, admission_period_id, department_id, field_of_study_id, year_of_study, submitted_at) VALUES (${`outcome-application-${suffix}`}, ${`outcome-applicant-${suffix}`}, ${`outcome-period-${suffix}`}, ${departmentId}, ${`outcome-field-${suffix}`}, 2, '2026-02-01T00:00:00.000Z')`; + } + + yield* sql`INSERT INTO organization_teams (team_id, department_id, name, kind) VALUES ('outcome-board-a', ${departmentA}, 'Styret', 'DepartmentBoard')`; + yield* sql`INSERT INTO organization_teams (team_id, department_id, name) VALUES ('outcome-team-a', ${departmentA}, 'Skolekoordinering')`; + yield* sql`INSERT INTO person_profiles (person_id, first_name, last_name) VALUES (${leaderA}, 'Lise', 'Leader'), (${memberA}, 'Mia', 'Member')`; + yield* sql` + INSERT INTO organization_memberships (membership_id, person_id, team_id, start_at, position_id, is_team_leader) + VALUES + ('outcome-leader-membership', ${leaderA}, 'outcome-board-a', '2020-01-01T00:00:00.000Z', 'leader', TRUE), + ('outcome-member-membership', ${memberA}, 'outcome-team-a', '2020-01-01T00:00:00.000Z', NULL, FALSE) + `; + }), +); + +/** Every session cookie names its person. */ +const sessionActor = (cookie: string | undefined) => { + const person = /better-auth\.session_token=([^;]+)/u.exec(cookie ?? "")?.[1]; + + return person === undefined + ? undefined + : IdentityActor.make({ + personId: PersonId.make(person), + sessionId: `session-${person}`, + expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), + }); +}; + +const identitySnapshot = IdentitySnapshot.of({ + resolveSession: (cookie) => { + const actor = sessionActor(cookie); + + return actor === undefined + ? Effect.fail(IdentitySessionNotFound.make()) + : Effect.succeed(actor); + }, + revokeCurrentSession: () => Effect.die("unexpected session mutation"), + revokeSession: () => Effect.die("unexpected session mutation"), + revokeOtherSessions: () => Effect.die("unexpected session mutation"), + revokeAllSessions: () => Effect.die("unexpected session mutation"), +}); + +const identity = Identity.of({ + signIn: () => Effect.die("unexpected sign-in"), + resolveSession: (cookie: string | undefined) => { + const actor = sessionActor(cookie); + + return actor === undefined + ? Effect.fail(IdentitySessionNotFound.make()) + : Effect.succeed(actor); + }, + readCurrentSession: () => Effect.die("unexpected session read"), + listSessions: () => Effect.die("unexpected session list"), + revokeCurrentSession: () => Effect.die("unexpected session mutation"), + revokeSession: () => Effect.die("unexpected session mutation"), + revokeOtherSessions: () => Effect.die("unexpected session mutation"), + revokeAllSessions: () => Effect.die("unexpected session mutation"), + recordSecurityEvent: () => Effect.die("unexpected identity audit"), + signOut: () => Effect.succeed({ setCookies: [] }), +} satisfies IdentityOperations); + +const fixture = () => + Effect.gen(function* () { + const database = backendDatabase(seed); + + const backend = makeBackendTestRpc({ + config: backendTestConfig, + services: Layer.mergeAll( + AdmissionsLive, + OrganizationLive, + Layer.succeed(IdentitySnapshot, identitySnapshot), + Layer.succeed(Identity, identity), + ).pipe(Layer.provideMerge(database.layer)), + }); + + const client = yield* backend.client; + + /** Runs one call as `personId`, with that person's session cookie. */ + const as = (effect: Effect.Effect, personId: string) => + RpcClient.withHeaders(effect, { cookie: `better-auth.session_token=${personId}` }); + + const history = (applicationId: string) => + database.run( + Database.use( + (sql) => + sql<{ readonly revision: number; readonly outcome: string }>` + SELECT revision, outcome FROM admission_application_outcomes + WHERE application_id = ${applicationId} ORDER BY revision + `, + ), + ); + + return { client, as, history }; + }); + +const key = (value: string) => IdempotencyKey.make(value.padEnd(22, "0")); + +/** The problem code of a failed call; a transport failure has none. */ +const codeOf = (failure: Problem | RpcClientError) => + isProblem(failure) ? failure.code : "defect"; + +describe("admission outcomes over RPC and PostgreSQL", () => { + it.live("lets the board leader decide, replay, and conflict on a stale version", () => + Effect.gen(function* () { + const { client, as, history } = yield* fixture(); + + const scopes = yield* as(client["admissionOutcomes.listScopes"](), leaderA); + expect(scopes.departments.map((department) => department.departmentId)).toEqual([ + departmentA, + ]); + + const board = yield* as( + client["admissionOutcomes.readOutcomes"]({ departmentId: departmentA, semesterId }), + leaderA, + ); + + if (!Predicate.isTagged(board, "Decide")) throw new Error("The leader decides the board"); + expect(board.entries.map((entry) => entry.applicationId)).toEqual([applicationA]); + + const entry = yield* as( + client["admissionOutcomes.readOutcome"]({ applicationId: applicationA }), + leaderA, + ); + + expect(entry.etag).toBe(board.entries[0]?.etag); + + const record = as( + client["admissionOutcomes.recordOutcome"]({ + applicationId: applicationA, + idempotencyKey: key("outcomeSubstitute"), + ifMatch: entry.etag, + request: { outcome: "Substitute" }, + }), + leaderA, + ); + + const recorded = yield* record; + expect(recorded).toMatchObject({ outcome: "Substitute", revision: 1 }); + expect(recorded.etag).not.toBe(entry.etag); + expect(yield* record).toEqual(recorded); + + const stale = yield* as( + client["admissionOutcomes.recordOutcome"]({ + applicationId: applicationA, + idempotencyKey: key("outcomeStale"), + ifMatch: entry.etag, + request: { outcome: "Rejected" }, + }), + leaderA, + ).pipe(Effect.flip); + + expect(codeOf(stale)).toBe("precondition.failed"); + expect(yield* history(applicationA)).toEqual([{ revision: 1, outcome: "Substitute" }]); + }), + ); + + it.live("refuses a record on another department's application with authority.denied", () => + Effect.gen(function* () { + const { client, as, history } = yield* fixture(); + + // The leader reaches department A only, so the evidence for B's application is refused + // before any read of its version or any write. + const denied = yield* as( + client["admissionOutcomes.recordOutcome"]({ + applicationId: applicationB, + idempotencyKey: key("outcomeForeign"), + ifMatch: (yield* as( + client["admissionOutcomes.readOutcome"]({ applicationId: applicationA }), + leaderA, + )).etag, + request: { outcome: "Admitted" }, + }), + leaderA, + ).pipe(Effect.flip); + + expect(codeOf(denied)).toBe("authority.denied"); + expect(yield* history(applicationB)).toEqual([]); + }), + ); + + it.live("shows a member only who is on call and denies the member's reads and records", () => + Effect.gen(function* () { + const { client, as } = yield* fixture(); + + const board = yield* as( + client["admissionOutcomes.readOutcomes"]({ departmentId: departmentA, semesterId }), + memberA, + ); + + if (!Predicate.isTagged(board, "ReadOnly")) throw new Error("A member only reads the board"); + expect(board.substitutes).toEqual([]); + + const read = yield* as( + client["admissionOutcomes.readOutcome"]({ applicationId: applicationA }), + memberA, + ).pipe(Effect.flip); + + expect(codeOf(read)).toBe("authority.denied"); + + const anonymous = yield* client["admissionOutcomes.listScopes"]().pipe(Effect.flip); + + expect(codeOf(anonymous)).toBe("credential.missing"); + }), + ); +}); diff --git a/apps/backend/src/admission/http-problem.ts b/apps/backend/src/admission/problem.ts similarity index 70% rename from apps/backend/src/admission/http-problem.ts rename to apps/backend/src/admission/problem.ts index e00fd6d48..43343717b 100644 --- a/apps/backend/src/admission/http-problem.ts +++ b/apps/backend/src/admission/problem.ts @@ -5,7 +5,8 @@ import type { ReturningAssistantError, } from "@vektorprogrammet/domain/application"; import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import { makeNativeValidationError, Problem } from "@vektorprogrammet/http-api/http-semantics"; +import { makeNativeValidationError, Problem } from "@vektorprogrammet/rpc/problem"; +import type { Headers } from "effect/unstable/http"; import type { OrganizationResolutionError } from "../authority.js"; import { type CredentialCases, @@ -15,7 +16,8 @@ import { type ProblemMapper, problemMapper, requestInvalid, -} from "../http-api/problem.js"; +} from "../rpc/problem.js"; +import { dual } from "effect/Function"; /** Public applications answer a spent rate limit with one fixed delay. */ const RATE_LIMIT_RETRY_AFTER_SECONDS = 60; @@ -28,7 +30,7 @@ type AdmissionFailure = | IdentityEngineError | OrganizationResolutionError; -/** The unavailable problem of an admission endpoint: a read, a command, or a returning assistant. */ +/** The unavailable problem of an admission RPC: a read, a command, or a returning assistant. */ type AdmissionUnavailable = | "admissions.unavailable" | "dependency.unavailable" @@ -37,7 +39,7 @@ type AdmissionUnavailable = /** The failures that answer from the credential that the request presented. */ type AdmissionCredentialTag = "UnauthenticatedActor" | "ReturningAssistantUnauthenticated"; -/** The failures of a store or dependency, which answer the endpoint's unavailable problem. */ +/** The failures of a store or dependency, which answer the RPC's unavailable problem. */ type AdmissionOutageTag = | "AdmissionPeriodPersistenceError" | "PublicApplicationPersistenceError" @@ -50,7 +52,7 @@ const admissionFixedCases = { InactiveActor: () => Problem.make("authority.denied"), AdmissionRoleDenied: () => Problem.make("authority.denied"), AdmissionScopeDenied: () => Problem.make("authority.denied"), - // The domain decodes the whole command, so the rejection names the whole body. + // The domain decodes the whole command, so the rejection names the whole request. AdmissionPeriodDecodeError: requestInvalid, PublicApplicationDecodeError: requestInvalid, ReturningAssistantDecodeError: requestInvalid, @@ -104,46 +106,57 @@ type AdmissionCases = typeof admission * The one answer for every admission failure. * * @remarks - * A failed store or dependency answers the endpoint's own unavailable problem, `unavailable`: a - * read answers admissions.unavailable, a command dependency.unavailable, and a returning-assistant - * operation returning.unavailable. A rejected credential answers from the credential that the - * request presented. A command that does not decode answers validation.failed at the root, and an - * unknown department or semester answers validation.failed at its member. + * A failed store or dependency answers the RPC's own unavailable problem, `unavailable`: a read + * answers admissions.unavailable, a command dependency.unavailable, and a returning-assistant + * operation returning.unavailable. A rejected credential answers from the credential headers that + * the request presented. A command that the domain does not decode answers validation.failed at + * the root, and an unknown department or semester answers validation.failed at its member. * * @sideEffects none * * @example * ```ts - * command.pipe(admissionProblems(request, "dependency.unavailable")); + * command.pipe(admissionProblems(headers, "dependency.unavailable")); * ``` * - * @avoid Mapping an admission failure in a handler: the answers of the admission endpoints drift - * apart. Pipe the handler's effect through this, with the endpoint's unavailable problem. + * @avoid Mapping an admission failure in a handler: the answers of the admission RPCs drift + * apart. Pipe the handler's effect through this, with the RPC's unavailable problem. * - * @construct http-problem + * @construct rpc-problem */ -export const admissionProblems = ( - request: Request, - unavailable: Unavailable, -): ProblemMapper> => { - const presentation = personPresentation(request); +export const admissionProblems: { + ( + unavailable: Unavailable, + ): (headers: Headers.Headers) => ProblemMapper>; + ( + headers: Headers.Headers, + unavailable: Unavailable, + ): ProblemMapper>; +} = dual( + 2, + ( + headers: Headers.Headers, + unavailable: Unavailable, + ): ProblemMapper> => { + const presentation = personPresentation(headers); - return problemMapper()>({ - ...admissionFixedCases, - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - ReturningAssistantUnauthenticated: () => Problem.unauthenticated(presentation), - AdmissionPeriodPersistenceError: () => Problem.make(unavailable), - PublicApplicationPersistenceError: () => Problem.make(unavailable), - PublicApplicationQueryLimitExceeded: () => Problem.make(unavailable), - IdentityEngineError: () => Problem.make(unavailable), - OrganizationDecodeError: () => Problem.make(unavailable), - OrganizationPersistenceError: () => Problem.make(unavailable), - }); -}; + return problemMapper()>({ + ...admissionFixedCases, + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + ReturningAssistantUnauthenticated: () => Problem.unauthenticated(presentation), + AdmissionPeriodPersistenceError: () => Problem.make(unavailable), + PublicApplicationPersistenceError: () => Problem.make(unavailable), + PublicApplicationQueryLimitExceeded: () => Problem.make(unavailable), + IdentityEngineError: () => Problem.make(unavailable), + OrganizationDecodeError: () => Problem.make(unavailable), + OrganizationPersistenceError: () => Problem.make(unavailable), + }); + }, +); /** - * Problems only a public application submission answers. The catalog, a - * confirmation, and applicant progress read rows at the clock's own instant. + * Problems only a public application submission answers. The catalog, a confirmation, and + * applicant progress read rows at the clock's own instant. */ export const submissionProblems = [ "validation.failed", @@ -157,9 +170,8 @@ export const submissionProblems = [ ] as const; /** - * Problems only an admission period command answers. A listing reads - * projections at the clock's own instant; it names no department, semester, - * window, or command. + * Problems only an admission period command answers. A listing reads projections at the clock's + * own instant; it names no department, semester, window, or command. */ export const periodCommandProblems = [ "validation.failed", diff --git a/apps/backend/src/admission/reads.ts b/apps/backend/src/admission/reads.ts new file mode 100644 index 000000000..33fb1d001 --- /dev/null +++ b/apps/backend/src/admission/reads.ts @@ -0,0 +1,344 @@ +/** Admission read handlers: admission periods, public applications, and returning assistants. */ +import { Database } from "@vektorprogrammet/database"; +import { Admissions } from "@vektorprogrammet/domain/admissions"; +import { + ApplicantProgressResponseSchema, + ReturningAssistantOptionsSchema, + ReturningAssistants, + type PublicApplicationId, +} from "@vektorprogrammet/domain/application"; +import { + AdmissionPeriodManagementListResponse, + ListAdmissionPeriods, + ListApplicationOptions, + ListOpenAdmissionPeriods, + OpenAdmissionPeriodListResponse, + PublicApplicationCatalogSchema, + PublicApplicationConfirmationSchema, + ReadApplicantProgress, + ReadApplicationConfirmation, + ReadReturningAssistantOptions, + reflectAccessSpec, +} from "@vektorprogrammet/rpc"; +import { Problem } from "@vektorprogrammet/rpc/problem"; +import { Effect, Option, Predicate } from "effect"; +import type { Headers } from "effect/unstable/http"; +import { + currentInstant, + resolveRequestPersonAuthority, + resolveRequestPersonAuthorityInTransaction, +} from "../authority.js"; +import { deriveStrongETag } from "../http-semantics.js"; +import { genericContext } from "../native-operation.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { authorizeAnonymous, strictOutput, unreachable } from "../rpc/problem.js"; +import { + admissionGrantScopes, + authorizeAdmissionPerson, + returningAuthorization, + returningPersonResource, +} from "./access.js"; +import { admissionActorForAuthority } from "./context.js"; +import { admissionProblems, periodCommandProblems, submissionProblems } from "./problem.js"; +import { dual } from "effect/Function"; + +const readReturningAssistantOptionsEffect = (headers: Headers.Headers, options: NativeRpcOptions) => + Effect.gen(function* () { + const authorization = yield* returningAuthorization( + headers, + options, + ReadReturningAssistantOptions, + ); + + const returningOptions = yield* ReturningAssistants.use(({ readOptions }) => + readOptions({ + personId: authorization.authority.personId, + now: authorization.authorizationInstant, + }), + ); + + return yield* strictOutput(ReturningAssistantOptionsSchema)(returningOptions); + }).pipe( + admissionProblems(headers, "returning.unavailable"), + // Reading the options selects no team and runs no command. + unreachable( + "returning.team-scope-denied", + "returning.revision-conflict", + "idempotency.digest-conflict", + ), + ); + +export const readReturningAssistantOptions: { + ( + options: NativeRpcOptions, + ): (headers: Headers.Headers) => ReturnType; + ( + headers: Headers.Headers, + options: NativeRpcOptions, + ): ReturnType; +} = dual(2, readReturningAssistantOptionsEffect); + +const listAdmissionPeriodsEffect = (headers: Headers.Headers, options: NativeRpcOptions) => + Effect.gen(function* () { + const authority = yield* resolveRequestPersonAuthority(credentialRequestOf(headers), { + now: options.now, + }); + + const actor = yield* admissionActorForAuthority(authority); + const now = yield* currentInstant(options.config.admission.now); + + yield* authorizeAdmissionPerson(headers, { + spec: Option.getOrThrow(reflectAccessSpec(ListAdmissionPeriods)), + request: credentialRequestOf(headers), + personId: actor.personId, + resolution: { + selection: "AllMatching", + contexts: [ + genericContext({ + domainId: "admissions", + departmentId: Predicate.isTagged(actor, "DepartmentAdministrator") + ? actor.departmentId + : null, + authorityVersion: `admissions:${actor._tag}`, + }), + ], + }, + grantScopes: admissionGrantScopes(actor), + now, + }); + + const rows = yield* Admissions.use(({ listAdmissionPeriodsForManagement }) => + listAdmissionPeriodsForManagement({ actor, now }), + ); + + const items = rows.map((row) => ({ + id: row.id, + departmentId: row.departmentId, + semesterId: row.semesterId, + startAt: row.startAt, + endAt: row.endAt, + revision: row.revision, + etag: deriveStrongETag({ + representationKind: "AdmissionPeriodManagementItem", + resourceIdentity: row.id, + version: row.revision, + }), + })); + + return yield* strictOutput(AdmissionPeriodManagementListResponse)({ + items, + totalItems: items.length, + }); + }).pipe( + admissionProblems(headers, "admissions.unavailable"), + // A listing runs no revision, so no revision of it is stale. + unreachable(...periodCommandProblems, "precondition.failed"), + ); + +export const listAdmissionPeriods: { + ( + options: NativeRpcOptions, + ): (headers: Headers.Headers) => ReturnType; + ( + headers: Headers.Headers, + options: NativeRpcOptions, + ): ReturnType; +} = dual(2, listAdmissionPeriodsEffect); + +const listOpenAdmissionPeriodsEffect = (headers: Headers.Headers, options: NativeRpcOptions) => + Effect.gen(function* () { + const now = yield* currentInstant(options.config.admission.now); + + yield* authorizeAnonymous( + Option.getOrThrow(reflectAccessSpec(ListOpenAdmissionPeriods)), + { + selection: "AllMatching", + contexts: [ + genericContext({ + domainId: "admissions", + authorityVersion: `admissions-open:${now}`, + }), + ], + }, + now, + ); + + const rows = yield* Admissions.use(({ listOpenAdmissionPeriods }) => + listOpenAdmissionPeriods(now), + ); + + return yield* strictOutput(OpenAdmissionPeriodListResponse)({ + items: rows.map((row) => ({ + id: row.id, + departmentId: row.departmentId, + semesterId: row.semesterId, + startAt: row.startAt, + endAt: row.endAt, + })), + totalItems: rows.length, + }); + }).pipe( + admissionProblems(headers, "admissions.unavailable"), + // The open listing resolves no actor and runs no revision. + unreachable( + ...periodCommandProblems, + "precondition.failed", + "credential.missing", + "credential.invalid", + "authority.denied", + ), + ); + +export const listOpenAdmissionPeriods: { + ( + options: NativeRpcOptions, + ): (headers: Headers.Headers) => ReturnType; + ( + headers: Headers.Headers, + options: NativeRpcOptions, + ): ReturnType; +} = dual(2, listOpenAdmissionPeriodsEffect); + +const listApplicationOptionsEffect = (headers: Headers.Headers, options: NativeRpcOptions) => + Effect.gen(function* () { + const now = yield* currentInstant(options.config.admission.now); + + yield* authorizeAnonymous( + Option.getOrThrow(reflectAccessSpec(ListApplicationOptions)), + { + selection: "AllMatching", + contexts: [ + genericContext({ + domainId: "admissions", + authorityVersion: `admissions-catalog:${now}`, + }), + ], + }, + now, + ); + + const source = yield* Admissions.use(({ listPublicApplicationCatalog }) => + listPublicApplicationCatalog({ now }), + ); + + return yield* strictOutput(PublicApplicationCatalogSchema)(source.catalog); + }).pipe( + admissionProblems(headers, "admissions.unavailable"), + unreachable(...submissionProblems, "application.not-found"), + ); + +export const listApplicationOptions: { + ( + options: NativeRpcOptions, + ): (headers: Headers.Headers) => ReturnType; + ( + headers: Headers.Headers, + options: NativeRpcOptions, + ): ReturnType; +} = dual(2, listApplicationOptionsEffect); + +const readApplicationConfirmationEffect = ( + headers: Headers.Headers, + applicationId: PublicApplicationId, + options: NativeRpcOptions, +) => + Effect.gen(function* () { + const now = yield* currentInstant(options.config.admission.now); + + yield* authorizeAnonymous( + Option.getOrThrow(reflectAccessSpec(ReadApplicationConfirmation)), + { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "admissions", + resourceKind: "application", + resourceId: applicationId, + authorityVersion: `admissions-application:${applicationId}`, + }), + ], + }, + now, + ); + + const confirmation = yield* Admissions.use(({ findPublicApplicationConfirmation }) => + findPublicApplicationConfirmation(applicationId), + ); + + return yield* strictOutput(PublicApplicationConfirmationSchema)(confirmation); + }).pipe( + admissionProblems(headers, "admissions.unavailable"), + // The payload already decoded the identifier the confirmation lookup decodes again. + unreachable(...submissionProblems), + ); + +export const readApplicationConfirmation: { + ( + applicationId: PublicApplicationId, + options: NativeRpcOptions, + ): (headers: Headers.Headers) => ReturnType; + ( + headers: Headers.Headers, + applicationId: PublicApplicationId, + options: NativeRpcOptions, + ): ReturnType; +} = dual(3, readApplicationConfirmationEffect); + +const readApplicantProgressEffect = (headers: Headers.Headers, options: NativeRpcOptions) => + Database.use((sql) => + sql.withTransaction( + Effect.gen(function* () { + yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`; + + const authorization = yield* resolveRequestPersonAuthorityInTransaction( + credentialRequestOf(headers), + { now: options.config.admission.now }, + ); + + yield* authorizeAdmissionPerson(headers, { + spec: Option.getOrThrow(reflectAccessSpec(ReadApplicantProgress)), + credential: authorization.credential, + personId: authorization.authority.personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "admissions", + resourceKind: "person-profile", + resourceId: authorization.authority.personId, + facts: { ownerPersonId: authorization.authority.personId }, + authorityVersion: "admissions:applicant-progress", + }), + ], + }, + grantScopes: [returningPersonResource(authorization.authority.personId)], + now: authorization.authorizationInstant, + }); + + const progress = yield* Admissions.use(({ readApplicantProgress }) => + readApplicantProgress( + authorization.authority.personId, + authorization.authorizationInstant, + ), + ); + + return yield* strictOutput(ApplicantProgressResponseSchema)(progress); + }), + ), + ).pipe( + // A snapshot the database cannot open or commit leaves the service unavailable. + Effect.catchTag("SqlError", () => Effect.fail(Problem.make("admissions.unavailable"))), + admissionProblems(headers, "admissions.unavailable"), + unreachable(...submissionProblems, "application.not-found"), + ); + +export const readApplicantProgress: { + ( + options: NativeRpcOptions, + ): (headers: Headers.Headers) => ReturnType; + ( + headers: Headers.Headers, + options: NativeRpcOptions, + ): ReturnType; +} = dual(2, readApplicantProgressEffect); diff --git a/apps/backend/src/admission/rpc.ts b/apps/backend/src/admission/rpc.ts new file mode 100644 index 000000000..79422ad33 --- /dev/null +++ b/apps/backend/src/admission/rpc.ts @@ -0,0 +1,48 @@ +/** + * The AdmissionsRpcs handlers. A read resolves the credential and the authority as the HTTP + * handler did, and evaluates the RPC's AccessSpec; a public read evaluates it for the anonymous + * principal. A command resolves them inside the serializable transaction that commits it, and + * stores its success as a command receipt, so a retry with the same idempotency key replays the + * first answer. + */ +import { AdmissionsRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { + createAdmissionPeriod, + registerReturningAssistant, + reviseAdmissionPeriod, + submitApplication, +} from "./commands.js"; +import { + listAdmissionPeriods, + listApplicationOptions, + listOpenAdmissionPeriods, + readApplicantProgress, + readApplicationConfirmation, + readReturningAssistantOptions, +} from "./reads.js"; + +/** The AdmissionsRpcs handlers. */ +export const AdmissionsRpcHandlers = (options: NativeRpcOptions) => + AdmissionsRpcs.toLayer({ + "admissions.listOpenAdmissionPeriods": (_payload, { headers }) => + listOpenAdmissionPeriods(headers, options), + "admissions.listApplicationOptions": (_payload, { headers }) => + listApplicationOptions(headers, options), + "admissions.submitApplication": (payload, { headers }) => + submitApplication(headers, payload, options), + "admissions.readApplicationConfirmation": ({ applicationId }, { headers }) => + readApplicationConfirmation(headers, applicationId, options), + "admissions.readApplicantProgress": (_payload, { headers }) => + readApplicantProgress(headers, options), + "admissions.listAdmissionPeriods": (_payload, { headers }) => + listAdmissionPeriods(headers, options), + "admissions.createAdmissionPeriod": (payload, { headers }) => + createAdmissionPeriod(headers, payload, options), + "admissions.reviseAdmissionPeriod": (payload, { headers }) => + reviseAdmissionPeriod(headers, payload, options), + "admissions.readReturningAssistantOptions": (_payload, { headers }) => + readReturningAssistantOptions(headers, options), + "admissions.registerReturningAssistant": (payload, { headers }) => + registerReturningAssistant(headers, payload, options), + }); diff --git a/apps/backend/src/admission/submit-application.http.test.ts b/apps/backend/src/admission/submit-application.http.test.ts deleted file mode 100644 index 4a7fe2969..000000000 --- a/apps/backend/src/admission/submit-application.http.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { randomBytes } from "node:crypto"; -import { Database } from "@vektorprogrammet/database"; -import { AdmissionsLive } from "@vektorprogrammet/database/admissions"; -import { DatabaseRuntimeLive } from "@vektorprogrammet/database/runtime"; -import { Admissions } from "@vektorprogrammet/domain/admissions"; -import { PublicApplicationSubmitInputSchema } from "@vektorprogrammet/domain/application"; -import { - AdmissionsSubmitApplicationProblem, - PublicApplicationConfirmationSchema, -} from "@vektorprogrammet/http-api"; -import { makeNativeValidationError } from "@vektorprogrammet/http-api/http-semantics"; -import { Deferred, Effect, Fiber, Layer, Schedule, Schema } from "effect"; -import { describe, expect, it } from "@effect/vitest"; -import { backendDatabase } from "../../test/database.js"; -import { decodeBackendConfig } from "../config.js"; -import { jsonText } from "../http-api/problem.js"; -import { makeBackendTestHttp } from "../test/native-http.js"; - -const environment = { - BACKEND_PG_URL: "postgres://test.invalid/vektorprogrammet", - BETTER_AUTH_SECRET: "admission-http-test-secret-with-32-characters", - NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify(["http://127.0.0.1:5174"]), - OAUTH_CANONICAL_ORIGIN: "http://127.0.0.1:5174", - OAUTH_DASHBOARD_ORIGIN: "http://127.0.0.1:5174", - OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - PUBLIC_APPLICATION_EFFECT_MODE: "disabled", - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_MODE: "disabled", - ADMISSION_FIXED_NOW: "2031-09-15T12:00:00.000Z", - ADMISSION_RATE_LIMIT_MAX: "100", -} as const; - -/** One open period for one department, with one active field of study. */ -const openPeriod = Database.use((sql) => - Effect.gen(function* () { - yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES ('department-http', 'Trondheim')`; - yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ('semester-http', '2031-08-01T00:00:00.000Z', '2032-01-01T00:00:00.000Z')`; - yield* sql` - INSERT INTO admission_periods (admission_period_id, department_id, semester_id, start_at, end_at, last_command_id) - VALUES ('period-http', 'department-http', 'semester-http', '2031-09-01T08:00:00.000Z', '2031-10-01T20:00:00.000Z', 'period-http-created') - `; - yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name) VALUES ('field-http', 'department-http', 'Matematikk')`; - }), -); - -const unavailableAuthHandler = { - handler: () => Effect.succeed(new Response(null, { status: 404 })), - recordTrustedOriginRejection: () => Effect.void, -}; - -const application = { - departmentId: "department-http", - firstName: "Ada", - lastName: "Applicant", - phone: "+47 900 00 000", - email: "ada@example.invalid", - gender: 0, - fieldOfStudyId: "field-http", - yearOfStudy: 3, - availability: { - mondayUnavailable: false, - tuesdayUnavailable: true, - wednesdayUnavailable: false, - thursdayUnavailable: false, - fridayUnavailable: true, - positionWeeks: 4, - preferredGroup: "block-2", - language: "Norsk og engelsk", - }, -}; - -/** Each fixture is one backend process with its own database and public rate limit. */ -const fixture = () => { - const database = backendDatabase(openPeriod); - - const http = makeBackendTestHttp( - Effect.runSync(decodeBackendConfig(environment)), - Layer.mergeAll(database.layer, AdmissionsLive.pipe(Layer.provide(database.layer))), - unavailableAuthHandler, - ); - - const submit = ( - idempotencyKey: string, - request: { - readonly body?: string; - readonly contentType?: string; - readonly path?: string; - } = {}, - ) => - http.fetch( - new Request(`http://backend.test${request.path ?? "/api/applications"}`, { - method: "POST", - headers: { - "content-type": request.contentType ?? "application/json", - "idempotency-key": idempotencyKey.padEnd(22, "0"), - }, - body: request.body ?? JSON.stringify(application), - }), - ); - - const read = (applicationId: string) => - http.fetch(new Request(`http://backend.test/api/applications/${applicationId}`)); - - const count = (table: string) => - database.run( - Database.use((sql) => - sql.unsafe<{ readonly count: number }>(`SELECT count(*)::integer AS count FROM ${table}`), - ).pipe(Effect.map((rows) => rows[0]?.count ?? 0)), - ); - - /** A second PostgreSQL session on the fixture database, outside the handler's connection. */ - const competitor = () => - Effect.gen(function* () { - const [target] = yield* database.run( - Database.use( - (sql) => - sql<{ - readonly host: string; - readonly port: number; - readonly database: string; - readonly username: string; - }>`SELECT current_setting('unix_socket_directories') AS host, current_setting('port')::integer AS port, current_database() AS database, current_user AS username`, - ), - ); - - if (target === undefined) throw new Error("Missing PostgreSQL connection configuration"); - - return DatabaseRuntimeLive({ ...target, maxConnections: 1 }).pipe(Layer.orDie); - }); - - return { database, submit, read, count, competitor }; -}; - -/** Decodes a rejection through the endpoint's closed Problem Details union. */ -const problem = (response: Response) => - Effect.gen(function* () { - expect(response.headers.get("content-type")).toBe("application/problem+json"); - - return yield* Schema.decodeUnknownEffect(AdmissionsSubmitApplicationProblem)( - yield* Effect.promise(() => response.json()), - { - onExcessProperty: "error", - }, - ); - }); - -describe("public application submission over HTTP", () => { - it.live("rejects an unknown department as a validation failure at /departmentId", () => - Effect.gen(function* () { - const { submit, count } = fixture(); - - const response = yield* submit("unknownDepartment", { - body: yield* jsonText({ ...application, departmentId: "department-unknown" }), - }); - - expect(response.status).toBe(422); - expect(yield* problem(response)).toMatchObject({ - code: "validation.failed", - validation: { - errors: [{ pointer: "/departmentId", code: "invalid" }], - truncated: false, - }, - }); - expect(yield* count("admission_applications")).toBe(0); - expect(yield* count("native_http_idempotency_receipts")).toBe(0); - }), - ); - - it.live("reads the confirmation of a submitted application and stores its availability", () => - Effect.gen(function* () { - const { database, submit, read } = fixture(); - const submitted = yield* submit("confirmedApplication"); - - expect(submitted.status).toBe(201); - - const { applicationId } = yield* Schema.decodeUnknownEffect( - PublicApplicationConfirmationSchema, - )(yield* Effect.promise(() => submitted.json()), { onExcessProperty: "error" }); - - const confirmation = yield* read(applicationId); - - expect(confirmation.status).toBe(200); - expect( - yield* Schema.decodeUnknownEffect(PublicApplicationConfirmationSchema)( - yield* Effect.promise(() => confirmation.json()), - { - onExcessProperty: "error", - }, - ), - ).toEqual(PublicApplicationConfirmationSchema.make({ applicationId })); - - const stored = yield* database.run( - Database.use( - (sql) => sql` - SELECT monday_unavailable AS "mondayUnavailable", - tuesday_unavailable AS "tuesdayUnavailable", - wednesday_unavailable AS "wednesdayUnavailable", - thursday_unavailable AS "thursdayUnavailable", - friday_unavailable AS "fridayUnavailable", - position_weeks AS "positionWeeks", - preferred_group AS "preferredGroup", - language - FROM admission_applications - WHERE application_id = ${applicationId} - `, - ), - ); - - expect(stored).toEqual([application.availability]); - }), - ); - - it.live("answers the loser of a concurrent same-applicant race as a duplicate", () => - Effect.gen(function* () { - const { submit, count, competitor } = fixture(); - const connection = yield* competitor(); - const lockHeld = yield* Deferred.make(); - - const input = yield* Schema.decodeUnknownEffect(PublicApplicationSubmitInputSchema)({ - ...application, - commandId: "race-winner", - }); - - // The competing submission commits only after the HTTP submission waits for the - // applicant identity lock, so the HTTP transaction's SERIALIZABLE snapshot is - // older than the committed winner. - const winner = yield* Database.use((sql) => - sql.withTransaction( - Effect.gen(function* () { - yield* Admissions.use((admissions) => - admissions.executePublicApplication(input, { - now: environment.ADMISSION_FIXED_NOW, - activationToken: randomBytes(32).toString("base64url"), - }), - ); - yield* Deferred.succeed(lockHeld, undefined); - - return yield* sql<{ readonly waiting: number }>` - SELECT count(*)::integer AS waiting - FROM pg_locks - WHERE locktype = 'advisory' AND NOT granted - `.pipe( - Effect.map((rows) => rows[0]?.waiting ?? 0), - Effect.repeat({ - until: (waiting) => waiting > 0, - times: 400, - schedule: Schedule.spaced("10 millis"), - }), - ); - }), - ), - ).pipe( - Effect.provide(AdmissionsLive), - Effect.provide(connection, { local: true }), - Effect.forkChild, - ); - - yield* Deferred.await(lockHeld); - - const loser = yield* submit("raceLoser", { - body: yield* jsonText({ ...application, email: "ADA@example.invalid" }), - }); - - expect(yield* Fiber.join(winner)).toBe(1); - expect(loser.status).toBe(409); - expect((yield* problem(loser)).code).toBe("application.duplicate"); - expect(yield* count("admission_applicants")).toBe(1); - expect(yield* count("admission_applications")).toBe(1); - expect(yield* count("native_http_idempotency_receipts")).toBe(0); - }), - ); - - it.live("answers a failed application write as dependency.unavailable", () => - Effect.gen(function* () { - const { database, submit, count } = fixture(); - - yield* database.run( - Database.use((sql) => - Effect.gen(function* () { - yield* sql`CREATE FUNCTION reject_application_write() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'application write rejected'; END $$`; - yield* sql`CREATE TRIGGER reject_application_write BEFORE INSERT ON admission_applications FOR EACH ROW EXECUTE FUNCTION reject_application_write()`; - }), - ), - ); - - const response = yield* submit("rejectedWrite"); - - expect(response.status).toBe(503); - expect(response.headers.get("retry-after")).toBe("5"); - expect((yield* problem(response)).code).toBe("dependency.unavailable"); - expect(yield* count("admission_applicants")).toBe(0); - expect(yield* count("native_http_idempotency_receipts")).toBe(0); - }), - ); - - it.live( - "rejects a foreign media type, unparsable JSON, and a query string as request failures", - () => - Effect.gen(function* () { - const { submit, count } = fixture(); - const textPlain = yield* submit("textPlain", { contentType: "text/plain" }); - const unparsable = yield* submit("unparsable", { body: "{" }); - const query = yield* submit("query", { path: "/api/applications?source=homepage" }); - - expect(textPlain.status).toBe(415); - expect((yield* problem(textPlain)).code).toBe("media-type.unsupported"); - expect(unparsable.status).toBe(400); - expect((yield* problem(unparsable)).code).toBe("request.malformed"); - expect(query.status).toBe(400); - expect((yield* problem(query)).code).toBe("request.malformed"); - expect(yield* count("admission_applications")).toBe(0); - }), - ); - - it.live("names every rejected member of an application in its validation member", () => - Effect.gen(function* () { - const { submit, count } = fixture(); - const { lastName: _omitted, ...withoutLastName } = application; - - const response = yield* submit("invalidMembers", { - body: yield* jsonText({ - ...withoutLastName, - firstName: "", - email: "not-an-email", - nickname: "Ada", - availability: { ...application.availability, positionWeeks: 6 }, - }), - }); - - expect(response.status).toBe(422); - expect(yield* problem(response)).toMatchObject({ - code: "validation.failed", - validation: { - errors: [ - makeNativeValidationError("/availability/positionWeeks", "invalid"), - makeNativeValidationError("/email", "invalid"), - makeNativeValidationError("/firstName", "invalid"), - makeNativeValidationError("/lastName", "missing"), - makeNativeValidationError("/nickname", "unknown"), - ], - truncated: false, - }, - }); - expect(yield* count("admission_applications")).toBe(0); - }), - ); -}); diff --git a/apps/backend/src/admission/submit-application.rpc.test.ts b/apps/backend/src/admission/submit-application.rpc.test.ts new file mode 100644 index 000000000..92de43f75 --- /dev/null +++ b/apps/backend/src/admission/submit-application.rpc.test.ts @@ -0,0 +1,288 @@ +import { randomBytes } from "node:crypto"; +import { Database } from "@vektorprogrammet/database"; +import { AdmissionsLive } from "@vektorprogrammet/database/admissions"; +import { DatabaseRuntimeLive } from "@vektorprogrammet/database/runtime"; +import { Admissions } from "@vektorprogrammet/domain/admissions"; +import { + PublicApplicationIdSchema, + PublicApplicationSubmitInputSchema, +} from "@vektorprogrammet/domain/application"; +import { + PublicApplicationConfirmationSchema, + SubmitApplicationRequest, +} from "@vektorprogrammet/rpc"; +import { IdempotencyKey, isProblem, type Problem } from "@vektorprogrammet/rpc/problem"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { Deferred, Effect, Fiber, Layer, Schedule, Schema } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +import { backendDatabase } from "../../test/database.js"; +import { decodeBackendConfig } from "../config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; + +const environment = { + BACKEND_PG_URL: "postgres://test.invalid/vektorprogrammet", + BETTER_AUTH_SECRET: "admission-http-test-secret-with-32-characters", + NATIVE_IDENTITY_DEPLOYMENT: "local", + NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify(["http://127.0.0.1:5174"]), + OAUTH_CANONICAL_ORIGIN: "http://127.0.0.1:5174", + OAUTH_DASHBOARD_ORIGIN: "http://127.0.0.1:5174", + OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", + PUBLIC_APPLICATION_EFFECT_MODE: "disabled", + PASSWORD_RESET_DELIVERY_MODE: "disabled", + RECEIPT_DELIVERY_MODE: "disabled", + ADMISSION_FIXED_NOW: "2031-09-15T12:00:00.000Z", + ADMISSION_RATE_LIMIT_MAX: "100", +} as const; + +/** One open period for one department, with one active field of study. */ +const openPeriod = Database.use((sql) => + Effect.gen(function* () { + yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES ('department-http', 'Trondheim')`; + yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ('semester-http', '2031-08-01T00:00:00.000Z', '2032-01-01T00:00:00.000Z')`; + yield* sql` + INSERT INTO admission_periods (admission_period_id, department_id, semester_id, start_at, end_at, last_command_id) + VALUES ('period-http', 'department-http', 'semester-http', '2031-09-01T08:00:00.000Z', '2031-10-01T20:00:00.000Z', 'period-http-created') + `; + yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name) VALUES ('field-http', 'department-http', 'Matematikk')`; + }), +); + +const applicationSource = { + departmentId: "department-http", + firstName: "Ada", + lastName: "Applicant", + phone: "+47 900 00 000", + email: "ada@example.invalid", + gender: 0, + fieldOfStudyId: "field-http", + yearOfStudy: 3, + availability: { + mondayUnavailable: false, + tuesdayUnavailable: true, + wednesdayUnavailable: false, + thursdayUnavailable: false, + fridayUnavailable: true, + positionWeeks: 4, + preferredGroup: "block-2", + language: "Norsk og engelsk", + }, +}; + +const application = Schema.decodeUnknownSync(SubmitApplicationRequest)(applicationSource); + +/** Each fixture is one backend process with its own database and public rate limit. */ +const fixture = () => + Effect.gen(function* () { + const database = backendDatabase(openPeriod); + + const backend = makeBackendTestRpc({ + config: yield* decodeBackendConfig(environment), + services: Layer.mergeAll(database.layer, AdmissionsLive.pipe(Layer.provide(database.layer))), + }); + + const client = yield* backend.client; + + const submit = (idempotencyKey: string, request: typeof application = application) => + client["admissions.submitApplication"]({ + idempotencyKey: IdempotencyKey.make(idempotencyKey.padEnd(22, "0")), + request, + }); + + const count = (table: string) => + database.run( + Database.use((sql) => + sql.unsafe<{ readonly count: number }>(`SELECT count(*)::integer AS count FROM ${table}`), + ).pipe(Effect.map((rows) => rows[0]?.count ?? 0)), + ); + + /** A second PostgreSQL session on the fixture database, outside the handler's connection. */ + const competitor = () => + Effect.gen(function* () { + const [target] = yield* database.run( + Database.use( + (sql) => + sql<{ + readonly host: string; + readonly port: number; + readonly database: string; + readonly username: string; + }>`SELECT current_setting('unix_socket_directories') AS host, current_setting('port')::integer AS port, current_database() AS database, current_user AS username`, + ), + ); + + if (target === undefined) throw new Error("Missing PostgreSQL connection configuration"); + + return DatabaseRuntimeLive({ ...target, maxConnections: 1 }).pipe(Layer.orDie); + }); + + return { database, client, submit, count, competitor }; + }); + +/** The problem code of a failed call; a transport failure has none. */ +const codeOf = (failure: Problem | RpcClientError) => + isProblem(failure) ? failure.code : "defect"; + +describe("public application submission over RPC", () => { + it.live("rejects an unknown department as a validation failure at /departmentId", () => + Effect.gen(function* () { + const { submit, count } = yield* fixture(); + + const request = yield* Schema.decodeUnknownEffect(SubmitApplicationRequest)({ + ...applicationSource, + departmentId: "department-unknown", + }); + + const failure = yield* submit("unknownDepartment", request).pipe(Effect.flip); + + expect(failure).toMatchObject({ + code: "validation.failed", + validation: { + errors: [{ pointer: "/departmentId", code: "invalid" }], + truncated: false, + }, + }); + expect(yield* count("admission_applications")).toBe(0); + expect(yield* count("native_http_idempotency_receipts")).toBe(0); + }), + ); + + it.live("reads the confirmation of a submitted application and stores its availability", () => + Effect.gen(function* () { + const { database, client, submit } = yield* fixture(); + const submitted = yield* submit("confirmedApplication"); + + const confirmation = yield* client["admissions.readApplicationConfirmation"]({ + applicationId: submitted.applicationId, + }); + + expect(confirmation).toEqual( + PublicApplicationConfirmationSchema.make({ applicationId: submitted.applicationId }), + ); + + const stored = yield* database.run( + Database.use( + (sql) => sql` + SELECT monday_unavailable AS "mondayUnavailable", + tuesday_unavailable AS "tuesdayUnavailable", + wednesday_unavailable AS "wednesdayUnavailable", + thursday_unavailable AS "thursdayUnavailable", + friday_unavailable AS "fridayUnavailable", + position_weeks AS "positionWeeks", + preferred_group AS "preferredGroup", + language + FROM admission_applications + WHERE application_id = ${submitted.applicationId} + `, + ), + ); + + expect(stored).toEqual([applicationSource.availability]); + }), + ); + + it.live("replays a submission by its idempotency key with the first answer", () => + Effect.gen(function* () { + const { submit, count } = yield* fixture(); + const first = yield* submit("replayedApplication"); + const replayed = yield* submit("replayedApplication"); + + expect(replayed).toEqual(first); + expect(yield* count("admission_applications")).toBe(1); + expect(yield* count("native_http_idempotency_receipts")).toBe(1); + }), + ); + + it.live("answers the loser of a concurrent same-applicant race as a duplicate", () => + Effect.gen(function* () { + const { submit, count, competitor } = yield* fixture(); + const connection = yield* competitor(); + const lockHeld = yield* Deferred.make(); + + const input = yield* Schema.decodeUnknownEffect(PublicApplicationSubmitInputSchema)({ + ...applicationSource, + commandId: "race-winner", + }); + + // The competing submission commits only after the RPC submission waits for the applicant + // identity lock, so the RPC transaction's SERIALIZABLE snapshot is older than the winner. + const winner = yield* Database.use((sql) => + sql.withTransaction( + Effect.gen(function* () { + yield* Admissions.use((admissions) => + admissions.executePublicApplication(input, { + now: environment.ADMISSION_FIXED_NOW, + activationToken: randomBytes(32).toString("base64url"), + }), + ); + yield* Deferred.succeed(lockHeld, undefined); + + return yield* sql<{ readonly waiting: number }>` + SELECT count(*)::integer AS waiting + FROM pg_locks + WHERE locktype = 'advisory' AND NOT granted + `.pipe( + Effect.map((rows) => rows[0]?.waiting ?? 0), + Effect.repeat({ + until: (waiting) => waiting > 0, + times: 400, + schedule: Schedule.spaced("10 millis"), + }), + ); + }), + ), + ).pipe( + Effect.provide(AdmissionsLive), + Effect.provide(connection, { local: true }), + Effect.forkChild, + ); + + yield* Deferred.await(lockHeld); + + const loserRequest = yield* Schema.decodeUnknownEffect(SubmitApplicationRequest)({ + ...applicationSource, + email: "ADA@example.invalid", + }); + + const loser = yield* submit("raceLoser", loserRequest).pipe(Effect.flip); + + expect(yield* Fiber.join(winner)).toBe(1); + expect(codeOf(loser)).toBe("application.duplicate"); + expect(yield* count("admission_applicants")).toBe(1); + expect(yield* count("admission_applications")).toBe(1); + expect(yield* count("native_http_idempotency_receipts")).toBe(0); + }), + ); + + it.live("answers a failed application write as dependency.unavailable", () => + Effect.gen(function* () { + const { database, submit, count } = yield* fixture(); + + yield* database.run( + Database.use((sql) => + Effect.gen(function* () { + yield* sql`CREATE FUNCTION reject_application_write() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'application write rejected'; END $$`; + yield* sql`CREATE TRIGGER reject_application_write BEFORE INSERT ON admission_applications FOR EACH ROW EXECUTE FUNCTION reject_application_write()`; + }), + ), + ); + + const failure = yield* submit("rejectedWrite").pipe(Effect.flip); + + expect(codeOf(failure)).toBe("dependency.unavailable"); + expect(yield* count("admission_applicants")).toBe(0); + expect(yield* count("native_http_idempotency_receipts")).toBe(0); + }), + ); + + it.live("answers an unknown application confirmation with application.not-found", () => + Effect.gen(function* () { + const { client } = yield* fixture(); + + const failure = yield* client["admissions.readApplicationConfirmation"]({ + applicationId: PublicApplicationIdSchema.make("application-unknown"), + }).pipe(Effect.flip); + + expect(codeOf(failure)).toBe("application.not-found"); + }), + ); +}); diff --git a/apps/backend/src/application/AGENTS.md b/apps/backend/src/application/AGENTS.md index 5e51b8ade..473750da1 100644 --- a/apps/backend/src/application/AGENTS.md +++ b/apps/backend/src/application/AGENTS.md @@ -3,7 +3,7 @@ # apps/backend/src/application This folder holds Admissions code under another name. Application effects and their worker. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The Admissions context in other folders: diff --git a/apps/backend/src/application/effects.ts b/apps/backend/src/application/effects.ts index 8c7580833..9b4a2e1e0 100644 --- a/apps/backend/src/application/effects.ts +++ b/apps/backend/src/application/effects.ts @@ -17,8 +17,8 @@ export const publicApplicationHttpEffects = ( const deliver = (request: PublicApplicationOutboxRequest) => deliverJson(request, config, { "idempotency-key": request.effectId }).pipe( Effect.provideService(HttpClient.HttpClient, client), - Effect.mapError( - () => new PublicApplicationEffectDeliveryError({ effectId: request.effectId }), + Effect.mapError(() => + PublicApplicationEffectDeliveryError.make({ effectId: request.effectId }), ), ); diff --git a/apps/backend/src/application/worker.ts b/apps/backend/src/application/worker.ts index 3bfddfa33..cf7b81fd0 100644 --- a/apps/backend/src/application/worker.ts +++ b/apps/backend/src/application/worker.ts @@ -9,6 +9,7 @@ import type { PublicApplicationPersistenceError, } from "@vektorprogrammet/domain/application"; import { pollForever } from "../worker-support.js"; +import { dual } from "effect/Function"; export interface PublicApplicationOutboxWorkerOptions { readonly workerId: string; @@ -24,40 +25,53 @@ const requirePositiveInteger = (value: number, name: string): void => { } }; -export const runPublicApplicationOutboxWorker = ( - interpreter: PublicApplicationEffectInterpreter, - options: PublicApplicationOutboxWorkerOptions, -): Effect.Effect => { - requirePositiveInteger(options.pollIntervalMilliseconds, "poll interval"); - requirePositiveInteger(options.staleClaimMilliseconds, "stale claim interval"); +export const runPublicApplicationOutboxWorker: { + ( + options: PublicApplicationOutboxWorkerOptions, + ): ( + interpreter: PublicApplicationEffectInterpreter, + ) => Effect.Effect; + ( + interpreter: PublicApplicationEffectInterpreter, + options: PublicApplicationOutboxWorkerOptions, + ): Effect.Effect; +} = dual( + 2, + ( + interpreter: PublicApplicationEffectInterpreter, + options: PublicApplicationOutboxWorkerOptions, + ): Effect.Effect => { + requirePositiveInteger(options.pollIntervalMilliseconds, "poll interval"); + requirePositiveInteger(options.staleClaimMilliseconds, "stale claim interval"); - if (options.workerId.length === 0) throw new Error("worker ID must not be empty"); + if (options.workerId.length === 0) throw new Error("worker ID must not be empty"); - let claimSequence = 0; + let claimSequence = 0; - const tick = Effect.gen(function* () { - const now = DateTime.formatIso(yield* DateTime.now); + const tick = Effect.gen(function* () { + const now = DateTime.formatIso(yield* DateTime.now); - return yield* deliverNextPublicApplicationOutbox( - `${options.workerId}:${claimSequence++}`, - now, - interpreter, - ); - }); + return yield* deliverNextPublicApplicationOutbox( + `${options.workerId}:${claimSequence++}`, + now, + interpreter, + ); + }); - return Effect.gen(function* () { - const now = yield* DateTime.now; + return Effect.gen(function* () { + const now = yield* DateTime.now; - const claimedBefore = DateTime.formatIso( - DateTime.subtract(now, { milliseconds: options.staleClaimMilliseconds }), - ); + const claimedBefore = DateTime.formatIso( + DateTime.subtract(now, { milliseconds: options.staleClaimMilliseconds }), + ); - yield* recoverAllStalePublicApplicationOutbox(claimedBefore); - options.onStart?.(); + yield* recoverAllStalePublicApplicationOutbox(claimedBefore); + options.onStart?.(); - return yield* pollForever(tick, { - interval: Duration.millis(options.pollIntervalMilliseconds), - skipDelay: Predicate.isTagged("Delivered"), - }); - }).pipe(Effect.ensuring(Effect.sync(() => options.onStop?.()))); -}; + return yield* pollForever(tick, { + interval: Duration.millis(options.pollIntervalMilliseconds), + skipDelay: Predicate.isTagged("Delivered"), + }); + }).pipe(Effect.ensuring(Effect.sync(() => options.onStop?.()))); + }, +); diff --git a/apps/backend/src/authority.schools.test.ts b/apps/backend/src/authority.schools.test.ts index f68fa6dd0..0c51a31e4 100644 --- a/apps/backend/src/authority.schools.test.ts +++ b/apps/backend/src/authority.schools.test.ts @@ -40,7 +40,7 @@ it.effect("captures the Schools authorization instant exactly once after session Effect.sync(() => { events.push("session"); - return new IdentityActor({ + return IdentityActor.make({ personId: PersonId.make("schools-authority-person"), sessionId: "schools-session", expiresAt: DateTime.makeUnsafe("2032-05-02T00:00:00.000Z"), @@ -70,8 +70,8 @@ it.effect("captures the Schools authorization instant exactly once after session ); it.effect.each([ - ["missing", new IdentitySessionNotFound()], - ["expired", new IdentitySessionExpired()], + ["missing", IdentitySessionNotFound.make()], + ["expired", IdentitySessionExpired.make()], ] as const)("maps a %s session to unauthenticated authority", ([_name, failure]) => Effect.gen(function* () { expect( @@ -86,7 +86,7 @@ it.effect.each([ it.effect("preserves a typed authentication engine failure", () => Effect.gen(function* () { - const failure = new IdentityEngineError({ + const failure = IdentityEngineError.make({ operation: "getSession", message: "authentication provider unavailable", }); diff --git a/apps/backend/src/authority.ts b/apps/backend/src/authority.ts index cfbe7c4eb..5aa38e63a 100644 --- a/apps/backend/src/authority.ts +++ b/apps/backend/src/authority.ts @@ -15,7 +15,6 @@ import { } from "@vektorprogrammet/domain/identity"; import type { DepartmentId, - OrganizationActor, OrganizationAuthorityInstant, OrganizationPersonAuthority, PersonId, @@ -23,7 +22,6 @@ import type { } from "@vektorprogrammet/domain/organization"; import { mapOrganizationAuthorityToDepartmentActor, - mapOrganizationAuthorityToOrganizationActor, mapOrganizationAuthorityToProfileRole, Organization, OrganizationAuthorityInstantSchema, @@ -54,6 +52,7 @@ import type { OrganizationDecodeError, OrganizationPersistenceError, } from "@vektorprogrammet/domain/organization"; +import { dual } from "effect/Function"; /** Projection failures are infrastructure-level and surface as typed denials upstream. */ export type OrganizationResolutionError = OrganizationDecodeError | OrganizationPersistenceError; @@ -73,7 +72,7 @@ const sessionEffect = ( ): Effect.Effect => Identity.use(({ resolveSession }) => resolveSession(cookieHeader)).pipe( Effect.catchTag(["IdentitySessionNotFound", "IdentitySessionExpired"], () => - Effect.fail(new UnauthenticatedActor({ message: "authentication required" })), + Effect.fail(UnauthenticatedActor.make({ message: "authentication required" })), ), ); @@ -83,12 +82,15 @@ const sessionEffect = ( * credential at most: with two, it fails before either one is resolved, so no * principal is ever chosen between them, whether they name one Person or two. */ -export const headerCredentialCount = ( - cookieHeader: string | null | undefined, - authorization: string | null | undefined, -): number => - (hasBetterAuthSessionCredential(cookieHeader ?? null) ? 1 : 0) + - (Predicate.isNotNullish(authorization) ? 1 : 0); +export const headerCredentialCount: { + (authorization: string | null | undefined): (cookieHeader: string | null | undefined) => number; + (cookieHeader: string | null | undefined, authorization: string | null | undefined): number; +} = dual( + 2, + (cookieHeader: string | null | undefined, authorization: string | null | undefined): number => + (hasBetterAuthSessionCredential(cookieHeader ?? null) ? 1 : 0) + + (Predicate.isNotNullish(authorization) ? 1 : 0), +); type AcceptedCredential = Extract; @@ -104,7 +106,7 @@ const requestCredentialEffect = ( const cookieHeader = request.headers.get("cookie"); if (headerCredentialCount(cookieHeader, authorization) > 1) { - return Effect.fail(new UnauthenticatedActor({ message: "authentication required" })); + return Effect.fail(UnauthenticatedActor.make({ message: "authentication required" })); } if (authorization !== null) { @@ -112,7 +114,7 @@ const requestCredentialEffect = ( Effect.flatMap((outcome) => Predicate.isTagged(outcome, "Accepted") ? Effect.succeed(outcome) - : Effect.fail(new UnauthenticatedActor({ message: "authentication required" })), + : Effect.fail(UnauthenticatedActor.make({ message: "authentication required" })), ), ); } @@ -135,7 +137,7 @@ const requestPersonEffect = ( Effect.flatMap(requestCredentialEffect(request, "OAuthUserBearer"), (credential) => Predicate.isTagged(credential.principal, "Person") ? Effect.succeed(credential.principal.personId) - : Effect.fail(new UnauthenticatedActor({ message: "authentication required" })), + : Effect.fail(UnauthenticatedActor.make({ message: "authentication required" })), ); const requestCredentialInTransactionEffect = ( @@ -151,7 +153,7 @@ const requestCredentialInTransactionEffect = ( const cookieHeader = request.headers.get("cookie"); if (headerCredentialCount(cookieHeader, authorization) > 1) { - return Effect.fail(new UnauthenticatedActor({ message: "authentication required" })); + return Effect.fail(UnauthenticatedActor.make({ message: "authentication required" })); } if (authorization !== null) { @@ -161,13 +163,13 @@ const requestCredentialInTransactionEffect = ( Effect.flatMap((outcome) => Predicate.isTagged(outcome, "Accepted") ? Effect.succeed(outcome) - : Effect.fail(new UnauthenticatedActor({ message: "authentication required" })), + : Effect.fail(UnauthenticatedActor.make({ message: "authentication required" })), ), ); } if (expected === "OAuthServiceBearer") { - return Effect.fail(new UnauthenticatedActor({ message: "authentication required" })); + return Effect.fail(UnauthenticatedActor.make({ message: "authentication required" })); } return IdentitySnapshot.use(({ resolveSession }) => @@ -180,7 +182,7 @@ const requestCredentialInTransactionEffect = ( evidenceRef: CredentialEvidenceRef.make(`better-auth:session:${actor.sessionId}`), })), Effect.catchTag("IdentitySessionNotFound", () => - Effect.fail(new UnauthenticatedActor({ message: "authentication required" })), + Effect.fail(UnauthenticatedActor.make({ message: "authentication required" })), ), ); }; @@ -216,13 +218,26 @@ export interface TransactionPersonAuthorityResolutionOptions { * Resolves the session of a session-only operation. An Authorization header beside the * session cookie is a second credential, so the request fails without resolving either. */ -export const resolveAuthenticatedSession = ( - cookieHeader: string | undefined, - authorization: string | undefined, -): Effect.Effect => - headerCredentialCount(cookieHeader, authorization) > 1 - ? Effect.fail(new UnauthenticatedActor({ message: "authentication required" })) - : sessionEffect(cookieHeader); +export const resolveAuthenticatedSession: { + ( + authorization: string | undefined, + ): ( + cookieHeader: string | undefined, + ) => Effect.Effect; + ( + cookieHeader: string | undefined, + authorization: string | undefined, + ): Effect.Effect; +} = dual( + 2, + ( + cookieHeader: string | undefined, + authorization: string | undefined, + ): Effect.Effect => + headerCredentialCount(cookieHeader, authorization) > 1 + ? Effect.fail(UnauthenticatedActor.make({ message: "authentication required" })) + : sessionEffect(cookieHeader), +); /** Cookie -> canonical PersonId only; for adapters that authenticate without roles. */ export const resolveAuthenticatedPerson = ( @@ -250,45 +265,90 @@ export interface AuthenticatedCredentialAtInstant { } /** Resolves one accepted request credential and captures one authorization instant. */ -export const resolveRequestCredentialAtInstant = ( - request: Request, - expected: "OAuthUserBearer" | "OAuthServiceBearer" | "Either", - options: AuthorityResolutionOptions = {}, -): Effect.Effect< - AuthenticatedCredentialAtInstant, - IdentityEngineError | UnauthenticatedActor, - Identity | OAuthCredentialAuthority -> => - Effect.flatMap(requestCredentialEffect(request, expected), (credential) => - Effect.map(currentInstant(options.now), (instant) => ({ - credential, - authorizationInstant: AuthorizationInstant.make(instant), - })), - ); +export const resolveRequestCredentialAtInstant: { + ( + request: Request, + expected: "OAuthUserBearer" | "OAuthServiceBearer" | "Either", + options?: AuthorityResolutionOptions, + ): Effect.Effect< + AuthenticatedCredentialAtInstant, + IdentityEngineError | UnauthenticatedActor, + Identity | OAuthCredentialAuthority + >; + ( + expected: "OAuthUserBearer" | "OAuthServiceBearer" | "Either", + options?: AuthorityResolutionOptions, + ): ( + request: Request, + ) => Effect.Effect< + AuthenticatedCredentialAtInstant, + IdentityEngineError | UnauthenticatedActor, + Identity | OAuthCredentialAuthority + >; +} = dual( + (args) => args[0] instanceof Request, + ( + request: Request, + expected: "OAuthUserBearer" | "OAuthServiceBearer" | "Either", + options: AuthorityResolutionOptions = {}, + ): Effect.Effect< + AuthenticatedCredentialAtInstant, + IdentityEngineError | UnauthenticatedActor, + Identity | OAuthCredentialAuthority + > => + Effect.flatMap(requestCredentialEffect(request, expected), (credential) => + Effect.map(currentInstant(options.now), (instant) => ({ + credential, + authorizationInstant: AuthorizationInstant.make(instant), + })), + ), +); /** * Resolves the current cookie or delegated bearer state through the caller's * ambient database transaction, then returns that exact credential evidence * and authorization instant to the AccessSpec evaluator. */ -export const resolveRequestCredentialInTransaction = ( - request: Request, - expected: "OAuthUserBearer" | "OAuthServiceBearer" | "Either", - options: TransactionCredentialResolutionOptions = {}, -): Effect.Effect< - AuthenticatedCredentialAtInstant, - IdentityEngineError | UnauthenticatedActor, - Database | IdentitySnapshot | OAuthCredentialAuthority -> => { - return Effect.flatMap( - Effect.map(currentInstant(options.now), (instant) => AuthorizationInstant.make(instant)), - (authorizationInstant) => - Effect.map( - requestCredentialInTransactionEffect(request, expected, authorizationInstant), - (credential) => ({ credential, authorizationInstant }), - ), - ); -}; +export const resolveRequestCredentialInTransaction: { + ( + request: Request, + expected: "OAuthUserBearer" | "OAuthServiceBearer" | "Either", + options?: TransactionCredentialResolutionOptions, + ): Effect.Effect< + AuthenticatedCredentialAtInstant, + IdentityEngineError | UnauthenticatedActor, + Database | IdentitySnapshot | OAuthCredentialAuthority + >; + ( + expected: "OAuthUserBearer" | "OAuthServiceBearer" | "Either", + options?: TransactionCredentialResolutionOptions, + ): ( + request: Request, + ) => Effect.Effect< + AuthenticatedCredentialAtInstant, + IdentityEngineError | UnauthenticatedActor, + Database | IdentitySnapshot | OAuthCredentialAuthority + >; +} = dual( + (args) => args[0] instanceof Request, + ( + request: Request, + expected: "OAuthUserBearer" | "OAuthServiceBearer" | "Either", + options: TransactionCredentialResolutionOptions = {}, + ): Effect.Effect< + AuthenticatedCredentialAtInstant, + IdentityEngineError | UnauthenticatedActor, + Database | IdentitySnapshot | OAuthCredentialAuthority + > => + Effect.flatMap( + Effect.map(currentInstant(options.now), (instant) => AuthorizationInstant.make(instant)), + (authorizationInstant) => + Effect.map( + requestCredentialInTransactionEffect(request, expected, authorizationInstant), + (credential) => ({ credential, authorizationInstant }), + ), + ), +); export interface TransactionPersonAuthority { readonly credential: AcceptedCredential; @@ -297,117 +357,247 @@ export interface TransactionPersonAuthority { } /** Resolves one current Person credential and its organization projection at one instant. */ -export const resolveRequestPersonAuthorityInTransaction = ( - request: Request, - options: TransactionPersonAuthorityResolutionOptions = {}, -): Effect.Effect< - TransactionPersonAuthority, - IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, - Database | Organization | IdentitySnapshot | OAuthCredentialAuthority -> => - Effect.gen(function* () { - const authenticated = yield* resolveRequestCredentialInTransaction( - request, - "OAuthUserBearer", - options, - ); - - if (!Predicate.isTagged(authenticated.credential.principal, "Person")) { - return yield* new UnauthenticatedActor({ message: "authentication required" }); - } - - const personId = authenticated.credential.principal.personId; - const organization = yield* Organization; - - const authority = yield* organization.resolvePersonAuthority( - personId, - decodeAuthorizationInstant(authenticated.authorizationInstant), - ); - - return { ...authenticated, authority }; - }); +export const resolveRequestPersonAuthorityInTransaction: { + ( + request: Request, + options?: TransactionPersonAuthorityResolutionOptions, + ): Effect.Effect< + TransactionPersonAuthority, + IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, + Database | Organization | IdentitySnapshot | OAuthCredentialAuthority + >; + ( + options?: TransactionPersonAuthorityResolutionOptions, + ): ( + request: Request, + ) => Effect.Effect< + TransactionPersonAuthority, + IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, + Database | Organization | IdentitySnapshot | OAuthCredentialAuthority + >; +} = dual( + (args) => args[0] instanceof Request, + ( + request: Request, + options: TransactionPersonAuthorityResolutionOptions = {}, + ): Effect.Effect< + TransactionPersonAuthority, + IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, + Database | Organization | IdentitySnapshot | OAuthCredentialAuthority + > => + Effect.gen(function* () { + const authenticated = yield* resolveRequestCredentialInTransaction( + request, + "OAuthUserBearer", + options, + ); + + if (!Predicate.isTagged(authenticated.credential.principal, "Person")) { + return yield* UnauthenticatedActor.make({ message: "authentication required" }); + } + + const personId = authenticated.credential.principal.personId; + const organization = yield* Organization; + + const authority = yield* organization.resolvePersonAuthority( + personId, + decodeAuthorizationInstant(authenticated.authorizationInstant), + ); + + return { ...authenticated, authority }; + }), +); /** * Authenticates first, then captures exactly one instant for a caller-owned * read-only journey. Organization resolves its projection inside that journey. */ -export const resolveAuthenticatedPersonAtInstant = ( - cookieHeader: string | undefined, - options: AuthorityResolutionOptions = {}, -): Effect.Effect< - AuthenticatedPersonAtInstant, - IdentityEngineError | UnauthenticatedActor, - Identity -> => - Effect.flatMap(sessionEffect(cookieHeader), (actor) => - Effect.map(currentInstant(options.now), (instant) => ({ - personId: actor.personId, - authorizationInstant: decodeAuthorizationInstant(instant), - })), - ); +export const resolveAuthenticatedPersonAtInstant: { + ( + cookieHeader: string | undefined, + options?: AuthorityResolutionOptions, + ): Effect.Effect< + AuthenticatedPersonAtInstant, + IdentityEngineError | UnauthenticatedActor, + Identity + >; + ( + options?: AuthorityResolutionOptions, + ): ( + cookieHeader: string | undefined, + ) => Effect.Effect< + AuthenticatedPersonAtInstant, + IdentityEngineError | UnauthenticatedActor, + Identity + >; +} = dual( + (args) => args.length >= 2 || (args.length === 1 && !Predicate.isObject(args[0])), + ( + cookieHeader: string | undefined, + options: AuthorityResolutionOptions = {}, + ): Effect.Effect< + AuthenticatedPersonAtInstant, + IdentityEngineError | UnauthenticatedActor, + Identity + > => + Effect.flatMap(sessionEffect(cookieHeader), (actor) => + Effect.map(currentInstant(options.now), (instant) => ({ + personId: actor.personId, + authorizationInstant: decodeAuthorizationInstant(instant), + })), + ), +); /** Authenticates either person mechanism before capturing one authorization instant. */ -export const resolveRequestPersonAtInstant = ( - request: Request, - options: AuthorityResolutionOptions = {}, -): Effect.Effect< - AuthenticatedPersonAtInstant, - IdentityEngineError | UnauthenticatedActor, - Identity | OAuthCredentialAuthority -> => - Effect.flatMap(requestPersonEffect(request), (personId) => - Effect.map(currentInstant(options.now), (instant) => ({ - personId, - authorizationInstant: decodeAuthorizationInstant(instant), - })), - ); +export const resolveRequestPersonAtInstant: { + ( + request: Request, + options?: AuthorityResolutionOptions, + ): Effect.Effect< + AuthenticatedPersonAtInstant, + IdentityEngineError | UnauthenticatedActor, + Identity | OAuthCredentialAuthority + >; + ( + options?: AuthorityResolutionOptions, + ): ( + request: Request, + ) => Effect.Effect< + AuthenticatedPersonAtInstant, + IdentityEngineError | UnauthenticatedActor, + Identity | OAuthCredentialAuthority + >; +} = dual( + (args) => args[0] instanceof Request, + ( + request: Request, + options: AuthorityResolutionOptions = {}, + ): Effect.Effect< + AuthenticatedPersonAtInstant, + IdentityEngineError | UnauthenticatedActor, + Identity | OAuthCredentialAuthority + > => + Effect.flatMap(requestPersonEffect(request), (personId) => + Effect.map(currentInstant(options.now), (instant) => ({ + personId, + authorizationInstant: decodeAuthorizationInstant(instant), + })), + ), +); /** Captures ONE authorizationInstant per request and resolves the full projection. */ -export const resolvePersonAuthority = ( - cookieHeader: string | undefined, - options: AuthorityResolutionOptions = {}, -): Effect.Effect< - OrganizationPersonAuthority, - IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, - Organization | Identity -> => { - return Effect.flatMap( - Effect.map(currentInstant(options.now), decodeAuthorizationInstant), - (instant) => personAuthorityEffect(cookieHeader, instant), - ); -}; +export const resolvePersonAuthority: { + ( + cookieHeader: string | undefined, + options?: AuthorityResolutionOptions, + ): Effect.Effect< + OrganizationPersonAuthority, + IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, + Organization | Identity + >; + ( + options?: AuthorityResolutionOptions, + ): ( + cookieHeader: string | undefined, + ) => Effect.Effect< + OrganizationPersonAuthority, + IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, + Organization | Identity + >; +} = dual( + (args) => args.length >= 2 || (args.length === 1 && !Predicate.isObject(args[0])), + ( + cookieHeader: string | undefined, + options: AuthorityResolutionOptions = {}, + ): Effect.Effect< + OrganizationPersonAuthority, + IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, + Organization | Identity + > => + Effect.flatMap(Effect.map(currentInstant(options.now), decodeAuthorizationInstant), (instant) => + personAuthorityEffect(cookieHeader, instant), + ), +); /** Resolves Identity first, then captures one authorization instant for a request. */ -export const resolvePersonAuthorityAfterSession = ( - cookieHeader: string | undefined, - options: AuthorityResolutionOptions = {}, -): Effect.Effect< - OrganizationPersonAuthority, - IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, - Organization | Identity -> => - Effect.flatMap(sessionEffect(cookieHeader), (actor) => - Effect.flatMap(Effect.map(currentInstant(options.now), decodeAuthorizationInstant), (instant) => - Organization.use(({ resolvePersonAuthority }) => - resolvePersonAuthority(actor.personId, instant), +export const resolvePersonAuthorityAfterSession: { + ( + cookieHeader: string | undefined, + options?: AuthorityResolutionOptions, + ): Effect.Effect< + OrganizationPersonAuthority, + IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, + Organization | Identity + >; + ( + options?: AuthorityResolutionOptions, + ): ( + cookieHeader: string | undefined, + ) => Effect.Effect< + OrganizationPersonAuthority, + IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, + Organization | Identity + >; +} = dual( + (args) => args.length >= 2 || (args.length === 1 && !Predicate.isObject(args[0])), + ( + cookieHeader: string | undefined, + options: AuthorityResolutionOptions = {}, + ): Effect.Effect< + OrganizationPersonAuthority, + IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, + Organization | Identity + > => + Effect.flatMap(sessionEffect(cookieHeader), (actor) => + Effect.flatMap( + Effect.map(currentInstant(options.now), decodeAuthorizationInstant), + (instant) => + Organization.use(({ resolvePersonAuthority }) => + resolvePersonAuthority(actor.personId, instant), + ), ), ), - ); +); /** Resolves either person credential into the same current organization authority. */ -export const resolveRequestPersonAuthority = ( - request: Request, - options: AuthorityResolutionOptions = {}, -): Effect.Effect< - OrganizationPersonAuthority, - IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, - Organization | Identity | OAuthCredentialAuthority -> => - Effect.flatMap(requestPersonEffect(request), (personId) => - Effect.flatMap(Effect.map(currentInstant(options.now), decodeAuthorizationInstant), (instant) => - Organization.use(({ resolvePersonAuthority }) => resolvePersonAuthority(personId, instant)), +export const resolveRequestPersonAuthority: { + ( + request: Request, + options?: AuthorityResolutionOptions, + ): Effect.Effect< + OrganizationPersonAuthority, + IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, + Organization | Identity | OAuthCredentialAuthority + >; + ( + options?: AuthorityResolutionOptions, + ): ( + request: Request, + ) => Effect.Effect< + OrganizationPersonAuthority, + IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, + Organization | Identity | OAuthCredentialAuthority + >; +} = dual( + (args) => args[0] instanceof Request, + ( + request: Request, + options: AuthorityResolutionOptions = {}, + ): Effect.Effect< + OrganizationPersonAuthority, + IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, + Organization | Identity | OAuthCredentialAuthority + > => + Effect.flatMap(requestPersonEffect(request), (personId) => + Effect.flatMap( + Effect.map(currentInstant(options.now), decodeAuthorizationInstant), + (instant) => + Organization.use(({ resolvePersonAuthority }) => + resolvePersonAuthority(personId, instant), + ), + ), ), - ); +); /** * Maps the projection onto the actor of one department scope for one capability. @@ -422,22 +612,22 @@ const departmentActorFor = ( if (Predicate.isTagged(decision, "Deny")) { throw decision.reason === "AuthorityInactive" - ? new InactiveActor({ personId: authority.personId }) - : new AdmissionScopeDenied({ personId: authority.personId, departmentId }); + ? InactiveActor.make({ personId: authority.personId }) + : AdmissionScopeDenied.make({ personId: authority.personId, departmentId }); } return decision.value; }; /** The admission-period actor of one department scope. */ -export const admissionActorForDepartment = ( - authority: OrganizationPersonAuthority, - departmentId: DepartmentId, -): AdmissionPeriodActor => departmentActorFor(authority, "admissions.periods", departmentId); - -/** Active global administrator maps to OrganizationAdministrator; everyone else Member. */ -export const organizationActorFrom = (authority: OrganizationPersonAuthority): OrganizationActor => - mapOrganizationAuthorityToOrganizationActor(authority); +export const admissionActorForDepartment: { + (departmentId: DepartmentId): (authority: OrganizationPersonAuthority) => AdmissionPeriodActor; + (authority: OrganizationPersonAuthority, departmentId: DepartmentId): AdmissionPeriodActor; +} = dual( + 2, + (authority: OrganizationPersonAuthority, departmentId: DepartmentId): AdmissionPeriodActor => + departmentActorFor(authority, "admissions.periods", departmentId), +); /** Coarse dashboard role from the full projection (spec 0055 §Profile). * Returns the raw Decision so the adapter can translate Deny(reason) into its @@ -478,8 +668,8 @@ export const unscopedAdmissionActorFrom = ( throw departments.length === 0 && (authority.memberships.length > 0 || authority.globalAdministrator === "Inactive") - ? new InactiveActor({ personId: authority.personId }) - : new AdmissionRoleDenied({ personId: authority.personId }); + ? InactiveActor.make({ personId: authority.personId }) + : AdmissionRoleDenied.make({ personId: authority.personId }); }; /** @@ -502,6 +692,6 @@ export const recruitmentBoardActorFrom = ( } throw departments.length === 0 && authority.memberships.length > 0 - ? new RecruitmentInactiveActor({ personId: authority.personId }) - : new RecruitmentRoleDenied({ personId: authority.personId }); + ? RecruitmentInactiveActor.make({ personId: authority.personId }) + : RecruitmentRoleDenied.make({ personId: authority.personId }); }; diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index 19952e995..e08584a45 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -1,6 +1,6 @@ import { ContactEmail } from "@vektorprogrammet/domain/contact"; import { mailDeliveryConfig, type MailDeliveryConfig } from "./mail/http.js"; -import { publicRateLimit, type PublicRateLimit } from "./http-api/public-rate-limit.js"; +import { publicRateLimit, type PublicRateLimit } from "./rpc/public-rate-limit.js"; import { receiptDeliveryConfig, type ReceiptDeliveryConfig } from "./receipt/delivery.js"; import { recruitmentNotificationConfig, @@ -30,6 +30,7 @@ import { import { contactConfig, type ContactConfig } from "./contact/config.js"; import { Config, ConfigProvider, Effect, Redacted, Schema, SchemaGetter } from "effect"; +import { dual } from "effect/Function"; export interface PublicApplicationEffectConfig { readonly endpoint: URL; @@ -183,15 +184,34 @@ const oauthBackendConfig = ( }), ); -export const decodeOAuthBackendConfig = ( - env: Readonly>, - trustedOrigins: ReadonlyArray, -): Effect.Effect, Config.ConfigError> => - oauthBackendConfig( - env, - trustedOrigins, - ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true }), - ); +export const decodeOAuthBackendConfig: { + ( + trustedOrigins: ReadonlyArray, + ): ( + env: Readonly>, + ) => Effect.Effect< + Pick, + Config.ConfigError + >; + ( + env: Readonly>, + trustedOrigins: ReadonlyArray, + ): Effect.Effect, Config.ConfigError>; +} = dual( + 2, + ( + env: Readonly>, + trustedOrigins: ReadonlyArray, + ): Effect.Effect< + Pick, + Config.ConfigError + > => + oauthBackendConfig( + env, + trustedOrigins, + ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true }), + ), +); const PositiveInteger = Schema.String.check(Schema.isPattern(/^\d+$/u)).pipe( Schema.decodeTo(Schema.Int.check(Schema.isGreaterThan(0)), { @@ -370,7 +390,8 @@ const passwordResetDeliveryConfig = ( const transport = mailDeliveryConfig(env); - if (!transport) throw new Error("Password reset delivery requires mail configuration"); + if (transport === undefined) + throw new Error("Password reset delivery requires mail configuration"); const sender = yield* Config.schema(Schema.Redacted(ContactEmail), "MAIL_SENDER").parse( provider, @@ -398,7 +419,13 @@ const receiptDeliveryPollConfig = ( throw new Error("RECEIPT_DELIVERY_MODE must be disabled or http"); } - if (!receiptDelivery || !env.RECEIPT_STAGING_ROOT || !env.RECEIPT_COMMITTED_ROOT) + if ( + receiptDelivery === undefined || + env.RECEIPT_STAGING_ROOT === undefined || + env.RECEIPT_STAGING_ROOT.length === 0 || + env.RECEIPT_COMMITTED_ROOT === undefined || + env.RECEIPT_COMMITTED_ROOT.length === 0 + ) throw new Error("Receipt worker requires delivery configuration and explicit storage roots"); return yield* Config.schema(PositiveInteger, "RECEIPT_DELIVERY_POLL_MS") diff --git a/apps/backend/src/contact/AGENTS.md b/apps/backend/src/contact/AGENTS.md index 3d854729d..cad1b169c 100644 --- a/apps/backend/src/contact/AGENTS.md +++ b/apps/backend/src/contact/AGENTS.md @@ -3,7 +3,7 @@ # apps/backend/src/contact This folder holds the Contact bounded context in `apps/backend/src`. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The Contact context in other folders: diff --git a/apps/backend/src/contact/config.ts b/apps/backend/src/contact/config.ts index fe0c84d10..ec75d4008 100644 --- a/apps/backend/src/contact/config.ts +++ b/apps/backend/src/contact/config.ts @@ -19,11 +19,14 @@ export const contactConfig = ( const timeout = Number(env.CONTACT_DELIVERY_TIMEOUT_MS); if ( - !backendToken || + backendToken === undefined || backendToken.length < 32 || - !token || - !sender || - !endpoint || + token === undefined || + token.length === 0 || + sender === undefined || + sender.length === 0 || + endpoint === undefined || + endpoint.length === 0 || !Schema.is(ContactEmail)(sender) || !Number.isSafeInteger(timeout) || timeout < 1 || @@ -34,7 +37,12 @@ export const contactConfig = ( try { const url = new URL(endpoint); - if (url.username || url.password || url.hash || !["http:", "https:"].includes(url.protocol)) + if ( + url.username.length > 0 || + url.password.length > 0 || + url.hash.length > 0 || + !["http:", "https:"].includes(url.protocol) + ) return undefined; return { diff --git a/apps/backend/src/contact/http.test.ts b/apps/backend/src/contact/http.test.ts deleted file mode 100644 index 3ce8f7fcf..000000000 --- a/apps/backend/src/contact/http.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { backendDatabase } from "../../test/database.js"; -import { Database } from "@vektorprogrammet/database"; -import { Organization } from "@vektorprogrammet/domain/organization"; -import { NativeProblem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Layer, Schema } from "effect"; -import { describe, expect, it } from "@effect/vitest"; -import { decodeBackendConfig } from "../config.js"; -import { makeBackendTestHttp } from "../test/native-http.js"; -import { contactConfig } from "./config.js"; - -const unexpectedOrganizationAccess = () => Effect.die("unexpected organization access"); - -const organization = Layer.mock(Organization, { - readDepartment: unexpectedOrganizationAccess, -}); - -const backendConfig = Effect.runSync( - decodeBackendConfig({ - BACKEND_PG_URL: "postgres://test.invalid/vektorprogrammet", - BETTER_AUTH_SECRET: "contact-test-secret-with-at-least-32-characters!", - NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify(["http://127.0.0.1:5174"]), - OAUTH_CANONICAL_ORIGIN: "http://127.0.0.1:5174", - OAUTH_DASHBOARD_ORIGIN: "http://127.0.0.1:5174", - OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - PUBLIC_APPLICATION_EFFECT_MODE: "disabled", - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_MODE: "disabled", - }), -); - -const config = contactConfig({ - CONTACT_BACKEND_TOKEN: "backend-test-credential-0000000000000000", - CONTACT_DELIVERY_TOKEN: "delivery-test", - CONTACT_SENDER: "contact@example.org", - CONTACT_DELIVERY_URL: "http://127.0.0.1:9999", - CONTACT_DELIVERY_TIMEOUT_MS: "100", -})!; - -const unavailableAuthHandler = { - handler: () => Effect.succeed(new Response(null, { status: 404 })), - recordTrustedOriginRejection: () => Effect.void, -}; - -const message = { - departmentId: "one", - name: "Ola", - email: "ola@example.org", - subject: "Hei", - message: "Hei", -}; - -/** The contact ingress as the homepage reaches it, with or without delivery configured. */ -const contactIngress = (contact: typeof config | undefined) => { - const database = backendDatabase(); - - const http = makeBackendTestHttp( - { ...backendConfig, contact }, - Layer.mergeAll(database.layer, organization), - unavailableAuthHandler, - ); - - return { - submit: ( - credentials: { readonly ip: string; readonly token: string | undefined }, - payload: Schema.Json = message, - ) => { - const headers = new Headers({ - "content-type": "application/json", - origin: "http://127.0.0.1:5174", - "x-vektor-contact-ip": credentials.ip, - }); - - if (credentials.token !== undefined) { - headers.set("x-vektor-contact-backend", credentials.token); - } - - return http.fetch( - new Request("http://backend.test/api/contact-messages", { - method: "POST", - headers, - body: JSON.stringify(payload), - }), - ); - }, - consumedWindows: () => - database.run( - Database.use( - (sql) => - sql<{ - count: number; - }>`SELECT count(*)::integer AS count FROM public.contact_rate_windows`, - ), - ), - }; -}; - -const problemOf = (response: Response) => - Effect.gen(function* () { - const problem = yield* Schema.decodeUnknownEffect(NativeProblem)( - yield* Effect.promise(() => response.json()), - ); - - return { status: response.status, code: problem.code }; - }); - -describe("native contact trust boundary", () => { - it.live("rejects missing, wrong and wrong-hop tokens before quota or delivery", () => - Effect.gen(function* () { - const ingress = contactIngress(config); - - for (const [token, code] of [ - [undefined, "credential.missing"], - ["wrong", "credential.invalid"], - ["ingress-test-credential-0000000000000000", "credential.invalid"], - ] as const) { - expect(yield* problemOf(yield* ingress.submit({ ip: "127.0.0.1", token }))).toEqual({ - status: 401, - code, - }); - } - - expect(yield* ingress.consumedWindows()).toEqual([{ count: 0 }]); - }), - ); - - it.live("fails closed for absent configuration and noncanonical addresses before mutation", () => - Effect.gen(function* () { - const unconfigured = contactIngress(undefined); - - expect( - yield* problemOf(yield* unconfigured.submit({ ip: "127.0.0.1", token: undefined })), - ).toEqual({ status: 503, code: "contact.unavailable" }); - - const ingress = contactIngress(config); - - for (const ip of ["", "::ffff:127.0.0.1", "127.0.0.1/32", "127.0.0.1,127.0.0.2"]) { - expect(yield* problemOf(yield* ingress.submit({ ip, token: config.backendToken }))).toEqual( - { - status: 400, - code: "header.malformed", - }, - ); - } - - expect(yield* ingress.consumedWindows()).toEqual([{ count: 0 }]); - }), - ); - - it.live("rejects schema invalid and injected recipient before quota", () => - Effect.gen(function* () { - const ingress = contactIngress(config); - - const credentials = { ip: "127.0.0.1", token: config.backendToken }; - - for (const payload of [ - { ...message, email: "malformed" }, - { ...message, to: "attacker@example.org" }, - { ...message, subject: "Hei\r\nBcc: bad" }, - ]) { - expect(yield* problemOf(yield* ingress.submit(credentials, payload))).toEqual({ - status: 422, - code: "validation.failed", - }); - } - - expect(yield* ingress.consumedWindows()).toEqual([{ count: 0 }]); - }), - ); -}); diff --git a/apps/backend/src/contact/http.ts b/apps/backend/src/contact/http.ts deleted file mode 100644 index 194f016c8..000000000 --- a/apps/backend/src/contact/http.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - ContactDelivery, - ContactFailure, - ContactMessage, - type ContactVisitorIp, - CONTACT_IP_HEADER, - submitContact, -} from "@vektorprogrammet/domain/contact"; -import { ContactQuotaLive } from "@vektorprogrammet/database/contact"; -import { ExternalNativeApi } from "@vektorprogrammet/http-api"; -import { Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Layer, Match } from "effect"; -import { HttpClient } from "effect/unstable/http"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import type { ContactConfig } from "./config.js"; -import { deliverJson } from "../delivery/http.js"; -import { - decodeRequest, - problemMapper, - readJsonBody, - requestInvalid, - webHandler, -} from "../http-api/problem.js"; - -/** The one answer for every contact failure. */ -const contactProblems = problemMapper()({ - ContactFailure: ({ reason }) => - Match.value(reason).pipe( - // A recipient that cannot receive the message makes the message itself invalid. - Match.when("InvalidRecipient", requestInvalid), - Match.when("RateLimited", () => Problem.rateLimited(3_600)), - Match.when("Unavailable", () => Problem.make("contact.unavailable")), - Match.exhaustive, - ), -}); - -const deliveryFor = (config: ContactConfig) => - Layer.effect( - ContactDelivery, - Effect.map(HttpClient.HttpClient, (client) => - ContactDelivery.of({ - send: (envelope) => - deliverJson( - { - from: config.sender, - to: envelope.to, - replyTo: envelope.replyTo, - subject: `[Kontaktskjema] ${envelope.subject}`, - text: `Navn: ${envelope.name}\nE-post: ${envelope.replyTo}\n\n${envelope.message}`, - }, - config.delivery, - ).pipe( - Effect.provideService(HttpClient.HttpClient, client), - Effect.mapError(() => new ContactFailure({ reason: "Unavailable" })), - ), - }), - ), - ); - -/** - * The public contact form. ContactSSR security admits only the homepage - * server's token and the endpoint schema admits only a canonical visitor - * address, so the handler starts at the message itself. - */ -export const ContactApiHandlers = (config: ContactConfig | undefined) => { - const services = - config === undefined ? undefined : Layer.merge(ContactQuotaLive, deliveryFor(config)); - - const submit = (request: Request, ip: ContactVisitorIp) => - Effect.gen(function* () { - // Without configuration the ingress admits every request, and nothing can be delivered. - if (services === undefined) return yield* Problem.make("contact.unavailable"); - - const input = yield* readJsonBody(request, /^application\/json(?:\s*;|$)/iu, 65_536); - - const message = yield* decodeRequest(ContactMessage)(input); - - yield* submitContact(message, ip).pipe(Effect.provide(services), contactProblems); - - return new Response(null, { - status: 201, - headers: { "cache-control": "no-store", vary: "Origin" }, - }); - }); - - return HttpApiBuilder.group(ExternalNativeApi, "contact", (handlers) => - handlers.handleRaw("submitContactMessage", ({ request, headers }) => - webHandler(request, (webRequest) => submit(webRequest, headers[CONTACT_IP_HEADER])), - ), - ); -}; diff --git a/apps/backend/src/contact/rpc.test.ts b/apps/backend/src/contact/rpc.test.ts new file mode 100644 index 000000000..646515947 --- /dev/null +++ b/apps/backend/src/contact/rpc.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Database } from "@vektorprogrammet/database"; +import { ContactMessage } from "@vektorprogrammet/domain/contact"; +import { + DepartmentId, + DepartmentNotFound, + Organization, +} from "@vektorprogrammet/domain/organization"; +import { nativeRpcPath } from "@vektorprogrammet/rpc"; +import { isProblem } from "@vektorprogrammet/rpc/problem"; +import { Effect, Layer, Schema } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import { backendDatabase } from "../../test/database.js"; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; +import { contactConfig } from "./config.js"; + +const config = contactConfig({ + CONTACT_BACKEND_TOKEN: "backend-test-credential-0000000000000000", + CONTACT_DELIVERY_TOKEN: "delivery-test", + CONTACT_SENDER: "contact@example.org", + CONTACT_DELIVERY_URL: "http://127.0.0.1:9999", + CONTACT_DELIVERY_TIMEOUT_MS: "100", +})!; + +const message = ContactMessage.make({ + departmentId: DepartmentId.make("one"), + name: "Ola", + email: "ola@example.org", + subject: "Hei", + message: "Hei", +}); + +/** An Organization in which no department can receive a message, so no delivery is attempted. */ +const organization = Layer.mock(Organization, { + readDepartment: (departmentId) => Effect.fail(DepartmentNotFound.make({ departmentId })), +}); + +/** The contact RPC as the homepage server reaches it, with or without delivery configured. */ +const contactIngress = (contact: typeof config | undefined) => { + const database = backendDatabase(); + + const backend = makeBackendTestRpc({ + config: { ...backendTestConfig, contact }, + services: Layer.mergeAll(database.layer, organization), + }); + + return { + backend, + submit: (credentials: { readonly ip: string; readonly token: string | undefined }) => { + const ip = { "x-vektor-contact-ip": credentials.ip }; + + const headers = + credentials.token === undefined + ? ip + : { ...ip, "x-vektor-contact-backend": credentials.token }; + + return Effect.flatMap(backend.client, (client) => + client["contact.submitContactMessage"](message).pipe( + RpcClient.withHeaders(headers), + Effect.flip, + Effect.map((failure) => (isProblem(failure) ? failure.code : failure)), + ), + ); + }, + consumedWindows: () => + database.run( + Database.use( + (sql) => + sql<{ + count: number; + }>`SELECT count(*)::integer AS count FROM public.contact_rate_windows`, + ), + ), + }; +}; + +const RpcRequest = Schema.TaggedStruct("Request", { + id: Schema.String, + tag: Schema.String, + payload: Schema.Json, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +}); + +const RpcExit = Schema.Array( + Schema.TaggedStruct("Exit", { + requestId: Schema.String, + exit: Schema.TaggedStruct("Failure", { + cause: Schema.Array(Schema.TaggedStruct("Die", { defect: Schema.Json })), + }), + }), +); + +describe("contact.submitContactMessage", () => { + it.live("rejects missing, wrong and wrong-hop tokens before quota or delivery", () => + Effect.gen(function* () { + const ingress = contactIngress(config); + + for (const [token, code] of [ + [undefined, "credential.missing"], + ["wrong", "credential.invalid"], + ["ingress-test-credential-0000000000000000", "credential.invalid"], + ] as const) { + expect(yield* ingress.submit({ ip: "127.0.0.1", token })).toBe(code); + } + + expect(yield* ingress.consumedWindows()).toEqual([{ count: 0 }]); + }), + ); + + it.live("fails closed for absent configuration and noncanonical addresses before mutation", () => + Effect.gen(function* () { + const unconfigured = contactIngress(undefined); + + expect(yield* unconfigured.submit({ ip: "127.0.0.1", token: undefined })).toBe( + "contact.unavailable", + ); + + const ingress = contactIngress(config); + + for (const ip of ["", "::ffff:127.0.0.1", "127.0.0.1/32", "127.0.0.1,127.0.0.2"]) { + expect(yield* ingress.submit({ ip, token: config.backendToken })).toBe("header.malformed"); + } + + expect(yield* ingress.consumedWindows()).toEqual([{ count: 0 }]); + }), + ); + + it.live("refuses a message that fails the contract schema before quota", () => + Effect.gen(function* () { + const ingress = contactIngress(config); + + for (const payload of [ + { ...message, email: "malformed" }, + { ...message, subject: "Hei\r\nBcc: bad" }, + ]) { + const body = yield* Schema.encodeEffect(Schema.fromJsonString(RpcRequest))( + RpcRequest.make({ + id: "1", + tag: "contact.submitContactMessage", + payload, + headers: [ + ["x-vektor-contact-ip", "127.0.0.1"], + ["x-vektor-contact-backend", config.backendToken], + ], + }), + ); + + const response = yield* ingress.backend.fetch( + new Request(`http://native-rpc.test${nativeRpcPath}`, { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:5174" }, + body, + }), + ); + + // The RPC server answers an undecodable payload as a defect of that request. + const answer = yield* Schema.decodeUnknownEffect(RpcExit)( + yield* Effect.promise(() => response.json()), + ); + + expect(answer).toHaveLength(1); + } + + expect(yield* ingress.consumedWindows()).toEqual([{ count: 0 }]); + }), + ); + + it.live("keeps five attempts per visitor window, whatever each attempt answers", () => + Effect.gen(function* () { + const ingress = contactIngress(config); + const credentials = { ip: "127.0.0.1", token: config.backendToken }; + + for (let attempt = 0; attempt < 5; attempt += 1) { + // The unknown department cannot receive the message, after the attempt counted. + expect(yield* ingress.submit(credentials)).toBe("validation.failed"); + } + + expect(yield* ingress.submit(credentials)).toBe("rate-limit.exceeded"); + expect(yield* ingress.submit({ ...credentials, ip: "127.0.0.2" })).toBe("validation.failed"); + }), + ); +}); diff --git a/apps/backend/src/contact/rpc.ts b/apps/backend/src/contact/rpc.ts new file mode 100644 index 000000000..454ce91d3 --- /dev/null +++ b/apps/backend/src/contact/rpc.ts @@ -0,0 +1,94 @@ +/** + * The ContactRpcs handlers: the public contact form that the homepage server relays. + * + * The ContactBackend credential admits only the homepage server's deployment secret, and the RPC + * payload schema admits only a well-formed message. The handler reads the visitor address that the + * homepage server sends beside the secret, consumes one attempt of that visitor's quota, and waits + * for the delivery transport to accept the message. + */ +import { ContactQuotaLive } from "@vektorprogrammet/database/contact"; +import { + ContactDelivery, + ContactFailure, + CONTACT_IP_HEADER, + ContactVisitorIp, + submitContact, +} from "@vektorprogrammet/domain/contact"; +import { ContactRpcs } from "@vektorprogrammet/rpc"; +import { Problem } from "@vektorprogrammet/rpc/problem"; +import { Effect, Layer, Match, Schema } from "effect"; +import { HttpClient } from "effect/unstable/http"; +import { deliverJson } from "../delivery/http.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { problemMapper, requestInvalid } from "../rpc/problem.js"; +import type { ContactConfig } from "./config.js"; + +/** The one answer for every contact failure. */ +const contactProblems = problemMapper()({ + ContactFailure: ({ reason }) => + Match.value(reason).pipe( + // A recipient that cannot receive the message makes the message itself invalid. + Match.when("InvalidRecipient", requestInvalid), + Match.when("RateLimited", () => Problem.rateLimited(3_600)), + Match.when("Unavailable", () => Problem.make("contact.unavailable")), + Match.exhaustive, + ), +}); + +const deliveryFor = (config: ContactConfig) => + Layer.effect( + ContactDelivery, + Effect.map(HttpClient.HttpClient, (client) => + ContactDelivery.of({ + send: (envelope) => + deliverJson( + { + from: config.sender, + to: envelope.to, + replyTo: envelope.replyTo, + subject: `[Kontaktskjema] ${envelope.subject}`, + text: `Navn: ${envelope.name}\nE-post: ${envelope.replyTo}\n\n${envelope.message}`, + }, + config.delivery, + ).pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.mapError(() => new ContactFailure({ reason: "Unavailable" })), + ), + }), + ), + ); + +/** The one canonical visitor address that the homepage server sends; anything else is malformed. */ +const decodeVisitorIp = Schema.decodeUnknownEffect(ContactVisitorIp); + +/** The ContactRpcs handlers. */ +export const ContactRpcHandlers = (options: NativeRpcOptions) => { + const config = options.config.contact; + + return ContactRpcs.toLayer( + Effect.gen(function* () { + // Built once with the handlers: the quota and the delivery hold no state of a request. + const services = + config === undefined + ? undefined + : yield* Layer.build(Layer.merge(ContactQuotaLive, deliveryFor(config))); + + return ContactRpcs.of({ + "contact.submitContactMessage": (message, { headers }) => + Effect.gen(function* () { + const ip = yield* decodeVisitorIp(headers[CONTACT_IP_HEADER]).pipe( + Effect.mapError(() => Problem.make("header.malformed")), + ); + + // Without configuration the credential admits every request, and nothing can be delivered. + if (services === undefined) return yield* Problem.make("contact.unavailable"); + + yield* submitContact(message, ip).pipe( + Effect.provideContext(services), + contactProblems, + ); + }), + }); + }), + ); +}; diff --git a/apps/backend/src/content/AGENTS.md b/apps/backend/src/content/AGENTS.md index a9e50d89a..6e5244928 100644 --- a/apps/backend/src/content/AGENTS.md +++ b/apps/backend/src/content/AGENTS.md @@ -3,7 +3,7 @@ # apps/backend/src/content This folder holds the Content bounded context in `apps/backend/src`. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The Content context in other folders: @@ -55,9 +55,9 @@ No `exports` entry of [apps/backend/package.json](../../package.json) points int The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../../../docs/constructs.md) indexes them all. -- [`authorizeContentOperation`](../../../../docs/constructs/http-problem.md#authorizecontentoperation) (http-problem): Evaluates a content endpoint's AccessSpec for one person with the content grant scope. -- [`contentProblems`](../../../../docs/constructs/http-problem.md#contentproblems) (http-problem): The one answer for every content domain failure. -- [`contentActorProblems`](../../../../docs/constructs/http-problem.md#contentactorproblems) (http-problem): A staff person rejected after ingress is answered from the credential the request presented. +- [`authorizeContentOperation`](../../../../docs/constructs/rpc-problem.md#authorizecontentoperation) (rpc-problem): Evaluates a content RPC's AccessSpec for one person with the content grant scope. +- [`contentProblems`](../../../../docs/constructs/rpc-problem.md#contentproblems) (rpc-problem): The one answer for every content domain failure. +- [`contentActorProblems`](../../../../docs/constructs/rpc-problem.md#contentactorproblems) (rpc-problem): A staff person rejected after the credential middleware is answered from the credential the request presented. Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. diff --git a/apps/backend/src/content/access.ts b/apps/backend/src/content/access.ts new file mode 100644 index 000000000..430317ed3 --- /dev/null +++ b/apps/backend/src/content/access.ts @@ -0,0 +1,148 @@ +/** + * Content access: the content grant scope, article access contexts, the person access check, and + * the article entity tag. + */ +import { + readContentArticleHttpSourcePostgres, + readContentAuthorityHttpSourcesPostgres, +} from "@vektorprogrammet/database/content"; +import { + AuthorityVersion, + DomainId, + ResourceId, + ResourceKind, + Scope, + type CanonicalScopeResolution, + type CredentialOutcome, +} from "@vektorprogrammet/domain/authz"; +import type { ArticleId, ContentArticleDetail } from "@vektorprogrammet/domain/content"; +import type { PersonId } from "@vektorprogrammet/domain/organization"; +import { reflectAccessSpec } from "@vektorprogrammet/rpc"; +import type { CredentialPresentation, Problem, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { Effect, Option, type Schema } from "effect"; +import type { Rpc } from "effect/unstable/rpc"; +import { deriveStrongETag } from "../http-semantics.js"; +import { authorizePerson, unreachable } from "../rpc/problem.js"; + +export const contentScope: Scope = Scope.Domain({ domainId: DomainId.make("content") }); + +/** + * Evaluates a content RPC's AccessSpec for one person with the content grant scope. + * + * @remarks + * It runs `authorizePerson` with the RPC's AccessSpec, the content domain as the grant scope, and + * `resolution` at `authorizationInstant`. A command passes the credential that its transaction + * resolved; a read passes the credential request, from which the credential is derived. Content + * access reveals every denial, so `unreachable("resource.not-found")` removes the concealment + * answer. + * + * @sideEffects none + * + * @example + * ```ts + * yield* authorizeContentOperation({ rpc: ReviseArticle, credential: actor.credential, personId: actor.personId, authorizationInstant, resolution, presentation }); + * ``` + * + * @avoid Granting a content operation from a role check in the handler: the AccessSpec of the + * contract then stops being the authority for the RPC. Evaluate it with this. + * + * @construct rpc-problem + */ +export const authorizeContentOperation = (input: { + readonly rpc: Pick; + readonly personId: PersonId; + readonly authorizationInstant: string; + readonly credential?: Extract; + readonly request?: Request; + readonly resolution: CanonicalScopeResolution; + readonly presentation: CredentialPresentation; +}): Effect.Effect< + void, + Problem<"authority.denied"> | Problem<"credential.invalid"> | Problem<"credential.missing"> +> => + authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(input.rpc)), + credential: input.credential, + request: input.request, + personId: input.personId, + resolution: input.resolution, + grantScopes: [contentScope], + now: input.authorizationInstant, + }, + input.presentation, + ).pipe( + // Content access reveals every denial, so it never conceals a resource as not found. + unreachable("resource.not-found"), + ); + +/** The access context of one article: its first department, its state, and its owner. */ +export const articleContext = (input: { + readonly detail: ContentArticleDetail; + readonly createdByPersonId: PersonId; + readonly authorityVersion: string; +}) => ({ + domainId: DomainId.make("content"), + departmentId: input.detail.departmentIds[0] ?? null, + resource: { + kind: ResourceKind.make("content-article"), + id: ResourceId.make(String(input.detail.articleId)), + }, + facts: { + state: input.detail.status, + ownerPersonId: input.createdByPersonId, + revisable: input.detail.canRevise, + publishable: input.detail.canPublish, + unpublishable: input.detail.status === "Published", + }, + authorityVersion: AuthorityVersion.make(input.authorityVersion), +}); + +/** The access context of a request that names no article, optionally within one department. */ +export const unscopedContext = (input: { + readonly departmentId: CanonicalScopeResolution["contexts"][number]["departmentId"]; + readonly authorityVersion: string; +}) => ({ + domainId: DomainId.make("content"), + departmentId: input.departmentId, + resource: null, + facts: {}, + authorityVersion: AuthorityVersion.make(input.authorityVersion), +}); + +type ArticleSource = Effect.Success>; + +type AuthoritySources = Effect.Success>; + +/** + * The strong entity tag of one article detail, as the HTTP contract derived it: the article + * revision, its author's profile revision, and the reader's content authority sources. + */ +export const articleETag = (input: { + readonly articleId: ArticleId; + readonly source: ArticleSource; + readonly authority: AuthoritySources; +}): StrongETag => + deriveStrongETag({ + representationKind: "ContentArticleDetailSchema", + resourceIdentity: `content-article:${input.articleId}`, + version: [ + input.source.articleRevision, + input.source.authorProfileRevision, + input.authority.map((item) => [item.kind, item.identity, item.revisions]), + ], + }); + +/** Reads the sources of one article's entity tag for one reader, and derives the tag. */ +export const readArticleETag = (input: { + readonly articleId: ArticleId; + readonly personId: PersonId; +}) => + Effect.all([ + readContentArticleHttpSourcePostgres(input.articleId), + readContentAuthorityHttpSourcesPostgres(input.personId), + ]).pipe( + Effect.map(([source, authority]) => + articleETag({ articleId: input.articleId, source, authority }), + ), + ); diff --git a/apps/backend/src/content/commands.ts b/apps/backend/src/content/commands.ts new file mode 100644 index 000000000..ecee977a7 --- /dev/null +++ b/apps/backend/src/content/commands.ts @@ -0,0 +1,527 @@ +/** + * Content commands: create, revise, publish, and unpublish articles. + * + * A command resolves the credential, the content actor, and the authority inside the serializable + * transaction that commits it, and stores its answer as a command receipt, byte for byte what the + * HTTP contract stored: the response body, and the article's new entity tag as the `etag` header + * (a new draft: status 201 and its `location` too). A retry that straddles the cutover from HTTP + * therefore replays its first answer. The database adapter resolves content authority itself, in + * the same transaction, before it writes. + */ +import { + createDraftPostgres, + publishPostgres, + readArticleDetailInTransactionPostgres, + readContentArticleHttpSourcePostgres, + readContentAuthorityHttpSourcesPostgres, + reviseDraftPostgres, + unpublishPostgres, +} from "@vektorprogrammet/database/content"; +import { + ContentArticleDetailSchema, + ContentCommandId, + type ArticleId, +} from "@vektorprogrammet/domain/content"; +import { + type ArticleMergePatch, + CreateArticle, + type CreateArticleRequest, + PublishArticle, + PublishArticleResponse, + ReviseArticle, + UnpublishArticle, + UnpublishArticleResponse, +} from "@vektorprogrammet/rpc"; +import { + type CredentialPresentation, + type IdempotencyKey, + Problem, + StrongETag, +} from "@vektorprogrammet/rpc/problem"; +import { Effect, Match, Predicate, Schema } from "effect"; +import type { Headers } from "effect/unstable/http"; +import { + interpretArticleMergePatchSource, + jsonBodyBytes, + normalizeTarget, + semanticMutationRequest, + semanticRequestDigest, + type CanonicalSemanticRequest, +} from "../http-semantics.js"; +import { + commandIdentity, + commandReceiptProblems, + personPresentation, + requireCurrentETag, + strictOutput, + unreachable, +} from "../rpc/problem.js"; +import { + executeNativeHttpCommandPostgres, + NativeHttpReceiptInvalid, + type NativeHttpCommandOutcome, + type NativeHttpResponseCapsule, +} from "../rpc/receipt-transaction.js"; +import { + articleContext, + articleETag, + authorizeContentOperation, + readArticleETag, + unscopedContext, +} from "./access.js"; +import { commandActorInTransaction, type ContentCommandActor } from "./context.js"; +import { contentActorProblems, contentProblems } from "./problem.js"; + +/** A schema whose JSON codec a command receipt stores. */ +type ReceiptSchema = Schema.Codec; + +/** + * The receipt of a content command: its body as JSON, and the article's entity tag as the `etag` + * header. A new draft also keeps its 201 status and the article's `location`. + */ +const articleCapsule = + (schema: S) => + (input: { + readonly body: S["Type"]; + readonly etag: StrongETag; + readonly location?: string; + }): Effect.Effect => + Schema.encodeEffect(Schema.toCodecJson(schema))(input.body).pipe( + Effect.orDie, + Effect.map( + (body): NativeHttpResponseCapsule => ({ + status: input.location === undefined ? 200 : 201, + mediaType: "application/json", + headers: + input.location === undefined + ? { "content-type": "application/json", etag: input.etag } + : { "content-type": "application/json", etag: input.etag, location: input.location }, + bodyBytes: jsonBodyBytes(body), + }), + ), + ); + +/** The body and entity tag that a content command receipt stores; a mismatch is a defect. */ +const storedArticleCommand = + (schema: S) => + (capsule: NativeHttpResponseCapsule) => + Effect.gen(function* () { + if (capsule.bodyBytes === null || capsule.headers.etag === undefined) { + return yield* Effect.die( + new NativeHttpReceiptInvalid({ reason: "a content receipt stores no article" }), + ); + } + + const body: S["Type"] = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.toCodecJson(schema)), + )(new TextDecoder().decode(capsule.bodyBytes)).pipe(Effect.orDie); + + const etag = yield* Schema.decodeEffect(StrongETag)(capsule.headers.etag).pipe(Effect.orDie); + + return { body, etag }; + }); + +/** + * The value of a content command outcome: the committed or replayed body beside its entity tag, + * or an idempotency problem. + */ +const articleCommandOutcome = + (schema: S) => + (outcome: NativeHttpCommandOutcome) => + Match.value(outcome).pipe( + Match.tag("Committed", "Replay", ({ response }) => storedArticleCommand(schema)(response)), + Match.tag("InFlight", () => Effect.fail(Problem.make("idempotency.in-flight"))), + Match.tag("DigestConflict", () => Effect.fail(Problem.make("idempotency.digest-conflict"))), + Match.tag("ResponseExpired", () => Effect.fail(Problem.make("idempotency.response-expired"))), + Match.exhaustive, + ); + +interface PreparedContentCommand { + readonly actor: ContentCommandActor; + readonly execute: (commandId: ContentCommandId) => Effect.Effect; +} + +/** + * Runs one content command and its receipt in one transaction: the actor and authority first, + * then the stored receipt, then the command. Domain, receipt, and credential failures are + * answered after the executor. + */ +const executeCommand = (input: { + readonly presentation: CredentialPresentation; + readonly operationId: string; + readonly normalizedTarget: string; + readonly idempotencyKey: IdempotencyKey; + readonly semanticRequest: CanonicalSemanticRequest; + readonly prepare: Effect.Effect, EPrepare, RPrepare>; +}) => + executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const prepared = yield* input.prepare; + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: `Person:${prepared.actor.personId}`, + qualifiedOperationId: input.operationId, + normalizedTarget: input.normalizedTarget, + idempotencyKey: input.idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest(input.semanticRequest), + operationId: input.operationId, + }, + execute: prepared.execute(ContentCommandId.make(identity.commandId)), + }; + }), + ).pipe(contentProblems, commandReceiptProblems, contentActorProblems(input.presentation)); + +/** The route of one article, as the HTTP contract named it. A path it cannot spell is a defect. */ +const articleTarget = (routeTemplate: string, articleId: ArticleId) => + Effect.sync(() => normalizeTarget(routeTemplate, { articleId: String(articleId) })); + +/** + * The article that a command is about to change, its entity tag, and the AccessSpec evaluation of + * `rpc` on it, all inside the command's transaction. + */ +const currentArticle = (input: { + readonly actor: ContentCommandActor; + readonly articleId: ArticleId; + readonly rpc: typeof ReviseArticle | typeof PublishArticle | typeof UnpublishArticle; + readonly presentation: CredentialPresentation; +}) => + Effect.gen(function* () { + const { actor, articleId } = input; + + const [current, source, authority] = yield* Effect.all([ + readArticleDetailInTransactionPostgres({ + personId: actor.personId, + authorizationInstant: actor.authorizationInstant, + articleId, + }), + readContentArticleHttpSourcePostgres(articleId), + readContentAuthorityHttpSourcesPostgres(actor.personId), + ]); + + yield* authorizeContentOperation({ + rpc: input.rpc, + credential: actor.credential, + personId: actor.personId, + authorizationInstant: actor.authorizationInstant, + resolution: { + selection: "ExactlyOne", + contexts: [ + articleContext({ + detail: current, + createdByPersonId: source.createdByPersonId, + authorityVersion: actor.authorizationInstant, + }), + ], + }, + presentation: input.presentation, + }); + + return { current, etag: articleETag({ articleId, source, authority }) }; + }); + +/** Creates, or replays by its idempotency key, one article draft. */ +export const createArticle = (input: { + readonly headers: Headers.Headers; + readonly idempotencyKey: IdempotencyKey; + readonly request: CreateArticleRequest; +}) => { + const presentation = personPresentation(input.headers); + const { request } = input; + + return Effect.gen(function* () { + const outcome = yield* executeCommand({ + presentation, + operationId: "content.createArticle", + normalizedTarget: "/api/content/articles", + idempotencyKey: input.idempotencyKey, + semanticRequest: { body: request }, + prepare: Effect.gen(function* () { + const actor = yield* commandActorInTransaction(input.headers); + + yield* authorizeContentOperation({ + rpc: CreateArticle, + credential: actor.credential, + personId: actor.personId, + authorizationInstant: actor.authorizationInstant, + resolution: { + selection: "ExactlyOne", + contexts: [ + unscopedContext({ departmentId: null, authorityVersion: actor.authorizationInstant }), + ], + }, + presentation, + }); + + return { + actor, + execute: (commandId: ContentCommandId) => + Effect.gen(function* () { + const created = yield* createDraftPostgres({ + command: { ...request, commandId }, + personId: actor.personId, + authorizationInstant: actor.authorizationInstant, + }); + + const detail = yield* readArticleDetailInTransactionPostgres({ + personId: actor.personId, + authorizationInstant: actor.authorizationInstant, + articleId: created.articleId, + }); + + const etag = yield* readArticleETag({ + articleId: created.articleId, + personId: actor.personId, + }); + + const body = yield* strictOutput(ContentArticleDetailSchema)(detail); + + return yield* articleCapsule(ContentArticleDetailSchema)({ + body, + etag, + location: `/api/content/articles/${created.articleId}`, + }); + }), + }; + }), + }); + + const stored = yield* articleCommandOutcome(ContentArticleDetailSchema)(outcome); + + return { article: stored.body, etag: stored.etag }; + }).pipe( + // A new draft names no existing article, and its receipt answers any repeated command first. + unreachable("content.article-not-found", "content.lifecycle-conflict"), + ); +}; + +/** + * Judges the merge patch before the command: an empty patch changes nothing, and a null member + * would delete a field that an article requires. + */ +const interpretPatch = (patch: ArticleMergePatch) => + Effect.gen(function* () { + // The payload schema decoded JSON, so each present member holds a JSON value or null. + const source: { [member: string]: Schema.Json } = {}; + + if (patch.title !== undefined) source.title = patch.title; + + if (patch.bodyHtml !== undefined) source.bodyHtml = patch.bodyHtml; + + if (patch.departmentIds !== undefined) source.departmentIds = patch.departmentIds; + + if (patch.sticky !== undefined) source.sticky = patch.sticky; + + const interpretation = yield* Effect.sync(() => interpretArticleMergePatchSource(source)); + + if (Predicate.isTagged(interpretation, "Rejected")) { + const { code, errors } = interpretation; + + // The payload schema decoded an object of known members only, so the interpreter's + // validation.failed (a non-object or an unknown member) cannot occur here. + return yield* code === "validation.failed" + ? Effect.die(new Error("a decoded article merge patch failed its interpretation")) + : Effect.fail(Problem.validation(code, errors)); + } + + return { + source, + title: patch.title ?? undefined, + bodyHtml: patch.bodyHtml ?? undefined, + departmentIds: patch.departmentIds ?? undefined, + sticky: patch.sticky ?? undefined, + }; + }); + +/** Revises, or replays by its idempotency key, one draft under its entity tag. */ +export const reviseArticle = (input: { + readonly headers: Headers.Headers; + readonly articleId: ArticleId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; + readonly request: ArticleMergePatch; +}) => { + const presentation = personPresentation(input.headers); + const { articleId, ifMatch } = input; + + return Effect.gen(function* () { + const patch = yield* interpretPatch(input.request); + + const outcome = yield* executeCommand({ + presentation, + operationId: "content.reviseArticle", + normalizedTarget: yield* articleTarget("/api/content/articles/{articleId}", articleId), + idempotencyKey: input.idempotencyKey, + semanticRequest: semanticMutationRequest(patch.source, ifMatch), + prepare: Effect.gen(function* () { + const actor = yield* commandActorInTransaction(input.headers); + + const { current, etag: currentETag } = yield* currentArticle({ + actor, + articleId, + rpc: ReviseArticle, + presentation, + }); + + return { + actor, + execute: (commandId: ContentCommandId) => + Effect.gen(function* () { + // Exact replay is selected before execute; a fresh mutation still checks the + // selected representation inside the owning transaction. + yield* requireCurrentETag(currentETag, ifMatch); + + const revised = yield* reviseDraftPostgres({ + command: { + commandId, + articleId, + expectedRevision: current.revision, + title: patch.title ?? current.title, + bodyHtml: patch.bodyHtml ?? current.bodyHtml, + departmentIds: patch.departmentIds ?? current.departmentIds, + sticky: patch.sticky ?? current.sticky, + }, + personId: actor.personId, + authorizationInstant: actor.authorizationInstant, + }); + + const detail = yield* readArticleDetailInTransactionPostgres({ + personId: actor.personId, + authorizationInstant: actor.authorizationInstant, + articleId: revised.articleId, + }); + + const body = yield* strictOutput(ContentArticleDetailSchema)(detail); + const etag = yield* readArticleETag({ articleId, personId: actor.personId }); + + return yield* articleCapsule(ContentArticleDetailSchema)({ body, etag }); + }), + }; + }), + }); + + const stored = yield* articleCommandOutcome(ContentArticleDetailSchema)(outcome); + + return { article: stored.body, etag: stored.etag }; + }).pipe( + // The revision is read in the command's own serializable snapshot, and its receipt answers + // any repeated command first. + unreachable("content.lifecycle-conflict"), + ); +}; + +/** Runs one publication transition under the article's entity tag. */ +const transitionArticle = (input: { + readonly headers: Headers.Headers; + readonly articleId: ArticleId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; + readonly operationId: "content.publishArticle" | "content.unpublishArticle"; + readonly routeTemplate: string; + readonly rpc: typeof PublishArticle | typeof UnpublishArticle; + readonly schema: S; + readonly transition: (command: { + readonly actor: ContentCommandActor; + readonly commandId: ContentCommandId; + }) => Effect.Effect; +}) => { + const presentation = personPresentation(input.headers); + const { articleId, ifMatch } = input; + + return Effect.gen(function* () { + const outcome = yield* executeCommand({ + presentation, + operationId: input.operationId, + normalizedTarget: yield* articleTarget(input.routeTemplate, articleId), + idempotencyKey: input.idempotencyKey, + // The HTTP body of a transition was an exact empty object, which the digest still covers. + semanticRequest: semanticMutationRequest({}, ifMatch), + prepare: Effect.gen(function* () { + const actor = yield* commandActorInTransaction(input.headers); + + const { etag: currentETag } = yield* currentArticle({ + actor, + articleId, + rpc: input.rpc, + presentation, + }); + + return { + actor, + execute: (commandId: ContentCommandId) => + Effect.gen(function* () { + // Exact replay is selected before execute; a fresh mutation still checks the + // selected representation inside the owning transaction. + yield* requireCurrentETag(currentETag, ifMatch); + + const result = yield* input.transition({ actor, commandId }); + const body = yield* strictOutput(input.schema)(result); + const etag = yield* readArticleETag({ articleId, personId: actor.personId }); + + return yield* articleCapsule(input.schema)({ body, etag }); + }), + }; + }), + }); + + const stored = yield* articleCommandOutcome(input.schema)(outcome); + + return { result: stored.body, etag: stored.etag }; + }).pipe( + // Publication changes neither the slug nor the departments of an article. + unreachable("content.slug-conflict", "content.department-not-found"), + ); +}; + +/** Publishes, or replays by its idempotency key, a new version of one article. */ +export const publishArticle = (input: { + readonly headers: Headers.Headers; + readonly articleId: ArticleId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; +}) => + transitionArticle({ + ...input, + operationId: "content.publishArticle", + routeTemplate: "/api/content/articles/{articleId}:publish", + rpc: PublishArticle, + schema: PublishArticleResponse, + transition: ({ actor, commandId }) => + publishPostgres({ + command: { commandId, articleId: input.articleId }, + personId: actor.personId, + authorizationInstant: actor.authorizationInstant, + }).pipe( + Effect.map((published) => ({ + articleId: published.articleId, + versionNumber: published.versionNumber, + publishedAt: published.publishedAt, + })), + ), + }); + +/** Unpublishes, or replays by its idempotency key, one published article. */ +export const unpublishArticle = (input: { + readonly headers: Headers.Headers; + readonly articleId: ArticleId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; +}) => + transitionArticle({ + ...input, + operationId: "content.unpublishArticle", + routeTemplate: "/api/content/articles/{articleId}:unpublish", + rpc: UnpublishArticle, + schema: UnpublishArticleResponse, + transition: ({ actor, commandId }) => + unpublishPostgres({ + command: { commandId, articleId: input.articleId }, + personId: actor.personId, + authorizationInstant: actor.authorizationInstant, + }).pipe(Effect.map((unpublished) => ({ articleId: unpublished.articleId }))), + }); diff --git a/apps/backend/src/content/context.ts b/apps/backend/src/content/context.ts new file mode 100644 index 000000000..620156df1 --- /dev/null +++ b/apps/backend/src/content/context.ts @@ -0,0 +1,84 @@ +/** + * Content request actors: the staff person of a read, resolved at one authorization instant, and + * the staff person of a command, resolved inside the command's transaction. + */ +import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import { + ContentAuthorityInactive, + ContentNotInScope, + resolveContentActor, +} from "@vektorprogrammet/domain/content"; +import { + Organization, + type OrganizationPersonAuthority, +} from "@vektorprogrammet/domain/organization"; +import { Effect, Predicate } from "effect"; +import type { Headers } from "effect/unstable/http"; +import { + resolveRequestPersonAtInstant, + resolveRequestPersonAuthorityInTransaction, +} from "../authority.js"; +import { credentialRequestOf } from "../rpc/credential.js"; + +/** + * A person with no content authority is denied before any article is read: an inactive authority + * and a person outside every content scope each fail as their own content failure. + */ +const requireContentActor = (authority: OrganizationPersonAuthority) => { + const decision = resolveContentActor(authority); + + if (Predicate.isTagged(decision, "Deny")) { + return decision.reason === "AuthorityInactive" + ? Effect.fail(ContentAuthorityInactive.make({})) + : Effect.fail(ContentNotInScope.make({})); + } + + return Effect.succeed(decision.value); +}; + +/** + * Resolves the staff person of a read at one authorization instant, and requires a content actor + * of the person's organization projection at that instant. + */ +export const readActor = (input: { + readonly headers: Headers.Headers; + readonly now: (() => string) | undefined; +}) => + Effect.gen(function* () { + const actor = yield* resolveRequestPersonAtInstant(credentialRequestOf(input.headers), { + now: input.now, + }); + + const authority = yield* Organization.use(({ resolvePersonAuthority }) => + resolvePersonAuthority(actor.personId, actor.authorizationInstant), + ); + + yield* requireContentActor(authority); + + return actor; + }); + +/** + * Resolves the staff person of a command, its credential, and its content actor inside the + * command's transaction. + */ +export const commandActorInTransaction = (headers: Headers.Headers) => + Effect.gen(function* () { + const authenticated = yield* resolveRequestPersonAuthorityInTransaction( + credentialRequestOf(headers), + ); + + if (!Predicate.isTagged(authenticated.credential.principal, "Person")) { + return yield* UnauthenticatedActor.make({ message: "authentication required" }); + } + + yield* requireContentActor(authenticated.authority); + + return { + personId: authenticated.credential.principal.personId, + authorizationInstant: authenticated.authorizationInstant, + credential: authenticated.credential, + }; + }); + +export type ContentCommandActor = Effect.Success>; diff --git a/apps/backend/src/content/http-access.ts b/apps/backend/src/content/http-access.ts deleted file mode 100644 index 12fb5114d..000000000 --- a/apps/backend/src/content/http-access.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** Content access: the content grant scope, article access contexts, and the person access check. */ -import { - AuthorityVersion, - DomainId, - ResourceId, - ResourceKind, - Scope, - type CanonicalScopeResolution, - type CredentialOutcome, -} from "@vektorprogrammet/domain/authz"; -import type { ContentArticleDetail } from "@vektorprogrammet/domain/content"; -import type { PersonId } from "@vektorprogrammet/domain/organization"; -import { reflectAccessSpec } from "@vektorprogrammet/http-api"; -import type { CredentialPresentation, Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { type Effect, Option, type Schema } from "effect"; -import { authorizePerson, unreachable } from "../http-api/problem.js"; -import type { ContentEndpoint } from "./http-context.js"; - -export const contentScope: Scope = Scope.Domain({ domainId: DomainId.make("content") }); - -/** - * Evaluates a content endpoint's AccessSpec for one person with the content grant scope. - * - * @remarks - * It runs `authorizePerson` with the endpoint's AccessSpec, the content domain as the grant scope, - * and `resolution` at `authorizationInstant`. A command passes the credential that its - * transaction resolved; a read passes the request, from which the credential is derived. Content - * access reveals every denial, so `unreachable("resource.not-found")` removes the concealment - * answer. - * - * @sideEffects none - * - * @example - * ```ts - * yield* authorizeContentOperation({ endpoint: ReviseArticleEndpoint, credential: actor.credential, personId: actor.personId, authorizationInstant, resolution, presentation }); - * ``` - * - * @avoid Granting a content operation from a role check in the handler: the AccessSpec of the - * contract then stops being the authority for the endpoint. Evaluate it with this. - * - * @construct http-problem - */ -export const authorizeContentOperation = (input: { - readonly endpoint: ContentEndpoint; - readonly personId: PersonId; - readonly authorizationInstant: string; - readonly credential?: Extract; - readonly request?: Request; - readonly resolution: CanonicalScopeResolution; - readonly presentation: CredentialPresentation; -}): Effect.Effect< - void, - Problem<"authority.denied"> | Problem<"credential.invalid"> | Problem<"credential.missing"> -> => - authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(input.endpoint)), - credential: input.credential, - request: input.request, - personId: input.personId, - resolution: input.resolution, - grantScopes: [contentScope], - now: input.authorizationInstant, - }, - input.presentation, - ).pipe( - // Content access reveals every denial, so it never conceals a resource as not found. - unreachable("resource.not-found"), - ); - -export const articleContext = ( - detail: ContentArticleDetail, - createdByPersonId: PersonId, - authorityVersion: string, -) => ({ - domainId: DomainId.make("content"), - departmentId: detail.departmentIds[0] ?? null, - resource: { - kind: ResourceKind.make("content-article"), - id: ResourceId.make(String(detail.articleId)), - }, - facts: { - state: detail.status, - ownerPersonId: createdByPersonId, - revisable: detail.canRevise, - publishable: detail.canPublish, - unpublishable: detail.status === "Published", - }, - authorityVersion: AuthorityVersion.make(authorityVersion), -}); diff --git a/apps/backend/src/content/http-commands.ts b/apps/backend/src/content/http-commands.ts deleted file mode 100644 index 4d4459e52..000000000 --- a/apps/backend/src/content/http-commands.ts +++ /dev/null @@ -1,455 +0,0 @@ -/** Content command handlers: create, revise, publish, and unpublish articles. */ -import type { Database } from "@vektorprogrammet/database"; -import { - createDraftPostgres, - publishPostgres, - readArticleDetailInTransactionPostgres, - readContentArticleHttpSourcePostgres, - readContentAuthorityHttpSourcesPostgres, - reviseDraftPostgres, - unpublishPostgres, -} from "@vektorprogrammet/database/content"; -import { AuthorityVersion, DomainId } from "@vektorprogrammet/domain/authz"; -import { - ContentArticleDetailSchema, - ContentCommandId, - type ArticleId, - type Content, - type ContentManagement, -} from "@vektorprogrammet/domain/content"; -import type { Organization } from "@vektorprogrammet/domain/organization"; -import type { Profile } from "@vektorprogrammet/domain/profile"; -import { - ArticleMergePatch, - CreateArticleEndpoint, - CreateArticleRequest, - PublishArticleEndpoint, - PublishArticleRequest, - PublishArticleResponse, - ReviseArticleEndpoint, - UnpublishArticleEndpoint, - UnpublishArticleRequest, - UnpublishArticleResponse, -} from "@vektorprogrammet/http-api"; -import { type CredentialPresentation, Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Predicate } from "effect"; -import { - commandOutcomeResponse, - commandReceiptProblems, - decodeRequest, - httpIdentity, - idempotencyKeyOf, - personPresentation, - readJsonBody, - requireCurrentETag, - requiredIfMatchOf, - requireNoQuery, - semanticProblem, - strictOutput, - unreachable, - jsonText, -} from "../http-api/problem.js"; -import { executeNativeHttpCommandPostgres } from "../http-api/receipt-transaction.js"; -import { - deriveStrongETag, - interpretArticleMergePatchSource, - NO_STORE, - normalizeTarget, - responseCapsule, - semanticMutationRequest, - semanticRequestDigest, - type CanonicalSemanticRequest, -} from "../http-semantics.js"; -import { articleContext, authorizeContentOperation } from "./http-access.js"; -import { - authorizedActorInTransaction, - contentOperationId, - type ContentEndpoint, - type TransactionalAuthorizedContentActor, -} from "./http-context.js"; -import { contentActorProblems, contentProblems } from "./http-problem.js"; -import { articleETagEffect } from "./http-representation.js"; - -type ContentBackendRequirements = Database | Organization | Profile | Content | ContentManagement; - -const JSON_BODY = /^application\/json(?:\s*;|$)/iu; - -const MERGE_PATCH_BODY = /^application\/merge-patch\+json(?:\s*;|$)/iu; - -interface PreparedContentCommand { - readonly actor: TransactionalAuthorizedContentActor; - readonly execute: ( - commandId: ContentCommandId, - ) => Effect.Effect; -} - -/** - * Runs one content command and its receipt in one transaction. Domain, - * receipt, and credential failures are answered after the executor. - */ -const executeCommand = ( - request: Request, - presentation: CredentialPresentation, - endpoint: ContentEndpoint, - routeTemplate: string, - identities: Readonly>, - semanticRequest: CanonicalSemanticRequest, - prepare: () => Effect.Effect, EPrepare, R>, -) => - Effect.gen(function* () { - const operationId = contentOperationId(endpoint); - - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const prepared = yield* prepare(); - const idempotencyKey = yield* idempotencyKeyOf(request); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${prepared.actor.personId}`, - qualifiedOperationId: operationId, - normalizedTarget: normalizeTarget(routeTemplate, identities), - idempotencyKey, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest(semanticRequest), - operationId, - }, - execute: prepared - .execute(ContentCommandId.make(identity.commandId)) - .pipe(Effect.flatMap((response) => Effect.promise(() => responseCapsule(response)))), - }; - }), - ).pipe(contentProblems, commandReceiptProblems, contentActorProblems(presentation)); - - return yield* commandOutcomeResponse(outcome); - }); - -export const createArticle = (request: Request, maxBodyBytes: number) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - yield* requireNoQuery(request); - const rawBody = yield* readJsonBody(request, JSON_BODY, maxBodyBytes); - const body = yield* decodeRequest(CreateArticleRequest)(rawBody); - - return yield* executeCommand( - request, - presentation, - CreateArticleEndpoint, - "/api/content/articles", - {}, - { body }, - () => - Effect.gen(function* () { - const actor = yield* authorizedActorInTransaction(request); - - yield* authorizeContentOperation({ - endpoint: CreateArticleEndpoint, - credential: actor.credential, - personId: actor.personId, - authorizationInstant: actor.authorizationInstant, - resolution: { - selection: "ExactlyOne", - contexts: [ - { - domainId: DomainId.make("content"), - departmentId: null, - resource: null, - facts: {}, - authorityVersion: AuthorityVersion.make(actor.authorizationInstant), - }, - ], - }, - presentation, - }); - - return { - actor, - execute: (commandId: ContentCommandId) => - createDraftPostgres({ - command: { ...body, commandId }, - personId: actor.personId, - authorizationInstant: actor.authorizationInstant, - }).pipe( - Effect.flatMap((created) => - Effect.gen(function* () { - const detail = yield* readArticleDetailInTransactionPostgres({ - personId: actor.personId, - authorizationInstant: actor.authorizationInstant, - articleId: created.articleId, - }); - - const source = yield* readContentArticleHttpSourcePostgres(created.articleId); - - const authority = yield* readContentAuthorityHttpSourcesPostgres( - actor.personId, - ); - - const etag = deriveStrongETag({ - representationKind: "ContentArticleDetailSchema", - resourceIdentity: `content-article:${created.articleId}`, - version: [ - source.articleRevision, - source.authorProfileRevision, - authority.map((item) => [item.kind, item.identity, item.revisions]), - ], - }); - - const output = yield* strictOutput(ContentArticleDetailSchema)(detail); - - return new Response(yield* jsonText(output), { - status: 201, - headers: { - "cache-control": NO_STORE, - "content-type": "application/json", - etag, - location: `/api/content/articles/${created.articleId}`, - }, - }); - }), - ), - ), - }; - }), - ); - }).pipe( - // A new draft names no existing article, and its receipt answers any repeated command first. - unreachable("content.article-not-found", "content.lifecycle-conflict"), - ); -}; - -export const reviseArticle = (request: Request, articleId: ArticleId, maxBodyBytes: number) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - yield* requireNoQuery(request); - const patchSource = yield* readJsonBody(request, MERGE_PATCH_BODY, maxBodyBytes); - - const interpretation = yield* semanticProblem( - () => interpretArticleMergePatchSource(patchSource), - ["request.malformed"], - ); - - if (Predicate.isTagged(interpretation, "Rejected")) { - return yield* Problem.validation(interpretation.code, interpretation.errors); - } - - const patch = yield* decodeRequest(ArticleMergePatch)(patchSource); - const ifMatch = yield* requiredIfMatchOf(request); - - return yield* executeCommand( - request, - presentation, - ReviseArticleEndpoint, - "/api/content/articles/{articleId}", - { articleId: String(articleId) }, - semanticMutationRequest(patch, ifMatch), - () => - Effect.gen(function* () { - const actor = yield* authorizedActorInTransaction(request); - - const [current, source, authority] = yield* Effect.all([ - readArticleDetailInTransactionPostgres({ - personId: actor.personId, - authorizationInstant: actor.authorizationInstant, - articleId, - }), - readContentArticleHttpSourcePostgres(articleId), - readContentAuthorityHttpSourcesPostgres(actor.personId), - ]); - - yield* authorizeContentOperation({ - endpoint: ReviseArticleEndpoint, - credential: actor.credential, - personId: actor.personId, - authorizationInstant: actor.authorizationInstant, - resolution: { - selection: "ExactlyOne", - contexts: [ - articleContext(current, source.createdByPersonId, actor.authorizationInstant), - ], - }, - presentation, - }); - - const currentETag = deriveStrongETag({ - representationKind: "ContentArticleDetailSchema", - resourceIdentity: `content-article:${articleId}`, - version: [ - source.articleRevision, - source.authorProfileRevision, - authority.map((item) => [item.kind, item.identity, item.revisions]), - ], - }); - - return { - actor, - execute: (commandId: ContentCommandId) => - Effect.gen(function* () { - // Exact replay is selected before execute; a fresh mutation still - // checks the selected representation inside the owning transaction. - yield* requireCurrentETag(currentETag, ifMatch); - - const revised = yield* reviseDraftPostgres({ - command: { - commandId, - articleId, - expectedRevision: current.revision, - title: patch.title ?? current.title, - bodyHtml: patch.bodyHtml ?? current.bodyHtml, - departmentIds: patch.departmentIds ?? current.departmentIds, - sticky: patch.sticky ?? current.sticky, - }, - personId: actor.personId, - authorizationInstant: actor.authorizationInstant, - }); - - const detail = yield* readArticleDetailInTransactionPostgres({ - personId: actor.personId, - authorizationInstant: actor.authorizationInstant, - articleId: revised.articleId, - }); - - const output = yield* strictOutput(ContentArticleDetailSchema)(detail); - const etag = yield* articleETagEffect(articleId, actor.personId); - - return new Response(yield* jsonText(output), { - status: 200, - headers: { "cache-control": NO_STORE, "content-type": "application/json", etag }, - }); - }), - }; - }), - ); - }).pipe( - // The revision is read in the command's own serializable snapshot, and - // its receipt answers any repeated command first. - unreachable("content.lifecycle-conflict"), - ); -}; - -export const lifecycleArticle = ( - request: Request, - articleId: ArticleId, - operation: "Publish" | "Unpublish", - maxBodyBytes: number, -) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - yield* requireNoQuery(request); - const endpoint = operation === "Publish" ? PublishArticleEndpoint : UnpublishArticleEndpoint; - const wireSchema = operation === "Publish" ? PublishArticleRequest : UnpublishArticleRequest; - const rawBody = yield* readJsonBody(request, JSON_BODY, maxBodyBytes); - const body = yield* decodeRequest(wireSchema)(rawBody); - - const ifMatch = yield* requiredIfMatchOf(request); - - const suffix = operation === "Publish" ? "publish" : "unpublish"; - - return yield* executeCommand( - request, - presentation, - endpoint, - `/api/content/articles/{articleId}:${suffix}`, - { articleId: String(articleId) }, - semanticMutationRequest(body, ifMatch), - () => - Effect.gen(function* () { - const actor = yield* authorizedActorInTransaction(request); - - const [current, source, authority] = yield* Effect.all([ - readArticleDetailInTransactionPostgres({ - personId: actor.personId, - authorizationInstant: actor.authorizationInstant, - articleId, - }), - readContentArticleHttpSourcePostgres(articleId), - readContentAuthorityHttpSourcesPostgres(actor.personId), - ]); - - yield* authorizeContentOperation({ - endpoint, - credential: actor.credential, - personId: actor.personId, - authorizationInstant: actor.authorizationInstant, - resolution: { - selection: "ExactlyOne", - contexts: [ - articleContext(current, source.createdByPersonId, actor.authorizationInstant), - ], - }, - presentation, - }); - - const currentETag = deriveStrongETag({ - representationKind: "ContentArticleDetailSchema", - resourceIdentity: `content-article:${articleId}`, - version: [ - source.articleRevision, - source.authorProfileRevision, - authority.map((item) => [item.kind, item.identity, item.revisions]), - ], - }); - - return { - actor, - execute: (commandId: ContentCommandId) => - Effect.gen(function* () { - // Exact replay is selected before execute; a fresh mutation still - // checks the selected representation inside the owning transaction. - yield* requireCurrentETag(currentETag, ifMatch); - - if (operation === "Publish") { - const published = yield* publishPostgres({ - command: { commandId, articleId }, - personId: actor.personId, - authorizationInstant: actor.authorizationInstant, - }); - - const output = yield* strictOutput(PublishArticleResponse)({ - articleId: published.articleId, - versionNumber: published.versionNumber, - publishedAt: published.publishedAt, - }); - - const etag = yield* articleETagEffect(articleId, actor.personId); - - return new Response(yield* jsonText(output), { - status: 200, - headers: { - "cache-control": NO_STORE, - "content-type": "application/json", - etag, - }, - }); - } - - const unpublished = yield* unpublishPostgres({ - command: { commandId, articleId }, - personId: actor.personId, - authorizationInstant: actor.authorizationInstant, - }); - - const output = yield* strictOutput(UnpublishArticleResponse)({ - articleId: unpublished.articleId, - }); - - const etag = yield* articleETagEffect(articleId, actor.personId); - - return new Response(yield* jsonText(output), { - status: 200, - headers: { "cache-control": NO_STORE, "content-type": "application/json", etag }, - }); - }), - }; - }), - ); - }).pipe( - // Publication changes neither the slug nor the departments of an article. - unreachable("content.slug-conflict", "content.department-not-found"), - ); -}; diff --git a/apps/backend/src/content/http-context.ts b/apps/backend/src/content/http-context.ts deleted file mode 100644 index a566cdcfa..000000000 --- a/apps/backend/src/content/http-context.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** Content HTTP operation identities, request actor types, and request actor resolution. */ -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { - ContentAuthorityInactive, - ContentNotInScope, - resolveContentActor, - type ContentActor, -} from "@vektorprogrammet/domain/content"; -import { Organization, type PersonId } from "@vektorprogrammet/domain/organization"; -import { ContentApi } from "@vektorprogrammet/http-api"; -import { type CredentialPresentation, Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Predicate } from "effect"; -import type { HttpApiGroup } from "effect/unstable/httpapi"; -import { - resolveRequestPersonAuthorityInTransaction, - type TransactionPersonAuthority, -} from "../authority.js"; - -export interface ContentRequestActor { - readonly personId: PersonId; - readonly authorizationInstant: string; -} - -export type ContentRequestActorResolver = ( - request: Request, -) => Effect.Effect; - -/** An endpoint declared by the content HttpApi contract. */ -export type ContentEndpoint = HttpApiGroup.Endpoints; - -/** - * Qualified operation id published for a content endpoint, derived by the native - * provenance rule `HttpApiGroup.identifier.HttpApiEndpoint.identifier`. - */ -export const contentOperationId = (endpoint: ContentEndpoint) => - `${ContentApi.identifier}.${endpoint.identifier}` as const; - -export interface AuthorizedContentActor extends ContentRequestActor { - readonly contentActor: ContentActor; -} - -export interface TransactionalAuthorizedContentActor extends AuthorizedContentActor { - readonly credential: TransactionPersonAuthority["credential"]; -} - -/** - * Resolves the staff person of a snapshot read and its content actor at the - * person's authorization instant. - */ -export const authorizedActor = ( - request: Request, - resolveActor: ContentRequestActorResolver, - presentation: CredentialPresentation, -) => - Effect.gen(function* () { - // The resolver only authenticates. A rejected person is answered from the - // credential the request presented; any other failure is internal. - const actor = yield* resolveActor(request).pipe( - Effect.mapError((failure) => - Predicate.isTagged(failure, "UnauthenticatedActor") - ? Problem.unauthenticated(presentation) - : Problem.make("internal.error"), - ), - ); - - const authority = yield* Organization.use(({ resolvePersonAuthority }) => - resolvePersonAuthority(actor.personId, actor.authorizationInstant), - ); - - const decision = resolveContentActor(authority); - - if (Predicate.isTagged(decision, "Deny")) { - return yield* decision.reason === "AuthorityInactive" - ? new ContentAuthorityInactive({}) - : new ContentNotInScope({}); - } - - return { ...actor, contentActor: decision.value }; - }); - -/** - * Resolves the staff person of a command, its credential, and its content - * actor inside the command's transaction. - */ -export const authorizedActorInTransaction = (request: Request) => - Effect.gen(function* () { - const authenticated = yield* resolveRequestPersonAuthorityInTransaction(request); - - if (!Predicate.isTagged(authenticated.credential.principal, "Person")) { - return yield* new UnauthenticatedActor({ message: "authentication required" }); - } - - const decision = resolveContentActor(authenticated.authority); - - if (Predicate.isTagged(decision, "Deny")) { - return yield* decision.reason === "AuthorityInactive" - ? new ContentAuthorityInactive({}) - : new ContentNotInScope({}); - } - - return { - personId: authenticated.credential.principal.personId, - authorizationInstant: authenticated.authorizationInstant, - credential: authenticated.credential, - contentActor: decision.value, - }; - }); diff --git a/apps/backend/src/content/http-decode.ts b/apps/backend/src/content/http-decode.ts deleted file mode 100644 index a7cb879b1..000000000 --- a/apps/backend/src/content/http-decode.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** Content query strings: the department filter and the published version selector. */ -import type { ContentWorkspaceQuery } from "@vektorprogrammet/domain/content"; -import type { DepartmentId } from "@vektorprogrammet/domain/organization"; -import { Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect } from "effect"; - -/** - * A workspace or news listing accepts at most one department filter and no - * other parameter. The endpoint's query schema has already decoded it. - */ -export const departmentQuery = ( - request: Request, - department: DepartmentId | undefined, -): Effect.Effect> => { - const keys = [...new URL(request.url).searchParams.keys()]; - - return keys.length > 1 || keys.some((key) => key !== "department") - ? Effect.fail(Problem.make("request.malformed")) - : Effect.succeed(department === undefined ? {} : { departmentId: department }); -}; - -/** - * A news article read accepts at most one positive published version and no other parameter. - */ -export const versionFromQuery = ( - request: Request, -): Effect.Effect> => { - const parameters = [...new URL(request.url).searchParams]; - - if (parameters.length === 0) return Effect.undefined; - - const version = Number(parameters[0]?.[1]); - - return parameters.length === 1 && - parameters[0]?.[0] === "version" && - Number.isSafeInteger(version) && - version >= 1 - ? Effect.succeed(version) - : Effect.fail(Problem.make("request.malformed")); -}; diff --git a/apps/backend/src/content/http-reads.ts b/apps/backend/src/content/http-reads.ts deleted file mode 100644 index 75c5ff66b..000000000 --- a/apps/backend/src/content/http-reads.ts +++ /dev/null @@ -1,274 +0,0 @@ -/** Content read handlers: staff workspace and article reads, and public news reads. */ -import { - readContentArticleHttpSourcePostgres, - readContentAuthorityHttpSourcesPostgres, - readPublishedNewsArticleHttpSourcePostgres, - readPublishedNewsCollectionHttpSourcesPostgres, -} from "@vektorprogrammet/database/content"; -import { - AuthorityVersion, - DomainId, - ResourceId, - ResourceKind, -} from "@vektorprogrammet/domain/authz"; -import { - ContentArticleDetailSchema, - ContentWorkspaceSchema, - PublicNewsRead, - PublishedNewsArticleSchema, - PublishedNewsListingSchema, - readPublicNews, - runContentArticleDetail, - runContentWorkspace, - type ArticleId, - type PublishedNewsArticle, - type PublishedNewsListing, -} from "@vektorprogrammet/domain/content"; -import type { DepartmentId } from "@vektorprogrammet/domain/organization"; -import { - ListNewsEndpoint, - ReadArticleEndpoint, - ReadContentWorkspaceEndpoint, - ReadNewsArticleEndpoint, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { Effect, Option } from "effect"; -import { currentInstant } from "../authority.js"; -import { - authorizeAnonymous, - conditionalJson, - personPresentation, - requireNoQuery, - strictOutput, - unreachable, - jsonText, -} from "../http-api/problem.js"; -import { deriveStrongETag, PRIVATE_NO_STORE, PUBLIC_CACHE_CONTROL } from "../http-semantics.js"; -import { articleContext, authorizeContentOperation } from "./http-access.js"; -import { authorizedActor, type ContentRequestActorResolver } from "./http-context.js"; -import { departmentQuery, versionFromQuery } from "./http-decode.js"; -import { contentActorProblems, contentProblems } from "./http-problem.js"; - -export const readContentWorkspace = ( - request: Request, - department: DepartmentId | undefined, - resolveActor: ContentRequestActorResolver, -) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - const query = yield* departmentQuery(request, department); - const actor = yield* authorizedActor(request, resolveActor, presentation); - - yield* authorizeContentOperation({ - endpoint: ReadContentWorkspaceEndpoint, - request, - personId: actor.personId, - authorizationInstant: actor.authorizationInstant, - resolution: { - selection: "AllMatching", - contexts: [ - { - domainId: DomainId.make("content"), - departmentId: query.departmentId ?? null, - resource: null, - facts: {}, - authorityVersion: AuthorityVersion.make(actor.authorizationInstant), - }, - ], - }, - presentation, - }); - - const workspace = yield* runContentWorkspace(actor.personId, actor.authorizationInstant, query); - const body = yield* strictOutput(ContentWorkspaceSchema)(workspace); - - return new Response(yield* jsonText(body), { - status: 200, - headers: { "cache-control": PRIVATE_NO_STORE, "content-type": "application/json" }, - }); - }).pipe( - contentProblems, - contentActorProblems(presentation), - // A workspace read names no article and runs no command. - unreachable("content.article-not-found", "content.slug-conflict", "content.lifecycle-conflict"), - ); -}; - -export const readArticle = ( - request: Request, - articleId: ArticleId, - resolveActor: ContentRequestActorResolver, -) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - yield* requireNoQuery(request); - const actor = yield* authorizedActor(request, resolveActor, presentation); - - const [detail, source, authority] = yield* Effect.all([ - runContentArticleDetail(actor.personId, actor.authorizationInstant, articleId), - readContentArticleHttpSourcePostgres(articleId), - readContentAuthorityHttpSourcesPostgres(actor.personId), - ]); - - yield* authorizeContentOperation({ - endpoint: ReadArticleEndpoint, - request, - personId: actor.personId, - authorizationInstant: actor.authorizationInstant, - resolution: { - selection: "ExactlyOne", - contexts: [articleContext(detail, source.createdByPersonId, actor.authorizationInstant)], - }, - presentation, - }); - - const output = yield* strictOutput(ContentArticleDetailSchema)(detail); - - const etag = deriveStrongETag({ - representationKind: "ContentArticleDetailSchema", - resourceIdentity: `content-article:${articleId}`, - version: [ - source.articleRevision, - source.authorProfileRevision, - authority.map((item) => [item.kind, item.identity, item.revisions]), - ], - }); - - return yield* conditionalJson({ - request, - body: output, - etag, - cacheControl: PRIVATE_NO_STORE, - contentType: "application/json", - }); - }).pipe( - contentProblems, - contentActorProblems(presentation), - // A detail read changes no slug, department, or lifecycle state. - unreachable( - "content.slug-conflict", - "content.department-not-found", - "content.lifecycle-conflict", - ), - ); -}; - -export const listNews = (request: Request, department: DepartmentId | undefined) => - Effect.gen(function* () { - const query = yield* departmentQuery(request, department); - - yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(ListNewsEndpoint)), - { - selection: "AllMatching", - contexts: [ - { - domainId: DomainId.make("content"), - departmentId: query.departmentId ?? null, - resource: null, - facts: {}, - authorityVersion: AuthorityVersion.make("public-news"), - }, - ], - }, - yield* currentInstant(undefined), - ); - - const listing = yield* readPublicNews( - PublicNewsRead.Listing({ departmentId: query.departmentId }), - ); - - const [body, sources] = yield* Effect.all([ - // SAFETY: a listing read answers the listing. - strictOutput(PublishedNewsListingSchema)(listing as PublishedNewsListing), - readPublishedNewsCollectionHttpSourcesPostgres(query.departmentId), - ]); - - const identity = - query.departmentId === undefined ? "/api/news" : `/api/news?department=${query.departmentId}`; - - const etag = deriveStrongETag({ - representationKind: "PublishedNewsListing", - resourceIdentity: identity, - version: sources.map((source) => [ - source.articleId, - source.currentVersionNumber, - source.publishedAt, - source.authorProfileRevision, - ]), - }); - - return yield* conditionalJson({ - request, - body, - etag, - cacheControl: PUBLIC_CACHE_CONTROL, - contentType: "application/json", - }); - }).pipe( - contentProblems, - // A listing read names no article. - unreachable("content.article-not-found"), - ); - -export const readNewsArticle = (request: Request, slug: string) => - Effect.gen(function* () { - const versionNumber = yield* versionFromQuery(request); - - yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(ReadNewsArticleEndpoint)), - { - selection: "ExactlyOne", - contexts: [ - { - domainId: DomainId.make("content"), - departmentId: null, - resource: { - kind: ResourceKind.make("content-article"), - id: ResourceId.make(slug), - }, - facts: {}, - authorityVersion: AuthorityVersion.make("public-news"), - }, - ], - }, - yield* currentInstant(undefined), - ); - - const article = yield* readPublicNews(PublicNewsRead.Article({ slug, versionNumber })); - - const [body, source] = yield* Effect.all([ - // SAFETY: an article read answers the article. - strictOutput(PublishedNewsArticleSchema)(article as PublishedNewsArticle), - readPublishedNewsArticleHttpSourcePostgres(slug, versionNumber), - ]); - - const etag = deriveStrongETag({ - representationKind: "PublishedNewsArticle", - resourceIdentity: - versionNumber === undefined - ? `/api/news/${slug}` - : `/api/news/${slug}?version=${versionNumber}`, - version: [ - source.articleId, - source.currentVersionNumber, - source.selectedVersionNumber, - source.publishedAt, - source.authorProfileRevision, - ], - }); - - return yield* conditionalJson({ - request, - body, - etag, - cacheControl: PUBLIC_CACHE_CONTROL, - contentType: "application/json", - }); - }).pipe( - contentProblems, - // An article read filters no department. - unreachable("content.department-not-found"), - ); diff --git a/apps/backend/src/content/http-representation.ts b/apps/backend/src/content/http-representation.ts deleted file mode 100644 index ebb3d48ca..000000000 --- a/apps/backend/src/content/http-representation.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Content HTTP representations: the article entity tag. */ -import { - readContentArticleHttpSourcePostgres, - readContentAuthorityHttpSourcesPostgres, -} from "@vektorprogrammet/database/content"; -import type { ArticleId } from "@vektorprogrammet/domain/content"; -import type { PersonId } from "@vektorprogrammet/domain/organization"; -import { Effect } from "effect"; -import { deriveStrongETag } from "../http-semantics.js"; - -export const articleETagEffect = (articleId: ArticleId, personId: PersonId) => - Effect.gen(function* () { - const [article, authority] = yield* Effect.all([ - readContentArticleHttpSourcePostgres(articleId), - readContentAuthorityHttpSourcesPostgres(personId), - ]); - - return deriveStrongETag({ - representationKind: "ContentArticleDetailSchema", - resourceIdentity: `content-article:${articleId}`, - version: [ - article.articleRevision, - article.authorProfileRevision, - authority.map((source) => [source.kind, source.identity, source.revisions]), - ], - }); - }); diff --git a/apps/backend/src/content/http.test.ts b/apps/backend/src/content/http.test.ts deleted file mode 100644 index 5425238d8..000000000 --- a/apps/backend/src/content/http.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { IdentitySnapshot } from "@vektorprogrammet/database"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { - ARTICLE_SLUG_MAX_LENGTH, - Content, - ContentArticleNotFound, - ContentDepartmentNotFound, - ContentIntegrityError, - ContentManagement, - ContentPersistenceError, - type ContentManagementFailure, -} from "@vektorprogrammet/domain/content"; -import { IdentityActor } from "@vektorprogrammet/domain/identity"; -import { DepartmentId, Organization, PersonId } from "@vektorprogrammet/domain/organization"; -import { ContentApi, ExternalNativeApi } from "@vektorprogrammet/http-api"; -import { NativeProblem } from "@vektorprogrammet/http-api/http-semantics"; -import { DateTime, Effect, Layer, Schema } from "effect"; -import { OpenApi } from "effect/unstable/httpapi"; -import { describe, expect, it } from "@effect/vitest"; -import { jsonText } from "../http-api/problem.js"; -import { makeContentManagementTestHttp, makePublicNewsTestHttp } from "../test/native-http.js"; -import { contentOperationId } from "./http-context.js"; - -/** Checks a problem response and decodes its body with the contract's problem schema. */ -const expectProblem = (response: Response, status: number, code: string) => - Effect.gen(function* () { - expect(response.headers.get("content-type")).toBe("application/problem+json"); - expect(response.headers.get("cache-control")).toBe("no-store"); - - const problem = yield* Schema.decodeUnknownEffect(NativeProblem)( - yield* Effect.promise(() => response.json()), - ); - - expect([response.status, problem.code]).toEqual([status, code]); - }); - -/** A request with a staff session cookie from the trusted dashboard origin. */ -const staffRequest = ( - path: string, - init: { - readonly method?: string; - readonly headers?: Record; - readonly body?: string; - } = {}, -) => - new Request(`http://backend.test${path}`, { - method: init.method, - headers: { - cookie: "better-auth.session_token=content-staff", - origin: "http://127.0.0.1:5174", - ...init.headers, - }, - body: init.body, - }); - -const staffPerson = PersonId.make("content-staff"); - -/** - * One content administrator. The test services leave the Identity engine - * unavailable, so person security admits every request to the handler, which - * resolves the person itself. - */ -const administrator = Layer.mergeAll( - Layer.mock(IdentitySnapshot, { - resolveSession: () => - Effect.succeed( - new IdentityActor({ - personId: staffPerson, - sessionId: "content-staff-session", - expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), - }), - ), - }), - Layer.mock(Organization, { - resolvePersonAuthority: (personId, evaluatedAt) => - Effect.succeed({ - personId, - evaluatedAt, - globalAdministrator: "Active", - memberships: [], - nationalBoardSeats: [], - delegations: [], - }), - resolvePersonAuthorityForRead: (personId, evaluatedAt) => - Effect.succeed({ - personId, - evaluatedAt, - globalAdministrator: "Active", - memberships: [], - nationalBoardSeats: [], - delegations: [], - }), - }), -); - -const signedIn = () => - Effect.succeed({ personId: staffPerson, authorizationInstant: "2030-01-01T00:00:00.000Z" }); - -describe("native content HTTP boundary", () => { - it("identifies every content endpoint by the operation id the published contract assigns it", () => { - const methods: ReadonlyArray = [ - "get", - "put", - "post", - "delete", - "options", - "head", - "patch", - "trace", - ]; - - const publishedRoutes = new Map( - Object.entries(OpenApi.fromApi(ExternalNativeApi).paths).flatMap(([path, item]) => - methods.flatMap((method) => { - const operation = item[method]; - - return operation === undefined - ? [] - : [ - [ - operation.operationId, - `${method.toUpperCase()} ${path.replace(/\{(\w+)\}/g, ":$1")}`, - ] as const, - ]; - }), - ), - ); - - const endpoints = Object.values(ContentApi.endpoints); - - expect(endpoints.map((endpoint) => publishedRoutes.get(contentOperationId(endpoint)))).toEqual( - endpoints.map((endpoint) => `${endpoint.method} ${endpoint.path}`), - ); - }); - - it.live("answers owned domain failures with the problems their operations declare", () => - Effect.gen(function* () { - const cases: ReadonlyArray = [ - [ - "/api/content/articles/1", - new ContentArticleNotFound({}), - 404, - "content.article-not-found", - ], - [ - "/api/content/articles/1", - new ContentIntegrityError({ operation: "read", message: "missing author" }), - 500, - "content.integrity-error", - ], - [ - "/api/content/articles/1", - new ContentPersistenceError({ operation: "read", message: "database unavailable" }), - 503, - "content.unavailable", - ], - [ - "/api/content/articles?department=department-1", - new ContentDepartmentNotFound({ departmentId: DepartmentId.make("department-1") }), - 422, - "content.department-not-found", - ], - ]; - - for (const [path, failure, status, code] of cases) { - const content = Layer.mock(ContentManagement, { - readArticleDetail: () => Effect.fail(failure), - readWorkspace: () => Effect.fail(failure), - }); - - const response = yield* makeContentManagementTestHttp( - signedIn, - Layer.merge(administrator, content), - ).fetch(staffRequest(path)); - - yield* expectProblem(response, status, code); - expect(response.headers.get("retry-after")).toBe(status === 503 ? "5" : null); - } - }), - ); - - it.live("answers a slug conflict from inside the create transaction", () => - Effect.gen(function* () { - const response = yield* makeContentManagementTestHttp(signedIn, administrator).fetch( - staffRequest("/api/content/articles", { - method: "POST", - headers: { - "content-type": "application/json", - "idempotency-key": "content-create-slug-conflict", - }, - // The title leaves no letter or digit to build a slug from. - body: yield* jsonText({ title: "!!!", bodyHtml: "

Tekst

", departmentIds: [] }), - }), - ); - - yield* expectProblem(response, 422, "content.slug-conflict"); - }), - ); - - it.live("routes an article slug of the maximum length to the news read", () => - Effect.gen(function* () { - const slug = "a".repeat(ARTICLE_SLUG_MAX_LENGTH); - const reads: Array = []; - - const content = Layer.mock(Content, { - readPublishedArticle: (requested) => { - reads.push(requested); - - return Effect.fail(new ContentArticleNotFound({})); - }, - }); - - const response = yield* makePublicNewsTestHttp(content).fetch( - new Request(`http://backend.test/api/news/${slug}`), - ); - - yield* expectProblem(response, 404, "content.article-not-found"); - expect(reads).toEqual([slug]); - }), - ); - - it.live("answers a person rejected after ingress from the credential the request presented", () => - Effect.gen(function* () { - // The test services leave Identity unavailable, so person security admits - // the request, and the handler's own person resolution rejects it. - const http = makeContentManagementTestHttp( - () => Effect.fail(new UnauthenticatedActor({ message: "authentication required" })), - Layer.empty, - ); - - for (const [headers, code] of [ - [{ cookie: "better-auth.session_token=content-staff" }, "credential.invalid"], - [{}, "credential.missing"], - ] as const) { - const response = yield* http.fetch( - new Request("http://backend.test/api/content/articles", { headers }), - ); - - yield* expectProblem(response, 401, code); - expect(response.headers.get("www-authenticate")).toBe( - 'VektorSession realm="native-api", Bearer realm="native-api"', - ); - } - }), - ); - - it.live("reads one bounded JSON body of its media type before decoding it", () => - Effect.gen(function* () { - const http = makeContentManagementTestHttp( - () => Effect.die("unexpected person resolution"), - Layer.empty, - ); - - const create = (headers: Record, body: string) => - http.fetch( - staffRequest("/api/content/articles", { - method: "POST", - headers: { "idempotency-key": "content-body-test-0000000", ...headers }, - body, - }), - ); - - yield* expectProblem( - yield* create({ "content-type": "text/plain" }, "{}"), - 415, - "media-type.unsupported", - ); - yield* expectProblem( - yield* create({ "content-type": "application/json", "content-length": "1048577" }, "{}"), - 413, - "request.too-large", - ); - yield* expectProblem( - yield* create({ "content-type": "application/json", "content-length": "+2" }, "{}"), - 400, - "request.malformed", - ); - yield* expectProblem( - yield* create({ "content-type": "application/json" }, '{"title":"first","title":"second"}'), - 400, - "request.malformed", - ); - }), - ); -}); diff --git a/apps/backend/src/content/http.ts b/apps/backend/src/content/http.ts deleted file mode 100644 index e60885938..000000000 --- a/apps/backend/src/content/http.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** Native HttpApi composition for staff content and public news endpoints. */ -import { ExternalNativeApi } from "@vektorprogrammet/http-api"; -import { Effect } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { webHandler } from "../http-api/problem.js"; -import { createArticle, lifecycleArticle, reviseArticle } from "./http-commands.js"; -import type { ContentRequestActorResolver } from "./http-context.js"; -import { listNews, readArticle, readContentWorkspace, readNewsArticle } from "./http-reads.js"; - -const DEFAULT_MAX_BODY_BYTES = 1_048_576; - -/** Native HttpApi implementations for staff content and public news endpoints. */ -export const ContentApiHandlers = ( - resolveActor: ContentRequestActorResolver, - maxBodyBytes = DEFAULT_MAX_BODY_BYTES, -) => - HttpApiBuilder.group(ExternalNativeApi, "content", (handlers) => - Effect.succeed( - handlers - .handleRaw("readContentWorkspace", ({ request, query }) => - webHandler(request, (webRequest) => - readContentWorkspace(webRequest, query.department, resolveActor), - ), - ) - .handleRaw("createArticle", ({ request }) => - webHandler(request, (webRequest) => createArticle(webRequest, maxBodyBytes)), - ) - .handleRaw("readArticle", ({ request, params }) => - webHandler(request, (webRequest) => - readArticle(webRequest, params.articleId, resolveActor), - ), - ) - .handleRaw("reviseArticle", ({ request, params }) => - webHandler(request, (webRequest) => - reviseArticle(webRequest, params.articleId, maxBodyBytes), - ), - ) - .handleRaw("publishArticle", ({ request, params }) => - webHandler(request, (webRequest) => - lifecycleArticle(webRequest, params.articleId, "Publish", maxBodyBytes), - ), - ) - .handleRaw("unpublishArticle", ({ request, params }) => - webHandler(request, (webRequest) => - lifecycleArticle(webRequest, params.articleId, "Unpublish", maxBodyBytes), - ), - ) - .handleRaw("listNews", ({ request, query }) => - webHandler(request, (webRequest) => listNews(webRequest, query.department)), - ) - .handleRaw("readNewsArticle", ({ request, params }) => - webHandler(request, (webRequest) => readNewsArticle(webRequest, params.slug)), - ), - ), - ); diff --git a/apps/backend/src/content/http-problem.ts b/apps/backend/src/content/problem.ts similarity index 90% rename from apps/backend/src/content/http-problem.ts rename to apps/backend/src/content/problem.ts index 5c95ed1d7..d0ff756d7 100644 --- a/apps/backend/src/content/http-problem.ts +++ b/apps/backend/src/content/problem.ts @@ -10,13 +10,13 @@ import type { OrganizationDecodeError, OrganizationPersistenceError, } from "@vektorprogrammet/domain/organization"; -import { type CredentialPresentation, Problem } from "@vektorprogrammet/http-api/http-semantics"; +import { type CredentialPresentation, Problem } from "@vektorprogrammet/rpc/problem"; import { type CredentialCases, type ProblemCases, type ProblemMapper, problemMapper, -} from "../http-api/problem.js"; +} from "../rpc/problem.js"; /** Every content failure that a content handler answers, but a rejected credential. */ type ContentFailure = @@ -53,15 +53,15 @@ const contentCases = { * read.pipe(contentProblems, contentActorProblems(presentation)); * ``` * - * @avoid Mapping a content failure in a handler: the content endpoints then answer one failure + * @avoid Mapping a content failure in a handler: the content RPCs then answer one failure * differently. Pipe the handler's effect through this. * - * @construct http-problem + * @construct rpc-problem */ export const contentProblems: ProblemMapper = problemMapper()(contentCases); -/** A staff person whom ingress admitted, rejected by the content actor resolution. */ +/** A staff person whom the credential middleware admitted, rejected by the actor resolution. */ type ContentActorFailure = | UnauthenticatedActor | ContentUnauthenticatedActor @@ -79,7 +79,8 @@ const contentActorFixedCases = { type ContentActorCases = typeof contentActorFixedCases & CredentialCases<"UnauthenticatedActor">; /** - * A staff person rejected after ingress is answered from the credential the request presented. + * A staff person rejected after the credential middleware is answered from the credential the + * request presented. * * @remarks * A rejected staff person answers `Problem.unauthenticated(presentation)`: credential.missing or @@ -96,7 +97,7 @@ type ContentActorCases = typeof contentActorFixedCases & CredentialCases<"Unauth * @avoid Answering a rejected staff person with a fixed credential code: a request that presented * a rejected session would be told that it presented none. Pipe the actor resolution through this. * - * @construct http-problem + * @construct rpc-problem */ export const contentActorProblems = ( presentation: CredentialPresentation, diff --git a/apps/backend/src/content/reads.ts b/apps/backend/src/content/reads.ts new file mode 100644 index 000000000..e6bfc8bf8 --- /dev/null +++ b/apps/backend/src/content/reads.ts @@ -0,0 +1,213 @@ +/** Content reads: the staff workspace and article detail, and the public news. */ +import { + readContentArticleHttpSourcePostgres, + readContentAuthorityHttpSourcesPostgres, +} from "@vektorprogrammet/database/content"; +import { + AuthorityVersion, + DomainId, + ResourceId, + ResourceKind, +} from "@vektorprogrammet/domain/authz"; +import { + ContentArticleDetailSchema, + ContentWorkspaceSchema, + PublicNewsRead, + PublishedNewsArticleSchema, + PublishedNewsListingSchema, + readPublicNews, + runContentArticleDetail, + runContentWorkspace, + type ArticleId, + type ArticleSlug, + type ContentWorkspaceQuery, + type PublishedNewsArticle, + type PublishedNewsListing, +} from "@vektorprogrammet/domain/content"; +import { + type ContentArticleResource, + ListNews, + ReadArticle, + ReadContentWorkspace, + ReadNewsArticle, + reflectAccessSpec, +} from "@vektorprogrammet/rpc"; +import { Effect, Option } from "effect"; +import type { Headers } from "effect/unstable/http"; +import { currentInstant } from "../authority.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { + authorizeAnonymous, + personPresentation, + strictOutput, + unreachable, +} from "../rpc/problem.js"; +import { + articleContext, + articleETag, + authorizeContentOperation, + unscopedContext, +} from "./access.js"; +import { readActor } from "./context.js"; +import { contentActorProblems, contentProblems } from "./problem.js"; + +/** The article drafts that the staff person may see, optionally of one department. */ +export const readContentWorkspace = (input: { + readonly headers: Headers.Headers; + readonly query: ContentWorkspaceQuery; + readonly options: NativeRpcOptions; +}) => { + const presentation = personPresentation(input.headers); + + return Effect.gen(function* () { + const actor = yield* readActor({ headers: input.headers, now: input.options.now }); + + yield* authorizeContentOperation({ + rpc: ReadContentWorkspace, + request: credentialRequestOf(input.headers), + personId: actor.personId, + authorizationInstant: actor.authorizationInstant, + resolution: { + selection: "AllMatching", + contexts: [ + unscopedContext({ + departmentId: input.query.departmentId ?? null, + authorityVersion: actor.authorizationInstant, + }), + ], + }, + presentation, + }); + + const query: ContentWorkspaceQuery = + input.query.departmentId === undefined ? {} : { departmentId: input.query.departmentId }; + + const workspace = yield* runContentWorkspace(actor.personId, actor.authorizationInstant, query); + + return yield* strictOutput(ContentWorkspaceSchema)(workspace); + }).pipe( + contentProblems, + contentActorProblems(presentation), + // A workspace read names no article and runs no command. + unreachable("content.article-not-found", "content.slug-conflict", "content.lifecycle-conflict"), + ); +}; + +/** One staff article detail beside the entity tag that its commands take as `ifMatch`. */ +export const readArticle = (input: { + readonly headers: Headers.Headers; + readonly articleId: ArticleId; + readonly options: NativeRpcOptions; +}) => { + const presentation = personPresentation(input.headers); + const { articleId } = input; + + return Effect.gen(function* () { + const actor = yield* readActor({ headers: input.headers, now: input.options.now }); + + const [detail, source, authority] = yield* Effect.all([ + runContentArticleDetail(actor.personId, actor.authorizationInstant, articleId), + readContentArticleHttpSourcePostgres(articleId), + readContentAuthorityHttpSourcesPostgres(actor.personId), + ]); + + yield* authorizeContentOperation({ + rpc: ReadArticle, + request: credentialRequestOf(input.headers), + personId: actor.personId, + authorizationInstant: actor.authorizationInstant, + resolution: { + selection: "ExactlyOne", + contexts: [ + articleContext({ + detail, + createdByPersonId: source.createdByPersonId, + authorityVersion: actor.authorizationInstant, + }), + ], + }, + presentation, + }); + + const article = yield* strictOutput(ContentArticleDetailSchema)(detail); + + return { + article, + etag: articleETag({ articleId, source, authority }), + } satisfies ContentArticleResource; + }).pipe( + contentProblems, + contentActorProblems(presentation), + // A detail read changes no slug, department, or lifecycle state. + unreachable( + "content.slug-conflict", + "content.department-not-found", + "content.lifecycle-conflict", + ), + ); +}; + +/** The current public news listing, optionally of one department. */ +export const listNews = (query: ContentWorkspaceQuery) => + Effect.gen(function* () { + const departmentId = query.departmentId; + + yield* authorizeAnonymous( + Option.getOrThrow(reflectAccessSpec(ListNews)), + { + selection: "AllMatching", + contexts: [ + unscopedContext({ departmentId: departmentId ?? null, authorityVersion: "public-news" }), + ], + }, + yield* currentInstant(undefined), + ); + + const listing = yield* readPublicNews(PublicNewsRead.Listing({ departmentId })); + + // SAFETY: a listing read answers the listing. + return yield* strictOutput(PublishedNewsListingSchema)(listing as PublishedNewsListing); + }).pipe( + contentProblems, + // A listing read names no article. + unreachable("content.article-not-found"), + ); + +/** The current, or the selected, published version of one news article. */ +export const readNewsArticle = (input: { + readonly slug: ArticleSlug; + readonly version?: number | undefined; +}) => + Effect.gen(function* () { + yield* authorizeAnonymous( + Option.getOrThrow(reflectAccessSpec(ReadNewsArticle)), + { + selection: "ExactlyOne", + contexts: [ + { + domainId: DomainId.make("content"), + departmentId: null, + resource: { + kind: ResourceKind.make("content-article"), + id: ResourceId.make(input.slug), + }, + facts: {}, + authorityVersion: AuthorityVersion.make("public-news"), + }, + ], + }, + yield* currentInstant(undefined), + ); + + const article = yield* readPublicNews( + PublicNewsRead.Article({ slug: input.slug, versionNumber: input.version }), + ); + + // SAFETY: an article read answers the article. + return yield* strictOutput(PublishedNewsArticleSchema)(article as PublishedNewsArticle); + }).pipe( + contentProblems, + // An article read filters no department. + unreachable("content.department-not-found"), + ); diff --git a/apps/backend/src/content/rpc.test.ts b/apps/backend/src/content/rpc.test.ts new file mode 100644 index 000000000..e7e4f2086 --- /dev/null +++ b/apps/backend/src/content/rpc.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Database, IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; +import { ContentLive, ContentManagementLive } from "@vektorprogrammet/database/content"; +import { OrganizationLive } from "@vektorprogrammet/database/organization"; +import { ProfileLive } from "@vektorprogrammet/database/profile"; +import { + ARTICLE_SLUG_MAX_LENGTH, + ArticleId, + ArticleSlug, + Content, + ContentArticleNotFound, + ContentDepartmentNotFound, + ContentIntegrityError, + ContentManagement, + ContentPersistenceError, + type ContentManagementFailure, +} from "@vektorprogrammet/domain/content"; +import { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; +import { DepartmentId, Organization, PersonId } from "@vektorprogrammet/domain/organization"; +import { IdempotencyKey, isProblem, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Exit, Layer, Option } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import { backendDatabase } from "../../test/database.js"; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; + +const staffPerson = PersonId.make("content-staff"); + +const cookie = { cookie: "better-auth.session_token=content-staff" }; + +const actor = IdentityActor.make({ + personId: staffPerson, + sessionId: "content-staff-session", + expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), +}); + +/** An identity engine that resolves sessions with `resolveSession` and does nothing else. */ +const identityOf = (resolveSession: IdentityOperations["resolveSession"]) => + Identity.of({ + signIn: () => Effect.die("unexpected sign-in"), + resolveSession, + readCurrentSession: () => Effect.die("unexpected session read"), + listSessions: () => Effect.die("unexpected session list"), + revokeCurrentSession: () => Effect.die("unexpected session mutation"), + revokeSession: () => Effect.die("unexpected session mutation"), + revokeOtherSessions: () => Effect.die("unexpected session mutation"), + revokeAllSessions: () => Effect.die("unexpected session mutation"), + recordSecurityEvent: () => Effect.die("unexpected identity audit"), + signOut: () => Effect.succeed({ setCookies: [] }), + } satisfies IdentityOperations); + +const staffSession = (cookieHeader: string | undefined) => + cookieHeader?.includes("content-staff") === true + ? Effect.succeed(actor) + : Effect.fail(IdentitySessionNotFound.make()); + +/** The staff person's session, in and outside a transaction, and no OAuth bearer. */ +const security = Layer.mergeAll( + Layer.succeed(Identity, identityOf(staffSession)), + Layer.mock(IdentitySnapshot, { resolveSession: staffSession }), + Layer.succeed( + OAuthCredentialAuthority, + OAuthCredentialAuthority.of({ + resolve: () => Effect.die("unexpected OAuth credential resolution"), + resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), + }), + ), +); + +const globalAdministrator = (personId: PersonId, evaluatedAt: string) => + Effect.succeed({ + personId, + evaluatedAt, + globalAdministrator: "Active" as const, + memberships: [], + nationalBoardSeats: [], + delegations: [], + }); + +/** One content administrator, whose Organization projection is mocked. */ +const administrator = Layer.merge( + security, + Layer.mock(Organization, { + resolvePersonAuthority: globalAdministrator, + resolvePersonAuthorityForRead: globalAdministrator, + }), +); + +const backendOver = (services: Layer.Layer) => + makeBackendTestRpc({ config: backendTestConfig, services }).client; + +/** The problem code of a failed call, or `ok`. */ +const codeOf = (exit: Exit.Exit): string => + Exit.isSuccess(exit) + ? "ok" + : Option.match(Exit.findErrorOption(exit), { + onNone: () => "defect", + onSome: (error) => (isProblem(error) ? error.code : "defect"), + }); + +const key = (value: string) => IdempotencyKey.make(value.padEnd(22, "0")); + +const staleETag = StrongETag.make(`"vkr2.${"A".repeat(43)}"`); + +describe("content RPC failures", () => { + it.live("answers owned domain failures with the problems their operations declare", () => + Effect.gen(function* () { + const cases: ReadonlyArray< + readonly ["readArticle" | "readContentWorkspace", ContentManagementFailure, string] + > = [ + ["readArticle", ContentArticleNotFound.make({}), "content.article-not-found"], + [ + "readArticle", + ContentIntegrityError.make({ operation: "read", message: "missing author" }), + "content.integrity-error", + ], + [ + "readArticle", + ContentPersistenceError.make({ operation: "read", message: "database unavailable" }), + "content.unavailable", + ], + [ + "readContentWorkspace", + ContentDepartmentNotFound.make({ departmentId: DepartmentId.make("department-1") }), + "content.department-not-found", + ], + ]; + + for (const [operation, failure, code] of cases) { + const client = yield* backendOver( + Layer.merge( + administrator, + Layer.mock(ContentManagement, { + readArticleDetail: () => Effect.fail(failure), + readWorkspace: () => Effect.fail(failure), + }), + ), + ); + + const answered = + operation === "readArticle" + ? codeOf( + yield* Effect.exit( + client["content.readArticle"]({ articleId: ArticleId.make(1) }), + ).pipe(RpcClient.withHeaders(cookie)), + ) + : codeOf( + yield* Effect.exit( + client["content.readContentWorkspace"]({ + departmentId: DepartmentId.make("department-1"), + }), + ).pipe(RpcClient.withHeaders(cookie)), + ); + + expect([operation, answered]).toEqual([operation, code]); + } + }), + ); + + it.live("answers a slug conflict from inside the create transaction", () => + Effect.gen(function* () { + const client = yield* backendOver(administrator); + + const exit = yield* Effect.exit( + client["content.createArticle"]({ + idempotencyKey: key("content-create-slug-conflict"), + // The title leaves no letter or digit to build a slug from. + request: { title: "!!!", bodyHtml: "

Tekst

", departmentIds: [] }, + }), + ).pipe(RpcClient.withHeaders(cookie)); + + expect(codeOf(exit)).toBe("content.slug-conflict"); + }), + ); + + it.live("reads a news article by a slug of the maximum length", () => + Effect.gen(function* () { + const slug = "a".repeat(ARTICLE_SLUG_MAX_LENGTH); + const reads: Array = []; + + const client = yield* backendOver( + Layer.mock(Content, { + readPublishedArticle: (requested) => { + reads.push(requested); + + return Effect.fail(ContentArticleNotFound.make({})); + }, + }), + ); + + const exit = yield* Effect.exit( + client["content.readNewsArticle"]({ slug: ArticleSlug.make(slug) }), + ); + + expect(codeOf(exit)).toBe("content.article-not-found"); + expect(reads).toEqual([slug]); + }), + ); + + it.live("answers a person rejected after the middleware from the credential presented", () => + Effect.gen(function* () { + let calls = 0; + + // The middleware admits the session; the handler's own resolution then rejects it. + const flaky: IdentityOperations["resolveSession"] = (cookieHeader) => { + calls += 1; + + return calls === 1 + ? staffSession(cookieHeader) + : Effect.fail(IdentitySessionNotFound.make()); + }; + + const client = yield* backendOver(Layer.succeed(Identity, identityOf(flaky))); + + const rejected = yield* Effect.exit(client["content.readContentWorkspace"]({})).pipe( + RpcClient.withHeaders(cookie), + ); + + const anonymous = yield* Effect.exit(client["content.readContentWorkspace"]({})); + + expect([codeOf(rejected), codeOf(anonymous), calls]).toEqual([ + "credential.invalid", + "credential.missing", + 2, + ]); + }), + ); +}); + +describe("content RPC journey on PostgreSQL", () => { + const seed = Database.use((sql) => + Effect.gen(function* () { + yield* sql`INSERT INTO person_profiles (person_id, first_name, last_name) VALUES (${staffPerson}, 'Kari', 'Penerbit')`; + yield* sql`INSERT INTO organization_global_administrator_grants (grant_id, person_id, start_at) VALUES ('content-admin-grant', ${staffPerson}, '2020-01-01T00:00:00.000Z')`; + }), + ); + + const backend = () => { + const database = backendDatabase(seed); + const organization = OrganizationLive.pipe(Layer.provide(database.layer)); + const profile = ProfileLive.pipe(Layer.provide(Layer.merge(database.layer, organization))); + + return backendOver( + Layer.mergeAll( + database.layer, + security, + organization, + profile, + ContentManagementLive.pipe(Layer.provide(database.layer)), + ContentLive.pipe(Layer.provide(Layer.mergeAll(database.layer, organization, profile))), + ), + ); + }; + + it.live("drafts, revises, publishes, and unpublishes under entity tags, replaying by key", () => + Effect.gen(function* () { + const client = yield* backend(); + const as = RpcClient.withHeaders(cookie); + + const createRequest = { + title: "Opptak til høsten", + bodyHtml: "

Søk nå.

", + departmentIds: [], + }; + + const created = yield* client["content.createArticle"]({ + idempotencyKey: key("content-create"), + request: createRequest, + }).pipe(as); + + const replayed = yield* client["content.createArticle"]({ + idempotencyKey: key("content-create"), + request: createRequest, + }).pipe(as); + + expect(replayed).toEqual(created); + expect(created.article.status).toBe("Draft"); + + const conflicting = yield* Effect.exit( + client["content.createArticle"]({ + idempotencyKey: key("content-create"), + request: { ...createRequest, title: "Et annet opptak" }, + }), + ).pipe(as); + + expect(codeOf(conflicting)).toBe("idempotency.digest-conflict"); + + const { articleId } = created.article; + const read = yield* client["content.readArticle"]({ articleId }).pipe(as); + + expect(read).toEqual(created); + + const revise = ( + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: Parameters<(typeof client)["content.reviseArticle"]>[0]["request"], + ) => + client["content.reviseArticle"]({ articleId, idempotencyKey, ifMatch, request }).pipe(as); + + expect(codeOf(yield* Effect.exit(revise(key("no-change"), read.etag, {})))).toBe( + "validation.no-change", + ); + expect( + codeOf(yield* Effect.exit(revise(key("delete-title"), read.etag, { title: null }))), + ).toBe("validation.field-not-deletable"); + expect( + codeOf(yield* Effect.exit(revise(key("stale"), staleETag, { bodyHtml: "

Nå.

" }))), + ).toBe("precondition.failed"); + + const revised = yield* revise(key("revise"), read.etag, { bodyHtml: "

Søk i dag.

" }); + + expect(revised.article.bodyHtml).toBe("

Søk i dag.

"); + expect(revised.article.title).toBe(createRequest.title); + expect(revised.etag).not.toBe(read.etag); + expect(yield* revise(key("revise"), read.etag, { bodyHtml: "

Søk i dag.

" })).toEqual( + revised, + ); + + const transition = { articleId, ifMatch: revised.etag }; + + expect( + codeOf( + yield* Effect.exit( + client["content.publishArticle"]({ + ...transition, + idempotencyKey: key("publish-stale"), + ifMatch: read.etag, + }).pipe(as), + ), + ), + ).toBe("precondition.failed"); + + const published = yield* client["content.publishArticle"]({ + ...transition, + idempotencyKey: key("publish"), + }).pipe(as); + + expect(published.result).toMatchObject({ articleId, versionNumber: 1 }); + expect( + yield* client["content.publishArticle"]({ + ...transition, + idempotencyKey: key("publish"), + }).pipe(as), + ).toEqual(published); + + const listing = yield* client["content.listNews"]({}); + const news = yield* client["content.readNewsArticle"]({ slug: created.article.slug }); + + expect(listing.articles.map((article) => article.slug)).toEqual([created.article.slug]); + expect(news.bodyHtml).toBe("

Søk i dag.

"); + + const unpublished = yield* client["content.unpublishArticle"]({ + articleId, + idempotencyKey: key("unpublish"), + ifMatch: published.etag, + }).pipe(as); + + expect(unpublished.result).toEqual({ articleId }); + expect((yield* client["content.listNews"]({})).articles).toEqual([]); + expect((yield* client["content.readArticle"]({ articleId }).pipe(as)).etag).toBe( + unpublished.etag, + ); + }), + ); +}); diff --git a/apps/backend/src/content/rpc.ts b/apps/backend/src/content/rpc.ts new file mode 100644 index 000000000..754a13a8a --- /dev/null +++ b/apps/backend/src/content/rpc.ts @@ -0,0 +1,28 @@ +/** + * The ContentRpcs handlers: staff article drafts and publication, and the public news. + * + * A staff read resolves the person at one authorization instant and evaluates the RPC's AccessSpec + * over the article or department it reads. A command resolves the credential and the authority + * inside the serializable transaction that commits it, compares `ifMatch` there, and stores its + * answer as a command receipt. The public news reads evaluate their anonymous AccessSpec. + */ +import { ContentRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { createArticle, publishArticle, reviseArticle, unpublishArticle } from "./commands.js"; +import { listNews, readArticle, readContentWorkspace, readNewsArticle } from "./reads.js"; + +/** The ContentRpcs handlers. */ +export const ContentRpcHandlers = (options: NativeRpcOptions) => + ContentRpcs.toLayer({ + "content.readContentWorkspace": (query, { headers }) => + readContentWorkspace({ headers, query, options }), + "content.createArticle": ({ idempotencyKey, request }, { headers }) => + createArticle({ headers, idempotencyKey, request }), + "content.readArticle": ({ articleId }, { headers }) => + readArticle({ headers, articleId, options }), + "content.reviseArticle": (payload, { headers }) => reviseArticle({ headers, ...payload }), + "content.publishArticle": (payload, { headers }) => publishArticle({ headers, ...payload }), + "content.unpublishArticle": (payload, { headers }) => unpublishArticle({ headers, ...payload }), + "content.listNews": (query) => listNews(query), + "content.readNewsArticle": (query) => readNewsArticle(query), + }); diff --git a/apps/backend/src/delivery/AGENTS.md b/apps/backend/src/delivery/AGENTS.md index a9fa5682d..d74c4c292 100644 --- a/apps/backend/src/delivery/AGENTS.md +++ b/apps/backend/src/delivery/AGENTS.md @@ -3,7 +3,7 @@ # apps/backend/src/delivery This folder holds the Delivery bounded context in `apps/backend/src`. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The Delivery context in other folders: diff --git a/apps/backend/src/delivery/http.ts b/apps/backend/src/delivery/http.ts index 9fd84958a..10b33ca1a 100644 --- a/apps/backend/src/delivery/http.ts +++ b/apps/backend/src/delivery/http.ts @@ -9,8 +9,9 @@ * delivery timeout with a timeout failure. It never retries: the caller's outbox claim decides * whether an ambiguous attempt runs again. */ -import { Data, Duration, Effect, Stream, type Cause, type Schema } from "effect"; +import { Data, Duration, Effect, Predicate, Stream, type Cause, type Schema } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { dual } from "effect/Function"; export interface HttpDeliveryConfig { readonly endpoint: URL; @@ -53,29 +54,48 @@ export class HttpDeliveryFailure extends Data.TaggedError("HttpDeliveryFailure") * * @construct delivery */ -export const deliverJson = ( - body: Schema.Json, - config: HttpDeliveryConfig, - headers: Readonly> = {}, -): Effect.Effect => - Effect.gen(function* () { - const response = yield* HttpClientRequest.post(config.endpoint).pipe( - HttpClientRequest.setHeaders({ - authorization: `Bearer ${config.token}`, - "content-type": "application/json", - ...headers, - }), - HttpClientRequest.bodyJson(body), - Effect.flatMap(HttpClient.execute), - // The provider sees the request of the bare fetch: no trace headers, and no redirect. - Effect.provideService(HttpClient.TracerPropagationEnabled, false), - Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), - Effect.mapError((cause) => new HttpDeliveryFailure({ reason: "Unavailable", cause })), - ); +export const deliverJson: { + ( + body: Schema.Json, + config: HttpDeliveryConfig, + headers?: Readonly>, + ): Effect.Effect; + ( + config: HttpDeliveryConfig, + headers?: Readonly>, + ): ( + body: Schema.Json, + ) => Effect.Effect; +} = dual( + (args) => + args.length >= 3 || + (args.length === 2 && + Predicate.hasProperty(args[1], "endpoint") && + args[1].endpoint instanceof URL), + ( + body: Schema.Json, + config: HttpDeliveryConfig, + headers: Readonly> = {}, + ): Effect.Effect => + Effect.gen(function* () { + const response = yield* HttpClientRequest.post(config.endpoint).pipe( + HttpClientRequest.setHeaders({ + authorization: `Bearer ${config.token}`, + "content-type": "application/json", + ...headers, + }), + HttpClientRequest.bodyJson(body), + Effect.flatMap(HttpClient.execute), + // The provider sees the request of the bare fetch: no trace headers, and no redirect. + Effect.provideService(HttpClient.TracerPropagationEnabled, false), + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), + Effect.mapError((cause) => new HttpDeliveryFailure({ reason: "Unavailable", cause })), + ); - if (response.status < 200 || response.status > 299) - return yield* new HttpDeliveryFailure({ reason: "Rejected", status: response.status }); + if (response.status < 200 || response.status > 299) + return yield* new HttpDeliveryFailure({ reason: "Rejected", status: response.status }); - // Closing the scope of the unread pull cancels the body reader. - yield* Stream.toPull(response.stream).pipe(Effect.asVoid, Effect.scoped); - }).pipe(Effect.timeout(Duration.millis(config.deliveryTimeoutMilliseconds))); + // Closing the scope of the unread pull cancels the body reader. + yield* Stream.toPull(response.stream).pipe(Effect.asVoid, Effect.scoped); + }).pipe(Effect.timeout(Duration.millis(config.deliveryTimeoutMilliseconds))), +); diff --git a/apps/backend/src/directory/AGENTS.md b/apps/backend/src/directory/AGENTS.md index 354422fa1..afd1a89e8 100644 --- a/apps/backend/src/directory/AGENTS.md +++ b/apps/backend/src/directory/AGENTS.md @@ -3,7 +3,7 @@ # apps/backend/src/directory This folder holds People code under another name. The people directory read. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The People context in other folders: diff --git a/apps/backend/src/directory/http.ts b/apps/backend/src/directory/http.ts deleted file mode 100644 index 9eb2abff9..000000000 --- a/apps/backend/src/directory/http.ts +++ /dev/null @@ -1,246 +0,0 @@ -import type { OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import type { Identity, IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import { - Organization, - resolveDirectoryGateScope, - directoryRowInScope, - type OrganizationPersonAuthority, -} from "@vektorprogrammet/domain/organization"; -import { Profile, type ProfileFailure } from "@vektorprogrammet/domain/profile"; -import { - ExternalNativeApi, - ListPeopleEndpoint, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { type CredentialPresentation, Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Predicate, Effect, Option, Schema } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import type { OrganizationResolutionError } from "../authority.js"; -import { genericContext } from "../native-operation.js"; -import { - authorizePerson, - personPresentation, - problemMapper, - unreachable, - webHandler, -} from "../http-api/problem.js"; -import { listSchools, type SchoolsApiHttpOptions } from "../schools/http.js"; -import { - readSchoolManagementHttp, - executeSchoolCommandHttp, -} from "../schools/administration-http.js"; - -/** - * GET /api/people — the native people directory. - * - * The adapter only decodes transport data, resolves authority through the - * shared spec 0055 helpers, and maps typed results. It imports no SQL and - * implements no domain transition. - */ - -export interface DirectoryApiHttpOptions { - /** Cookie -> PersonId + one authorizationInstant -> caller projection. */ - readonly resolveAuthority: ( - request: Request, - ) => Effect.Effect< - OrganizationPersonAuthority, - IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, - Organization | Identity | OAuthCredentialAuthority - >; -} - -const privateJsonResponse = (body: Schema.Json): Response => - new Response(JSON.stringify(body), { - status: 200, - headers: { - "content-type": "application/json; charset=utf-8", - "cache-control": "private, no-store", - vary: "Origin", - }, - }); - -const DirectoryEntrySchema = Schema.Struct({ - personId: Schema.String, - firstName: Schema.String, - lastName: Schema.String, - email: Schema.String, - phone: Schema.String, - /** Null until spec 0058 adds the Organization-owned association. */ - studyProgramme: Schema.Null, - departments: Schema.Array(Schema.String), - isActive: Schema.Boolean, -}); - -const DirectoryResponseSchema = Schema.Struct({ - activePeople: Schema.Array(DirectoryEntrySchema), - inactivePeople: Schema.Array(DirectoryEntrySchema), - nextCursor: Schema.NullOr(Schema.String), -}); - -const DIRECTORY_PAGE_LIMIT = 200; - -const unavailable = () => Problem.make("directory.unavailable"); - -/** - * The one answer for every people-directory failure. A read that cannot - * complete, or a response that does not fit its schema, leaves the directory - * unavailable. - */ -const directoryProblems = problemMapper< - OrganizationResolutionError | ProfileFailure | Schema.SchemaError ->()({ - OrganizationDecodeError: unavailable, - OrganizationPersistenceError: unavailable, - ProfileDecodeError: unavailable, - ProfileQueryLimitExceeded: unavailable, - ProfileNotFound: unavailable, - ProfileContactNotFound: unavailable, - ProfileStaleRevision: unavailable, - ProfileCommandConflict: unavailable, - ProfilePersistenceError: unavailable, - SchemaError: unavailable, -}); - -/** - * A person credential rejected after ingress is answered from the request's - * own evidence; an unavailable identity provider leaves the directory - * unavailable. - */ -const directoryCredentialProblems = (presentation: CredentialPresentation) => - problemMapper()({ - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - IdentityEngineError: unavailable, - }); - -const listPeople = (request: Request, input: DirectoryApiHttpOptions) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - if (new URL(request.url).search !== "") { - return yield* Problem.make("directory.cursor-malformed"); - } - - // One captured authorizationInstant drives the gate and every row - // derivation; Profile and Organization read one database snapshot. - const authority = yield* input.resolveAuthority(request); - const decision = resolveDirectoryGateScope(authority); - - if (Predicate.isTagged(decision, "Deny")) { - return yield* Problem.make("authority.denied"); - } - - const scope = decision.value; - - const contexts = !Predicate.isTagged(scope, "Departments") - ? [ - genericContext({ - domainId: "profile", - authorityVersion: `directory:${authority.evaluatedAt}`, - }), - ] - : scope.departmentIds.map((departmentId) => - genericContext({ - domainId: "profile", - departmentId, - authorityVersion: `directory:${authority.evaluatedAt}`, - }), - ); - - const grantScopes = !Predicate.isTagged(scope, "Departments") - ? [{ _tag: "Global" as const }] - : scope.departmentIds.map((departmentId) => ({ - _tag: "Department" as const, - departmentId, - })); - - yield* authorizePerson( - { - request, - personId: authority.personId, - spec: Option.getOrThrow(reflectAccessSpec(ListPeopleEndpoint)), - resolution: { selection: "AllMatching", contexts }, - grantScopes, - now: authority.evaluatedAt, - }, - presentation, - ); - - const response = yield* Effect.gen(function* () { - const organization = yield* Organization; - const profile = yield* Profile; - const activePeople: Array = []; - const inactivePeople: Array = []; - let cursor: string | undefined; - - while (true) { - const page = yield* profile.readDirectoryPage({ limit: DIRECTORY_PAGE_LIMIT, cursor }); - - if (page.entries.length > 0) { - const facts = yield* organization.deriveDirectoryFacts( - page.entries.map((entry) => entry.personId), - authority.evaluatedAt, - ); - - for (const entry of page.entries) { - const fact = facts.get(entry.personId); - - if (fact === undefined || !directoryRowInScope(scope, fact.departments)) continue; - - const row = { - personId: entry.personId, - firstName: entry.firstName, - lastName: entry.lastName, - email: entry.email, - phone: entry.phone, - studyProgramme: null, - departments: [...fact.departmentNames], - isActive: fact.isActive, - }; - - if (fact.isActive) activePeople.push(row); - else inactivePeople.push(row); - } - } - - if (page.nextCursor === undefined) break; - cursor = page.nextCursor; - } - - return yield* Schema.decodeEffect(DirectoryResponseSchema)( - { activePeople, inactivePeople, nextCursor: cursor ?? null }, - { onExcessProperty: "error" }, - ); - }); - - return privateJsonResponse(response); - }).pipe( - directoryProblems, - directoryCredentialProblems(presentation), - // A revealing AccessSpec answers every authority failure as a denial, never as 404. - unreachable("resource.not-found"), - ); -}; - -/** Native HttpApi implementation for the people and school directories. */ -export const DirectoryApiHandlers = ( - input: DirectoryApiHttpOptions, - schools: SchoolsApiHttpOptions, -) => - HttpApiBuilder.group(ExternalNativeApi, "directory", (handlers) => - Effect.succeed( - handlers - .handleRaw("readSchoolManagement", ({ request }) => - webHandler(request, readSchoolManagementHttp), - ) - .handleRaw("executeSchoolCommand", ({ request }) => - webHandler(request, executeSchoolCommandHttp), - ) - .handleRaw("listPeople", ({ request }) => - webHandler(request, (webRequest) => listPeople(webRequest, input)), - ) - .handleRaw("listSchools", ({ request }) => - webHandler(request, (webRequest) => listSchools(webRequest, schools)), - ), - ), - ); diff --git a/apps/backend/src/directory/http.test.ts b/apps/backend/src/directory/rpc.test.ts similarity index 69% rename from apps/backend/src/directory/http.test.ts rename to apps/backend/src/directory/rpc.test.ts index 23da446de..9be8841e3 100644 --- a/apps/backend/src/directory/http.test.ts +++ b/apps/backend/src/directory/rpc.test.ts @@ -1,5 +1,4 @@ import { backendDatabase } from "../../test/database.js"; -import { PeopleDirectoryResponse } from "@vektorprogrammet/http-api"; import { OAuthCredentialAuthority } from "@vektorprogrammet/database"; import { Identity, @@ -8,7 +7,6 @@ import { type IdentityOperations, } from "@vektorprogrammet/domain/identity"; -import { SocialEvents } from "@vektorprogrammet/domain/social-events"; import { MembershipId, TeamId, @@ -31,26 +29,14 @@ import { } from "@vektorprogrammet/domain/profile"; import { Schema, DateTime, Effect, Layer } from "effect"; import { describe, expect, it } from "@effect/vitest"; -import { decodeBackendConfig } from "../config.js"; -import { makeBackendTestHttp as backendHttpHandler } from "../test/native-http.js"; +import { PeopleDirectoryResponse } from "@vektorprogrammet/rpc"; +import { isProblem, problemBody } from "@vektorprogrammet/rpc/problem"; +import { RpcClient } from "effect/unstable/rpc"; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; const token = "better-auth.session_token"; -const environment = { - BACKEND_PG_URL: "postgres://test.invalid/vektorprogrammet", - BETTER_AUTH_SECRET: "router-test-secret-with-at-least-32-characters!", - NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify(["http://127.0.0.1:5174"]), - OAUTH_CANONICAL_ORIGIN: "http://127.0.0.1:5174", - OAUTH_DASHBOARD_ORIGIN: "http://127.0.0.1:5174", - OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - PUBLIC_APPLICATION_EFFECT_MODE: "disabled", - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_MODE: "disabled", -} as const; - -const config = Effect.runSync(decodeBackendConfig(environment)); - const instant = "2031-09-15T12:00:00.000Z"; const departmentA = DepartmentId.make("department-a"); @@ -120,21 +106,19 @@ const database = backendDatabase(); const profile: ProfileOperations = { readProfiles: (personIds) => Effect.succeed( - personIds.map( - (personId) => - new PersonProfile({ personId, firstName: "First", lastName: "Last", revision: 0 }), + personIds.map((personId) => + PersonProfile.make({ personId, firstName: "First", lastName: "Last", revision: 0 }), ), ), readContacts: (personIds) => Effect.succeed( - personIds.map( - (personId) => - new PersonContactProfile({ - personId, - email: `${personId}@example.invalid`, - phone: "90000000", - revision: 0, - }), + personIds.map((personId) => + PersonContactProfile.make({ + personId, + email: `${personId}@example.invalid`, + phone: "90000000", + revision: 0, + }), ), ), readOwnProfile: (personId) => @@ -174,7 +158,7 @@ const profile: ProfileOperations = { ); if (found < 0) - return yield* new ProfileContactNotFound({ personId: PersonId.make(tuple.personId) }); + return yield* ProfileContactNotFound.make({ personId: PersonId.make(tuple.personId) }); offset = found + 1; } @@ -182,8 +166,8 @@ const profile: ProfileOperations = { const entries: Array = []; for (const person of page) { - if (missingContactFor === person.personId || !person.email) { - return yield* new ProfileContactNotFound({ + if (missingContactFor === person.personId || person.email.length === 0) { + return yield* ProfileContactNotFound.make({ personId: PersonId.make(person.personId), }); } @@ -258,7 +242,7 @@ const organization = { const grants = personIds.flatMap((personId) => { const grant = grantsByPerson[personId]; - return grant + return grant !== undefined ? [{ personId: PersonId.make(personId), globalAdministrator: grant.status }] : []; }); @@ -338,14 +322,6 @@ const resetScenario = () => { resetScenario(); -const socialEvents = SocialEvents.of({ - readSnapshotInstant: Effect.die("unexpected social-event read"), - readScope: () => Effect.die("unexpected social-event read"), - readList: () => Effect.die("unexpected social-event read"), - validateScope: () => Effect.die("unexpected social-event validation"), - create: () => Effect.die("unexpected social-event create"), -}); - const oauthCredentialAuthority = OAuthCredentialAuthority.of({ resolve: () => Effect.die("unexpected OAuth credential resolution"), resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), @@ -356,13 +332,13 @@ const identity = Identity.of({ resolveSession: (cookieHeader: string | undefined) => cookieHeader !== undefined && cookieHeader.includes(`${token}=`) ? Effect.succeed( - new IdentityActor({ + IdentityActor.make({ personId: PersonId.make("person-caller"), sessionId: "session-1", expiresAt: DateTime.makeUnsafe("2031-09-16T00:00:00.000Z"), }), ) - : Effect.fail(new IdentitySessionNotFound()), + : Effect.fail(IdentitySessionNotFound.make()), readCurrentSession: () => Effect.die("unexpected session read"), listSessions: () => Effect.die("unexpected session list"), revokeCurrentSession: () => Effect.die("unexpected session mutation"), @@ -377,29 +353,40 @@ const backendServices = Layer.mergeAll( database.layer, Layer.succeed(Profile, profile), Layer.mock(Organization, organization), - Layer.succeed(SocialEvents, socialEvents), Layer.succeed(Identity, identity), Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), ); -const backend = backendHttpHandler(config, backendServices, { - handler: () => Effect.succeed(new Response(null, { status: 404 })), - recordTrustedOriginRejection: () => Effect.void, +const backend = makeBackendTestRpc({ config: backendTestConfig, services: backendServices }); + +const listPeople = Effect.gen(function* () { + const client = yield* backend.client; + + return yield* RpcClient.withHeaders(client["directory.listPeople"](), { + cookie: `${token}=value`, + }); }); -const request = (): Effect.Effect => - backend.fetch( - new Request("http://backend.test/api/people", { - headers: { cookie: `${token}=value` }, - }), - ); +/** The problem that the directory read failed with, as its frozen body. */ +const failure = Effect.flip(listPeople).pipe( + Effect.map((cause) => (isProblem(cause) ? problemBody(cause) : cause)), +); + +const authorityDenied = { + type: "urn:vektorprogrammet:problem:v0.2:authority.denied", + title: "Authority denied", + status: 403, + detail: "The authenticated principal is not permitted to perform this operation.", + code: "authority.denied", +}; -describe("GET /api/people (spec 0077.2)", () => { - it.live("answers 401 without a session", () => +describe("directory.listPeople", () => { + it.live("answers credential.missing without a session", () => Effect.gen(function* () { - const response = yield* backend.fetch(new Request("http://backend.test/api/people")); - expect(response.status).toBe(401); - expect(yield* Effect.promise(() => response.json())).toEqual({ + const client = yield* backend.client; + const cause = yield* Effect.flip(client["directory.listPeople"]()); + + expect(isProblem(cause) ? problemBody(cause) : cause).toEqual({ type: "urn:vektorprogrammet:problem:v0.2:credential.missing", title: "Credential required", status: 401, @@ -409,7 +396,7 @@ describe("GET /api/people (spec 0077.2)", () => { }), ); - it.live("denies a plain member with typed 403 AuthorityInactive", () => + it.live("denies a plain member with authority.denied", () => Effect.gen(function* () { resetScenario(); callerProjection = { globalAdministrator: "Absent" }; @@ -421,19 +408,11 @@ describe("GET /api/people (spec 0077.2)", () => { boardLeader: false, }, ]); - const response = yield* request(); - expect(response.status).toBe(403); - expect(yield* Effect.promise(() => response.json())).toEqual({ - type: "urn:vektorprogrammet:problem:v0.2:authority.denied", - title: "Authority denied", - status: 403, - detail: "The authenticated principal is not permitted to perform this operation.", - code: "authority.denied", - }); + expect(yield* failure).toEqual(authorityDenied); }), ); - it.live("denies an inactive leader with typed 403 AuthorityInactive", () => + it.live("denies an inactive leader with authority.denied", () => Effect.gen(function* () { resetScenario(); callerProjection = { globalAdministrator: "Absent" }; @@ -445,43 +424,23 @@ describe("GET /api/people (spec 0077.2)", () => { boardLeader: true, }, ]); - const response = yield* request(); - expect(response.status).toBe(403); - expect(yield* Effect.promise(() => response.json())).toEqual({ - type: "urn:vektorprogrammet:problem:v0.2:authority.denied", - title: "Authority denied", - status: 403, - detail: "The authenticated principal is not permitted to perform this operation.", - code: "authority.denied", - }); + expect(yield* failure).toEqual(authorityDenied); }), ); - it.live("denies an inactive administrator with typed 403 AuthorityInactive", () => + it.live("denies an inactive administrator with authority.denied", () => Effect.gen(function* () { resetScenario(); callerProjection = { globalAdministrator: "Inactive" }; - const response = yield* request(); - expect(response.status).toBe(403); - expect(yield* Effect.promise(() => response.json())).toEqual({ - type: "urn:vektorprogrammet:problem:v0.2:authority.denied", - title: "Authority denied", - status: 403, - detail: "The authenticated principal is not permitted to perform this operation.", - code: "authority.denied", - }); + expect(yield* failure).toEqual(authorityDenied); }), ); it.live("shows an active global administrator the cross-department directory", () => Effect.gen(function* () { resetScenario(); - const response = yield* request(); - expect(response.status).toBe(200); - const body = yield* Schema.decodeUnknownEffect(PeopleDirectoryResponse)( - yield* Effect.promise(() => response.json()), - ); + const body = yield* listPeople; expect(body.activePeople.map((row) => row.personId)).toEqual([ "person-multi-department", @@ -490,10 +449,12 @@ describe("GET /api/people (spec 0077.2)", () => { expect(body.inactivePeople.map((row) => row.personId)).toEqual(["person-ended-membership"]); expect(body.nextCursor).toBeNull(); const multi = body.activePeople.find((row) => row.personId === "person-multi-department"); - // The frozen entry carries department NAMES (spec 0057 falsifier), sorted. + // The frozen entry carries department NAMES, sorted. expect(multi?.departments).toEqual(["Name of department-a", "Name of department-b"]); - for (const row of [...body.activePeople, ...body.inactivePeople]) { + const wire = yield* Schema.encodeEffect(PeopleDirectoryResponse)(body); + + for (const row of [...wire.activePeople, ...wire.inactivePeople]) { expect(Object.keys(row).sort()).toEqual([ "departments", "email", @@ -509,21 +470,13 @@ describe("GET /api/people (spec 0077.2)", () => { }), ); - it.live("denies a caller with no Organization record with typed 403 NotInScope", () => + it.live("denies a caller with no Organization record with authority.denied", () => Effect.gen(function* () { resetScenario(); - // Absent grant plus no memberships at all: NotInScope, never a 401. + // Absent grant plus no memberships at all: a denial, never a credential problem. callerProjection = { globalAdministrator: "Absent" }; membershipsByPerson.set("person-caller", []); - const response = yield* request(); - expect(response.status).toBe(403); - expect(yield* Effect.promise(() => response.json())).toEqual({ - type: "urn:vektorprogrammet:problem:v0.2:authority.denied", - title: "Authority denied", - status: 403, - detail: "The authenticated principal is not permitted to perform this operation.", - code: "authority.denied", - }); + expect(yield* failure).toEqual(authorityDenied); }), ); @@ -539,12 +492,8 @@ describe("GET /api/people (spec 0077.2)", () => { boardLeader: true, }, ]); - const response = yield* request(); - expect(response.status).toBe(200); - const body = yield* Schema.decodeUnknownEffect(PeopleDirectoryResponse)( - yield* Effect.promise(() => response.json()), - ); + const body = yield* listPeople; // Only the multi-department person touches department B. expect(body.activePeople.map((row) => row.personId)).toEqual(["person-multi-department"]); @@ -552,7 +501,7 @@ describe("GET /api/people (spec 0077.2)", () => { }), ); - it.live("returns a legitimate 200 with empty arrays when nothing intersects", () => + it.live("answers empty arrays when nothing intersects", () => Effect.gen(function* () { resetScenario(); callerProjection = { globalAdministrator: "Absent" }; @@ -565,9 +514,7 @@ describe("GET /api/people (spec 0077.2)", () => { }, ]); people = []; - const response = yield* request(); - expect(response.status).toBe(200); - expect(yield* Effect.promise(() => response.json())).toEqual({ + expect(yield* listPeople).toEqual({ activePeople: [], inactivePeople: [], nextCursor: null, @@ -587,12 +534,8 @@ describe("GET /api/people (spec 0077.2)", () => { })); membershipsByPerson = new Map>([]); grantsByPerson = {}; - const response = yield* request(); - expect(response.status).toBe(200); - const body = yield* Schema.decodeUnknownEffect(PeopleDirectoryResponse)( - yield* Effect.promise(() => response.json()), - ); + const body = yield* listPeople; const ids = [...body.activePeople, ...body.inactivePeople].map((row) => row.personId); expect(new Set(ids).size).toBe(205); @@ -600,13 +543,11 @@ describe("GET /api/people (spec 0077.2)", () => { }), ); - it.live("fails 503 when a scanned person has no contact row instead of dropping them", () => + it.live("fails directory.unavailable when a scanned person has no contact row", () => Effect.gen(function* () { resetScenario(); missingContactFor = "person-multi-department"; - const response = yield* request(); - expect(response.status).toBe(503); - expect(yield* Effect.promise(() => response.json())).toEqual({ + expect(yield* failure).toEqual({ type: "urn:vektorprogrammet:problem:v0.2:directory.unavailable", title: "Directory unavailable", status: 503, @@ -615,18 +556,4 @@ describe("GET /api/people (spec 0077.2)", () => { }); }), ); - - it.live("rejects a query string with 422", () => - Effect.gen(function* () { - resetScenario(); - - const response = yield* backend.fetch( - new Request("http://backend.test/api/people?page=2", { - headers: { cookie: `${token}=value` }, - }), - ); - - expect(response.status).toBe(422); - }), - ); }); diff --git a/apps/backend/src/directory/rpc.ts b/apps/backend/src/directory/rpc.ts new file mode 100644 index 000000000..3a9718106 --- /dev/null +++ b/apps/backend/src/directory/rpc.ts @@ -0,0 +1,365 @@ +/** + * The DirectoryRpcs handlers: the people directory and the school directory and administration. + * + * The people directory resolves the caller's authority at one instant, gates it to the directory + * reach, and reads every profile page, keeping the rows inside that reach. The school directory + * evaluates its AccessSpec before the Schools journey reads under the caller's authority. School + * management resolves the caller's credential in its transaction; a school command resolves it, + * and the `SchoolCommandAuthorization` that Schools mints, inside the transaction that commits the + * command, and stores its success as a command receipt. + */ +import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import { Scope } from "@vektorprogrammet/domain/authz"; +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import { + directoryRowInScope, + Organization, + resolveDirectoryGateScope, +} from "@vektorprogrammet/domain/organization"; +import { Profile, type ProfileFailure } from "@vektorprogrammet/domain/profile"; +import { + SchoolCommandFailure, + Schools, + type ReadSchoolsDirectoryFailure, +} from "@vektorprogrammet/domain/schools"; +import { readSchoolsDirectory } from "@vektorprogrammet/database/schools"; +import { + DirectoryRpcs, + ExecuteSchoolCommand, + ListPeople, + ListSchools, + type PeopleDirectoryEntry, + PeopleDirectoryResponse, + ReadSchoolManagement, + reflectAccessSpec, + SchoolCommandResult, + SchoolDirectorySchema, +} from "@vektorprogrammet/rpc"; +import { type CredentialPresentation, Problem } from "@vektorprogrammet/rpc/problem"; +import { Effect, Match, Option, Predicate, Schema } from "effect"; +import type { Headers } from "effect/unstable/http"; +import type { Rpc } from "effect/unstable/rpc"; +import { + type OrganizationResolutionError, + resolveRequestCredentialInTransaction, + resolveRequestPersonAtInstant, + resolveRequestPersonAuthority, +} from "../authority.js"; +import { semanticRequestDigest } from "../http-semantics.js"; +import { genericContext } from "../native-operation.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { + authorizePerson, + commandIdentity, + commandOutcome, + commandReceiptProblems, + personPresentation, + problemMapper, + unreachable, +} from "../rpc/problem.js"; +import { executeNativeHttpCommandPostgres, successCapsule } from "../rpc/receipt-transaction.js"; + +const DIRECTORY_PAGE_LIMIT = 200; + +const directoryUnavailable = () => Problem.make("directory.unavailable"); + +/** + * The one answer for every people-directory failure. A read that cannot complete, or a response + * that does not fit its schema, leaves the directory unavailable. + */ +const directoryProblems = problemMapper< + OrganizationResolutionError | ProfileFailure | Schema.SchemaError +>()({ + OrganizationDecodeError: directoryUnavailable, + OrganizationPersistenceError: directoryUnavailable, + ProfileDecodeError: directoryUnavailable, + ProfileQueryLimitExceeded: directoryUnavailable, + ProfileNotFound: directoryUnavailable, + ProfileContactNotFound: directoryUnavailable, + ProfileStaleRevision: directoryUnavailable, + ProfileCommandConflict: directoryUnavailable, + ProfilePersistenceError: directoryUnavailable, + SchemaError: directoryUnavailable, +}); + +/** + * A person credential rejected after ingress is answered from the request's own evidence; an + * unavailable identity provider leaves the directory unavailable. + */ +const directoryCredentialProblems = (presentation: CredentialPresentation) => + problemMapper()({ + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + IdentityEngineError: directoryUnavailable, + }); + +/** + * The one answer for every Schools failure. A representation that does not fit its schema leaves + * Schools unavailable, as a failed Schools read does. + */ +const schoolsProblems = problemMapper< + ReadSchoolsDirectoryFailure | SchoolCommandFailure | Schema.SchemaError +>()({ + AuthorityInactive: () => Problem.make("authority.denied"), + NotInScope: () => Problem.make("authority.denied"), + SchoolsDepartmentOutOfScope: () => Problem.make("authority.denied"), + SchoolsDepartmentNotFound: () => Problem.make("schools.invalid-department"), + SchoolsDecodeError: () => Problem.make("schools.unavailable"), + SchoolsPersistenceError: () => Problem.make("schools.unavailable"), + SchemaError: () => Problem.make("schools.unavailable"), + SchoolCommandFailure: ({ code }) => + Match.value(code).pipe( + Match.when("Denied", () => Problem.make("authority.denied")), + Match.when("NotFound", () => Problem.make("resource.not-found")), + Match.when("Stale", () => Problem.make("precondition.failed")), + Match.when("Conflict", () => Problem.make("idempotency.digest-conflict")), + Match.when("AssociationInUse", () => Problem.make("schools.association-in-use")), + Match.when("InactiveSchool", () => Problem.make("schools.inactive")), + Match.when("CapacityExists", () => Problem.make("schools.capacity-exists")), + Match.whenOr("InvalidReference", "Invalid", () => Problem.make("schools.invalid-command")), + Match.exhaustive, + ), +}); + +/** + * A person credential rejected after ingress is answered from the request's own evidence; an + * unavailable identity provider leaves Schools unavailable. + */ +const schoolsCredentialProblems = (presentation: CredentialPresentation) => + problemMapper()({ + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + IdentityEngineError: () => Problem.make("schools.unavailable"), + }); + +/** + * Resolves the current Person credential of a school-management call and evaluates the RPC's + * AccessSpec for it with a global grant: Schools itself scopes what the person manages. + */ +const authorizeSchoolManagement = Effect.fn("Schools.authorizeManagement")(function* ( + headers: Headers.Headers, + rpc: Pick, +) { + const resolved = yield* resolveRequestCredentialInTransaction( + credentialRequestOf(headers), + "OAuthUserBearer", + ); + + if (!Predicate.isTagged(resolved.credential.principal, "Person")) + return yield* Problem.unauthenticated(personPresentation(headers)); + + const personId = resolved.credential.principal.personId; + + yield* authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(rpc)), + credential: resolved.credential, + personId, + resolution: { + selection: "ExactlyOne", + contexts: [genericContext({ domainId: "schools", authorityVersion: "school-management" })], + }, + grantScopes: [Scope.Global()], + now: resolved.authorizationInstant, + }, + personPresentation(headers), + ); + + return personId; +}); + +/** The DirectoryRpcs handlers. */ +export const DirectoryRpcHandlers = (options: NativeRpcOptions) => + DirectoryRpcs.toLayer({ + "directory.listPeople": (_payload, { headers }) => + Effect.gen(function* () { + // One captured authorization instant drives the gate and every row derivation. + const authority = yield* resolveRequestPersonAuthority(credentialRequestOf(headers), { + now: options.now, + }); + + const decision = resolveDirectoryGateScope(authority); + + if (Predicate.isTagged(decision, "Deny")) { + return yield* Problem.make("authority.denied"); + } + + const scope = decision.value; + + const contexts = !Predicate.isTagged(scope, "Departments") + ? [ + genericContext({ + domainId: "profile", + authorityVersion: `directory:${authority.evaluatedAt}`, + }), + ] + : scope.departmentIds.map((departmentId) => + genericContext({ + domainId: "profile", + departmentId, + authorityVersion: `directory:${authority.evaluatedAt}`, + }), + ); + + const grantScopes = !Predicate.isTagged(scope, "Departments") + ? [Scope.Global()] + : scope.departmentIds.map((departmentId) => Scope.Department({ departmentId })); + + yield* authorizePerson( + { + request: credentialRequestOf(headers), + personId: authority.personId, + spec: Option.getOrThrow(reflectAccessSpec(ListPeople)), + resolution: { selection: "AllMatching", contexts }, + grantScopes, + now: authority.evaluatedAt, + }, + personPresentation(headers), + ); + + const organization = yield* Organization; + const profile = yield* Profile; + const activePeople: Array = []; + const inactivePeople: Array = []; + let cursor: string | undefined; + + while (true) { + const page = yield* profile.readDirectoryPage({ limit: DIRECTORY_PAGE_LIMIT, cursor }); + + if (page.entries.length > 0) { + const facts = yield* organization.deriveDirectoryFacts( + page.entries.map((entry) => entry.personId), + authority.evaluatedAt, + ); + + for (const entry of page.entries) { + const fact = facts.get(entry.personId); + + if (fact === undefined || !directoryRowInScope(scope, fact.departments)) continue; + + const row = { + personId: entry.personId, + firstName: entry.firstName, + lastName: entry.lastName, + email: entry.email, + phone: entry.phone, + studyProgramme: null, + departments: [...fact.departmentNames], + isActive: fact.isActive, + }; + + if (fact.isActive) activePeople.push(row); + else inactivePeople.push(row); + } + } + + if (page.nextCursor === undefined) break; + cursor = page.nextCursor; + } + + return yield* Schema.decodeEffect(PeopleDirectoryResponse)( + { activePeople, inactivePeople, nextCursor: cursor ?? null }, + { onExcessProperty: "error" }, + ); + }).pipe( + directoryProblems, + directoryCredentialProblems(personPresentation(headers)), + // A revealing AccessSpec answers every authority failure as a denial, never as 404. + unreachable("resource.not-found"), + ), + + "directory.listSchools": (query, { headers }) => + Effect.gen(function* () { + const actor = yield* resolveRequestPersonAtInstant(credentialRequestOf(headers), { + now: options.now, + }); + + yield* authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(ListSchools)), + request: credentialRequestOf(headers), + personId: actor.personId, + resolution: { + selection: "AllMatching", + contexts: [ + genericContext({ + domainId: "schools", + departmentId: query.departmentId ?? null, + authorityVersion: `schools:${actor.authorizationInstant}`, + }), + ], + }, + grantScopes: [Scope.Global()], + now: actor.authorizationInstant, + }, + personPresentation(headers), + ); + + const directory = yield* readSchoolsDirectory( + actor.personId, + actor.authorizationInstant, + query, + ); + + return yield* Schema.decodeEffect(SchoolDirectorySchema)(directory, { + onExcessProperty: "error", + }); + }).pipe( + schoolsProblems, + schoolsCredentialProblems(personPresentation(headers)), + // A revealing AccessSpec answers every authority failure as a denial, never as 404. + unreachable("resource.not-found"), + ), + + "directory.readSchoolManagement": (_payload, { headers }) => + Effect.gen(function* () { + const personId = yield* authorizeSchoolManagement(headers, ReadSchoolManagement); + + return yield* Schools.use((schools) => schools.readManagement(personId)); + }).pipe(schoolsProblems, schoolsCredentialProblems(personPresentation(headers))), + + "directory.executeSchoolCommand": ({ idempotencyKey, request: command }, { headers }) => + Effect.gen(function* () { + const operationId = "directory.executeSchoolCommand"; + + if (idempotencyKey !== command.commandId) + return yield* SchoolCommandFailure.make({ code: "Conflict" }); + + // Domain and credential failures are mapped after the executor, whose retry reads their + // causes. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const personId = yield* authorizeSchoolManagement(headers, ExecuteSchoolCommand); + + const authorization = yield* Schools.use((schools) => + schools.authorizeCommand(command, personId), + ); + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: `Person:${personId}`, + qualifiedOperationId: operationId, + normalizedTarget: "/api/schools/commands", + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest({ body: command }), + operationId, + }, + execute: Schools.use((schools) => schools.executeCommand(authorization)).pipe( + Effect.flatMap(successCapsule(SchoolCommandResult)), + ), + }; + }), + { retry: "serialization-once" }, + ); + + return yield* commandOutcome(SchoolCommandResult)(outcome); + }).pipe( + schoolsProblems, + commandReceiptProblems, + schoolsCredentialProblems(personPresentation(headers)), + ), + }); diff --git a/apps/backend/src/schools/http.test.ts b/apps/backend/src/directory/schools.rpc.test.ts similarity index 60% rename from apps/backend/src/schools/http.test.ts rename to apps/backend/src/directory/schools.rpc.test.ts index 7a00bdb8a..c7d4e80e0 100644 --- a/apps/backend/src/schools/http.test.ts +++ b/apps/backend/src/directory/schools.rpc.test.ts @@ -1,6 +1,5 @@ import { backendDatabase } from "../../test/database.js"; import { OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; import { Identity, IdentityActor, @@ -27,20 +26,20 @@ import { type SchoolDirectory, type SchoolDirectoryListInput, } from "@vektorprogrammet/domain/schools"; -import { Schema, DateTime, Effect, Layer } from "effect"; +import { isProblem, problemBody } from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Layer } from "effect"; import { describe, expect, it } from "@effect/vitest"; -import { makeSchoolsTestHttp as makeSchoolsApiHttp } from "../test/native-http.js"; +import { RpcClient } from "effect/unstable/rpc"; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc, testAuthHandler } from "../test/native-rpc.js"; -const personId = PersonId.make("schools-http-person"); +const personId = PersonId.make("schools-rpc-person"); -const sessionRequest = (url: string): Request => - new Request(url, { - headers: { cookie: "better-auth.session_token=schools-test-session" }, - }); +const SESSION = "schools-test-session"; -const departmentA = DepartmentId.make("schools-http-a"); +const departmentA = DepartmentId.make("schools-rpc-a"); -const departmentB = DepartmentId.make("schools-http-b"); +const departmentB = DepartmentId.make("schools-rpc-b"); const instant = OrganizationAuthorityInstantSchema.make("2032-04-01T12:00:00.000Z"); @@ -54,8 +53,8 @@ const projection = ( globalAdministrator: "Absent", memberships: [ { - membershipId: MembershipId.make("schools-http-membership"), - teamId: TeamId.make("schools-http-team"), + membershipId: MembershipId.make("schools-rpc-membership"), + teamId: TeamId.make("schools-rpc-team"), departmentId: departmentA, active: true, unitLeader: false, @@ -79,15 +78,15 @@ const oauthCredentialAuthority = OAuthCredentialAuthority.of({ const identity = Identity.of({ signIn: () => Effect.die("unexpected sign-in"), resolveSession: (cookieHeader: string | undefined) => - cookieHeader?.includes("schools-test-session") + cookieHeader?.includes(SESSION) === true ? Effect.succeed( - new IdentityActor({ + IdentityActor.make({ personId, - sessionId: "schools-test-session", + sessionId: SESSION, expiresAt: DateTime.makeUnsafe("2032-04-02T12:00:00.000Z"), }), ) - : Effect.fail(new IdentitySessionNotFound()), + : Effect.fail(IdentitySessionNotFound.make()), readCurrentSession: () => Effect.die("unexpected session read"), listSessions: () => Effect.die("unexpected session list"), revokeCurrentSession: () => Effect.die("unexpected session mutation"), @@ -98,7 +97,7 @@ const identity = Identity.of({ signOut: () => Effect.succeed({ setCookies: [] }), } satisfies IdentityOperations); -const makeServices = ( +const makeBackend = ( authority: OrganizationPersonAuthority, listDirectory: ( input: SchoolDirectoryListInput, @@ -107,7 +106,7 @@ const makeServices = ( departmentId, ) => Effect.succeed( - new Department({ + Department.make({ departmentId, name: "Fixture department", shortName: "Fixture", @@ -122,32 +121,45 @@ const makeServices = ( revision: 0, }), ), -) => { - const organization = { - resolvePersonAuthorityForRead: () => Effect.succeed(authority), - readDepartment, - }; - - const schools = Schools.of({ - listDirectory, - readManagement: () => Effect.die("unexpected school management read"), - authorizeCommand: () => Effect.die("unexpected school command authorization"), - executeCommand: () => Effect.die("unexpected school command"), +) => + makeBackendTestRpc({ + config: backendTestConfig, + services: Layer.mergeAll( + database.layer, + Layer.mock(Organization, { + resolvePersonAuthorityForRead: () => Effect.succeed(authority), + readDepartment, + }), + Layer.succeed( + Schools, + Schools.of({ + listDirectory, + readManagement: () => Effect.die("unexpected school management read"), + authorizeCommand: () => Effect.die("unexpected school command authorization"), + executeCommand: () => Effect.die("unexpected school command"), + }), + ), + Layer.succeed(Identity, identity), + Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), + ), + authHandler: testAuthHandler, + options: // The composition pins the authorization instant that the projection was evaluated at. + { now: () => instant }, }); - return Layer.mergeAll( - database.layer, - Layer.mock(Organization, organization), - Layer.succeed(Schools, schools), - Layer.succeed(Identity, identity), - Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), - ); -}; - -const responseBody = (response: Response) => - Effect.promise(() => response.json()).pipe( - Effect.flatMap(Schema.decodeUnknownEffect(Schema.Json)), - ); +const listSchools = ( + backend: ReturnType, + departmentId: DepartmentId | undefined, + session: string = SESSION, +) => + Effect.gen(function* () { + const client = yield* backend.client; + + return yield* RpcClient.withHeaders( + client["directory.listSchools"](departmentId === undefined ? {} : { departmentId }), + { cookie: `better-auth.session_token=${session}` }, + ); + }); const expectedProblem = (code: string, title: string, status: number, detail: string) => ({ type: `urn:vektorprogrammet:problem:v0.2:${code}`, @@ -193,7 +205,7 @@ const expectedProblemForTag = (tag: string, status: number) => { ); }; -describe("Schools native HTTP adapter", () => { +describe("Schools directory RPC", () => { it.live("runs the named journey once and narrows the visible union by department", () => Effect.gen(function* () { const listInputs: Array = []; @@ -214,25 +226,13 @@ describe("Schools native HTTP adapter", () => { inactiveSchools: [], }; - const services = makeServices(projection(), (input) => { + const backend = makeBackend(projection(), (input) => { listInputs.push(input); return Effect.succeed(school); }); - const api = makeSchoolsApiHttp( - { resolveActor: () => Effect.succeed({ personId, authorizationInstant: instant }) }, - services, - ); - - const response = yield* api.fetch( - sessionRequest(`http://backend.test/api/schools?department=${departmentA}`), - ); - - expect({ status: response.status, body: yield* responseBody(response) }).toEqual({ - status: 200, - body: school, - }); + expect(yield* listSchools(backend, departmentA)).toEqual(school); expect(listInputs).toEqual([ { scope: SchoolDirectoryScopeSchema.cases.DepartmentIds.make({ @@ -244,57 +244,6 @@ describe("Schools native HTTP adapter", () => { }), ); - it.live("rejects unknown, duplicate, and malformed query input before authentication", () => - Effect.gen(function* () { - let actorCalls = 0; - let listCalls = 0; - - const services = makeServices(projection(), () => { - listCalls += 1; - - return Effect.succeed(emptyDirectory); - }); - - const api = makeSchoolsApiHttp( - { - resolveActor: () => - Effect.sync(() => { - actorCalls += 1; - - return { personId, authorizationInstant: instant }; - }), - }, - services, - ); - - const malformed = expectedProblem( - "request.malformed", - "Malformed request", - 400, - "The request is malformed.", - ); - - for (const query of [ - "unknown=1", - `department=${departmentA}&unknown=1`, - "department=a&department=b", - "department=", - ]) { - const response = yield* api.fetch( - sessionRequest(`http://backend.test/api/schools?${query}`), - ); - - expect({ status: response.status, body: yield* responseBody(response) }, query).toEqual({ - status: 400, - body: malformed, - }); - } - - expect(actorCalls).toBe(0); - expect(listCalls).toBe(0); - }), - ); - it.live( "maps authentication, authority, reference, scope, and persistence failures exactly", () => @@ -304,11 +253,8 @@ describe("Schools native HTTP adapter", () => { readonly expectedStatus: number; readonly expectedTag: string; readonly authority?: OrganizationPersonAuthority; - readonly query?: string; - readonly resolveActor?: () => Effect.Effect< - { personId: PersonId; authorizationInstant: typeof instant }, - UnauthenticatedActor - >; + readonly departmentId?: DepartmentId; + readonly session?: string; readonly readDepartment?: ( departmentId: DepartmentId, ) => Effect.Effect; @@ -317,11 +263,10 @@ describe("Schools native HTTP adapter", () => { ) => Effect.Effect; }> = [ { - name: "missing session", + name: "unknown session", expectedStatus: 401, expectedTag: "UnauthenticatedActor", - resolveActor: () => - Effect.fail(new UnauthenticatedActor({ message: "authentication required" })), + session: "unknown-session", }, { name: "inactive authority", @@ -344,14 +289,15 @@ describe("Schools native HTTP adapter", () => { name: "unknown department", expectedStatus: 422, expectedTag: "SchoolsDepartmentNotFound", - query: `department=${departmentB}`, - readDepartment: (departmentId) => Effect.fail(new DepartmentNotFound({ departmentId })), + departmentId: departmentB, + readDepartment: (departmentId) => + Effect.fail(DepartmentNotFound.make({ departmentId })), }, { name: "outside scope", expectedStatus: 403, expectedTag: "SchoolsDepartmentOutOfScope", - query: `department=${departmentB}`, + departmentId: departmentB, }, { name: "persistence", @@ -359,7 +305,7 @@ describe("Schools native HTTP adapter", () => { expectedTag: "SchoolsPersistenceError", listDirectory: () => Effect.fail( - new SchoolsPersistenceError({ + SchoolsPersistenceError.make({ operation: "read Schools directory", message: "unavailable", }), @@ -371,7 +317,7 @@ describe("Schools native HTTP adapter", () => { expectedTag: "SchoolsDecodeError", listDirectory: () => Effect.fail( - new SchoolsDecodeError({ + SchoolsDecodeError.make({ operation: "decode Schools directory rows", message: "malformed row", }), @@ -380,32 +326,19 @@ describe("Schools native HTTP adapter", () => { ]; for (const testCase of cases) { - const api = makeSchoolsApiHttp( - { - resolveActor: - testCase.resolveActor ?? - (() => Effect.succeed({ personId, authorizationInstant: instant })), - }, - makeServices( - testCase.authority ?? projection(), - testCase.listDirectory ?? (() => Effect.succeed(emptyDirectory)), - testCase.readDepartment, - ), + const backend = makeBackend( + testCase.authority ?? projection(), + testCase.listDirectory ?? (() => Effect.succeed(emptyDirectory)), + testCase.readDepartment, ); - const query = testCase.query === undefined ? "" : `?${testCase.query}`; - - const response = yield* api.fetch( - sessionRequest(`http://backend.test/api/schools${query}`), + const failure = yield* Effect.flip( + listSchools(backend, testCase.departmentId, testCase.session), ); - expect( - { status: response.status, body: yield* responseBody(response) }, - testCase.name, - ).toEqual({ - status: testCase.expectedStatus, - body: expectedProblemForTag(testCase.expectedTag, testCase.expectedStatus), - }); + expect(isProblem(failure) ? problemBody(failure) : failure, testCase.name).toEqual( + expectedProblemForTag(testCase.expectedTag, testCase.expectedStatus), + ); } }), ); diff --git a/apps/backend/src/http-api/AGENTS.md b/apps/backend/src/http-api/AGENTS.md deleted file mode 100644 index 8404adc17..000000000 --- a/apps/backend/src/http-api/AGENTS.md +++ /dev/null @@ -1,42 +0,0 @@ -[//]: # "guide: generated from docs/model/contexts.cml, the @construct tags, and package.json exports by just guides write; do not edit" - -# apps/backend/src/http-api - -This folder is not a bounded context. Transport shared by every context: problems, rate limits, JSON reading, receipt transactions. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. - -## Entry points - -No `exports` entry of [apps/backend/package.json](../../package.json) points into this folder, so other packages do not import it. - -## Constructs - -The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../../../docs/constructs.md) indexes them all. - -- [`problemWebResponse`](../../../../docs/constructs/http-problem.md#problemwebresponse) (http-problem): Renders one problem outside HttpApi encoding, with the encoder's body and headers. -- [`jsonText`](../../../../docs/constructs/http-problem.md#jsontext) (http-problem): The JSON text of a representation, byte for byte what `JSON.stringify` writes. -- [`webHandler`](../../../../docs/constructs/http-problem.md#webhandler) (http-problem): Runs one Effect-native Web transport operation. -- [`semanticProblem`](../../../../docs/constructs/http-problem.md#semanticproblem) (http-problem): Runs a throwing semantic parser. -- [`problemMapper`](../../../../docs/constructs/http-problem.md#problemmapper) (http-problem): Builds the one failure-to-problem mapper of a domain. -- [`requireNoQuery`](../../../../docs/constructs/http-problem.md#requirenoquery) (http-problem): An operation that accepts no query answers any query as malformed. -- [`readJsonBody`](../../../../docs/constructs/http-problem.md#readjsonbody) (http-problem): Reads a bounded JSON body of the one media type `mediaType` accepts. -- [`idempotencyKeyOf`](../../../../docs/constructs/http-problem.md#idempotencykeyof) (http-problem): Decodes the one Idempotency-Key a replayable mutation requires. -- [`requiredIfMatchOf`](../../../../docs/constructs/http-problem.md#requiredifmatchof) (http-problem): Decodes the one strong If-Match an item mutation requires. -- [`httpIdentity`](../../../../docs/constructs/http-problem.md#httpidentity) (http-problem): Derives a command's idempotency identity; a tuple outside the frozen grammar is a request problem. -- [`requireCurrentETag`](../../../../docs/constructs/http-problem.md#requirecurrentetag) (http-problem): Fails a mutation whose If-Match no longer names the current representation. -- [`conditionalJson`](../../../../docs/constructs/http-problem.md#conditionaljson) (http-problem): Answers a conditional JSON read after authority and concealment: the representation, a bodyless 304, or precondition.failed. -- [`personPresentation`](../../../../docs/constructs/http-problem.md#personpresentation) (http-problem): The person credential a request presented, for a rejection answered after ingress. -- [`isSerializationConflict`](../../../../docs/constructs/http-problem.md#isserializationconflict) (http-problem): Whether a failure, or one of its causes, is a lost serialization or deadlock race: a transaction.conflict the client may retry. -- [`requestInvalid`](../../../../docs/constructs/http-problem.md#requestinvalid) (http-problem): The request as a whole fails validation; no single member is singled out. -- [`decodeRequest`](../../../../docs/constructs/http-problem.md#decoderequest) (http-problem): Decodes one JSON request value strictly; any mismatch fails the whole request's validation. -- [`strictOutput`](../../../../docs/constructs/http-problem.md#strictoutput) (http-problem): Decodes one response value strictly. -- [`commandReceiptProblems`](../../../../docs/constructs/http-problem.md#commandreceiptproblems) (http-problem): HTTP command receipts: the transport's own persistence failures. -- [`commandOutcomeResponse`](../../../../docs/constructs/http-problem.md#commandoutcomeresponse) (http-problem): Answers a command receipt outcome: committed and replayed results, or an idempotency problem. -- [`authorizeAnonymous`](../../../../docs/constructs/http-problem.md#authorizeanonymous) (http-problem): An anonymous AccessSpec grants every caller and conceals nothing, so a denial means the spec and its scope resolution disagree: a defect. -- [`authorizePerson`](../../../../docs/constructs/http-problem.md#authorizeperson) (http-problem): A rejected person credential is answered from the ingress evidence, never by string choice. -- [`unreachable`](../../../../docs/constructs/http-problem.md#unreachable) (http-problem): Marks problems a shared mapper can produce but this operation cannot, such as a serialization conflict inside a read-only snapshot. -- [`ProblemBoundaryLive`](../../../../docs/constructs/http-problem.md#problemboundarylive) (http-problem): The only Cause consumer. - -Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. - -[//]: # "guide: end" diff --git a/apps/backend/src/http-api/problem.ts b/apps/backend/src/http-api/problem.ts deleted file mode 100644 index 6aaf48b32..000000000 --- a/apps/backend/src/http-api/problem.ts +++ /dev/null @@ -1,898 +0,0 @@ -/** - * Typed transport failures for native HttpApi handlers. Handlers fail with - * `Problem` values and HttpApiBuilder encodes them against the endpoint's - * declared problems, so an undeclared code, an unmapped domain failure, or a - * credential code chosen without ingress evidence does not compile. - */ -import type { AccessSpec, CanonicalScopeResolution } from "@vektorprogrammet/domain/authz"; -import { - credentialPresentation, - type CredentialPresentation, - type IdempotencyKey, - isProblem, - makeNativeValidationError, - type NativeProblemCode, - nativeUserChallenges, - type PlainProblemCode, - Problem, - problemBody, - problemHeaders, - type StrongETag, -} from "@vektorprogrammet/http-api/http-semantics"; -import { Cause, Effect, ErrorReporter, type Layer, Match, Predicate, Schema } from "effect"; -import { - HttpRouter, - HttpServerError, - HttpServerRequest, - HttpServerResponse, -} from "effect/unstable/http"; -import { - type DerivedHttpIdentity, - deriveHttpIdentity, - evaluateMutationPrecondition, - evaluateReadPreconditions, - type NativeIdempotencyIdentity, - notModifiedResponse, - parseIdempotencyKey, - parseIfNoneMatch, - parseReadIfMatch, - parseRequiredIfMatch, - responseFromCapsule, -} from "../http-semantics.js"; -import { - authorizeAnonymousNativeOperation, - authorizePersonNativeOperation, - type NativePersonAuthorization, -} from "../native-operation.js"; -import { hasBetterAuthSessionCredential } from "../session-security.js"; -import { readBoundedJson } from "./read-json.js"; -import type { - NativeHttpCommandOutcome, - NativeHttpReceiptInvalid, - NativeHttpReceiptPersistenceError, -} from "./receipt-transaction.js"; - -/** - * Renders one problem outside HttpApi encoding, with the encoder's body and headers. - * - * @remarks - * The body is the JSON text of `problemBody(problem)`, the status is `problem.status`, and the - * headers are those of `problemHeaders(problem)` with `content-type: application/problem+json`: - * the answer that HttpApiBuilder encodes for a declared problem. It answers where no endpoint - * encodes: an origin denial, a method outside the allowed set, an unknown path, and the internal - * error of `ProblemBoundaryLive`. - * - * @sideEffects none - * - * @example - * ```ts - * const response = problemWebResponse(Problem.make("method.not-allowed")); - * ``` - * - * @avoid Building a problem response by hand: its body or headers drift from the frozen encoding - * that clients decode. Render the `Problem` with this. - * - * @construct http-problem - */ -export const problemWebResponse = (problem: Problem): Response => - new Response(JSON.stringify(problemBody(problem)), { - status: problem.status, - headers: { ...problemHeaders(problem), "content-type": "application/problem+json" }, - }); - -const JsonText = Schema.fromJsonString(Schema.Unknown); - -/** - * The JSON text of a representation, byte for byte what `JSON.stringify` writes. - * - * @remarks - * It encodes `value` with `Schema.fromJsonString(Schema.Unknown)`. A value that JSON cannot - * represent, such as a `bigint`, fails the encoding, and the failure becomes a defect, which - * `ProblemBoundaryLive` answers as internal.error. - * - * @sideEffects none - * - * @example - * ```ts - * return new Response(yield* jsonText(body), { headers: { "content-type": "application/json" } }); - * ``` - * - * @avoid `JSON.stringify` in a handler: it throws outside the error channel, and - * `effecttsgo/prefer-schema-over-json` rejects it. Write the text with `jsonText`. - * - * @construct http-problem - */ -export const jsonText = (value: A): Effect.Effect => - Schema.encodeEffect(JsonText)(value).pipe(Effect.orDie); - -/** - * Records, from the raw request only, whether person credential material was presented. - */ -export const classifyCredential = ( - authorization: string | null | undefined, - cookie: string | null | undefined, - challenge: string, -): CredentialPresentation => - credentialPresentation({ - presented: - Predicate.isNotNullish(authorization) || hasBetterAuthSessionCredential(cookie ?? null), - challenge, - }); - -/** - * Runs one Effect-native Web transport operation. - * - * @remarks - * It converts the HttpApi request to a Web `Request`, runs `handle`, and converts the `Response` - * back; a request that does not convert is a defect. The typed failures of `handle` stay in the - * error channel, where HttpApiBuilder encodes each against the endpoint's declared problems, so a - * problem that the endpoint does not declare fails its type check. - * - * @sideEffects none of its own; it runs `handle`. - * - * @example - * ```ts - * .handleRaw("readTeamApplication", ({ request, params }) => webHandler(request, (webRequest) => readApplication(webRequest, params.applicationId))) - * ``` - * - * @avoid Answering a failure inside `handle` with a hand-built `Response`: HttpApiBuilder then - * cannot check it against the endpoint's declared problems. Fail with the `Problem`. - * - * @construct http-problem - */ -export const webHandler = ( - request: HttpServerRequest.HttpServerRequest, - handle: (request: Request) => Effect.Effect, -): Effect.Effect => - HttpServerRequest.toWeb(request).pipe( - Effect.orDie, - Effect.flatMap(handle), - Effect.map(HttpServerResponse.fromWeb), - ); - -/** - * Runs a throwing semantic parser. - * - * @remarks - * `parse` runs when the effect runs. A thrown `Problem` whose code is in `codes` becomes a failure - * with that code; any other throw, a problem of another code included, is a defect. The HTTP - * semantic parsers, such as `normalizeTarget`, throw their problems, so an Effect handler calls - * them through this. - * - * @sideEffects none of its own; it runs `parse`. - * - * @example - * ```ts - * const normalizedTarget = yield* semanticProblem(() => normalizeTarget(routeTemplate, identities), ["request.malformed"]); - * ``` - * - * @avoid `Effect.try` with a hand-written catch around a parser: an unexpected throw then becomes - * a failure that the endpoint does not declare. List the codes that the endpoint declares here. - * - * @construct http-problem - */ -export const semanticProblem = ( - parse: () => A, - codes: ReadonlyArray, -): Effect.Effect> => - Effect.suspend(() => { - try { - return Effect.succeed(parse()); - } catch (cause) { - const code = isProblem(cause) ? codes.find((declared) => declared === cause.code) : undefined; - - return code === undefined ? Effect.die(cause) : Effect.fail(Problem.make(code)); - } - }); - -interface TaggedFailure { - readonly _tag: string; -} - -/** One case per tag of `Failure`: the problem that answers a failure with that tag. */ -export type ProblemCases = { - readonly [Tag in Failure["_tag"]]: (failure: Extract) => Problem; -}; - -/** `Cases` with no case for a tag outside `Failure`. */ -type ExactCases = Cases & { - readonly [Extra in Exclude]: never; -}; - -type MappedFailure> = - | Exclude - | ReturnType["_tag"]]>; - -/** The mapper that `problemMapper` builds from the cases `Cases` of `Failure`. */ -export type ProblemMapper> = < - A, - E, - R, ->( - effect: Effect.Effect, -) => Effect.Effect, R>; - -/** Cases that answer each failure tagged `Tag` from the credential that the request presented. */ -export type CredentialCases = { - readonly [Case in Tag]: () => Problem<"credential.missing"> | Problem<"credential.invalid">; -}; - -/** Cases that answer each failure tagged `Tag` with the endpoint's unavailable problem `Code`. */ -export type OutageCases = { - readonly [Case in Tag]: () => Problem; -}; - -/** - * Builds the one failure-to-problem mapper of a domain. - * - * @remarks - * `problemMapper()(cases)` takes one case per tag of `Failure` and no other key. The - * mapper maps each failure of an effect whose tag has a case to the problem that its case - * returns, and it passes every other failure through unchanged, so an unmapped failure still - * reaches, and fails, the endpoint's type check. The mapped error channel keeps only the problems - * that the effect's own failures map to. A mapper constant declares its type as - * `ProblemMapper`, with its cases in a constant that `satisfies` - * `ProblemCases`. A mapper that a function builds per request adds `CredentialCases` - * and `OutageCases` to its constant cases, for the answers that depend on its arguments. - * - * @sideEffects none - * - * @example - * ```ts - * const storedReceiptProblems = problemMapper()({ ReceiptDecodeError: () => Problem.make("receipts.unavailable") }); - * ``` - * - * @avoid Mapping a domain's failures with `Effect.mapError` or `Effect.catchTag` in each handler: - * the copies drift apart, so one failure gets different problems on different endpoints. Map each - * domain once and pipe its mapper. - * - * @construct http-problem - */ -export const problemMapper = - (): (>( - cases: ExactCases, - ) => ProblemMapper) => - >( - cases: ExactCases, - ): ProblemMapper => - ( - effect: Effect.Effect, - ): Effect.Effect, R> => { - // `cases` has exactly one handler per `Failure` tag and no other member. - const handlers = new Map Problem>(Object.entries(cases)); - - const mapped = Effect.mapError(effect, (error) => { - const tag = Predicate.hasProperty(error, "_tag") ? error._tag : undefined; - const handle = Predicate.isString(tag) ? handlers.get(tag) : undefined; - - return handle === undefined ? error : handle(error); - }); - - // SAFETY: failures with a mapped tag became the problem their case returns; the rest are unchanged. - // oxlint-disable-next-line effecttsgo/unsafe-effect-type-assertion -- EX-0002: Each failure tag maps to the problem type its case returns; TypeScript cannot index the generic `Cases` by the failure tag to infer that union, so the mapped error channel is asserted once here. - return mapped as Effect.Effect, R>; - }; - -/** - * The values of one request header; an absent header has none. - */ -export const headerValues = (request: Request, name: string): ReadonlyArray => { - const value = request.headers.get(name); - - return value === null ? [] : [value]; -}; - -/** - * An operation that accepts no query answers any query as malformed. - * - * @remarks - * A request whose URL has a non-empty query fails with request.malformed, so an operation that - * declares no query parameter never answers as if it had applied one. - * - * @sideEffects none - * - * @example - * ```ts - * yield* requireNoQuery(request); - * ``` - * - * @avoid Ignoring an unexpected query: a client that sends a filter that the operation does not - * know gets an unfiltered answer that looks filtered. Call it first in an operation without query - * parameters. - * - * @construct http-problem - */ -export const requireNoQuery = ( - request: Request, -): Effect.Effect> => - new URL(request.url).search === "" ? Effect.void : Effect.fail(Problem.make("request.malformed")); - -/** - * Reads a bounded JSON body of the one media type `mediaType` accepts. - * - * @remarks - * A `content-type` that `mediaType` does not match fails with media-type.unsupported before a - * byte is read. `readBoundedJson` then reads the body: a malformed or too large Content-Length - * fails at once, the bytes are counted as they arrive and fail with request.too-large past - * `maxBytes`, and the JSON parses without duplicate member names, else request.malformed. A body - * that cannot be read is internal.error. It takes the body reader when it is called, not when - * the effect runs. - * - * @sideEffects Reads and consumes the request body. - * - * @example - * ```ts - * const body = yield* readJsonBody(request, /^application\/json(?:\s*;|$)/iu, MAX_SUBMISSION_BYTES); - * ``` - * - * @avoid `request.json()`: it reads without a bound, accepts duplicate member names, and throws - * outside the error channel. Read the body with this. - * - * @construct http-problem - */ -export const readJsonBody = ( - request: Request, - mediaType: RegExp, - maxBytes: number, -): Effect.Effect< - Schema.Json, - | Problem<"media-type.unsupported"> - | Problem<"request.malformed"> - | Problem<"request.too-large"> - | Problem<"internal.error"> -> => - mediaType.test(request.headers.get("content-type") ?? "") - ? readBoundedJson(request, maxBytes) - : Effect.fail(Problem.make("media-type.unsupported")); - -/** - * Decodes the one Idempotency-Key a replayable mutation requires. - * - * @remarks - * `parseIdempotencyKey` decodes the Idempotency-Key field: exactly one field, with no comma, of - * the frozen key grammar. A missing, repeated, or malformed key fails with - * idempotency-key.invalid. - * - * @sideEffects none - * - * @example - * ```ts - * const idempotencyKey = yield* idempotencyKeyOf(request); - * ``` - * - * @avoid Reading the field with `request.headers.get` and trimming it: a repeated or malformed key - * then reaches the command identity. Decode it with this before `httpIdentity`. - * - * @construct http-problem - */ -export const idempotencyKeyOf = ( - request: Request, -): Effect.Effect> => - semanticProblem( - () => parseIdempotencyKey(headerValues(request, "idempotency-key")), - ["idempotency-key.invalid"], - ); - -/** - * Decodes the one strong If-Match an item mutation requires. - * - * @remarks - * `parseRequiredIfMatch` decodes the If-Match field: no field fails with precondition.required, - * and anything but one strong entity tag, such as a weak tag, a list, or `*`, fails with - * precondition.invalid. - * - * @sideEffects none - * - * @example - * ```ts - * const ifMatch = yield* requiredIfMatchOf(request); - * ``` - * - * @avoid Treating a missing If-Match as a match: a client that never read the representation - * overwrites a concurrent change. Require it, and compare it with `requireCurrentETag`. - * - * @construct http-problem - */ -export const requiredIfMatchOf = ( - request: Request, -): Effect.Effect> => - semanticProblem( - () => parseRequiredIfMatch(headerValues(request, "if-match")), - ["precondition.required", "precondition.invalid"], - ); - -/** - * Derives a command's idempotency identity; a tuple outside the frozen grammar is a request problem. - * - * @remarks - * It runs `deriveHttpIdentity` through `semanticProblem`: a credential subject, operation id, or - * target outside its grammar fails with request.malformed, and a key outside its grammar with - * idempotency-key.invalid. `commandId` and `identitySha256` key the command receipt of the - * mutation. - * - * @sideEffects none - * - * @example - * ```ts - * const identity = yield* httpIdentity({ credentialSubject: `Person:${personId}`, qualifiedOperationId: operationId, normalizedTarget, idempotencyKey }); - * ``` - * - * @avoid Calling `deriveHttpIdentity` directly in an Effect handler: its throw escapes the error - * channel and becomes a defect, internal.error, instead of the declared request problem. - * - * @construct http-problem - */ -export const httpIdentity = ( - identity: NativeIdempotencyIdentity, -): Effect.Effect> => - semanticProblem( - () => deriveHttpIdentity(identity), - ["request.malformed", "idempotency-key.invalid"], - ); - -/** - * Fails a mutation whose If-Match no longer names the current representation. - * - * @remarks - * `evaluateMutationPrecondition` compares the strong tag of the current representation with the - * one that If-Match named: equal tags proceed, and different ones fail with precondition.failed. - * A command runs it in its transaction, after authorization and concealment, on the state that - * it is about to change. - * - * @sideEffects none - * - * @example - * ```ts - * service.reviseIntake(command, staff.principal, (current) => requireCurrentETag(intakeETag(teamId, current.revision), ifMatch)); - * ``` - * - * @avoid Comparing tags before the transaction, as a preflight read: a concurrent change can - * commit between the check and the write. Compare inside the transaction that writes. - * - * @construct http-problem - */ -export const requireCurrentETag = ( - current: StrongETag, - ifMatch: StrongETag, -): Effect.Effect> => - Predicate.isTagged(evaluateMutationPrecondition(current, ifMatch), "Failed") - ? Effect.fail(Problem.make("precondition.failed")) - : Effect.void; - -/** - * Answers a conditional JSON read after authority and concealment: the representation, a - * bodyless 304, or precondition.failed. - * - * @remarks - * It parses If-Match and If-None-Match, and a field outside their grammar fails with - * precondition.invalid. An If-Match without the current strong tag fails with - * precondition.failed. An If-None-Match that names the current tag, or `*`, answers a bodyless 304 - * with the tag, the cache policy, and `Vary: Origin`. Otherwise it answers 200 with the JSON text - * of `body` and the same headers. - * - * @sideEffects none - * - * @example - * ```ts - * return yield* conditionalJson({ request, body, etag, cacheControl: PRIVATE_NO_STORE, contentType: "application/json" }); - * ``` - * - * @avoid Evaluating the conditions before authorization and concealment: a 304 or 412 then tells a - * caller that a resource it may not see exists. Answer the read with this, last. - * - * @construct http-problem - */ -export const conditionalJson = (input: { - readonly request: Request; - readonly body: unknown; - readonly etag: StrongETag; - readonly cacheControl: string; - readonly contentType: "application/json" | "application/json; charset=utf-8"; -}): Effect.Effect | Problem<"precondition.invalid">> => - Effect.gen(function* () { - const conditions = yield* semanticProblem( - () => ({ - ifMatch: parseReadIfMatch(headerValues(input.request, "if-match")), - ifNoneMatch: parseIfNoneMatch(headerValues(input.request, "if-none-match")), - }), - ["precondition.invalid"], - ); - - const decision = evaluateReadPreconditions({ currentETag: input.etag, ...conditions }); - - if (Predicate.isTagged(decision, "Failed")) { - return yield* Problem.make("precondition.failed"); - } - - if (Predicate.isTagged(decision, "NotModified")) { - return notModifiedResponse({ - etag: input.etag, - cacheControl: input.cacheControl, - vary: "Origin", - }); - } - - return new Response(yield* jsonText(input.body), { - status: 200, - headers: { - "cache-control": input.cacheControl, - "content-type": input.contentType, - etag: input.etag, - vary: "Origin", - }, - }); - }); - -/** - * The person credential a request presented, for a rejection answered after ingress. - * - * @remarks - * It classifies the raw request once: an Authorization field or a Better Auth session cookie - * counts as presented, whatever its validity. `Problem.unauthenticated` answers this evidence with - * credential.missing for an absent credential and credential.invalid for a presented one, with - * `challenge` in `WWW-Authenticate`; the default challenge names the native session cookie and - * bearer tokens. - * - * @sideEffects none - * - * @example - * ```ts - * const presentation = personPresentation(request); - * ``` - * - * @avoid Choosing credential.missing or credential.invalid by string: a request that sent a - * rejected token would be told that it sent none. Answer a rejected credential from this evidence - * with `Problem.unauthenticated`. - * - * @construct http-problem - */ -export const personPresentation = ( - request: Request, - challenge: string = nativeUserChallenges(), -): CredentialPresentation => - classifyCredential( - request.headers.get("authorization"), - request.headers.get("cookie"), - challenge, - ); - -/** - * Whether a failure, or one of its causes, is a lost serialization or deadlock race: a - * transaction.conflict the client may retry. - * - * @remarks - * It follows `cause` through its `cause` members, at most eight levels deep. A `code` of SQLSTATE - * 40001 (serialization failure) or 40P01 (deadlock detected), or a `reason` tagged - * `SerializationError` or `DeadlockError`, is such a race. - * - * @sideEffects none - * - * @example - * ```ts - * isSerializationConflict(failure) ? Problem.make("transaction.conflict") : failure; - * ``` - * - * @avoid Matching SQLSTATEs or messages in each handler: a race that a wrapper carries one level - * deeper then answers internal.error instead of a retryable conflict. Classify it with this. - * - * @construct http-problem - */ -export const isSerializationConflict = (cause: unknown, depth: number = 0): boolean => - depth < 8 && - Predicate.isObjectOrArray(cause) && - (("code" in cause && (cause.code === "40001" || cause.code === "40P01")) || - (Predicate.hasProperty(cause, "reason") && - (Predicate.isTagged(cause.reason, "SerializationError") || - Predicate.isTagged(cause.reason, "DeadlockError"))) || - (Predicate.hasProperty(cause, "cause") && isSerializationConflict(cause.cause, depth + 1))); - -/** - * The request as a whole fails validation; no single member is singled out. - * - * @remarks - * It is validation.failed with one diagnostic, code `invalid`, at the root pointer `""`. A domain - * that decodes a whole command at once cannot name the member that failed, so it answers this. - * - * @sideEffects none - * - * @example - * ```ts - * AdmissionPeriodDecodeError: requestInvalid, - * ``` - * - * @avoid Echoing the schema issue, or pointing at a guessed member: the answer then leaks decoding - * internals or misleads the client. Answer the whole request with this, or build - * `Problem.validation` with the pointers that the decoder knows. - * - * @construct http-problem - */ -export const requestInvalid = (): Problem<"validation.failed"> => - Problem.validation("validation.failed", [makeNativeValidationError("", "invalid")]); - -/** - * Decodes one JSON request value strictly; any mismatch fails the whole request's validation. - * - * @remarks - * It decodes `value` with `schema` and rejects excess properties (`onExcessProperty: "error"`). - * Any mismatch fails with `requestInvalid()`: one validation.failed problem for the request. - * - * @sideEffects none of its own; it runs the decoding services of `schema`. - * - * @example - * ```ts - * const body = yield* decodeRequest(CreateSocialEventRequest)(json); - * ``` - * - * @avoid Decoding a request with the default options of `Schema.decodeUnknownEffect`: excess - * properties then pass silently, and the schema error is a failure that the endpoint does not - * declare. Decode request values with this. - * - * @construct http-problem - */ -export const decodeRequest = - >( - schema: S, - ): (( - value: Schema.Json, - ) => Effect.Effect, S["DecodingServices"]>) => - (value: Schema.Json) => - Schema.decodeEffect(schema)(value, { onExcessProperty: "error" }).pipe( - Effect.mapError(requestInvalid), - ); - -/** - * Decodes one response value strictly. - * - * @remarks - * It decodes the value that a handler is about to answer with the response schema and rejects - * excess properties, so a private field that the domain value carries cannot reach the response. - * A mismatch is a defect, which `ProblemBoundaryLive` answers as internal.error. - * - * @sideEffects none of its own; it runs the decoding services of `schema`. - * - * @example - * ```ts - * return yield* strictOutput(SocialEventListResource)(body); - * ``` - * - * @avoid Answering a domain value as it is, or through a spread: its private fields then reach the - * client. Decode it with the response schema first. - * - * @construct http-problem - */ -export const strictOutput = - >( - schema: S, - ): ((value: S["Type"]) => Effect.Effect) => - (value: S["Type"]) => - Schema.decodeUnknownEffect(schema)(value, { onExcessProperty: "error" }).pipe(Effect.orDie); - -const commandReceiptCases = { - NativeHttpReceiptInvalid: () => Problem.make("internal.error"), - NativeHttpReceiptPersistenceError: (failure: NativeHttpReceiptPersistenceError) => - isSerializationConflict(failure) - ? Problem.make("transaction.conflict") - : Problem.make("idempotency.unavailable"), -} satisfies ProblemCases; - -/** - * HTTP command receipts: the transport's own persistence failures. - * - * @remarks - * An invalid stored receipt answers internal.error. A receipt persistence failure answers - * transaction.conflict when it lost a serialization race, and idempotency.unavailable otherwise. - * A replayable mutation pipes its command effect through its domain mapper, then through this. - * - * @sideEffects none - * - * @example - * ```ts - * command.pipe(teamApplicationProblems, commandReceiptProblems); - * ``` - * - * @avoid Mapping receipt failures in a domain mapper: each domain would answer the transport's own - * failures differently. Pipe this after the domain's mapper. - * - * @construct http-problem - */ -export const commandReceiptProblems: ProblemMapper< - NativeHttpReceiptInvalid | NativeHttpReceiptPersistenceError, - typeof commandReceiptCases -> = problemMapper()( - commandReceiptCases, -); - -/** - * Answers a command receipt outcome: committed and replayed results, or an idempotency problem. - * - * @remarks - * A committed or replayed outcome answers its stored response capsule with - * `Cache-Control: no-store`, so a replay returns the status, bytes, and headers of the first - * answer. A command that still runs under the key fails with idempotency.in-flight, a key reused - * for another request with idempotency.digest-conflict, and a response that is no longer kept - * with idempotency.response-expired. - * - * @sideEffects none - * - * @example - * ```ts - * return yield* commandOutcomeResponse(outcome); - * ``` - * - * @avoid Rebuilding the response of a replay from the current state: a retry then sees another - * answer than the first attempt. Answer every outcome through this. - * - * @construct http-problem - */ -export const commandOutcomeResponse = ( - outcome: NativeHttpCommandOutcome, -): Effect.Effect< - Response, - | Problem<"idempotency.in-flight"> - | Problem<"idempotency.digest-conflict"> - | Problem<"idempotency.response-expired"> -> => - Match.value(outcome).pipe( - Match.tag("Committed", "Replay", ({ response }) => - Effect.succeed(responseFromCapsule(response)), - ), - Match.tag("InFlight", () => Effect.fail(Problem.make("idempotency.in-flight"))), - Match.tag("DigestConflict", () => Effect.fail(Problem.make("idempotency.digest-conflict"))), - Match.tag("ResponseExpired", () => Effect.fail(Problem.make("idempotency.response-expired"))), - Match.exhaustive, - ); - -/** - * An anonymous AccessSpec grants every caller and conceals nothing, so a denial means the spec and - * its scope resolution disagree: a defect. - * - * @remarks - * It evaluates `spec` for the anonymous principal, with no grants, over `resolution` at `now`. A - * public operation calls it with the scope that it resolved, so the AccessSpec of the contract - * stays the one authority. A denial dies with the HTTP status of the evaluation. - * - * @sideEffects none - * - * @example - * ```ts - * yield* authorizeAnonymous(Option.getOrThrow(reflectAccessSpec(ReadTeamApplicationIntakeEndpoint)), resolution, instant); - * ``` - * - * @avoid Skipping the evaluation because the operation is public: the declared AccessSpec and the - * handler then drift apart unnoticed. Evaluate it, so that a mismatch is a defect. - * - * @construct http-problem - */ -export const authorizeAnonymous = ( - spec: AccessSpec, - resolution: CanonicalScopeResolution, - now: string, -): Effect.Effect => - authorizeAnonymousNativeOperation(spec, resolution, now).pipe( - Effect.catch((failure) => - Effect.die(new Error(`anonymous access denied with HTTP ${failure.status}`)), - ), - ); - -/** - * A rejected person credential is answered from the ingress evidence, never by string choice. - * - * @remarks - * It evaluates the AccessSpec of `input` for the person, with a grant for each capability of the - * spec in each grant scope. An evaluation of 401 answers `Problem.unauthenticated(presentation)`, - * 404 answers resource.not-found for a concealed resource, and 403 answers authority.denied. An - * operation whose spec reveals every denial pipes `unreachable("resource.not-found")` after it. - * - * @sideEffects none - * - * @example - * ```ts - * yield* authorizePerson({ spec, credential: authorization.credential, personId, resolution, grantScopes, now }, personPresentation(request)); - * ``` - * - * @avoid Choosing the answer to a denial in the handler: a concealed resource would answer 403 - * and reveal that it exists, or a credential answer would ignore the ingress evidence. Map the - * evaluation with this. - * - * @construct http-problem - */ -export const authorizePerson = ( - input: NativePersonAuthorization, - presentation: CredentialPresentation, -): Effect.Effect< - void, - | Problem<"authority.denied"> - | Problem<"credential.invalid"> - | Problem<"credential.missing"> - | Problem<"resource.not-found"> -> => - authorizePersonNativeOperation(input).pipe( - Effect.mapError((failure) => - Match.value(failure.status).pipe( - Match.when(401, () => Problem.unauthenticated(presentation)), - Match.when(404, () => Problem.make("resource.not-found")), - Match.orElse(() => Problem.make("authority.denied")), - ), - ), - ); - -/** - * Marks problems a shared mapper can produce but this operation cannot, such as a serialization - * conflict inside a read-only snapshot. - * - * @remarks - * It turns each failure that is a problem with one of the listed codes into a defect, which - * `ProblemBoundaryLive` answers as internal.error, and removes those problems from the error - * channel, so the endpoint need not declare them. At least one code is required: an empty list - * would widen `Code` to every code and erase every problem. - * - * @sideEffects none - * - * @example - * ```ts - * authorizePerson(input, presentation).pipe(unreachable("resource.not-found")); - * ``` - * - * @avoid Declaring a problem on an endpoint only because a shared mapper can produce it: the - * contract then promises an answer that the operation never gives. Mark it unreachable here. - * - * @construct http-problem - */ -export const unreachable = - ( - code: Code, - ...codes: ReadonlyArray - ): (( - effect: Effect.Effect, - ) => Effect.Effect>>, R>) => - (effect: Effect.Effect) => { - const listed: ReadonlyArray = [code, ...codes]; - - // Inferred apart from the declared return type, which would select another overload of catchIf. - const unlisted = Effect.catchIf( - effect, - (error): error is Extract> => - isProblem(error) && listed.includes(error.code), - (error) => Effect.die(error), - ); - - return unlisted; - }; - -/** - * The only Cause consumer. - * - * @remarks - * A global router middleware. HttpApiBuilder has already answered every declared typed failure, so - * a cause that reaches this boundary is a defect, an interrupt, or a failure that no endpoint - * declared. It reports the cause through `ErrorReporter` and answers the frozen internal.error - * problem. A cause of interrupts only that a client abort annotates passes through unanswered, - * because the client is gone. - * - * @sideEffects Reports each cause that it answers through `ErrorReporter`. - * - * @example - * ```ts - * return Layer.mergeAll(nativeRoutes, notFound, ProblemBoundaryLive); - * ``` - * - * @avoid Catching defects or rendering causes in a handler or another middleware: a cause then - * reaches the client, or a fault goes unreported. Leave causes to this boundary. - * - * @construct http-problem - */ -export const ProblemBoundaryLive: Layer.Layer = - HttpRouter.middleware( - (httpEffect) => - Effect.catchCause(httpEffect, (cause) => - Cause.hasInterruptsOnly(cause) && - cause.reasons.some((reason) => reason.annotations.has(HttpServerError.ClientAbort.key)) - ? Effect.failCause(cause) - : ErrorReporter.report(cause).pipe( - Effect.as( - HttpServerResponse.fromWeb(problemWebResponse(Problem.make("internal.error"))), - ), - ), - ), - { global: true }, - ); diff --git a/apps/backend/src/http-api/system.ts b/apps/backend/src/http-api/system.ts deleted file mode 100644 index f8124b0b6..000000000 --- a/apps/backend/src/http-api/system.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { Scope } from "@vektorprogrammet/domain/authz"; -import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { IdentitySnapshot, type IdentitySnapshotService } from "@vektorprogrammet/database"; -import { databaseHealth, type Database } from "@vektorprogrammet/database"; -import { - Identity, - type IdentityEngineError, - type IdentityOwnedSessionNotFound, - type IdentitySessionExpired, - type IdentitySessionNotFound, - type IdentityActor, - type IdentitySession, -} from "@vektorprogrammet/domain/identity"; -import { - DeleteOwnedSessionEndpoint, - DeleteSessionEndpoint, - ExternalNativeApi, - HealthEndpoint, - ListSessionsEndpoint, - ReadSessionEndpoint, - RevokeAllSessionsEndpoint, - RevokeOtherSessionsEndpoint, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { - type CredentialPresentation, - nativeCookieChallenge, - Problem, -} from "@vektorprogrammet/http-api/http-semantics"; -import { Schema, DateTime, Effect, Option } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { - resolveAuthenticatedPersonAtInstant, - resolveRequestCredentialInTransaction, - type AuthenticatedPersonAtInstant, -} from "../authority.js"; -import { encodePathIdentity, semanticRequestDigest } from "../http-semantics.js"; -import { genericContext } from "../native-operation.js"; -import { identityRequestContext } from "../session-security.js"; -import { - authorizeAnonymous, - authorizePerson, - commandOutcomeResponse, - commandReceiptProblems, - httpIdentity, - idempotencyKeyOf, - personPresentation, - problemMapper, - requireNoQuery, - unreachable, - webHandler, -} from "./problem.js"; -import { - executeNativeHttpCommandPostgres, - type NativeHttpResponseCapsule, -} from "./receipt-transaction.js"; - -const jsonResponse = ( - body: Schema.Json, - cacheControl: "no-store" | "private, no-store", -): Response => - new Response(JSON.stringify(body), { - status: 200, - headers: { - "content-type": "application/json; charset=utf-8", - "cache-control": cacheControl, - }, - }); - -/** A session resource is secured by the session cookie alone, so it challenges only that scheme. */ -const sessionPresentation = (request: Request) => - personPresentation(request, nativeCookieChallenge); - -/** - * The one answer for every identity failure of a session resource. A rejected - * session is answered from the request's evidence; an identity engine failure - * is the operation's own unavailability problem. - */ -const sessionProblems = < - const Unavailable extends Problem<"identity.unavailable"> | Problem<"dependency.unavailable">, ->( - presentation: CredentialPresentation, - unavailable: Unavailable, -) => - problemMapper< - | IdentityEngineError - | IdentitySessionNotFound - | IdentitySessionExpired - | IdentityOwnedSessionNotFound - | UnauthenticatedActor - >()({ - IdentityEngineError: () => unavailable, - IdentitySessionNotFound: () => Problem.unauthenticated(presentation), - IdentitySessionExpired: () => Problem.unauthenticated(presentation), - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - IdentityOwnedSessionNotFound: () => Problem.make("resource.not-found"), - }); - -const projection = (personId: string, session: IdentitySession) => ({ - sessionId: session.sessionId, - personId, - createdAt: DateTime.toDateUtc(session.createdAt).toISOString(), - updatedAt: DateTime.toDateUtc(session.updatedAt).toISOString(), - expiresAt: DateTime.toDateUtc(session.expiresAt).toISOString(), - ipAddress: session.ipAddress, - userAgent: session.userAgent, - current: session.current, -}); - -interface SystemOptions { - readonly now?: () => string; -} - -const principalFor = (request: Request, options: SystemOptions) => - resolveAuthenticatedPersonAtInstant(request.headers.get("cookie") ?? undefined, { - now: options.now, - }); - -const authorizeSessionOperation = ( - input: { - readonly request: Request; - readonly principal: AuthenticatedPersonAtInstant; - readonly endpoint: Parameters[0]; - readonly resourceId?: string; - readonly collection?: boolean; - }, - presentation: CredentialPresentation, -) => - authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(input.endpoint)), - request: input.request, - personId: input.principal.personId, - resolution: { - selection: input.collection === true ? "AllMatching" : "ExactlyOne", - contexts: [ - genericContext({ - domainId: "identity", - resourceKind: input.resourceId === undefined ? undefined : "identity-session", - resourceId: input.resourceId, - facts: { ownerPersonId: input.principal.personId }, - authorityVersion: `identity:${input.principal.authorizationInstant}`, - }), - ], - }, - grantScopes: [Scope.Global()], - now: input.principal.authorizationInstant, - }, - presentation, - ); - -const health = (request: Request, options: SystemOptions) => - Effect.gen(function* () { - yield* requireNoQuery(request); - yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(HealthEndpoint)), - { - selection: "ExactlyOne", - contexts: [genericContext({ domainId: "system", authorityVersion: "system-health" })], - }, - options.now === undefined ? DateTime.formatIso(yield* DateTime.now) : options.now(), - ); - yield* databaseHealth.pipe(Effect.mapError(() => Problem.make("health.unavailable"))); - - return jsonResponse({ status: "ok" }, "no-store"); - }); - -const readSession = (request: Request, options: SystemOptions) => { - const presentation = sessionPresentation(request); - - return Effect.gen(function* () { - yield* requireNoQuery(request); - const principal = yield* principalFor(request, options); - - const session = yield* Identity.use(({ readCurrentSession }) => - readCurrentSession(request.headers.get("cookie") ?? undefined), - ); - - yield* authorizeSessionOperation( - { request, principal, endpoint: ReadSessionEndpoint, resourceId: session.sessionId }, - presentation, - ); - - return jsonResponse(projection(principal.personId, session), "private, no-store"); - }).pipe( - sessionProblems(presentation, Problem.make("identity.unavailable")), - // The owner is granted their own session; only a mismatched credential is rejected. - unreachable("authority.denied", "resource.not-found"), - ); -}; - -const listSessions = (request: Request, options: SystemOptions) => { - const presentation = sessionPresentation(request); - - return Effect.gen(function* () { - yield* requireNoQuery(request); - const principal = yield* principalFor(request, options); - - yield* authorizeSessionOperation( - { request, principal, endpoint: ListSessionsEndpoint, collection: true }, - presentation, - ); - - const sessions = yield* Identity.use(({ listSessions }) => - listSessions(request.headers.get("cookie") ?? undefined), - ); - - return jsonResponse( - sessions.map((session) => projection(principal.personId, session)), - "private, no-store", - ); - }).pipe( - sessionProblems(presentation, Problem.make("identity.unavailable")), - // The owner is granted their own sessions; only a mismatched credential is rejected. - unreachable("authority.denied", "resource.not-found"), - ); -}; - -const executeSessionMutation = (input: { - readonly request: Request; - readonly options: SystemOptions; - readonly endpoint: Parameters[0]; - readonly operationId: string; - readonly normalizedTarget: string; - readonly resourceId?: string; - readonly mutate: ( - identity: IdentitySnapshotService, - actor: IdentityActor, - ) => Effect.Effect< - unknown, - IdentityEngineError | IdentitySessionNotFound | IdentityOwnedSessionNotFound, - Database - >; -}) => { - const presentation = sessionPresentation(input.request); - - return Effect.gen(function* () { - yield* requireNoQuery(input.request); - - const idempotencyKey = yield* idempotencyKeyOf(input.request); - - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const authenticated = yield* resolveRequestCredentialInTransaction( - input.request, - "OAuthUserBearer", - { now: input.options.now }, - ); - - const identity = yield* IdentitySnapshot; - - const actor = yield* identity.resolveSession( - input.request.headers.get("cookie") ?? undefined, - authenticated.authorizationInstant, - ); - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(input.endpoint)), - credential: authenticated.credential, - personId: actor.personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "identity", - resourceKind: input.resourceId === undefined ? undefined : "identity-session", - resourceId: input.resourceId, - facts: { ownerPersonId: actor.personId }, - authorityVersion: `identity:${authenticated.authorizationInstant}`, - }), - ], - }, - grantScopes: [Scope.Global()], - now: authenticated.authorizationInstant, - }, - presentation, - ); - - const derived = yield* httpIdentity({ - credentialSubject: `Person:${actor.personId}`, - qualifiedOperationId: input.operationId, - normalizedTarget: input.normalizedTarget, - idempotencyKey, - }); - - return { - identity: { - identitySha256: derived.identitySha256, - requestSha256: semanticRequestDigest({}), - operationId: input.operationId, - }, - execute: input.mutate(identity, actor).pipe( - Effect.as({ - status: 204, - mediaType: null, - headers: {}, - bodyBytes: null, - }), - ), - }; - }), - ).pipe( - // The frozen command table answers an identity engine failure as an unavailable dependency. - sessionProblems(presentation, Problem.make("dependency.unavailable")), - commandReceiptProblems, - ); - - return yield* commandOutcomeResponse(outcome); - }); -}; - -/** Native HttpApi implementations for health and the six frozen session resources. */ -export const SystemApiHandlers = (options: SystemOptions = {}) => - HttpApiBuilder.group(ExternalNativeApi, "system", (handlers) => - Effect.succeed( - handlers - .handleRaw("health", ({ request }) => - webHandler(request, (webRequest) => health(webRequest, options)), - ) - .handleRaw("readSession", ({ request }) => - webHandler(request, (webRequest) => readSession(webRequest, options)), - ) - .handleRaw("deleteSession", ({ request }) => - webHandler(request, (webRequest) => - executeSessionMutation({ - request: webRequest, - options, - endpoint: DeleteSessionEndpoint, - operationId: "system.deleteSession", - normalizedTarget: "/api/session", - mutate: (identity, actor) => - identity.revokeCurrentSession(actor, identityRequestContext(webRequest)), - }).pipe( - // Only the owned-session delete names a session other than the caller's own. - unreachable("resource.not-found"), - ), - ), - ) - .handleRaw("listSessions", ({ request }) => - webHandler(request, (webRequest) => listSessions(webRequest, options)), - ) - .handleRaw("deleteOwnedSession", ({ request, params }) => - webHandler(request, (webRequest) => - executeSessionMutation({ - request: webRequest, - options, - endpoint: DeleteOwnedSessionEndpoint, - operationId: "system.deleteOwnedSession", - normalizedTarget: `/api/sessions/${encodePathIdentity(params.sessionId)}`, - resourceId: params.sessionId, - mutate: (identity, actor) => - identity.revokeSession(actor, params.sessionId, identityRequestContext(webRequest)), - }), - ), - ) - .handleRaw("revokeOtherSessions", ({ request }) => - webHandler(request, (webRequest) => - executeSessionMutation({ - request: webRequest, - options, - endpoint: RevokeOtherSessionsEndpoint, - operationId: "system.revokeOtherSessions", - normalizedTarget: "/api/sessions::revoke-others", - mutate: (identity, actor) => - identity.revokeOtherSessions(actor, identityRequestContext(webRequest)), - }).pipe( - // Only the owned-session delete names a session other than the caller's own. - unreachable("resource.not-found"), - ), - ), - ) - .handleRaw("revokeAllSessions", ({ request }) => - webHandler(request, (webRequest) => - executeSessionMutation({ - request: webRequest, - options, - endpoint: RevokeAllSessionsEndpoint, - operationId: "system.revokeAllSessions", - normalizedTarget: "/api/sessions::revoke-all", - mutate: (identity, actor) => - identity.revokeAllSessions(actor, identityRequestContext(webRequest)), - }).pipe( - // Only the owned-session delete names a session other than the caller's own. - unreachable("resource.not-found"), - ), - ), - ), - ), - ); diff --git a/apps/backend/src/http-api/transport.test.ts b/apps/backend/src/http-api/transport.test.ts deleted file mode 100644 index 62b8c8a1c..000000000 --- a/apps/backend/src/http-api/transport.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { backendTestConfig } from "../../test/config.js"; -import { OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { Identity, IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import { - ExternalNativeApi, - InternalNativeApi, - NativeProblem, - Problem, -} from "@vektorprogrammet/http-api"; -import { Effect, Layer, Schema, SchemaAST, SchemaIssue } from "effect"; -import { - HttpApi, - HttpApiError, - type HttpApiEndpoint, - type HttpApiGroup, -} from "effect/unstable/httpapi"; -import { describe, expect, it, vi } from "@effect/vitest"; -import { - makeContentManagementTestHttp, - makeOrganizationTestHttp, - makeProfileTestHttp, -} from "../test/native-http.js"; -import { requestSchemaErrorResponse } from "./transport.js"; - -const expectProblem = (response: Response, status: number, code: string) => - Effect.gen(function* () { - expect(response.status).toBe(status); - expect(response.headers.get("content-type")).toBe("application/problem+json"); - expect(response.headers.get("cache-control")).toBe("no-store"); - const body = yield* Effect.promise(() => response.json()); - expect(body).toMatchObject({ - type: `urn:vektorprogrammet:problem:v0.2:${code}`, - status, - code, - }); - expect(body).not.toHaveProperty("error"); - }); - -const unreachable = vi.fn(() => Effect.die("request schema failure reached endpoint dispatch")); - -const securityServices = Layer.mergeAll( - Layer.mock(Identity, { - signIn: () => Effect.die("Unexpected sign-in"), - readCurrentSession: () => Effect.die("Unexpected session read"), - listSessions: () => Effect.die("Unexpected session list"), - revokeCurrentSession: () => Effect.die("Unexpected session mutation"), - revokeSession: () => Effect.die("Unexpected session mutation"), - revokeOtherSessions: () => Effect.die("Unexpected session mutation"), - revokeAllSessions: () => Effect.die("Unexpected session mutation"), - recordSecurityEvent: () => Effect.die("Unexpected identity audit"), - signOut: () => Effect.die("Unexpected sign-out"), - resolveSession: () => - Effect.fail( - new IdentityEngineError({ - operation: "resolveSession", - message: "request schema failure reached authentication", - }), - ), - }), - Layer.mock(OAuthCredentialAuthority, { - resolve: () => Effect.die("request schema failure reached authentication"), - }), -); - -const validIdempotencyKey = "A".repeat(22); - -const validETag = '"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"'; - -describe("native request schema error transport", () => { - it.live.each([ - ["missing If-Match", { "idempotency-key": validIdempotencyKey }, 428, "precondition.required"], - [ - "malformed If-Match", - { "idempotency-key": validIdempotencyKey, "if-match": `W/${validETag}` }, - 400, - "precondition.invalid", - ], - [ - "malformed Idempotency-Key", - { "idempotency-key": "too-short", "if-match": validETag }, - 400, - "idempotency-key.invalid", - ], - ] as const)("maps %s before dispatch", ([_name, transportHeaders, status, code]) => - Effect.gen(function* () { - unreachable.mockClear(); - - const response = yield* makeProfileTestHttp( - { - config: backendTestConfig, - resolveActor: unreachable, - }, - securityServices, - ).fetch( - new Request("http://backend.test/api/profile", { - method: "PATCH", - headers: { - cookie: "better-auth.session_token=transport-test-session", - "content-type": "application/merge-patch+json", - origin: "http://127.0.0.1:5174", - ...transportHeaders, - }, - body: '{"firstName":"Ada"}', - }), - ); - - yield* expectProblem(response, status, code); - expect(unreachable).not.toHaveBeenCalled(); - }), - ); - - it.live("maps query decoding to request.malformed before dispatch", () => - Effect.gen(function* () { - unreachable.mockClear(); - - const response = yield* makeOrganizationTestHttp( - { - config: backendTestConfig.organization, - resolveActor: unreachable, - resolveAuthority: unreachable, - }, - securityServices, - ).fetch( - new Request("http://backend.test/api/mailing-lists?type=unknown", { - headers: { cookie: "better-auth.session_token=transport-test-session" }, - }), - ); - - yield* expectProblem(response, 400, "request.malformed"); - expect(unreachable).not.toHaveBeenCalled(); - }), - ); - - it.live("maps path-parameter decoding to request.malformed before dispatch", () => - Effect.gen(function* () { - unreachable.mockClear(); - - const response = yield* makeContentManagementTestHttp(unreachable, securityServices).fetch( - new Request("http://backend.test/api/content/articles/not-a-number", { - headers: { cookie: "better-auth.session_token=transport-test-session" }, - }), - ); - - yield* expectProblem(response, 400, "request.malformed"); - expect(unreachable).not.toHaveBeenCalled(); - }), - ); -}); - -const inputProperties = (schema: Schema.Top | undefined) => { - const ast = schema === undefined ? undefined : SchemaAST.toType(schema.ast); - - return ast !== undefined && SchemaAST.isObjects(ast) ? ast.propertySignatures : []; -}; - -/** Every request-schema failure HttpApiBuilder can raise before dispatching one endpoint. */ -const requestSchemaErrors = (endpoint: HttpApiEndpoint.Top) => { - const errors: Array = []; - - const raise = (kind: HttpApiError.HttpApiSchemaError["kind"], issue: SchemaIssue.Issue) => - errors.push( - new HttpApiError.HttpApiSchemaError({ kind, cause: new Schema.SchemaError(issue) }), - ); - - for (const header of inputProperties(endpoint.headers)) { - raise( - "Headers", - new SchemaIssue.Pointer([header.name], new SchemaIssue.InvalidType(header.type)), - ); - - if (!SchemaAST.isOptional(header.type)) { - raise( - "Headers", - new SchemaIssue.Pointer([header.name], new SchemaIssue.MissingKey(undefined)), - ); - } - } - - for (const [kind, schema] of [ - ["Params", endpoint.params], - ["Query", endpoint.query], - ] as const) { - for (const property of inputProperties(schema)) { - raise( - kind, - new SchemaIssue.Pointer([property.name], new SchemaIssue.InvalidType(property.type)), - ); - } - } - - return errors; -}; - -interface SchemaErrorCheck { - readonly operation: string; - readonly declared: ReadonlyArray; - readonly error: HttpApiError.HttpApiSchemaError; -} - -const schemaErrorChecks = ( - api: HttpApi.HttpApi, -) => { - const checks: Array = []; - - HttpApi.reflect(api, { - onGroup: () => undefined, - onEndpoint: ({ group, endpoint, errors }) => { - for (const error of requestSchemaErrors(endpoint)) { - checks.push({ - operation: `${group.identifier}.${endpoint.identifier}`, - declared: [...errors.values()].flat(), - error, - }); - } - }, - }); - - return checks; -}; - -describe("request schema error coverage", () => { - it.live("answers every request schema failure with a problem the endpoint declares", () => - Effect.gen(function* () { - const checks = [ - ...schemaErrorChecks(ExternalNativeApi), - ...schemaErrorChecks(InternalNativeApi), - ]; - - const answered = new Set(); - const gaps: Array = []; - - for (const { operation, declared, error } of checks) { - const { code } = yield* Schema.decodeUnknownEffect(NativeProblem)( - yield* Effect.promise(() => requestSchemaErrorResponse(error).json()), - ); - - const problem = Problem.fromWire({ code }, {}); - answered.add(code); - - if (!declared.some((schema) => Schema.is(schema)(problem))) { - gaps.push(`${operation} ${error.kind} ${code}`); - } - } - - // Every client-caused transform branch is reachable from some declared endpoint input. - expect([...answered].sort()).toEqual([ - "header.malformed", - "idempotency-key.invalid", - "precondition.invalid", - "precondition.required", - "request.malformed", - ]); - expect(gaps).toEqual([]); - }), - ); -}); diff --git a/apps/backend/src/http-api/transport.ts b/apps/backend/src/http-api/transport.ts deleted file mode 100644 index 83f69fc03..000000000 --- a/apps/backend/src/http-api/transport.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { - ContactSsrSecurity, - InvitationCapabilitySecurity, - PersonOrServiceSecurity, - PersonSecurity, - RequestSchemaErrorMiddleware, - SessionSecurity, -} from "@vektorprogrammet/http-api"; -import { timingSafeEqual } from "node:crypto"; -import { OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { CONTACT_BACKEND_HEADER } from "@vektorprogrammet/domain/contact"; -import { Identity } from "@vektorprogrammet/domain/identity"; -import { - credentialPresentation, - invitationCapabilityChallenge, - Problem, -} from "@vektorprogrammet/http-api/http-semantics"; -import { Match, Effect, Layer, Redacted, Result, Schema, type SchemaIssue } from "effect"; -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"; -import { - headerCredentialCount, - resolveAuthenticatedPerson, - resolveAuthenticatedSession, - resolveRequestCredentialAtInstant, - resolveRequestPerson, -} from "../authority.js"; -import type { ContactConfig } from "../contact/config.js"; -import { classifyCredential, problemWebResponse } from "./problem.js"; - -const issueAtHeader = ( - issue: SchemaIssue.Issue, - headerName: string, - leafTag?: SchemaIssue.Leaf["_tag"], - path: ReadonlyArray = [], -): boolean => { - return Match.value(issue).pipe( - Match.tag("Filter", "Encoding", (issue) => { - return issueAtHeader(issue.issue, headerName, leafTag, path); - }), - Match.tag("Pointer", (issue) => { - return issueAtHeader(issue.issue, headerName, leafTag, [...path, ...issue.path]); - }), - Match.tag("Composite", "AnyOf", (issue) => { - return issue.issues.some((nested) => issueAtHeader(nested, headerName, leafTag, path)); - }), - Match.orElse((issue) => { - return ( - path.some((segment) => segment === headerName) && - (leafTag === undefined || issue._tag === leafTag) - ); - }), - ); -}; - -/** Maps automatic request decoding failures to the frozen transport problem families. */ -const requestSchemaProblem = (error: HttpApiError.HttpApiSchemaError) => { - if (error.kind === "Headers") { - if (issueAtHeader(error.cause.issue, "if-match")) { - return issueAtHeader(error.cause.issue, "if-match", "MissingKey") - ? Problem.make("precondition.required") - : Problem.make("precondition.invalid"); - } - - if (issueAtHeader(error.cause.issue, "if-none-match")) { - return Problem.make("precondition.invalid"); - } - - if (issueAtHeader(error.cause.issue, "idempotency-key")) { - return Problem.make("idempotency-key.invalid"); - } - - return Problem.make("header.malformed"); - } - - if (error.kind === "Params" || error.kind === "Query") { - return Problem.make("request.malformed"); - } - - return Problem.make("internal.error"); -}; - -/** - * RequestSchemaErrorMiddleware is API-wide, so it declares no error that would - * spread to every endpoint. It answers with the rendering the encoder uses. - */ -export const requestSchemaErrorResponse = (error: HttpApiError.HttpApiSchemaError): Response => - problemWebResponse(requestSchemaProblem(error)); - -type CredentialChallenge = - | 'VektorSession realm="native-api"' - | 'VektorSession realm="native-api", Bearer realm="native-api"'; - -/** - * Rejects a failed person or session authentication from the raw request. - * Effect decodes an absent credential as an empty value, so absence is read - * from the headers: no Better Auth session cookie and no Authorization header - * is a missing credential; any presented credential that failed is invalid. - */ -const rejectCredential = ( - request: HttpServerRequest.HttpServerRequest, - challenge: CredentialChallenge, -) => - Effect.fail( - Problem.unauthenticated( - classifyCredential(request.headers.authorization, request.headers.cookie, challenge), - ), - ); - -const isUnauthenticated = Schema.is(UnauthenticatedActor); - -const sessionSecurityLayer = Layer.effect( - SessionSecurity, - Effect.map(Identity, (identity) => - SessionSecurity.of({ - cookieHeader: (httpEffect, { credential }) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - - const authentication = yield* Effect.result( - resolveAuthenticatedSession( - Redacted.value(credential), - request.headers.authorization, - ).pipe(Effect.provideService(Identity, identity)), - ); - - if (Result.isFailure(authentication) && isUnauthenticated(authentication.failure)) { - return yield* rejectCredential(request, 'VektorSession realm="native-api"'); - } - - return yield* httpEffect; - }), - }), - ), -); - -const personSecurityLayer = Layer.effect( - PersonSecurity, - Effect.gen(function* () { - const identity = yield* Identity; - const oauthCredentialAuthority = yield* OAuthCredentialAuthority; - - return PersonSecurity.of({ - cookieHeader: (httpEffect, { credential }) => - Effect.gen(function* () { - const cookieHeader = Redacted.value(credential); - const request = yield* HttpServerRequest.HttpServerRequest; - - const webRequest = new Request(new URL(request.url, "http://native-api.invalid"), { - method: request.method, - headers: request.headers, - }); - - const authentication = yield* Effect.result( - request.headers.authorization !== undefined - ? resolveRequestPerson(webRequest).pipe( - Effect.provideService(Identity, identity), - Effect.provideService(OAuthCredentialAuthority, oauthCredentialAuthority), - ) - : resolveAuthenticatedPerson(cookieHeader).pipe( - Effect.provideService(Identity, identity), - ), - ); - - if (Result.isFailure(authentication) && isUnauthenticated(authentication.failure)) { - return yield* rejectCredential( - request, - 'VektorSession realm="native-api", Bearer realm="native-api"', - ); - } - - return yield* httpEffect; - }), - oauthUserBearer: (httpEffect) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - - const webRequest = new Request(new URL(request.url, "http://native-api.invalid"), { - method: request.method, - headers: request.headers, - }); - - const authentication = yield* Effect.result( - resolveRequestPerson(webRequest).pipe( - Effect.provideService(Identity, identity), - Effect.provideService(OAuthCredentialAuthority, oauthCredentialAuthority), - ), - ); - - if (Result.isFailure(authentication) && isUnauthenticated(authentication.failure)) { - return yield* rejectCredential( - request, - 'VektorSession realm="native-api", Bearer realm="native-api"', - ); - } - - return yield* httpEffect; - }), - }); - }), -); - -const personOrServiceSecurityLayer = Layer.effect( - PersonOrServiceSecurity, - Effect.gen(function* () { - const identity = yield* Identity; - const oauthCredentialAuthority = yield* OAuthCredentialAuthority; - - const authenticate = (httpEffect: Effect.Effect) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - - const webRequest = new Request(new URL(request.url, "http://native-api.invalid"), { - method: request.method, - headers: request.headers, - }); - - const authentication = yield* Effect.result( - resolveRequestCredentialAtInstant(webRequest, "Either").pipe( - Effect.provideService(Identity, identity), - Effect.provideService(OAuthCredentialAuthority, oauthCredentialAuthority), - ), - ); - - if (Result.isFailure(authentication) && isUnauthenticated(authentication.failure)) { - return yield* rejectCredential( - request, - 'VektorSession realm="native-api", Bearer realm="native-api"', - ); - } - - return yield* httpEffect; - }); - - return PersonOrServiceSecurity.of({ - cookieHeader: authenticate, - oauthUserBearer: authenticate, - oauthServiceBearer: authenticate, - }); - }), -); - -/** - * An absent capability names no invitation. A present one is the request's one - * credential, so a session cookie or an Authorization header beside it fails - * before the capability is read. - */ -const invitationCapabilitySecurityLayer = Layer.succeed( - InvitationCapabilitySecurity, - InvitationCapabilitySecurity.of({ - invitationCapability: (httpEffect, { credential }) => - Effect.gen(function* () { - if (Redacted.value(credential).length === 0) { - return yield* Problem.make("resource.not-found"); - } - - const request = yield* HttpServerRequest.HttpServerRequest; - - if (headerCredentialCount(request.headers.cookie, request.headers.authorization) > 0) { - return yield* Problem.credentialInvalid( - credentialPresentation({ presented: true, challenge: invitationCapabilityChallenge }), - ); - } - - return yield* httpEffect; - }), - }), -); - -const contactSsrSecurityLayer = (contact: ContactConfig | undefined) => { - const expected = contact === undefined ? undefined : Buffer.from(contact.backendToken); - - return Layer.succeed( - ContactSsrSecurity, - ContactSsrSecurity.of({ - contactBackend: (httpEffect, { credential }) => { - if (expected === undefined) return httpEffect; - const supplied = Buffer.from(Redacted.value(credential)); - - // A request without the header presented no credential; a wrong token presented an invalid one. - return supplied.length === expected.length && timingSafeEqual(supplied, expected) - ? httpEffect - : Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) => - Effect.fail( - Problem.unauthenticated( - credentialPresentation({ - presented: request.headers[CONTACT_BACKEND_HEADER] !== undefined, - challenge: 'ContactSSR realm="native-contact"', - }), - ), - ), - ); - }, - }), - ); -}; - -const RequestSchemaErrorLive = HttpApiMiddleware.layerSchemaErrorTransform( - RequestSchemaErrorMiddleware, - (error) => Effect.succeed(HttpServerResponse.fromWeb(requestSchemaErrorResponse(error))), -); - -/** - * Implements declared transport security at ingress. Domain handlers retain - * transaction-scoped authorization so command authority is re-evaluated under - * the serializable transaction that commits the command. - */ -export const nativeHttpApiMiddlewareLayer = (contact?: ContactConfig) => - Layer.mergeAll( - contactSsrSecurityLayer(contact), - sessionSecurityLayer, - personSecurityLayer, - personOrServiceSecurityLayer, - invitationCapabilitySecurityLayer, - RequestSchemaErrorLive, - ); - -/** Shared default for focused contract tests without configured contact delivery. */ -export const NativeHttpApiMiddlewareLive = nativeHttpApiMiddlewareLayer(); diff --git a/apps/backend/src/http-semantics.test.ts b/apps/backend/src/http-semantics.test.ts index 28e832521..8fe308089 100644 --- a/apps/backend/src/http-semantics.test.ts +++ b/apps/backend/src/http-semantics.test.ts @@ -1,5 +1,5 @@ -import { StrongETag } from "@vektorprogrammet/http-api"; -import { Problem } from "@vektorprogrammet/http-api/http-semantics"; +import { StrongETag } from "@vektorprogrammet/rpc"; +import { Problem } from "@vektorprogrammet/rpc/problem"; import { Effect } from "effect"; import { describe, expect, it } from "@effect/vitest"; import { @@ -401,14 +401,24 @@ describe("native HTTP semantics", () => { ).toThrow("unsupported"); } - const request = new Request("https://api.example.invalid/api/profile", { - method: "OPTIONS", - headers: { - "access-control-request-headers": "Content-Type, If-Match", - }, - }); + const preflightAsking = (headers: string) => + new Request("https://api.example.invalid/api/rpc", { + method: "OPTIONS", + headers: { "access-control-request-headers": headers }, + }); + + expect(allowsNativePreflightHeaders(preflightAsking("Content-Type, Authorization"))).toBe(true); + + // An RPC carries its precondition, idempotency key, and capability in the payload. + for (const header of [ + "If-Match", + "If-None-Match", + "Idempotency-Key", + "X-Recruitment-Invitation-Capability", + ]) { + expect(allowsNativePreflightHeaders(preflightAsking(`Content-Type, ${header}`))).toBe(false); + } - expect(allowsNativePreflightHeaders(request)).toBe(true); const preflight = trustedPreflightResponse(policy.trustedOrigins[0]!, ["PATCH", "GET"]); expect(preflight.headers.get("access-control-allow-methods")).toBe("GET, HEAD, PATCH, OPTIONS"); expect(preflight.headers.get("vary")).toBe( diff --git a/apps/backend/src/http-semantics.ts b/apps/backend/src/http-semantics.ts index e9ea5a4d7..aa1748371 100644 --- a/apps/backend/src/http-semantics.ts +++ b/apps/backend/src/http-semantics.ts @@ -12,8 +12,9 @@ import { type StrongETag, type ValidationProblemCode, StrongETag as StrongETagSchema, -} from "@vektorprogrammet/http-api/http-semantics"; +} from "@vektorprogrammet/rpc/problem"; import { Array as Arr, Data, Predicate, Result, Schema } from "effect"; +import { dual } from "effect/Function"; const encoder = new TextEncoder(); @@ -171,64 +172,75 @@ const jsonPointerProperty = (property: string): string => /** * Preserves absence, value, and explicit deletion before typed merge-patch decoding. */ -export const interpretMergePatchSource = >( - source: Schema.Json, - allowedFields: Fields, -): MergePatchInterpretation => { - if (!isJsonObject(source)) { - return MergePatchInterpretation.Rejected({ - code: "validation.failed", - errors: [makeNativeValidationError("", "invalid")], - }); - } +export const interpretMergePatchSource: { + >( + allowedFields: Fields, + ): (source: Schema.Json) => MergePatchInterpretation; + >( + source: Schema.Json, + allowedFields: Fields, + ): MergePatchInterpretation; +} = dual( + 2, + >( + source: Schema.Json, + allowedFields: Fields, + ): MergePatchInterpretation => { + if (!isJsonObject(source)) { + return MergePatchInterpretation.Rejected({ + code: "validation.failed", + errors: [makeNativeValidationError("", "invalid")], + }); + } - const allowed = new Set(allowedFields); - const ownKeys = Object.keys(source); - const unknownKeys = ownKeys.filter((key) => !allowed.has(key)).sort(); + const allowed = new Set(allowedFields); + const ownKeys = Object.keys(source); + const unknownKeys = ownKeys.filter((key) => !allowed.has(key)).sort(); - if (unknownKeys.length > 0) { - return MergePatchInterpretation.Rejected({ - code: "validation.failed", - errors: unknownKeys.map((key) => - makeNativeValidationError(jsonPointerProperty(key), "unknown"), - ), - }); - } + if (unknownKeys.length > 0) { + return MergePatchInterpretation.Rejected({ + code: "validation.failed", + errors: unknownKeys.map((key) => + makeNativeValidationError(jsonPointerProperty(key), "unknown"), + ), + }); + } - if (ownKeys.length === 0) { - return MergePatchInterpretation.Rejected({ - code: "validation.no-change", - errors: [makeNativeValidationError("", "no-change")], - }); - } + if (ownKeys.length === 0) { + return MergePatchInterpretation.Rejected({ + code: "validation.no-change", + errors: [makeNativeValidationError("", "no-change")], + }); + } - const record = source; + const record = source; - const fields = allowedFields.map((field) => { - if (!Object.hasOwn(record, field)) return [field, MergePatchFieldState.Absent()] as const; - const value = record[field]; + const fields = allowedFields.map((field) => { + if (!Object.hasOwn(record, field)) return [field, MergePatchFieldState.Absent()] as const; + const value = record[field]; - if (value === undefined) throw Problem.make("request.malformed"); + if (value === undefined) throw Problem.make("request.malformed"); - return [ - field, - value === null ? MergePatchFieldState.Null() : MergePatchFieldState.Value({ value }), - ] as const; - }); + return [ + field, + value === null ? MergePatchFieldState.Null() : MergePatchFieldState.Value({ value }), + ] as const; + }); - const deletedFields = fields.filter(([, state]) => Predicate.isTagged(state, "Null")); + const deletedFields = fields.filter(([, state]) => Predicate.isTagged(state, "Null")); - if (deletedFields.length > 0) { - return MergePatchInterpretation.Rejected({ - code: "validation.field-not-deletable", - errors: deletedFields.map(([field]) => - makeNativeValidationError(jsonPointerProperty(field), "field-not-deletable"), - ), - }); - } + if (deletedFields.length > 0) { + return MergePatchInterpretation.Rejected({ + code: "validation.field-not-deletable", + errors: deletedFields.map(([field]) => + makeNativeValidationError(jsonPointerProperty(field), "field-not-deletable"), + ), + }); + } - return MergePatchInterpretation.Accepted({ fields }); -}; + return MergePatchInterpretation.Accepted({ fields }); + }, +); const profileMergePatchFields = ["firstName", "lastName", "email", "phone"] as const; @@ -410,17 +422,18 @@ export const encodePathIdentity = (identity: string): string => { * * @construct http-transport */ -export const normalizeTarget = ( - routeTemplate: string, - identities: Readonly>, -): string => +export const normalizeTarget: { + (identities: Readonly>): (routeTemplate: string) => string; + (routeTemplate: string, identities: Readonly>): string; +} = dual(2, (routeTemplate: string, identities: Readonly>): string => routeTemplate.replaceAll(/\{([^}]+)\}/gu, (_match, name: string) => { const identity = identities[name]; if (identity === undefined) throw Problem.make("request.malformed"); return encodePathIdentity(identity); - }); + }), +); export interface DerivedHttpIdentity { readonly identitySha256: Sha256Hex; @@ -495,10 +508,13 @@ export const semanticRequestDigest = (request: CanonicalSemanticRequest): Sha256 sha256Hex(jcsBytes(request)); /** Builds the canonical envelope shared by every preconditioned mutation. */ -export const semanticMutationRequest = ( - body: Schema.Json, - ifMatch: StrongETag, -): CanonicalSemanticRequest => ({ body, ifMatch }); +export const semanticMutationRequest: { + (ifMatch: StrongETag): (body: Schema.Json) => CanonicalSemanticRequest; + (body: Schema.Json, ifMatch: StrongETag): CanonicalSemanticRequest; +} = dual( + 2, + (body: Schema.Json, ifMatch: StrongETag): CanonicalSemanticRequest => ({ body, ifMatch }), +); export type SemanticFile = { readonly byteLength: number; @@ -507,11 +523,17 @@ export type SemanticFile = { }; /** Converts staged multipart bytes to their boundary-independent semantic value. */ -export const semanticFile = (bytes: Uint8Array, contentType: string): SemanticFile => ({ - byteLength: bytes.byteLength, - contentType, - sha256: sha256Hex(bytes), -}); +export const semanticFile: { + (contentType: string): (bytes: Uint8Array) => SemanticFile; + (bytes: Uint8Array, contentType: string): SemanticFile; +} = dual( + 2, + (bytes: Uint8Array, contentType: string): SemanticFile => ({ + byteLength: bytes.byteLength, + contentType, + sha256: sha256Hex(bytes), + }), +); export type ETagVersionSource = | number @@ -591,13 +613,16 @@ export const evaluateReadPreconditions = (input: { }; /** Evaluates one required mutation precondition after authorization and concealment. */ -export const evaluateMutationPrecondition = ( - currentETag: StrongETag, - ifMatch: StrongETag, -): PreconditionDecision => - currentETag === ifMatch - ? PreconditionDecision.Proceed() - : PreconditionDecision.Failed({ code: "precondition.failed", status: 412 }); +export const evaluateMutationPrecondition: { + (ifMatch: StrongETag): (currentETag: StrongETag) => PreconditionDecision; + (currentETag: StrongETag, ifMatch: StrongETag): PreconditionDecision; +} = dual( + 2, + (currentETag: StrongETag, ifMatch: StrongETag): PreconditionDecision => + currentETag === ifMatch + ? PreconditionDecision.Proceed() + : PreconditionDecision.Failed({ code: "precondition.failed", status: 412 }), +); export const PUBLIC_CACHE_CONTROL = "public, max-age=60, s-maxage=300, must-revalidate"; @@ -606,10 +631,10 @@ export const PRIVATE_NO_STORE = "private, no-store"; export const NO_STORE = "no-store"; /** Computes freshness without crossing an admission start or end boundary. */ -export const admissionCacheControl = ( - nowEpochMilliseconds: number, - futureBoundaries: ReadonlyArray, -): string => { +export const admissionCacheControl: { + (futureBoundaries: ReadonlyArray): (nowEpochMilliseconds: number) => string; + (nowEpochMilliseconds: number, futureBoundaries: ReadonlyArray): string; +} = dual(2, (nowEpochMilliseconds: number, futureBoundaries: ReadonlyArray): string => { const next = futureBoundaries .filter((boundary) => boundary >= nowEpochMilliseconds) .sort((left, right) => left - right)[0]; @@ -620,7 +645,7 @@ export const admissionCacheControl = ( : Math.max(0, Math.min(30, Math.floor((next - nowEpochMilliseconds) / 1000))); return `public, max-age=${ttl}, s-maxage=${ttl}, must-revalidate`; -}; +}); const methodOrder = ["GET", "HEAD", "POST", "PATCH", "DELETE", "OPTIONS"] as const; diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 57aecaeab..0cca15e9e 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -4,34 +4,12 @@ export * from "./router.js"; export * from "./admission/config.js"; -export * from "./admission/http.js"; - -export type { AdmissionApiHttpOptions } from "./admission/http-context.js"; - export * from "./receipt/config.js"; export * from "./receipt/filesystem.js"; export * from "./receipt/r2.js"; -export * from "./receipt/http.js"; - -export type { ReceiptApiHttpOptions, ReceiptIdentityResolvers } from "./receipt/http-context.js"; - export * from "./organization/config.js"; -export * from "./organization/http.js"; - export * from "./recruitment/config.js"; - -export * from "./recruitment/http.js"; - -export type { RecruitmentApiHttpOptions } from "./recruitment/http-context.js"; - -export { recruitmentInterviewAccessContext } from "./recruitment/http-access.js"; - -export { - interviewETag, - invitationETag, - schedulingBoardWithETags, -} from "./recruitment/http-representation.js"; diff --git a/apps/backend/src/mail/AGENTS.md b/apps/backend/src/mail/AGENTS.md index 734d2e2b7..e4918decd 100644 --- a/apps/backend/src/mail/AGENTS.md +++ b/apps/backend/src/mail/AGENTS.md @@ -3,7 +3,7 @@ # apps/backend/src/mail This folder holds Delivery code under another name. Mail provider adapters behind the Delivery mail port. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The Delivery context in other folders: diff --git a/apps/backend/src/mail/cloudflare.ts b/apps/backend/src/mail/cloudflare.ts index 40b9d5325..045f8704d 100644 --- a/apps/backend/src/mail/cloudflare.ts +++ b/apps/backend/src/mail/cloudflare.ts @@ -66,12 +66,12 @@ const validMailbox = (value: string): boolean => !CONTROL.test(value) && MAILBOX.test(value); -const permanent = (): MailDeliveryError => new MailDeliveryError({ kind: "permanent-rejection" }); +const permanent = (): MailDeliveryError => MailDeliveryError.make({ kind: "permanent-rejection" }); const temporary = (): MailDeliveryError => - new MailDeliveryError({ kind: "temporary-unavailability" }); + MailDeliveryError.make({ kind: "temporary-unavailability" }); -const ambiguous = (): MailDeliveryError => new MailDeliveryError({ kind: "ambiguous-outcome" }); +const ambiguous = (): MailDeliveryError => MailDeliveryError.make({ kind: "ambiguous-outcome" }); export const classifyCloudflareMailError = (cause: unknown): MailDeliveryError => { if ( @@ -170,6 +170,6 @@ export const CloudflareMailLive = ( return Mail.of(makeCloudflareMail(config)); }, - catch: () => new CloudflareMailConfigurationError(), + catch: () => CloudflareMailConfigurationError.make(), }), ); diff --git a/apps/backend/src/mail/http.ts b/apps/backend/src/mail/http.ts index 3ce8a08e2..689c2df03 100644 --- a/apps/backend/src/mail/http.ts +++ b/apps/backend/src/mail/http.ts @@ -12,17 +12,18 @@ export const mailDeliveryConfig = ( if (keys.every((key) => env[key] === undefined)) return undefined; - if (keys.some((key) => !env[key])) throw new Error("Incomplete mail delivery configuration"); + if (keys.some((key) => (env[key] ?? "").length === 0)) + throw new Error("Incomplete mail delivery configuration"); const endpoint = new URL(env.MAIL_DELIVERY_URL!); const timeout = Number(env.MAIL_DELIVERY_TIMEOUT_MS); if ( (endpoint.protocol !== "https:" && !(endpoint.protocol === "http:" && endpoint.hostname === "127.0.0.1")) || - endpoint.username || - endpoint.password || - endpoint.search || - endpoint.hash || + endpoint.username.length > 0 || + endpoint.password.length > 0 || + endpoint.search.length > 0 || + endpoint.hash.length > 0 || !Number.isSafeInteger(timeout) || timeout < 1 || timeout > 30_000 @@ -39,15 +40,15 @@ const makeHttpMailDelivery = ( ): MailOperations => ({ deliver: (request) => config === undefined - ? Effect.fail(new MailDeliveryError({ kind: "temporary-unavailability" })) + ? Effect.fail(MailDeliveryError.make({ kind: "temporary-unavailability" })) : deliverJson(request, config, { "idempotency-key": request.deliveryId }).pipe( Effect.provideService(HttpClient.HttpClient, client), Effect.as({ providerReference: request.deliveryId }), Effect.catchTags({ - TimeoutError: () => Effect.fail(new MailDeliveryError({ kind: "ambiguous-outcome" })), + TimeoutError: () => Effect.fail(MailDeliveryError.make({ kind: "ambiguous-outcome" })), HttpDeliveryFailure: ({ status }) => Effect.fail( - new MailDeliveryError({ + MailDeliveryError.make({ kind: status !== undefined && status >= 400 && status < 500 ? "permanent-rejection" diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 1e6ec2850..2596f9e10 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -48,10 +48,9 @@ import { } from "./placements/notification.js"; import { backendHttpHandler, - ExternalNativeApiRouterLive, + ExternalNativeRpcRouterLive, internalBackendHttpHandler, - InternalNativeApiRouterLive, - nativeHttpRouterConfig, + InternalNativeRpcRouterLive, nativeRouterWebHandler, } from "./router.js"; @@ -150,21 +149,19 @@ const backendServicesLayer = Layer.mergeAll( const httpPlatformLayer = Layer.mergeAll(platformLayer, BunHttpPlatform.layer, Etag.layer); -const httpRouterLayer = HttpRouter.layer.pipe( - Layer.provide(Layer.succeed(HttpRouter.RouterConfig)(nativeHttpRouterConfig)), -); - -const httpLayer = Layer.merge(httpPlatformLayer, httpRouterLayer); +const httpLayer = Layer.merge(httpPlatformLayer, HttpRouter.layer); -const nativeApiLayer = ( - ingress === "external" ? ExternalNativeApiRouterLive(config) : InternalNativeApiRouterLive(config) +const nativeRpcLayer = ( + ingress === "external" + ? ExternalNativeRpcRouterLive({ config }) + : InternalNativeRpcRouterLive({ config }) ).pipe( HttpRouter.provideRequest(Layer.merge(backendServicesLayer, platformLayer)), Layer.provide(backendServicesLayer), Layer.provide(httpLayer), ); -const backendLayer = Layer.mergeAll(backendServicesLayer, httpLayer, nativeApiLayer); +const backendLayer = Layer.mergeAll(backendServicesLayer, httpLayer, nativeRpcLayer); const runtime = ManagedRuntime.make(backendLayer); diff --git a/apps/backend/src/native-api-preflight.ts b/apps/backend/src/native-api-preflight.ts deleted file mode 100644 index f5110de22..000000000 --- a/apps/backend/src/native-api-preflight.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ExternalNativeApi, reflectAccessSpec } from "@vektorprogrammet/http-api"; -import { Option } from "effect"; -import { nativePreflightMethodResolver } from "./native-preflight.js"; - -const externalNativeEndpoints = Object.values(ExternalNativeApi.groups).flatMap((group) => - Object.values(group.endpoints), -); - -export interface NativePreflightAttachmentGap { - readonly identifier: string; - readonly method: string; - readonly path: string; -} - -/** Old endpoints that 0077.2 must replace with AccessSpec-annotated route attachments. */ -export const externalNativePreflightAttachmentGaps: ReadonlyArray = - externalNativeEndpoints.flatMap((endpoint) => - Option.isSome(reflectAccessSpec(endpoint)) - ? [] - : [ - { - identifier: endpoint.identifier, - method: endpoint.method, - path: endpoint.path, - }, - ], - ); - -/** Methods for the current NativeApi route graph. */ -export const externalNativePreflightMethodsForPath = - nativePreflightMethodResolver(externalNativeEndpoints); diff --git a/apps/backend/src/native-operation.ts b/apps/backend/src/native-operation.ts index 4d94d3881..1cce675c8 100644 --- a/apps/backend/src/native-operation.ts +++ b/apps/backend/src/native-operation.ts @@ -19,21 +19,15 @@ import { } from "@vektorprogrammet/domain/authz"; import type { PersonId } from "@vektorprogrammet/domain/organization"; import { Data, Match, Predicate, Effect, type Schema } from "effect"; +import { dual } from "effect/Function"; -const capabilities = (spec: AccessSpec) => { - return Match.value(spec.capabilities).pipe( - Match.tag("None", () => { - return []; - }), - Match.tag("One", (capabilities) => { - return [capabilities.capability]; - }), - Match.tag("All", "Any", (capabilities) => { - return capabilities.capabilities; - }), +const capabilities = (spec: AccessSpec) => + Match.value(spec.capabilities).pipe( + Match.tag("None", () => []), + Match.tag("One", (capabilities) => [capabilities.capability]), + Match.tag("All", "Any", (capabilities) => capabilities.capabilities), Match.exhaustive, ); -}; /** * An AccessSpec evaluation that did not grant the operation. The status is @@ -44,29 +38,42 @@ export class NativeAccessRejected extends Data.TaggedError("NativeAccessRejected readonly status: 401 | 403 | 404; }> {} -export const authorizeAnonymousNativeOperation = ( - spec: AccessSpec, - resolution: CanonicalScopeResolution, - now: string, -): Effect.Effect => - evaluateAccessJourney(spec, undefined, { - now: Effect.succeed(AuthorizationInstant.make(now)), - resolveCredential: () => - Effect.succeed({ - _tag: "Accepted" as const, - mechanism: CredentialMechanismSchema.cases.None.make({}), - principal: PrincipalSchema.cases.Anonymous.make({}), - evidenceRef: CredentialEvidenceRef.make("anonymous"), - }), - resolveScope: () => Effect.succeed(resolution), - resolveGrants: () => Effect.succeed([]), - }).pipe( - Effect.flatMap((evaluation) => { - const status = accessHttpStatus(evaluation, spec.concealment); +export const authorizeAnonymousNativeOperation: { + ( + resolution: CanonicalScopeResolution, + now: string, + ): (spec: AccessSpec) => Effect.Effect; + ( + spec: AccessSpec, + resolution: CanonicalScopeResolution, + now: string, + ): Effect.Effect; +} = dual( + 3, + ( + spec: AccessSpec, + resolution: CanonicalScopeResolution, + now: string, + ): Effect.Effect => + evaluateAccessJourney(spec, undefined, { + now: Effect.succeed(AuthorizationInstant.make(now)), + resolveCredential: () => + Effect.succeed({ + _tag: "Accepted" as const, + mechanism: CredentialMechanismSchema.cases.None.make({}), + principal: PrincipalSchema.cases.Anonymous.make({}), + evidenceRef: CredentialEvidenceRef.make("anonymous"), + }), + resolveScope: () => Effect.succeed(resolution), + resolveGrants: () => Effect.succeed([]), + }).pipe( + Effect.flatMap((evaluation) => { + const status = accessHttpStatus(evaluation, spec.concealment); - return status === 200 ? Effect.void : Effect.fail(new NativeAccessRejected({ status })); - }), - ); + return status === 200 ? Effect.void : Effect.fail(new NativeAccessRejected({ status })); + }), + ), +); type AcceptedCredential = Extract; diff --git a/apps/backend/src/onboarding/AGENTS.md b/apps/backend/src/onboarding/AGENTS.md index 0238ba2f6..a3a104a7e 100644 --- a/apps/backend/src/onboarding/AGENTS.md +++ b/apps/backend/src/onboarding/AGENTS.md @@ -3,7 +3,7 @@ # apps/backend/src/onboarding This folder holds Recruitment code under another name. Onboarding invitation and account claim; decision D7 splits them between Recruitment and Identity. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The Recruitment context in other folders: diff --git a/apps/backend/src/onboarding/claim.http.test.ts b/apps/backend/src/onboarding/claim.http.test.ts deleted file mode 100644 index 79e08e284..000000000 --- a/apps/backend/src/onboarding/claim.http.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { createHash } from "node:crypto"; -import { Database, IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { commandOnboarding } from "@vektorprogrammet/database/onboarding"; -import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; -import { - CredentialEvidenceRef, - CredentialMechanismSchema, - CredentialOutcomeSchema, - PrincipalSchema, -} from "@vektorprogrammet/domain/authz"; -import { IdentityActor, IdentitySessionNotFound } from "@vektorprogrammet/domain/identity"; -import { OnboardingClaim, OnboardingClaimResult } from "@vektorprogrammet/domain/onboarding"; -import { DepartmentId, Organization, PersonId } from "@vektorprogrammet/domain/organization"; -import { NativeProblem } from "@vektorprogrammet/http-api"; -import { DateTime, Effect, Layer, Schema } from "effect"; -import { describe, expect, it } from "@effect/vitest"; -import { backendDatabase } from "../../test/database.js"; -import { decodeBackendConfig } from "../config.js"; -import { jsonText } from "../http-api/problem.js"; -import { makeBackendTestHttp } from "../test/native-http.js"; - -const environment = { - BACKEND_PG_URL: "postgres://test.invalid/vektorprogrammet", - BETTER_AUTH_SECRET: "onboarding-claim-test-secret-with-32-characters", - NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify(["http://127.0.0.1:5174"]), - OAUTH_CANONICAL_ORIGIN: "http://127.0.0.1:5174", - OAUTH_DASHBOARD_ORIGIN: "http://127.0.0.1:5174", - OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - PUBLIC_APPLICATION_EFFECT_MODE: "disabled", - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_MODE: "disabled", -} as const; - -const session = "better-auth.session_token=member-1-session"; - -// A delegated OAuth bearer of the same Person as the session. -const bearer = "Bearer member-1-bearer"; - -const bearerOutcome = (request: Request) => - request.headers.get("authorization") === bearer - ? CredentialOutcomeSchema.cases.Accepted.make({ - mechanism: CredentialMechanismSchema.cases.OAuthUserBearer.make({}), - principal: PrincipalSchema.cases.Person.make({ personId: PersonId.make("member-1") }), - evidenceRef: CredentialEvidenceRef.make("oauth:Person:member-1-bearer"), - }) - : CredentialOutcomeSchema.cases.Rejected.make({ reason: "Invalid" }); - -// Each invitation binds one applicant; the unknown token was never issued. -const tokens = { - a: `onboard_${"a".repeat(64)}`, - b: `onboard_${"b".repeat(64)}`, - unknown: `onboard_${"c".repeat(64)}`, -} as const; - -const seed = Database.use((sql) => - Effect.gen(function* () { - // The claim compares expiry with the database clock, so invitations are issued now. - const issuedAt = DateTime.formatIso(yield* DateTime.now); - - yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES ('claim-department', 'Trondheim')`; - yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ('claim-semester', '2031-08-01T00:00:00.000Z', '2032-01-01T00:00:00.000Z')`; - yield* sql` - INSERT INTO admission_periods (admission_period_id, department_id, semester_id, start_at, end_at, last_command_id) - VALUES ('claim-period', 'claim-department', 'claim-semester', '2031-09-01T08:00:00.000Z', '2031-10-01T20:00:00.000Z', 'claim-period-created') - `; - yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name) VALUES ('claim-field', 'claim-department', 'Matematikk')`; - yield* sql`INSERT INTO person_profiles (person_id, first_name, last_name) VALUES ('claim-issuer', 'Cora', 'Coordinator'), ('member-1', 'Mona', 'Member')`; - - for (const applicant of ["a", "b"] as const) { - yield* sql` - INSERT INTO admission_applicants (applicant_id, normalized_email, email, first_name, last_name, phone, gender, field_of_study_id, year_of_study) - VALUES (${`claim-applicant-${applicant}`}, ${`${applicant}@example.invalid`}, ${`${applicant}@example.invalid`}, 'Ada', 'Applicant', '12345678', 0, 'claim-field', 2) - `; - yield* sql` - INSERT INTO admission_applications (application_id, applicant_id, admission_period_id, department_id, field_of_study_id, year_of_study, submitted_at) - VALUES (${`claim-application-${applicant}`}, ${`claim-applicant-${applicant}`}, 'claim-period', 'claim-department', 'claim-field', 2, '2031-09-15T12:00:00.000Z') - `; - yield* sql.withTransaction( - commandOnboarding({ - departmentId: DepartmentId.make("claim-department"), - command: { - applicationId: PublicApplicationIdSchema.make(`claim-application-${applicant}`), - action: "Issue", - }, - actor: PersonId.make("claim-issuer"), - now: issuedAt, - invitationId: `claim-invitation-${applicant}`, - token: tokens[applicant], - digest: createHash("sha256").update(tokens[applicant]).digest("hex"), - }), - ); - } - }), -); - -const database = backendDatabase(seed); - -const http = makeBackendTestHttp( - Effect.runSync(decodeBackendConfig(environment)), - Layer.mergeAll( - database.layer, - // The session cookie and the bearer both name member-1; nothing else authenticates. - Layer.mock(IdentitySnapshot, { - resolveSession: (cookieHeader) => - cookieHeader?.split(/;\s*/u).includes(session) === true - ? Effect.succeed( - new IdentityActor({ - personId: PersonId.make("member-1"), - sessionId: "member-1-session", - expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), - }), - ) - : Effect.fail(new IdentitySessionNotFound()), - }), - Layer.mock(OAuthCredentialAuthority, { - resolve: (request) => Effect.succeed(bearerOutcome(request)), - resolveInTransaction: (request) => Effect.succeed(bearerOutcome(request)), - }), - Layer.mock(Organization, { - resolvePersonAuthority: (personId, evaluatedAt) => - Effect.succeed({ - personId, - evaluatedAt, - globalAdministrator: "Absent", - memberships: [], - nationalBoardSeats: [], - delegations: [], - }), - }), - ), - { - handler: () => Effect.succeed(new Response(null, { status: 404 })), - recordTrustedOriginRejection: () => Effect.void, - }, -); - -const claim = (body: typeof OnboardingClaim.Encoded, headers: Record = {}) => - Effect.gen(function* () { - const response = yield* http.fetch( - new Request("http://backend.test/api/onboarding/claim", { - method: "POST", - headers: { - "content-type": "application/json", - origin: "http://127.0.0.1:5174", - ...headers, - }, - body: yield* jsonText(body), - }), - ); - - const json: unknown = yield* Effect.promise(() => response.json()); - - if (response.ok) - return { - status: response.status, - ...(yield* Schema.decodeUnknownEffect(OnboardingClaimResult)(json)), - }; - - const { code } = yield* Schema.decodeUnknownEffect(NativeProblem)(json); - - // A credential problem also names the credential the claim accepts. - return response.status === 401 - ? { status: response.status, code, challenge: response.headers.get("www-authenticate") } - : { status: response.status, code }; - }); - -// Links, invitation states, and Persons are the facts a claim may change. -const claimFacts = () => - database.run( - Database.use((sql) => - Effect.gen(function* () { - const links = yield* sql<{ - readonly applicantId: string; - readonly personId: string; - readonly invitationId: string; - }>`SELECT applicant_id AS "applicantId", person_id AS "personId", invitation_id AS "invitationId" FROM applicant_account_links ORDER BY applicant_id`; - - const invitations = yield* sql<{ - readonly invitationId: string; - readonly state: string; - }>`SELECT invitation_id AS "invitationId", state FROM applicant_account_invitations ORDER BY invitation_id`; - - const persons = yield* sql<{ - readonly count: number; - }>`SELECT count(*)::integer AS count FROM person_profiles`; - - return { links, invitations, persons: persons[0]?.count }; - }), - ), - ); - -const unclaimed = { - links: [], - invitations: [ - { invitationId: "claim-invitation-a", state: "Open" }, - { invitationId: "claim-invitation-b", state: "Open" }, - ], - persons: 2, -}; - -describe("onboarding claim principal", () => { - it.live("links the session's Person once, to the invitation its token binds", () => - Effect.gen(function* () { - const existing = { mode: "ExistingAccount", token: tokens.a } as const; - - expect(yield* claim(existing, { cookie: session })).toEqual({ - status: 200, - state: "Claimed", - departmentId: "claim-department", - }); - expect(yield* claim(existing, { cookie: session })).toEqual({ - status: 400, - code: "onboarding.claim-invalid", - }); - expect(yield* claimFacts()).toEqual({ - links: [ - { - applicantId: "claim-applicant-a", - personId: "member-1", - invitationId: "claim-invitation-a", - }, - ], - invitations: [ - { invitationId: "claim-invitation-a", state: "Claimed" }, - { invitationId: "claim-invitation-b", state: "Open" }, - ], - persons: 2, - }); - }), - ); - - it.live("requires the session before it reads an existing-account token", () => - Effect.gen(function* () { - // Without its one principal, the claim answers a valid and an unknown token alike. - for (const token of [tokens.a, tokens.unknown]) { - expect(yield* claim({ mode: "ExistingAccount", token })).toEqual({ - status: 401, - code: "credential.missing", - challenge: 'VektorSession realm="native-api"', - }); - } - - expect(yield* claimFacts()).toEqual(unclaimed); - }), - ); - - it.live("lets only the browser session make an existing-account claim", () => - Effect.gen(function* () { - const existing = { mode: "ExistingAccount", token: tokens.a } as const; - - // The bearer names the session's own Person, yet a delegated bearer cannot claim. - for (const token of [tokens.a, tokens.unknown]) { - expect(yield* claim({ mode: "ExistingAccount", token }, { authorization: bearer })).toEqual( - { - status: 401, - code: "credential.invalid", - challenge: 'VektorSession realm="native-api"', - }, - ); - } - - expect(yield* claimFacts()).toEqual(unclaimed); - expect(yield* claim(existing, { cookie: session })).toEqual({ - status: 200, - state: "Claimed", - departmentId: "claim-department", - }); - expect(yield* claim(existing, { cookie: session })).toEqual({ - status: 400, - code: "onboarding.claim-invalid", - }); - }), - ); - - it.live("rejects a new-account token presented beside a session or a bearer", () => - Effect.gen(function* () { - const created = { - mode: "NewAccount", - token: tokens.b, - password: "correct horse battery", - } as const; - - for (const [header, credential] of [ - ["cookie", session], - ["authorization", bearer], - ] as const) { - expect(yield* claim(created, { [header]: credential })).toEqual({ - status: 401, - code: "credential.invalid", - challenge: 'VektorSession realm="native-api"', - }); - } - - expect(yield* claimFacts()).toEqual(unclaimed); - expect(yield* claim(created)).toEqual({ - status: 200, - state: "Claimed", - departmentId: "claim-department", - }); - }), - ); -}); diff --git a/apps/backend/src/onboarding/claim.rpc.test.ts b/apps/backend/src/onboarding/claim.rpc.test.ts new file mode 100644 index 000000000..f5c1e5684 --- /dev/null +++ b/apps/backend/src/onboarding/claim.rpc.test.ts @@ -0,0 +1,390 @@ +import { administratorDepartmentReach } from "@vektorprogrammet/domain/organization/authority-fixtures"; +import { createHash } from "node:crypto"; +import { Database, IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; +import { commandOnboarding } from "@vektorprogrammet/database/onboarding"; +import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; +import { + CredentialEvidenceRef, + CredentialMechanismSchema, + CredentialOutcomeSchema, + PrincipalSchema, +} from "@vektorprogrammet/domain/authz"; +import { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; +import type { OnboardingClaim } from "@vektorprogrammet/domain/onboarding"; +import { + DepartmentId, + MembershipId, + Organization, + PersonId, + TeamId, +} from "@vektorprogrammet/domain/organization"; +import { IdempotencyKey, isProblem, type Problem } from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Layer } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { describe, expect, it } from "@effect/vitest"; +import { backendDatabase } from "../../test/database.js"; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; + +const session = "better-auth.session_token=member-1-session"; + +// The coordinator leads the department's board, so it holds `admissions.outcomes` there. +const issuerSession = "better-auth.session_token=claim-issuer-session"; + +// A delegated OAuth bearer of the same Person as the session. +const bearer = "Bearer member-1-bearer"; + +const departmentId = DepartmentId.make("claim-department"); + +const bearerOutcome = (request: Request) => + request.headers.get("authorization") === bearer + ? CredentialOutcomeSchema.cases.Accepted.make({ + mechanism: CredentialMechanismSchema.cases.OAuthUserBearer.make({}), + principal: PrincipalSchema.cases.Person.make({ personId: PersonId.make("member-1") }), + evidenceRef: CredentialEvidenceRef.make("oauth:Person:member-1-bearer"), + }) + : CredentialOutcomeSchema.cases.Rejected.make({ reason: "Invalid" }); + +// Each invitation binds one applicant; the unknown token was never issued. +const tokens = { + a: `onboard_${"a".repeat(64)}`, + b: `onboard_${"b".repeat(64)}`, + unknown: `onboard_${"c".repeat(64)}`, +} as const; + +const seed = Database.use((sql) => + Effect.gen(function* () { + // The claim compares expiry with the database clock, so invitations are issued now. + const issuedAt = DateTime.formatIso(yield* DateTime.now); + + yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES ('claim-department', 'Trondheim')`; + yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ('claim-semester', '2031-08-01T00:00:00.000Z', '2032-01-01T00:00:00.000Z')`; + yield* sql` + INSERT INTO admission_periods (admission_period_id, department_id, semester_id, start_at, end_at, last_command_id) + VALUES ('claim-period', 'claim-department', 'claim-semester', '2031-09-01T08:00:00.000Z', '2031-10-01T20:00:00.000Z', 'claim-period-created') + `; + yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name) VALUES ('claim-field', 'claim-department', 'Matematikk')`; + yield* sql`INSERT INTO person_profiles (person_id, first_name, last_name) VALUES ('claim-issuer', 'Cora', 'Coordinator'), ('member-1', 'Mona', 'Member')`; + + for (const applicant of ["a", "b"] as const) { + yield* sql` + INSERT INTO admission_applicants (applicant_id, normalized_email, email, first_name, last_name, phone, gender, field_of_study_id, year_of_study) + VALUES (${`claim-applicant-${applicant}`}, ${`${applicant}@example.invalid`}, ${`${applicant}@example.invalid`}, 'Ada', 'Applicant', '12345678', 0, 'claim-field', 2) + `; + yield* sql` + INSERT INTO admission_applications (application_id, applicant_id, admission_period_id, department_id, field_of_study_id, year_of_study, submitted_at) + VALUES (${`claim-application-${applicant}`}, ${`claim-applicant-${applicant}`}, 'claim-period', 'claim-department', 'claim-field', 2, '2031-09-15T12:00:00.000Z') + `; + yield* sql.withTransaction( + commandOnboarding({ + coordinator: administratorDepartmentReach( + "claim-issuer", + "admissions.outcomes", + "claim-department", + ), + command: { + applicationId: PublicApplicationIdSchema.make(`claim-application-${applicant}`), + action: "Issue", + }, + now: issuedAt, + invitationId: `claim-invitation-${applicant}`, + token: tokens[applicant], + digest: createHash("sha256").update(tokens[applicant]).digest("hex"), + }), + ); + } + }), +); + +/** The session cookies name member-1 and the coordinator; nothing else authenticates. */ +const sessionActor = (cookieHeader: string | undefined) => { + const cookies = cookieHeader?.split(/;\s*/u) ?? []; + + const personId = cookies.includes(session) + ? "member-1" + : cookies.includes(issuerSession) + ? "claim-issuer" + : undefined; + + return personId === undefined + ? Effect.fail(IdentitySessionNotFound.make({})) + : Effect.succeed( + IdentityActor.make({ + personId: PersonId.make(personId), + sessionId: `${personId}-session`, + expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), + }), + ); +}; + +const identity = Identity.of({ + signIn: () => Effect.die("unexpected sign-in"), + resolveSession: sessionActor, + readCurrentSession: () => Effect.die("unexpected session read"), + listSessions: () => Effect.die("unexpected session list"), + revokeCurrentSession: () => Effect.die("unexpected session mutation"), + revokeSession: () => Effect.die("unexpected session mutation"), + revokeOtherSessions: () => Effect.die("unexpected session mutation"), + revokeAllSessions: () => Effect.die("unexpected session mutation"), + recordSecurityEvent: () => Effect.die("unexpected identity audit"), + signOut: () => Effect.succeed({ setCookies: [] }), +} satisfies IdentityOperations); + +const fixture = () => + Effect.gen(function* () { + const database = backendDatabase(seed); + + const backend = makeBackendTestRpc({ + config: backendTestConfig, + services: Layer.mergeAll( + Layer.mock(IdentitySnapshot, { resolveSession: sessionActor }), + Layer.succeed(Identity, identity), + Layer.mock(OAuthCredentialAuthority, { + resolve: (request) => Effect.succeed(bearerOutcome(request)), + resolveInTransaction: (request) => Effect.succeed(bearerOutcome(request)), + }), + Layer.mock(Organization, { + resolvePersonAuthority: (personId, evaluatedAt) => + Effect.succeed({ + personId, + evaluatedAt, + globalAdministrator: "Absent", + memberships: + personId === "claim-issuer" + ? [ + { + membershipId: MembershipId.make("claim-board-membership"), + teamId: TeamId.make("claim-board"), + departmentId, + active: true, + unitLeader: true, + unitKind: "DepartmentBoard" as const, + teamScope: "HomeDepartment" as const, + departmentIndependent: true, + }, + ] + : [], + nationalBoardSeats: [], + delegations: [], + }), + }), + ).pipe(Layer.provideMerge(database.layer)), + }); + + return { database, client: yield* backend.client }; + }); + +type Client = Effect.Success>["client"]; + +/** The problem code of a failed call; a transport failure has none. */ +const codeOf = (failure: Problem | RpcClientError) => + isProblem(failure) ? failure.code : "defect"; + +/** One claim with the credential headers of a browser or a delegated client. */ +const claimWith = + (client: Client) => + (payload: typeof OnboardingClaim.Type, headers: Readonly> = {}) => + RpcClient.withHeaders(client["onboarding.claim"](payload), headers).pipe( + Effect.match({ + onSuccess: (result) => result, + onFailure: (failure) => ({ code: codeOf(failure) }), + }), + ); + +// Links, invitation states, and Persons are the facts a claim may change. +const claimFacts = (database: Effect.Success>["database"]) => + database.run( + Database.use((sql) => + Effect.gen(function* () { + const links = yield* sql<{ + readonly applicantId: string; + readonly personId: string; + readonly invitationId: string; + }>`SELECT applicant_id AS "applicantId", person_id AS "personId", invitation_id AS "invitationId" FROM applicant_account_links ORDER BY applicant_id`; + + const invitations = yield* sql<{ + readonly invitationId: string; + readonly state: string; + }>`SELECT invitation_id AS "invitationId", state FROM applicant_account_invitations ORDER BY invitation_id`; + + const persons = yield* sql<{ + readonly count: number; + }>`SELECT count(*)::integer AS count FROM person_profiles`; + + return { links, invitations, persons: persons[0]?.count }; + }), + ), + ); + +const unclaimed = { + links: [], + invitations: [ + { invitationId: "claim-invitation-a", state: "Open" }, + { invitationId: "claim-invitation-b", state: "Open" }, + ], + persons: 2, +}; + +const claimed = { state: "Claimed", departmentId: "claim-department" }; + +describe("onboarding claim principal", () => { + it.live("links the session's Person once, to the invitation its token binds", () => + Effect.gen(function* () { + const { database, client } = yield* fixture(); + const claim = claimWith(client); + const existing = { mode: "ExistingAccount", token: tokens.a } as const; + + expect(yield* claim(existing, { cookie: session })).toEqual(claimed); + expect(yield* claim(existing, { cookie: session })).toEqual({ + code: "onboarding.claim-invalid", + }); + expect(yield* claimFacts(database)).toEqual({ + links: [ + { + applicantId: "claim-applicant-a", + personId: "member-1", + invitationId: "claim-invitation-a", + }, + ], + invitations: [ + { invitationId: "claim-invitation-a", state: "Claimed" }, + { invitationId: "claim-invitation-b", state: "Open" }, + ], + persons: 2, + }); + }), + ); + + it.live("requires the session before it reads an existing-account token", () => + Effect.gen(function* () { + const { database, client } = yield* fixture(); + const claim = claimWith(client); + + // Without its one principal, the claim answers a valid and an unknown token alike. + for (const token of [tokens.a, tokens.unknown]) { + expect(yield* claim({ mode: "ExistingAccount", token })).toEqual({ + code: "credential.missing", + }); + } + + expect(yield* claimFacts(database)).toEqual(unclaimed); + }), + ); + + it.live("lets only the browser session make an existing-account claim", () => + Effect.gen(function* () { + const { database, client } = yield* fixture(); + const claim = claimWith(client); + const existing = { mode: "ExistingAccount", token: tokens.a } as const; + + // The bearer names the session's own Person, yet a delegated bearer cannot claim. + for (const token of [tokens.a, tokens.unknown]) { + expect(yield* claim({ mode: "ExistingAccount", token }, { authorization: bearer })).toEqual( + { code: "credential.invalid" }, + ); + } + + expect(yield* claimFacts(database)).toEqual(unclaimed); + expect(yield* claim(existing, { cookie: session })).toEqual(claimed); + expect(yield* claim(existing, { cookie: session })).toEqual({ + code: "onboarding.claim-invalid", + }); + }), + ); + + it.live("rejects a new-account token presented beside a session or a bearer", () => + Effect.gen(function* () { + const { database, client } = yield* fixture(); + const claim = claimWith(client); + + const created = { + mode: "NewAccount", + token: tokens.b, + password: "correct horse battery", + } as const; + + for (const [header, credential] of [ + ["cookie", session], + ["authorization", bearer], + ] as const) { + expect(yield* claim(created, { [header]: credential })).toEqual({ + code: "credential.invalid", + }); + } + + expect(yield* claimFacts(database)).toEqual(unclaimed); + expect(yield* claim(created)).toEqual(claimed); + }), + ); +}); + +describe("onboarding board over RPC and PostgreSQL", () => { + it.live("lets the department coordinator revoke with the board's tag, and replays it", () => + Effect.gen(function* () { + const { database, client } = yield* fixture(); + + const asIssuer = (effect: Effect.Effect) => + RpcClient.withHeaders(effect, { cookie: issuerSession }); + + const board = yield* asIssuer(client["onboarding.readBoard"]({ departmentId })); + + expect(board.items.map((item) => [item.applicationId, item.state])).toEqual([ + ["claim-application-a", "Open"], + ["claim-application-b", "Open"], + ]); + + const revoke = asIssuer( + client["onboarding.command"]({ + departmentId, + idempotencyKey: IdempotencyKey.make("onboarding-revoke".padEnd(22, "0")), + ifMatch: board.etag, + request: { + applicationId: PublicApplicationIdSchema.make("claim-application-a"), + action: "Revoke", + }, + }), + ); + + const revoked = yield* revoke; + + expect(revoked.etag).not.toBe(board.etag); + expect( + revoked.items.find((item) => item.applicationId === "claim-application-a")?.state, + ).toBe("Revoked"); + + // A retry with the same key replays the first answer. + expect(yield* revoke).toEqual(revoked); + + // The board changed, so its first tag no longer names it. + const stale = yield* asIssuer( + client["onboarding.command"]({ + departmentId, + idempotencyKey: IdempotencyKey.make("onboarding-stale".padEnd(22, "0")), + ifMatch: board.etag, + request: { + applicationId: PublicApplicationIdSchema.make("claim-application-b"), + action: "Revoke", + }, + }), + ).pipe(Effect.flip); + + expect(codeOf(stale)).toBe("precondition.failed"); + + // A member without `admissions.outcomes` in the department neither reads nor commands. + const read = yield* RpcClient.withHeaders(client["onboarding.readBoard"]({ departmentId }), { + cookie: session, + }).pipe(Effect.flip); + + expect(codeOf(read)).toBe("authority.denied"); + expect( + (yield* claimFacts(database)).invitations.map((invitation) => invitation.state), + ).toEqual(["Revoked", "Open"]); + }), + ); +}); diff --git a/apps/backend/src/onboarding/delivery.ts b/apps/backend/src/onboarding/delivery.ts index be8c16b8e..20fe5265a 100644 --- a/apps/backend/src/onboarding/delivery.ts +++ b/apps/backend/src/onboarding/delivery.ts @@ -1,5 +1,6 @@ import { Database } from "@vektorprogrammet/database"; import { Crypto, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { ContactEmail } from "@vektorprogrammet/domain/contact"; import { deliverJson, type HttpDeliveryConfig } from "../delivery/http.js"; import { pollForever } from "../worker-support.js"; @@ -26,18 +27,18 @@ export const onboardingDeliveryConfig = ( const endpoint = new URL(env.ONBOARDING_DELIVERY_URL!); const origin = new URL(env.OAUTH_DASHBOARD_ORIGIN!); - if (origin.pathname !== "/" || origin.search || origin.hash) throw new Error(); + if (origin.pathname !== "/" || origin.search !== "" || origin.hash !== "") throw new Error(); const claimUrl = new URL("/konto-aktivering", origin); - const token = env.ONBOARDING_DELIVERY_TOKEN!; + const token = env.ONBOARDING_DELIVERY_TOKEN; const timeout = Number(env.ONBOARDING_DELIVERY_TIMEOUT_MS); const sender = Schema.decodeUnknownSync(ContactEmail)(env.ONBOARDING_DELIVERY_SENDER); for (const url of [endpoint, claimUrl]) if ( - url.username || - url.password || - url.search || - url.hash || + url.username !== "" || + url.password !== "" || + url.search !== "" || + url.hash !== "" || !( url.protocol === "https:" || (url.protocol === "http:" && ["127.0.0.1", "[::1]"].includes(url.hostname)) @@ -45,7 +46,13 @@ export const onboardingDeliveryConfig = ( ) throw new Error(); - if (!token?.trim() || !Number.isInteger(timeout) || timeout < 1 || timeout > 30000) + if ( + token === undefined || + token.trim() === "" || + !Number.isInteger(timeout) || + timeout < 1 || + timeout > 30000 + ) throw new Error(); return { @@ -58,8 +65,7 @@ export const onboardingDeliveryConfig = ( } }; -/** One durable queue, explicit operator retry; receiver must deduplicate deliveryId. */ -export const drainOnboardingDelivery = ( +const drainOnboardingDeliveryOnce = ( applicationId: string, config: OnboardingDeliveryConfig | undefined, ) => @@ -67,7 +73,7 @@ export const drainOnboardingDelivery = ( Effect.gen(function* () { yield* expireOnboardingSecrets; - if (!config) return "Pending" as const; + if (config === undefined) return "Pending" as const; const claimId = yield* Crypto.Crypto.use((crypto) => crypto.randomUUIDv4).pipe(Effect.orDie); const selected = yield* sql<{ @@ -79,7 +85,7 @@ export const drainOnboardingDelivery = ( const row = selected[0]; - if (!row) return "BusyOrComplete" as const; + if (row === undefined) return "BusyOrComplete" as const; const url = new URL(config.claimUrl); url.hash = row.secret; @@ -97,7 +103,7 @@ export const drainOnboardingDelivery = ( envelope: unknown; }>`UPDATE public.applicant_account_delivery SET envelope=${sql.json(envelope)} WHERE invitation_id=${row.invitationId} AND claim_id=${claimId} AND state='Claimed' RETURNING envelope`; - if (!saved.length) return "BusyOrComplete" as const; + if (saved.length === 0) return "BusyOrComplete" as const; const delivered = yield* deliverJson( yield* Schema.decodeUnknownEffect(Schema.Json)(saved[0]!.envelope), @@ -111,18 +117,28 @@ export const drainOnboardingDelivery = ( const acknowledged = yield* sql`UPDATE public.applicant_account_delivery SET state='Delivered',secret=NULL,envelope=NULL,claim_id=NULL,claimed_at=NULL WHERE invitation_id=${row.invitationId} AND claim_id=${claimId} AND state='Claimed' RETURNING invitation_id`; - if (!acknowledged.length) return "BusyOrComplete" as const; + if (acknowledged.length === 0) return "BusyOrComplete" as const; } else { const retried = yield* sql`UPDATE public.applicant_account_delivery SET state='Pending',claim_id=NULL,claimed_at=NULL WHERE invitation_id=${row.invitationId} AND claim_id=${claimId} AND state='Claimed' RETURNING invitation_id`; - if (!retried.length) return "BusyOrComplete" as const; + if (retried.length === 0) return "BusyOrComplete" as const; } return delivered ? ("Delivered" as const) : ("Pending" as const); }), ); +type OnboardingDeliveryDrain = ReturnType; + +/** One durable queue, explicit operator retry; receiver must deduplicate deliveryId. */ +export const drainOnboardingDelivery: { + ( + config: OnboardingDeliveryConfig | undefined, + ): (applicationId: string) => OnboardingDeliveryDrain; + (applicationId: string, config: OnboardingDeliveryConfig | undefined): OnboardingDeliveryDrain; +} = dual(2, drainOnboardingDeliveryOnce); + export const expireOnboardingSecrets = Database.use( (sql) => sql`UPDATE public.applicant_account_delivery d SET state='Cancelled',secret=NULL,envelope=NULL,claim_id=NULL,claimed_at=NULL FROM public.applicant_account_invitations i WHERE d.invitation_id=i.invitation_id AND d.state IN ('Pending','Claimed') AND (i.expires_at<=clock_timestamp() OR i.state<>'Open')`, diff --git a/apps/backend/src/onboarding/http.ts b/apps/backend/src/onboarding/http.ts deleted file mode 100644 index 96d363034..000000000 --- a/apps/backend/src/onboarding/http.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { - credentialMatchesAccessSpec, - reaches, - ReachTarget, - Scope, -} from "@vektorprogrammet/domain/authz"; -import { Database } from "@vektorprogrammet/database"; -import { executeNativeHttpCommandPostgres } from "../http-api/receipt-transaction.js"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import { - OnboardingClaim, - OnboardingCommand, - type OnboardingFailure, - OnboardingScope, -} from "@vektorprogrammet/domain/onboarding"; -import { - checkOnboardingClaim, - claimOnboarding, - commandOnboarding, - lockOnboardingApplicant, - onboardingApplication, - readOnboardingBoard, -} from "@vektorprogrammet/database/onboarding"; -import { hashOnboardingPassword, provisionOnboardingAccount } from "@vektorprogrammet/database"; -import { - type OrganizationDecodeError, - type OrganizationPersistenceError, - PersonId, -} from "@vektorprogrammet/domain/organization"; -import { - ClaimOnboardingEndpoint, - ExternalNativeApi, - OnboardingResource, - ReadOnboardingEndpoint, - CommandOnboardingEndpoint, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { - type CredentialPresentation, - nativeCookieChallenge, - Problem, -} from "@vektorprogrammet/http-api/http-semantics"; -import { Crypto, flow, Predicate, Effect, Option, Schema } from "effect"; -import type { SqlError } from "effect/unstable/sql/SqlError"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { - currentInstant, - headerCredentialCount, - resolveRequestPersonAuthorityInTransaction, -} from "../authority.js"; -import { - authorizePerson, - commandOutcomeResponse, - commandReceiptProblems, - decodeRequest, - httpIdentity, - idempotencyKeyOf, - isSerializationConflict, - personPresentation, - problemMapper, - readJsonBody, - requireCurrentETag, - requireNoQuery, - requiredIfMatchOf, - webHandler, -} from "../http-api/problem.js"; -import { - deriveStrongETag, - normalizeTarget, - semanticMutationRequest, - semanticRequestDigest, - responseCapsule, -} from "../http-semantics.js"; -import { genericContext } from "../native-operation.js"; -import { drainOnboardingDelivery, type OnboardingDeliveryConfig } from "./delivery.js"; - -/** Both bodies are JSON. The media type is matched case-sensitively. */ -const JSON_MEDIA_TYPE = /^application\/json(?:\s*;|$)/u; - -const MAX_BODY_BYTES = 8_192; - -/** The SQLSTATE of the first coded failure in a cause chain. */ -const sqlState = (cause: unknown, depth = 0): string | undefined => { - if (depth >= 8 || !Predicate.isObjectOrArray(cause)) return undefined; - - if (Predicate.hasProperty(cause, "code") && Predicate.isString(cause.code)) return cause.code; - - return Predicate.hasProperty(cause, "cause") ? sqlState(cause.cause, depth + 1) : undefined; -}; - -/** - * A failed statement. A unique violation means a racing claim provisioned - * the same account first, so the claimant must sign in to it. - */ -const sqlProblem = (cause: SqlError | OrganizationPersistenceError) => - sqlState(cause) === "23505" - ? Problem.make("onboarding.sign-in-required") - : isSerializationConflict(cause) - ? Problem.make("transaction.conflict") - : Problem.make("internal.error"); - -/** - * The one answer for every onboarding failure. A person credential rejected - * inside the transaction is answered from the request's own evidence. - */ -const onboardingProblems = (presentation: CredentialPresentation) => - problemMapper< - | OnboardingFailure - | UnauthenticatedActor - | IdentityEngineError - | OrganizationDecodeError - | OrganizationPersistenceError - | SqlError - >()({ - OnboardingFailure: ({ code }) => Problem.make(code), - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - IdentityEngineError: () => Problem.make("internal.error"), - OrganizationDecodeError: () => Problem.make("internal.error"), - OrganizationPersistenceError: sqlProblem, - SqlError: sqlProblem, - }); - -/** - * Hex SHA-256 of a claim token, the only form the database stores. - */ -const tokenDigest = (token: string) => - Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(token))).pipe( - Effect.map((digest) => Buffer.from(digest).toString("hex")), - ); - -const resource = (body: A) => ({ - ...body, - etag: deriveStrongETag({ - representationKind: "OnboardingSnapshot", - resourceIdentity: JSON.stringify(body), - version: JSON.stringify(body), - }), -}); - -const json = (body: Schema.Json, etag?: string) => { - const nativeHeaders = new Headers(); - nativeHeaders.set("content-type", "application/json"); - nativeHeaders.set("cache-control", etag ? "no-store" : "private, no-store"); - nativeHeaders.set("vary", "Origin"); - - if (etag) { - nativeHeaders.set("etag", etag); - } - - return new Response(JSON.stringify(body), { - headers: nativeHeaders, - }); -}; - -/** - * Board rows reach the schema unchecked; a row outside it is a server defect, not a request error. - */ -const boardOutput = flow( - Schema.decodeUnknownEffect(OnboardingResource, { onExcessProperty: "error" }), - Effect.orDie, -); - -/** The one departmentId query member the board operations accept. */ -const scopeOf = (request: Request) => { - const query = new URL(request.url).searchParams; - - return [...query.keys()].some((k) => k !== "departmentId" || query.getAll(k).length !== 1) - ? Effect.fail(Problem.make("request.malformed")) - : decodeRequest(OnboardingScope)(Object.fromEntries(query)); -}; - -/** - * Whoever holds `admissions.outcomes` in the department invites admitted applicants, then the - * declared AccessSpec, at one transaction instant. - */ -const authorize = ( - request: Request, - endpoint: typeof ReadOnboardingEndpoint | typeof CommandOnboardingEndpoint, - departmentId: typeof OnboardingScope.Type.departmentId, - presentation: CredentialPresentation, - now?: () => string, -) => - Effect.gen(function* () { - const auth = yield* resolveRequestPersonAuthorityInTransaction(request, { now }); - - if (!reaches(auth.authority, "admissions.outcomes", ReachTarget.Department({ departmentId }))) - return yield* Problem.make("authority.denied"); - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(endpoint)), - credential: auth.credential, - personId: auth.authority.personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "organization", - departmentId, - authorityVersion: auth.authorizationInstant, - }), - ], - }, - grantScopes: [Scope.Department({ departmentId })], - now: auth.authorizationInstant, - }, - presentation, - ); - - return auth; - }); - -/** - * The one principal of an existing-account claim: a Person credential that the claim's - * AccessSpec accepts. The AccessSpec accepts the browser session and no bearer. The token is - * checked and consumed afterwards, by claimOnboarding. - */ -const claimingPerson = (request: Request, now?: () => string) => - Effect.gen(function* () { - const auth = yield* resolveRequestPersonAuthorityInTransaction(request, { now }); - - if ( - !credentialMatchesAccessSpec( - Option.getOrThrow(reflectAccessSpec(ClaimOnboardingEndpoint)), - auth.credential, - ) - ) - return yield* new UnauthenticatedActor({ message: "authentication required" }); - - return auth.authority.personId; - }); - -export const OnboardingApiHandlers = (input: { - now?: () => string; - delivery?: OnboardingDeliveryConfig; -}) => { - const now = currentInstant(input.now); - - const read = (request: Request) => { - const presentation = personPresentation(request); - - return Database.use((sql) => - sql.withTransaction( - Effect.gen(function* () { - const scope = yield* scopeOf(request); - yield* authorize( - request, - ReadOnboardingEndpoint, - scope.departmentId, - presentation, - input.now, - ); - const board = yield* readOnboardingBoard(scope.departmentId); - - return json(yield* boardOutput(resource(board))); - }), - ), - ).pipe(onboardingProblems(presentation)); - }; - - const command = (request: Request) => - Effect.gen(function* () { - const body = yield* readJsonBody(request, JSON_MEDIA_TYPE, MAX_BODY_BYTES); - const selected = yield* decodeRequest(OnboardingCommand)(body); - const scope = yield* scopeOf(request); - const ifMatch = yield* requiredIfMatchOf(request); - const idempotencyKey = yield* idempotencyKeyOf(request); - const presentation = personPresentation(request); - - const token = - "onboard_" + Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"); - - const digest = yield* tokenDigest(token); - - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const auth = yield* authorize( - request, - CommandOnboardingEndpoint, - scope.departmentId, - presentation, - input.now, - ); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${auth.authority.personId}`, - qualifiedOperationId: "onboarding.command", - normalizedTarget: normalizeTarget("/api/onboarding/{departmentId}", scope), - idempotencyKey, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest(semanticMutationRequest(body, ifMatch)), - operationId: "onboarding.command", - }, - execute: Effect.gen(function* () { - const app = yield* onboardingApplication(selected.applicationId, scope.departmentId); - yield* lockOnboardingApplicant(app.applicantId); - const current = resource(yield* readOnboardingBoard(scope.departmentId)); - yield* requireCurrentETag(current.etag, ifMatch); - yield* commandOnboarding({ - departmentId: scope.departmentId, - command: selected, - actor: auth.authority.personId, - now: auth.authorizationInstant, - invitationId: "onboarding-" + identity.identitySha256, - token, - digest, - }); - - const changed = yield* boardOutput( - resource(yield* readOnboardingBoard(scope.departmentId)), - ); - - return yield* Effect.promise(() => responseCapsule(json(changed, changed.etag))); - }), - }; - }), - ).pipe(onboardingProblems(presentation), commandReceiptProblems); - - const response = yield* commandOutcomeResponse(outcome); - - if (selected.action !== "Revoke") - yield* drainOnboardingDelivery(selected.applicationId, input.delivery).pipe( - Effect.catchTag("SchemaError", Effect.die), - onboardingProblems(presentation), - ); - - return response; - }); - - // The session is the claim's one Person credential, so a credential problem challenges for it. - const claim = (request: Request) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - const body = yield* decodeRequest(OnboardingClaim)( - yield* readJsonBody(request, JSON_MEDIA_TYPE, MAX_BODY_BYTES), - ); - - const digest = yield* tokenDigest(body.token); - - // A new-account claim presents one credential: its token. An existing-account claim has one - // principal, the browser session's Person. Its token is then a single-use requirement bound to - // one invitation, which claimOnboarding checks and consumes after the Person is resolved. - if (body.mode === "NewAccount") { - if ( - headerCredentialCount( - request.headers.get("cookie"), - request.headers.get("authorization"), - ) > 0 - ) - return yield* new UnauthenticatedActor({ message: "authentication required" }); - - yield* checkOnboardingClaim(digest, yield* now); - } - - const newAccount = - body.mode === "NewAccount" - ? { - mode: "NewAccount" as const, - personId: PersonId.make( - yield* Crypto.Crypto.use((crypto) => crypto.randomUUIDv4).pipe(Effect.orDie), - ), - passwordHash: yield* hashOnboardingPassword(body.password).pipe(Effect.orDie), - } - : null; - - const result = yield* Database.use((sql) => - sql.withTransaction( - Effect.gen(function* () { - const identity = newAccount ?? { - mode: "ExistingAccount" as const, - personId: yield* claimingPerson(request, input.now), - }; - - return yield* claimOnboarding({ - digest, - now: yield* now, - identity, - provision: provisionOnboardingAccount, - }); - }), - ), - ); - - return json(result); - }).pipe(onboardingProblems(personPresentation(request, nativeCookieChallenge))); - - return HttpApiBuilder.group(ExternalNativeApi, "onboarding", (handlers) => - Effect.succeed( - handlers - .handleRaw("readBoard", ({ request }) => webHandler(request, read)) - .handleRaw("command", ({ request }) => webHandler(request, command)) - .handleRaw("claim", ({ request }) => webHandler(request, claim)), - ), - ); -}; diff --git a/apps/backend/src/onboarding/rpc.ts b/apps/backend/src/onboarding/rpc.ts new file mode 100644 index 000000000..9d2c3902f --- /dev/null +++ b/apps/backend/src/onboarding/rpc.ts @@ -0,0 +1,401 @@ +/** + * The OnboardingRpcs handlers. A coordinator, who holds `admissions.outcomes` in the department, + * reads the department's onboarding board and invites or revokes, with the authority resolved in + * the transaction that reads or commits. A command stores its answer as a command receipt, so a + * retry with the same idempotency key replays it. A claim is authorized by its token: in + * new-account mode the token is the one credential, in existing-account mode the browser session + * names the Person and the token is its requirement. + */ +import { + Database, + hashOnboardingPassword, + provisionOnboardingAccount, +} from "@vektorprogrammet/database"; +import { + checkOnboardingClaim, + claimOnboarding, + commandOnboarding, + lockOnboardingApplicant, + onboardingApplication, + readOnboardingBoard, +} from "@vektorprogrammet/database/onboarding"; +import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import { + credentialMatchesAccessSpec, + requireDepartmentReach, + Scope, +} from "@vektorprogrammet/domain/authz"; +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import { + OnboardingCommand, + OnboardingClaim, + type OnboardingFailure, +} from "@vektorprogrammet/domain/onboarding"; +import { + type DepartmentId, + type OrganizationDecodeError, + type OrganizationPersistenceError, + PersonId, +} from "@vektorprogrammet/domain/organization"; +import { + ClaimOnboarding, + CommandOnboarding, + OnboardingResource, + OnboardingRpcs, + ReadOnboardingBoard, + reflectAccessSpec, +} from "@vektorprogrammet/rpc"; +import { + type CredentialPresentation, + type IdempotencyKey, + nativeCookieChallenge, + Problem, + type StrongETag, +} from "@vektorprogrammet/rpc/problem"; +import { Crypto, Effect, Option, Predicate, Schema } from "effect"; +import type { Headers } from "effect/unstable/http"; +import type { Rpc } from "effect/unstable/rpc"; +import type { SqlError } from "effect/unstable/sql/SqlError"; +import { + currentInstant, + headerCredentialCount, + resolveRequestPersonAuthorityInTransaction, +} from "../authority.js"; +import { + deriveStrongETag, + jsonBodyBytes, + normalizeTarget, + semanticMutationRequest, + semanticRequestDigest, +} from "../http-semantics.js"; +import { genericContext } from "../native-operation.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { + authorizePerson, + commandIdentity, + commandOutcome, + commandReceiptProblems, + isSerializationConflict, + jsonText, + personPresentation, + problemMapper, + requireCurrentETag, +} from "../rpc/problem.js"; +import { + executeNativeHttpCommandPostgres, + type NativeHttpResponseCapsule, +} from "../rpc/receipt-transaction.js"; +import { drainOnboardingDelivery } from "./delivery.js"; + +/** The SQLSTATE of the first coded failure in a cause chain. */ +const sqlState = (cause: unknown, depth = 0): string | undefined => { + if (depth >= 8 || !Predicate.isObjectOrArray(cause)) return undefined; + + if (Predicate.hasProperty(cause, "code") && Predicate.isString(cause.code)) return cause.code; + + return Predicate.hasProperty(cause, "cause") ? sqlState(cause.cause, depth + 1) : undefined; +}; + +/** + * A failed statement. A unique violation means a racing claim provisioned the same account first, + * so the claimant must sign in to it. + */ +const sqlProblem = (cause: SqlError | OrganizationPersistenceError) => + sqlState(cause) === "23505" + ? Problem.make("onboarding.sign-in-required") + : isSerializationConflict(cause) + ? Problem.make("transaction.conflict") + : Problem.make("internal.error"); + +/** + * The one answer for every onboarding failure. A person credential rejected inside the + * transaction is answered from the request's own evidence. + */ +const onboardingProblems = (presentation: CredentialPresentation) => + problemMapper< + | OnboardingFailure + | UnauthenticatedActor + | IdentityEngineError + | OrganizationDecodeError + | OrganizationPersistenceError + | SqlError + >()({ + OnboardingFailure: ({ code }) => Problem.make(code), + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + IdentityEngineError: () => Problem.make("internal.error"), + OrganizationDecodeError: () => Problem.make("internal.error"), + OrganizationPersistenceError: sqlProblem, + SqlError: sqlProblem, + }); + +/** Hex SHA-256 of a claim token, the only form the database stores. */ +const tokenDigest = (token: string) => + Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(token))).pipe( + Effect.map((digest) => Buffer.from(digest).toString("hex")), + ); + +/** + * The board beside its entity tag, which covers the whole board: any change to it changes the tag. + * Board rows reach the schema unchecked, so a row outside it is a defect, not a request error. + */ +const boardResource = (board: { + readonly departmentId: DepartmentId; + readonly items: ReadonlyArray; +}) => + Effect.gen(function* () { + const text = yield* jsonText(board); + + return yield* Schema.decodeUnknownEffect(OnboardingResource)( + { + ...board, + etag: deriveStrongETag({ + representationKind: "OnboardingSnapshot", + resourceIdentity: text, + version: text, + }), + }, + { onExcessProperty: "error" }, + ).pipe(Effect.orDie); + }); + +/** + * The receipt of an onboarding command, byte for byte what the HTTP contract stored: the resource + * as JSON, and its entity tag as the `etag` header. + */ +const resourceCapsule = (resource: OnboardingResource) => + Schema.encodeEffect(Schema.toCodecJson(OnboardingResource))(resource).pipe( + Effect.orDie, + Effect.map( + (body): NativeHttpResponseCapsule => ({ + status: 200, + mediaType: "application/json", + headers: { "content-type": "application/json", etag: resource.etag }, + bodyBytes: jsonBodyBytes(body), + }), + ), + ); + +/** + * Whoever holds `admissions.outcomes` in the department invites admitted applicants, then the + * declared AccessSpec, at one transaction instant. The reach is the command's evidence. + */ +const authorize = (input: { + readonly headers: Headers.Headers; + readonly rpc: Pick; + readonly departmentId: DepartmentId; + readonly now: (() => string) | undefined; +}) => + Effect.gen(function* () { + const auth = yield* resolveRequestPersonAuthorityInTransaction( + credentialRequestOf(input.headers), + { now: input.now }, + ); + + const coordinator = yield* Effect.fromResult( + requireDepartmentReach(auth.authority, "admissions.outcomes", input.departmentId), + ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); + + yield* authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(input.rpc)), + credential: auth.credential, + personId: auth.authority.personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "organization", + departmentId: input.departmentId, + authorityVersion: auth.authorizationInstant, + }), + ], + }, + grantScopes: [Scope.Department({ departmentId: input.departmentId })], + now: auth.authorizationInstant, + }, + personPresentation(input.headers), + ); + + return { ...auth, coordinator }; + }); + +/** + * The one principal of an existing-account claim: a Person credential that the claim's AccessSpec + * accepts. The AccessSpec accepts the browser session and no bearer. The token is checked and + * consumed afterwards, by claimOnboarding. + */ +const claimingPerson = (headers: Headers.Headers, now: (() => string) | undefined) => + Effect.gen(function* () { + const auth = yield* resolveRequestPersonAuthorityInTransaction(credentialRequestOf(headers), { + now, + }); + + if ( + !credentialMatchesAccessSpec( + Option.getOrThrow(reflectAccessSpec(ClaimOnboarding)), + auth.credential, + ) + ) + return yield* UnauthenticatedActor.make({ message: "authentication required" }); + + return auth.authority.personId; + }); + +const OnboardingCommandJson = Schema.toCodecJson(OnboardingCommand); + +const readBoard = ( + headers: Headers.Headers, + departmentId: DepartmentId, + options: NativeRpcOptions, +) => + Database.use((sql) => + sql.withTransaction( + Effect.gen(function* () { + yield* authorize({ headers, rpc: ReadOnboardingBoard, departmentId, now: options.now }); + + return yield* boardResource(yield* readOnboardingBoard(departmentId)); + }), + ), + ).pipe(onboardingProblems(personPresentation(headers))); + +const command = ( + headers: Headers.Headers, + payload: { + readonly departmentId: DepartmentId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; + readonly request: OnboardingCommand; + }, + options: NativeRpcOptions, +) => + Effect.gen(function* () { + const { departmentId, idempotencyKey, ifMatch, request: selected } = payload; + const presentation = personPresentation(headers); + + // The HTTP contract digested the command's JSON body. + const body = yield* Schema.encodeEffect(OnboardingCommandJson)(selected).pipe(Effect.orDie); + + // The HTTP route of the department stays the normalized target, so receipts are stable. + const normalizedTarget = yield* Effect.sync(() => + normalizeTarget("/api/onboarding/{departmentId}", { departmentId }), + ); + + const token = + "onboard_" + Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"); + + const digest = yield* tokenDigest(token); + + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const auth = yield* authorize({ + headers, + rpc: CommandOnboarding, + departmentId, + now: options.now, + }); + + const identity = yield* commandIdentity({ + credentialSubject: `Person:${auth.authority.personId}`, + qualifiedOperationId: "onboarding.command", + normalizedTarget, + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest(semanticMutationRequest(body, ifMatch)), + operationId: "onboarding.command", + }, + execute: Effect.gen(function* () { + const app = yield* onboardingApplication(selected.applicationId, departmentId); + yield* lockOnboardingApplicant(app.applicantId); + const current = yield* boardResource(yield* readOnboardingBoard(departmentId)); + yield* requireCurrentETag(current.etag, ifMatch); + yield* commandOnboarding({ + coordinator: auth.coordinator, + command: selected, + now: auth.authorizationInstant, + invitationId: "onboarding-" + identity.identitySha256, + token, + digest, + }); + + const changed = yield* boardResource(yield* readOnboardingBoard(departmentId)); + + return yield* resourceCapsule(changed); + }), + }; + }), + ).pipe(onboardingProblems(presentation), commandReceiptProblems); + + const resource = yield* commandOutcome(OnboardingResource)(outcome); + + if (selected.action !== "Revoke") + yield* drainOnboardingDelivery(selected.applicationId, options.config.onboarding).pipe( + Effect.catchTag("SchemaError", Effect.die), + onboardingProblems(presentation), + ); + + return resource; + }); + +// The session is the claim's one Person credential, so a credential problem challenges for it. +const claim = ( + headers: Headers.Headers, + payload: typeof OnboardingClaim.Type, + options: NativeRpcOptions, +) => + Effect.gen(function* () { + const now = currentInstant(options.now); + const digest = yield* tokenDigest(payload.token); + + // A new-account claim presents one credential: its token. An existing-account claim has one + // principal, the browser session's Person. Its token is then a single-use requirement bound to + // one invitation, which claimOnboarding checks and consumes after the Person is resolved. + if (payload.mode === "NewAccount") { + if (headerCredentialCount(headers.cookie, headers.authorization) > 0) + return yield* UnauthenticatedActor.make({ message: "authentication required" }); + + yield* checkOnboardingClaim(digest, yield* now); + } + + const newAccount = + payload.mode === "NewAccount" + ? { + mode: "NewAccount" as const, + personId: PersonId.make( + yield* Crypto.Crypto.use((crypto) => crypto.randomUUIDv4).pipe(Effect.orDie), + ), + passwordHash: yield* hashOnboardingPassword(payload.password).pipe(Effect.orDie), + } + : null; + + return yield* Database.use((sql) => + sql.withTransaction( + Effect.gen(function* () { + const identity = newAccount ?? { + mode: "ExistingAccount" as const, + personId: yield* claimingPerson(headers, options.now), + }; + + return yield* claimOnboarding({ + digest, + now: yield* now, + identity, + provision: provisionOnboardingAccount, + }); + }), + ), + ); + }).pipe(onboardingProblems(personPresentation(headers, nativeCookieChallenge))); + +/** The OnboardingRpcs handlers. */ +export const OnboardingRpcHandlers = (options: NativeRpcOptions) => + OnboardingRpcs.toLayer({ + "onboarding.readBoard": ({ departmentId }, { headers }) => + readBoard(headers, departmentId, options), + "onboarding.command": (payload, { headers }) => command(headers, payload, options), + "onboarding.claim": (payload, { headers }) => claim(headers, payload, options), + }); diff --git a/apps/backend/src/organization/AGENTS.md b/apps/backend/src/organization/AGENTS.md index 9461ee6dd..4e6bd4ee4 100644 --- a/apps/backend/src/organization/AGENTS.md +++ b/apps/backend/src/organization/AGENTS.md @@ -3,7 +3,7 @@ # apps/backend/src/organization This folder holds the Organization bounded context in `apps/backend/src`. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The Organization context in other folders: @@ -62,7 +62,7 @@ Responsibilities: | Context | Relationship | Exposes | Integration | | ------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [TeamApplications](../team-application/AGENTS.md) | open host service, published language, anticorruption layer | `Team`, `Department` | Team and department activity plus mailbox, translated into TeamIntakeFacts; the intake itself is TeamApplications' own, and TeamApplications never writes Organization state | -| [Schools](../schools/AGENTS.md) | open host service, published language, conformist | `Department` | | +| Schools | open host service, published language, conformist | `Department` | | | [Admissions](../admission/AGENTS.md) | open host service, published language, conformist | `Department`, `FieldOfStudy` | | | [Recruitment](../recruitment/AGENTS.md) | open host service, published language, conformist | `Department` | | | [Placements](../placements/AGENTS.md) | open host service, published language, conformist | `Department` | | diff --git a/apps/backend/src/organization/http.test.ts b/apps/backend/src/organization/http.test.ts deleted file mode 100644 index 69624a348..000000000 --- a/apps/backend/src/organization/http.test.ts +++ /dev/null @@ -1,601 +0,0 @@ -import { backendDatabase } from "../../test/database.js"; -import { IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { - Identity, - IdentityActor, - IdentitySessionNotFound, - type IdentityOperations, -} from "@vektorprogrammet/domain/identity"; -import { - Department, - DepartmentJsonSchema, - FieldOfStudy, - FieldOfStudyJsonSchema, - Organization, - PersonId, - Team, - TeamJsonSchema, - type OrganizationOperations, - OrganizationCommandId, - DepartmentCreatedObservationSchema, - TeamCreatedObservationSchema, - FieldOfStudyCreatedObservationSchema, - OrganizationActorSchema, - OrganizationRoleDenied, - OrganizationCommandConflict, - OrganizationPersistenceError, - OrganizationInvalidReference, -} from "@vektorprogrammet/domain/organization"; -import { makeNativeValidationError } from "@vektorprogrammet/http-api/http-semantics"; -import { Predicate, DateTime, Effect, Layer, Schema } from "effect"; -import { describe, expect, it } from "@effect/vitest"; -import { decodeOrganizationApiConfig } from "./config.js"; -import { jsonText } from "../http-api/problem.js"; -import { makeOrganizationTestHttp as makeOrganizationApiHttp } from "../test/native-http.js"; - -const ADMIN_SESSION = "organization-admin-session"; - -const MEMBER_SESSION = "organization-member-session"; - -const config = decodeOrganizationApiConfig({ - ORGANIZATION_MAX_BODY_BYTES: "1024", -}); - -const department = Schema.decodeSync(DepartmentJsonSchema)( - { - departmentId: "department-created", - name: "Vektorprogrammet Trondheim", - shortName: "Trondheim", - email: "trondheim@example.invalid", - address: "Høgskoleringen 1", - city: "Trondheim", - latitude: "63.4195", - longitude: "10.4021", - slackChannel: null, - logoPath: null, - active: true, - revision: 0, - }, - { onExcessProperty: "error" }, -); - -const team = Schema.decodeSync(TeamJsonSchema)( - { - teamId: "team-created", - departmentId: department.departmentId, - name: "Rekruttering", - email: null, - description: "Rekrutterer studenter", - shortDescription: null, - acceptApplication: true, - deadline: null, - active: true, - revision: 0, - }, - { onExcessProperty: "error" }, -); - -const fieldOfStudy = Schema.decodeSync(FieldOfStudyJsonSchema)( - { - fieldOfStudyId: "field-created", - name: "Datateknologi", - shortName: "Data", - departmentId: null, - active: true, - revision: 0, - }, - { onExcessProperty: "error" }, -); - -const createDepartmentRequest = { - name: department.name, - shortName: department.shortName, - email: department.email, - address: department.address, - city: department.city, - latitude: department.latitude, - longitude: department.longitude, -} as const; - -const createTeamRequest = { - departmentId: department.departmentId, - name: team.name, - email: team.email, - description: team.description, - shortDescription: team.shortDescription, - acceptApplication: team.acceptApplication, - deadline: team.deadline, - active: team.active, -} as const; - -const createFieldOfStudyRequest = { - name: fieldOfStudy.name, - shortName: fieldOfStudy.shortName, - departmentId: fieldOfStudy.departmentId, -} as const; - -// Like the PostgreSQL layer, the doubles answer with the model instance that they read back. -// Canonical JSON refuses a model instance, so the response must come from the contract schema. -const departmentResult = (commandId: OrganizationCommandId) => ({ - committed: true as const, - observation: { - ...DepartmentCreatedObservationSchema.make({ commandId, department }), - department: Schema.decodeSync(Department)(department), - }, -}); - -let publicListCalls = 0; - -let createCalls = 0; - -const organization = { - listDepartments: Effect.sync(() => { - publicListCalls += 1; - - return [department]; - }), - listTeams: () => Effect.succeed([team]), - listFieldOfStudies: Effect.succeed([fieldOfStudy]), - resolvePersonAuthority: (personId: PersonId, evaluatedAt: string) => - Effect.succeed({ - personId, - evaluatedAt, - globalAdministrator: personId === "person-admin" ? "Active" : "Absent", - memberships: [], - nationalBoardSeats: [], - delegations: [], - }), - createDepartment: ( - command: Parameters[0], - actor: Parameters[1], - ) => { - createCalls += 1; - - if (Predicate.isTagged(actor, "OrganizationMember")) { - return Effect.fail( - new OrganizationRoleDenied({ - actorPersonId: actor.personId, - requiredRole: "OrganizationAdministrator", - }), - ); - } - - if (command.name === "Conflict") { - return Effect.fail(new OrganizationCommandConflict({ commandId: command.commandId })); - } - - if (command.name === "Unavailable") { - return Effect.fail( - new OrganizationPersistenceError({ - operation: "createDepartment", - message: "database unavailable", - }), - ); - } - - return Effect.succeed(departmentResult(command.commandId)); - }, - createTeam: (command: Parameters[0]) => { - createCalls += 1; - - if (command.departmentId === "department-unknown") { - return Effect.fail(new OrganizationInvalidReference({ referenceKind: "Department" })); - } - - return Effect.succeed({ - committed: true as const, - observation: { - ...TeamCreatedObservationSchema.make({ commandId: command.commandId, team }), - team: Schema.decodeSync(Team)(team), - }, - }); - }, - createFieldOfStudy: (command: Parameters[0]) => - Effect.succeed({ - committed: true as const, - observation: { - ...FieldOfStudyCreatedObservationSchema.make({ - commandId: command.commandId, - fieldOfStudy, - }), - fieldOfStudy: Schema.decodeSync(FieldOfStudy)(fieldOfStudy), - }, - }), -} satisfies Partial; - -const database = backendDatabase(); - -const identitySnapshot = IdentitySnapshot.of({ - resolveSession: (cookieHeader) => - Effect.suspend(() => { - const personId = cookieHeader?.includes(ADMIN_SESSION) - ? PersonId.make("person-admin") - : cookieHeader?.includes(MEMBER_SESSION) - ? PersonId.make("person-member") - : undefined; - - return personId === undefined - ? Effect.fail(new IdentitySessionNotFound()) - : Effect.succeed( - new IdentityActor({ - personId, - sessionId: "organization-http-session", - expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), - }), - ); - }), - revokeCurrentSession: () => Effect.succeed({ setCookies: [] }), - revokeSession: () => Effect.succeed({ setCookies: [] }), - revokeOtherSessions: () => Effect.succeed({ setCookies: [] }), - revokeAllSessions: () => Effect.succeed({ setCookies: [] }), -}); - -const oauthCredentialAuthority = OAuthCredentialAuthority.of({ - resolve: () => Effect.die("unexpected OAuth credential resolution"), - resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), -}); - -const identity = Identity.of({ - signIn: () => Effect.die("unexpected sign-in"), - resolveSession: (cookieHeader: string | undefined) => { - const personId = cookieHeader?.includes(ADMIN_SESSION) - ? PersonId.make("person-admin") - : cookieHeader?.includes(MEMBER_SESSION) - ? PersonId.make("person-member") - : undefined; - - return personId === undefined - ? Effect.fail(new IdentitySessionNotFound()) - : Effect.succeed( - new IdentityActor({ - personId, - sessionId: "organization-http-session", - expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), - }), - ); - }, - readCurrentSession: () => Effect.die("unexpected session read"), - listSessions: () => Effect.die("unexpected session list"), - revokeCurrentSession: () => Effect.die("unexpected session mutation"), - revokeSession: () => Effect.die("unexpected session mutation"), - revokeOtherSessions: () => Effect.die("unexpected session mutation"), - revokeAllSessions: () => Effect.die("unexpected session mutation"), - recordSecurityEvent: () => Effect.die("unexpected identity audit"), - signOut: () => Effect.succeed({ setCookies: [] }), -} satisfies IdentityOperations); - -const services = Layer.mergeAll( - database.layer, - Layer.succeed(IdentitySnapshot, identitySnapshot), - Layer.mock(Organization, organization), - Layer.succeed(Identity, identity), - Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), -); - -const http = makeOrganizationApiHttp( - { - config, - resolveActor: (request) => { - const cookieHeader = request.headers.get("cookie"); - - if (cookieHeader === null) { - return Effect.fail(new UnauthenticatedActor({ message: "authentication required" })); - } - - return Effect.succeed( - cookieHeader.includes(`better-auth.session_token=${ADMIN_SESSION}`) - ? OrganizationActorSchema.members[0].make({ personId: PersonId.make("person-admin") }) - : OrganizationActorSchema.members[1].make({ personId: PersonId.make("person-member") }), - ); - }, - resolveAuthority: () => - Effect.succeed({ - personId: PersonId.make("person-admin"), - evaluatedAt: "2031-09-15T12:00:00.000Z", - globalAdministrator: "Active", - memberships: [], - nationalBoardSeats: [], - delegations: [], - }), - }, - services, -); - -const request = (pathname: string, init?: RequestInit): Effect.Effect => - http.fetch(new Request(`http://backend.test${pathname}`, init)); - -const post = ( - pathname: string, - session: string, - body: Schema.Json, - idempotencyKey: string, - contentType = "application/json", -): Effect.Effect => - request(pathname, { - method: "POST", - headers: { - cookie: `better-auth.session_token=${session}`, - "content-type": contentType, - "idempotency-key": idempotencyKey, - origin: "http://127.0.0.1:5174", - }, - body: JSON.stringify(body), - }); - -const responseBody = (response: Response) => - Effect.gen(function* () { - return { - status: response.status, - body: yield* Effect.promise(() => response.json()), - }; - }); - -const expectedProblem = (code: string, title: string, status: number, detail: string) => ({ - type: `urn:vektorprogrammet:problem:v0.2:${code}`, - title, - status, - detail, - code, -}); - -const requestValidationProblem = { - ...expectedProblem( - "validation.failed", - "Validation failed", - 422, - "The request contains invalid semantic values.", - ), - validation: { errors: [makeNativeValidationError("", "invalid")], truncated: false }, -}; - -describe("Organization HTTP boundary", () => { - it.live("returns only canonical Organization JSON projections from all public routes", () => - Effect.gen(function* () { - const [departments, teams, fields] = yield* Effect.all( - [request("/api/departments"), request("/api/teams"), request("/api/field-of-studies")], - { concurrency: "unbounded" }, - ); - - expect(yield* responseBody(departments)).toEqual({ status: 200, body: [department] }); - expect(yield* responseBody(teams)).toEqual({ status: 200, body: [team] }); - expect(yield* responseBody(fields)).toEqual({ status: 200, body: [fieldOfStudy] }); - - const serialized = yield* jsonText( - yield* Effect.forEach( - ["/api/departments", "/api/teams", "/api/field-of-studies"], - (pathname) => - request(pathname).pipe( - Effect.flatMap(responseBody), - Effect.map(({ body }) => body), - ), - ), - ); - - for (const forbidden of ["personId", "membership", "commandId", "audit", "actorsByToken"]) { - expect(serialized).not.toContain(forbidden); - } - }), - ); - - it.live("returns canonical resources for committed and replayed generated operations", () => - Effect.gen(function* () { - const departmentKey = "department-create-key-0001"; - - const created = yield* post( - "/api/departments", - ADMIN_SESSION, - createDepartmentRequest, - departmentKey, - ); - - const createdTeam = yield* post( - "/api/teams", - ADMIN_SESSION, - createTeamRequest, - "team-create-key-00000001", - ); - - const createdField = yield* post( - "/api/field-of-studies", - ADMIN_SESSION, - createFieldOfStudyRequest, - "field-create-key-0000001", - ); - - const replayed = yield* post( - "/api/departments", - ADMIN_SESSION, - createDepartmentRequest, - departmentKey, - ); - - expect(yield* responseBody(created)).toEqual({ status: 201, body: department }); - expect(created.headers.get("location")).toBe("/api/departments/department-created"); - expect(created.headers.get("etag")).toMatch(/^"vkr2\.[A-Za-z0-9_-]{43}"$/u); - expect(yield* responseBody(createdTeam)).toEqual({ status: 201, body: team }); - expect(yield* responseBody(createdField)).toEqual({ status: 201, body: fieldOfStudy }); - expect(yield* responseBody(replayed)).toEqual({ status: 201, body: department }); - }), - ); - - it.live("maps authority, reference, conflict, and dependency failures to RFC 9457", () => - Effect.gen(function* () { - const denied = yield* post( - "/api/departments", - MEMBER_SESSION, - createDepartmentRequest, - "department-denied-key-0001", - ); - - const invalidReference = yield* post( - "/api/teams", - ADMIN_SESSION, - { ...createTeamRequest, departmentId: "department-unknown" }, - "team-invalid-ref-key-00001", - ); - - const conflict = yield* post( - "/api/departments", - ADMIN_SESSION, - { ...createDepartmentRequest, name: "Conflict" }, - "department-conflict-key-01", - ); - - const unavailable = yield* post( - "/api/departments", - ADMIN_SESSION, - { ...createDepartmentRequest, name: "Unavailable" }, - "department-unavailable-001", - ); - - expect(yield* responseBody(denied)).toEqual({ - status: 403, - body: expectedProblem( - "authority.denied", - "Authority denied", - 403, - "The authenticated principal is not permitted to perform this operation.", - ), - }); - expect(yield* responseBody(invalidReference)).toEqual({ - status: 422, - body: expectedProblem( - "organization.invalid-reference", - "Invalid organization reference", - 422, - "An organization reference is invalid.", - ), - }); - expect(yield* responseBody(conflict)).toEqual({ - status: 409, - body: expectedProblem( - "idempotency.digest-conflict", - "Idempotency conflict", - 409, - "This idempotency key identifies a different semantic request.", - ), - }); - expect(yield* responseBody(unavailable)).toEqual({ - status: 503, - body: expectedProblem( - "organization.unavailable", - "Organization unavailable", - 503, - "The organization service is temporarily unavailable.", - ), - }); - }), - ); - - it.live("rejects malformed JSON, wrong content type, excess fields, and oversized bodies", () => - Effect.gen(function* () { - const before = createCalls; - - const malformed = yield* request("/api/departments", { - method: "POST", - headers: { - cookie: `better-auth.session_token=${ADMIN_SESSION}`, - "content-type": "application/json", - "idempotency-key": "department-malformed-key-01", - origin: "http://127.0.0.1:5174", - }, - body: "{", - }); - - const wrongContentType = yield* post( - "/api/departments", - ADMIN_SESSION, - createDepartmentRequest, - "department-media-key-0001", - "text/plain", - ); - - const excess = yield* post( - "/api/departments", - ADMIN_SESSION, - { ...createDepartmentRequest, actorRole: "OrganizationAdministrator" }, - "department-excess-key-001", - ); - - const oversized = yield* post( - "/api/departments", - ADMIN_SESSION, - { ...createDepartmentRequest, name: "x".repeat(2_000) }, - "department-oversize-key-01", - ); - - for (const response of [malformed, wrongContentType, excess]) { - expect(yield* responseBody(response)).toEqual({ - status: 422, - body: requestValidationProblem, - }); - } - - expect(yield* responseBody(oversized)).toEqual({ - status: 413, - body: expectedProblem( - "request.too-large", - "Request too large", - 413, - "The request body exceeds the permitted size.", - ), - }); - expect(createCalls).toBe(before); - }), - ); - - it.live( - "rejects public query strings before reading Organization and uses exact credentialed preflight origins", - () => - Effect.gen(function* () { - const before = publicListCalls; - const queried = yield* request("/api/departments?active=true"); - - const preflight = yield* request("/api/departments", { - method: "OPTIONS", - headers: { - origin: "http://127.0.0.1:5174", - "access-control-request-method": "GET", - }, - }); - - expect(yield* responseBody(queried)).toEqual({ - status: 400, - body: expectedProblem( - "request.malformed", - "Malformed request", - 400, - "The request is malformed.", - ), - }); - expect(publicListCalls).toBe(before); - expect(preflight.status).toBe(204); - expect(preflight.headers.get("access-control-allow-origin")).toBe("http://127.0.0.1:5174"); - expect(preflight.headers.get("access-control-allow-credentials")).toBe("true"); - }), - ); - - it.live("fails closed with an RFC 9457 credential problem", () => - Effect.gen(function* () { - const anonymous = yield* request("/api/departments", { - method: "POST", - headers: { - "content-type": "application/json", - "idempotency-key": "anonymous-department-key-01", - }, - body: yield* jsonText(createDepartmentRequest), - }); - - expect(yield* responseBody(anonymous)).toEqual({ - status: 401, - body: expectedProblem( - "credential.missing", - "Credential required", - 401, - "A credential is required for this operation.", - ), - }); - }), - ); -}); diff --git a/apps/backend/src/organization/http.ts b/apps/backend/src/organization/http.ts deleted file mode 100644 index ee61f7d43..000000000 --- a/apps/backend/src/organization/http.ts +++ /dev/null @@ -1,1254 +0,0 @@ -import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { - reachedDepartments, - ReachedDepartments, - reachedTeams, - ResourceId, - ResourceKind, - Scope, -} from "@vektorprogrammet/domain/authz"; -import type { OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { - AppointmentManagement, - BoardRosters, - CreateDepartmentCommandSchema, - CreateFieldOfStudyCommandSchema, - CreateTeamCommandSchema, - DepartmentId, - DepartmentJsonSchema, - FieldOfStudyJsonSchema, - Organization, - OrganizationCommandId, - OrganizationLifecycleCommand, - SemesterId, - type TeamId, - TeamJsonSchema, - type OrganizationActor, - type OrganizationCommandFailure, - type OrganizationLifecycleFailure, - type OrganizationPersonAuthority, - type TeamInterestFilter, -} from "@vektorprogrammet/domain/organization"; -import type { Identity, IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import type { ProfileFailure } from "@vektorprogrammet/domain/profile"; -import { - CreateDepartmentEndpoint, - CreateDepartmentRequest, - CreateFieldOfStudyEndpoint, - CreateFieldOfStudyRequest, - CreateTeamEndpoint, - CreateTeamRequest, - DelegationCommand, - DelegationManagement, - ExecuteDelegationEndpoint, - ExecuteOrganizationLifecycleEndpoint, - ExternalNativeApi, - ListDepartmentsEndpoint, - ListFieldOfStudiesEndpoint, - ListMailingListsEndpoint, - ListTeamInterestEndpoint, - ListTeamsEndpoint, - MailingListResponse, - ReadAppointmentManagementEndpoint, - ReadBoardRostersEndpoint, - ReadDelegationManagementEndpoint, - reflectAccessSpec, - TeamInterestResponse, -} from "@vektorprogrammet/http-api"; -import { - type CredentialPresentation, - type IdempotencyKey, - Problem, - type StrongETag, -} from "@vektorprogrammet/http-api/http-semantics"; -import { DateTime, Effect, flow, Match, Option, Predicate, Schema } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { - organizationActorFrom, - resolveRequestCredentialInTransaction, - resolveRequestPersonAuthorityInTransaction, - type OrganizationResolutionError, -} from "../authority.js"; -import { - authorizeAnonymous, - authorizePerson, - commandOutcomeResponse, - commandReceiptProblems, - conditionalJson, - decodeRequest, - httpIdentity, - idempotencyKeyOf, - personPresentation, - problemMapper, - requestInvalid, - requireNoQuery, - unreachable, - webHandler, -} from "../http-api/problem.js"; -import { - executeNativeHttpCommandPostgres, - type NativeHttpResponseCapsule, -} from "../http-api/receipt-transaction.js"; -import { - deriveStrongETag, - encodePathIdentity, - jsonBodyBytes, - PRIVATE_NO_STORE, - PUBLIC_CACHE_CONTROL, - semanticRequestDigest, -} from "../http-semantics.js"; -import { genericContext } from "../native-operation.js"; -import type { OrganizationApiConfig } from "./config.js"; - -export interface OrganizationApiHttpOptions { - readonly config: OrganizationApiConfig; - /** Cookie -> Organization projection -> OrganizationAdministrator|Member. */ - readonly resolveActor: ( - request: Request, - ) => Effect.Effect< - OrganizationActor, - IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, - Identity | OAuthCredentialAuthority | Organization - >; - /** - * Cookie -> full 0055 authority projection for leader-scoped admin reads - * (specs 0059/0060). One captured authorizationInstant per request. - */ - readonly resolveAuthority: ( - request: Request, - ) => Effect.Effect< - OrganizationPersonAuthority, - IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, - Identity | OAuthCredentialAuthority | Organization - >; -} - -const organizationUnavailable = () => Problem.make("organization.unavailable"); - -/** - * The one answer for every organization failure. The store answers - * organization.unavailable whether it could not be reached or refused its own - * record; mailing lists read their recipients through the profile store. - */ -const organizationProblems = problemMapper< - OrganizationCommandFailure | OrganizationLifecycleFailure | ProfileFailure ->()({ - OrganizationLifecycleFailure: ({ code }) => - Match.value(code).pipe( - Match.whenOr("Denied", "SelfDisable", "LastAdministrator", () => - Problem.make("authority.denied"), - ), - Match.when("NotFound", () => Problem.make("resource.not-found")), - Match.when("Stale", () => Problem.make("precondition.failed")), - Match.when("Conflict", () => Problem.make("idempotency.digest-conflict")), - Match.when("Invalid", requestInvalid), - Match.when("Unavailable", organizationUnavailable), - Match.exhaustive, - ), - OrganizationRoleDenied: () => Problem.make("authority.denied"), - OrganizationInvalidReference: () => Problem.make("organization.invalid-reference"), - OrganizationCommandConflict: () => Problem.make("idempotency.digest-conflict"), - OrganizationDecodeError: organizationUnavailable, - OrganizationPersistenceError: organizationUnavailable, - ProfileDecodeError: organizationUnavailable, - ProfileQueryLimitExceeded: organizationUnavailable, - ProfileNotFound: organizationUnavailable, - ProfileContactNotFound: organizationUnavailable, - ProfileStaleRevision: organizationUnavailable, - ProfileCommandConflict: organizationUnavailable, - ProfilePersistenceError: organizationUnavailable, -}); - -/** - * A person credential rejected after ingress is answered from the request's - * own evidence; a failed identity engine leaves organization unavailable. - */ -const credentialProblems = (presentation: CredentialPresentation) => - problemMapper()({ - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - IdentityEngineError: organizationUnavailable, - }); - -/** - * Commands and appointment management answer any query as an invalid request. - */ -const rejectQuery = (request: Request) => - requireNoQuery(request).pipe(Effect.mapError(requestInvalid)); - -/** The UTF-8 text of a body of at most `maxBytes`, or undefined past that bound. */ -const readBoundedText = (request: Request, maxBytes: number) => { - const stream = request.body; - - if (stream === null) return Effect.succeed(""); - - return Effect.acquireUseRelease( - Effect.try({ try: () => stream.getReader(), catch: requestInvalid }), - (reader) => - Effect.gen(function* () { - const chunks: Uint8Array[] = []; - let totalBytes = 0; - - for (;;) { - const next = yield* Effect.tryPromise({ - try: () => reader.read(), - catch: requestInvalid, - }); - - if (next.done) break; - totalBytes += next.value.byteLength; - - if (totalBytes > maxBytes) { - yield* Effect.tryPromise({ try: () => reader.cancel(), catch: requestInvalid }); - - return undefined; - } - - chunks.push(next.value); - } - - const body = new Uint8Array(totalBytes); - let offset = 0; - - for (const chunk of chunks) { - body.set(chunk, offset); - offset += chunk.byteLength; - } - - return yield* Effect.try({ - try: () => new TextDecoder("utf-8", { fatal: true }).decode(body), - catch: requestInvalid, - }); - }), - (reader) => Effect.sync(() => reader.releaseLock()), - ); -}; - -/** - * Reads one bounded JSON command body. Organization commands answer every - * body they cannot read, including one of another media type, as an invalid - * request. - */ -const readCommandBody = (request: Request, maxBytes: number) => - Effect.gen(function* () { - if (!/^application\/json(?:\s*;|$)/iu.test(request.headers.get("content-type") ?? "")) { - return yield* requestInvalid(); - } - - const declared = request.headers.get("content-length"); - - if (declared !== null) { - if (!/^\d+$/u.test(declared) || !Number.isSafeInteger(Number(declared))) { - return yield* requestInvalid(); - } - - if (Number(declared) > maxBytes) return yield* Problem.make("request.too-large"); - } - - const text = yield* readBoundedText(request, maxBytes); - - if (text === undefined) return yield* Problem.make("request.too-large"); - - return yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Json))(text).pipe( - Effect.mapError(requestInvalid), - ); - }); - -/** - * Reads a response value through its contract schema. Domain layers return model instances, - * which canonical JSON refuses; the contract decode yields the plain resource. A value outside - * the contract leaves organization unavailable. - */ -const responseJson = >(schema: S) => - flow( - Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }), - Effect.mapError(organizationUnavailable), - ); - -/** - * Encodes a private read through its contract schema; no shared cache may store it. - */ -const privateReadJson = >(schema: S) => - flow( - responseJson(schema), - Effect.map( - (decoded) => - new Response(JSON.stringify(decoded), { - headers: { - "content-type": "application/json; charset=utf-8", - "cache-control": PRIVATE_NO_STORE, - }, - }), - ), - ); - -const listDepartments = (request: Request) => - Effect.gen(function* () { - yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(ListDepartmentsEndpoint)), - { - selection: "AllMatching", - contexts: [ - genericContext({ - domainId: "organization", - authorityVersion: "organization-public-departments", - }), - ], - }, - DateTime.formatIso(yield* DateTime.now), - ); - yield* requireNoQuery(request); - - const departments = yield* Organization.use(({ listDepartments }) => listDepartments).pipe( - organizationProblems, - Effect.flatMap(responseJson(Schema.Array(DepartmentJsonSchema))), - ); - - return yield* conditionalJson({ - request, - body: departments, - etag: deriveStrongETag({ - representationKind: "DepartmentListResponse", - resourceIdentity: new URL(request.url).pathname, - version: departments.map((row) => [row.departmentId, row.revision] as const), - }), - cacheControl: PUBLIC_CACHE_CONTROL, - contentType: "application/json", - }); - }); - -const listTeams = (request: Request) => - Effect.gen(function* () { - yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(ListTeamsEndpoint)), - { - selection: "AllMatching", - contexts: [ - genericContext({ - domainId: "organization", - authorityVersion: "organization-public-teams", - }), - ], - }, - DateTime.formatIso(yield* DateTime.now), - ); - yield* requireNoQuery(request); - - const teams = yield* Organization.use(({ listTeams }) => listTeams()).pipe( - organizationProblems, - Effect.flatMap(responseJson(Schema.Array(TeamJsonSchema))), - ); - - return yield* conditionalJson({ - request, - body: teams, - etag: deriveStrongETag({ - representationKind: "TeamListResponse", - resourceIdentity: new URL(request.url).pathname, - version: teams.map((row) => [row.teamId, row.revision] as const), - }), - cacheControl: PUBLIC_CACHE_CONTROL, - contentType: "application/json", - }); - }); - -const listFieldOfStudies = (request: Request) => - Effect.gen(function* () { - yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(ListFieldOfStudiesEndpoint)), - { - selection: "AllMatching", - contexts: [ - genericContext({ - domainId: "organization", - authorityVersion: "organization-public-field-of-studies", - }), - ], - }, - DateTime.formatIso(yield* DateTime.now), - ); - yield* requireNoQuery(request); - - const fieldOfStudies = yield* Organization.use( - ({ listFieldOfStudies }) => listFieldOfStudies, - ).pipe( - organizationProblems, - Effect.flatMap(responseJson(Schema.Array(FieldOfStudyJsonSchema))), - ); - - return yield* conditionalJson({ - request, - body: fieldOfStudies, - etag: deriveStrongETag({ - representationKind: "FieldOfStudyListResponse", - resourceIdentity: new URL(request.url).pathname, - version: fieldOfStudies.map((row) => [row.fieldOfStudyId, row.revision] as const), - }), - cacheControl: PUBLIC_CACHE_CONTROL, - contentType: "application/json", - }); - }); - -/** The 201 receipt of one created organization resource. */ -const createdCapsule = ( - resource: Schema.Json, - location: string, - etag: StrongETag, -): NativeHttpResponseCapsule => ({ - status: 201, - mediaType: "application/json", - headers: { "content-type": "application/json", location, etag }, - bodyBytes: jsonBodyBytes(resource), -}); - -/** - * Resolves the caller's current authority inside the command transaction, - * evaluates the create AccessSpec for it, and derives the command identity. - */ -const authorizeCreate = (input: { - readonly request: Request; - readonly endpoint: Parameters[0]; - readonly operationId: string; - readonly target: string; - readonly idempotencyKey: IdempotencyKey; - readonly presentation: CredentialPresentation; -}) => - Effect.gen(function* () { - const resolved = yield* resolveRequestPersonAuthorityInTransaction(input.request, {}); - const actor = organizationActorFrom(resolved.authority); - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(input.endpoint)), - credential: resolved.credential, - personId: actor.personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "organization", - authorityVersion: `organization:${actor._tag}`, - }), - ], - }, - grantScopes: Predicate.isTagged(actor, "OrganizationAdministrator") ? [Scope.Global()] : [], - now: resolved.authorizationInstant, - }, - input.presentation, - ); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${actor.personId}`, - qualifiedOperationId: input.operationId, - normalizedTarget: input.target, - idempotencyKey: input.idempotencyKey, - }); - - return { actor, identity }; - }); - -const createDepartment = (request: Request, input: OrganizationApiHttpOptions) => - Effect.gen(function* () { - yield* rejectQuery(request); - - const payload = yield* readCommandBody(request, input.config.maxBodyBytes).pipe( - Effect.flatMap(decodeRequest(CreateDepartmentRequest)), - ); - - const idempotencyKey = yield* idempotencyKeyOf(request); - const presentation = personPresentation(request); - const operationId = "organization.createDepartment"; - - // Domain and credential failures are mapped after the executor, with its receipt failures. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const { actor, identity } = yield* authorizeCreate({ - request, - endpoint: CreateDepartmentEndpoint, - operationId, - target: "/api/departments", - idempotencyKey, - presentation, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({ body: payload }), - operationId, - }, - execute: Organization.use((organization) => - organization.createDepartment( - CreateDepartmentCommandSchema.make({ - commandId: OrganizationCommandId.make(identity.commandId), - ...payload, - }), - actor, - ), - ).pipe( - Effect.flatMap(({ observation }) => - responseJson(DepartmentJsonSchema)( - Predicate.isTagged(observation, "Replayed") - ? observation.original.department - : observation.department, - ), - ), - Effect.map((department) => - createdCapsule( - department, - `/api/departments/${encodePathIdentity(department.departmentId)}`, - deriveStrongETag({ - representationKind: "DepartmentJson", - resourceIdentity: department.departmentId, - version: department.revision, - }), - ), - ), - ), - }; - }), - ).pipe( - organizationProblems, - commandReceiptProblems, - credentialProblems(presentation), - // A person AccessSpec reveals every denial; none is concealed as absent. - unreachable("resource.not-found"), - ); - - return yield* commandOutcomeResponse(outcome); - }); - -const createTeam = (request: Request, input: OrganizationApiHttpOptions) => - Effect.gen(function* () { - yield* rejectQuery(request); - - const payload = yield* readCommandBody(request, input.config.maxBodyBytes).pipe( - Effect.flatMap(decodeRequest(CreateTeamRequest)), - ); - - const idempotencyKey = yield* idempotencyKeyOf(request); - const presentation = personPresentation(request); - const operationId = "organization.createTeam"; - - // Domain and credential failures are mapped after the executor, with its receipt failures. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const { actor, identity } = yield* authorizeCreate({ - request, - endpoint: CreateTeamEndpoint, - operationId, - target: "/api/teams", - idempotencyKey, - presentation, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({ body: payload }), - operationId, - }, - execute: Organization.use((organization) => - organization.createTeam( - CreateTeamCommandSchema.make({ - commandId: OrganizationCommandId.make(identity.commandId), - ...payload, - }), - actor, - ), - ).pipe( - Effect.flatMap(({ observation }) => - responseJson(TeamJsonSchema)( - Predicate.isTagged(observation, "Replayed") - ? observation.original.team - : observation.team, - ), - ), - Effect.map((team) => - createdCapsule( - team, - `/api/teams/${encodePathIdentity(team.teamId)}`, - deriveStrongETag({ - representationKind: "TeamJson", - resourceIdentity: team.teamId, - version: team.revision, - }), - ), - ), - ), - }; - }), - ).pipe( - organizationProblems, - commandReceiptProblems, - credentialProblems(presentation), - // A person AccessSpec reveals every denial; none is concealed as absent. - unreachable("resource.not-found"), - ); - - return yield* commandOutcomeResponse(outcome); - }); - -const createFieldOfStudy = (request: Request, input: OrganizationApiHttpOptions) => - Effect.gen(function* () { - yield* rejectQuery(request); - - const payload = yield* readCommandBody(request, input.config.maxBodyBytes).pipe( - Effect.flatMap(decodeRequest(CreateFieldOfStudyRequest)), - ); - - const idempotencyKey = yield* idempotencyKeyOf(request); - const presentation = personPresentation(request); - const operationId = "organization.createFieldOfStudy"; - - // Domain and credential failures are mapped after the executor, with its receipt failures. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const { actor, identity } = yield* authorizeCreate({ - request, - endpoint: CreateFieldOfStudyEndpoint, - operationId, - target: "/api/field-of-studies", - idempotencyKey, - presentation, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({ body: payload }), - operationId, - }, - execute: Organization.use((organization) => - organization.createFieldOfStudy( - CreateFieldOfStudyCommandSchema.make({ - commandId: OrganizationCommandId.make(identity.commandId), - ...payload, - }), - actor, - ), - ).pipe( - Effect.flatMap(({ observation }) => - responseJson(FieldOfStudyJsonSchema)( - Predicate.isTagged(observation, "Replayed") - ? observation.original.fieldOfStudy - : observation.fieldOfStudy, - ), - ), - Effect.map((fieldOfStudy) => - createdCapsule( - fieldOfStudy, - `/api/field-of-studies/${encodePathIdentity(fieldOfStudy.fieldOfStudyId)}`, - deriveStrongETag({ - representationKind: "FieldOfStudyJson", - resourceIdentity: fieldOfStudy.fieldOfStudyId, - version: fieldOfStudy.revision, - }), - ), - ), - ), - }; - }), - ).pipe( - organizationProblems, - commandReceiptProblems, - credentialProblems(presentation), - // A person AccessSpec reveals every denial; none is concealed as absent. - unreachable("resource.not-found"), - ); - - return yield* commandOutcomeResponse(outcome); - }); - -const MailingListTypeSchema = Schema.Literals(["assistants", "team", "all"]); - -/** - * One optional query identity; a value outside its schema is a malformed request. - */ -const optionalQueryIdentity = >( - request: Request, - name: "department" | "semester", - schema: S, -): Effect.Effect> => { - const value = new URL(request.url).searchParams.get(name); - - return value === null - ? Effect.undefined - : Schema.decodeEffect(schema)(value).pipe( - Effect.mapError(() => Problem.make("request.malformed")), - ); -}; - -/** - * Spec 0059/0060 gating: the departments where the person holds the capability through a board - * leadership, a delegation or the global-administrator grant. A reach over the whole organization - * authorizes every department, also while there is none. - */ -const authorizedDepartmentScope = ( - authority: OrganizationPersonAuthority, - capability: "people.read" | "team-interest.read", -) => - Effect.gen(function* () { - const reached = reachedDepartments(authority, capability); - - if (ReachedDepartments.$is("Departments")(reached)) - return { organizationWide: false, departmentIds: reached.departmentIds }; - - const departments = yield* Organization.use(({ listDepartments }) => listDepartments); - - return { - organizationWide: true, - departmentIds: departments.map((department) => department.departmentId), - }; - }); - -/** Narrows the authorized scope; out-of-scope known department denies with 403. */ -const narrowScopeOrThrow = ( - authorized: ReadonlyArray, - departmentId: DepartmentId | undefined, -) => - departmentId === undefined - ? Effect.succeed(authorized) - : authorized.some((authorizedId) => authorizedId === departmentId) - ? Effect.succeed([departmentId]) - : Effect.fail(Problem.make("authority.denied")); - -/** Unknown department reference denies with 422 before any data leaves the store. */ -const assertDepartmentsExist = (departmentIds: ReadonlyArray) => - Organization.use(({ listDepartments }) => listDepartments).pipe( - Effect.flatMap((known) => { - for (const requested of departmentIds) { - if (!known.some((department) => department.departmentId === requested)) { - return Effect.fail(Problem.make("organization.invalid-reference")); - } - } - - return Effect.void; - }), - ); - -const authorizeOrganizationCollection = (input: { - readonly request: Request; - readonly authority: OrganizationPersonAuthority; - readonly endpoint: Parameters[0]; - readonly departmentIds: ReadonlyArray; - readonly teams?: ReadonlyArray<{ readonly teamId: TeamId; readonly departmentId: DepartmentId }>; - readonly presentation: CredentialPresentation; -}) => { - const global = input.authority.globalAdministrator === "Active"; - const teams = input.teams ?? []; - const authorityVersion = `organization:${input.authority.evaluatedAt}`; - const unscoped = global && input.departmentIds.length === 0 && teams.length === 0; - - const contexts = unscoped - ? [ - genericContext({ - domainId: "organization", - facts: { departmentAdministratorPersonIds: [input.authority.personId] }, - authorityVersion, - }), - ] - : [ - ...input.departmentIds.map((departmentId) => - genericContext({ - domainId: "organization", - departmentId, - facts: { departmentAdministratorPersonIds: [input.authority.personId] }, - authorityVersion, - }), - ), - ...teams.map(({ teamId, departmentId }) => - genericContext({ - domainId: "organization", - departmentId, - resourceKind: "organization-team", - resourceId: teamId, - authorityVersion, - }), - ), - ]; - - const scopes = unscoped - ? [Scope.Global()] - : [ - ...input.departmentIds.map((departmentId) => Scope.Department({ departmentId })), - ...teams.map(({ teamId }) => - Scope.Resource({ - resource: { - kind: ResourceKind.make("organization-team"), - id: ResourceId.make(teamId), - }, - }), - ), - ]; - - return authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(input.endpoint)), - request: input.request, - personId: input.authority.personId, - resolution: { selection: "AllMatching", contexts }, - grantScopes: scopes, - now: input.authority.evaluatedAt, - }, - input.presentation, - ); -}; - -const listTeamInterest = (request: Request, input: OrganizationApiHttpOptions) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - const authority = yield* input.resolveAuthority(request); - const requested = yield* optionalQueryIdentity(request, "department", DepartmentId); - // An authenticated caller without team-interest reach receives a typed denial, never an - // empty success (spec 0059 authorization boundary). A team's current leader reads the - // registrations of that team; department reach reads the whole department. - const scope = yield* authorizedDepartmentScope(authority, "team-interest.read"); - const departmentScope = scope.departmentIds; - - const teamScope = reachedTeams(authority, "team-interest.read").flatMap((teamId) => { - const membership = authority.memberships.find((entry) => entry.teamId === teamId); - - return membership === undefined ? [] : [{ teamId, departmentId: membership.departmentId }]; - }); - - if (!scope.organizationWide && departmentScope.length === 0 && teamScope.length === 0) { - return yield* Problem.make("authority.denied"); - } - - const authorizedTeams = teamScope.filter( - (team) => - (requested === undefined || team.departmentId === requested) && - !departmentScope.includes(team.departmentId), - ); - - const authorized = - requested === undefined - ? departmentScope - : departmentScope.includes(requested) - ? [requested] - : authorizedTeams.length > 0 - ? [] - : yield* Problem.make("authority.denied"); - - yield* authorizeOrganizationCollection({ - request, - authority, - endpoint: ListTeamInterestEndpoint, - departmentIds: authorized, - teams: authorizedTeams, - presentation, - }); - - // Unknown department reference denies with 422 before any data leaves the store. - if (requested !== undefined) yield* assertDepartmentsExist([requested]); - - const filter: TeamInterestFilter = { - authorizedDepartmentIds: authorized, - authorizedTeamIds: authorizedTeams.map(({ teamId }) => teamId), - semesterId: yield* optionalQueryIdentity(request, "semester", SemesterId), - }; - - const rows = yield* Organization.use(({ listTeamInterestRegistrations }) => - listTeamInterestRegistrations(filter), - ); - - return yield* privateReadJson(TeamInterestResponse)({ - "hydra:member": rows.map((row) => ({ - id: row.registrationId, - userName: row.submitterName, - teamName: row.teamName, - })), - "hydra:totalItems": rows.length, - }); - }).pipe( - organizationProblems, - credentialProblems(presentation), - // A person AccessSpec reveals every denial; none is concealed as absent. - unreachable("resource.not-found"), - ); -}; - -const listMailingLists = (request: Request, input: OrganizationApiHttpOptions) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - const type = yield* Schema.decodeUnknownEffect(MailingListTypeSchema)( - new URL(request.url).searchParams.get("type") ?? "assistants", - ).pipe(Effect.mapError(() => Problem.make("request.malformed"))); - - const authority = yield* input.resolveAuthority(request); - const requested = yield* optionalQueryIdentity(request, "department", DepartmentId); - const scope = yield* authorizedDepartmentScope(authority, "people.read"); - - if (!scope.organizationWide && scope.departmentIds.length === 0) { - return yield* Problem.make("authority.denied"); - } - - const authorized = yield* narrowScopeOrThrow(scope.departmentIds, requested); - - if (requested !== undefined) yield* assertDepartmentsExist([requested]); - yield* authorizeOrganizationCollection({ - request, - authority, - endpoint: ListMailingListsEndpoint, - departmentIds: authorized, - presentation, - }); - const semesterId = yield* optionalQueryIdentity(request, "semester", SemesterId); - - const lists = yield* Organization.use(({ projectMailingLists }) => - projectMailingLists({ - type, - actorPersonId: authority.personId, - authorizationInstant: authority.evaluatedAt, - departmentId: requested, - semesterId, - }), - ); - - return yield* privateReadJson(MailingListResponse)(lists); - }).pipe( - organizationProblems, - credentialProblems(presentation), - // A person AccessSpec reveals every denial; none is concealed as absent. - unreachable("resource.not-found"), - ); -}; - -const readAppointmentManagement = (request: Request) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - yield* rejectQuery(request); - const resolved = yield* resolveRequestCredentialInTransaction(request, "OAuthUserBearer"); - - if (!Predicate.isTagged(resolved.credential.principal, "Person")) { - return yield* Problem.unauthenticated(presentation); - } - - const personId = resolved.credential.principal.personId; - - const snapshot = yield* Organization.use((service) => - service.readAppointmentManagement(personId), - ); - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(ReadAppointmentManagementEndpoint)), - credential: resolved.credential, - personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "organization", - authorityVersion: "appointment-management", - }), - ], - }, - grantScopes: [Scope.Global()], - now: resolved.authorizationInstant, - }, - presentation, - ); - - return yield* privateReadJson(AppointmentManagement)(snapshot); - }).pipe(organizationProblems, credentialProblems(presentation)); -}; - -/** The certificate-issuing boards the reader manages, with appointed and derived seats. */ -const readBoardRosters = (request: Request) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - yield* rejectQuery(request); - const resolved = yield* resolveRequestCredentialInTransaction(request, "OAuthUserBearer"); - - if (!Predicate.isTagged(resolved.credential.principal, "Person")) { - return yield* Problem.unauthenticated(presentation); - } - - const personId = resolved.credential.principal.personId; - const rosters = yield* Organization.use((service) => service.readBoardRosters(personId)); - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(ReadBoardRostersEndpoint)), - credential: resolved.credential, - personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "organization", - authorityVersion: "appointment-management", - }), - ], - }, - grantScopes: [Scope.Global()], - now: resolved.authorizationInstant, - }, - presentation, - ); - - return yield* privateReadJson(BoardRosters)(rosters); - }).pipe(organizationProblems, credentialProblems(presentation)); -}; - -const executeLifecycle = (request: Request, input: OrganizationApiHttpOptions) => - Effect.gen(function* () { - yield* rejectQuery(request); - - const command = yield* readCommandBody(request, input.config.maxBodyBytes).pipe( - Effect.flatMap(decodeRequest(OrganizationLifecycleCommand)), - ); - - const idempotencyKey = yield* idempotencyKeyOf(request); - - // A command replays under its own identifier only. - if (command.commandId !== idempotencyKey) { - return yield* Problem.make("idempotency.digest-conflict"); - } - - const presentation = personPresentation(request); - - // Domain and credential failures are mapped after the executor, whose serialization - // retry reads their causes. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const resolved = yield* resolveRequestCredentialInTransaction(request, "OAuthUserBearer"); - - if (!Predicate.isTagged(resolved.credential.principal, "Person")) { - return yield* Problem.unauthenticated(presentation); - } - - const personId = resolved.credential.principal.personId; - - // Authority, history and both receipts commit in this ambient transaction. - const result = yield* Organization.use((service) => - service.executeLifecycle(command, personId), - ); - - const current = yield* resolveRequestCredentialInTransaction(request, "OAuthUserBearer"); - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(ExecuteOrganizationLifecycleEndpoint)), - credential: current.credential, - personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "organization", - authorityVersion: "appointment-management", - }), - ], - }, - grantScopes: [Scope.Global()], - now: current.authorizationInstant, - }, - presentation, - ); - - // An accepted person, the decoded key, and the fixed operation and target always derive one. - const identity = yield* httpIdentity({ - credentialSubject: `Person:${personId}`, - qualifiedOperationId: "organization.executeLifecycle", - normalizedTarget: "/api/organization/appointments/commands", - idempotencyKey, - }).pipe(unreachable("request.malformed", "idempotency-key.invalid")); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({ body: command }), - operationId: "organization.executeLifecycle", - }, - execute: Effect.succeed({ - status: 200, - mediaType: "application/json", - headers: { - "content-type": "application/json", - etag: deriveStrongETag({ - representationKind: "OrganizationLifecycleResult", - resourceIdentity: result.subjectId, - version: result.revision, - }), - }, - bodyBytes: jsonBodyBytes(result), - }), - }; - }), - { retry: "serialization-once" }, - ).pipe(organizationProblems, commandReceiptProblems, credentialProblems(presentation)); - - return yield* commandOutcomeResponse(outcome); - }); - -const readDelegationManagement = (request: Request) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - yield* rejectQuery(request); - const resolved = yield* resolveRequestCredentialInTransaction(request, "OAuthUserBearer"); - - if (!Predicate.isTagged(resolved.credential.principal, "Person")) { - return yield* Problem.unauthenticated(presentation); - } - - const personId = resolved.credential.principal.personId; - - const snapshot = yield* Organization.use((service) => - service.readDelegationManagement(personId), - ); - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(ReadDelegationManagementEndpoint)), - credential: resolved.credential, - personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "organization", - authorityVersion: "delegation-management", - }), - ], - }, - grantScopes: [Scope.Global()], - now: resolved.authorizationInstant, - }, - presentation, - ); - - return yield* privateReadJson(DelegationManagement)(snapshot); - }).pipe(organizationProblems, credentialProblems(presentation)); -}; - -const executeDelegation = (request: Request, input: OrganizationApiHttpOptions) => - Effect.gen(function* () { - yield* rejectQuery(request); - - const command = yield* readCommandBody(request, input.config.maxBodyBytes).pipe( - Effect.flatMap(decodeRequest(DelegationCommand)), - ); - - const idempotencyKey = yield* idempotencyKeyOf(request); - - // A command replays under its own identifier only. - if (command.commandId !== idempotencyKey) { - return yield* Problem.make("idempotency.digest-conflict"); - } - - const presentation = personPresentation(request); - - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const resolved = yield* resolveRequestCredentialInTransaction(request, "OAuthUserBearer"); - - if (!Predicate.isTagged(resolved.credential.principal, "Person")) { - return yield* Problem.unauthenticated(presentation); - } - - const personId = resolved.credential.principal.personId; - - // Authority, history and both receipts commit in this ambient transaction. - const result = yield* Organization.use((service) => - service.executeDelegation(command, personId), - ); - - const current = yield* resolveRequestCredentialInTransaction(request, "OAuthUserBearer"); - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(ExecuteDelegationEndpoint)), - credential: current.credential, - personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "organization", - authorityVersion: "delegation-management", - }), - ], - }, - grantScopes: [Scope.Global()], - now: current.authorizationInstant, - }, - presentation, - ); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${personId}`, - qualifiedOperationId: "organization.executeDelegation", - normalizedTarget: "/api/organization/delegations/commands", - idempotencyKey, - }).pipe(unreachable("request.malformed", "idempotency-key.invalid")); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({ body: command }), - operationId: "organization.executeDelegation", - }, - execute: Effect.succeed({ - status: 200, - mediaType: "application/json", - headers: { - "content-type": "application/json", - etag: deriveStrongETag({ - representationKind: "DelegationResult", - resourceIdentity: result.delegationId, - version: result.revision, - }), - }, - bodyBytes: jsonBodyBytes(result), - }), - }; - }), - { retry: "serialization-once" }, - ).pipe(organizationProblems, commandReceiptProblems, credentialProblems(presentation)); - - return yield* commandOutcomeResponse(outcome); - }); - -/** Native HttpApi implementations for organization endpoints. */ -export const OrganizationApiHandlers = (input: OrganizationApiHttpOptions) => - HttpApiBuilder.group(ExternalNativeApi, "organization", (handlers) => - Effect.succeed( - handlers - .handleRaw("readAppointmentManagement", ({ request }) => - webHandler(request, readAppointmentManagement), - ) - .handleRaw("readBoardRosters", ({ request }) => webHandler(request, readBoardRosters)) - .handleRaw("executeLifecycle", ({ request }) => - webHandler(request, (webRequest) => executeLifecycle(webRequest, input)), - ) - .handleRaw("readDelegationManagement", ({ request }) => - webHandler(request, readDelegationManagement), - ) - .handleRaw("executeDelegation", ({ request }) => - webHandler(request, (webRequest) => executeDelegation(webRequest, input)), - ) - .handleRaw("listDepartments", ({ request }) => webHandler(request, listDepartments)) - .handleRaw("listTeams", ({ request }) => webHandler(request, listTeams)) - .handleRaw("listFieldOfStudies", ({ request }) => webHandler(request, listFieldOfStudies)) - .handleRaw("listTeamInterest", ({ request }) => - webHandler(request, (webRequest) => listTeamInterest(webRequest, input)), - ) - .handleRaw("listMailingLists", ({ request }) => - webHandler(request, (webRequest) => listMailingLists(webRequest, input)), - ) - .handleRaw("createDepartment", ({ request }) => - webHandler(request, (webRequest) => createDepartment(webRequest, input)), - ) - .handleRaw("createTeam", ({ request }) => - webHandler(request, (webRequest) => createTeam(webRequest, input)), - ) - .handleRaw("createFieldOfStudy", ({ request }) => - webHandler(request, (webRequest) => createFieldOfStudy(webRequest, input)), - ), - ), - ); diff --git a/apps/backend/src/organization/rpc.test.ts b/apps/backend/src/organization/rpc.test.ts new file mode 100644 index 000000000..c4df7e999 --- /dev/null +++ b/apps/backend/src/organization/rpc.test.ts @@ -0,0 +1,420 @@ +import { IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; +import { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; +import { + Department, + DepartmentCreatedObservationSchema, + DepartmentId, + DepartmentJsonSchema, + FieldOfStudy, + FieldOfStudyCreatedObservationSchema, + FieldOfStudyJsonSchema, + Organization, + OrganizationCommandConflict, + type OrganizationCommandId, + OrganizationInvalidReference, + type OrganizationOperations, + OrganizationPersistenceError, + PersonId, + Team, + TeamCreatedObservationSchema, + TeamJsonSchema, +} from "@vektorprogrammet/domain/organization"; +import { IdempotencyKey, isProblem, problemBody } from "@vektorprogrammet/rpc/problem"; +import { describe, expect, it } from "@effect/vitest"; +import { DateTime, Effect, Layer, Schema } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import { backendDatabase } from "../../test/database.js"; +import { backendTestConfig } from "../../test/config.js"; +import { jsonText } from "../rpc/problem.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; + +const ADMIN_SESSION = "organization-admin-session"; + +const MEMBER_SESSION = "organization-member-session"; + +const department = Schema.decodeSync(DepartmentJsonSchema)( + { + departmentId: "department-created", + name: "Vektorprogrammet Trondheim", + shortName: "Trondheim", + email: "trondheim@example.invalid", + address: "Høgskoleringen 1", + city: "Trondheim", + latitude: "63.4195", + longitude: "10.4021", + slackChannel: null, + logoPath: null, + active: true, + revision: 0, + }, + { onExcessProperty: "error" }, +); + +const team = Schema.decodeSync(TeamJsonSchema)( + { + teamId: "team-created", + departmentId: department.departmentId, + name: "Rekruttering", + email: null, + description: "Rekrutterer studenter", + shortDescription: null, + acceptApplication: true, + deadline: null, + active: true, + revision: 0, + }, + { onExcessProperty: "error" }, +); + +const fieldOfStudy = Schema.decodeSync(FieldOfStudyJsonSchema)( + { + fieldOfStudyId: "field-created", + name: "Datateknologi", + shortName: "Data", + departmentId: null, + active: true, + revision: 0, + }, + { onExcessProperty: "error" }, +); + +const createDepartmentRequest = { + name: department.name, + shortName: department.shortName, + email: department.email, + address: department.address, + city: department.city, + latitude: department.latitude, + longitude: department.longitude, +} as const; + +const createTeamRequest = { + departmentId: department.departmentId, + name: team.name, + email: team.email, + description: team.description, + shortDescription: team.shortDescription, + acceptApplication: team.acceptApplication, + deadline: team.deadline, + active: team.active, +} as const; + +const createFieldOfStudyRequest = { + name: fieldOfStudy.name, + shortName: fieldOfStudy.shortName, + departmentId: fieldOfStudy.departmentId, +} as const; + +// Like the PostgreSQL layer, the doubles answer with the model instance that they read back. +// Canonical JSON refuses a model instance, so the response must come from the contract schema. +const departmentResult = (commandId: OrganizationCommandId) => ({ + committed: true as const, + observation: { + ...DepartmentCreatedObservationSchema.make({ commandId, department }), + department: Schema.decodeSync(Department)(department), + }, +}); + +let createCalls = 0; + +const organization = { + listDepartments: Effect.succeed([department]), + listTeams: () => Effect.succeed([team]), + listFieldOfStudies: Effect.succeed([fieldOfStudy]), + resolvePersonAuthority: (personId: PersonId, evaluatedAt: string) => + Effect.succeed({ + personId, + evaluatedAt, + globalAdministrator: personId === "person-admin" ? "Active" : "Absent", + memberships: [], + nationalBoardSeats: [], + delegations: [], + }), + createDepartment: ( + command: Parameters[0], + _administrator: Parameters[1], + ) => { + createCalls += 1; + + if (command.name === "Conflict") { + return Effect.fail(OrganizationCommandConflict.make({ commandId: command.commandId })); + } + + if (command.name === "Unavailable") { + return Effect.fail( + OrganizationPersistenceError.make({ + operation: "createDepartment", + message: "database unavailable", + }), + ); + } + + return Effect.succeed(departmentResult(command.commandId)); + }, + createTeam: (command: Parameters[0]) => { + createCalls += 1; + + if (command.departmentId === "department-unknown") { + return Effect.fail(OrganizationInvalidReference.make({ referenceKind: "Department" })); + } + + return Effect.succeed({ + committed: true as const, + observation: { + ...TeamCreatedObservationSchema.make({ commandId: command.commandId, team }), + team: Schema.decodeSync(Team)(team), + }, + }); + }, + createFieldOfStudy: (command: Parameters[0]) => + Effect.succeed({ + committed: true as const, + observation: { + ...FieldOfStudyCreatedObservationSchema.make({ + commandId: command.commandId, + fieldOfStudy, + }), + fieldOfStudy: Schema.decodeSync(FieldOfStudy)(fieldOfStudy), + }, + }), +} satisfies Partial; + +const sessionPerson = (cookieHeader: string | undefined) => + cookieHeader?.includes(ADMIN_SESSION) === true + ? PersonId.make("person-admin") + : cookieHeader?.includes(MEMBER_SESSION) === true + ? PersonId.make("person-member") + : undefined; + +const sessionActor = (cookieHeader: string | undefined) => { + const personId = sessionPerson(cookieHeader); + + return personId === undefined + ? Effect.fail(IdentitySessionNotFound.make()) + : Effect.succeed( + IdentityActor.make({ + personId, + sessionId: "organization-rpc-session", + expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), + }), + ); +}; + +const identitySnapshot = IdentitySnapshot.of({ + resolveSession: (cookieHeader) => Effect.suspend(() => sessionActor(cookieHeader)), + revokeCurrentSession: () => Effect.succeed({ setCookies: [] }), + revokeSession: () => Effect.succeed({ setCookies: [] }), + revokeOtherSessions: () => Effect.succeed({ setCookies: [] }), + revokeAllSessions: () => Effect.succeed({ setCookies: [] }), +}); + +const oauthCredentialAuthority = OAuthCredentialAuthority.of({ + resolve: () => Effect.die("unexpected OAuth credential resolution"), + resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), +}); + +const identity = Identity.of({ + signIn: () => Effect.die("unexpected sign-in"), + resolveSession: (cookieHeader: string | undefined) => sessionActor(cookieHeader), + readCurrentSession: () => Effect.die("unexpected session read"), + listSessions: () => Effect.die("unexpected session list"), + revokeCurrentSession: () => Effect.die("unexpected session mutation"), + revokeSession: () => Effect.die("unexpected session mutation"), + revokeOtherSessions: () => Effect.die("unexpected session mutation"), + revokeAllSessions: () => Effect.die("unexpected session mutation"), + recordSecurityEvent: () => Effect.die("unexpected identity audit"), + signOut: () => Effect.succeed({ setCookies: [] }), +} satisfies IdentityOperations); + +const database = backendDatabase(); + +const backend = makeBackendTestRpc({ + config: backendTestConfig, + services: Layer.mergeAll( + database.layer, + Layer.succeed(IdentitySnapshot, identitySnapshot), + Layer.mock(Organization, organization), + Layer.succeed(Identity, identity), + Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), + ), +}); + +const asSession = + (session: string) => + (effect: Effect.Effect) => + RpcClient.withHeaders(effect, { cookie: `better-auth.session_token=${session}` }); + +const key = (value: string) => IdempotencyKey.make(value); + +/** The problem an RPC failed with, as its frozen body. */ +const failureOf = (effect: Effect.Effect) => + Effect.flip(effect).pipe( + Effect.map((failure) => (isProblem(failure) ? problemBody(failure) : failure)), + ); + +const expectedProblem = (code: string, title: string, status: number, detail: string) => ({ + type: `urn:vektorprogrammet:problem:v0.2:${code}`, + title, + status, + detail, + code, +}); + +describe("Organization RPC boundary", () => { + it.live("returns only canonical Organization JSON projections from all public reads", () => + Effect.gen(function* () { + const client = yield* backend.client; + + const [departments, teams, fields] = yield* Effect.all( + [ + client["organization.listDepartments"](), + client["organization.listTeams"](), + client["organization.listFieldOfStudies"](), + ], + { concurrency: "unbounded" }, + ); + + expect(departments).toEqual([department]); + expect(teams).toEqual([team]); + expect(fields).toEqual([fieldOfStudy]); + + const serialized = yield* jsonText([departments, teams, fields]); + + for (const forbidden of ["personId", "membership", "commandId", "audit", "actorsByToken"]) { + expect(serialized).not.toContain(forbidden); + } + }), + ); + + it.live("returns canonical resources for committed and replayed commands", () => + Effect.gen(function* () { + const client = yield* backend.client; + const departmentKey = key("department-create-key-0001"); + + const created = yield* client["organization.createDepartment"]({ + idempotencyKey: departmentKey, + request: createDepartmentRequest, + }).pipe(asSession(ADMIN_SESSION)); + + const createdTeam = yield* client["organization.createTeam"]({ + idempotencyKey: key("team-create-key-00000001"), + request: createTeamRequest, + }).pipe(asSession(ADMIN_SESSION)); + + const createdField = yield* client["organization.createFieldOfStudy"]({ + idempotencyKey: key("field-create-key-0000001"), + request: createFieldOfStudyRequest, + }).pipe(asSession(ADMIN_SESSION)); + + const before = createCalls; + + const replayed = yield* client["organization.createDepartment"]({ + idempotencyKey: departmentKey, + request: createDepartmentRequest, + }).pipe(asSession(ADMIN_SESSION)); + + expect(created).toEqual(department); + expect(createdTeam).toEqual(team); + expect(createdField).toEqual(fieldOfStudy); + expect(replayed).toEqual(department); + // The replay answers the stored receipt without running the command again. + expect(createCalls).toBe(before); + }), + ); + + it.live("maps authority, reference, conflict, and dependency failures to problems", () => + Effect.gen(function* () { + const client = yield* backend.client; + + const denied = yield* failureOf( + client["organization.createDepartment"]({ + idempotencyKey: key("department-denied-key-0001"), + request: createDepartmentRequest, + }).pipe(asSession(MEMBER_SESSION)), + ); + + const invalidReference = yield* failureOf( + client["organization.createTeam"]({ + idempotencyKey: key("team-invalid-ref-key-00001"), + request: { ...createTeamRequest, departmentId: DepartmentId.make("department-unknown") }, + }).pipe(asSession(ADMIN_SESSION)), + ); + + const conflict = yield* failureOf( + client["organization.createDepartment"]({ + idempotencyKey: key("department-conflict-key-01"), + request: { ...createDepartmentRequest, name: "Conflict" }, + }).pipe(asSession(ADMIN_SESSION)), + ); + + const unavailable = yield* failureOf( + client["organization.createDepartment"]({ + idempotencyKey: key("department-unavailable-001"), + request: { ...createDepartmentRequest, name: "Unavailable" }, + }).pipe(asSession(ADMIN_SESSION)), + ); + + expect(denied).toEqual( + expectedProblem( + "authority.denied", + "Authority denied", + 403, + "The authenticated principal is not permitted to perform this operation.", + ), + ); + expect(invalidReference).toEqual( + expectedProblem( + "organization.invalid-reference", + "Invalid organization reference", + 422, + "An organization reference is invalid.", + ), + ); + expect(conflict).toEqual( + expectedProblem( + "idempotency.digest-conflict", + "Idempotency conflict", + 409, + "This idempotency key identifies a different semantic request.", + ), + ); + expect(unavailable).toEqual( + expectedProblem( + "organization.unavailable", + "Organization unavailable", + 503, + "The organization service is temporarily unavailable.", + ), + ); + }), + ); + + it.live("fails closed with a credential problem before the command runs", () => + Effect.gen(function* () { + const client = yield* backend.client; + const before = createCalls; + + const anonymous = yield* failureOf( + client["organization.createDepartment"]({ + idempotencyKey: key("anonymous-department-key-01"), + request: createDepartmentRequest, + }), + ); + + expect(anonymous).toEqual( + expectedProblem( + "credential.missing", + "Credential required", + 401, + "A credential is required for this operation.", + ), + ); + expect(createCalls).toBe(before); + }), + ); +}); diff --git a/apps/backend/src/organization/rpc.ts b/apps/backend/src/organization/rpc.ts new file mode 100644 index 000000000..9e43ad9e0 --- /dev/null +++ b/apps/backend/src/organization/rpc.ts @@ -0,0 +1,815 @@ +/** + * The OrganizationRpcs handlers. + * + * The public directories evaluate their anonymous AccessSpecs and read the store. The scoped reads + * resolve the caller's authority at one instant, require the team-interest or people reach, and + * evaluate the RPC's AccessSpec over the scope that they read. A create command resolves the + * caller's authority inside the transaction that commits it, mints + * `OrganizationAdministratorEvidence`, and stores its success as a command receipt, so a retry with + * the same idempotency key replays the first answer. Appointment and delegation commands commit + * their authority, history, and receipts in that transaction too. + */ +import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import { + reachedDepartments, + ReachedDepartments, + ResourceId, + ResourceKind, + Scope, +} from "@vektorprogrammet/domain/authz"; +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import { + CreateDepartmentCommandSchema, + CreateFieldOfStudyCommandSchema, + CreateTeamCommandSchema, + type DepartmentId, + mapOrganizationAuthorityToOrganizationActor, + Organization, + OrganizationCommandId, + type OrganizationCommandFailure, + type OrganizationLifecycleFailure, + type OrganizationPersonAuthority, + type PersonId, + requireOrganizationAdministrator, + requireTeamInterestScope, + type TeamId, +} from "@vektorprogrammet/domain/organization"; +import type { ProfileFailure } from "@vektorprogrammet/domain/profile"; +import { + AppointmentManagement, + BoardRosters, + CreateDepartment, + CreateFieldOfStudy, + CreateTeam, + DelegationManagement, + DelegationResult, + DepartmentJsonSchema, + ExecuteDelegation, + ExecuteLifecycle, + FieldOfStudyJsonSchema, + ListDepartments, + ListFieldOfStudies, + ListMailingLists, + ListTeamInterest, + ListTeams, + MailingListResponse, + OrganizationLifecycleResult, + OrganizationRpcs, + ReadAppointmentManagement, + ReadBoardRosters, + ReadDelegationManagement, + reflectAccessSpec, + TeamInterestResponse, + TeamJsonSchema, +} from "@vektorprogrammet/rpc"; +import { + type CredentialPresentation, + type IdempotencyKey, + Problem, +} from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, flow, Match, Option, Predicate, Result, Schema } from "effect"; +import type { Headers } from "effect/unstable/http"; +import type { Rpc } from "effect/unstable/rpc"; +import { + resolveRequestCredentialInTransaction, + resolveRequestPersonAuthority, + resolveRequestPersonAuthorityInTransaction, +} from "../authority.js"; +import { semanticRequestDigest } from "../http-semantics.js"; +import { genericContext } from "../native-operation.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { + authorizeAnonymous, + authorizePerson, + commandIdentity, + commandOutcome, + commandReceiptProblems, + personPresentation, + problemMapper, + requestInvalid, + unreachable, +} from "../rpc/problem.js"; +import { executeNativeHttpCommandPostgres, successCapsule } from "../rpc/receipt-transaction.js"; + +type AnnotatedRpc = Pick; + +const organizationUnavailable = () => Problem.make("organization.unavailable"); + +/** + * The one answer for every organization failure. The store answers organization.unavailable + * whether it could not be reached or refused its own record; mailing lists read their recipients + * through the profile store. + */ +const organizationProblems = problemMapper< + OrganizationCommandFailure | OrganizationLifecycleFailure | ProfileFailure +>()({ + OrganizationLifecycleFailure: ({ code }) => + Match.value(code).pipe( + Match.whenOr("Denied", "SelfDisable", "LastAdministrator", () => + Problem.make("authority.denied"), + ), + Match.when("NotFound", () => Problem.make("resource.not-found")), + Match.when("Stale", () => Problem.make("precondition.failed")), + Match.when("Conflict", () => Problem.make("idempotency.digest-conflict")), + Match.when("Invalid", requestInvalid), + Match.when("Unavailable", organizationUnavailable), + Match.exhaustive, + ), + OrganizationRoleDenied: () => Problem.make("authority.denied"), + OrganizationInvalidReference: () => Problem.make("organization.invalid-reference"), + OrganizationCommandConflict: () => Problem.make("idempotency.digest-conflict"), + OrganizationDecodeError: organizationUnavailable, + OrganizationPersistenceError: organizationUnavailable, + ProfileDecodeError: organizationUnavailable, + ProfileQueryLimitExceeded: organizationUnavailable, + ProfileNotFound: organizationUnavailable, + ProfileContactNotFound: organizationUnavailable, + ProfileStaleRevision: organizationUnavailable, + ProfileCommandConflict: organizationUnavailable, + ProfilePersistenceError: organizationUnavailable, +}); + +/** + * A person credential rejected after ingress is answered from the request's own evidence; a + * failed identity engine leaves organization unavailable. + */ +const credentialProblems = (presentation: CredentialPresentation) => + problemMapper()({ + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + IdentityEngineError: organizationUnavailable, + }); + +/** + * Reads a response value through its contract schema. Domain layers return model instances; the + * contract decode yields the plain resource. A value outside the contract leaves organization + * unavailable. + */ +const responseJson = >(schema: S) => + flow( + Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }), + Effect.mapError(organizationUnavailable), + ); + +/** Evaluates the anonymous AccessSpec of one public directory at the current instant. */ +const authorizePublicList = (rpc: AnnotatedRpc, authorityVersion: string) => + Effect.gen(function* () { + yield* authorizeAnonymous( + Option.getOrThrow(reflectAccessSpec(rpc)), + { + selection: "AllMatching", + contexts: [genericContext({ domainId: "organization", authorityVersion })], + }, + DateTime.formatIso(yield* DateTime.now), + ); + }); + +/** + * Resolves the caller's current authority inside the command transaction, evaluates the create + * AccessSpec for it, mints the administrator evidence, and derives the command identity. + */ +const authorizeCreate = (input: { + readonly headers: Headers.Headers; + readonly options: NativeRpcOptions; + readonly rpc: AnnotatedRpc; + readonly operationId: string; + readonly target: string; + readonly idempotencyKey: IdempotencyKey; +}) => + Effect.gen(function* () { + const resolved = yield* resolveRequestPersonAuthorityInTransaction( + credentialRequestOf(input.headers), + { now: input.options.now }, + ); + + const personId = resolved.authority.personId; + const administrator = requireOrganizationAdministrator(resolved.authority); + + // The AccessSpec answers a denial; the evidence is what the command takes. + yield* authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(input.rpc)), + credential: resolved.credential, + personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "organization", + authorityVersion: `organization:${mapOrganizationAuthorityToOrganizationActor(resolved.authority)._tag}`, + }), + ], + }, + grantScopes: Result.isSuccess(administrator) ? [Scope.Global()] : [], + now: resolved.authorizationInstant, + }, + personPresentation(input.headers), + ); + + const evidence = yield* Effect.fromResult(administrator); + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: `Person:${personId}`, + qualifiedOperationId: input.operationId, + normalizedTarget: input.target, + idempotencyKey: input.idempotencyKey, + }); + + return { administrator: evidence, identity }; + }); + +/** + * The departments where the person holds the capability through a board leadership, a delegation + * or the global-administrator grant. A reach over the whole organization authorizes every + * department, also while there is none. + */ +const authorizedDepartmentScope = ( + authority: OrganizationPersonAuthority, + capability: "people.read" | "team-interest.read", +) => + Effect.gen(function* () { + const reached = reachedDepartments(authority, capability); + + if (ReachedDepartments.$is("Departments")(reached)) + return { organizationWide: false, departmentIds: reached.departmentIds }; + + const departments = yield* Organization.use(({ listDepartments }) => listDepartments); + + return { + organizationWide: true, + departmentIds: departments.map((department) => department.departmentId), + }; + }); + +/** Narrows the authorized scope to one department; a department outside it is denied. */ +const narrowScope = ( + authorized: ReadonlyArray, + departmentId: DepartmentId | undefined, +) => + departmentId === undefined + ? Effect.succeed(authorized) + : authorized.some((authorizedId) => authorizedId === departmentId) + ? Effect.succeed([departmentId]) + : Effect.fail(Problem.make("authority.denied")); + +/** An unknown department answers organization.invalid-reference before data leaves the store. */ +const assertDepartmentsExist = (departmentIds: ReadonlyArray) => + Organization.use(({ listDepartments }) => listDepartments).pipe( + Effect.flatMap((known) => { + for (const requested of departmentIds) { + if (!known.some((department) => department.departmentId === requested)) { + return Effect.fail(Problem.make("organization.invalid-reference")); + } + } + + return Effect.void; + }), + ); + +/** Evaluates a scoped collection read's AccessSpec over every department and team it reads. */ +const authorizeOrganizationCollection = (input: { + readonly headers: Headers.Headers; + readonly authority: OrganizationPersonAuthority; + readonly rpc: AnnotatedRpc; + readonly departmentIds: ReadonlyArray; + readonly teams?: ReadonlyArray<{ readonly teamId: TeamId; readonly departmentId: DepartmentId }>; +}) => { + const global = input.authority.globalAdministrator === "Active"; + const teams = input.teams ?? []; + const authorityVersion = `organization:${input.authority.evaluatedAt}`; + const unscoped = global && input.departmentIds.length === 0 && teams.length === 0; + + const contexts = unscoped + ? [ + genericContext({ + domainId: "organization", + facts: { departmentAdministratorPersonIds: [input.authority.personId] }, + authorityVersion, + }), + ] + : [ + ...input.departmentIds.map((departmentId) => + genericContext({ + domainId: "organization", + departmentId, + facts: { departmentAdministratorPersonIds: [input.authority.personId] }, + authorityVersion, + }), + ), + ...teams.map(({ teamId, departmentId }) => + genericContext({ + domainId: "organization", + departmentId, + resourceKind: "organization-team", + resourceId: teamId, + authorityVersion, + }), + ), + ]; + + const scopes = unscoped + ? [Scope.Global()] + : [ + ...input.departmentIds.map((departmentId) => Scope.Department({ departmentId })), + ...teams.map(({ teamId }) => + Scope.Resource({ + resource: { + kind: ResourceKind.make("organization-team"), + id: ResourceId.make(teamId), + }, + }), + ), + ]; + + return authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(input.rpc)), + request: credentialRequestOf(input.headers), + personId: input.authority.personId, + resolution: { selection: "AllMatching", contexts }, + grantScopes: scopes, + now: input.authority.evaluatedAt, + }, + personPresentation(input.headers), + ); +}; + +/** + * Resolves the reader's current credential, reads one management snapshot for that person, and + * evaluates the RPC's AccessSpec with a global grant: the snapshot holds only what the person + * manages. + */ +const readManagement = >(input: { + readonly headers: Headers.Headers; + readonly rpc: AnnotatedRpc; + readonly authorityVersion: string; + readonly response: S; + readonly read: ( + personId: PersonId, + ) => Effect.Effect; +}) => { + const presentation = personPresentation(input.headers); + + return Effect.gen(function* () { + const resolved = yield* resolveRequestCredentialInTransaction( + credentialRequestOf(input.headers), + "OAuthUserBearer", + ); + + if (!Predicate.isTagged(resolved.credential.principal, "Person")) { + return yield* Problem.unauthenticated(presentation); + } + + const personId = resolved.credential.principal.personId; + const snapshot = yield* input.read(personId); + + yield* authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(input.rpc)), + credential: resolved.credential, + personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + genericContext({ domainId: "organization", authorityVersion: input.authorityVersion }), + ], + }, + grantScopes: [Scope.Global()], + now: resolved.authorizationInstant, + }, + presentation, + ); + + return yield* responseJson(input.response)(snapshot); + }).pipe(organizationProblems, credentialProblems(presentation)); +}; + +/** + * Runs one appointment or delegation command. The domain commits authority, history, and its own + * receipt in the command transaction, and the RPC's AccessSpec is evaluated against the + * credential as it stands after the command. + */ +const executeManagementCommand = >(input: { + readonly headers: Headers.Headers; + readonly rpc: AnnotatedRpc; + readonly operationId: string; + readonly target: string; + readonly authorityVersion: string; + readonly idempotencyKey: IdempotencyKey; + readonly commandId: string; + readonly request: Schema.Json; + readonly success: S; + readonly execute: ( + personId: PersonId, + ) => Effect.Effect; +}) => + Effect.gen(function* () { + // A command replays under its own identifier only. + if (input.commandId !== input.idempotencyKey) { + return yield* Problem.make("idempotency.digest-conflict"); + } + + const presentation = personPresentation(input.headers); + const request = credentialRequestOf(input.headers); + + // Domain and credential failures are mapped after the executor, whose serialization retry + // reads their causes. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const resolved = yield* resolveRequestCredentialInTransaction(request, "OAuthUserBearer"); + + if (!Predicate.isTagged(resolved.credential.principal, "Person")) { + return yield* Problem.unauthenticated(presentation); + } + + const personId = resolved.credential.principal.personId; + + // Authority, history and both receipts commit in this ambient transaction. + const result = yield* input.execute(personId); + + const current = yield* resolveRequestCredentialInTransaction(request, "OAuthUserBearer"); + + yield* authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(input.rpc)), + credential: current.credential, + personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "organization", + authorityVersion: input.authorityVersion, + }), + ], + }, + grantScopes: [Scope.Global()], + now: current.authorizationInstant, + }, + presentation, + ); + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: `Person:${personId}`, + qualifiedOperationId: input.operationId, + normalizedTarget: input.target, + idempotencyKey: input.idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest({ body: input.request }), + operationId: input.operationId, + }, + execute: successCapsule(input.success)(result), + }; + }), + { retry: "serialization-once" }, + ).pipe(organizationProblems, commandReceiptProblems, credentialProblems(presentation)); + + return yield* commandOutcome(input.success)(outcome); + }); + +/** The OrganizationRpcs handlers. */ +export const OrganizationRpcHandlers = (options: NativeRpcOptions) => + OrganizationRpcs.toLayer({ + "organization.listDepartments": () => + Effect.gen(function* () { + yield* authorizePublicList(ListDepartments, "organization-public-departments"); + + return yield* Organization.use(({ listDepartments }) => listDepartments).pipe( + organizationProblems, + Effect.flatMap(responseJson(Schema.Array(DepartmentJsonSchema))), + ); + }), + + "organization.listTeams": () => + Effect.gen(function* () { + yield* authorizePublicList(ListTeams, "organization-public-teams"); + + return yield* Organization.use(({ listTeams }) => listTeams()).pipe( + organizationProblems, + Effect.flatMap(responseJson(Schema.Array(TeamJsonSchema))), + ); + }), + + "organization.listFieldOfStudies": () => + Effect.gen(function* () { + yield* authorizePublicList(ListFieldOfStudies, "organization-public-field-of-studies"); + + return yield* Organization.use(({ listFieldOfStudies }) => listFieldOfStudies).pipe( + organizationProblems, + Effect.flatMap(responseJson(Schema.Array(FieldOfStudyJsonSchema))), + ); + }), + + "organization.listTeamInterest": ({ departmentId, semesterId }, { headers }) => + Effect.gen(function* () { + const authority = yield* resolveRequestPersonAuthority(credentialRequestOf(headers), { + now: options.now, + }); + + const departments = yield* Organization.use(({ listDepartments }) => listDepartments); + + // An authenticated caller without team-interest reach receives a typed denial, never an + // empty success. A team's current leader reads the registrations of that team; + // department reach reads the whole department. + const scope = yield* Effect.fromResult( + requireTeamInterestScope(authority, { + requested: departmentId, + departments: departments.map((department) => department.departmentId), + }), + ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); + + yield* authorizeOrganizationCollection({ + headers, + authority, + rpc: ListTeamInterest, + departmentIds: scope.departmentIds, + teams: scope.teams, + }); + + // An unknown department reference is refused before any data leaves the store. + if (departmentId !== undefined) yield* assertDepartmentsExist([departmentId]); + + const rows = yield* Organization.use(({ listTeamInterestRegistrations }) => + listTeamInterestRegistrations(scope, semesterId), + ); + + return yield* responseJson(TeamInterestResponse)({ + "hydra:member": rows.map((row) => ({ + id: row.registrationId, + userName: row.submitterName, + teamName: row.teamName, + })), + "hydra:totalItems": rows.length, + }); + }).pipe( + organizationProblems, + credentialProblems(personPresentation(headers)), + // A person AccessSpec reveals every denial; none is concealed as absent. + unreachable("resource.not-found"), + ), + + "organization.listMailingLists": ({ departmentId, semesterId, type }, { headers }) => + Effect.gen(function* () { + const authority = yield* resolveRequestPersonAuthority(credentialRequestOf(headers), { + now: options.now, + }); + + const scope = yield* authorizedDepartmentScope(authority, "people.read"); + + if (!scope.organizationWide && scope.departmentIds.length === 0) { + return yield* Problem.make("authority.denied"); + } + + const authorized = yield* narrowScope(scope.departmentIds, departmentId); + + if (departmentId !== undefined) yield* assertDepartmentsExist([departmentId]); + + yield* authorizeOrganizationCollection({ + headers, + authority, + rpc: ListMailingLists, + departmentIds: authorized, + }); + + const lists = yield* Organization.use(({ projectMailingLists }) => + projectMailingLists({ + type: type ?? "assistants", + actorPersonId: authority.personId, + authorizationInstant: authority.evaluatedAt, + departmentId, + semesterId, + }), + ); + + return yield* responseJson(MailingListResponse)(lists); + }).pipe( + organizationProblems, + credentialProblems(personPresentation(headers)), + // A person AccessSpec reveals every denial; none is concealed as absent. + unreachable("resource.not-found"), + ), + + "organization.createDepartment": ({ idempotencyKey, request }, { headers }) => + Effect.gen(function* () { + const operationId = "organization.createDepartment"; + + // Domain and credential failures are mapped after the executor, with its receipt failures. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const { administrator, identity } = yield* authorizeCreate({ + headers, + options, + rpc: CreateDepartment, + operationId, + target: "/api/departments", + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest({ body: request }), + operationId, + }, + execute: Organization.use((organization) => + organization.createDepartment( + CreateDepartmentCommandSchema.make({ + commandId: OrganizationCommandId.make(identity.commandId), + ...request, + }), + administrator, + ), + ).pipe( + Effect.flatMap(({ observation }) => + responseJson(DepartmentJsonSchema)( + Predicate.isTagged(observation, "Replayed") + ? observation.original.department + : observation.department, + ), + ), + Effect.flatMap(successCapsule(DepartmentJsonSchema)), + ), + }; + }), + ).pipe( + organizationProblems, + commandReceiptProblems, + credentialProblems(personPresentation(headers)), + // A person AccessSpec reveals every denial; none is concealed as absent. + unreachable("resource.not-found"), + ); + + return yield* commandOutcome(DepartmentJsonSchema)(outcome); + }), + + "organization.createTeam": ({ idempotencyKey, request }, { headers }) => + Effect.gen(function* () { + const operationId = "organization.createTeam"; + + // Domain and credential failures are mapped after the executor, with its receipt failures. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const { administrator, identity } = yield* authorizeCreate({ + headers, + options, + rpc: CreateTeam, + operationId, + target: "/api/teams", + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest({ body: request }), + operationId, + }, + execute: Organization.use((organization) => + organization.createTeam( + CreateTeamCommandSchema.make({ + commandId: OrganizationCommandId.make(identity.commandId), + ...request, + }), + administrator, + ), + ).pipe( + Effect.flatMap(({ observation }) => + responseJson(TeamJsonSchema)( + Predicate.isTagged(observation, "Replayed") + ? observation.original.team + : observation.team, + ), + ), + Effect.flatMap(successCapsule(TeamJsonSchema)), + ), + }; + }), + ).pipe( + organizationProblems, + commandReceiptProblems, + credentialProblems(personPresentation(headers)), + // A person AccessSpec reveals every denial; none is concealed as absent. + unreachable("resource.not-found"), + ); + + return yield* commandOutcome(TeamJsonSchema)(outcome); + }), + + "organization.createFieldOfStudy": ({ idempotencyKey, request }, { headers }) => + Effect.gen(function* () { + const operationId = "organization.createFieldOfStudy"; + + // Domain and credential failures are mapped after the executor, with its receipt failures. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const { administrator, identity } = yield* authorizeCreate({ + headers, + options, + rpc: CreateFieldOfStudy, + operationId, + target: "/api/field-of-studies", + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest({ body: request }), + operationId, + }, + execute: Organization.use((organization) => + organization.createFieldOfStudy( + CreateFieldOfStudyCommandSchema.make({ + commandId: OrganizationCommandId.make(identity.commandId), + ...request, + }), + administrator, + ), + ).pipe( + Effect.flatMap(({ observation }) => + responseJson(FieldOfStudyJsonSchema)( + Predicate.isTagged(observation, "Replayed") + ? observation.original.fieldOfStudy + : observation.fieldOfStudy, + ), + ), + Effect.flatMap(successCapsule(FieldOfStudyJsonSchema)), + ), + }; + }), + ).pipe( + organizationProblems, + commandReceiptProblems, + credentialProblems(personPresentation(headers)), + // A person AccessSpec reveals every denial; none is concealed as absent. + unreachable("resource.not-found"), + ); + + return yield* commandOutcome(FieldOfStudyJsonSchema)(outcome); + }), + + "organization.readAppointmentManagement": (_payload, { headers }) => + readManagement({ + headers, + rpc: ReadAppointmentManagement, + authorityVersion: "appointment-management", + response: AppointmentManagement, + read: (personId) => + Organization.use((service) => service.readAppointmentManagement(personId)), + }), + + "organization.readBoardRosters": (_payload, { headers }) => + readManagement({ + headers, + rpc: ReadBoardRosters, + authorityVersion: "appointment-management", + response: BoardRosters, + read: (personId) => Organization.use((service) => service.readBoardRosters(personId)), + }), + + "organization.executeLifecycle": ({ idempotencyKey, request }, { headers }) => + executeManagementCommand({ + headers, + rpc: ExecuteLifecycle, + operationId: "organization.executeLifecycle", + target: "/api/organization/appointments/commands", + authorityVersion: "appointment-management", + idempotencyKey, + commandId: request.commandId, + request, + success: OrganizationLifecycleResult, + execute: (personId) => + Organization.use((service) => service.executeLifecycle(request, personId)), + }), + + "organization.readDelegationManagement": (_payload, { headers }) => + readManagement({ + headers, + rpc: ReadDelegationManagement, + authorityVersion: "delegation-management", + response: DelegationManagement, + read: (personId) => + Organization.use((service) => service.readDelegationManagement(personId)), + }), + + "organization.executeDelegation": ({ idempotencyKey, request }, { headers }) => + executeManagementCommand({ + headers, + rpc: ExecuteDelegation, + operationId: "organization.executeDelegation", + target: "/api/organization/delegations/commands", + authorityVersion: "delegation-management", + idempotencyKey, + commandId: request.commandId, + request, + success: DelegationResult, + execute: (personId) => + Organization.use((service) => service.executeDelegation(request, personId)), + }), + }); diff --git a/apps/backend/src/organization/team-interest.http.test.ts b/apps/backend/src/organization/team-interest.rpc.test.ts similarity index 51% rename from apps/backend/src/organization/team-interest.http.test.ts rename to apps/backend/src/organization/team-interest.rpc.test.ts index e4e54c2ee..0bcb6af26 100644 --- a/apps/backend/src/organization/team-interest.http.test.ts +++ b/apps/backend/src/organization/team-interest.rpc.test.ts @@ -1,5 +1,4 @@ import { OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; import { Identity, IdentityActor, @@ -8,24 +7,25 @@ import { } from "@vektorprogrammet/domain/identity"; import { DepartmentId, - TeamId, - SemesterId, - MembershipId, - OrganizationActorSchema, DepartmentJsonSchema, + MembershipId, Organization, - PersonId, type OrganizationOperations, + PersonId, + SemesterId, + TeamId, + type TeamInterestReadScope, } from "@vektorprogrammet/domain/organization"; -import { DateTime, Effect, Layer, Schema } from "effect"; +import { isProblem } from "@vektorprogrammet/rpc/problem"; import { describe, expect, it } from "@effect/vitest"; -import { decodeOrganizationApiConfig } from "./config.js"; -import { makeOrganizationTestHttp as makeOrganizationApiHttp } from "../test/native-http.js"; -import { PRIVATE_NO_STORE } from "../http-semantics.js"; +import { DateTime, Effect, Layer, Schema } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; /** - * Specs 0059/0060 gate matrix and wire shapes, driven through the backend - * authority flow: cookie -> resolveAuthority (one instant) -> leader scope. + * The team-interest gate matrix and wire shape, driven through the backend authority flow: + * credential -> one authorization instant -> leader scope. */ const department = Schema.decodeSync(DepartmentJsonSchema)( @@ -100,40 +100,12 @@ const registrationRows = [ }, ] as const; -let lastTeamInterestFilter: { - authorizedDepartmentIds: ReadonlyArray; - authorizedTeamIds: ReadonlyArray; - semesterId?: string; -}; +// The scope that the handler required on the last listing. +let lastTeamInterestScope: TeamInterestReadScope | undefined; // The departments that the organization store holds; a test may empty it. let storedDepartments = [department, secondDepartment]; -const organization = { - listDepartments: Effect.sync(() => storedDepartments), - listTeams: () => Effect.succeed([]), - listFieldOfStudies: Effect.succeed([]), - listTeamInterestRegistrations: (filter: { - authorizedDepartmentIds: ReadonlyArray; - authorizedTeamIds: ReadonlyArray; - semesterId?: string; - }) => - Effect.sync(() => { - lastTeamInterestFilter = filter; - - const rows = registrationRows - .filter( - (row) => - (filter.authorizedDepartmentIds.includes(row.departmentId) || - filter.authorizedTeamIds.includes(row.teamId)) && - (filter.semesterId === undefined || row.semesterId === filter.semesterId), - ) - .toSorted((left, right) => left.registrationId - right.registrationId); - - return rows.map((row) => ({ ...row })); - }), -} satisfies Partial; - type TokenMembership = { teamId: TeamId; departmentId: DepartmentId; @@ -158,8 +130,9 @@ const departmentOne = DepartmentId.make("department-1"); const departmentTwo = DepartmentId.make("department-2"); -const authorityForToken = (cookie: string | null): AuthorityByToken => { - if (cookie?.includes("admin-session")) { +/** Each session token names one caller; the person ID carries the token. */ +const authorityForToken = (token: string): AuthorityByToken => { + if (token === "admin-session") { return { globalAdministrator: "Active", memberships: [ @@ -174,7 +147,7 @@ const authorityForToken = (cookie: string | null): AuthorityByToken => { }; } - if (cookie?.includes("leader-session")) { + if (token === "leader-session") { return { globalAdministrator: "Absent", memberships: [ @@ -196,7 +169,7 @@ const authorityForToken = (cookie: string | null): AuthorityByToken => { }; } - if (cookie?.includes("team-captain")) { + if (token === "team-captain") { return { globalAdministrator: "Absent", memberships: [ @@ -211,7 +184,7 @@ const authorityForToken = (cookie: string | null): AuthorityByToken => { }; } - if (cookie?.includes("inactive-leader")) { + if (token === "inactive-leader") { return { globalAdministrator: "Absent", memberships: [ @@ -240,9 +213,51 @@ const authorityForToken = (cookie: string | null): AuthorityByToken => { }; }; -const config = decodeOrganizationApiConfig({ - ORGANIZATION_MAX_BODY_BYTES: "1024", -}); +const tokenPrefix = "better-auth.session_token="; + +const organization = { + listDepartments: Effect.sync(() => storedDepartments), + listTeams: () => Effect.succeed([]), + listFieldOfStudies: Effect.succeed([]), + resolvePersonAuthority: (personId: PersonId, evaluatedAt: string) => { + const authority = authorityForToken(personId); + + return Effect.succeed({ + personId, + evaluatedAt, + ...authority, + memberships: authority.memberships.map( + ({ leader, teamId, departmentId, active, unitKind }, index) => ({ + membershipId: MembershipId.make(`membership-${index}`), + teamId, + departmentId, + active, + unitLeader: leader, + unitKind, + teamScope: "HomeDepartment" as const, + departmentIndependent: true, + }), + ), + nationalBoardSeats: [], + delegations: [], + }); + }, + listTeamInterestRegistrations: (scope: TeamInterestReadScope, semesterId?: SemesterId) => + Effect.sync(() => { + lastTeamInterestScope = scope; + + const rows = registrationRows + .filter( + (row) => + (scope.departmentIds.includes(row.departmentId) || + scope.teams.some((team) => team.teamId === row.teamId)) && + (semesterId === undefined || row.semesterId === semesterId), + ) + .toSorted((left, right) => left.registrationId - right.registrationId); + + return rows.map((row) => ({ ...row })); + }), +} satisfies Partial; const oauthCredentialAuthority = OAuthCredentialAuthority.of({ resolve: () => Effect.die("unexpected OAuth credential resolution"), @@ -251,16 +266,20 @@ const oauthCredentialAuthority = OAuthCredentialAuthority.of({ const identity = Identity.of({ signIn: () => Effect.die("unexpected sign-in"), - resolveSession: (cookieHeader: string | undefined) => - cookieHeader === undefined || cookieHeader.length === 0 - ? Effect.fail(new IdentitySessionNotFound()) + resolveSession: (cookieHeader: string | undefined) => { + const token = + cookieHeader?.startsWith(tokenPrefix) === true ? cookieHeader.slice(tokenPrefix.length) : ""; + + return token.length === 0 + ? Effect.fail(IdentitySessionNotFound.make()) : Effect.succeed( - new IdentityActor({ - personId: PersonId.make("team-interest-person"), + IdentityActor.make({ + personId: PersonId.make(token), sessionId: "team-interest-session", expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), }), - ), + ); + }, readCurrentSession: () => Effect.die("unexpected session read"), listSessions: () => Effect.die("unexpected session list"), revokeCurrentSession: () => Effect.die("unexpected session mutation"), @@ -271,92 +290,61 @@ const identity = Identity.of({ signOut: () => Effect.succeed({ setCookies: [] }), } satisfies IdentityOperations); -const services = Layer.mergeAll( - Layer.mock(Organization, organization), - Layer.succeed(Identity, identity), - Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), -); +const backend = makeBackendTestRpc({ + config: backendTestConfig, + services: Layer.mergeAll( + Layer.mock(Organization, organization), + Layer.succeed(Identity, identity), + Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), + ), +}); -const http = makeOrganizationApiHttp( - { - config, - resolveActor: () => - Effect.succeed( - OrganizationActorSchema.members[1].make({ personId: PersonId.make("person-member") }), - ), - resolveAuthority: (request) => { - const cookie = request.headers.get("cookie"); - - if (cookie === null || cookie.length === 0) { - return Effect.fail(new UnauthenticatedActor({ message: "authentication required" })); - } - - const authority = authorityForToken(cookie); - - return Effect.succeed({ - personId: PersonId.make("person-any"), - evaluatedAt: "2031-09-15T12:00:00.000Z", - ...authority, - memberships: authority.memberships.map( - ({ leader, teamId, departmentId, active, unitKind }, index) => ({ - membershipId: MembershipId.make(`membership-${index}`), - teamId, - departmentId, - active, - unitLeader: leader, - unitKind, - teamScope: "HomeDepartment" as const, - departmentIndependent: true, - }), - ), - nationalBoardSeats: [], - delegations: [], - }); - }, - }, - services, -); +const asSession = + (token: string) => + (effect: Effect.Effect) => + RpcClient.withHeaders(effect, { cookie: `${tokenPrefix}${token}` }); -const get = (pathname: string, cookie?: string): Effect.Effect => - http.fetch( - new Request(`http://backend.test${pathname}`, { - headers: - cookie === undefined - ? {} - : { cookie: `better-auth.session_token=${cookie.replace(/^session=/, "")}` }, - }), +const codeOf = (effect: Effect.Effect) => + Effect.flip(effect).pipe( + Effect.map((failure) => (isProblem(failure) ? failure.code : String(failure))), ); -describe("spec 0059 team-interest HTTP boundary", () => { - it.live("answers 401 without a session before any data leaves the store", () => +describe("team-interest RPC boundary", () => { + it.live("answers credential.missing without a session before any data leaves the store", () => Effect.gen(function* () { - const response = yield* get("/api/team-interest-registrations"); - expect(response.status).toBe(401); + const client = yield* backend.client; + + expect(yield* codeOf(client["organization.listTeamInterest"]({}))).toBe("credential.missing"); }), ); - it.live("denies a plain member and an inactive leader with typed 403", () => + it.live("denies a plain member and an inactive leader", () => Effect.gen(function* () { - const member = yield* get("/api/team-interest-registrations", "session=member-session"); - expect(member.status).toBe(403); - expect(yield* Effect.promise(() => member.json())).toEqual({ - type: "urn:vektorprogrammet:problem:v0.2:authority.denied", - title: "Authority denied", - status: 403, - detail: "The authenticated principal is not permitted to perform this operation.", - code: "authority.denied", - }); + const client = yield* backend.client; - const inactive = yield* get("/api/team-interest-registrations", "session=inactive-leader"); - expect(inactive.status).toBe(403); + expect( + yield* codeOf( + client["organization.listTeamInterest"]({}).pipe(asSession("member-session")), + ), + ).toBe("authority.denied"); + + expect( + yield* codeOf( + client["organization.listTeamInterest"]({}).pipe(asSession("inactive-leader")), + ), + ).toBe("authority.denied"); }), ); - it.live("scopes a leader to their authorized union and emits the exact fixture envelope", () => + it.live("scopes a leader to their authorized union and answers the exact envelope", () => Effect.gen(function* () { - const response = yield* get("/api/team-interest-registrations", "session=leader-session"); - expect(response.status).toBe(200); - expect(yield* Effect.promise(() => response.json())).toEqual({ + const client = yield* backend.client; + + const response = yield* client["organization.listTeamInterest"]({}).pipe( + asSession("leader-session"), + ); + + expect(response).toEqual({ "hydra:member": [ { id: 1, userName: "User A", teamName: "Team One" }, { id: 2, userName: "User B", teamName: "Team One" }, @@ -364,37 +352,44 @@ describe("spec 0059 team-interest HTTP boundary", () => { "hydra:totalItems": 2, }); // Rows ordered registration_id ASC regardless of insert order. - expect(lastTeamInterestFilter.authorizedDepartmentIds).toEqual(["department-1"]); + expect(lastTeamInterestScope?.departmentIds).toEqual(["department-1"]); }), ); - it.live("lets an ordinary team's leader read the own team's interest only (O8-11)", () => + it.live("lets an ordinary team's leader read the own team's interest only", () => Effect.gen(function* () { - const response = yield* get("/api/team-interest-registrations", "session=team-captain"); - expect(response.status).toBe(200); - expect(yield* Effect.promise(() => response.json())).toEqual({ - "hydra:member": [{ id: 3, userName: "User C", teamName: "Team Two" }], - "hydra:totalItems": 1, - }); - expect(lastTeamInterestFilter.authorizedDepartmentIds).toEqual([]); - expect(lastTeamInterestFilter.authorizedTeamIds).toEqual(["team-2"]); + const client = yield* backend.client; - const otherDepartment = yield* get( - "/api/team-interest-registrations?department=department-1", - "session=team-captain", + const response = yield* client["organization.listTeamInterest"]({}).pipe( + asSession("team-captain"), ); - expect(otherDepartment.status).toBe(403); + expect(response).toEqual({ + "hydra:member": [{ id: 3, userName: "User C", teamName: "Team Two" }], + "hydra:totalItems": 1, + }); + expect(lastTeamInterestScope?.departmentIds).toEqual([]); + expect(lastTeamInterestScope?.teams.map(({ teamId }) => teamId)).toEqual(["team-2"]); + + expect( + yield* codeOf( + client["organization.listTeamInterest"]({ departmentId: departmentOne }).pipe( + asSession("team-captain"), + ), + ), + ).toBe("authority.denied"); }), ); it.live("gives a global administrator every department despite having one membership", () => Effect.gen(function* () { - const teamInterest = yield* get("/api/team-interest-registrations", "session=admin-session"); - expect(teamInterest.status).toBe(200); - // The contract declares a private read; the dashboard's SDK rejects any other cache policy. - expect(teamInterest.headers.get("cache-control")).toBe(PRIVATE_NO_STORE); - expect(yield* Effect.promise(() => teamInterest.json())).toEqual({ + const client = yield* backend.client; + + const response = yield* client["organization.listTeamInterest"]({}).pipe( + asSession("admin-session"), + ); + + expect(response).toEqual({ "hydra:member": [ { id: 1, userName: "User A", teamName: "Team One" }, { id: 2, userName: "User B", teamName: "Team One" }, @@ -402,10 +397,7 @@ describe("spec 0059 team-interest HTTP boundary", () => { ], "hydra:totalItems": 3, }); - expect(lastTeamInterestFilter.authorizedDepartmentIds).toEqual([ - "department-1", - "department-2", - ]); + expect(lastTeamInterestScope?.departmentIds).toEqual(["department-1", "department-2"]); }), ); @@ -413,13 +405,13 @@ describe("spec 0059 team-interest HTTP boundary", () => { Effect.gen(function* () { storedDepartments = []; - const teamInterest = yield* get("/api/team-interest-registrations", "session=admin-session"); + const client = yield* backend.client; - expect(teamInterest.status).toBe(200); - expect(yield* Effect.promise(() => teamInterest.json())).toEqual({ - "hydra:member": [], - "hydra:totalItems": 0, - }); + const response = yield* client["organization.listTeamInterest"]({}).pipe( + asSession("admin-session"), + ); + + expect(response).toEqual({ "hydra:member": [], "hydra:totalItems": 0 }); }).pipe( Effect.ensuring( Effect.sync(() => { @@ -429,34 +421,23 @@ describe("spec 0059 team-interest HTTP boundary", () => { ), ); - it.live("rejects an unknown mailing-list type at the decode boundary", () => + it.live("narrows by department inside scope and denies a department outside it", () => Effect.gen(function* () { - const response = yield* get("/api/mailing-lists?type=unknown", "session=admin-session"); - expect(response.status).toBe(400); - expect(yield* Effect.promise(() => response.json())).toEqual({ - type: "urn:vektorprogrammet:problem:v0.2:request.malformed", - title: "Malformed request", - status: 400, - detail: "The request is malformed.", - code: "request.malformed", - }); - }), - ); - it.live("narrows by department inside scope and denies out-of-scope with 403", () => - Effect.gen(function* () { - const inScope = yield* get( - "/api/team-interest-registrations?department=department-1", - "session=leader-session", - ); + const client = yield* backend.client; - expect(inScope.status).toBe(200); + const inScope = yield* client["organization.listTeamInterest"]({ + departmentId: departmentOne, + }).pipe(asSession("leader-session")); - const outOfScope = yield* get( - "/api/team-interest-registrations?department=department-2", - "session=leader-session", - ); + expect(inScope["hydra:totalItems"]).toBe(2); - expect(outOfScope.status).toBe(403); + expect( + yield* codeOf( + client["organization.listTeamInterest"]({ departmentId: departmentTwo }).pipe( + asSession("leader-session"), + ), + ), + ).toBe("authority.denied"); }), ); }); diff --git a/apps/backend/src/password-recovery/AGENTS.md b/apps/backend/src/password-recovery/AGENTS.md index 5c44c3067..c6202816e 100644 --- a/apps/backend/src/password-recovery/AGENTS.md +++ b/apps/backend/src/password-recovery/AGENTS.md @@ -3,7 +3,7 @@ # apps/backend/src/password-recovery This folder holds Identity code under another name. The password recovery worker. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The Identity context in other folders: diff --git a/apps/backend/src/password-recovery/drain-main.ts b/apps/backend/src/password-recovery/drain-main.ts index 06e178042..8cb56205f 100644 --- a/apps/backend/src/password-recovery/drain-main.ts +++ b/apps/backend/src/password-recovery/drain-main.ts @@ -14,7 +14,7 @@ if (process.argv.length !== 3 || process.argv[2] !== "--once") // An unset or empty variable is missing. const sender = Effect.runSync(Config.String("MAIL_SENDER").pipe(Config.withDefault(""))); -if (!sender) throw new Error("MAIL_SENDER is required"); +if (sender.length === 0) throw new Error("MAIL_SENDER is required"); const config = Effect.runSync(decodeBackendConfig(process.env)); diff --git a/apps/backend/src/placements/AGENTS.md b/apps/backend/src/placements/AGENTS.md index aac09b6fd..52e5506ba 100644 --- a/apps/backend/src/placements/AGENTS.md +++ b/apps/backend/src/placements/AGENTS.md @@ -3,7 +3,7 @@ # apps/backend/src/placements This folder holds the Placements bounded context in `apps/backend/src`. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The Placements context in other folders: @@ -48,7 +48,7 @@ Implementation: packages/domain/src/placements: portable contracts; packages/dat - `Person` and `Profile` of [People](../directory/AGENTS.md) - `SemesterCatalogue` of AcademicCalendar - `Department` of [Organization](../organization/AGENTS.md) -- `School` and `CapacityPlan` of [Schools](../schools/AGENTS.md) +- `School` and `CapacityPlan` of Schools - `AdmissionApplication` of [Admissions](../admission/AGENTS.md) - `OutboxEnvelope` of [Delivery](../delivery/AGENTS.md) @@ -60,7 +60,7 @@ Implementation: packages/domain/src/placements: portable contracts; packages/dat | [People](../directory/AGENTS.md) | open host service, published language, conformist | | | AcademicCalendar | open host service, published language, conformist | | | [Organization](../organization/AGENTS.md) | open host service, published language, conformist | | -| [Schools](../schools/AGENTS.md) | supplier, customer | Active school, department association and capacity, which bounds the scheduling function; Placements answers the dependent-records query Schools needs before an association is removed | +| Schools | supplier, customer | Active school, department association and capacity, which bounds the scheduling function; Placements answers the dependent-records query Schools needs before an association is removed | | [Admissions](../admission/AGENTS.md) | open host service, published language, conformist | The scheduling function reads each accepted applicant's weekday availability, bolk and school wishes from the application; Placements never writes them | | [Delivery](../delivery/AGENTS.md) | open host service, published language, conformist | | | LegacySymfony | anticorruption layer | Reviewed current assignments and append-only historical service | diff --git a/apps/backend/src/placements/certificate-pdf.ts b/apps/backend/src/placements/certificate-pdf.ts index 51599ae22..389e2d5b6 100644 --- a/apps/backend/src/placements/certificate-pdf.ts +++ b/apps/backend/src/placements/certificate-pdf.ts @@ -8,6 +8,7 @@ import type { CertificateIssue } from "@vektorprogrammet/domain/placements"; import { Context, Data, Effect, FileSystem, Layer, Path, Result } from "effect"; import { sha256Hex } from "../http-semantics.js"; import { parseTrueType, type TrueTypeFont, type TrueTypeSubset } from "./true-type.js"; +import { dual } from "effect/Function"; /** A text on the certificate that no glyph of the embedded fonts covers. */ export class CertificateUnprintable extends Data.TaggedError("CertificateUnprintable")<{ @@ -21,7 +22,7 @@ export interface CertificateFaces { } export class CertificateFonts extends Context.Service()( - "@vektorprogrammet/backend/CertificateFonts", + "@vektorprogrammet/backend/placements/certificate-pdf/CertificateFonts", ) {} /** Reads and parses the certificate fonts once, when the process composes its handlers. */ @@ -340,11 +341,7 @@ const faceObjects = ( ]; }; -/** - * Renders one issue: the assistant, the department, every included semester with its schools - * and confirmed days, the issuer's name and seat title, and the issue date. - */ -export const renderCertificatePdf = ( +const renderCertificatePdfWith = ( issue: CertificateIssue, faces: CertificateFaces, ): Result.Result, CertificateUnprintable> => @@ -499,3 +496,17 @@ export const renderCertificatePdf = ( ...pages, ]); }); + +/** + * Renders one issue: the assistant, the department, every included semester with its schools + * and confirmed days, the issuer's name and seat title, and the issue date. + */ +export const renderCertificatePdf: { + ( + faces: CertificateFaces, + ): (issue: CertificateIssue) => Result.Result, CertificateUnprintable>; + ( + issue: CertificateIssue, + faces: CertificateFaces, + ): Result.Result, CertificateUnprintable>; +} = dual(2, renderCertificatePdfWith); diff --git a/apps/backend/src/placements/certificates-http.ts b/apps/backend/src/placements/certificates-http.ts deleted file mode 100644 index aa36dc4b4..000000000 --- a/apps/backend/src/placements/certificates-http.ts +++ /dev/null @@ -1,533 +0,0 @@ -/** - * Native HTTP handlers of days served and certificates. Reads answer from one repeatable-read - * snapshot. Commands run through the receipt executor: current authority before any replay, the - * `If-Match` precondition on the fresh entry or certificate, and one retry of a serialization - * conflict. The certificate PDF is rendered from the recorded issue inside the transaction. - */ -import { Database } from "@vektorprogrammet/database"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { type CredentialOutcome, DomainId, Scope } from "@vektorprogrammet/domain/authz"; -import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import type { DepartmentId, PersonId, SemesterId } from "@vektorprogrammet/domain/organization"; -import { - CertificateCommandTarget, - daysServedEntryVersion, - Placements, - type CertificateCommandFailure, - type CertificatePreview, - type CertificatePrincipal, - type CertificateReadFailure, - type DaysServedEntry, -} from "@vektorprogrammet/domain/placements"; -import { - CertificateListResponse, - CertificatePreviewResource, - CertificateScopes, - ConfirmDaysServedEndpoint, - ConfirmDaysServedInput, - DaysServedEntryResource, - DaysServedListResponse, - ExternalNativeApi, - IssueCertificateEndpoint, - ListCertificatesEndpoint, - ListDaysServedEndpoint, - ReadCertificateEndpoint, - ReadCertificateScopesEndpoint, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { - type CredentialPresentation, - makeNativeValidationError, - Problem, -} from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Option, Predicate, Schema } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import type { SqlError } from "effect/unstable/sql/SqlError"; -import { resolveRequestCredentialInTransaction } from "../authority.js"; -import { - authorizePerson, - commandOutcomeResponse, - commandReceiptProblems, - httpIdentity, - idempotencyKeyOf, - isSerializationConflict, - jsonText, - personPresentation, - problemMapper, - readJsonBody, - requireCurrentETag, - requiredIfMatchOf, - requireNoQuery, - strictOutput, - webHandler, -} from "../http-api/problem.js"; -import { - executeNativeHttpCommandPostgres, - type NativeHttpResponseCapsule, -} from "../http-api/receipt-transaction.js"; -import { - deriveStrongETag, - jsonBodyBytes, - normalizeTarget, - PRIVATE_NO_STORE, - semanticMutationRequest, - semanticRequestDigest, -} from "../http-semantics.js"; -import { genericContext } from "../native-operation.js"; -import { - type CertificateFaces, - CertificateFonts, - type CertificateUnprintable, - renderCertificatePdf, -} from "./certificate-pdf.js"; - -/** A total is a small integer; the body is bounded well above it. */ -const MAX_CONFIRMATION_BYTES = 1_024; - -type AcceptedCredential = Extract; - -interface Reader { - readonly credential: AcceptedCredential; - readonly principal: CertificatePrincipal; -} - -type CertificateEndpoint = - | typeof ReadCertificateScopesEndpoint - | typeof ListDaysServedEndpoint - | typeof ConfirmDaysServedEndpoint - | typeof ListCertificatesEndpoint - | typeof ReadCertificateEndpoint - | typeof IssueCertificateEndpoint; - -/** The one answer for every days-served and certificate failure. */ -const certificateProblems = problemMapper< - CertificateReadFailure | CertificateCommandFailure | CertificateUnprintable ->()({ - CertificateAccessDenied: () => Problem.make("authority.denied"), - CertificateScopeNotFound: () => Problem.make("resource.not-found"), - CertificateAssistantNotFound: () => Problem.make("resource.not-found"), - CertificateInvalidCursor: () => Problem.make("request.malformed"), - CertificateEmpty: () => Problem.make("certificate.empty"), - CertificateUnprintable: () => Problem.make("certificate.unprintable"), - CertificatePersistenceError: (failure) => - failure.conflict ? Problem.make("transaction.conflict") : Problem.make("internal.error"), -}); - -/** A person credential rejected inside the transaction is answered from the request's evidence. */ -const credentialProblems = (presentation: CredentialPresentation) => - problemMapper()({ - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - IdentityEngineError: () => Problem.make("internal.error"), - }); - -/** The snapshot transaction of a read: a lost serialization race is a conflict. */ -const snapshotProblems = problemMapper()({ - SqlError: (failure) => - isSerializationConflict(failure) - ? Problem.make("transaction.conflict") - : Problem.make("internal.error"), -}); - -/** An entry's tag names its revision and the evidence that a confirmation would record. */ -const entryETag = (entry: DaysServedEntry) => - deriveStrongETag({ - representationKind: "DaysServedEntry", - resourceIdentity: `${entry.departmentId}/${entry.semesterId}/${entry.personId}`, - version: daysServedEntryVersion(entry), - }); - -/** A certificate's tag names the content that an issue would record. */ -const certificateETag = ( - preview: Pick, -) => - deriveStrongETag({ - representationKind: "Certificate", - resourceIdentity: `${preview.departmentId}/${preview.personId}`, - version: preview.contentSha256 ?? "empty", - }); - -const entryResource = (entry: DaysServedEntry) => ({ ...entry, etag: entryETag(entry) }); - -const privateJson = - >(schema: S) => - (body: S["Type"]) => - Effect.gen(function* () { - const checked = yield* strictOutput(schema)(body); - - return new Response(yield* jsonText(checked), { - headers: { - "content-type": "application/json", - "cache-control": PRIVATE_NO_STORE, - vary: "Origin", - }, - }); - }); - -/** Resolves the current Person credential through the caller's transaction at one instant. */ -const reader = (request: Request) => - Effect.gen(function* () { - const authenticated = yield* resolveRequestCredentialInTransaction(request, "OAuthUserBearer"); - const principal = authenticated.credential.principal; - - if (!Predicate.isTagged(principal, "Person")) { - return yield* new UnauthenticatedActor({ message: "authentication required" }); - } - - return { - credential: authenticated.credential, - principal: { - personId: principal.personId, - authorizationInstant: authenticated.authorizationInstant, - }, - } satisfies Reader; - }); - -/** Evaluates the declared AccessSpec in the department the domain decided for, or organization-wide. */ -const authorizeReader = ( - request: Request, - endpoint: CertificateEndpoint, - current: Reader, - departmentId: DepartmentId | null, -) => - authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(endpoint)), - credential: current.credential, - personId: current.principal.personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "organization", - departmentId: departmentId ?? undefined, - authorityVersion: `certificates:${current.principal.authorizationInstant}`, - }), - ], - }, - grantScopes: - departmentId === null - ? [Scope.Domain({ domainId: DomainId.make("organization") })] - : [Scope.Department({ departmentId })], - now: current.principal.authorizationInstant, - }, - personPresentation(request), - ); - -/** Answers one read from a repeatable-read snapshot; every check runs inside it. */ -const snapshotRead = (effect: Effect.Effect) => - Database.use((sql) => - sql.withTransaction( - Effect.gen(function* () { - yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`; - - return yield* effect; - }), - ), - ).pipe(snapshotProblems); - -/** The only query member is one optional cursor. */ -const cursorQuery = (request: Request) => - Effect.suspend(() => { - const parameters = new URL(request.url).searchParams; - - return [...parameters.keys()].some((key) => key !== "cursor") || - parameters.getAll("cursor").length > 1 - ? Effect.fail(Problem.make("request.malformed")) - : Effect.succeed(parameters.get("cursor") ?? undefined); - }); - -const readScopes = (request: Request) => { - const presentation = personPresentation(request); - - return snapshotRead( - Effect.gen(function* () { - yield* requireNoQuery(request); - const current = yield* reader(request); - - const scopes = yield* Placements.use((placements) => - placements.readCertificateScopes(current.principal), - ); - - yield* authorizeReader(request, ReadCertificateScopesEndpoint, current, null); - - return yield* privateJson(CertificateScopes)(scopes); - }), - ).pipe(certificateProblems, credentialProblems(presentation)); -}; - -const listDaysServed = (request: Request, departmentId: DepartmentId, semesterId: SemesterId) => { - const presentation = personPresentation(request); - - return snapshotRead( - Effect.gen(function* () { - const cursor = yield* cursorQuery(request); - const current = yield* reader(request); - - const page = yield* Placements.use((placements) => - placements.readDaysServed(current.principal, { departmentId, semesterId }, cursor), - ); - - yield* authorizeReader(request, ListDaysServedEndpoint, current, departmentId); - - const body = { - departmentId, - departmentName: page.departmentName, - semester: page.semester, - items: page.items.map(entryResource), - }; - - return yield* privateJson(DaysServedListResponse)( - page.nextCursor === undefined ? body : { ...body, nextCursor: page.nextCursor }, - ); - }), - ).pipe(certificateProblems, credentialProblems(presentation)); -}; - -const listCertificates = (request: Request, departmentId: DepartmentId) => { - const presentation = personPresentation(request); - - return snapshotRead( - Effect.gen(function* () { - const cursor = yield* cursorQuery(request); - const current = yield* reader(request); - - const page = yield* Placements.use((placements) => - placements.listCertificates(current.principal, departmentId, cursor), - ); - - yield* authorizeReader(request, ListCertificatesEndpoint, current, departmentId); - - const body = { departmentId, departmentName: page.departmentName, items: page.items }; - - return yield* privateJson(CertificateListResponse)( - page.nextCursor === undefined ? body : { ...body, nextCursor: page.nextCursor }, - ); - }), - ).pipe(certificateProblems, credentialProblems(presentation)); -}; - -const readCertificate = (request: Request, departmentId: DepartmentId, personId: PersonId) => { - const presentation = personPresentation(request); - - return snapshotRead( - Effect.gen(function* () { - yield* requireNoQuery(request); - const current = yield* reader(request); - - const preview = yield* Placements.use((placements) => - placements.readCertificate(current.principal, departmentId, personId), - ); - - yield* authorizeReader(request, ReadCertificateEndpoint, current, departmentId); - - return yield* privateJson(CertificatePreviewResource)({ - ...preview, - etag: certificateETag(preview), - }); - }), - ).pipe(certificateProblems, credentialProblems(presentation)); -}; - -const confirmDaysServed = ( - request: Request, - departmentId: DepartmentId, - semesterId: SemesterId, - personId: PersonId, -) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - const body = yield* readJsonBody( - request, - /^application\/json(?:\s*;|$)/iu, - MAX_CONFIRMATION_BYTES, - ); - - const input = Schema.decodeUnknownOption(ConfirmDaysServedInput)(body, { - onExcessProperty: "error", - }); - - if (Option.isNone(input)) { - return yield* Problem.validation("validation.failed", [ - makeNativeValidationError("/total", "invalid"), - ]); - } - - const ifMatch = yield* requiredIfMatchOf(request); - const idempotencyKey = yield* idempotencyKeyOf(request); - const presentation = personPresentation(request); - const operationId = "certificates.confirmDaysServed"; - - // Domain and credential failures are mapped after the executor, whose retry reads their causes. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const current = yield* reader(request); - - // Authority is current before any stored response can be replayed. - yield* Placements.use((placements) => - placements.authorizeCertificateCommand( - current.principal, - CertificateCommandTarget.ConfirmDaysServed({ departmentId, semesterId }), - ), - ); - yield* authorizeReader(request, ConfirmDaysServedEndpoint, current, departmentId); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${current.principal.personId}`, - qualifiedOperationId: operationId, - normalizedTarget: normalizeTarget( - "/api/departments/{departmentId}/semesters/{semesterId}/days-served/{personId}", - { departmentId, semesterId, personId }, - ), - idempotencyKey, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest(semanticMutationRequest(body, ifMatch)), - operationId, - }, - execute: Placements.use((placements) => - placements.confirmDaysServed( - current.principal, - { - commandId: identity.identitySha256, - departmentId, - semesterId, - personId, - total: input.value.total, - }, - (entry) => requireCurrentETag(entryETag(entry), ifMatch), - ), - ).pipe( - Effect.flatMap((entry) => strictOutput(DaysServedEntryResource)(entryResource(entry))), - Effect.map( - (resource): NativeHttpResponseCapsule => ({ - status: 200, - mediaType: "application/json", - headers: { "content-type": "application/json", etag: resource.etag }, - bodyBytes: jsonBodyBytes(resource), - }), - ), - ), - }; - }), - { retry: "serialization-once" }, - ).pipe(certificateProblems, commandReceiptProblems, credentialProblems(presentation)); - - return yield* commandOutcomeResponse(outcome); - }); - -const issueCertificate = ( - request: Request, - departmentId: DepartmentId, - personId: PersonId, - faces: CertificateFaces, -) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - const ifMatch = yield* requiredIfMatchOf(request); - const idempotencyKey = yield* idempotencyKeyOf(request); - const presentation = personPresentation(request); - const operationId = "certificates.issueCertificate"; - - // Domain and credential failures are mapped after the executor, whose retry reads their causes. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const current = yield* reader(request); - - // A revoked seat cannot replay a stored certificate. - yield* Placements.use((placements) => - placements.authorizeCertificateCommand( - current.principal, - CertificateCommandTarget.IssueCertificate({ departmentId, personId }), - ), - ); - yield* authorizeReader(request, IssueCertificateEndpoint, current, departmentId); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${current.principal.personId}`, - qualifiedOperationId: operationId, - normalizedTarget: normalizeTarget( - "/api/departments/{departmentId}/certificates/{personId}/issues", - { departmentId, personId }, - ), - idempotencyKey, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({ ifMatch }), - operationId, - }, - // The PDF renders before commit, so an unprintable certificate records no issue. - execute: Placements.use((placements) => - placements.issueCertificate( - current.principal, - { commandId: identity.identitySha256, departmentId, personId }, - (preview) => requireCurrentETag(certificateETag(preview), ifMatch), - ), - ).pipe( - Effect.flatMap((issue) => - Effect.map( - Effect.fromResult(renderCertificatePdf(issue, faces)), - (pdf): NativeHttpResponseCapsule => ({ - status: 200, - mediaType: "application/pdf", - headers: { - "content-type": "application/pdf", - etag: certificateETag({ - departmentId, - personId, - contentSha256: issue.contentSha256, - }), - }, - bodyBytes: pdf, - }), - ), - ), - ), - }; - }), - { retry: "serialization-once" }, - ).pipe(certificateProblems, commandReceiptProblems, credentialProblems(presentation)); - - return yield* commandOutcomeResponse(outcome); - }); - -/** Native HttpApi handlers of days served and certificates; the fonts load once, with the group. */ -export const CertificatesApiHandlers = HttpApiBuilder.group( - ExternalNativeApi, - "certificates", - (handlers) => - CertificateFonts.useSync((faces) => - handlers - .handleRaw("readCertificateScopes", ({ request }) => webHandler(request, readScopes)) - .handleRaw("listDaysServed", ({ request, params }) => - webHandler(request, (webRequest) => - listDaysServed(webRequest, params.departmentId, params.semesterId), - ), - ) - .handleRaw("confirmDaysServed", ({ request, params }) => - webHandler(request, (webRequest) => - confirmDaysServed(webRequest, params.departmentId, params.semesterId, params.personId), - ), - ) - .handleRaw("listCertificates", ({ request, params }) => - webHandler(request, (webRequest) => listCertificates(webRequest, params.departmentId)), - ) - .handleRaw("readCertificate", ({ request, params }) => - webHandler(request, (webRequest) => - readCertificate(webRequest, params.departmentId, params.personId), - ), - ) - .handleRaw("issueCertificate", ({ request, params }) => - webHandler(request, (webRequest) => - issueCertificate(webRequest, params.departmentId, params.personId, faces), - ), - ), - ), -); diff --git a/apps/backend/src/placements/certificates-http.test.ts b/apps/backend/src/placements/certificates-rpc.test.ts similarity index 61% rename from apps/backend/src/placements/certificates-http.test.ts rename to apps/backend/src/placements/certificates-rpc.test.ts index cc9c64300..51a507df3 100644 --- a/apps/backend/src/placements/certificates-http.test.ts +++ b/apps/backend/src/placements/certificates-rpc.test.ts @@ -1,9 +1,10 @@ /** - * Days served and certificates over HTTP on PostgreSQL (docs/specs/certificates-days-served.md): - * the confirmation precondition, denial of everyone but the department's issuers, the replay of - * one issue key, authority checked before a replay, a commit failure recovered in the same - * request, and text that the certificate font cannot print. + * Days served and certificates over RPC on PostgreSQL: the confirmation precondition, denial of + * everyone but the department's issuers, the replay of one issue key, authority checked before a + * replay, a commit failure recovered in the same request, and text that the certificate font + * cannot print. */ +import { describe, expect, it } from "@effect/vitest"; import { Database, IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; import { PlacementsLive } from "@vektorprogrammet/database/placements"; import { @@ -12,20 +13,14 @@ import { IdentitySessionNotFound, type IdentityOperations, } from "@vektorprogrammet/domain/identity"; -import { PersonId } from "@vektorprogrammet/domain/organization"; -import { - CertificatePreviewResource, - CertificateScopes, - CertificatesConfirmProblem, - CertificatesIssueProblem, - CertificatesReadProblem, - DaysServedEntryResource, - DaysServedListResponse, -} from "@vektorprogrammet/http-api"; -import { DateTime, Effect, Layer, Schema } from "effect"; -import { describe, expect, it } from "@effect/vitest"; +import { DepartmentId, PersonId, SemesterId } from "@vektorprogrammet/domain/organization"; +import { DaysServedTotal } from "@vektorprogrammet/domain/placements"; +import { IdempotencyKey, isProblem, type StrongETag } from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Layer } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; import { backendDatabase } from "../../test/database.js"; -import { makeCertificatesTestHttp } from "../test/native-http.js"; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; const expiresAt = DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"); @@ -33,7 +28,7 @@ const sessionPerson = (cookie: string | undefined) => /better-auth\.session_token=([^;]+)/u.exec(cookie ?? "")?.[1]; const actorFor = (personId: string) => - new IdentityActor({ + IdentityActor.make({ personId: PersonId.make(personId), sessionId: `session-${personId}`, expiresAt, @@ -45,7 +40,7 @@ const identity = Identity.of({ const person = sessionPerson(cookie); return person === undefined - ? Effect.fail(new IdentitySessionNotFound()) + ? Effect.fail(IdentitySessionNotFound.make({})) : Effect.succeed(actorFor(person)); }, readCurrentSession: () => Effect.die("unexpected session read"), @@ -63,7 +58,7 @@ const identitySnapshot = IdentitySnapshot.of({ const person = sessionPerson(cookie); return person === undefined - ? Effect.fail(new IdentitySessionNotFound()) + ? Effect.fail(IdentitySessionNotFound.make({})) : Effect.succeed(actorFor(person)); }, revokeCurrentSession: () => Effect.die("unexpected session mutation"), @@ -77,9 +72,9 @@ const oauth = OAuthCredentialAuthority.of({ resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), }); -const department = "cert-http-trondheim"; +const department = DepartmentId.make("cert-http-trondheim"); -const spring = "cert-http-spring"; +const spring = SemesterId.make("cert-http-spring"); const person = { leader: "cert-http-leader", @@ -154,38 +149,20 @@ const seed = Database.use((sql) => }), ); -type TestRequest = ( - pathname: string, - init?: RequestInit & { readonly person?: string }, -) => Effect.Effect; - /** Each fixture is one backend process with its own database. */ const fixture = () => { const database = backendDatabase(seed); - const http = makeCertificatesTestHttp( - Layer.mergeAll( + const { client } = makeBackendTestRpc({ + config: backendTestConfig, + services: Layer.mergeAll( database.layer, PlacementsLive.pipe(Layer.provide(database.layer)), Layer.succeed(Identity, identity), Layer.succeed(IdentitySnapshot, identitySnapshot), Layer.succeed(OAuthCredentialAuthority, oauth), ), - ); - - const request: TestRequest = (pathname, init = {}) => { - const headers = new Headers(init.headers); - - if (init.person !== undefined) { - headers.set("cookie", `better-auth.session_token=${init.person}`); - } - - if (init.method !== undefined && init.method !== "GET") { - headers.set("origin", "http://127.0.0.1:5174"); - } - - return http.fetch(new Request(`http://backend.test${pathname}`, { ...init, headers })); - }; + }); const count = (table: string, personId: string) => database.run( @@ -197,106 +174,88 @@ const fixture = () => { ).pipe(Effect.map((rows) => rows[0]?.count ?? 0)), ); - return { database, request, count }; + return { database, client, count }; }; -const key = (value: string) => value.padEnd(22, "0"); +type Client = Effect.Success["client"]>; -/** Decodes a response body through its declared contract schema. */ -const decoded = >( - response: Response, - schema: S, -) => - Effect.promise(() => response.json()).pipe( - Effect.flatMap((body) => - Schema.decodeUnknownEffect(schema)(body, { onExcessProperty: "error" }), - ), - ); +const as = (personId: string) => + RpcClient.withHeaders({ cookie: `better-auth.session_token=${personId}` }); -const bytes = (response: Response) => - Effect.promise(() => response.arrayBuffer()).pipe(Effect.map((buffer) => new Uint8Array(buffer))); +const key = (value: string) => IdempotencyKey.make(value.padEnd(22, "0")); -const daysServedPath = `/api/departments/${department}/semesters/${spring}/days-served`; +const problemCode = (failure: E) => (isProblem(failure) ? failure.code : failure); -const certificatePath = (personId: string) => - `/api/departments/${department}/certificates/${personId}`; +const daysServedScope = { departmentId: department, semesterId: spring }; const confirm = ( - request: TestRequest, - input: { readonly personId: string; readonly ifMatch: string; readonly key: string }, + client: Client, + input: { readonly personId: string; readonly ifMatch: StrongETag; readonly key: string }, total: number, ) => - request(`${daysServedPath}/${input.personId}`, { - method: "POST", - person: person.leader, - headers: { - "content-type": "application/json", - "idempotency-key": key(input.key), - "if-match": input.ifMatch, - }, - body: JSON.stringify({ total }), - }); + client["certificates.confirmDaysServed"]({ + ...daysServedScope, + personId: PersonId.make(input.personId), + idempotencyKey: key(input.key), + ifMatch: input.ifMatch, + request: { total: DaysServedTotal.make(total) }, + }).pipe(as(person.leader)); const issue = ( - request: TestRequest, + client: Client, input: { readonly issuer: string; readonly personId: string; - readonly ifMatch: string; + readonly ifMatch: StrongETag; readonly key: string; }, ) => - request(`${certificatePath(input.personId)}/issues`, { - method: "POST", - person: input.issuer, - headers: { "idempotency-key": key(input.key), "if-match": input.ifMatch }, - }); + client["certificates.issueCertificate"]({ + departmentId: department, + personId: PersonId.make(input.personId), + idempotencyKey: key(input.key), + ifMatch: input.ifMatch, + }).pipe(as(input.issuer)); /** The leader reads the assistant's entry and confirms the calculated count. */ -const confirmCalculated = (request: TestRequest, personId: string) => +const confirmCalculated = (client: Client, personId: string) => Effect.gen(function* () { - const list = yield* decoded( - yield* request(daysServedPath, { person: person.leader }), - DaysServedListResponse, + const list = yield* client["certificates.listDaysServed"](daysServedScope).pipe( + as(person.leader), ); const entry = list.items.find((item) => item.personId === personId); expect(entry).toBeDefined(); - const confirmed = yield* confirm( - request, - { personId, ifMatch: entry?.etag ?? "", key: "calculated" }, - entry?.calculated ?? 0, - ); + if (entry === undefined) return; - expect(confirmed.status).toBe(200); + yield* confirm(client, { personId, ifMatch: entry.etag, key: "calculated" }, entry.calculated); }); /** The certificate as the leader previews it, with the tag that an issue repeats. */ -const preview = (request: TestRequest, personId: string) => - Effect.gen(function* () { - const response = yield* request(certificatePath(personId), { person: person.leader }); - - expect(response.status).toBe(200); - - return yield* decoded(response, CertificatePreviewResource); - }); +const preview = (client: Client, personId: string) => + client["certificates.readCertificate"]({ + departmentId: department, + personId: PersonId.make(personId), + }).pipe(as(person.leader)); -describe("certificate scopes over HTTP", () => { +describe("certificate scopes over RPC", () => { it.live("grants the scope read to either capability and denies everyone else", () => Effect.gen(function* () { - const { request, count } = fixture(); - const leader = yield* request("/api/certificate-scopes", { person: person.leader }); + const { client: connect, count } = fixture(); + const client = yield* connect; + const leader = yield* client["certificates.readCertificateScopes"]().pipe(as(person.leader)); - const coordinator = yield* request("/api/certificate-scopes", { - person: person.coordinator, - }); + const coordinator = yield* client["certificates.readCertificateScopes"]().pipe( + as(person.coordinator), + ); - const outsider = yield* request("/api/certificate-scopes", { person: person.outsider }); + const outsider = yield* Effect.flip( + client["certificates.readCertificateScopes"]().pipe(as(person.outsider)), + ); - expect(leader.status).toBe(200); - expect((yield* decoded(leader, CertificateScopes)).departments).toEqual([ + expect(leader.departments).toEqual([ { departmentId: department, name: "Trondheim", @@ -304,8 +263,7 @@ describe("certificate scopes over HTTP", () => { issueCertificates: true, }, ]); - expect(coordinator.status).toBe(200); - expect((yield* decoded(coordinator, CertificateScopes)).departments).toEqual([ + expect(coordinator.departments).toEqual([ { departmentId: department, name: "Trondheim", @@ -313,84 +271,75 @@ describe("certificate scopes over HTTP", () => { issueCertificates: false, }, ]); - expect(outsider.status).toBe(403); - expect((yield* decoded(outsider, CertificatesReadProblem)).code).toBe("authority.denied"); + expect(problemCode(outsider)).toBe("authority.denied"); // The delegated capability confirms days served and issues nothing. - expect((yield* request(daysServedPath, { person: person.coordinator })).status).toBe(200); + yield* client["certificates.listDaysServed"](daysServedScope).pipe(as(person.coordinator)); - yield* confirmCalculated(request, person.ada); + yield* confirmCalculated(client, person.ada); - const { etag } = yield* preview(request, person.ada); + const { etag } = yield* preview(client, person.ada); - const denied = yield* issue(request, { - issuer: person.coordinator, - personId: person.ada, - ifMatch: etag, - key: "coordinator", - }); + const denied = yield* Effect.flip( + issue(client, { + issuer: person.coordinator, + personId: person.ada, + ifMatch: etag, + key: "coordinator", + }), + ); - expect(denied.status).toBe(403); + expect(problemCode(denied)).toBe("authority.denied"); expect(yield* count("certificate_issues", person.ada)).toBe(0); }), ); }); -describe("days served over HTTP", () => { +describe("days served over RPC", () => { it.live("confirms at the observed tag, refuses a stale one, and reads back the result", () => Effect.gen(function* () { - const { request, count } = fixture(); + const { client: connect, count } = fixture(); + const client = yield* connect; - const before = yield* decoded( - yield* request(daysServedPath, { person: person.leader }), - DaysServedListResponse, + const before = yield* client["certificates.listDaysServed"](daysServedScope).pipe( + as(person.leader), ); const ada = before.items.find((item) => item.personId === person.ada); expect(ada).toMatchObject({ calculated: 6, revision: 0, confirmation: null }); - const observed = ada?.etag ?? ""; + if (ada === undefined) return; - const confirmed = yield* confirm( - request, - { personId: person.ada, ifMatch: observed, key: "first" }, + const entry = yield* confirm( + client, + { personId: person.ada, ifMatch: ada.etag, key: "first" }, 6, ); - expect(confirmed.status).toBe(200); - - const entry = yield* decoded(confirmed, DaysServedEntryResource); - expect(entry).toMatchObject({ revision: 1, confirmation: { revision: 1, total: 6, calculated: 6, confirmedBy: person.leader }, }); - expect(confirmed.headers.get("etag")).toBe(entry.etag); - expect(entry.etag).not.toBe(observed); + expect(entry.etag).not.toBe(ada.etag); + + // A retry of the same key answers the first result. + expect( + yield* confirm(client, { personId: person.ada, ifMatch: ada.etag, key: "first" }, 6), + ).toEqual(entry); // The tag of the first read is stale now: the adjustment writes nothing. - const stale = yield* confirm( - request, - { personId: person.ada, ifMatch: observed, key: "stale" }, - 4, + const stale = yield* Effect.flip( + confirm(client, { personId: person.ada, ifMatch: ada.etag, key: "stale" }, 4), ); - expect(stale.status).toBe(412); - expect((yield* decoded(stale, CertificatesConfirmProblem)).code).toBe("precondition.failed"); + expect(problemCode(stale)).toBe("precondition.failed"); expect(yield* count("days_served_confirmations", person.ada)).toBe(1); - const adjusted = yield* confirm( - request, - { personId: person.ada, ifMatch: entry.etag, key: "adjust" }, - 4, - ); - - expect(adjusted.status).toBe(200); + yield* confirm(client, { personId: person.ada, ifMatch: entry.etag, key: "adjust" }, 4); - const after = yield* decoded( - yield* request(daysServedPath, { person: person.leader }), - DaysServedListResponse, + const after = yield* client["certificates.listDaysServed"](daysServedScope).pipe( + as(person.leader), ); // The correction reads back as the next revision; the other assistant is unchanged. @@ -403,42 +352,44 @@ describe("days served over HTTP", () => { ); }); -describe("certificates over HTTP", () => { +describe("certificates over RPC", () => { it.live("denies the outsider and the assistant, and records no issue", () => Effect.gen(function* () { - const { request, count } = fixture(); + const { client: connect, count } = fixture(); + const client = yield* connect; - yield* confirmCalculated(request, person.ada); + yield* confirmCalculated(client, person.ada); - const { etag } = yield* preview(request, person.ada); + const { etag } = yield* preview(client, person.ada); - const outsiderList = yield* request(`/api/departments/${department}/certificates`, { - person: person.outsider, - }); + const outsiderList = yield* Effect.flip( + client["certificates.listCertificates"]({ departmentId: department }).pipe( + as(person.outsider), + ), + ); - expect(outsiderList.status).toBe(403); - expect((yield* decoded(outsiderList, CertificatesReadProblem)).code).toBe("authority.denied"); + expect(problemCode(outsiderList)).toBe("authority.denied"); for (const [issuer, attempt] of [ [person.outsider, "outsider"], [person.ada, "own"], ] as const) { - const denied = yield* issue(request, { - issuer, - personId: person.ada, - ifMatch: etag, - key: attempt, - }); + const denied = yield* Effect.flip( + issue(client, { issuer, personId: person.ada, ifMatch: etag, key: attempt }), + ); - expect(denied.status).toBe(403); - expect((yield* decoded(denied, CertificatesIssueProblem)).code).toBe("authority.denied"); + expect(problemCode(denied)).toBe("authority.denied"); } // The assistant cannot read their own certificate either. - const own = yield* request(certificatePath(person.ada), { person: person.ada }); + const own = yield* Effect.flip( + client["certificates.readCertificate"]({ + departmentId: department, + personId: PersonId.make(person.ada), + }).pipe(as(person.ada)), + ); - expect(own.status).toBe(403); - expect((yield* decoded(own, CertificatesReadProblem)).code).toBe("authority.denied"); + expect(problemCode(own)).toBe("authority.denied"); expect(yield* count("certificate_issues", person.ada)).toBe(0); }), ); @@ -447,50 +398,41 @@ describe("certificates over HTTP", () => { "replays one issue key with the same bytes, records a new key, and checks authority first", () => Effect.gen(function* () { - const { database, request, count } = fixture(); + const { database, client: connect, count } = fixture(); + const client = yield* connect; - yield* confirmCalculated(request, person.ada); + yield* confirmCalculated(client, person.ada); - const certificate = yield* preview(request, person.ada); + const certificate = yield* preview(client, person.ada); expect(certificate.content?.semesters.map((semester) => semester.days)).toEqual([6]); - const first = yield* issue(request, { + const document = yield* issue(client, { issuer: person.leader, personId: person.ada, ifMatch: certificate.etag, key: "issue", }); - expect(first.status).toBe(200); - expect(first.headers.get("content-type")).toBe("application/pdf"); - expect(first.headers.get("etag")).toBe(certificate.etag); - expect(first.headers.get("content-disposition")).toBeNull(); - - const document = yield* bytes(first); - expect(new TextDecoder().decode(document.subarray(0, 5))).toBe("%PDF-"); - const replay = yield* issue(request, { + const replay = yield* issue(client, { issuer: person.leader, personId: person.ada, ifMatch: certificate.etag, key: "issue", }); - expect(replay.status).toBe(200); - expect(yield* bytes(replay)).toEqual(document); + expect(replay).toEqual(document); expect(yield* count("certificate_issues", person.ada)).toBe(1); - const second = yield* issue(request, { + yield* issue(client, { issuer: person.leader, personId: person.ada, ifMatch: certificate.etag, key: "second", }); - expect(second.status).toBe(200); - const hashes = yield* database.run( Database.use( (sql) => sql<{ readonly contentSha256: string }>` @@ -514,26 +456,28 @@ describe("certificates over HTTP", () => { ), ); - const revoked = yield* issue(request, { - issuer: person.leader, - personId: person.ada, - ifMatch: certificate.etag, - key: "issue", - }); + const revoked = yield* Effect.flip( + issue(client, { + issuer: person.leader, + personId: person.ada, + ifMatch: certificate.etag, + key: "issue", + }), + ); - expect(revoked.status).toBe(403); - expect((yield* decoded(revoked, CertificatesIssueProblem)).code).toBe("authority.denied"); + expect(problemCode(revoked)).toBe("authority.denied"); expect(yield* count("certificate_issues", person.ada)).toBe(2); }), ); it.live("recovers a serialization failure at commit within the same request", () => Effect.gen(function* () { - const { database, request, count } = fixture(); + const { database, client: connect, count } = fixture(); + const client = yield* connect; - yield* confirmCalculated(request, person.ada); + yield* confirmCalculated(client, person.ada); - const certificate = yield* preview(request, person.ada); + const certificate = yield* preview(client, person.ada); // The first commit that records an issue fails with SQLSTATE 40001. The sequence is not // rolled back with it, so the retried transaction commits. @@ -556,15 +500,14 @@ describe("certificates over HTTP", () => { ), ); - const issued = yield* issue(request, { + const issued = yield* issue(client, { issuer: person.leader, personId: person.ada, ifMatch: certificate.etag, key: "commit-fault", }); - expect(issued.status).toBe(200); - expect(new TextDecoder().decode((yield* bytes(issued)).subarray(0, 5))).toBe("%PDF-"); + expect(new TextDecoder().decode(issued.subarray(0, 5))).toBe("%PDF-"); const attempts = yield* database.run( Database.use( @@ -580,23 +523,23 @@ describe("certificates over HTTP", () => { it.live("refuses a name that no certificate glyph covers and records nothing", () => Effect.gen(function* () { - const { request, count } = fixture(); + const { client: connect, count } = fixture(); + const client = yield* connect; - yield* confirmCalculated(request, person.unprintable); + yield* confirmCalculated(client, person.unprintable); - const certificate = yield* preview(request, person.unprintable); + const certificate = yield* preview(client, person.unprintable); - const refused = yield* issue(request, { - issuer: person.leader, - personId: person.unprintable, - ifMatch: certificate.etag, - key: "unprintable", - }); - - expect(refused.status).toBe(422); - expect((yield* decoded(refused, CertificatesIssueProblem)).code).toBe( - "certificate.unprintable", + const refused = yield* Effect.flip( + issue(client, { + issuer: person.leader, + personId: person.unprintable, + ifMatch: certificate.etag, + key: "unprintable", + }), ); + + expect(problemCode(refused)).toBe("certificate.unprintable"); expect(yield* count("certificate_issues", person.unprintable)).toBe(0); }), ); diff --git a/apps/backend/src/placements/certificates-rpc.ts b/apps/backend/src/placements/certificates-rpc.ts new file mode 100644 index 000000000..966600ffd --- /dev/null +++ b/apps/backend/src/placements/certificates-rpc.ts @@ -0,0 +1,464 @@ +/** + * The CertificatesRpcs handlers: days served and certificates. Reads answer from one + * repeatable-read snapshot. Commands run through the receipt executor: current authority before + * any replay, taken as `DaysServedConfirmationAuthorization` or `CertificateIssueAuthorization` + * evidence, the `ifMatch` precondition on the fresh entry or certificate, and one retry of a + * serialization conflict. The certificate PDF is rendered from the recorded issue inside the + * transaction, and its bytes are the issue's answer and receipt. + */ +import { Database } from "@vektorprogrammet/database"; +import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import { type CredentialOutcome, DomainId, Scope } from "@vektorprogrammet/domain/authz"; +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import type { DepartmentId } from "@vektorprogrammet/domain/organization"; +import { + daysServedEntryVersion, + Placements, + type CertificateCommandFailure, + type CertificatePreview, + type CertificatePrincipal, + type CertificateReadFailure, + type DaysServedEntry, +} from "@vektorprogrammet/domain/placements"; +import { + CertificateListResponse, + CertificatePreviewResource, + CertificateScopes, + CertificatesRpcs, + ConfirmDaysServed, + DaysServedEntryResource, + DaysServedListResponse, + IssueCertificate, + ListCertificates, + ListDaysServed, + ReadCertificate, + ReadCertificateScopes, + reflectAccessSpec, +} from "@vektorprogrammet/rpc"; +import { type CredentialPresentation, Problem } from "@vektorprogrammet/rpc/problem"; +import { Effect, Layer, Match, Option, Predicate } from "effect"; +import type { Headers } from "effect/unstable/http"; +import type { SqlError } from "effect/unstable/sql/SqlError"; +import { resolveRequestCredentialInTransaction } from "../authority.js"; +import { + deriveStrongETag, + normalizeTarget, + semanticMutationRequest, + semanticRequestDigest, +} from "../http-semantics.js"; +import { genericContext } from "../native-operation.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { + authorizePerson, + commandIdentity, + commandOutcome, + commandReceiptProblems, + isSerializationConflict, + personPresentation, + problemMapper, + requireCurrentETag, + strictOutput, + unreachable, +} from "../rpc/problem.js"; +import { + executeNativeHttpCommandPostgres, + NativeHttpReceiptInvalid, + type NativeHttpCommandOutcome, + type NativeHttpResponseCapsule, + successCapsule, +} from "../rpc/receipt-transaction.js"; +import { CertificateFonts, CertificateFontsLive, renderCertificatePdf } from "./certificate-pdf.js"; +import type { CertificateUnprintable } from "./certificate-pdf.js"; + +type AcceptedCredential = Extract; + +interface Reader { + readonly credential: AcceptedCredential; + readonly principal: CertificatePrincipal; +} + +type CertificateRpc = + | typeof ReadCertificateScopes + | typeof ListDaysServed + | typeof ConfirmDaysServed + | typeof ListCertificates + | typeof ReadCertificate + | typeof IssueCertificate; + +/** The one answer for every days-served and certificate failure. */ +const certificateProblems = problemMapper< + CertificateReadFailure | CertificateCommandFailure | CertificateUnprintable +>()({ + CertificateAccessDenied: () => Problem.make("authority.denied"), + CertificateScopeNotFound: () => Problem.make("resource.not-found"), + CertificateAssistantNotFound: () => Problem.make("resource.not-found"), + CertificateInvalidCursor: () => Problem.make("request.malformed"), + CertificateEmpty: () => Problem.make("certificate.empty"), + CertificateUnprintable: () => Problem.make("certificate.unprintable"), + CertificatePersistenceError: (failure) => + failure.conflict ? Problem.make("transaction.conflict") : Problem.make("internal.error"), +}); + +/** A person credential rejected inside the transaction is answered from the request's evidence. */ +const credentialProblems = (presentation: CredentialPresentation) => + problemMapper()({ + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + IdentityEngineError: () => Problem.make("internal.error"), + }); + +/** The snapshot transaction of a read: a lost serialization race is a conflict. */ +const snapshotProblems = problemMapper()({ + SqlError: (failure) => + isSerializationConflict(failure) + ? Problem.make("transaction.conflict") + : Problem.make("internal.error"), +}); + +/** An entry's tag names its revision and the evidence that a confirmation would record. */ +const entryETag = (entry: DaysServedEntry) => + deriveStrongETag({ + representationKind: "DaysServedEntry", + resourceIdentity: `${entry.departmentId}/${entry.semesterId}/${entry.personId}`, + version: daysServedEntryVersion(entry), + }); + +/** A certificate's tag names the content that an issue would record. */ +const certificateETag = ( + preview: Pick, +) => + deriveStrongETag({ + representationKind: "Certificate", + resourceIdentity: `${preview.departmentId}/${preview.personId}`, + version: preview.contentSha256 ?? "empty", + }); + +const entryResource = (entry: DaysServedEntry) => ({ ...entry, etag: entryETag(entry) }); + +/** Resolves the current Person credential through the caller's transaction at one instant. */ +const reader = (headers: Headers.Headers) => + Effect.gen(function* () { + const authenticated = yield* resolveRequestCredentialInTransaction( + credentialRequestOf(headers), + "OAuthUserBearer", + ); + + const principal = authenticated.credential.principal; + + if (!Predicate.isTagged(principal, "Person")) { + return yield* UnauthenticatedActor.make({ message: "authentication required" }); + } + + return { + credential: authenticated.credential, + principal: { + personId: principal.personId, + authorizationInstant: authenticated.authorizationInstant, + }, + } satisfies Reader; + }); + +/** Evaluates the declared AccessSpec in the department the domain decided for, or organization-wide. */ +const authorizeReader = ( + headers: Headers.Headers, + rpc: CertificateRpc, + current: Reader, + departmentId: DepartmentId | null, +) => + authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(rpc)), + credential: current.credential, + personId: current.principal.personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "organization", + departmentId: departmentId ?? undefined, + authorityVersion: `certificates:${current.principal.authorizationInstant}`, + }), + ], + }, + grantScopes: + departmentId === null + ? [Scope.Domain({ domainId: DomainId.make("organization") })] + : [Scope.Department({ departmentId })], + now: current.principal.authorizationInstant, + }, + personPresentation(headers), + ).pipe( + // Certificate AccessSpecs reveal every denial, so none is answered as not found. + unreachable("resource.not-found"), + ); + +/** Answers one read from a repeatable-read snapshot; every check runs inside it. */ +const snapshotRead = (headers: Headers.Headers, read: Effect.Effect) => + Database.use((sql) => + sql.withTransaction( + Effect.gen(function* () { + yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`; + + return yield* read; + }), + ), + ).pipe(snapshotProblems, certificateProblems, credentialProblems(personPresentation(headers))); + +/** The PDF that an issue receipt stores; a receipt of anything else is a defect. */ +const storedPdf = (capsule: NativeHttpResponseCapsule) => + capsule.bodyBytes === null || capsule.mediaType !== "application/pdf" + ? Effect.die(new NativeHttpReceiptInvalid({ reason: "an issue receipt stores no PDF" })) + : Effect.succeed(capsule.bodyBytes); + +/** The PDF of a committed or replayed issue, or its idempotency problem. */ +const issueOutcome = (outcome: NativeHttpCommandOutcome) => + Match.value(outcome).pipe( + Match.tag("Committed", "Replay", ({ response }) => storedPdf(response)), + Match.tag("InFlight", () => Effect.fail(Problem.make("idempotency.in-flight"))), + Match.tag("DigestConflict", () => Effect.fail(Problem.make("idempotency.digest-conflict"))), + Match.tag("ResponseExpired", () => Effect.fail(Problem.make("idempotency.response-expired"))), + Match.exhaustive, + ); + +/** + * The CertificatesRpcs handlers. The certificate fonts load once, when the handlers are built, as + * the HTTP router provided them to the HTTP group. + */ +export const CertificatesRpcHandlers = (_options: NativeRpcOptions) => + CertificatesRpcs.toLayer( + Effect.gen(function* () { + const faces = yield* CertificateFonts; + + return CertificatesRpcs.of({ + "certificates.readCertificateScopes": (_payload, { headers }) => + snapshotRead( + headers, + Effect.gen(function* () { + const current = yield* reader(headers); + + const scopes = yield* Placements.use((placements) => + placements.readCertificateScopes(current.principal), + ); + + yield* authorizeReader(headers, ReadCertificateScopes, current, null); + + return yield* strictOutput(CertificateScopes)(scopes); + }), + ).pipe( + // The scope read names no department and pages nothing. + unreachable("request.malformed"), + ), + + "certificates.listDaysServed": ({ departmentId, semesterId, cursor }, { headers }) => + snapshotRead( + headers, + Effect.gen(function* () { + const current = yield* reader(headers); + + const page = yield* Placements.use((placements) => + placements.readDaysServed(current.principal, { departmentId, semesterId }, cursor), + ); + + yield* authorizeReader(headers, ListDaysServed, current, departmentId); + + const body = { + departmentId, + departmentName: page.departmentName, + semester: page.semester, + items: page.items.map(entryResource), + }; + + return yield* strictOutput(DaysServedListResponse)( + page.nextCursor === undefined ? body : { ...body, nextCursor: page.nextCursor }, + ); + }), + ), + + "certificates.listCertificates": ({ departmentId, cursor }, { headers }) => + snapshotRead( + headers, + Effect.gen(function* () { + const current = yield* reader(headers); + + const page = yield* Placements.use((placements) => + placements.listCertificates(current.principal, departmentId, cursor), + ); + + yield* authorizeReader(headers, ListCertificates, current, departmentId); + + const body = { departmentId, departmentName: page.departmentName, items: page.items }; + + return yield* strictOutput(CertificateListResponse)( + page.nextCursor === undefined ? body : { ...body, nextCursor: page.nextCursor }, + ); + }), + ), + + "certificates.readCertificate": ({ departmentId, personId }, { headers }) => + snapshotRead( + headers, + Effect.gen(function* () { + const current = yield* reader(headers); + + const preview = yield* Placements.use((placements) => + placements.readCertificate(current.principal, departmentId, personId), + ); + + yield* authorizeReader(headers, ReadCertificate, current, departmentId); + + return yield* strictOutput(CertificatePreviewResource)({ + ...preview, + etag: certificateETag(preview), + }); + }), + ).pipe( + // A preview pages nothing. + unreachable("request.malformed"), + ), + + "certificates.confirmDaysServed": ( + { departmentId, semesterId, personId, idempotencyKey, ifMatch, request }, + { headers }, + ) => + Effect.gen(function* () { + const operationId = "certificates.confirmDaysServed"; + + // Domain and credential failures are mapped after the executor, whose retry reads their causes. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const current = yield* reader(headers); + + // Authority is current before any stored response can be replayed. + const authorization = yield* Placements.use((placements) => + placements.authorizeDaysServedConfirmation(current.principal, { + departmentId, + semesterId, + }), + ); + + yield* authorizeReader(headers, ConfirmDaysServed, current, departmentId); + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: `Person:${current.principal.personId}`, + qualifiedOperationId: operationId, + normalizedTarget: normalizeTarget( + "/api/departments/{departmentId}/semesters/{semesterId}/days-served/{personId}", + { departmentId, semesterId, personId }, + ), + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest( + semanticMutationRequest({ total: request.total }, ifMatch), + ), + operationId, + }, + execute: Placements.use((placements) => + placements.confirmDaysServed( + authorization, + { commandId: identity.identitySha256, personId, total: request.total }, + (entry) => requireCurrentETag(entryETag(entry), ifMatch), + ), + ).pipe( + Effect.flatMap((entry) => + strictOutput(DaysServedEntryResource)(entryResource(entry)), + ), + Effect.flatMap(successCapsule(DaysServedEntryResource)), + ), + }; + }), + { retry: "serialization-once" }, + ).pipe( + certificateProblems, + commandReceiptProblems, + credentialProblems(personPresentation(headers)), + // A confirmation pages nothing. + unreachable("request.malformed"), + ); + + return yield* commandOutcome(DaysServedEntryResource)(outcome); + }), + + "certificates.issueCertificate": ( + { departmentId, personId, idempotencyKey, ifMatch }, + { headers }, + ) => + Effect.gen(function* () { + const operationId = "certificates.issueCertificate"; + + // Domain and credential failures are mapped after the executor, whose retry reads their causes. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const current = yield* reader(headers); + + // A revoked seat cannot replay a stored certificate. + const authorization = yield* Placements.use((placements) => + placements.authorizeCertificateIssue(current.principal, departmentId, personId), + ); + + yield* authorizeReader(headers, IssueCertificate, current, departmentId); + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: `Person:${current.principal.personId}`, + qualifiedOperationId: operationId, + normalizedTarget: normalizeTarget( + "/api/departments/{departmentId}/certificates/{personId}/issues", + { departmentId, personId }, + ), + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest({ ifMatch }), + operationId, + }, + // The PDF renders before commit, so an unprintable certificate records no issue. + execute: Placements.use((placements) => + placements.issueCertificate( + authorization, + { commandId: identity.identitySha256 }, + (preview) => requireCurrentETag(certificateETag(preview), ifMatch), + ), + ).pipe( + Effect.flatMap((issue) => + Effect.map( + Effect.fromResult(renderCertificatePdf(issue, faces)), + (pdf): NativeHttpResponseCapsule => ({ + status: 200, + mediaType: "application/pdf", + headers: { + "content-type": "application/pdf", + etag: certificateETag({ + departmentId, + personId, + contentSha256: issue.contentSha256, + }), + }, + bodyBytes: pdf, + }), + ), + ), + ), + }; + }), + { retry: "serialization-once" }, + ).pipe( + certificateProblems, + commandReceiptProblems, + credentialProblems(personPresentation(headers)), + // An issue pages nothing. + unreachable("request.malformed"), + ); + + return yield* issueOutcome(outcome); + }), + }); + }), + ).pipe(Layer.provide(CertificateFontsLive)); diff --git a/apps/backend/src/placements/http.ts b/apps/backend/src/placements/http.ts deleted file mode 100644 index c11fcb6a9..000000000 --- a/apps/backend/src/placements/http.ts +++ /dev/null @@ -1,501 +0,0 @@ -import { Database } from "@vektorprogrammet/database"; -import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { DomainId, Scope } from "@vektorprogrammet/domain/authz"; -import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import { - AffiliationScope, - CoverageCommand, - OwnAffiliationCommand, - OwnCoverageCommand, - PlacementCommand, - Placements, - PlacementScope, - PlacementScopes, - canManagePlacements, - type CoverageCommand as CoverageCommandType, - type OwnAffiliationCommand as OwnAffiliationCommandType, - type OwnCoverageCommand as OwnCoverageCommandType, - type PlacementCommand as PlacementCommandType, - type PlacementOperationFailure, -} from "@vektorprogrammet/domain/placements"; -import { - CommandCoverageBoardEndpoint, - CommandOwnAffiliationEndpoint, - CommandOwnCoverageEndpoint, - CommandPlacementBoardEndpoint, - CoverageBoardResource, - ExternalNativeApi, - ListPlacementScopesEndpoint, - OwnAffiliationResource, - OwnCoverageResource, - PlacementBoardResource, - PlacementDraftResource, - ReadCoverageBoardEndpoint, - ReadOwnAffiliationEndpoint, - ReadOwnCoverageEndpoint, - ReadPlacementBoardEndpoint, - ReadPlacementDraftEndpoint, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { type CredentialPresentation, Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Option, Schema } from "effect"; -import type { SqlError } from "effect/unstable/sql/SqlError"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { - type OrganizationResolutionError, - resolveRequestPersonAuthorityInTransaction, -} from "../authority.js"; -import { - authorizePerson, - commandOutcomeResponse, - commandReceiptProblems, - decodeRequest, - httpIdentity, - idempotencyKeyOf, - isSerializationConflict, - personPresentation, - problemMapper, - readJsonBody, - requireCurrentETag, - requiredIfMatchOf, - requireNoQuery, - strictOutput, - webHandler, -} from "../http-api/problem.js"; -import { executeNativeHttpCommandPostgres } from "../http-api/receipt-transaction.js"; -import { - deriveStrongETag, - normalizeTarget, - responseCapsule, - semanticMutationRequest, - semanticRequestDigest, -} from "../http-semantics.js"; -import { genericContext } from "../native-operation.js"; - -/** - * The one answer for every placement domain failure. Each failure carries the - * registry code it is answered with. - */ -const placementProblems = problemMapper()({ - PlacementFailure: (failure) => Problem.make(failure.code), - PlacementPersistenceError: (failure) => Problem.make(failure.code), -}); - -/** - * A person credential or organization projection that fails inside the - * transaction. A projection read that lost a serialization race is a conflict. - */ -const authorityProblems = (presentation: CredentialPresentation) => - problemMapper()({ - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - IdentityEngineError: () => Problem.make("internal.error"), - OrganizationDecodeError: () => Problem.make("internal.error"), - OrganizationPersistenceError: (failure) => - isSerializationConflict(failure) - ? Problem.make("transaction.conflict") - : Problem.make("internal.error"), - }); - -/** - * The snapshot transaction of a read: a lost serialization race is a conflict. - */ -const snapshotProblems = problemMapper()({ - SqlError: (failure) => - isSerializationConflict(failure) - ? Problem.make("transaction.conflict") - : Problem.make("internal.error"), -}); - -const resource = (body: A) => ({ - ...body, - etag: deriveStrongETag({ - representationKind: "PlacementSnapshot", - resourceIdentity: JSON.stringify(body), - version: JSON.stringify(body), - }), -}); - -const json = (body: Schema.Json, etag?: string) => { - const nativeHeaders = new Headers(); - nativeHeaders.set("content-type", "application/json"); - nativeHeaders.set("cache-control", "private, no-store"); - nativeHeaders.set("vary", "Origin"); - - if (!(etag === undefined)) { - nativeHeaders.set("etag", etag); - } - - return new Response(JSON.stringify(body), { - headers: nativeHeaders, - }); -}; - -/** Each scope member appears exactly once; any other query member is malformed. */ -const query = (request: Request, mode: "affiliation" | "scope") => - Effect.suspend(() => { - const parameters = new URL(request.url).searchParams; - const keys = mode === "affiliation" ? ["departmentId"] : ["departmentId", "semesterId"]; - - return [...parameters.keys()].some( - (key) => !keys.includes(key) || parameters.getAll(key).length !== 1, - ) - ? Effect.fail(Problem.make("request.malformed")) - : Effect.succeed(Object.fromEntries(parameters)); - }); - -type Endpoint = - | typeof ListPlacementScopesEndpoint - | typeof ReadOwnAffiliationEndpoint - | typeof CommandOwnAffiliationEndpoint - | typeof ReadPlacementBoardEndpoint - | typeof CommandPlacementBoardEndpoint - | typeof ReadOwnCoverageEndpoint - | typeof CommandOwnCoverageEndpoint - | typeof ReadCoverageBoardEndpoint - | typeof CommandCoverageBoardEndpoint - | typeof ReadPlacementDraftEndpoint; - -const authorize = ( - request: Request, - endpoint: Endpoint, - departmentId: PlacementScope["departmentId"] | null, - manage: boolean, - now?: () => string, -) => - Effect.gen(function* () { - const auth = yield* resolveRequestPersonAuthorityInTransaction(request, { now }); - - if (manage && (departmentId === null || !canManagePlacements(auth.authority, departmentId))) { - return yield* Problem.make("authority.denied"); - } - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(endpoint)), - credential: auth.credential, - personId: auth.authority.personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "organization", - departmentId: departmentId ?? undefined, - authorityVersion: auth.authorizationInstant, - }), - ], - }, - grantScopes: - departmentId === null - ? [Scope.Domain({ domainId: DomainId.make("organization") })] - : [Scope.Department({ departmentId })], - now: auth.authorizationInstant, - }, - personPresentation(request), - ); - - return auth; - }); - -type MutationSelection = - | { - readonly mode: "affiliation"; - readonly scope: typeof AffiliationScope.Type; - readonly command: OwnAffiliationCommandType; - readonly endpoint: typeof CommandOwnAffiliationEndpoint; - readonly operationId: "placements.commandOwnAffiliation"; - readonly target: "/api/placements/affiliation/{departmentId}"; - readonly manage: false; - } - | { - readonly mode: "board"; - readonly scope: PlacementScope; - readonly command: PlacementCommandType; - readonly endpoint: typeof CommandPlacementBoardEndpoint; - readonly operationId: "placements.commandBoard"; - readonly target: "/api/placements/{departmentId}/{semesterId}"; - readonly manage: true; - } - | { - readonly mode: "ownCoverage"; - readonly scope: PlacementScope; - readonly command: OwnCoverageCommandType; - readonly endpoint: typeof CommandOwnCoverageEndpoint; - readonly operationId: "placements.commandOwnCoverage"; - readonly target: "/api/placements/coverage/own/{departmentId}/{semesterId}"; - readonly manage: false; - } - | { - readonly mode: "coverage"; - readonly scope: PlacementScope; - readonly command: CoverageCommandType; - readonly endpoint: typeof CommandCoverageBoardEndpoint; - readonly operationId: "placements.commandCoverageBoard"; - readonly target: "/api/placements/coverage/{departmentId}/{semesterId}"; - readonly manage: true; - }; - -const selectionForMutation = ( - request: Request, - body: Schema.Json, - mode: MutationSelection["mode"], -) => - Effect.gen(function* () { - switch (mode) { - case "affiliation": - return { - mode, - scope: yield* decodeRequest(AffiliationScope)(yield* query(request, "affiliation")), - command: yield* decodeRequest(OwnAffiliationCommand)(body), - endpoint: CommandOwnAffiliationEndpoint, - operationId: "placements.commandOwnAffiliation", - target: "/api/placements/affiliation/{departmentId}", - manage: false, - } as const; - case "board": - return { - mode, - scope: yield* decodeRequest(PlacementScope)(yield* query(request, "scope")), - command: yield* decodeRequest(PlacementCommand)(body), - endpoint: CommandPlacementBoardEndpoint, - operationId: "placements.commandBoard", - target: "/api/placements/{departmentId}/{semesterId}", - manage: true, - } as const; - case "ownCoverage": - return { - mode, - scope: yield* decodeRequest(PlacementScope)(yield* query(request, "scope")), - command: yield* decodeRequest(OwnCoverageCommand)(body), - endpoint: CommandOwnCoverageEndpoint, - operationId: "placements.commandOwnCoverage", - target: "/api/placements/coverage/own/{departmentId}/{semesterId}", - manage: false, - } as const; - case "coverage": - return { - mode, - scope: yield* decodeRequest(PlacementScope)(yield* query(request, "scope")), - command: yield* decodeRequest(CoverageCommand)(body), - endpoint: CommandCoverageBoardEndpoint, - operationId: "placements.commandCoverageBoard", - target: "/api/placements/coverage/{departmentId}/{semesterId}", - manage: true, - } as const; - } - }); - -export const PlacementsApiHandlers = (input: { now?: () => string }) => { - /** - * Answers one read from a repeatable-read snapshot; every check runs inside it. - */ - const read = ( - request: Request, - mode: "scopes" | "own" | "board" | "draft" | "ownCoverage" | "coverage", - ) => - Database.use((sql) => - sql.withTransaction( - Effect.gen(function* () { - const placements = yield* Placements; - yield* Database.use( - (transaction) => transaction`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`, - ); - - if (mode === "scopes") { - yield* requireNoQuery(request); - - const auth = yield* authorize( - request, - ListPlacementScopesEndpoint, - null, - false, - input.now, - ); - - return json( - yield* strictOutput(PlacementScopes)(yield* placements.listScopes(auth.authority)), - ); - } - - if (mode === "own") { - const scope = yield* decodeRequest(AffiliationScope)( - yield* query(request, "affiliation"), - ); - - const auth = yield* authorize( - request, - ReadOwnAffiliationEndpoint, - scope.departmentId, - false, - input.now, - ); - - return json( - yield* strictOutput(OwnAffiliationResource)( - resource( - yield* placements.readOwnAffiliation(auth.authority.personId, scope.departmentId), - ), - ), - ); - } - - const scope = yield* decodeRequest(PlacementScope)(yield* query(request, "scope")); - - if (mode === "board") { - yield* authorize( - request, - ReadPlacementBoardEndpoint, - scope.departmentId, - true, - input.now, - ); - - return json( - yield* strictOutput(PlacementBoardResource)( - resource(yield* placements.readBoard(scope)), - ), - ); - } - - if (mode === "draft") { - yield* authorize( - request, - ReadPlacementDraftEndpoint, - scope.departmentId, - true, - input.now, - ); - - // One snapshot: the draft is drafted from exactly the board version it names. - const board = resource(yield* placements.readBoard(scope)); - - return json( - yield* strictOutput(PlacementDraftResource)({ - ...(yield* placements.readDraft(scope)), - boardEtag: board.etag, - }), - ); - } - - if (mode === "ownCoverage") { - const auth = yield* authorize( - request, - ReadOwnCoverageEndpoint, - scope.departmentId, - false, - input.now, - ); - - return json( - yield* strictOutput(OwnCoverageResource)( - resource(yield* placements.readOwnCoverage(scope, auth.authority.personId)), - ), - ); - } - - yield* authorize(request, ReadCoverageBoardEndpoint, scope.departmentId, true, input.now); - - return json( - yield* strictOutput(CoverageBoardResource)( - resource(yield* placements.readCoverageBoard(scope)), - ), - ); - }), - ), - ).pipe(placementProblems, authorityProblems(personPresentation(request)), snapshotProblems); - - const mutate = (request: Request, mode: MutationSelection["mode"]) => - Effect.gen(function* () { - const body = yield* readJsonBody(request, /^\s*application\/json\s*(?:;|$)/u, 8192); - const selected = yield* selectionForMutation(request, body, mode); - const ifMatch = yield* requiredIfMatchOf(request); - const key = yield* idempotencyKeyOf(request); - - // Domain and credential failures are mapped after the executor, whose retry reads their causes. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const auth = yield* authorize( - request, - selected.endpoint, - selected.scope.departmentId, - selected.manage, - input.now, - ); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${auth.authority.personId}`, - qualifiedOperationId: selected.operationId, - normalizedTarget: normalizeTarget(selected.target, selected.scope), - idempotencyKey: key, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest(semanticMutationRequest(body, ifMatch)), - operationId: selected.operationId, - }, - execute: Effect.gen(function* () { - const placements = yield* Placements; - - const changed = resource( - yield* placements.execute( - { - mutation: selected, - actor: auth.authority.personId, - now: auth.authorizationInstant, - commandId: identity.identitySha256, - }, - (current) => requireCurrentETag(resource(current).etag, ifMatch), - ), - ); - - return yield* Effect.promise(() => responseCapsule(json(changed, changed.etag))); - }), - }; - }), - { retry: "serialization-once" }, - ).pipe( - placementProblems, - commandReceiptProblems, - authorityProblems(personPresentation(request)), - ); - - return yield* commandOutcomeResponse(outcome); - }); - - return HttpApiBuilder.group(ExternalNativeApi, "placements", (handlers) => - Effect.succeed( - handlers - .handleRaw("listScopes", ({ request }) => - webHandler(request, (webRequest) => read(webRequest, "scopes")), - ) - .handleRaw("readOwnAffiliation", ({ request }) => - webHandler(request, (webRequest) => read(webRequest, "own")), - ) - .handleRaw("readBoard", ({ request }) => - webHandler(request, (webRequest) => read(webRequest, "board")), - ) - .handleRaw("readDraft", ({ request }) => - webHandler(request, (webRequest) => read(webRequest, "draft")), - ) - .handleRaw("readOwnCoverage", ({ request }) => - webHandler(request, (webRequest) => read(webRequest, "ownCoverage")), - ) - .handleRaw("readCoverageBoard", ({ request }) => - webHandler(request, (webRequest) => read(webRequest, "coverage")), - ) - .handleRaw("commandOwnAffiliation", ({ request }) => - webHandler(request, (webRequest) => mutate(webRequest, "affiliation")), - ) - .handleRaw("commandBoard", ({ request }) => - webHandler(request, (webRequest) => mutate(webRequest, "board")), - ) - .handleRaw("commandOwnCoverage", ({ request }) => - webHandler(request, (webRequest) => mutate(webRequest, "ownCoverage")), - ) - .handleRaw("commandCoverageBoard", ({ request }) => - webHandler(request, (webRequest) => mutate(webRequest, "coverage")), - ), - ), - ); -}; diff --git a/apps/backend/src/placements/notification.ts b/apps/backend/src/placements/notification.ts index e65a09267..d1b14b171 100644 --- a/apps/backend/src/placements/notification.ts +++ b/apps/backend/src/placements/notification.ts @@ -11,6 +11,7 @@ import { DateTime, Predicate, Duration, Effect } from "effect"; import { HttpClient } from "effect/unstable/http"; import { deliverJson } from "../delivery/http.js"; import { pollForever } from "../worker-support.js"; +import { dual } from "effect/Function"; export interface SchoolServiceNotificationConfig { readonly endpoint: URL; @@ -104,13 +105,15 @@ export const schoolServiceNotificationDelivery = ( ), ); -export const runSchoolServiceNotificationWorker = ( +interface SchoolServiceNotificationWorkerOptions { + readonly workerId: string; + readonly pollIntervalMilliseconds: number; + readonly staleClaimMilliseconds: number; +} + +const runSchoolServiceNotificationWorkerWith = ( interpreter: SchoolServiceNotificationInterpreter, - options: { - readonly workerId: string; - readonly pollIntervalMilliseconds: number; - readonly staleClaimMilliseconds: number; - }, + options: SchoolServiceNotificationWorkerOptions, ) => { if (options.workerId.length === 0) throw new Error("worker ID must not be empty"); let sequence = 0; @@ -136,3 +139,15 @@ export const runSchoolServiceNotificationWorker = ( skipDelay: Predicate.isTagged("Delivered"), }); }; + +type SchoolServiceNotificationWorker = ReturnType; + +export const runSchoolServiceNotificationWorker: { + ( + options: SchoolServiceNotificationWorkerOptions, + ): (interpreter: SchoolServiceNotificationInterpreter) => SchoolServiceNotificationWorker; + ( + interpreter: SchoolServiceNotificationInterpreter, + options: SchoolServiceNotificationWorkerOptions, + ): SchoolServiceNotificationWorker; +} = dual(2, runSchoolServiceNotificationWorkerWith); diff --git a/apps/backend/src/placements/http.test.ts b/apps/backend/src/placements/rpc.test.ts similarity index 74% rename from apps/backend/src/placements/http.test.ts rename to apps/backend/src/placements/rpc.test.ts index 50ffffecf..cbfbdd4ea 100644 --- a/apps/backend/src/placements/http.test.ts +++ b/apps/backend/src/placements/rpc.test.ts @@ -1,3 +1,9 @@ +/** + * Placement drafts over RPC and PostgreSQL: the draft of open demand within capacity for unplaced + * active assistants, its application through the board command, and its denial to anyone but a + * scoped coordinator. + */ +import { describe, expect, it } from "@effect/vitest"; import { Database, IdentitySnapshot } from "@vektorprogrammet/database"; import { OrganizationLive } from "@vektorprogrammet/database/organization"; import { PlacementsLive } from "@vektorprogrammet/database/placements"; @@ -7,38 +13,20 @@ import { IdentitySessionNotFound, type IdentityOperations, } from "@vektorprogrammet/domain/identity"; -import { PersonId } from "@vektorprogrammet/domain/organization"; -import { - PlacementBoardResource, - PlacementDraftResource, - PlacementProblem, -} from "@vektorprogrammet/http-api"; -import { DateTime, Effect, Layer, Schema } from "effect"; +import { DepartmentId, PersonId, SemesterId } from "@vektorprogrammet/domain/organization"; +import { IdempotencyKey, isProblem } from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Layer } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; import { createHash } from "node:crypto"; -import { describe, expect, it } from "@effect/vitest"; import { backendDatabase } from "../../test/database.js"; -import { decodeBackendConfig } from "../config.js"; -import { jsonText } from "../http-api/problem.js"; -import { makeBackendTestHttp } from "../test/native-http.js"; - -const environment = { - BACKEND_PG_URL: "postgres://test.invalid/vektorprogrammet", - BETTER_AUTH_SECRET: "placement-draft-http-test-secret-32-chars", - NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify(["http://127.0.0.1:5174"]), - OAUTH_CANONICAL_ORIGIN: "http://127.0.0.1:5174", - OAUTH_DASHBOARD_ORIGIN: "http://127.0.0.1:5174", - OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - PUBLIC_APPLICATION_EFFECT_MODE: "disabled", - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_MODE: "disabled", -} as const; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; -const departmentId = "draft-department"; +const departmentId = DepartmentId.make("draft-department"); -const semesterId = "draft-semester"; +const semesterId = SemesterId.make("draft-semester"); -const scope = new URLSearchParams({ departmentId, semesterId }).toString(); +const scope = { departmentId, semesterId }; const digest = (value: string) => createHash("sha256").update(value).digest("hex"); @@ -157,7 +145,7 @@ const sessionActor = (cookie: string | undefined) => { return person === undefined ? undefined - : new IdentityActor({ + : IdentityActor.make({ personId: PersonId.make(person), sessionId: `session-${person}`, expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), @@ -168,7 +156,9 @@ const identitySnapshot = IdentitySnapshot.of({ resolveSession: (cookie) => { const actor = sessionActor(cookie); - return actor === undefined ? Effect.fail(new IdentitySessionNotFound()) : Effect.succeed(actor); + return actor === undefined + ? Effect.fail(IdentitySessionNotFound.make({})) + : Effect.succeed(actor); }, revokeCurrentSession: () => Effect.die("unexpected session mutation"), revokeSession: () => Effect.die("unexpected session mutation"), @@ -181,7 +171,9 @@ const identity = Identity.of({ resolveSession: (cookie: string | undefined) => { const actor = sessionActor(cookie); - return actor === undefined ? Effect.fail(new IdentitySessionNotFound()) : Effect.succeed(actor); + return actor === undefined + ? Effect.fail(IdentitySessionNotFound.make({})) + : Effect.succeed(actor); }, readCurrentSession: () => Effect.die("unexpected session read"), listSessions: () => Effect.die("unexpected session list"), @@ -193,62 +185,35 @@ const identity = Identity.of({ signOut: () => Effect.succeed({ setCookies: [] }), } satisfies IdentityOperations); +/** One backend over its own seeded database; `as` sends a person's session cookie. */ const fixture = () => { const database = backendDatabase(seed); - const http = makeBackendTestHttp( - Effect.runSync(decodeBackendConfig(environment)), - Layer.mergeAll( + const { client } = makeBackendTestRpc({ + config: backendTestConfig, + services: Layer.mergeAll( PlacementsLive, OrganizationLive, Layer.succeed(IdentitySnapshot, identitySnapshot), Layer.succeed(Identity, identity), ).pipe(Layer.provideMerge(database.layer)), - { - handler: () => Effect.succeed(new Response(null, { status: 404 })), - recordTrustedOriginRejection: () => Effect.void, - }, - ); - - const request = (path: string, init: RequestInit = {}, personId = "coordinator") => { - const headers = new Headers(init.headers); - headers.set("cookie", `better-auth.session_token=${personId}`); + }); - if (init.method !== undefined && init.method !== "GET") { - headers.set("origin", "http://127.0.0.1:5174"); - } + const as = (personId = "coordinator") => + RpcClient.withHeaders({ cookie: `better-auth.session_token=${personId}` }); - return http.fetch(new Request(`http://backend.test${path}`, { ...init, headers })); - }; - - return { request }; + return { client, as }; }; -const decodeStrict = >( - schema: S, - response: Response, -) => - Effect.promise(() => response.json()).pipe( - Effect.flatMap((body) => - Schema.decodeUnknownEffect(schema)(body, { onExcessProperty: "error" }), - ), - ); +const problemCode = (failure: E) => (isProblem(failure) ? failure.code : failure); -describe("placement drafts over HTTP and PostgreSQL", () => { +describe("placement drafts over RPC and PostgreSQL", () => { it.live("drafts open demand within capacity for unplaced active assistants", () => Effect.gen(function* () { - const { request } = fixture(); - const response = yield* request(`/api/placements/draft?${scope}`); - - expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe("private, no-store"); - - const draft = yield* decodeStrict(PlacementDraftResource, response); - - const board = yield* decodeStrict( - PlacementBoardResource, - yield* request(`/api/placements?${scope}`), - ); + const { client: connect, as } = fixture(); + const client = yield* connect; + const draft = yield* client["placements.readDraft"](scope).pipe(as()); + const board = yield* client["placements.readBoard"](scope).pipe(as()); expect(draft.boardEtag).toBe(board.etag); expect(draft.openPlaces).toBe(4); @@ -293,9 +258,7 @@ describe("placement drafts over HTTP and PostgreSQL", () => { ).toEqual([["Beta skole", "Wednesday", "1", 1]]); // Nothing is stored, and the same board gives the same draft. - const again = yield* request(`/api/placements/draft?${scope}`); - - expect(yield* Effect.promise(() => again.json())).toEqual(draft); + expect(yield* client["placements.readDraft"](scope).pipe(as())).toEqual(draft); }), ); @@ -303,68 +266,120 @@ describe("placement drafts over HTTP and PostgreSQL", () => { "applies through the placement commands, after which only the unfilled rest remains", () => Effect.gen(function* () { - const { request } = fixture(); - - const draft = yield* decodeStrict( - PlacementDraftResource, - yield* request(`/api/placements/draft?${scope}`), - ); + const { client: connect, as } = fixture(); + const client = yield* connect; + const draft = yield* client["placements.readDraft"](scope).pipe(as()); let etag = draft.boardEtag; for (const placement of draft.placements) { - const response = yield* request(`/api/placements?${scope}`, { - method: "POST", - headers: { - "content-type": "application/json", - "if-match": etag, - "idempotency-key": `apply-draft-${placement.personId}`.padEnd(22, "0"), - }, - body: yield* jsonText({ + const command = { + ...scope, + idempotencyKey: IdempotencyKey.make( + `apply-draft-${placement.personId}`.padEnd(22, "0"), + ), + ifMatch: etag, + request: { action: "Create", personId: placement.personId, schoolId: placement.schoolId, day: placement.day, workdays: placement.workdays, block: placement.block, - }), - }); + }, + } as const; + + const board = yield* client["placements.commandBoard"](command).pipe(as()); - expect(response.status).toBe(200); - etag = (yield* decodeStrict(PlacementBoardResource, response)).etag; + // A retry with the same key answers the first result, although its tag is stale now. + expect(yield* client["placements.commandBoard"](command).pipe(as())).toEqual(board); + + etag = board.etag; } - const after = yield* decodeStrict( - PlacementDraftResource, - yield* request(`/api/placements/draft?${scope}`), - ); + const after = yield* client["placements.readDraft"](scope).pipe(as()); expect(after.boardEtag).toBe(etag); expect(after.placements).toEqual([]); expect(after.filledPlaces).toBe(0); expect(after.openPlaces).toBe(1); expect(after.unplaced.map(({ personId }) => personId)).toEqual(["dina", "erik", "olav"]); + + // The tag of the first draft is stale: a new command under it changes nothing. + const stale = yield* Effect.flip( + client["placements.commandBoard"]({ + ...scope, + idempotencyKey: IdempotencyKey.make("stale-draft".padEnd(22, "0")), + ifMatch: draft.boardEtag, + request: { action: "GenerateProposal" }, + }).pipe(as()), + ); + + expect(problemCode(stale)).toBe("precondition.failed"); }), ); - it.live("serves drafts to scoped coordinators for a known semester only", () => + it.live("serves drafts and board commands to scoped coordinators for a known semester only", () => Effect.gen(function* () { - const { request } = fixture(); - const assistant = yield* request(`/api/placements/draft?${scope}`, {}, "berit"); + const { client: connect, as } = fixture(); + const client = yield* connect; + const assistant = yield* Effect.flip(client["placements.readDraft"](scope).pipe(as("berit"))); + + expect(problemCode(assistant)).toBe("authority.denied"); + + const board = yield* client["placements.readBoard"](scope).pipe(as()); + + // An assistant holds no coordinator reach, so the board command is denied before any write. + const denied = yield* Effect.flip( + client["placements.commandBoard"]({ + ...scope, + idempotencyKey: IdempotencyKey.make("assistant-command".padEnd(22, "0")), + ifMatch: board.etag, + request: { action: "GenerateProposal" }, + }).pipe(as("berit")), + ); - expect(assistant.status).toBe(403); - expect(yield* decodeStrict(PlacementProblem, assistant)).toMatchObject({ - code: "authority.denied", - }); + expect(problemCode(denied)).toBe("authority.denied"); + expect((yield* client["placements.readBoard"](scope).pipe(as())).etag).toBe(board.etag); - const unknown = yield* request( - `/api/placements/draft?${new URLSearchParams({ departmentId, semesterId: "draft-unknown" }).toString()}`, + const unknown = yield* Effect.flip( + client["placements.readDraft"]({ + departmentId, + semesterId: SemesterId.make("draft-unknown"), + }).pipe(as()), ); - expect(unknown.status).toBe(422); - expect(yield* decodeStrict(PlacementProblem, unknown)).toMatchObject({ - code: "scope.invalid", - }); + expect(problemCode(unknown)).toBe("scope.invalid"); + + const anonymous = yield* Effect.flip(client["placements.readDraft"](scope)); + + expect(problemCode(anonymous)).toBe("credential.missing"); + }), + ); + + it.live("answers the caller's own affiliation as that person", () => + Effect.gen(function* () { + const { client: connect, as } = fixture(); + const client = yield* connect; + + const own = yield* client["placements.readOwnAffiliation"]({ departmentId }).pipe( + as("petter"), + ); + + expect(own).toMatchObject({ personId: "petter", status: "Pending" }); + + const withdrawn = yield* client["placements.commandOwnAffiliation"]({ + departmentId, + idempotencyKey: IdempotencyKey.make("petter-withdraw".padEnd(22, "0")), + ifMatch: own.etag, + request: { action: "Withdraw" }, + }).pipe(as("petter")); + + expect(withdrawn.personId).toBe("petter"); + expect(withdrawn.etag).not.toBe(own.etag); + expect( + yield* client["placements.readOwnAffiliation"]({ departmentId }).pipe(as("petter")), + ).toEqual(withdrawn); }), ); }); diff --git a/apps/backend/src/placements/rpc.ts b/apps/backend/src/placements/rpc.ts new file mode 100644 index 000000000..f04d42ac3 --- /dev/null +++ b/apps/backend/src/placements/rpc.ts @@ -0,0 +1,515 @@ +/** + * The PlacementsRpcs handlers. A read answers from one repeatable-read snapshot that also resolves + * the caller's credential and authority. A command resolves them again inside the serializable + * transaction that commits it: a board or coverage-board change runs on the coordinator's + * `DepartmentReach<"placements.coordinate">`, and a person's own affiliation or coverage runs as + * that person. The `ifMatch` precondition is compared under the department lock, and the changed + * snapshot is stored as the command's receipt. + */ +import { Database } from "@vektorprogrammet/database"; +import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import { DomainId, requireDepartmentReach, Scope } from "@vektorprogrammet/domain/authz"; +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import type { + DepartmentId, + OrganizationPersonAuthority, +} from "@vektorprogrammet/domain/organization"; +import { + Placements, + type PlacementExecution, + type PlacementOperationFailure, + type PlacementSnapshot, +} from "@vektorprogrammet/domain/placements"; +import { + CommandCoverageBoard, + CommandOwnAffiliation, + CommandOwnCoverage, + CommandPlacementBoard, + CoverageBoardResource, + CoverageCommand, + ListPlacementScopes, + OwnAffiliationCommand, + OwnAffiliationResource, + OwnCoverageCommand, + OwnCoverageResource, + PlacementBoardResource, + PlacementCommand, + PlacementDraftResource, + PlacementScopes, + PlacementsRpcs, + ReadCoverageBoard, + ReadOwnAffiliation, + ReadOwnCoverage, + ReadPlacementBoard, + ReadPlacementDraft, + reflectAccessSpec, +} from "@vektorprogrammet/rpc"; +import { + type CredentialPresentation, + type IdempotencyKey, + Problem, + type StrongETag, +} from "@vektorprogrammet/rpc/problem"; +import { Effect, Option, Schema } from "effect"; +import type { Headers } from "effect/unstable/http"; +import type { SqlError } from "effect/unstable/sql/SqlError"; +import { + type OrganizationResolutionError, + resolveRequestPersonAuthorityInTransaction, + type TransactionPersonAuthority, +} from "../authority.js"; +import { + deriveStrongETag, + normalizeTarget, + semanticMutationRequest, + semanticRequestDigest, +} from "../http-semantics.js"; +import { genericContext } from "../native-operation.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { + authorizePerson, + commandIdentity, + commandOutcome, + commandReceiptProblems, + isSerializationConflict, + jsonText, + personPresentation, + problemMapper, + requireCurrentETag, + strictOutput, +} from "../rpc/problem.js"; +import { executeNativeHttpCommandPostgres, successCapsule } from "../rpc/receipt-transaction.js"; + +/** + * The one answer for every placement domain failure. Each failure carries the registry code it is + * answered with. + */ +const placementProblems = problemMapper()({ + PlacementFailure: (failure) => Problem.make(failure.code), + PlacementPersistenceError: (failure) => Problem.make(failure.code), +}); + +/** + * A person credential or organization projection that fails inside the transaction. A projection + * read that lost a serialization race is a conflict. + */ +const authorityProblems = (presentation: CredentialPresentation) => + problemMapper()({ + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + IdentityEngineError: () => Problem.make("internal.error"), + OrganizationDecodeError: () => Problem.make("internal.error"), + OrganizationPersistenceError: (failure) => + isSerializationConflict(failure) + ? Problem.make("transaction.conflict") + : Problem.make("internal.error"), + }); + +/** The snapshot transaction of a read: a lost serialization race is a conflict. */ +const snapshotProblems = problemMapper()({ + SqlError: (failure) => + isSerializationConflict(failure) + ? Problem.make("transaction.conflict") + : Problem.make("internal.error"), +}); + +/** + * A snapshot with its strong tag. The tag versions the whole snapshot text, as the HTTP handler's + * did, so a tag read before the cutover still names the same version. + */ +const resource = (body: A) => + Effect.map(jsonText(body), (text) => ({ + ...body, + etag: deriveStrongETag({ + representationKind: "PlacementSnapshot", + resourceIdentity: text, + version: text, + }), + })); + +type PlacementRpc = + | typeof ListPlacementScopes + | typeof ReadOwnAffiliation + | typeof CommandOwnAffiliation + | typeof ReadPlacementBoard + | typeof CommandPlacementBoard + | typeof ReadOwnCoverage + | typeof CommandOwnCoverage + | typeof ReadCoverageBoard + | typeof CommandCoverageBoard + | typeof ReadPlacementDraft; + +/** The coordinator's reach over the department, which a board or coverage-board change requires. */ +const requireCoordinator = ( + authority: OrganizationPersonAuthority, + departmentId: DepartmentId | null, +) => + departmentId === null + ? Effect.fail(Problem.make("authority.denied")) + : Effect.fromResult( + requireDepartmentReach(authority, "placements.coordinate", departmentId), + ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); + +/** Evaluates the RPC's AccessSpec for the resolved person in the department's scope. */ +const evaluateAccess = ( + headers: Headers.Headers, + rpc: PlacementRpc, + departmentId: DepartmentId | null, + auth: TransactionPersonAuthority, +) => + authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(rpc)), + credential: auth.credential, + personId: auth.authority.personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "organization", + departmentId: departmentId ?? undefined, + authorityVersion: auth.authorizationInstant, + }), + ], + }, + grantScopes: + departmentId === null + ? [Scope.Domain({ domainId: DomainId.make("organization") })] + : [Scope.Department({ departmentId })], + now: auth.authorizationInstant, + }, + personPresentation(headers), + ); + +/** The encoded JSON of a decoded command: the body that the HTTP request digest covered. */ +const jsonBody = + >(schema: S) => + (value: S["Type"]): Effect.Effect => + Schema.encodeEffect(Schema.toCodecJson(schema))(value).pipe(Effect.orDie); + +/** A changed snapshot, decoded strictly with the success schema of its command. */ +const snapshotOutput = + >(success: S) => + (snapshot: PlacementSnapshot & { readonly etag: StrongETag }): Effect.Effect => + Schema.decodeEffect(success)(snapshot, { onExcessProperty: "error" }).pipe(Effect.orDie); + +/** One placement command: its receipt identity, its precondition, and how it is authorized. */ +interface MutationPlan, E, R> { + readonly operationId: + | "placements.commandOwnAffiliation" + | "placements.commandBoard" + | "placements.commandOwnCoverage" + | "placements.commandCoverageBoard"; + /** The route template that the HTTP handler filled as the normalized target. */ + readonly target: string; + readonly scope: Readonly>; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; + /** The command as the JSON body of the HTTP request, for the request digest. */ + readonly body: Effect.Effect; + readonly success: S; + /** Resolves the actor or the coordinator inside the transaction, and builds the execution. */ + readonly authorize: Effect.Effect< + { + readonly auth: TransactionPersonAuthority; + readonly execution: (commandId: string) => PlacementExecution; + }, + E, + R + >; +} + +/** The PlacementsRpcs handlers. */ +export const PlacementsRpcHandlers = (options: NativeRpcOptions) => { + const personAuthority = (headers: Headers.Headers) => + resolveRequestPersonAuthorityInTransaction(credentialRequestOf(headers), { + now: options.now, + }); + + /** Resolves the person, requires the coordinator's reach when `manage`, and evaluates access. */ + const authorize = ( + headers: Headers.Headers, + rpc: PlacementRpc, + departmentId: DepartmentId | null, + manage: boolean, + ) => + Effect.gen(function* () { + const auth = yield* personAuthority(headers); + + if (manage) yield* requireCoordinator(auth.authority, departmentId); + + yield* evaluateAccess(headers, rpc, departmentId, auth); + + return auth; + }); + + /** Resolves the person, requires the coordinator's reach, and returns it as evidence. */ + const authorizeCoordinator = ( + headers: Headers.Headers, + rpc: PlacementRpc, + departmentId: DepartmentId, + ) => + Effect.gen(function* () { + const auth = yield* personAuthority(headers); + const coordinator = yield* requireCoordinator(auth.authority, departmentId); + yield* evaluateAccess(headers, rpc, departmentId, auth); + + return { auth, coordinator }; + }); + + /** Answers one read from a repeatable-read snapshot; every check runs inside it. */ + const snapshotRead = (headers: Headers.Headers, read: Effect.Effect) => + Database.use((sql) => + sql.withTransaction( + Effect.gen(function* () { + yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`; + + return yield* read; + }), + ), + ).pipe(placementProblems, authorityProblems(personPresentation(headers)), snapshotProblems); + + const mutate = , E, R>( + headers: Headers.Headers, + plan: MutationPlan, + ) => + Effect.gen(function* () { + const body = yield* plan.body; + + // Domain and credential failures are mapped after the executor, whose retry reads their causes. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const { auth, execution } = yield* plan.authorize; + + // The HTTP target stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: `Person:${auth.authority.personId}`, + qualifiedOperationId: plan.operationId, + normalizedTarget: normalizeTarget(plan.target, plan.scope), + idempotencyKey: plan.idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest(semanticMutationRequest(body, plan.ifMatch)), + operationId: plan.operationId, + }, + execute: Effect.gen(function* () { + const changed = yield* Placements.use((placements) => + placements.execute(execution(identity.identitySha256), (current) => + Effect.flatMap(resource(current), (snapshot) => + requireCurrentETag(snapshot.etag, plan.ifMatch), + ), + ), + ); + + const answer = yield* snapshotOutput(plan.success)(yield* resource(changed)); + + return yield* successCapsule(plan.success)(answer); + }), + }; + }), + { retry: "serialization-once" }, + ).pipe( + placementProblems, + commandReceiptProblems, + authorityProblems(personPresentation(headers)), + ); + + return yield* commandOutcome(plan.success)(outcome); + }); + + return PlacementsRpcs.toLayer({ + "placements.listScopes": (_payload, { headers }) => + snapshotRead( + headers, + Effect.gen(function* () { + const auth = yield* authorize(headers, ListPlacementScopes, null, false); + + const scopes = yield* Placements.use((placements) => + placements.listScopes(auth.authority), + ); + + return yield* strictOutput(PlacementScopes)(scopes); + }), + ), + + "placements.readOwnAffiliation": (scope, { headers }) => + snapshotRead( + headers, + Effect.gen(function* () { + const auth = yield* authorize(headers, ReadOwnAffiliation, scope.departmentId, false); + + const affiliation = yield* Placements.use((placements) => + placements.readOwnAffiliation(auth.authority.personId, scope.departmentId), + ); + + return yield* strictOutput(OwnAffiliationResource)(yield* resource(affiliation)); + }), + ), + + "placements.readBoard": (scope, { headers }) => + snapshotRead( + headers, + Effect.gen(function* () { + yield* authorize(headers, ReadPlacementBoard, scope.departmentId, true); + + const board = yield* Placements.use((placements) => placements.readBoard(scope)); + + return yield* strictOutput(PlacementBoardResource)(yield* resource(board)); + }), + ), + + "placements.readDraft": (scope, { headers }) => + snapshotRead( + headers, + Effect.gen(function* () { + yield* authorize(headers, ReadPlacementDraft, scope.departmentId, true); + + // One snapshot: the draft is drafted from exactly the board version it names. + const board = yield* resource( + yield* Placements.use((placements) => placements.readBoard(scope)), + ); + + const draft = yield* Placements.use((placements) => placements.readDraft(scope)); + + return yield* strictOutput(PlacementDraftResource)({ ...draft, boardEtag: board.etag }); + }), + ), + + "placements.readOwnCoverage": (scope, { headers }) => + snapshotRead( + headers, + Effect.gen(function* () { + const auth = yield* authorize(headers, ReadOwnCoverage, scope.departmentId, false); + + const coverage = yield* Placements.use((placements) => + placements.readOwnCoverage(scope, auth.authority.personId), + ); + + return yield* strictOutput(OwnCoverageResource)(yield* resource(coverage)); + }), + ), + + "placements.readCoverageBoard": (scope, { headers }) => + snapshotRead( + headers, + Effect.gen(function* () { + yield* authorize(headers, ReadCoverageBoard, scope.departmentId, true); + + const board = yield* Placements.use((placements) => placements.readCoverageBoard(scope)); + + return yield* strictOutput(CoverageBoardResource)(yield* resource(board)); + }), + ), + + // A person's own affiliation runs as that person. + "placements.commandOwnAffiliation": ( + { departmentId, idempotencyKey, ifMatch, request }, + { headers }, + ) => + mutate(headers, { + operationId: "placements.commandOwnAffiliation", + target: "/api/placements/affiliation/{departmentId}", + scope: { departmentId }, + idempotencyKey, + ifMatch, + body: jsonBody(OwnAffiliationCommand)(request), + success: OwnAffiliationResource, + authorize: authorize(headers, CommandOwnAffiliation, departmentId, false).pipe( + Effect.map((auth) => ({ + auth, + execution: (commandId: string): PlacementExecution => ({ + mutation: { mode: "affiliation", scope: { departmentId }, command: request }, + actor: auth.authority.personId, + now: auth.authorizationInstant, + commandId, + }), + })), + ), + }), + + // A board change runs on the coordinator's reach over the department. + "placements.commandBoard": ( + { departmentId, semesterId, idempotencyKey, ifMatch, request }, + { headers }, + ) => + mutate(headers, { + operationId: "placements.commandBoard", + target: "/api/placements/{departmentId}/{semesterId}", + scope: { departmentId, semesterId }, + idempotencyKey, + ifMatch, + body: jsonBody(PlacementCommand)(request), + success: PlacementBoardResource, + authorize: authorizeCoordinator(headers, CommandPlacementBoard, departmentId).pipe( + Effect.map(({ auth, coordinator }) => ({ + auth, + execution: (commandId: string): PlacementExecution => ({ + mutation: { mode: "board", scope: { departmentId, semesterId }, command: request }, + coordinator, + now: auth.authorizationInstant, + commandId, + }), + })), + ), + }), + + // A person's own coverage runs as that person. + "placements.commandOwnCoverage": ( + { departmentId, semesterId, idempotencyKey, ifMatch, request }, + { headers }, + ) => + mutate(headers, { + operationId: "placements.commandOwnCoverage", + target: "/api/placements/coverage/own/{departmentId}/{semesterId}", + scope: { departmentId, semesterId }, + idempotencyKey, + ifMatch, + body: jsonBody(OwnCoverageCommand)(request), + success: OwnCoverageResource, + authorize: authorize(headers, CommandOwnCoverage, departmentId, false).pipe( + Effect.map((auth) => ({ + auth, + execution: (commandId: string): PlacementExecution => ({ + mutation: { + mode: "ownCoverage", + scope: { departmentId, semesterId }, + command: request, + }, + actor: auth.authority.personId, + now: auth.authorizationInstant, + commandId, + }), + })), + ), + }), + + // A coverage-board change runs on the coordinator's reach over the department. + "placements.commandCoverageBoard": ( + { departmentId, semesterId, idempotencyKey, ifMatch, request }, + { headers }, + ) => + mutate(headers, { + operationId: "placements.commandCoverageBoard", + target: "/api/placements/coverage/{departmentId}/{semesterId}", + scope: { departmentId, semesterId }, + idempotencyKey, + ifMatch, + body: jsonBody(CoverageCommand)(request), + success: CoverageBoardResource, + authorize: authorizeCoordinator(headers, CommandCoverageBoard, departmentId).pipe( + Effect.map(({ auth, coordinator }) => ({ + auth, + execution: (commandId: string): PlacementExecution => ({ + mutation: { mode: "coverage", scope: { departmentId, semesterId }, command: request }, + coordinator, + now: auth.authorizationInstant, + commandId, + }), + })), + ), + }), + }); +}; diff --git a/apps/backend/src/profile/AGENTS.md b/apps/backend/src/profile/AGENTS.md index 516cb93ef..5f43c42bc 100644 --- a/apps/backend/src/profile/AGENTS.md +++ b/apps/backend/src/profile/AGENTS.md @@ -2,8 +2,8 @@ # apps/backend/src/profile -This folder holds People code under another name. Profile HTTP handlers under their older name. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +This folder holds People code under another name. Profile RPC handlers under their older name. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The People context in other folders: diff --git a/apps/backend/src/profile/http.test.ts b/apps/backend/src/profile/http.test.ts deleted file mode 100644 index 5c0d6597b..000000000 --- a/apps/backend/src/profile/http.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { backendDatabase } from "../../test/database.js"; -import { backendTestConfig } from "../../test/config.js"; -import { Database, OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { - Identity, - IdentityActor, - IdentitySessionNotFound, - type IdentityOperations, -} from "@vektorprogrammet/domain/identity"; -import { - OrganizationPersistenceError, - type OrganizationPersonAuthority, - OrganizationPersonAuthoritySchema, - PersonId, -} from "@vektorprogrammet/domain/organization"; -import { Profile } from "@vektorprogrammet/domain/profile"; -import { DateTime, Effect, Layer, Schema } from "effect"; -import { describe, expect, it } from "@effect/vitest"; -import { makeProfileTestHttp as makeProfileApiHttp } from "../test/native-http.js"; - -const authority = ( - personId: string, - globalAdministrator: "Active" | "Inactive" | "Absent", - memberships: ReadonlyArray<{ readonly active: boolean; readonly unitLeader: boolean }>, -) => - Schema.decodeUnknownSync(OrganizationPersonAuthoritySchema)({ - personId, - evaluatedAt: "2032-04-01T12:00:00.000Z", - globalAdministrator, - memberships: memberships.map((membership, index) => ({ - membershipId: `membership-${index}`, - teamId: "team-1", - departmentId: "department-1", - unitKind: "Team", - teamScope: "HomeDepartment", - departmentIndependent: false, - ...membership, - })), - nationalBoardSeats: [], - delegations: [], - }); - -const authorityDeniedProblem = { - type: "urn:vektorprogrammet:problem:v0.2:authority.denied", - title: "Authority denied", - status: 403, - detail: "The authenticated principal is not permitted to perform this operation.", - code: "authority.denied", -} as const; - -const profileUnavailableProblem = { - type: "urn:vektorprogrammet:problem:v0.2:profile.unavailable", - title: "Profile unavailable", - status: 503, - detail: "The profile service is temporarily unavailable.", - code: "profile.unavailable", -} as const; - -const oauthCredentialAuthority = OAuthCredentialAuthority.of({ - resolve: () => Effect.die("unexpected OAuth credential resolution"), - resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), -}); - -const identity = Identity.of({ - signIn: () => Effect.die("unexpected sign-in"), - resolveSession: (cookieHeader: string | undefined) => - cookieHeader?.includes("profile-test-session") - ? Effect.succeed( - new IdentityActor({ - personId: PersonId.make("profile-test-person"), - sessionId: "profile-test-session", - expiresAt: DateTime.makeUnsafe("2032-04-02T12:00:00.000Z"), - }), - ) - : Effect.fail(new IdentitySessionNotFound()), - readCurrentSession: () => Effect.die("unexpected session read"), - listSessions: () => Effect.die("unexpected session list"), - revokeCurrentSession: () => Effect.die("unexpected session mutation"), - revokeSession: () => Effect.die("unexpected session mutation"), - revokeOtherSessions: () => Effect.die("unexpected session mutation"), - revokeAllSessions: () => Effect.die("unexpected session mutation"), - recordSecurityEvent: () => Effect.die("unexpected identity audit"), - signOut: () => Effect.succeed({ setCookies: [] }), -} satisfies IdentityOperations); - -const securityServices = Layer.mergeAll( - Layer.succeed(Identity, identity), - Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), -); - -const request = ( - resolved: Effect.Effect, -) => - makeProfileApiHttp( - { - config: backendTestConfig, - resolveActor: () => resolved, - }, - securityServices, - ).fetch( - new Request("http://backend.test/api/profile", { - headers: { cookie: "better-auth.session_token=profile-test-session" }, - }), - ); - -describe("Profile HTTP authority failures", () => { - it.live.each([ - [ - "AuthorityInactive", - authority("profile-test-person", "Inactive", [{ active: false, unitLeader: false }]), - ], - ["NotInScope", authority("profile-test-person", "Absent", [])], - ] as const)("preserves %s as a typed scope denial", ([_, resolved]) => - Effect.gen(function* () { - const response = yield* request(Effect.succeed(resolved)); - - expect(response.status).toBe(403); - expect(response.headers.get("content-type")).toBe("application/problem+json"); - expect(yield* Effect.promise(() => response.json())).toEqual(authorityDeniedProblem); - }), - ); - - it.live("maps an unavailable authority provider failure to unavailable", () => - Effect.gen(function* () { - const response = yield* request( - Effect.fail( - new OrganizationPersistenceError({ - operation: "resolve profile test authority", - message: "provider unavailable", - }), - ), - ); - - expect(response.status).toBe(503); - expect(yield* Effect.promise(() => response.json())).toEqual(profileUnavailableProblem); - }), - ); -}); - -describe("Profile HTTP ETag", () => { - const profile = { - personId: PersonId.make("person-1"), - firstName: "Ada", - lastName: "Lovelace", - email: "ada@example.invalid", - phone: "+47 900 00 000", - nameRevision: 7, - contactRevision: 11, - } as const; - - const profileService = { - readOwnProfile: () => Effect.succeed(profile), - }; - - const readAs = ( - role: "ROLE_TEAM_MEMBER" | "ROLE_TEAM_LEADER", - representationRevision: number, - ) => { - const database = backendDatabase( - Database.use((sql) => - Effect.gen(function* () { - yield* sql`INSERT INTO person_profiles VALUES (${profile.personId},${profile.firstName},${profile.lastName},${profile.nameRevision})`; - yield* sql`INSERT INTO person_contact_profiles VALUES (${profile.personId},${profile.email},${profile.phone},${profile.contactRevision})`; - yield* sql`UPDATE public.profile_http_versions SET representation_revision=${representationRevision} WHERE person_id=${profile.personId}`; - }), - ), - ); - - const services = Layer.mergeAll( - database.layer, - Layer.mock(Profile, profileService), - securityServices, - ); - - return makeProfileApiHttp( - { - config: backendTestConfig, - resolveActor: () => - Effect.succeed( - authority(profile.personId, "Absent", [ - { active: true, unitLeader: role === "ROLE_TEAM_LEADER" }, - ]), - ), - }, - services, - ).fetch( - new Request("http://backend.test/api/profile", { - headers: { cookie: "better-auth.session_token=profile-test-session" }, - }), - ); - }; - - it.live("changes only after the persisted role representation revision changes", () => - Effect.gen(function* () { - const member = yield* readAs("ROLE_TEAM_MEMBER", 3); - const changedProjectionWithoutRevision = yield* readAs("ROLE_TEAM_LEADER", 3); - const leaderAfterCommittedAuthorityChange = yield* readAs("ROLE_TEAM_LEADER", 4); - - expect(member.status).toBe(200); - expect(changedProjectionWithoutRevision.status).toBe(200); - expect(leaderAfterCommittedAuthorityChange.status).toBe(200); - expect(member.headers.get("etag")).toMatch(/^"vkr2\.[A-Za-z0-9_-]{43}"$/u); - expect(changedProjectionWithoutRevision.headers.get("etag")).toBe(member.headers.get("etag")); - expect(leaderAfterCommittedAuthorityChange.headers.get("etag")).not.toBe( - member.headers.get("etag"), - ); - }), - ); -}); diff --git a/apps/backend/src/profile/http.ts b/apps/backend/src/profile/http.ts deleted file mode 100644 index 0c0fa3690..000000000 --- a/apps/backend/src/profile/http.ts +++ /dev/null @@ -1,354 +0,0 @@ -import type { OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { readOwnProfileHttpSourcePostgres } from "@vektorprogrammet/database/profile"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { ResourceId, ResourceKind } from "@vektorprogrammet/domain/authz"; -import type { Identity, IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import type { - Organization, - OrganizationPersonAuthority, - PersonId, - ProfileRole, -} from "@vektorprogrammet/domain/organization"; -import { - type OwnProfile, - Profile, - ProfileCommandId, - UpdateOwnProfileCommand, - type ProfileFailure, -} from "@vektorprogrammet/domain/profile"; -import { - ExternalNativeApi, - ProfileMergePatch, - ReadOwnProfileEndpoint, - UpdateOwnProfileEndpoint, - UserProfileResponse, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { - type CredentialPresentation, - makeNativeValidationError, - Problem, -} from "@vektorprogrammet/http-api/http-semantics"; -import { DateTime, Effect, Option, Predicate, Schema } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { - profileRoleFrom, - resolveRequestPersonAuthorityInTransaction, - type OrganizationResolutionError, -} from "../authority.js"; -import type { BackendConfig } from "../config.js"; -import { - authorizePerson, - commandOutcomeResponse, - commandReceiptProblems, - conditionalJson, - httpIdentity, - idempotencyKeyOf, - personPresentation, - problemMapper, - requestInvalid, - requireCurrentETag, - requiredIfMatchOf, - strictOutput, - unreachable, - webHandler, -} from "../http-api/problem.js"; -import { executeNativeHttpCommandPostgres } from "../http-api/receipt-transaction.js"; -import { - deriveProfileStrongETag, - jsonBodyBytes, - PRIVATE_NO_STORE, - semanticMutationRequest, - semanticRequestDigest, -} from "../http-semantics.js"; -import { genericContext } from "../native-operation.js"; - -export interface ProfileApiHttpOptions { - readonly config: BackendConfig; - /** Cookie or delegated bearer -> the current organization authority of that person. */ - readonly resolveActor: ( - request: Request, - ) => Effect.Effect< - OrganizationPersonAuthority, - IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, - Identity | OAuthCredentialAuthority | Organization - >; -} - -interface ProfileActor { - readonly personId: PersonId; - readonly role: ProfileRole; -} - -/** - * The one answer for every profile failure; a rejected credential is answered - * from the request's own credential evidence. - */ -const profileProblems = (presentation: CredentialPresentation) => - problemMapper< - ProfileFailure | OrganizationResolutionError | IdentityEngineError | UnauthenticatedActor - >()({ - ProfileNotFound: () => Problem.make("profile.not-found"), - ProfileContactNotFound: () => Problem.make("profile.not-found"), - ProfileDecodeError: requestInvalid, - ProfileStaleRevision: () => Problem.make("precondition.failed"), - ProfileCommandConflict: () => Problem.make("idempotency.digest-conflict"), - ProfilePersistenceError: () => Problem.make("profile.unavailable"), - ProfileQueryLimitExceeded: () => Problem.make("profile.unavailable"), - OrganizationDecodeError: () => Problem.make("profile.unavailable"), - OrganizationPersistenceError: () => Problem.make("profile.unavailable"), - IdentityEngineError: () => Problem.make("profile.unavailable"), - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - }); - -/** The dashboard role of an authority; a person without one is denied or unauthenticated. */ -const profileActor = (authority: OrganizationPersonAuthority) => { - const decision = profileRoleFrom(authority); - - if (Predicate.isTagged(decision, "Deny")) { - return Effect.fail( - decision.reason === "Unauthenticated" - ? new UnauthenticatedActor({ message: "Authentication required" }) - : Problem.make("authority.denied"), - ); - } - - return Effect.succeed({ personId: authority.personId, role: decision.value }); -}; - -/** The owner-only AccessSpec input shared by both profile operations. */ -const ownProfileAuthorization = (actor: ProfileActor) => ({ - personId: actor.personId, - resolution: { - selection: "ExactlyOne" as const, - contexts: [ - genericContext({ - domainId: "profile", - resourceKind: "person-profile", - resourceId: actor.personId, - facts: { ownerPersonId: actor.personId }, - authorityVersion: `profile:${actor.role}`, - }), - ], - }, - grantScopes: [ - { - _tag: "Resource" as const, - resource: { - kind: ResourceKind.make("person-profile"), - id: ResourceId.make(actor.personId), - }, - }, - ], -}); - -/** The profile representation: the stored profile and the caller's dashboard role. */ -const profileRepresentation = (profile: OwnProfile, role: ProfileRole) => ({ - personId: profile.personId, - firstName: profile.firstName, - lastName: profile.lastName, - email: profile.email, - phone: profile.phone, - role, - nameRevision: profile.nameRevision, - contactRevision: profile.contactRevision, -}); - -/** A stored profile that does not fit its representation is a server defect, never the caller's. */ -const readProfileSource = (personId: PersonId) => - readOwnProfileHttpSourcePostgres(personId).pipe( - Effect.catchTag("ProfileDecodeError", (cause) => Effect.die(cause)), - ); - -const readJsonPatch = (request: Request) => - Effect.gen(function* () { - if ( - !/^application\/merge-patch\+json(?:\s*;|$)/iu.test(request.headers.get("content-type") ?? "") - ) { - return yield* Problem.make("media-type.unsupported"); - } - - // The merge patch is read without a size bound or duplicate-member check, as before. - const text = yield* Effect.tryPromise({ - try: () => request.text(), - catch: () => Problem.make("request.malformed"), - }); - - const body = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(text).pipe( - Effect.mapError(() => Problem.make("request.malformed")), - ); - - const patch = yield* Schema.decodeUnknownEffect(ProfileMergePatch)(body, { - onExcessProperty: "error", - }).pipe(Effect.mapError(requestInvalid)); - - const fields = ["firstName", "lastName", "email", "phone"] as const; - - if (!fields.some((field) => Object.hasOwn(patch, field))) { - return yield* Problem.validation("validation.no-change", [ - makeNativeValidationError("", "no-change"), - ]); - } - - if (fields.some((field) => Object.hasOwn(patch, field) && patch[field] === null)) { - return yield* Problem.validation("validation.field-not-deletable", [ - makeNativeValidationError("", "field-not-deletable"), - ]); - } - - return patch; - }); - -const readOwnProfile = (request: Request, input: ProfileApiHttpOptions) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - const actor = yield* input.resolveActor(request).pipe(Effect.flatMap(profileActor)); - const now = DateTime.formatIso(yield* DateTime.now); - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(ReadOwnProfileEndpoint)), - request, - ...ownProfileAuthorization(actor), - now, - }, - presentation, - ); - - const source = yield* readProfileSource(actor.personId); - const profile = source.profile; - - const body = yield* strictOutput(UserProfileResponse)( - profileRepresentation(profile, actor.role), - ); - - return yield* conditionalJson({ - request, - body, - etag: deriveProfileStrongETag({ - personId: profile.personId, - nameRevision: profile.nameRevision, - contactRevision: profile.contactRevision, - representationRevision: source.representationRevision, - }), - cacheControl: PRIVATE_NO_STORE, - contentType: "application/json; charset=utf-8", - }); - }).pipe( - profileProblems(presentation), - // Reveal concealment never hides the profile, and a read never replays a command. - unreachable("resource.not-found", "idempotency.digest-conflict"), - ); -}; - -const updateOwnProfile = (request: Request) => - Effect.gen(function* () { - const idempotencyKey = yield* idempotencyKeyOf(request); - const ifMatch = yield* requiredIfMatchOf(request); - const patch = yield* readJsonPatch(request); - const presentation = personPresentation(request); - const operationId = "profile.updateOwnProfile"; - - // Failures are mapped once, after the executor, which adds its own receipt failures. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const resolved = yield* resolveRequestPersonAuthorityInTransaction(request); - const actor = yield* profileActor(resolved.authority); - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(UpdateOwnProfileEndpoint)), - credential: resolved.credential, - ...ownProfileAuthorization(actor), - now: resolved.authorizationInstant, - }, - presentation, - ); - - const currentSource = yield* readProfileSource(actor.personId); - const current = currentSource.profile; - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${actor.personId}`, - qualifiedOperationId: operationId, - normalizedTarget: "/api/profile", - idempotencyKey, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest(semanticMutationRequest(patch, ifMatch)), - operationId, - }, - // A receipt lookup precedes the precondition, so a replay after this command's own - // write still answers its stored response, and a changed body under the same key - // is a digest conflict rather than a stale precondition. - execute: Effect.gen(function* () { - yield* requireCurrentETag( - deriveProfileStrongETag({ - personId: current.personId, - nameRevision: current.nameRevision, - contactRevision: current.contactRevision, - representationRevision: currentSource.representationRevision, - }), - ifMatch, - ); - - yield* Profile.use((profileService) => - profileService.updateOwnProfile({ - actorPersonId: actor.personId, - command: UpdateOwnProfileCommand.make({ - commandId: ProfileCommandId.make(identity.commandId), - expectedNameRevision: current.nameRevision, - expectedContactRevision: current.contactRevision, - firstName: patch.firstName ?? current.firstName, - lastName: patch.lastName ?? current.lastName, - email: patch.email ?? current.email, - phone: patch.phone ?? current.phone, - }), - }), - ); - - const updatedSource = yield* readProfileSource(actor.personId); - const updated = updatedSource.profile; - - return { - status: 200, - mediaType: "application/json", - headers: { - "content-type": "application/json", - etag: deriveProfileStrongETag({ - personId: updated.personId, - nameRevision: updated.nameRevision, - contactRevision: updated.contactRevision, - representationRevision: updatedSource.representationRevision, - }), - }, - bodyBytes: jsonBodyBytes(profileRepresentation(updated, actor.role)), - }; - }), - }; - }), - ).pipe( - profileProblems(presentation), - commandReceiptProblems, - // Reveal concealment never hides the caller's own profile. - unreachable("resource.not-found"), - ); - - return yield* commandOutcomeResponse(outcome); - }); - -/** Native HttpApi implementations for self-service profile endpoints. */ -export const ProfileApiHandlers = (input: ProfileApiHttpOptions) => - HttpApiBuilder.group(ExternalNativeApi, "profile", (handlers) => - Effect.succeed( - handlers - .handleRaw("readOwnProfile", ({ request }) => - webHandler(request, (webRequest) => readOwnProfile(webRequest, input)), - ) - .handleRaw("updateOwnProfile", ({ request }) => webHandler(request, updateOwnProfile)), - ), - ); diff --git a/apps/backend/src/profile/rpc.test.ts b/apps/backend/src/profile/rpc.test.ts new file mode 100644 index 000000000..55e4810ce --- /dev/null +++ b/apps/backend/src/profile/rpc.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Database, IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; +import { ProfileLive } from "@vektorprogrammet/database/profile"; +import { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; +import { + Organization, + OrganizationPersistenceError, + type OrganizationPersonAuthority, + OrganizationPersonAuthoritySchema, + PersonId, +} from "@vektorprogrammet/domain/organization"; +import { IdempotencyKey, isProblem, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Layer, Schema } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import { backendDatabase } from "../../test/database.js"; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; + +const personId = PersonId.make("profile-test-person"); + +const cookie = { cookie: "better-auth.session_token=profile-test-session" }; + +const authority = ( + globalAdministrator: "Active" | "Inactive" | "Absent", + memberships: ReadonlyArray<{ readonly active: boolean; readonly unitLeader: boolean }>, +) => + Schema.decodeUnknownSync(OrganizationPersonAuthoritySchema)({ + personId, + evaluatedAt: "2032-04-01T12:00:00.000Z", + globalAdministrator, + memberships: memberships.map((membership, index) => ({ + membershipId: `membership-${index}`, + teamId: "team-1", + departmentId: "department-1", + unitKind: "Team", + teamScope: "HomeDepartment", + departmentIndependent: false, + ...membership, + })), + nationalBoardSeats: [], + delegations: [], + }); + +const member = authority("Absent", [{ active: true, unitLeader: false }]); + +const actor = IdentityActor.make({ + personId, + sessionId: "profile-test-session", + expiresAt: DateTime.makeUnsafe("2032-04-02T12:00:00.000Z"), +}); + +const identity = Identity.of({ + signIn: () => Effect.die("unexpected sign-in"), + resolveSession: (cookieHeader: string | undefined) => + cookieHeader?.includes("profile-test-session") === true + ? Effect.succeed(actor) + : Effect.fail(IdentitySessionNotFound.make()), + readCurrentSession: () => Effect.die("unexpected session read"), + listSessions: () => Effect.die("unexpected session list"), + revokeCurrentSession: () => Effect.die("unexpected session mutation"), + revokeSession: () => Effect.die("unexpected session mutation"), + revokeOtherSessions: () => Effect.die("unexpected session mutation"), + revokeAllSessions: () => Effect.die("unexpected session mutation"), + recordSecurityEvent: () => Effect.die("unexpected identity audit"), + signOut: () => Effect.succeed({ setCookies: [] }), +} satisfies IdentityOperations); + +const securityServices = Layer.mergeAll( + Layer.succeed(Identity, identity), + Layer.mock(IdentitySnapshot, { + resolveSession: (cookieHeader) => + identity + .resolveSession(cookieHeader) + .pipe( + Effect.catchTag("IdentitySessionExpired", () => + Effect.fail(IdentitySessionNotFound.make()), + ), + ), + }), + Layer.succeed( + OAuthCredentialAuthority, + OAuthCredentialAuthority.of({ + resolve: () => Effect.die("unexpected OAuth credential resolution"), + resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), + }), + ), +); + +const seedProfile = (representationRevision: number) => + Database.use((sql) => + Effect.gen(function* () { + yield* sql`INSERT INTO person_profiles VALUES (${personId},'Ada','Lovelace',7)`; + yield* sql`INSERT INTO person_contact_profiles VALUES (${personId},'ada@example.invalid','+47 900 00 000',11)`; + yield* sql`UPDATE public.profile_http_versions SET representation_revision=${representationRevision} WHERE person_id=${personId}`; + }), + ); + +/** The backend over a seeded profile, whose Organization resolves the caller to `resolved`. */ +const backendFor = ( + resolved: Effect.Effect, + representationRevision = 3, +) => { + const database = backendDatabase(seedProfile(representationRevision)); + const organization = Layer.mock(Organization, { resolvePersonAuthority: () => resolved }); + + return makeBackendTestRpc({ + config: backendTestConfig, + services: Layer.mergeAll( + database.layer, + organization, + securityServices, + ProfileLive.pipe(Layer.provide(Layer.merge(database.layer, organization))), + ), + }); +}; + +const problemCode = (failure: E) => (isProblem(failure) ? failure.code : failure); + +const key = (value: string) => IdempotencyKey.make(value.padEnd(22, "0")); + +const staleETag = StrongETag.make(`"vkr2.${"A".repeat(43)}"`); + +describe("profile.readOwnProfile", () => { + it.live.each([ + ["AuthorityInactive", authority("Inactive", [{ active: false, unitLeader: false }])], + ["NotInScope", authority("Absent", [])], + ] as const)("answers %s as a typed denial", ([_, resolved]) => + Effect.gen(function* () { + const client = yield* backendFor(Effect.succeed(resolved)).client; + + const failure = yield* Effect.flip( + client["profile.readOwnProfile"]().pipe(RpcClient.withHeaders(cookie)), + ); + + expect(problemCode(failure)).toBe("authority.denied"); + }), + ); + + it.live("answers an unavailable authority provider as profile.unavailable", () => + Effect.gen(function* () { + const client = yield* backendFor( + Effect.fail( + OrganizationPersistenceError.make({ + operation: "resolve profile test authority", + message: "provider unavailable", + }), + ), + ).client; + + const failure = yield* Effect.flip( + client["profile.readOwnProfile"]().pipe(RpcClient.withHeaders(cookie)), + ); + + expect(problemCode(failure)).toBe("profile.unavailable"); + }), + ); + + it.live("changes its entity tag only after the persisted representation revision changes", () => + Effect.gen(function* () { + const read = (resolved: OrganizationPersonAuthority, representationRevision: number) => + Effect.flatMap( + backendFor(Effect.succeed(resolved), representationRevision).client, + (client) => client["profile.readOwnProfile"]().pipe(RpcClient.withHeaders(cookie)), + ); + + const leader = authority("Absent", [{ active: true, unitLeader: true }]); + const asMember = yield* read(member, 3); + const asLeaderWithoutRevision = yield* read(leader, 3); + const asLeaderAfterRevision = yield* read(leader, 4); + + expect(asMember.profile).toEqual({ + personId, + firstName: "Ada", + lastName: "Lovelace", + email: "ada@example.invalid", + phone: "+47 900 00 000", + role: "ROLE_TEAM_MEMBER", + nameRevision: 7, + contactRevision: 11, + }); + expect(asLeaderWithoutRevision.profile.role).toBe("ROLE_TEAM_LEADER"); + expect(asLeaderWithoutRevision.etag).toBe(asMember.etag); + expect(asLeaderAfterRevision.etag).not.toBe(asMember.etag); + }), + ); +}); + +describe("profile.updateOwnProfile", () => { + it.live("applies a merge patch under its entity tag and replays it by key", () => + Effect.gen(function* () { + const client = yield* backendFor(Effect.succeed(member)).client; + const as = RpcClient.withHeaders(cookie); + const current = yield* client["profile.readOwnProfile"]().pipe(as); + + const update = ( + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: Parameters<(typeof client)["profile.updateOwnProfile"]>[0]["request"], + ) => client["profile.updateOwnProfile"]({ idempotencyKey, ifMatch, request }).pipe(as); + + expect(problemCode(yield* Effect.flip(update(key("no-change"), current.etag, {})))).toBe( + "validation.no-change", + ); + + expect( + problemCode(yield* Effect.flip(update(key("delete-phone"), current.etag, { phone: null }))), + ).toBe("validation.field-not-deletable"); + + expect( + problemCode( + yield* Effect.flip(update(key("stale-tag"), staleETag, { firstName: "Grace" })), + ), + ).toBe("precondition.failed"); + + const updated = yield* update(key("rename"), current.etag, { firstName: "Grace" }); + + expect(updated.profile).toEqual({ + ...current.profile, + firstName: "Grace", + // The domain command writes the name and the contact together. + nameRevision: current.profile.nameRevision + 1, + contactRevision: current.profile.contactRevision + 1, + }); + expect(updated.etag).not.toBe(current.etag); + + // A retry answers the first result, although the tag it names is no longer current. + expect(yield* update(key("rename"), current.etag, { firstName: "Grace" })).toEqual(updated); + + expect( + problemCode( + yield* Effect.flip(update(key("rename"), current.etag, { firstName: "Ingrid" })), + ), + ).toBe("idempotency.digest-conflict"); + + expect(yield* client["profile.readOwnProfile"]().pipe(as)).toEqual(updated); + }), + ); +}); diff --git a/apps/backend/src/profile/rpc.ts b/apps/backend/src/profile/rpc.ts new file mode 100644 index 000000000..e765fe8e6 --- /dev/null +++ b/apps/backend/src/profile/rpc.ts @@ -0,0 +1,362 @@ +/** + * The ProfileRpcs handlers: the caller's own profile. + * + * A read resolves the caller's organization authority, evaluates the owner-only AccessSpec, and + * answers the profile beside its strong entity tag. An update applies a JSON merge patch inside + * the serializable transaction that resolves the credential again, compares `ifMatch` with the + * current tag there, and stores its answer as a command receipt. + */ +import { readOwnProfileHttpSourcePostgres } from "@vektorprogrammet/database/profile"; +import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import { ResourceId, ResourceKind } from "@vektorprogrammet/domain/authz"; +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import type { + OrganizationPersonAuthority, + PersonId, + ProfileRole, +} from "@vektorprogrammet/domain/organization"; +import { + type OwnProfile, + Profile, + ProfileCommandId, + UpdateOwnProfileCommand, + type ProfileFailure, +} from "@vektorprogrammet/domain/profile"; +import { + type OwnProfileResource, + type ProfileMergePatch, + ProfileRpcs, + ReadOwnProfile, + UpdateOwnProfile, + UserProfileResponse, + reflectAccessSpec, +} from "@vektorprogrammet/rpc"; +import { + type CredentialPresentation, + makeNativeValidationError, + Problem, + StrongETag, +} from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Match, Option, Predicate, Schema } from "effect"; +import { + profileRoleFrom, + resolveRequestPersonAuthority, + resolveRequestPersonAuthorityInTransaction, + type OrganizationResolutionError, +} from "../authority.js"; +import { + deriveProfileStrongETag, + jsonBodyBytes, + semanticMutationRequest, + semanticRequestDigest, +} from "../http-semantics.js"; +import { genericContext } from "../native-operation.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { + authorizePerson, + commandIdentity, + commandReceiptProblems, + personPresentation, + problemMapper, + requestInvalid, + requireCurrentETag, + strictOutput, + unreachable, +} from "../rpc/problem.js"; +import { + executeNativeHttpCommandPostgres, + NativeHttpReceiptInvalid, + type NativeHttpCommandOutcome, + type NativeHttpResponseCapsule, +} from "../rpc/receipt-transaction.js"; + +interface ProfileActor { + readonly personId: PersonId; + readonly role: ProfileRole; +} + +/** + * The one answer for every profile failure; a rejected credential is answered from the request's + * own credential evidence. + */ +const profileProblems = (presentation: CredentialPresentation) => + problemMapper< + ProfileFailure | OrganizationResolutionError | IdentityEngineError | UnauthenticatedActor + >()({ + ProfileNotFound: () => Problem.make("profile.not-found"), + ProfileContactNotFound: () => Problem.make("profile.not-found"), + ProfileDecodeError: requestInvalid, + ProfileStaleRevision: () => Problem.make("precondition.failed"), + ProfileCommandConflict: () => Problem.make("idempotency.digest-conflict"), + ProfilePersistenceError: () => Problem.make("profile.unavailable"), + ProfileQueryLimitExceeded: () => Problem.make("profile.unavailable"), + OrganizationDecodeError: () => Problem.make("profile.unavailable"), + OrganizationPersistenceError: () => Problem.make("profile.unavailable"), + IdentityEngineError: () => Problem.make("profile.unavailable"), + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + }); + +/** The dashboard role of an authority; a person without one is denied or unauthenticated. */ +const profileActor = (authority: OrganizationPersonAuthority) => { + const decision = profileRoleFrom(authority); + + if (Predicate.isTagged(decision, "Deny")) { + return Effect.fail( + decision.reason === "Unauthenticated" + ? UnauthenticatedActor.make({ message: "Authentication required" }) + : Problem.make("authority.denied"), + ); + } + + return Effect.succeed({ personId: authority.personId, role: decision.value }); +}; + +/** The owner-only AccessSpec input shared by both profile operations. */ +const ownProfileAuthorization = (actor: ProfileActor) => ({ + personId: actor.personId, + resolution: { + selection: "ExactlyOne" as const, + contexts: [ + genericContext({ + domainId: "profile", + resourceKind: "person-profile", + resourceId: actor.personId, + facts: { ownerPersonId: actor.personId }, + authorityVersion: `profile:${actor.role}`, + }), + ], + }, + grantScopes: [ + { + _tag: "Resource" as const, + resource: { + kind: ResourceKind.make("person-profile"), + id: ResourceId.make(actor.personId), + }, + }, + ], +}); + +/** The profile representation: the stored profile and the caller's dashboard role. */ +const profileRepresentation = (profile: OwnProfile, role: ProfileRole) => ({ + personId: profile.personId, + firstName: profile.firstName, + lastName: profile.lastName, + email: profile.email, + phone: profile.phone, + role, + nameRevision: profile.nameRevision, + contactRevision: profile.contactRevision, +}); + +/** A stored profile that does not fit its representation is a server defect, never the caller's. */ +const readProfileSource = (personId: PersonId) => + readOwnProfileHttpSourcePostgres(personId).pipe( + Effect.catchTag("ProfileDecodeError", (cause) => Effect.die(cause)), + ); + +type ProfileSource = Effect.Success>; + +const profileETag = (source: ProfileSource) => + deriveProfileStrongETag({ + personId: source.profile.personId, + nameRevision: source.profile.nameRevision, + contactRevision: source.profile.contactRevision, + representationRevision: source.representationRevision, + }); + +const patchFields = ["firstName", "lastName", "email", "phone"] as const; + +/** + * The merge patch semantics of RFC 7396 that a profile admits: at least one member, and no member + * set to `null`, because no profile field can be deleted. + */ +const requireApplicablePatch = (patch: ProfileMergePatch) => { + if (!patchFields.some((field) => Object.hasOwn(patch, field))) { + return Effect.fail( + Problem.validation("validation.no-change", [makeNativeValidationError("", "no-change")]), + ); + } + + if (patchFields.some((field) => Object.hasOwn(patch, field) && patch[field] === null)) { + return Effect.fail( + Problem.validation("validation.field-not-deletable", [ + makeNativeValidationError("", "field-not-deletable"), + ]), + ); + } + + return Effect.void; +}; + +const ProfileBodyJson = Schema.fromJsonString(Schema.toCodecJson(UserProfileResponse)); + +/** + * The receipt of a profile update, byte for byte what the HTTP contract stored: the profile as the + * JSON body, and its entity tag as the `etag` header. A retry that straddles the cutover from HTTP + * therefore replays the first answer. + */ +const profileCapsule = (resource: OwnProfileResource) => + Schema.encodeEffect(Schema.toCodecJson(UserProfileResponse))(resource.profile).pipe( + Effect.orDie, + Effect.map( + (body): NativeHttpResponseCapsule => ({ + status: 200, + mediaType: "application/json", + headers: { "content-type": "application/json", etag: resource.etag }, + bodyBytes: jsonBodyBytes(body), + }), + ), + ); + +/** The profile and entity tag that a profile update receipt stores; a mismatch is a defect. */ +const storedProfile = (capsule: NativeHttpResponseCapsule) => + Effect.gen(function* () { + if (capsule.bodyBytes === null || capsule.headers.etag === undefined) { + return yield* Effect.die( + new NativeHttpReceiptInvalid({ reason: "a profile receipt stores no profile" }), + ); + } + + const profile = yield* Schema.decodeEffect(ProfileBodyJson)( + new TextDecoder().decode(capsule.bodyBytes), + ).pipe(Effect.orDie); + + const etag = yield* Schema.decodeEffect(StrongETag)(capsule.headers.etag).pipe(Effect.orDie); + + return { profile, etag } satisfies OwnProfileResource; + }); + +const profileOutcome = (outcome: NativeHttpCommandOutcome) => + Match.value(outcome).pipe( + Match.tag("Committed", "Replay", ({ response }) => storedProfile(response)), + Match.tag("InFlight", () => Effect.fail(Problem.make("idempotency.in-flight"))), + Match.tag("DigestConflict", () => Effect.fail(Problem.make("idempotency.digest-conflict"))), + Match.tag("ResponseExpired", () => Effect.fail(Problem.make("idempotency.response-expired"))), + Match.exhaustive, + ); + +/** The ProfileRpcs handlers. */ +export const ProfileRpcHandlers = (options: NativeRpcOptions) => + ProfileRpcs.toLayer({ + "profile.readOwnProfile": (_payload, { headers }) => { + const presentation = personPresentation(headers); + const request = credentialRequestOf(headers); + + return Effect.gen(function* () { + const actor = yield* resolveRequestPersonAuthority(request, { now: options.now }).pipe( + Effect.flatMap(profileActor), + ); + + const now = DateTime.formatIso(yield* DateTime.now); + + yield* authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(ReadOwnProfile)), + request, + ...ownProfileAuthorization(actor), + now, + }, + presentation, + ); + + const source = yield* readProfileSource(actor.personId); + + const profile = yield* strictOutput(UserProfileResponse)( + profileRepresentation(source.profile, actor.role), + ); + + return { profile, etag: profileETag(source) } satisfies OwnProfileResource; + }).pipe( + profileProblems(presentation), + // Reveal concealment never hides the profile, and a read neither replays nor writes. + unreachable("resource.not-found", "idempotency.digest-conflict", "precondition.failed"), + ); + }, + + "profile.updateOwnProfile": ({ idempotencyKey, ifMatch, request: patch }, { headers }) => { + const presentation = personPresentation(headers); + const operationId = "profile.updateOwnProfile"; + + return Effect.gen(function* () { + yield* requireApplicablePatch(patch); + + // Failures are mapped once, after the executor, which adds its own receipt failures. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const resolved = yield* resolveRequestPersonAuthorityInTransaction( + credentialRequestOf(headers), + ); + + const actor = yield* profileActor(resolved.authority); + + yield* authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(UpdateOwnProfile)), + credential: resolved.credential, + ...ownProfileAuthorization(actor), + now: resolved.authorizationInstant, + }, + presentation, + ); + + const currentSource = yield* readProfileSource(actor.personId); + const current = currentSource.profile; + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: `Person:${actor.personId}`, + qualifiedOperationId: operationId, + normalizedTarget: "/api/profile", + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest(semanticMutationRequest(patch, ifMatch)), + operationId, + }, + // A receipt lookup precedes the precondition, so a replay after this command's own + // write still answers its stored response, and a changed body under the same key + // is a digest conflict rather than a stale precondition. + execute: Effect.gen(function* () { + yield* requireCurrentETag(profileETag(currentSource), ifMatch); + + yield* Profile.use((profileService) => + profileService.updateOwnProfile({ + actorPersonId: actor.personId, + command: UpdateOwnProfileCommand.make({ + commandId: ProfileCommandId.make(identity.commandId), + expectedNameRevision: current.nameRevision, + expectedContactRevision: current.contactRevision, + firstName: patch.firstName ?? current.firstName, + lastName: patch.lastName ?? current.lastName, + email: patch.email ?? current.email, + phone: patch.phone ?? current.phone, + }), + }), + ); + + const updatedSource = yield* readProfileSource(actor.personId); + + const profile = yield* strictOutput(UserProfileResponse)( + profileRepresentation(updatedSource.profile, actor.role), + ); + + return yield* profileCapsule({ profile, etag: profileETag(updatedSource) }); + }), + }; + }), + ).pipe( + profileProblems(presentation), + commandReceiptProblems, + // Reveal concealment never hides the caller's own profile. + unreachable("resource.not-found"), + ); + + return yield* profileOutcome(outcome); + }); + }, + }); diff --git a/apps/backend/src/receipt/AGENTS.md b/apps/backend/src/receipt/AGENTS.md index a3af77d1c..b46354d37 100644 --- a/apps/backend/src/receipt/AGENTS.md +++ b/apps/backend/src/receipt/AGENTS.md @@ -2,8 +2,8 @@ # apps/backend/src/receipt -This folder holds Economy code under another name. Expense claim HTTP handlers and private files. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +This folder holds Economy code under another name. Expense claim RPC handlers and private files. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The Economy context in other folders: @@ -65,8 +65,8 @@ No downstream context. The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../../../docs/constructs.md) indexes them all. -- [`receiptProblems`](../../../../docs/constructs/http-problem.md#receiptproblems) (http-problem): The one answer for every receipt failure other than a rejected credential, including an unavailable store, Identity, or E2E barrier. -- [`receiptCredentialProblems`](../../../../docs/constructs/http-problem.md#receiptcredentialproblems) (http-problem): A credential rejected inside a receipt handler is answered from the request's own evidence. +- [`receiptProblems`](../../../../docs/constructs/rpc-problem.md#receiptproblems) (rpc-problem): The one answer for every receipt failure other than a rejected credential, including an unavailable store, Identity, or E2E barrier. +- [`receiptCredentialProblems`](../../../../docs/constructs/rpc-problem.md#receiptcredentialproblems) (rpc-problem): A credential rejected inside a receipt handler is answered from the request's own evidence. Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. diff --git a/apps/backend/src/receipt/http-commands.ts b/apps/backend/src/receipt/commands.ts similarity index 51% rename from apps/backend/src/receipt/http-commands.ts rename to apps/backend/src/receipt/commands.ts index 9dc8594d6..c153f4a18 100644 --- a/apps/backend/src/receipt/http-commands.ts +++ b/apps/backend/src/receipt/commands.ts @@ -1,8 +1,15 @@ -/** Receipt command handlers: submit, revise, withdraw, approve/reject/reopen, and settle. */ +/** + * Receipt commands: submit, revise, withdraw, approve, reject, reopen, and settle. + * + * Each command resolves the caller's credential inside the serializable transaction that commits + * it, authorizes the mutation there through `Economy.authorizeReceiptMutation`, compares If-Match + * with the current entity tag, and stores its answer as a command receipt. The receipt keeps the + * HTTP route as its normalized target and the HTTP capsule as its answer, so a retry that + * straddles the cutover replays. + */ import { Economy, ReceiptCommandRequestSchema, - ReceiptDecodeError, ReceiptId, ReceiptMutationAuthorizationTarget, ReceiptPersistenceError, @@ -14,73 +21,69 @@ import { type ReceiptMutationAuthorization, type ReceiptSubmissionAllocation, } from "@vektorprogrammet/domain/receipt"; -import { Problem, type StrongETag } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Match, Predicate, type Schema } from "effect"; import { - commandOutcomeResponse, + type IdempotencyKey, + RecordReceiptSettlementRequest, + type ReviseReceiptRequest, + type SubmitReceiptRequest, + ReceiptResource, + ReceiptSettlementEvidenceResource, +} from "@vektorprogrammet/rpc"; +import { Problem, type StrongETag } from "@vektorprogrammet/rpc/problem"; +import { Effect, Match, Predicate, Schema } from "effect"; +import { semanticMutationRequest, semanticRequestDigest } from "../http-semantics.js"; +import { + commandIdentity, + commandOutcome, commandReceiptProblems, - decodeRequest, - httpIdentity, - idempotencyKeyOf, personPresentation, - problemWebResponse, + requestInvalid, requireCurrentETag, - requiredIfMatchOf, - requireNoQuery, unreachable, -} from "../http-api/problem.js"; -import { executeNativeHttpCommandPostgres } from "../http-api/receipt-transaction.js"; -import { - PRIVATE_NO_STORE, - semanticMutationRequest, - semanticRequestDigest, -} from "../http-semantics.js"; -import { - RECEIPT_E2E_CONCURRENCY_RESPONSE_HEADER, - type ReceiptE2ETransactionBarrier, -} from "./e2e-support.js"; +} from "../rpc/problem.js"; +import { executeNativeHttpCommandPostgres } from "../rpc/receipt-transaction.js"; +import { authorizationPrincipalInTransaction, type ReceiptCaller } from "./context.js"; +import type { ReceiptE2ETransactionBarrier } from "./e2e-support.js"; import type { ReceiptFileStore, StagedReceiptFile } from "./filesystem.js"; -import { - authorizationPrincipalInTransaction, - type ReceiptApiHttpOptions, - type ReceiptIdentityFailure, -} from "./http-context.js"; -import { - decodeExactEmptyJson, - decodeReviseMultipart, - decodeSettlementRequest, - decodeSubmitMultipart, - decodeSubmitQuery, -} from "./http-decode.js"; -import { receiptCredentialProblems, receiptProblems } from "./http-problem.js"; +import { drainReceiptOutbox } from "./outbox-drain.js"; +import { receiptCredentialProblems, receiptProblems } from "./problem.js"; import { receiptEtag, receiptMutationCapsule, settlementMutationCapsule, -} from "./http-representation.js"; -import { drainReceiptOutbox } from "./outbox-drain.js"; +} from "./representation.js"; +import { + type ValidatedReceiptFile, + validateRevision, + validateSubmission, + validateSubmitDepartment, +} from "./validate.js"; + +export type ReceiptApprovalAction = "approve" | "reject" | "reopen"; + +/** A command on one existing receipt: its idempotency key and the tag that If-Match names. */ +interface ReceiptTransition { + readonly receiptId: ReceiptId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; +} -export type ReceiptApprovalRoute = { - readonly action: "approve" | "reject" | "reopen"; - readonly receiptId: string; -}; +/** The empty JSON body of the HTTP transitions, which their request digest still covers. */ +const emptyBody = {}; const mutationIdentity = ( - request: Request, principal: ReceiptCommandPrincipal, qualifiedOperationId: string, normalizedTarget: string, + idempotencyKey: IdempotencyKey, ) => - idempotencyKeyOf(request).pipe( - Effect.flatMap((idempotencyKey) => - httpIdentity({ - credentialSubject: `Person:${principal.personId}`, - qualifiedOperationId, - normalizedTarget, - idempotencyKey, - }), - ), - ); + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + commandIdentity({ + credentialSubject: `Person:${principal.personId}`, + qualifiedOperationId, + normalizedTarget, + idempotencyKey, + }); interface PreparedReceiptMutation { readonly identity: { @@ -114,7 +117,7 @@ const authorizeReceiptMutationInTransaction = , ) : Effect.fail( - new ReceiptPersistenceError({ + ReceiptPersistenceError.make({ operation: "authorize Receipt mutation", message: "authorization target mismatch", }), @@ -148,7 +151,10 @@ const executeReceiptMutation = ( prepared.allocation, ); - return receiptMutationCapsule(result.receipt, prepared.response); + return yield* receiptMutationCapsule({ + receipt: result.receipt, + response: prepared.response, + }); }), ), }; @@ -156,18 +162,17 @@ const executeReceiptMutation = ( execution, ); -/** Stages uploaded bytes; bytes past the limit fail validation and any other failure is the store's. */ +/** Stages uploaded bytes; any failure of the store is the store's. */ const stageReceiptFile = ( fileStore: ReceiptFileStore, - file: File, + upload: ValidatedReceiptFile, commandId: string, - contentType: ReceiptFile["contentType"], maxFileBytes: number, ) => - fileStore.stageBytes(file, commandId, contentType, maxFileBytes).pipe( + fileStore.stageBytes(upload.file, commandId, upload.contentType, maxFileBytes).pipe( Effect.catchTag("ReceiptFileStoreError", (cause) => Effect.fail( - new ReceiptPersistenceError({ + ReceiptPersistenceError.make({ operation: "stage receipt file", message: "receipt file staging failed", cause, @@ -179,23 +184,46 @@ const stageReceiptFile = ( const cleanupStagedFile = (fileStore: ReceiptFileStore, staged: StagedReceiptFile) => fileStore.cleanupStage(staged.file).pipe(Effect.ignore); -export const submitReceipt = ( - request: Request, - options: ReceiptApiHttpOptions, - fileStore: ReceiptFileStore, -) => { +const semanticFile = (file: ReceiptFile) => ({ + contentType: file.contentType, + byteLength: file.byteLength, + sha256: file.sha256, +}); + +/** The environment of every receipt command: its call and the private receipt store. */ +export interface ReceiptCommandContext extends ReceiptCaller { + readonly fileStore: ReceiptFileStore; +} + +export const submitReceipt = ({ + context, + payload, +}: { + readonly context: ReceiptCommandContext; + readonly payload: { + readonly idempotencyKey: IdempotencyKey; + readonly departmentId?: string | undefined; + readonly request: SubmitReceiptRequest; + }; +}) => { + const { headers, options, fileStore } = context; + const config = options.config.receipt; let staged: StagedReceiptFile | undefined; let allocation: ReceiptSubmissionAllocation | undefined; let committed = false; return Effect.gen(function* () { - const departmentId = yield* decodeSubmitQuery(request); - const fields = yield* decodeSubmitMultipart(request, options.config.maxFileBytes); + const departmentId = yield* validateSubmitDepartment(payload.departmentId); + + const fields = yield* validateSubmission({ + request: payload.request, + maxFileBytes: config.maxFileBytes, + }); // Failures are mapped after the executor, whose retry reads their causes. const outcome = yield* executeReceiptMutation(() => Effect.gen(function* () { - const principal = yield* authorizationPrincipalInTransaction(request, options); + const principal = yield* authorizationPrincipalInTransaction(context); const authorization = yield* authorizeReceiptMutationInTransaction( ReceiptMutationAuthorizationTarget.SubmitReceipt( @@ -205,35 +233,32 @@ export const submitReceipt = ( ); const identity = yield* mutationIdentity( - request, principal, "receipts.submitReceipt", "/api/receipts", + payload.idempotencyKey, ); const nextStaged = yield* stageReceiptFile( fileStore, fields.file, identity.commandId, - fields.contentType, - options.config.maxFileBytes, + config.maxFileBytes, ); staged = nextStaged; yield* fileStore.service.stage(nextStaged.file); - const semanticBody: Schema.JsonObject = { + const semanticFields = { description: fields.description, amountOre: fields.amountOre, receiptDate: fields.receiptDate, - file: { - contentType: nextStaged.file.contentType, - byteLength: nextStaged.file.byteLength, - sha256: nextStaged.file.sha256, - }, + file: semanticFile(nextStaged.file), }; - if (departmentId !== undefined) Object.assign(semanticBody, { departmentId }); + // The HTTP body named a department only when the query did, and the digest keeps that. + const semanticBody = + departmentId === undefined ? semanticFields : { ...semanticFields, departmentId }; const commandFields = { commandId: identity.commandId, @@ -243,13 +268,13 @@ export const submitReceipt = ( file: nextStaged.file, }; - if (departmentId !== undefined) Object.assign(commandFields, { departmentId }); - - const command = ReceiptCommandRequestSchema.cases.SubmitReceipt.make(commandFields); + const command = ReceiptCommandRequestSchema.cases.SubmitReceipt.make( + departmentId === undefined ? commandFields : { ...commandFields, departmentId }, + ); const nextAllocation = { - receiptId: ReceiptId.make(options.config.nextReceiptId()), - visualId: ReceiptVisualId.make(options.config.nextVisualId()), + receiptId: ReceiptId.make(config.nextReceiptId()), + visualId: ReceiptVisualId.make(config.nextVisualId()), }; allocation = nextAllocation; @@ -265,29 +290,29 @@ export const submitReceipt = ( location: `/api/receipts/${encodeURIComponent(nextAllocation.receiptId)}`, }, allocation: nextAllocation, - }; + } satisfies PreparedReceiptMutation; }), ); if (Predicate.isTagged(outcome, "Committed")) { if (allocation === undefined) { - return yield* new ReceiptPersistenceError({ + return yield* ReceiptPersistenceError.make({ operation: "submit receipt allocation", message: "transaction preparation produced no allocation", }); } committed = true; - yield* drainReceiptOutbox(options, fileStore, allocation.receiptId); + yield* drainReceiptOutbox({ config }, fileStore, allocation.receiptId); } else if (staged?.created === true) { yield* cleanupStagedFile(fileStore, staged); } - return yield* commandOutcomeResponse(outcome); + return yield* commandOutcome(ReceiptResource)(outcome); }).pipe( receiptProblems, commandReceiptProblems, - receiptCredentialProblems(personPresentation(request)), + receiptCredentialProblems(personPresentation(headers)), // A submission names no existing receipt: none is missing, stale, or in another state. unreachable("receipt.not-found", "precondition.failed", "receipt.invalid-transition"), Effect.ensuring( @@ -298,22 +323,33 @@ export const submitReceipt = ( ); }; -export const reviseReceipt = ( - request: Request, - receiptId: string, - options: ReceiptApiHttpOptions, - fileStore: ReceiptFileStore, -) => { +export const reviseReceipt = ({ + context, + payload, +}: { + readonly context: ReceiptCommandContext; + readonly payload: { + readonly receiptId: ReceiptId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; + readonly request: ReviseReceiptRequest; + }; +}) => { + const { headers, options, fileStore } = context; + const config = options.config.receipt; + const { receiptId, ifMatch } = payload; let staged: StagedReceiptFile | undefined; let committed = false; return Effect.gen(function* () { - const ifMatch = yield* requiredIfMatchOf(request); - const fields = yield* decodeReviseMultipart(request, options.config.maxFileBytes); + const fields = yield* validateRevision({ + request: payload.request, + maxFileBytes: config.maxFileBytes, + }); const outcome = yield* executeReceiptMutation(() => Effect.gen(function* () { - const principal = yield* authorizationPrincipalInTransaction(request, options); + const principal = yield* authorizationPrincipalInTransaction(context); const authorization = yield* authorizeReceiptMutationInTransaction( ReceiptMutationAuthorizationTarget.RevisePendingReceipt({ receiptId }), @@ -323,26 +359,18 @@ export const reviseReceipt = ( const current = authorization.current; const identity = yield* mutationIdentity( - request, principal, "receipts.reviseReceipt", `/api/receipts/${encodeURIComponent(receiptId)}`, + payload.idempotencyKey, ); if (fields.file !== undefined) { - const file = fields.file; - const contentType = fields.contentType; - - if (contentType === undefined) { - return yield* new ReceiptDecodeError({ message: "invalid receipt file" }); - } - const nextStaged = yield* stageReceiptFile( fileStore, - file, + fields.file, identity.commandId, - contentType, - options.config.maxFileBytes, + config.maxFileBytes, ); staged = nextStaged; @@ -352,31 +380,21 @@ export const reviseReceipt = ( const amountOre = fields.amountOre ?? Number(current.amountOre); if (!Number.isSafeInteger(amountOre) || amountOre <= 0) { - return yield* new ReceiptPersistenceError({ + return yield* ReceiptPersistenceError.make({ operation: "decode current receipt amount", message: "invalid amount", }); } - const semanticBody: Schema.JsonObject = {}; + const semanticBody: Record = {}; - if (fields.description !== undefined) - Object.assign(semanticBody, { description: fields.description }); + if (fields.description !== undefined) semanticBody.description = fields.description; - if (fields.amountOre !== undefined) - Object.assign(semanticBody, { amountOre: fields.amountOre }); + if (fields.amountOre !== undefined) semanticBody.amountOre = fields.amountOre; - if (fields.receiptDate !== undefined) - Object.assign(semanticBody, { receiptDate: fields.receiptDate }); + if (fields.receiptDate !== undefined) semanticBody.receiptDate = fields.receiptDate; - if (staged !== undefined) - Object.assign(semanticBody, { - file: { - contentType: staged.file.contentType, - byteLength: staged.file.byteLength, - sha256: staged.file.sha256, - }, - }); + if (staged !== undefined) semanticBody.file = semanticFile(staged.file); const command = ReceiptCommandRequestSchema.cases.RevisePendingReceipt.make({ commandId: identity.commandId, @@ -401,24 +419,24 @@ export const reviseReceipt = ( response: { status: 200, ifMatch, - currentEtag: receiptEtag(receiptId, current.revision), + currentEtag: receiptEtag({ receiptId, revision: current.revision }), }, - }; + } satisfies PreparedReceiptMutation; }), ); if (Predicate.isTagged(outcome, "Committed")) { committed = true; - yield* drainReceiptOutbox(options, fileStore, receiptId); + yield* drainReceiptOutbox({ config }, fileStore, receiptId); } else if (staged?.created === true) { yield* cleanupStagedFile(fileStore, staged); } - return yield* commandOutcomeResponse(outcome); + return yield* commandOutcome(ReceiptResource)(outcome); }).pipe( receiptProblems, commandReceiptProblems, - receiptCredentialProblems(personPresentation(request)), + receiptCredentialProblems(personPresentation(headers)), // Only a submission allocates a receipt identity that can already exist. unreachable("receipt.already-exists"), Effect.ensuring( @@ -429,19 +447,20 @@ export const reviseReceipt = ( ); }; -export const withdrawReceipt = ( - request: Request, - receiptId: string, - options: ReceiptApiHttpOptions, - fileStore: ReceiptFileStore, -) => - Effect.gen(function* () { - const ifMatch = yield* requiredIfMatchOf(request); - const body = yield* decodeExactEmptyJson(request); +export const withdrawReceipt = ({ + context, + payload, +}: { + readonly context: ReceiptCommandContext; + readonly payload: ReceiptTransition; +}) => { + const { headers, options, fileStore } = context; + const { receiptId, ifMatch } = payload; + return Effect.gen(function* () { const outcome = yield* executeReceiptMutation(() => Effect.gen(function* () { - const principal = yield* authorizationPrincipalInTransaction(request, options); + const principal = yield* authorizationPrincipalInTransaction(context); const authorization = yield* authorizeReceiptMutationInTransaction( ReceiptMutationAuthorizationTarget.WithdrawPendingReceipt({ receiptId }), @@ -451,16 +470,16 @@ export const withdrawReceipt = ( const current = authorization.current; const identity = yield* mutationIdentity( - request, principal, "receipts.withdrawReceipt", `/api/receipts/${encodeURIComponent(receiptId)}/withdraw`, + payload.idempotencyKey, ); return { identity, operationId: "receipts.withdrawReceipt", - requestSha256: semanticRequestDigest(semanticMutationRequest(body, ifMatch)), + requestSha256: semanticRequestDigest(semanticMutationRequest(emptyBody, ifMatch)), command: ReceiptCommandRequestSchema.cases.WithdrawPendingReceipt.make({ commandId: identity.commandId, receiptId: current.receiptId, @@ -470,189 +489,172 @@ export const withdrawReceipt = ( response: { status: 200, ifMatch, - currentEtag: receiptEtag(receiptId, current.revision), + currentEtag: receiptEtag({ receiptId, revision: current.revision }), }, - }; + } satisfies PreparedReceiptMutation; }), ); if (Predicate.isTagged(outcome, "Committed")) { - yield* drainReceiptOutbox(options, fileStore, receiptId); + yield* drainReceiptOutbox({ config: options.config.receipt }, fileStore, receiptId); } - return yield* commandOutcomeResponse(outcome); + return yield* commandOutcome(ReceiptResource)(outcome); }).pipe( receiptProblems, commandReceiptProblems, - receiptCredentialProblems(personPresentation(request)), - // Only a submission allocates a receipt identity that can already exist. - unreachable("receipt.already-exists"), + receiptCredentialProblems(personPresentation(headers)), + // Only a submission allocates a receipt identity that can already exist; only a revision + // decodes fields or stages a file. + unreachable("receipt.already-exists", "validation.failed", "receipt.file-not-staged"), ); +}; -/** - * A lane the E2E barrier synchronized says so on its answer. - */ -const markSynchronized = (response: Response) => { - response.headers.set(RECEIPT_E2E_CONCURRENCY_RESPONSE_HEADER, "1"); +const approvalOperation = (action: ReceiptApprovalAction) => + Match.value(action).pipe( + Match.when("approve", () => "receipts.approveReceipt" as const), + Match.when("reopen", () => "receipts.reopenReceipt" as const), + Match.orElse(() => "receipts.rejectReceipt" as const), + ); - return response; -}; +const approvalTarget = (action: ReceiptApprovalAction, receiptId: string) => + Match.value(action).pipe( + Match.when("approve", () => ReceiptMutationAuthorizationTarget.ApproveReceipt({ receiptId })), + Match.when("reopen", () => + ReceiptMutationAuthorizationTarget.ReopenRejectedReceipt({ receiptId }), + ), + Match.orElse(() => ReceiptMutationAuthorizationTarget.RejectReceipt({ receiptId })), + ); + +const approvalCommandRequest = ( + action: ReceiptApprovalAction, + fields: { + readonly commandId: string; + readonly receiptId: ReceiptId; + readonly expectedRevision: number; + }, +) => + Match.value(action).pipe( + Match.when("approve", () => ReceiptCommandRequestSchema.cases.ApproveReceipt.make(fields)), + Match.when("reopen", () => + ReceiptCommandRequestSchema.cases.ReopenRejectedReceipt.make(fields), + ), + Match.orElse(() => ReceiptCommandRequestSchema.cases.RejectReceipt.make(fields)), + ); /** - * Approve, reject, or reopen one receipt. Approve and reject join the E2E concurrency barrier - * when one is composed, and mark synchronized responses, including failures. + * Approve, reject, or reopen one receipt. Approve and reject join the E2E concurrency barrier when + * one is composed. */ -export const approvalCommand = ( - request: Request, - route: ReceiptApprovalRoute, - options: ReceiptApiHttpOptions, - fileStore: ReceiptFileStore, - barrier: ReceiptE2ETransactionBarrier | undefined, -) => { - let synchronized = false; +export const approvalCommand = ({ + context, + action, + payload, + barrier, +}: { + readonly context: ReceiptCommandContext; + readonly action: ReceiptApprovalAction; + readonly payload: ReceiptTransition; + readonly barrier: ReceiptE2ETransactionBarrier | undefined; +}) => { + const { headers, options, fileStore } = context; + const { receiptId, ifMatch } = payload; + const operationId = approvalOperation(action); + const normalizedTarget = `/api/receipts/${encodeURIComponent(receiptId)}/${action}`; return Effect.gen(function* () { - yield* requireNoQuery(request); - const ifMatch = yield* requiredIfMatchOf(request); - const body = yield* decodeExactEmptyJson(request); - - const operationId = Match.value(route.action).pipe( - Match.when("approve", () => "receipts.approveReceipt" as const), - Match.when("reopen", () => "receipts.reopenReceipt" as const), - Match.orElse(() => "receipts.rejectReceipt" as const), - ); - - const normalizedTarget = `/api/receipts/${encodeURIComponent(route.receiptId)}/${route.action}`; + const outcome = yield* executeReceiptMutation( + () => + Effect.gen(function* () { + const principal = yield* authorizationPrincipalInTransaction(context); - const answer = Effect.gen(function* () { - const outcome = yield* executeReceiptMutation( - () => - Effect.gen(function* () { - const principal = yield* authorizationPrincipalInTransaction(request, options); - - if (route.action !== "reopen") { - synchronized = - barrier === undefined - ? false - : yield* barrier(request, route.receiptId, route.action); - } - - const authorization = yield* authorizeReceiptMutationInTransaction( - Match.value(route.action).pipe( - Match.when("approve", () => - ReceiptMutationAuthorizationTarget.ApproveReceipt({ receiptId: route.receiptId }), - ), - Match.when("reopen", () => - ReceiptMutationAuthorizationTarget.ReopenRejectedReceipt({ - receiptId: route.receiptId, - }), - ), - Match.orElse(() => - ReceiptMutationAuthorizationTarget.RejectReceipt({ receiptId: route.receiptId }), - ), - ), - principal, - ); - - const current = authorization.current; + if (action !== "reopen" && barrier !== undefined) { + yield* barrier(headers, receiptId, action); + } - const identity = yield* mutationIdentity( - request, - principal, - operationId, - normalizedTarget, - ); + const authorization = yield* authorizeReceiptMutationInTransaction( + approvalTarget(action, receiptId), + principal, + ); - return { - identity, - operationId, - requestSha256: semanticRequestDigest(semanticMutationRequest(body, ifMatch)), - command: Match.value(route.action).pipe( - Match.when("approve", () => - ReceiptCommandRequestSchema.cases.ApproveReceipt.make({ - commandId: identity.commandId, - receiptId: current.receiptId, - expectedRevision: current.revision, - }), - ), - Match.when("reopen", () => - ReceiptCommandRequestSchema.cases.ReopenRejectedReceipt.make({ - commandId: identity.commandId, - receiptId: current.receiptId, - expectedRevision: current.revision, - }), - ), - Match.orElse(() => - ReceiptCommandRequestSchema.cases.RejectReceipt.make({ - commandId: identity.commandId, - receiptId: current.receiptId, - expectedRevision: current.revision, - }), - ), - ), - authorization, - response: { - status: 200, - ifMatch, - currentEtag: receiptEtag(route.receiptId, current.revision), - }, - }; - }), - route.action === "reopen" ? {} : { retry: "serialization-once" }, - ); + const current = authorization.current; - if (Predicate.isTagged(outcome, "Committed") && route.action !== "reopen") { - yield* drainReceiptOutbox(options, fileStore, route.receiptId); - } + const identity = yield* mutationIdentity( + principal, + operationId, + normalizedTarget, + payload.idempotencyKey, + ); - return yield* commandOutcomeResponse(outcome); - }).pipe( - receiptProblems, - commandReceiptProblems, - receiptCredentialProblems(personPresentation(request)), - // Only a submission allocates a receipt identity that can already exist. - unreachable("receipt.already-exists"), + return { + identity, + operationId, + requestSha256: semanticRequestDigest(semanticMutationRequest(emptyBody, ifMatch)), + command: approvalCommandRequest(action, { + commandId: identity.commandId, + receiptId: current.receiptId, + expectedRevision: current.revision, + }), + authorization, + response: { + status: 200, + ifMatch, + currentEtag: receiptEtag({ receiptId, revision: current.revision }), + }, + } satisfies PreparedReceiptMutation; + }), + action === "reopen" ? {} : { retry: "serialization-once" }, ); - // A synchronized lane answers its problem itself, so the answer can carry the mark. - return yield* answer.pipe( - Effect.map((response) => (synchronized ? markSynchronized(response) : response)), - Effect.catch((problem) => - synchronized - ? Effect.succeed(markSynchronized(problemWebResponse(problem))) - : Effect.fail(problem), - ), - ); - }); + if (Predicate.isTagged(outcome, "Committed") && action !== "reopen") { + yield* drainReceiptOutbox({ config: options.config.receipt }, fileStore, receiptId); + } + + return yield* commandOutcome(ReceiptResource)(outcome); + }).pipe( + receiptProblems, + commandReceiptProblems, + receiptCredentialProblems(personPresentation(headers)), + // Only a submission allocates a receipt identity that can already exist; only a revision + // decodes fields or stages a file; a barrier probe that names another lane is a test defect. + unreachable( + "receipt.already-exists", + "validation.failed", + "receipt.file-not-staged", + "request.malformed", + ), + ); }; -export const settleReceipt = ( - request: Request, - receiptId: ReceiptId, - options: ReceiptApiHttpOptions, - fileStore: ReceiptFileStore, -) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const ifMatch = yield* requiredIfMatchOf(request); - const body = yield* decodeSettlementRequest(request); +export const settleReceipt = ({ + context, + payload, +}: { + readonly context: ReceiptCommandContext; + readonly payload: ReceiptTransition & { + readonly request: typeof RecordReceiptSettlementRequest.Type; + }; +}) => { + const { headers, options, fileStore } = context; + const { receiptId, ifMatch, request: body } = payload; + return Effect.gen(function* () { const outcome = yield* executeNativeHttpCommandPostgres( Effect.gen(function* () { - const principal = yield* authorizationPrincipalInTransaction(request, options); + const principal = yield* authorizationPrincipalInTransaction(context); const revision = yield* Economy.use(({ readReceiptSettlementRevision }) => readReceiptSettlementRevision(receiptId, principal), ); const identity = yield* mutationIdentity( - request, principal, "receipts.settleReceipt", `/api/receipts/${encodeURIComponent(receiptId)}/settle`, + payload.idempotencyKey, ); - const command = yield* decodeRequest(ReceiptSettlementCommandRequestSchema)( + const command = yield* Schema.decodeEffect(ReceiptSettlementCommandRequestSchema)( ReceiptSettlementCommandRequestSchema.cases.RecordReceiptSettlement.make({ commandId: identity.commandId, receiptId, @@ -661,17 +663,17 @@ export const settleReceipt = ( externalReference: body.externalReference, settledAt: body.settledAt, }), - ); + ).pipe(Effect.mapError(() => requestInvalid())); return { identity: { identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest(semanticMutationRequest(body, ifMatch)), + requestSha256: semanticRequestDigest(semanticMutationRequest({ ...body }, ifMatch)), operationId: "receipts.settleReceipt", }, execute: Economy.use(({ recordReceiptSettlement }) => Effect.gen(function* () { - yield* requireCurrentETag(receiptEtag(receiptId, revision), ifMatch); + yield* requireCurrentETag(receiptEtag({ receiptId, revision }), ifMatch); if (body.expectedRevision !== revision) { return yield* Problem.make("precondition.failed"); @@ -679,7 +681,10 @@ export const settleReceipt = ( const result = yield* recordReceiptSettlement(command, principal); - return settlementMutationCapsule(result.settlement, result.receipt); + return yield* settlementMutationCapsule({ + settlement: result.settlement, + receipt: result.receipt, + }); }), ), }; @@ -688,17 +693,13 @@ export const settleReceipt = ( ); if (Predicate.isTagged(outcome, "Committed")) { - yield* drainReceiptOutbox(options, fileStore, receiptId); + yield* drainReceiptOutbox({ config: options.config.receipt }, fileStore, receiptId); } - // Only the settlement evidence is private; an idempotency problem keeps its declared policy. - const response = yield* commandOutcomeResponse(outcome); - response.headers.set("cache-control", PRIVATE_NO_STORE); - response.headers.set("vary", "Origin"); - - return response; + return yield* commandOutcome(ReceiptSettlementEvidenceResource)(outcome); }).pipe( receiptProblems, commandReceiptProblems, - receiptCredentialProblems(personPresentation(request)), + receiptCredentialProblems(personPresentation(headers)), ); +}; diff --git a/apps/backend/src/receipt/config.ts b/apps/backend/src/receipt/config.ts index c4832e64a..f27bbedc1 100644 --- a/apps/backend/src/receipt/config.ts +++ b/apps/backend/src/receipt/config.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { RECEIPT_FILE_MAX_BYTES } from "@vektorprogrammet/domain/receipt"; import { Predicate } from "effect"; import type { IdentityDeployment } from "../session-security.js"; +import { dual } from "effect/Function"; /** * Test-only receipt authority: internal lifecycle evidence, the approval concurrency barrier, @@ -60,11 +61,7 @@ export const decodeReceiptApiConfig = ( nextVisualId: () => `visual_${randomUUID()}`, }); -/** - * Decodes the receipt E2E composition. Any receipt E2E variable outside a local deployment, - * or without `RECEIPT_E2E_TEST_MODE=1`, fails startup. - */ -export const decodeReceiptE2EComposition = ( +const decodeReceiptE2ECompositionFrom = ( env: Readonly>, deployment: IdentityDeployment, ): ReceiptE2EComposition | undefined => { @@ -85,3 +82,17 @@ export const decodeReceiptE2EComposition = ( ? {} : { failNextPromotionEffectId }; }; + +/** + * Decodes the receipt E2E composition. Any receipt E2E variable outside a local deployment, + * or without `RECEIPT_E2E_TEST_MODE=1`, fails startup. + */ +export const decodeReceiptE2EComposition: { + ( + deployment: IdentityDeployment, + ): (env: Readonly>) => ReceiptE2EComposition | undefined; + ( + env: Readonly>, + deployment: IdentityDeployment, + ): ReceiptE2EComposition | undefined; +} = dual(2, decodeReceiptE2ECompositionFrom); diff --git a/apps/backend/src/receipt/context.ts b/apps/backend/src/receipt/context.ts new file mode 100644 index 000000000..93e31b11d --- /dev/null +++ b/apps/backend/src/receipt/context.ts @@ -0,0 +1,40 @@ +/** The caller of a receipt RPC: the Person that its credential names, at one instant. */ +import { UnauthenticatedActor } from "@vektorprogrammet/domain/receipt"; +import { Effect, Predicate } from "effect"; +import type { Headers } from "effect/unstable/http"; +import { + resolveRequestCredentialAtInstant, + resolveRequestCredentialInTransaction, + resolveRequestPersonAtInstant, +} from "../authority.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +/** One receipt RPC call: the headers that it carries, and the options of the composition. */ +export interface ReceiptCaller { + readonly headers: Headers.Headers; + readonly options: NativeRpcOptions; +} + +/** Resolves the current Person credential through the caller's transaction at one instant. */ +export const authorizationPrincipalInTransaction = ({ headers, options }: ReceiptCaller) => + resolveRequestCredentialInTransaction(credentialRequestOf(headers), "OAuthUserBearer", { + now: options.now, + }).pipe( + Effect.flatMap((authenticated) => + Predicate.isTagged(authenticated.credential.principal, "Person") + ? Effect.succeed({ + personId: authenticated.credential.principal.personId, + authorizationInstant: authenticated.authorizationInstant, + }) + : Effect.fail(UnauthenticatedActor.make({ message: "authentication required" })), + ), + ); + +/** The Person that the credential names, and one authorization instant; no role or authority. */ +export const authorizationPrincipal = ({ headers, options }: ReceiptCaller) => + resolveRequestPersonAtInstant(credentialRequestOf(headers), { now: options.now }); + +/** The Person or service principal that the credential names, at one instant. */ +export const approvalCredential = ({ headers, options }: ReceiptCaller) => + resolveRequestCredentialAtInstant(credentialRequestOf(headers), "Either", { now: options.now }); diff --git a/apps/backend/src/receipt/delivery.ts b/apps/backend/src/receipt/delivery.ts index 7f37c9eb2..6b61afbf0 100644 --- a/apps/backend/src/receipt/delivery.ts +++ b/apps/backend/src/receipt/delivery.ts @@ -39,7 +39,7 @@ export const receiptDeliveryConfig = ( try { const endpoint = new URL(env.RECEIPT_DELIVERY_URL!); - const token = env.RECEIPT_DELIVERY_TOKEN!; + const token = env.RECEIPT_DELIVERY_TOKEN; const sender = Schema.decodeUnknownSync(ContactEmail)(env.RECEIPT_DELIVERY_SENDER); const timeout = Number(env.RECEIPT_DELIVERY_TIMEOUT_MS); @@ -49,21 +49,22 @@ export const receiptDeliveryConfig = ( // Cleartext delivery is confined to local rehearsals; credentials never follow redirects. if ( - endpoint.username || - endpoint.password || - endpoint.hash || - endpoint.search || + endpoint.username !== "" || + endpoint.password !== "" || + endpoint.hash !== "" || + endpoint.search !== "" || !( endpoint.protocol === "https:" || (endpoint.protocol === "http:" && ["127.0.0.1", "[::1]"].includes(endpoint.hostname)) ) || - !token || + token === undefined || + token === "" || /[\r\n]/.test(token) || !Number.isSafeInteger(timeout) || timeout < 1 || timeout > 30_000 || Object.keys(recipients).length === 0 || - Object.keys(recipients).some((key) => !key.trim()) + Object.keys(recipients).some((key) => key.trim() === "") ) throw new Error(); @@ -88,21 +89,21 @@ export const ReceiptDeliveryLive = (config: ReceiptDeliveryConfig | undefined) = return ReceiptAuxiliaryEffects.of({ apply: (request, claimId) => Effect.gen(function* () { - if (!claimId) - return yield* new ReceiptDeliveryUnavailable({ effectId: request.effectId }); + if (claimId === undefined || claimId === "") + return yield* ReceiptDeliveryUnavailable.make({ effectId: request.effectId }); if (Predicate.isTagged(request, "WriteReceiptAudit")) { const audit = yield* sql`SELECT 1 FROM economy_receipt_audit WHERE command_id = ${request.commandId} AND receipt_id = ${request.receiptId}`; if (audit.length !== 1) - return yield* new ReceiptDeliveryUnavailable({ effectId: request.effectId }); + return yield* ReceiptDeliveryUnavailable.make({ effectId: request.effectId }); return; } if (config === undefined) - return yield* new ReceiptDeliveryUnavailable({ effectId: request.effectId }); + return yield* ReceiptDeliveryUnavailable.make({ effectId: request.effectId }); const envelope = yield* sql.withTransaction( Effect.gen(function* () { @@ -114,8 +115,8 @@ export const ReceiptDeliveryLive = (config: ReceiptDeliveryConfig | undefined) = const row = rows[0]; - if (!row || row.effect_type !== request._tag) - return yield* new ReceiptDeliveryUnavailable({ effectId: request.effectId }); + if (row === undefined || row.effect_type !== request._tag) + return yield* ReceiptDeliveryUnavailable.make({ effectId: request.effectId }); if (row.delivery_envelope !== null) return yield* Schema.decodeUnknownEffect(ReceiptDeliveryEnvelope)( @@ -164,7 +165,7 @@ export const ReceiptDeliveryLive = (config: ReceiptDeliveryConfig | undefined) = const status = submitted ? "Pending" : approved ? "Approved" : "Rejected"; if ( - !fact || + fact === undefined || (!settled && fact.status !== status) || (settled && (fact.settlement_amount === null || @@ -173,7 +174,7 @@ export const ReceiptDeliveryLive = (config: ReceiptDeliveryConfig | undefined) = fact.external_reference === null || fact.settled_at === null)) ) { - return yield* new ReceiptDeliveryUnavailable({ effectId: request.effectId }); + return yield* ReceiptDeliveryUnavailable.make({ effectId: request.effectId }); } const subject = submitted @@ -210,7 +211,7 @@ export const ReceiptDeliveryLive = (config: ReceiptDeliveryConfig | undefined) = SqlError: () => Effect.die(new Error("Receipt delivery persistence failed")), SchemaError: () => Effect.die(new Error("Receipt delivery envelope is invalid")), }), - Effect.mapError(() => new ReceiptDeliveryUnavailable({ effectId: request.effectId })), + Effect.mapError(() => ReceiptDeliveryUnavailable.make({ effectId: request.effectId })), ), }); }), diff --git a/apps/backend/src/receipt/drain-main.ts b/apps/backend/src/receipt/drain-main.ts index 3ab22dddf..2cb6729ad 100644 --- a/apps/backend/src/receipt/drain-main.ts +++ b/apps/backend/src/receipt/drain-main.ts @@ -28,17 +28,22 @@ const environment = Effect.runSync( const receiptId = process.argv[2]; -if (!receiptId || process.argv.length !== 3 || !environment.postgresUrl) +if ( + receiptId === undefined || + receiptId === "" || + process.argv.length !== 3 || + environment.postgresUrl === "" +) throw new TypeError("Usage: BACKEND_PG_URL=... bun run src/receipt/drain-main.ts "); Schema.decodeSync(ReceiptId)(receiptId); -if (!environment.stagingRoot || !environment.committedRoot) +if (environment.stagingRoot === "" || environment.committedRoot === "") throw new TypeError("Explicit receipt staging and committed roots required"); const config = receiptDeliveryConfig(process.env); -if (!config) throw new TypeError("Receipt delivery is not configured"); +if (config === undefined) throw new TypeError("Receipt delivery is not configured"); const database = DatabaseLive({ url: Redacted.make(environment.postgresUrl), @@ -60,14 +65,18 @@ const result = await Effect.runPromise( if (exists.length !== 1) return "NotFound"; const cutoff = DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { minutes: 1 })); - for (const claim of yield* listStaleReceiptOutboxClaimIds(cutoff, receiptId)) + for (const claim of yield* listStaleReceiptOutboxClaimIds({ claimedBefore: cutoff, receiptId })) yield* recoverStaleReceiptOutbox(claim, cutoff); const last = yield* repeatReceiptDelivery( Effect.gen(function* () { const now = DateTime.formatIso(yield* DateTime.now); - return yield* deliverNextReceiptOutbox(randomUUID(), now, receiptId); + return yield* deliverNextReceiptOutbox({ + claimId: randomUUID(), + claimedAt: now, + receiptId, + }); }), ); diff --git a/apps/backend/src/receipt/e2e-support.test.ts b/apps/backend/src/receipt/e2e-support.test.ts index acd812871..6e504d548 100644 --- a/apps/backend/src/receipt/e2e-support.test.ts +++ b/apps/backend/src/receipt/e2e-support.test.ts @@ -1,6 +1,7 @@ -import { isProblem, type Problem } from "@vektorprogrammet/http-api/http-semantics"; +import { isProblem, type Problem } from "@vektorprogrammet/rpc/problem"; import { Cause, Effect, Fiber } from "effect"; import { TestClock } from "effect/testing"; +import { Headers } from "effect/unstable/http"; import { describe, expect, it } from "@effect/vitest"; import { RECEIPT_E2E_CONCURRENCY_REQUEST_HEADER, @@ -9,9 +10,7 @@ import { } from "./e2e-support.js"; const probe = (lane: ReceiptE2EConcurrencyLane | null) => - new Request("http://backend.test/api/receipts/receipt-1/approve", { - headers: lane === null ? {} : { [RECEIPT_E2E_CONCURRENCY_REQUEST_HEADER]: lane }, - }); + Headers.fromInput(lane === null ? {} : { [RECEIPT_E2E_CONCURRENCY_REQUEST_HEADER]: lane }); const malformedStatus = (failure: Problem<"request.malformed"> | Cause.TimeoutError) => isProblem(failure) ? `${failure.code}:${failure.status}` : "other"; diff --git a/apps/backend/src/receipt/e2e-support.ts b/apps/backend/src/receipt/e2e-support.ts index ef1ca9d3f..9edfc4160 100644 --- a/apps/backend/src/receipt/e2e-support.ts +++ b/apps/backend/src/receipt/e2e-support.ts @@ -2,8 +2,9 @@ * Local E2E concurrency probe for receipt approval evidence. * Composed only when `ReceiptApiConfig.e2e` is present (local deployments only). */ -import { Problem } from "@vektorprogrammet/http-api/http-semantics"; +import { Problem } from "@vektorprogrammet/rpc/problem"; import { Cause, Clock, Deferred, Duration, Effect } from "effect"; +import type { Headers } from "effect/unstable/http"; import type { ReceiptApiConfig } from "./config.js"; export type ReceiptE2EConcurrencyLane = "file-read" | "approve" | "reject"; @@ -17,17 +18,18 @@ export type ReceiptE2EBarrierArrival = Effect.Effect< Problem<"request.malformed"> | Cause.TimeoutError >; -/** Holds each probed request inside its transaction until all three lanes have arrived. */ +/** + * Holds each probed request inside its transaction until all three lanes have arrived. The probe + * is a real HTTP header of the RPC request, which the RPC `headers` carry. + */ export type ReceiptE2ETransactionBarrier = ( - request: Request, + headers: Headers.Headers, receiptId: string, lane: ReceiptE2EConcurrencyLane, ) => ReceiptE2EBarrierArrival; export const RECEIPT_E2E_CONCURRENCY_REQUEST_HEADER = "x-receipt-e2e-concurrency-probe"; -export const RECEIPT_E2E_CONCURRENCY_RESPONSE_HEADER = "x-receipt-e2e-concurrency-synchronized"; - const LANE_COUNT = 3; const BARRIER_TIMEOUT_MS = 10_000; @@ -50,12 +52,12 @@ export const makeReceiptE2ETransactionBarrier: Effect.Effect + return (headers, receiptId, lane) => Clock.clockWith((clock) => Effect.suspend((): ReceiptE2EBarrierArrival => { - const marker = request.headers.get(RECEIPT_E2E_CONCURRENCY_REQUEST_HEADER); + const marker = headers[RECEIPT_E2E_CONCURRENCY_REQUEST_HEADER]; - if (marker === null) return Effect.succeed(false); + if (marker === undefined) return Effect.succeed(false); if (marker !== lane) return malformed; diff --git a/apps/backend/src/receipt/filesystem.ts b/apps/backend/src/receipt/filesystem.ts index 0bc115908..402c09cfe 100644 --- a/apps/backend/src/receipt/filesystem.ts +++ b/apps/backend/src/receipt/filesystem.ts @@ -78,7 +78,7 @@ const fileIdentity = ( }; const fileFailure = (effectId: string, fileRef: string): ReceiptFileNotStaged => - new ReceiptFileNotStaged({ effectId, fileRef }); + ReceiptFileNotStaged.make({ effectId, fileRef }); const failedOperation = (operation: ReceiptFileStoreError["operation"]) => (cause: PlatformError.PlatformError) => @@ -109,7 +109,7 @@ export interface ReceiptFileStore { export class ReceiptFileStoreResource extends Context.Service< ReceiptFileStoreResource, ReceiptFileStore ->()("@vektorprogrammet/backend/ReceiptFileStore") {} +>()("@vektorprogrammet/backend/receipt/filesystem/ReceiptFileStoreResource") {} /** Private receipt files under two local roots, through the platform FileSystem and Path. */ export const makeReceiptFileStore = (config: ReceiptFileStoreConfig) => @@ -194,7 +194,7 @@ export const makeReceiptFileStore = (config: ReceiptFileStoreConfig) => ), ); - if (recorded !== digest) return yield* new ReceiptFileEffectConflict({ effectId }); + if (recorded !== digest) return yield* ReceiptFileEffectConflict.make({ effectId }); }); // Streams the upload into the open temporary file; bytes past the limit fail validation. @@ -221,7 +221,7 @@ export const makeReceiptFileStore = (config: ReceiptFileStoreConfig) => byteLength += chunk.value.byteLength; if (byteLength > maxFileBytes) { - return yield* new ReceiptDecodeError({ + return yield* ReceiptDecodeError.make({ message: "receipt file exceeds configured limit", }); } @@ -323,7 +323,7 @@ export const makeReceiptFileStore = (config: ReceiptFileStoreConfig) => if (staged === "matching") return; if (staged === "different") { - return yield* new ReceiptFileIdentityConflict({ + return yield* ReceiptFileIdentityConflict.make({ effectId: "stage", objectKey: file.objectKey, }); @@ -334,7 +334,7 @@ export const makeReceiptFileStore = (config: ReceiptFileStoreConfig) => if (committed === "matching") return; if (committed === "different") { - return yield* new ReceiptFileIdentityConflict({ + return yield* ReceiptFileIdentityConflict.make({ effectId: "stage", objectKey: file.objectKey, }); @@ -352,7 +352,7 @@ export const makeReceiptFileStore = (config: ReceiptFileStoreConfig) => if (promotion && failNextPromotionEffectId === request.effectId) { failNextPromotionEffectId = undefined; - return yield* new ReceiptFileInjectedFailure({ effectId: request.effectId }); + return yield* ReceiptFileInjectedFailure.make({ effectId: request.effectId }); } const stagingPath = yield* pathFor(config.stagingRoot, request.file.fileRef); @@ -360,7 +360,7 @@ export const makeReceiptFileStore = (config: ReceiptFileStoreConfig) => const committed = yield* inspectFile(committedPath, request.file); if (committed === "different") { - return yield* new ReceiptFileIdentityConflict({ + return yield* ReceiptFileIdentityConflict.make({ effectId: request.effectId, objectKey: request.file.objectKey, }); diff --git a/apps/backend/src/receipt/http-context.ts b/apps/backend/src/receipt/http-context.ts deleted file mode 100644 index b4848a838..000000000 --- a/apps/backend/src/receipt/http-context.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** Receipt HTTP composition options and request principal resolution. */ -import type { AuthorizationInstant, CredentialOutcome } from "@vektorprogrammet/domain/authz"; -import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import { - UnauthenticatedActor, - type ReceiptCommandPrincipal, -} from "@vektorprogrammet/domain/receipt"; -import { Effect, Predicate } from "effect"; -import { resolveRequestCredentialInTransaction } from "../authority.js"; -import type { ReceiptApiConfig } from "./config.js"; - -type AcceptedCredential = Extract; - -/** A composed identity resolver rejects the credential or finds Identity unavailable; nothing else. */ -export type ReceiptIdentityFailure = UnauthenticatedActor | IdentityEngineError; - -export interface ReceiptIdentityResolvers { - /** Request credential -> canonical person and one instant; never role or authority facts. */ - readonly resolveAuthorizationPrincipal: ( - request: Request, - ) => Effect.Effect; - /** Request credential -> owner person id; no role or authority facts. */ - readonly resolvePersonId: (request: Request) => Effect.Effect; - /** Exact row 42 credential bridge; no token-carried authorization facts. */ - readonly resolveApprovalCredential?: (request: Request) => Effect.Effect< - { - readonly credential: AcceptedCredential; - readonly authorizationInstant: AuthorizationInstant; - }, - E, - R - >; -} - -export interface ReceiptApiHttpOptions { - readonly config: ReceiptApiConfig; - readonly identity: ReceiptIdentityResolvers; - readonly now?: () => string; - - /** - * Stable worker claim identity used to recover its stale in-flight effects. - * The composition root may supply an operationally durable identity. - */ - readonly outboxClaimId?: string; -} - -/** Resolves the current Person credential through the caller's transaction at one instant. */ -export const authorizationPrincipalInTransaction = ( - request: Request, - options: ReceiptApiHttpOptions, -) => - resolveRequestCredentialInTransaction(request, "OAuthUserBearer", { now: options.now }).pipe( - Effect.flatMap((authenticated) => - Predicate.isTagged(authenticated.credential.principal, "Person") - ? Effect.succeed({ - personId: authenticated.credential.principal.personId, - authorizationInstant: authenticated.authorizationInstant, - }) - : Effect.fail(new UnauthenticatedActor({ message: "authentication required" })), - ), - ); diff --git a/apps/backend/src/receipt/http-decode.ts b/apps/backend/src/receipt/http-decode.ts deleted file mode 100644 index 4eb52ed61..000000000 --- a/apps/backend/src/receipt/http-decode.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** Receipt request decoding: query strings, list cursors, multipart, and JSON bodies. */ -import { DepartmentId } from "@vektorprogrammet/domain/organization"; -import { - ReceiptDecodeError, - decodeReceiptCursor, - isIsoDate, - type ReceiptStatus, -} from "@vektorprogrammet/domain/receipt"; -import { - RecordReceiptSettlementRequest, - readBoundedReceiptForm, - receiptTransferMaxBytes, -} from "@vektorprogrammet/http-api"; -import { Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Predicate, Schema } from "effect"; -import { decodeRequest, readJsonBody, requestInvalid } from "../http-api/problem.js"; - -const SUPPORTED_CONTENT_TYPES = ["image/jpeg", "image/png", "application/pdf"] as const; - -type SupportedContentType = (typeof SUPPORTED_CONTENT_TYPES)[number]; - -type MultipartFields = ReadonlyMap>; - -const isReceiptStatus = (value: string): value is ReceiptStatus => { - switch (value) { - case "Pending": - case "Approved": - case "Rejected": - case "Withdrawn": - return true; - default: - return false; - } -}; - -const isSupportedContentType = (value: string): value is SupportedContentType => - SUPPORTED_CONTENT_TYPES.some((contentType) => contentType === value); - -/** Decodes `status` and `cursor`; a present cursor must decode before any read. */ -export const decodeReceiptListQuery = (request: Request, allowStatus = true) => - Effect.gen(function* () { - const search = new URL(request.url).searchParams; - - if ( - [...search.keys()].some((key) => key !== "cursor" && !(allowStatus && key === "status")) || - search.getAll("status").length > 1 || - search.getAll("cursor").length > 1 - ) { - return yield* Problem.make("request.malformed"); - } - - const status = search.get("status") ?? undefined; - - if (status !== undefined && !isReceiptStatus(status)) { - return yield* Problem.make("request.malformed"); - } - - const cursor = search.get("cursor") ?? undefined; - - if (cursor !== undefined) { - yield* decodeReceiptCursor(cursor).pipe( - Effect.mapError(() => Problem.make("request.malformed")), - ); - } - - return { status, cursor }; - }); - -/** Decodes the optional single `departmentId` submit query parameter. */ -export const decodeSubmitQuery = (request: Request) => - Effect.gen(function* () { - const entries = [...new URL(request.url).searchParams.entries()]; - - if ( - entries.some(([name]) => name !== "departmentId") || - entries.filter(([name]) => name === "departmentId").length > 1 - ) { - return yield* Problem.make("request.malformed"); - } - - const value = entries[0]?.[1]; - - if (value === undefined) return undefined; - - if (value.trim().length === 0) return yield* requestInvalid(); - - return DepartmentId.make(value); - }); - -/** - * Runs throwing field checks: a ReceiptDecodeError fails the request's - * validation, anything else is a defect. - */ -const validated = (decode: () => A): Effect.Effect> => - Effect.suspend(() => { - try { - return Effect.succeed(decode()); - } catch (cause) { - return Schema.is(ReceiptDecodeError)(cause) - ? Effect.fail(requestInvalid()) - : Effect.die(cause); - } - }); - -const parseSafeAmountOre = (value: string): number => { - if (!/^[1-9]\d*$/.test(value)) throw new ReceiptDecodeError({ message: "invalid amountOre" }); - const amountOre = Number(value); - - if (!Number.isSafeInteger(amountOre) || amountOre <= 0) { - throw new ReceiptDecodeError({ message: "invalid amountOre" }); - } - - return amountOre; -}; - -const readSingleField = (fields: MultipartFields, name: string): string => { - const values = fields.get(name); - - if (values === undefined || values.length !== 1 || !Predicate.isString(values[0])) { - throw new ReceiptDecodeError({ message: `invalid ${name}` }); - } - - return values[0]; -}; - -const decodeMultipartFields = (request: Request, maxFileBytes: number) => - Effect.gen(function* () { - const contentType = request.headers.get("content-type") ?? ""; - - if (contentType.split(";", 1)[0]?.trim().toLowerCase() !== "multipart/form-data") { - return yield* requestInvalid(); - } - - const contentLength = request.headers.get("content-length"); - - if (contentLength === null || !/^\d+$/.test(contentLength)) { - return yield* requestInvalid(); - } - - const bodyLength = Number(contentLength); - - if ( - !Number.isSafeInteger(bodyLength) || - bodyLength <= 0 || - bodyLength > receiptTransferMaxBytes(maxFileBytes) - ) { - return yield* requestInvalid(); - } - - const form = yield* Effect.tryPromise({ - try: () => readBoundedReceiptForm(request, maxFileBytes), - catch: requestInvalid, - }); - - const fields = new Map>(); - - for (const [name, value] of form.entries()) { - const values = fields.get(name); - - if (values === undefined) fields.set(name, [value]); - else values.push(value); - } - - return fields; - }); - -const requireMultipartFields = ( - fields: MultipartFields, - required: Readonly>, - optional: Readonly> = {}, -): void => { - for (const name of fields.keys()) { - if (required[name] !== true && optional[name] !== true) { - throw new ReceiptDecodeError({ message: "unexpected multipart field" }); - } - } - - const requiredNames = Object.keys(required); - - if ( - fields.size < requiredNames.length || - fields.size > requiredNames.length + Object.keys(optional).length - ) { - throw new ReceiptDecodeError({ message: "invalid multipart fields" }); - } - - for (const name of requiredNames) { - if (!fields.has(name)) throw new ReceiptDecodeError({ message: "missing multipart field" }); - } -}; - -interface DecodedReceiptFile { - readonly file?: File; - readonly contentType?: SupportedContentType; -} - -const decodeReceiptFile = ( - fields: MultipartFields, - maxFileBytes: number, - required: boolean, -): DecodedReceiptFile => { - const fileValues = fields.get("file"); - - if (fileValues === undefined) { - if (required) throw new ReceiptDecodeError({ message: "receipt file is required" }); - - return {}; - } - - if (fileValues.length !== 1 || !(fileValues[0] instanceof File)) { - throw new ReceiptDecodeError({ message: "invalid receipt file" }); - } - - const file = fileValues[0]; - - if (file.size <= 0 || file.size > maxFileBytes || !isSupportedContentType(file.type)) { - throw new ReceiptDecodeError({ message: "unsupported receipt file" }); - } - - return { file, contentType: file.type }; -}; - -export const decodeSubmitMultipart = (request: Request, maxFileBytes: number) => - Effect.gen(function* () { - const fields = yield* decodeMultipartFields(request, maxFileBytes); - - return yield* validated(() => { - requireMultipartFields(fields, { - description: true, - amountOre: true, - receiptDate: true, - file: true, - }); - const description = readSingleField(fields, "description"); - const amountOre = parseSafeAmountOre(readSingleField(fields, "amountOre")); - const receiptDate = readSingleField(fields, "receiptDate"); - - if (description.length < 1 || description.length > 5000) { - throw new ReceiptDecodeError({ message: "invalid receipt description" }); - } - - if (!isIsoDate(receiptDate)) { - throw new ReceiptDecodeError({ message: "invalid receipt date" }); - } - - const decodedFile = decodeReceiptFile(fields, maxFileBytes, true); - - if (decodedFile.file === undefined || decodedFile.contentType === undefined) { - throw new ReceiptDecodeError({ message: "receipt file is required" }); - } - - return { - description, - amountOre, - receiptDate, - file: decodedFile.file, - contentType: decodedFile.contentType, - }; - }); - }); - -export const decodeReviseMultipart = (request: Request, maxFileBytes: number) => - Effect.gen(function* () { - const fields = yield* decodeMultipartFields(request, maxFileBytes); - - return yield* validated(() => { - requireMultipartFields( - fields, - {}, - { - description: true, - amountOre: true, - receiptDate: true, - file: true, - }, - ); - - if (fields.size === 0) { - throw new ReceiptDecodeError({ - message: "receipt revision must change at least one field", - }); - } - - const description = fields.has("description") - ? readSingleField(fields, "description") - : undefined; - - if (description !== undefined && (description.length < 1 || description.length > 5000)) { - throw new ReceiptDecodeError({ message: "invalid receipt description" }); - } - - const amountOre = fields.has("amountOre") - ? parseSafeAmountOre(readSingleField(fields, "amountOre")) - : undefined; - - const receiptDate = fields.has("receiptDate") - ? readSingleField(fields, "receiptDate") - : undefined; - - if (receiptDate !== undefined && !isIsoDate(receiptDate)) { - throw new ReceiptDecodeError({ message: "invalid receipt date" }); - } - - const decodedFile = decodeReceiptFile(fields, maxFileBytes, false); - - const parsedFields: DecodedReceiptFile & { - description?: string; - amountOre?: number; - receiptDate?: string; - } = { ...decodedFile }; - - if (description !== undefined) parsedFields.description = description; - - if (amountOre !== undefined) parsedFields.amountOre = amountOre; - - if (receiptDate !== undefined) parsedFields.receiptDate = receiptDate; - - return parsedFields; - }); - }); - -/** - * One bounded `application/json` object; media type parameters and - * surrounding space are accepted. - */ -const decodeJsonObject = (request: Request) => - Effect.gen(function* () { - const body = yield* readJsonBody(request, /^\s*application\/json\s*(?:;|$)/iu, 65_536); - - if (body === null || !Predicate.isObjectOrArray(body) || Array.isArray(body)) { - return yield* requestInvalid(); - } - - return body; - }); - -export const decodeExactEmptyJson = (request: Request) => - decodeJsonObject(request).pipe( - Effect.flatMap((body) => - Object.keys(body).length === 0 ? Effect.succeed({}) : Effect.fail(requestInvalid()), - ), - ); - -export const decodeSettlementRequest = (request: Request) => - decodeJsonObject(request).pipe(Effect.flatMap(decodeRequest(RecordReceiptSettlementRequest))); diff --git a/apps/backend/src/receipt/http-representation.ts b/apps/backend/src/receipt/http-representation.ts deleted file mode 100644 index 13962c24e..000000000 --- a/apps/backend/src/receipt/http-representation.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** Receipt HTTP representations: ETags, JSON resources, command capsules, and file bodies. */ -import { - ReceiptPersistenceError, - type OwnedReceiptProjectionItem, - type Receipt, - type ReceiptFile, - type ReceiptSettlementEvidence, -} from "@vektorprogrammet/domain/receipt"; -import { - ReceiptSettlementEvidenceResource, - type ReceiptListItem, - type ReceiptResource, -} from "@vektorprogrammet/http-api"; -import { Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Schema } from "effect"; -import type { NativeHttpResponseCapsule } from "../http-api/receipt-transaction.js"; -import { deriveStrongETag } from "../http-semantics.js"; -import type { ReceiptFileStore } from "./filesystem.js"; - -/** - * A JSON body under the receipt cache policy the caller names. - */ -export const jsonResponse = ( - body: Schema.Json, - status = 200, - cacheControl: "no-store" | "private, no-store" = "no-store", -): Response => - new Response(JSON.stringify(body), { - status, - headers: { - "content-type": "application/json", - "cache-control": cacheControl, - }, - }); - -/** - * A JSON body private to the caller, varying by Origin. - */ -export const privateJsonResponse = (body: Schema.Json, status = 200): Response => { - const response = jsonResponse(body, status, "private, no-store"); - response.headers.set("vary", "Origin"); - - return response; -}; - -/** - * Projects stored rows onto response items. A stored value outside the - * response contract is the receipt store failing; any other throw is a defect. - */ -export const projected = (project: () => A): Effect.Effect => - Effect.suspend(() => { - try { - return Effect.succeed(project()); - } catch (cause) { - return Schema.is(ReceiptPersistenceError)(cause) ? Effect.fail(cause) : Effect.die(cause); - } - }); - -export const receiptEtag = (receiptId: string, revision: number) => - deriveStrongETag({ - representationKind: "ReceiptResource", - resourceIdentity: receiptId, - version: revision, - }); - -export const receiptSettlementEvidenceResource = ( - settlement: ReceiptSettlementEvidence, -): typeof ReceiptSettlementEvidenceResource.Type => { - const amountOre = Number(settlement.amountOre); - - if (!Number.isSafeInteger(amountOre) || amountOre <= 0) { - throw new ReceiptPersistenceError({ - operation: "decode receipt settlement evidence", - message: "invalid amount", - }); - } - - try { - return Schema.decodeSync(ReceiptSettlementEvidenceResource)({ - ...settlement, - amountOre, - }); - } catch { - throw new ReceiptPersistenceError({ - operation: "decode receipt settlement evidence", - message: "invalid settlement evidence", - }); - } -}; - -const receiptResource = (receipt: Receipt): typeof ReceiptResource.Type => { - const amountOre = Number(receipt.amountOre); - - if (!Number.isSafeInteger(amountOre) || amountOre <= 0) { - throw new ReceiptPersistenceError({ - operation: "decode receipt resource", - message: "invalid amount", - }); - } - - return { - receiptId: receipt.receiptId, - visualId: receipt.visualId, - ownerPersonId: receipt.ownerPersonId, - departmentId: receipt.departmentId, - description: receipt.description, - amountOre, - currency: receipt.currency, - receiptDate: receipt.receiptDate, - status: receipt.status, - submittedAt: receipt.submittedAt, - approvedAt: receipt.approvedAt, - revision: receipt.revision, - etag: receiptEtag(receipt.receiptId, receipt.revision), - }; -}; - -export const ownedReceiptResource = ( - receipt: OwnedReceiptProjectionItem, -): typeof ReceiptListItem.Type => { - const amountOre = Number(receipt.amountOre); - - if (!Number.isSafeInteger(amountOre) || amountOre <= 0) { - throw new ReceiptPersistenceError({ - operation: "decode owned receipt projection", - message: "invalid amount", - }); - } - - return { - receiptId: receipt.receiptId, - visualId: receipt.visualId, - ownerPersonId: receipt.ownerPersonId, - departmentId: receipt.departmentId, - description: receipt.description, - amountOre, - currency: receipt.currency, - receiptDate: receipt.receiptDate, - status: receipt.status, - approvedAt: receipt.approvedAt, - settlement: - receipt.settlement === null ? null : receiptSettlementEvidenceResource(receipt.settlement), - revision: receipt.revision, - etag: receiptEtag(receipt.receiptId, receipt.revision), - }; -}; - -/** A created receipt names its location; an updated one does not. */ -export type ReceiptMutationStatus = - | { readonly status: 200 } - | { readonly status: 201; readonly location: string }; - -/** - * The replayable response of one receipt mutation. - */ -export const receiptMutationCapsule = ( - receipt: Receipt, - response: ReceiptMutationStatus, -): NativeHttpResponseCapsule => ({ - status: response.status, - mediaType: "application/json", - bodyBytes: new TextEncoder().encode(JSON.stringify(receiptResource(receipt))), - headers: - response.status === 201 - ? { - "content-type": "application/json", - etag: receiptEtag(receipt.receiptId, receipt.revision), - location: response.location, - } - : { - "content-type": "application/json", - etag: receiptEtag(receipt.receiptId, receipt.revision), - }, -}); - -export const settlementMutationCapsule = ( - settlement: ReceiptSettlementEvidence, - receipt: Receipt, -): NativeHttpResponseCapsule => ({ - status: 200, - mediaType: "application/json", - bodyBytes: new TextEncoder().encode( - JSON.stringify(receiptSettlementEvidenceResource(settlement)), - ), - headers: { - "content-type": "application/json", - etag: receiptEtag(receipt.receiptId, receipt.revision), - }, -}); - -const receiptFileName = (contentType: ReceiptFile["contentType"]): string => { - switch (contentType) { - case "image/jpeg": - return "receipt.jpg"; - case "image/png": - return "receipt.png"; - case "application/pdf": - return "receipt.pdf"; - } -}; - -/** - * Answers verified private bytes with their exact headers; unreadable bytes are the receipt - * store failing. - */ -export const readPrivateReceiptFile = ( - file: ReceiptFile, - fileStore: ReceiptFileStore, - maxFileBytes: number, - extraHeaders: Readonly> = {}, -) => - fileStore.readCommitted(file, maxFileBytes).pipe( - Effect.mapError(() => Problem.make("receipts.unavailable")), - Effect.map( - (bytes) => - new Response(bytes, { - headers: { - ...extraHeaders, - "content-type": file.contentType, - "content-length": String(bytes.byteLength), - "content-disposition": `inline; filename="${receiptFileName(file.contentType)}"`, - "x-content-type-options": "nosniff", - "cache-control": "private, no-store", - vary: "Origin", - }, - }), - ), - ); diff --git a/apps/backend/src/receipt/http.ts b/apps/backend/src/receipt/http.ts deleted file mode 100644 index 4995aabee..000000000 --- a/apps/backend/src/receipt/http.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** Native HttpApi composition for receipt endpoints. */ -import { ExternalNativeApi, InternalNativeApi } from "@vektorprogrammet/http-api"; -import { Effect } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { webHandler } from "../http-api/problem.js"; -import { receiptE2ETransactionBarrierFor } from "./e2e-support.js"; -import { ReceiptFileStoreResource } from "./filesystem.js"; -import { - approvalCommand, - reviseReceipt, - settleReceipt, - submitReceipt, - withdrawReceipt, -} from "./http-commands.js"; -import type { ReceiptApiHttpOptions, ReceiptIdentityFailure } from "./http-context.js"; -import { - listOwnedReceipts, - listReceiptsForApproval, - listReceiptsForSettlement, - readApprovalReceiptFile, - readOwnerReceiptFile, - readReceiptLifecycleEvidence, - readSettlementForFinance, -} from "./http-reads.js"; - -/** Native HttpApi implementations for receipt lifecycle endpoints. */ -export const ReceiptApiHandlers = ( - input: ReceiptApiHttpOptions, -) => - HttpApiBuilder.group(ExternalNativeApi, "receipts", (handlers) => - Effect.gen(function* () { - const fileStore = yield* ReceiptFileStoreResource; - - const barrier = yield* receiptE2ETransactionBarrierFor(input.config); - - return handlers - .handleRaw("readReceiptFile", ({ request, params }) => - webHandler(request, (webRequest) => - readOwnerReceiptFile(webRequest, params.receiptId, input, fileStore), - ), - ) - .handleRaw("readReceiptFileForApproval", ({ request, params }) => - webHandler(request, (webRequest) => - readApprovalReceiptFile(webRequest, params.receiptId, input, fileStore, barrier), - ), - ) - .handleRaw("submitReceipt", ({ request }) => - webHandler(request, (webRequest) => submitReceipt(webRequest, input, fileStore)), - ) - .handleRaw("reviseReceipt", ({ request, params }) => - webHandler(request, (webRequest) => - reviseReceipt(webRequest, params.receiptId, input, fileStore), - ), - ) - .handleRaw("withdrawReceipt", ({ request, params }) => - webHandler(request, (webRequest) => - withdrawReceipt(webRequest, params.receiptId, input, fileStore), - ), - ) - .handleRaw("listReceipts", ({ request }) => - webHandler(request, (webRequest) => listOwnedReceipts(webRequest, input)), - ) - .handleRaw("listReceiptsForApproval", ({ request }) => - webHandler(request, (webRequest) => listReceiptsForApproval(webRequest, input)), - ) - .handleRaw("listReceiptsForSettlement", ({ request }) => - webHandler(request, (webRequest) => listReceiptsForSettlement(webRequest, input)), - ) - .handleRaw("readReceiptSettlementForFinance", ({ request, params }) => - webHandler(request, (webRequest) => - readSettlementForFinance(webRequest, params.receiptId, input), - ), - ) - .handleRaw("settleReceipt", ({ request, params }) => - webHandler(request, (webRequest) => - settleReceipt(webRequest, params.receiptId, input, fileStore), - ), - ) - .handleRaw("approveReceipt", ({ request, params }) => - webHandler(request, (webRequest) => - approvalCommand( - webRequest, - { action: "approve", receiptId: params.receiptId }, - input, - fileStore, - barrier, - ), - ), - ) - .handleRaw("rejectReceipt", ({ request, params }) => - webHandler(request, (webRequest) => - approvalCommand( - webRequest, - { action: "reject", receiptId: params.receiptId }, - input, - fileStore, - barrier, - ), - ), - ) - .handleRaw("reopenReceipt", ({ request, params }) => - webHandler(request, (webRequest) => - approvalCommand( - webRequest, - { action: "reopen", receiptId: params.receiptId }, - input, - fileStore, - barrier, - ), - ), - ); - }), - ); - -/** Native HttpApi implementation for the internal receipt evidence endpoint. */ -export const InternalReceiptApiHandlers = ( - input: ReceiptApiHttpOptions, -) => - HttpApiBuilder.group(InternalNativeApi, "internal", (handlers) => - Effect.succeed( - handlers.handleRaw("readReceiptEvidence", ({ request, params }) => - webHandler(request, (webRequest) => - readReceiptLifecycleEvidence(webRequest, params.receiptId, input), - ), - ), - ), - ); diff --git a/apps/backend/src/receipt/import-snapshot.ts b/apps/backend/src/receipt/import-snapshot.ts index d09a2f858..517e6bcf1 100644 --- a/apps/backend/src/receipt/import-snapshot.ts +++ b/apps/backend/src/receipt/import-snapshot.ts @@ -10,6 +10,7 @@ import { createHash } from "node:crypto"; // oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0003: Effect FileSystem.open has no O_NOFOLLOW; the snapshot reader refuses a symlink at open time import { constants, open } from "node:fs/promises"; import { Data, Effect, FileSystem, Match, Option, Path, Predicate, Result, Schema } from "effect"; +import { dual } from "effect/Function"; import { canonicalJsonBytes, sha256Hex } from "@vektorprogrammet/domain/shared-kernel"; import { PersonId, DepartmentId } from "@vektorprogrammet/domain/organization"; @@ -72,6 +73,7 @@ export const digest = (value: Uint8Array | string): string => export const rowDigest = (row: Schema.Json): string => sha256Hex(canonicalJsonBytes(row)); +// oxlint-disable-next-line effecttsgo/missing-pipeable-signature -- EX-0010: a Schema decoder whose second parameter is parse options; its callers pass non-JSON fixtures export const decodeSnapshot = Schema.decodeUnknownSync(ReceiptSnapshot, { onExcessProperty: "error", }); @@ -137,7 +139,9 @@ const readRegularFile = (resolved: string, file: typeof FileEntry.Type) => (handle) => readable(() => handle.close()), ); -export const readSnapshotFile = (root: string, file: typeof FileEntry.Type) => +type SnapshotFileEntry = typeof FileEntry.Type; + +const readSnapshotFileAt = (root: string, file: SnapshotFileEntry) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -174,6 +178,14 @@ export const readSnapshotFile = (root: string, file: typeof FileEntry.Type) => ), ); +type SnapshotFileRead = ReturnType; + +/** Reads one snapshot file under the root, refusing an unsafe path, a digest mismatch, or an unsupported file. */ +export const readSnapshotFile: { + (file: SnapshotFileEntry): (root: string) => SnapshotFileRead; + (root: string, file: SnapshotFileEntry): SnapshotFileRead; +} = dual(2, readSnapshotFileAt); + const uniqueIdentityMap = (entries: ReadonlyArray) => { const map = new Map(); @@ -234,8 +246,7 @@ const stageSnapshotFile = ( ), ); -/** No persistence here: every occurrence is transformed before collision checks. */ -export const prepareReceiptSnapshot = ( +const prepareReceiptSnapshotWith = ( snapshot: ReceiptSnapshot, root: string, files: ReceiptFileStore, @@ -409,3 +420,11 @@ export const prepareReceiptSnapshot = ( fileFailures: [...failures].map(([occurrence, reason]) => ({ occurrence, reason })), }; }); + +type PreparedReceiptSnapshot = ReturnType; + +/** No persistence here: every occurrence is transformed before collision checks. */ +export const prepareReceiptSnapshot: { + (root: string, files: ReceiptFileStore): (snapshot: ReceiptSnapshot) => PreparedReceiptSnapshot; + (snapshot: ReceiptSnapshot, root: string, files: ReceiptFileStore): PreparedReceiptSnapshot; +} = dual(3, prepareReceiptSnapshotWith); diff --git a/apps/backend/src/receipt/outbox-drain.test.ts b/apps/backend/src/receipt/outbox-drain.test.ts index 7fdef33a7..cb6a8bdbc 100644 --- a/apps/backend/src/receipt/outbox-drain.test.ts +++ b/apps/backend/src/receipt/outbox-drain.test.ts @@ -15,7 +15,7 @@ type Attempt = "Delivered" | "Idle" | "Failed" | "TransportFailure"; const receiptId = "receipt-drain"; -const persistenceFailure = new ReceiptPersistenceError({ +const persistenceFailure = ReceiptPersistenceError.make({ operation: "drain fixture", message: "unavailable", }); diff --git a/apps/backend/src/receipt/outbox-drain.ts b/apps/backend/src/receipt/outbox-drain.ts index abea3d4bb..94b9d6ebb 100644 --- a/apps/backend/src/receipt/outbox-drain.ts +++ b/apps/backend/src/receipt/outbox-drain.ts @@ -6,6 +6,7 @@ import { type EconomyOperations, } from "@vektorprogrammet/domain/receipt"; import { DateTime, Effect, Option, Predicate } from "effect"; +import { dual } from "effect/Function"; import { currentInstant } from "../authority.js"; import type { ReceiptFileStore } from "./filesystem.js"; import type { ReceiptApiConfig } from "./config.js"; @@ -45,14 +46,15 @@ const staleOutboxCutoff = (now: string): string => DateTime.formatIso(DateTime.subtract(instant, { milliseconds: STALE_OUTBOX_CLAIM_AGE_MS })), }); -/** - * Recovers stale claims for the receipt, then delivers until the outbox is idle, one delivery - * fails, or {@link MAX_DELIVERIES_PER_DRAIN} deliveries have succeeded (`Limit`). - * Recovery and delivery failures never fail the committed request. - */ -export const drainReceiptOutboxWith = ( +/** Where a drain reads its clock and which outbox claim prefix it writes. */ +interface ReceiptOutboxDrainOptions { + readonly config: ReceiptApiConfig; + readonly outboxClaimId?: string; +} + +const drainReceiptOutboxUsing = ( economy: ReceiptOutboxDrainOperations, - options: { readonly config: ReceiptApiConfig; readonly outboxClaimId?: string }, + options: ReceiptOutboxDrainOptions, fileStore: ReceiptFileStore, receiptId: string, ) => @@ -84,8 +86,44 @@ export const drainReceiptOutboxWith = ( return Predicate.isTagged(last, "Delivered") ? ("Limit" as const) : last._tag; }); -export const drainReceiptOutbox = ( - options: { readonly config: ReceiptApiConfig; readonly outboxClaimId?: string }, +type ReceiptOutboxDrain = ReturnType; + +/** + * Recovers stale claims for the receipt, then delivers until the outbox is idle, one delivery + * fails, or {@link MAX_DELIVERIES_PER_DRAIN} deliveries have succeeded (`Limit`). + * Recovery and delivery failures never fail the committed request. + */ +export const drainReceiptOutboxWith: { + ( + options: ReceiptOutboxDrainOptions, + fileStore: ReceiptFileStore, + receiptId: string, + ): (economy: ReceiptOutboxDrainOperations) => ReceiptOutboxDrain; + ( + economy: ReceiptOutboxDrainOperations, + options: ReceiptOutboxDrainOptions, + fileStore: ReceiptFileStore, + receiptId: string, + ): ReceiptOutboxDrain; +} = dual(4, drainReceiptOutboxUsing); + +const drainReceiptOutboxFromEconomy = ( + options: ReceiptOutboxDrainOptions, fileStore: ReceiptFileStore, receiptId: string, ) => Economy.use((economy) => drainReceiptOutboxWith(economy, options, fileStore, receiptId)); + +type ReceiptOutboxEconomyDrain = ReturnType; + +/** {@link drainReceiptOutboxWith} on the Economy service of the context. */ +export const drainReceiptOutbox: { + ( + fileStore: ReceiptFileStore, + receiptId: string, + ): (options: ReceiptOutboxDrainOptions) => ReceiptOutboxEconomyDrain; + ( + options: ReceiptOutboxDrainOptions, + fileStore: ReceiptFileStore, + receiptId: string, + ): ReceiptOutboxEconomyDrain; +} = dual(3, drainReceiptOutboxFromEconomy); diff --git a/apps/backend/src/receipt/http-problem.ts b/apps/backend/src/receipt/problem.ts similarity index 87% rename from apps/backend/src/receipt/http-problem.ts rename to apps/backend/src/receipt/problem.ts index d92141128..19a96abb0 100644 --- a/apps/backend/src/receipt/http-problem.ts +++ b/apps/backend/src/receipt/problem.ts @@ -1,4 +1,4 @@ -/** The problems receipt handlers answer for domain, store, and credential failures. */ +/** The problems that receipt handlers answer for domain, store, and credential failures. */ import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; import type { ReceiptDecodeError, @@ -7,7 +7,7 @@ import type { ReceiptSettlementFailure, UnauthenticatedActor, } from "@vektorprogrammet/domain/receipt"; -import { type CredentialPresentation, Problem } from "@vektorprogrammet/http-api/http-semantics"; +import { type CredentialPresentation, Problem } from "@vektorprogrammet/rpc/problem"; import type { Cause } from "effect"; import type { SqlError } from "effect/unstable/sql/SqlError"; import { @@ -16,10 +16,10 @@ import { type ProblemMapper, problemMapper, requestInvalid, -} from "../http-api/problem.js"; +} from "../rpc/problem.js"; /** Every failure that a receipt handler answers, but a rejected credential. */ -type ReceiptHttpFailure = +type ReceiptRpcFailure = | Exclude | ReceiptFileFailure | IdentityEngineError @@ -52,7 +52,7 @@ const receiptCases = { IdentityEngineError: () => Problem.make("receipts.unavailable"), SqlError: () => Problem.make("receipts.unavailable"), TimeoutError: () => Problem.make("receipts.unavailable"), -} satisfies ProblemCases; +} satisfies ProblemCases; /** * The one answer for every receipt failure other than a rejected credential, including an @@ -72,17 +72,15 @@ const receiptCases = { * command.pipe(receiptProblems, commandReceiptProblems, receiptCredentialProblems(presentation)); * ``` * - * @avoid Mapping a receipt failure in a handler: the receipt endpoints then answer one failure + * @avoid Mapping a receipt failure in a handler: the receipt RPCs then answer one failure * differently. Pipe the handler's effect through this. * - * @construct http-problem + * @construct rpc-problem */ -export const receiptProblems: ProblemMapper = - problemMapper()(receiptCases); +export const receiptProblems: ProblemMapper = + problemMapper()(receiptCases); -/** - * A stored receipt value a read cannot decode is the receipt store failing, not the request. - */ +/** A stored receipt value that a read cannot decode is the receipt store failing, not the request. */ export const storedReceiptProblems = problemMapper()({ ReceiptDecodeError: () => Problem.make("receipts.unavailable"), }); @@ -105,7 +103,7 @@ export const storedReceiptProblems = problemMapper()({ * @avoid Answering a rejected credential with a fixed code: a request that presented a rejected * credential would be told that it presented none. Pipe the handler's effect through this. * - * @construct http-problem + * @construct rpc-problem */ export const receiptCredentialProblems = ( presentation: CredentialPresentation, diff --git a/apps/backend/src/receipt/r2.test.ts b/apps/backend/src/receipt/r2.test.ts index 1676a8770..8e738931f 100644 --- a/apps/backend/src/receipt/r2.test.ts +++ b/apps/backend/src/receipt/r2.test.ts @@ -133,7 +133,7 @@ describe("makeR2ReceiptFileStore", () => { expect(results.filter(Result.isSuccess)).toHaveLength(1); expect(results.find(Result.isFailure)).toMatchObject({ - failure: new ReceiptFileEffectConflict({ effectId: "contended-effect" }), + failure: ReceiptFileEffectConflict.make({ effectId: "contended-effect" }), }); expect(yield* store.readCommitted(staged.file, 64)).toEqual(new Uint8Array([5, 6, 7])); diff --git a/apps/backend/src/receipt/r2.ts b/apps/backend/src/receipt/r2.ts index 289b18040..24d11c473 100644 --- a/apps/backend/src/receipt/r2.ts +++ b/apps/backend/src/receipt/r2.ts @@ -1,5 +1,5 @@ import { ReceiptFileStoreError, ReceiptFileStoreResource } from "./filesystem.js"; -import { jsonText } from "../http-api/problem.js"; +import { jsonText } from "../rpc/problem.js"; import { Data, Match, Effect, Layer, Predicate } from "effect"; import { ReceiptDecodeError, @@ -99,7 +99,7 @@ const receiptFileIdentity = ( }); const notStaged = (effectId: string, fileRef: string): ReceiptFileNotStaged => - new ReceiptFileNotStaged({ effectId, fileRef }); + ReceiptFileNotStaged.make({ effectId, fileRef }); const readMatching = (bucket: R2Bucket, key: string, file: ReceiptFile) => Effect.gen(function* () { @@ -138,7 +138,7 @@ const claimEffect = (bucket: R2Bucket, markerKey: string, request: ReceiptFileRe if (existing !== null) { if (existing !== requestJson) - return yield* new ReceiptFileEffectConflict({ effectId: request.effectId }); + return yield* ReceiptFileEffectConflict.make({ effectId: request.effectId }); return; } @@ -154,14 +154,14 @@ const claimEffect = (bucket: R2Bucket, markerKey: string, request: ReceiptFileRe const winner = yield* markerRequest(bucket, markerKey); if (winner !== requestJson) - return yield* new ReceiptFileEffectConflict({ effectId: request.effectId }); + return yield* ReceiptFileEffectConflict.make({ effectId: request.effectId }); }); const ensureFileIdentity = (file: ReceiptFile) => keyIsSafe(file.fileRef) && keyIsSafe(file.objectKey) ? Effect.void : Effect.fail( - new ReceiptFileIdentityConflict({ effectId: "identity", objectKey: file.objectKey }), + ReceiptFileIdentityConflict.make({ effectId: "identity", objectKey: file.objectKey }), ); export const makeR2ReceiptFileStore = (config: R2ReceiptFileStoreConfig): ReceiptFileStore => { @@ -192,7 +192,7 @@ export const makeR2ReceiptFileStore = (config: R2ReceiptFileStoreConfig): Receip const bytes = new Uint8Array(yield* call(() => file.arrayBuffer())); if (bytes.byteLength > maxFileBytes) { - return yield* new ReceiptDecodeError({ message: "receipt file exceeds configured limit" }); + return yield* ReceiptDecodeError.make({ message: "receipt file exceeds configured limit" }); } const identity = yield* receiptFileIdentity( @@ -255,7 +255,7 @@ export const makeR2ReceiptFileStore = (config: R2ReceiptFileStoreConfig): Receip if (staged === "matching") return; if (staged === "different") { - return yield* new ReceiptFileIdentityConflict({ + return yield* ReceiptFileIdentityConflict.make({ effectId: "stage", objectKey: file.objectKey, }); @@ -266,7 +266,7 @@ export const makeR2ReceiptFileStore = (config: R2ReceiptFileStoreConfig): Receip if (committed === "matching") return; if (committed === "different") { - return yield* new ReceiptFileIdentityConflict({ + return yield* ReceiptFileIdentityConflict.make({ effectId: "stage", objectKey: file.objectKey, }); @@ -291,7 +291,7 @@ export const makeR2ReceiptFileStore = (config: R2ReceiptFileStoreConfig): Receip const committed = yield* readMatching(bucket, request.file.objectKey, request.file); if (committed === "different") { - return yield* new ReceiptFileIdentityConflict({ + return yield* ReceiptFileIdentityConflict.make({ effectId: request.effectId, objectKey: request.file.objectKey, }); @@ -314,7 +314,7 @@ export const makeR2ReceiptFileStore = (config: R2ReceiptFileStoreConfig): Receip const committed = yield* readMatching(bucket, request.file.objectKey, request.file); if (committed === "different") { - return yield* new ReceiptFileIdentityConflict({ + return yield* ReceiptFileIdentityConflict.make({ effectId: request.effectId, objectKey: request.file.objectKey, }); diff --git a/apps/backend/src/receipt/http-reads.ts b/apps/backend/src/receipt/reads.ts similarity index 64% rename from apps/backend/src/receipt/http-reads.ts rename to apps/backend/src/receipt/reads.ts index d639f16cb..18ae0d366 100644 --- a/apps/backend/src/receipt/http-reads.ts +++ b/apps/backend/src/receipt/reads.ts @@ -1,4 +1,4 @@ -/** Receipt read handlers: owner, approver, and finance lists; settlement evidence; files; evidence. */ +/** Receipt reads: owner, approver, and finance lists; settlement evidence; files; evidence. */ import { Database, IdentitySnapshot } from "@vektorprogrammet/database"; import { readOwnedReceiptFile } from "@vektorprogrammet/database/receipt/postgres"; import { @@ -28,38 +28,32 @@ import { Economy, ReceiptNotFound, ReceiptPersistenceError, + type ReceiptStatus, } from "@vektorprogrammet/domain/receipt"; import { - ReadReceiptFileEndpoint, + ReadReceiptFile, ReceiptLifecycleEvidenceResponse, reflectAccessSpec, + type ReceiptApprovalQueueItem, type ReceiptSettlementQueueItem, -} from "@vektorprogrammet/http-api"; -import { nativeCookieChallenge, Problem } from "@vektorprogrammet/http-api/http-semantics"; +} from "@vektorprogrammet/rpc"; +import { nativeCookieChallenge, Problem } from "@vektorprogrammet/rpc/problem"; import { Effect, Option, Predicate, Schema } from "effect"; import { currentInstant, resolveRequestCredentialInTransaction } from "../authority.js"; -import { personPresentation, requireNoQuery } from "../http-api/problem.js"; -import { - RECEIPT_E2E_CONCURRENCY_RESPONSE_HEADER, - type ReceiptE2ETransactionBarrier, -} from "./e2e-support.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import { personPresentation } from "../rpc/problem.js"; +import { approvalCredential, authorizationPrincipal, type ReceiptCaller } from "./context.js"; +import type { ReceiptE2ETransactionBarrier } from "./e2e-support.js"; import type { ReceiptFileStore } from "./filesystem.js"; -import type { ReceiptApiHttpOptions, ReceiptIdentityFailure } from "./http-context.js"; -import { decodeReceiptListQuery } from "./http-decode.js"; -import { - receiptCredentialProblems, - receiptProblems, - storedReceiptProblems, -} from "./http-problem.js"; +import { receiptCredentialProblems, receiptProblems, storedReceiptProblems } from "./problem.js"; import { - jsonResponse, ownedReceiptResource, - privateJsonResponse, projected, readPrivateReceiptFile, receiptEtag, receiptSettlementEvidenceResource, -} from "./http-representation.js"; +} from "./representation.js"; +import { requireReceiptCursor } from "./validate.js"; interface ReceiptAccessRow { readonly ownerPersonId: string; @@ -68,42 +62,80 @@ interface ReceiptAccessRow { readonly revision: number; } -export const listOwnedReceipts = ( - request: Request, - options: ReceiptApiHttpOptions, -) => +/** One page of a receipt list, with its continuation when there is one. */ +const page = (items: ReadonlyArray, nextCursor: string | undefined) => + nextCursor === undefined ? { items } : { items, nextCursor }; + +/** A receipt list query: an optional status and continuation. */ +interface ReceiptListQuery { + readonly status?: ReceiptStatus | undefined; + readonly cursor?: string | undefined; +} + +export const listOwnedReceipts = ({ + caller, + query, +}: { + readonly caller: ReceiptCaller; + readonly query: ReceiptListQuery; +}) => Effect.gen(function* () { - const { status: selectedStatus, cursor } = yield* decodeReceiptListQuery(request); + yield* requireReceiptCursor(query.cursor); - const principal = yield* options.identity.resolveAuthorizationPrincipal(request); + const principal = yield* authorizationPrincipal(caller); const rows = yield* Economy.use(({ listOwnedReceipts }) => - listOwnedReceipts(principal.personId, selectedStatus, cursor), + listOwnedReceipts(principal.personId, query.status, query.cursor), ).pipe(storedReceiptProblems); const items = yield* projected(() => rows.items.map(ownedReceiptResource)); - return jsonResponse( - rows.nextCursor === undefined ? { items } : { items, nextCursor: rows.nextCursor }, - 200, - "private, no-store", - ); - }).pipe(receiptProblems, receiptCredentialProblems(personPresentation(request))); + return page(items, rows.nextCursor); + }).pipe(receiptProblems, receiptCredentialProblems(personPresentation(caller.headers))); + +const approvalQueueItem = ( + receipt: Omit & { + readonly amountOre: string | number; + }, + operation: string, +): ReceiptApprovalQueueItem => { + const amountOre = Number(receipt.amountOre); + + if (!Number.isSafeInteger(amountOre) || amountOre <= 0) { + throw ReceiptPersistenceError.make({ operation, message: "invalid amount" }); + } + + return { + receiptId: receipt.receiptId, + visualId: receipt.visualId, + ownerPersonId: receipt.ownerPersonId, + departmentId: receipt.departmentId, + amountOre, + currency: receipt.currency, + description: receipt.description, + receiptDate: receipt.receiptDate, + status: receipt.status, + approvedAt: receipt.approvedAt, + revision: receipt.revision, + etag: receiptEtag(receipt), + }; +}; -export const listReceiptsForApproval = ( - request: Request, - options: ReceiptApiHttpOptions, -) => +export const listReceiptsForApproval = ({ + caller, + query, +}: { + readonly caller: ReceiptCaller; + readonly query: ReceiptListQuery; +}) => Effect.gen(function* () { - const { status, cursor } = yield* decodeReceiptListQuery(request); + const { status, cursor } = query; + + yield* requireReceiptCursor(cursor); - const resolved = - options.identity.resolveApprovalCredential === undefined - ? undefined - : yield* options.identity.resolveApprovalCredential(request); + const resolved = yield* approvalCredential(caller); if ( - resolved !== undefined && Predicate.isTagged(resolved.credential.mechanism, "OAuthServiceBearer") && Predicate.isTagged(resolved.credential.principal, "ServicePrincipal") ) { @@ -117,12 +149,11 @@ export const listReceiptsForApproval = ( ({ readReceiptApprovalCandidates }) => readReceiptApprovalCandidates(credential, resolved.authorizationInstant, status, cursor), ).pipe( - Effect.mapError( - () => - new ReceiptPersistenceError({ - operation: "read service receipt approval authority", - message: "service receipt approval authority is unavailable", - }), + Effect.mapError(() => + ReceiptPersistenceError.make({ + operation: "read service receipt approval authority", + message: "service receipt approval authority is unavailable", + }), ), ); @@ -150,98 +181,41 @@ export const listReceiptsForApproval = ( seen.add(receipt.receiptId); if (status !== undefined && receipt.status !== status) return []; - const amountOre = Number(receipt.amountOre); - - if (!Number.isSafeInteger(amountOre) || amountOre <= 0) { - throw new ReceiptPersistenceError({ - operation: "decode service approver projection", - message: "invalid amount", - }); - } - - return [ - { - receiptId: receipt.receiptId, - visualId: receipt.visualId, - ownerPersonId: receipt.ownerPersonId, - departmentId: receipt.departmentId, - amountOre, - currency: receipt.currency, - description: receipt.description, - receiptDate: receipt.receiptDate, - status: receipt.status, - approvedAt: receipt.approvedAt, - revision: receipt.revision, - etag: receiptEtag(receipt.receiptId, receipt.revision), - }, - ]; + + return [approvalQueueItem(receipt, "decode service approver projection")]; }); }); - return jsonResponse( - authority.nextCursor === undefined - ? { items } - : { items, nextCursor: authority.nextCursor }, - 200, - "private, no-store", - ); + return page(items, authority.nextCursor); } - const principal = - resolved !== undefined && Predicate.isTagged(resolved.credential.principal, "Person") - ? { - personId: resolved.credential.principal.personId, - authorizationInstant: resolved.authorizationInstant, - } - : yield* options.identity.resolveAuthorizationPrincipal(request); + const principal = Predicate.isTagged(resolved.credential.principal, "Person") + ? { + personId: resolved.credential.principal.personId, + authorizationInstant: resolved.authorizationInstant, + } + : yield* authorizationPrincipal(caller); const rows = yield* Economy.use(({ listReceiptsForApproval }) => listReceiptsForApproval(principal.personId, principal.authorizationInstant, status, cursor), ).pipe(storedReceiptProblems); const items = yield* projected(() => - rows.items.map((row) => { - const amountOre = Number(row.amountOre); - - if (!Number.isSafeInteger(amountOre) || amountOre <= 0) { - throw new ReceiptPersistenceError({ - operation: "decode approver projection", - message: "invalid amount", - }); - } - - return { - receiptId: row.receiptId, - visualId: row.visualId, - ownerPersonId: row.ownerPersonId, - departmentId: row.departmentId, - amountOre, - currency: row.currency, - description: row.description, - receiptDate: row.receiptDate, - status: row.status, - approvedAt: row.approvedAt, - revision: row.revision, - etag: receiptEtag(row.receiptId, row.revision), - }; - }), + rows.items.map((row) => approvalQueueItem(row, "decode approver projection")), ); - return jsonResponse( - rows.nextCursor === undefined ? { items } : { items, nextCursor: rows.nextCursor }, - 200, - "private, no-store", - ); - }).pipe(receiptProblems, receiptCredentialProblems(personPresentation(request))); + return page(items, rows.nextCursor); + }).pipe(receiptProblems, receiptCredentialProblems(personPresentation(caller.headers))); -export const readSettlementForFinance = ( - request: Request, - receiptId: string, - options: ReceiptApiHttpOptions, -) => +export const readSettlementForFinance = ({ + caller, + receiptId, +}: { + readonly caller: ReceiptCaller; + readonly receiptId: string; +}) => Effect.gen(function* () { - yield* requireNoQuery(request); - const principal = yield* options.identity.resolveAuthorizationPrincipal(request); + const principal = yield* authorizationPrincipal(caller); const settlement = yield* Economy.use(({ readReceiptSettlementForFinance }) => readReceiptSettlementForFinance( @@ -251,23 +225,27 @@ export const readSettlementForFinance = ( ), ).pipe(storedReceiptProblems); - return privateJsonResponse(receiptSettlementEvidenceResource(settlement)); - }).pipe(receiptProblems, receiptCredentialProblems(personPresentation(request))); + return yield* projected(() => receiptSettlementEvidenceResource(settlement)); + }).pipe(receiptProblems, receiptCredentialProblems(personPresentation(caller.headers))); -export const listReceiptsForSettlement = ( - request: Request, - options: ReceiptApiHttpOptions, -) => +export const listReceiptsForSettlement = ({ + caller, + cursor, +}: { + readonly caller: ReceiptCaller; + readonly cursor: string | undefined; +}) => Effect.gen(function* () { - const { cursor } = yield* decodeReceiptListQuery(request, false); - const principal = yield* options.identity.resolveAuthorizationPrincipal(request); + yield* requireReceiptCursor(cursor); + + const principal = yield* authorizationPrincipal(caller); const rows = yield* Economy.use(({ listReceiptsForSettlement }) => listReceiptsForSettlement(principal.personId, principal.authorizationInstant, cursor), ).pipe(storedReceiptProblems); const items = yield* projected(() => - rows.items.map((row): typeof ReceiptSettlementQueueItem.Type => { + rows.items.map((row): ReceiptSettlementQueueItem => { const amountOre = Number(row.amountOre); if ( @@ -276,7 +254,7 @@ export const listReceiptsForSettlement = ( row.status !== "Approved" || row.approvedAt.length === 0 ) { - throw new ReceiptPersistenceError({ + throw ReceiptPersistenceError.make({ operation: "decode settlement queue projection", message: "invalid settlement queue item", }); @@ -294,24 +272,29 @@ export const listReceiptsForSettlement = ( status: row.status, approvedAt: row.approvedAt, revision: row.revision, - etag: receiptEtag(row.receiptId, row.revision), + etag: receiptEtag(row), }; }), ); - return privateJsonResponse( - rows.nextCursor === undefined ? { items } : { items, nextCursor: rows.nextCursor }, - ); - }).pipe(receiptProblems, receiptCredentialProblems(personPresentation(request))); + return page(items, rows.nextCursor); + }).pipe(receiptProblems, receiptCredentialProblems(personPresentation(caller.headers))); + +/** The environment of every receipt file read: its call and the private receipt store. */ +export interface ReceiptFileReadContext extends ReceiptCaller { + readonly fileStore: ReceiptFileStore; +} /** Reads the owner's receipt file after an owner grant evaluates inside one snapshot. */ -export const readOwnerReceiptFile = ( - request: Request, - receiptId: string, - options: ReceiptApiHttpOptions, - fileStore: ReceiptFileStore, -) => { - const presentation = personPresentation(request); +export const readOwnerReceiptFile = ({ + context, + receiptId, +}: { + readonly context: ReceiptFileReadContext; + readonly receiptId: string; +}) => { + const { headers, options, fileStore } = context; + const presentation = personPresentation(headers); return Effect.gen(function* () { const sql = yield* Database; @@ -321,7 +304,7 @@ export const readOwnerReceiptFile = ( yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`; const authenticated = yield* resolveRequestCredentialInTransaction( - request, + credentialRequestOf(headers), "OAuthUserBearer", { now: options.now }, ); @@ -344,7 +327,7 @@ export const readOwnerReceiptFile = ( }; const evaluation = evaluateAccess({ - spec: Option.getOrThrow(reflectAccessSpec(ReadReceiptFileEndpoint)), + spec: Option.getOrThrow(reflectAccessSpec(ReadReceiptFile)), credential: authenticated.credential, resolution: { selection: "ExactlyOne", @@ -388,23 +371,29 @@ export const readOwnerReceiptFile = ( }), ); - return yield* readPrivateReceiptFile(file, fileStore, options.config.maxFileBytes); + return yield* readPrivateReceiptFile({ + file, + fileStore, + maxFileBytes: options.config.receipt.maxFileBytes, + }); }).pipe(receiptProblems, receiptCredentialProblems(presentation)); }; /** - * Reads one approver-visible receipt file without granting owner access. - * Credential resolution and rule-aware metadata selection share one snapshot. + * Reads one approver-visible receipt file without granting owner access. Credential resolution and + * rule-aware metadata selection share one snapshot. */ -export const readApprovalReceiptFile = ( - request: Request, - receiptId: string, - options: ReceiptApiHttpOptions, - fileStore: ReceiptFileStore, - barrier: ReceiptE2ETransactionBarrier | undefined, -) => { - const presentation = personPresentation(request); - let synchronized = false; +export const readApprovalReceiptFile = ({ + context, + receiptId, + barrier, +}: { + readonly context: ReceiptFileReadContext; + readonly receiptId: string; + readonly barrier: ReceiptE2ETransactionBarrier | undefined; +}) => { + const { headers, options, fileStore } = context; + const presentation = personPresentation(headers); return Effect.gen(function* () { const sql = yield* Database; @@ -414,7 +403,7 @@ export const readApprovalReceiptFile = ( yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`; const authenticated = yield* resolveRequestCredentialInTransaction( - request, + credentialRequestOf(headers), "OAuthUserBearer", { now: options.now }, ); @@ -426,7 +415,7 @@ export const readApprovalReceiptFile = ( } if (barrier !== undefined) { - synchronized = yield* barrier(request, receiptId, "file-read"); + yield* barrier(headers, receiptId, "file-read"); } return yield* Economy.use(({ readReceiptFileForApproval }) => @@ -442,22 +431,24 @@ export const readApprovalReceiptFile = ( }), ); - return yield* readPrivateReceiptFile( + return yield* readPrivateReceiptFile({ file, fileStore, - options.config.maxFileBytes, - synchronized ? { [RECEIPT_E2E_CONCURRENCY_RESPONSE_HEADER]: "1" } : {}, - ); + maxFileBytes: options.config.receipt.maxFileBytes, + }); }).pipe(receiptProblems, receiptCredentialProblems(presentation)); }; /** Internal lifecycle evidence; enabled only by the E2E test-mode access fact. */ -export const readReceiptLifecycleEvidence = ( - request: Request, - receiptId: string, - options: ReceiptApiHttpOptions, -) => +export const readReceiptLifecycleEvidence = ({ + caller, + receiptId, +}: { + readonly caller: ReceiptCaller; + readonly receiptId: string; +}) => Effect.gen(function* () { + const { headers, options } = caller; const authorizationInstant = AuthorizationInstant.make(yield* currentInstant(options.now)); const sql = yield* Database; @@ -470,11 +461,11 @@ export const readReceiptLifecycleEvidence = ( // Only the Cookie credential reaches this operation, so a rejection challenges for it alone. const actor = yield* IdentitySnapshot.use(({ resolveSession }) => - resolveSession(request.headers.get("cookie") ?? undefined, authorizationInstant), + resolveSession(headers.cookie, authorizationInstant), ).pipe( Effect.catchTag("IdentitySessionNotFound", () => Effect.fail( - Problem.unauthenticated(personPresentation(request, nativeCookieChallenge)), + Problem.unauthenticated(personPresentation(headers, nativeCookieChallenge)), ), ), ); @@ -490,7 +481,7 @@ export const readReceiptLifecycleEvidence = ( const row = rows[0]; - if (row === undefined) return yield* new ReceiptNotFound({ receiptId }); + if (row === undefined) return yield* ReceiptNotFound.make({ receiptId }); const principal = { _tag: "Person" as const, personId }; const context = { @@ -505,7 +496,7 @@ export const readReceiptLifecycleEvidence = ( state: row.status, approverPersonIds: [], approverServicePrincipalIds: [], - internalEvidenceEnabled: options.config.e2e !== undefined, + internalEvidenceEnabled: options.config.receipt.e2e !== undefined, } satisfies ReceiptAccessFacts, authorityVersion: AuthorityVersion.make(`receipt:${row.revision}`), }; @@ -549,11 +540,9 @@ export const readReceiptLifecycleEvidence = ( readReceiptLifecycleEvidence(receiptId, personId), ); - const encoded = yield* Schema.encodeEffect(ReceiptLifecycleEvidenceResponse)(evidence).pipe( + return yield* Schema.decodeEffect(ReceiptLifecycleEvidenceResponse)(evidence).pipe( Effect.mapError(() => Problem.make("receipts.unavailable")), ); - - return jsonResponse(encoded); }), ); }).pipe(receiptProblems); diff --git a/apps/backend/src/receipt/representation.ts b/apps/backend/src/receipt/representation.ts new file mode 100644 index 000000000..352b858b7 --- /dev/null +++ b/apps/backend/src/receipt/representation.ts @@ -0,0 +1,190 @@ +/** Receipt representations: entity tags, resources, command capsules, and file contents. */ +import { + ReceiptPersistenceError, + type OwnedReceiptProjectionItem, + type Receipt, + type ReceiptFile, + type ReceiptSettlementEvidence, +} from "@vektorprogrammet/domain/receipt"; +import { + ReceiptSettlementEvidenceResource, + type ReceiptFileContent, + type ReceiptListItem, + ReceiptResource, +} from "@vektorprogrammet/rpc"; +import { Problem } from "@vektorprogrammet/rpc/problem"; +import { Effect, Schema } from "effect"; +import { deriveStrongETag, jsonBodyBytes } from "../http-semantics.js"; +import type { NativeHttpResponseCapsule } from "../rpc/receipt-transaction.js"; +import type { ReceiptFileStore } from "./filesystem.js"; + +/** + * Projects stored rows onto response items. A stored value outside the response contract is the + * receipt store failing; any other throw is a defect. + */ +export const projected = (project: () => A): Effect.Effect => + Effect.suspend(() => { + try { + return Effect.succeed(project()); + } catch (cause) { + return Schema.is(ReceiptPersistenceError)(cause) ? Effect.fail(cause) : Effect.die(cause); + } + }); + +/** The strong entity tag of one receipt revision, which its commands take as `ifMatch`. */ +export const receiptEtag = (receipt: { readonly receiptId: string; readonly revision: number }) => + deriveStrongETag({ + representationKind: "ReceiptResource", + resourceIdentity: receipt.receiptId, + version: receipt.revision, + }); + +export const receiptSettlementEvidenceResource = ( + settlement: ReceiptSettlementEvidence, +): typeof ReceiptSettlementEvidenceResource.Type => { + const amountOre = Number(settlement.amountOre); + + if (!Number.isSafeInteger(amountOre) || amountOre <= 0) { + throw ReceiptPersistenceError.make({ + operation: "decode receipt settlement evidence", + message: "invalid amount", + }); + } + + try { + return Schema.decodeSync(ReceiptSettlementEvidenceResource)({ + ...settlement, + amountOre, + }); + } catch { + throw ReceiptPersistenceError.make({ + operation: "decode receipt settlement evidence", + message: "invalid settlement evidence", + }); + } +}; + +const receiptResource = (receipt: Receipt): typeof ReceiptResource.Type => { + const amountOre = Number(receipt.amountOre); + + if (!Number.isSafeInteger(amountOre) || amountOre <= 0) { + throw ReceiptPersistenceError.make({ + operation: "decode receipt resource", + message: "invalid amount", + }); + } + + return { + receiptId: receipt.receiptId, + visualId: receipt.visualId, + ownerPersonId: receipt.ownerPersonId, + departmentId: receipt.departmentId, + description: receipt.description, + amountOre, + currency: receipt.currency, + receiptDate: receipt.receiptDate, + status: receipt.status, + submittedAt: receipt.submittedAt, + approvedAt: receipt.approvedAt, + revision: receipt.revision, + etag: receiptEtag(receipt), + }; +}; + +export const ownedReceiptResource = (receipt: OwnedReceiptProjectionItem): ReceiptListItem => { + const amountOre = Number(receipt.amountOre); + + if (!Number.isSafeInteger(amountOre) || amountOre <= 0) { + throw ReceiptPersistenceError.make({ + operation: "decode owned receipt projection", + message: "invalid amount", + }); + } + + return { + receiptId: receipt.receiptId, + visualId: receipt.visualId, + ownerPersonId: receipt.ownerPersonId, + departmentId: receipt.departmentId, + description: receipt.description, + amountOre, + currency: receipt.currency, + receiptDate: receipt.receiptDate, + status: receipt.status, + approvedAt: receipt.approvedAt, + settlement: + receipt.settlement === null ? null : receiptSettlementEvidenceResource(receipt.settlement), + revision: receipt.revision, + etag: receiptEtag(receipt), + }; +}; + +/** A created receipt names its location; an updated one does not. */ +export type ReceiptMutationStatus = + | { readonly status: 200 } + | { readonly status: 201; readonly location: string }; + +/** + * The replayable receipt of one receipt mutation: the HTTP capsule byte for byte, so a retry that + * straddles the cutover replays its first answer. Its body is the `ReceiptResource`, which + * `commandOutcome(ReceiptResource)` decodes. + */ +export const receiptMutationCapsule = ({ + receipt, + response, +}: { + readonly receipt: Receipt; + readonly response: ReceiptMutationStatus; +}): Effect.Effect => + projected(() => receiptResource(receipt)).pipe( + Effect.map( + (resource): NativeHttpResponseCapsule => ({ + status: response.status, + mediaType: "application/json", + bodyBytes: jsonBodyBytes(resource), + headers: + response.status === 201 + ? { + "content-type": "application/json", + etag: resource.etag, + location: response.location, + } + : { "content-type": "application/json", etag: resource.etag }, + }), + ), + ); + +/** The replayable receipt of a settlement: the evidence, under the settled receipt's tag. */ +export const settlementMutationCapsule = ({ + settlement, + receipt, +}: { + readonly settlement: ReceiptSettlementEvidence; + readonly receipt: Receipt; +}): Effect.Effect => + projected(() => receiptSettlementEvidenceResource(settlement)).pipe( + Effect.map((evidence) => ({ + status: 200, + mediaType: "application/json", + bodyBytes: jsonBodyBytes(evidence), + headers: { + "content-type": "application/json", + etag: receiptEtag(receipt), + }, + })), + ); + +/** Reads verified private bytes; unreadable bytes are the receipt store failing. */ +export const readPrivateReceiptFile = ({ + file, + fileStore, + maxFileBytes, +}: { + readonly file: ReceiptFile; + readonly fileStore: ReceiptFileStore; + readonly maxFileBytes: number; +}): Effect.Effect> => + fileStore.readCommitted(file, maxFileBytes).pipe( + Effect.mapError(() => Problem.make("receipts.unavailable")), + Effect.map((bytes) => ({ contentType: file.contentType, bytes })), + ); diff --git a/apps/backend/src/receipt/reviewed-import.ts b/apps/backend/src/receipt/reviewed-import.ts index f4f5687e2..19232771a 100644 --- a/apps/backend/src/receipt/reviewed-import.ts +++ b/apps/backend/src/receipt/reviewed-import.ts @@ -24,6 +24,7 @@ import { import { readSnapshotFile } from "./import-snapshot.js"; import { PaymentAccountCustodyError, type PaymentAccountCipher } from "./payment-account.js"; import type { ReceiptFileStore } from "./filesystem.js"; +import { dual } from "effect/Function"; const maxFileBytes = 10 * 1024 * 1024; @@ -67,7 +68,7 @@ const encryptAccount = (cipher: PaymentAccountCipher, account: string, receiptId ), ); -export const runReviewedReceiptImport = ( +const runReviewedReceiptImportWith = ( input: ReviewedReceiptSnapshot, accounts: ReadonlyMap, archiveRoot: string, @@ -269,12 +270,11 @@ export const runReviewedReceiptImport = ( ? Effect.succeed(false) : files.readCommitted(receipt.file, maxFileBytes).pipe( Effect.as(true), - Effect.mapError( - () => - new ReceiptPersistenceError({ - operation: "observe reviewed receipt bytes", - message: "Private bytes unavailable", - }), + Effect.mapError(() => + ReceiptPersistenceError.make({ + operation: "observe reviewed receipt bytes", + message: "Private bytes unavailable", + }), ), ), ).pipe(Effect.mapError(() => new ReceiptCohortFailure({ code: "ReceiptObservationFailed" }))); @@ -286,3 +286,21 @@ export const runReviewedReceiptImport = ( return { ...report, reconciled, pending, complete: pending === 0 }; }); + +type ReviewedReceiptImport = ReturnType; + +export const runReviewedReceiptImport: { + ( + accounts: ReadonlyMap, + archiveRoot: string, + files: ReceiptFileStore, + cipher: PaymentAccountCipher, + ): (input: ReviewedReceiptSnapshot) => ReviewedReceiptImport; + ( + input: ReviewedReceiptSnapshot, + accounts: ReadonlyMap, + archiveRoot: string, + files: ReceiptFileStore, + cipher: PaymentAccountCipher, + ): ReviewedReceiptImport; +} = dual(5, runReviewedReceiptImportWith); diff --git a/apps/backend/src/receipt/http.test.ts b/apps/backend/src/receipt/rpc.test.ts similarity index 50% rename from apps/backend/src/receipt/http.test.ts rename to apps/backend/src/receipt/rpc.test.ts index 80b1427a4..d5913fd7f 100644 --- a/apps/backend/src/receipt/http.test.ts +++ b/apps/backend/src/receipt/rpc.test.ts @@ -1,34 +1,23 @@ -import { backendDatabase } from "../../test/database.js"; +import { describe, expect, it } from "@effect/vitest"; +import { + type DatabaseOperations, + Database, + IdentitySnapshot, + OAuthCredentialAuthority, +} from "@vektorprogrammet/database"; import { AcceptedOAuthServiceCredential, + CredentialEvidenceRef, CredentialMechanismSchema, - PrincipalSchema, CredentialOutcomeSchema, - CredentialEvidenceRef, - ServicePrincipalId, - ServicePrincipalGrantAuthority, NATIVE_API_PROTECTED_RESOURCE, + PrincipalSchema, RECEIPT_APPROVAL_QUEUE_OPERATION, + ServicePrincipalGrantAuthority, + ServicePrincipalId, makeServicePrincipalReceiptGrant, type ServicePrincipalReceiptGrantAuthority, } from "@vektorprogrammet/domain/authz"; -import { - type DatabaseOperations, - Database, - IdentitySnapshot, - OAuthCredentialAuthority, -} from "@vektorprogrammet/database"; -import { - ReceiptApprovalQueueResponse, - IdempotencyKey, - ReceiptResource, - ReceiptListItem, - ReceiptsReopenReceiptProblem, -} from "@vektorprogrammet/http-api"; -import { - NativeHttpCommandOutcome, - executeNativeHttpCommandPostgres, -} from "../http-api/receipt-transaction.js"; import { Identity, IdentityActor, @@ -38,53 +27,61 @@ import { } from "@vektorprogrammet/domain/identity"; import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; import { - Receipt, - ReceiptId, - ReceiptVisualId, - ReceiptFileSchema, - ReceiptSettlementEvidenceSchema, - type ReceiptSettlementCommandRequest, - ReceiptMutationAuthorization, - type OwnedReceiptProjectionItem, ApprovalScopeSchema, - ReceiptOutboxDeliveryResult, Economy, InactiveActor, + Receipt, + ReceiptCommandRequestSchema, ReceiptDecodeError, + ReceiptFileSchema, ReceiptFileService, + ReceiptId, + ReceiptMutationAuthorization, ReceiptNotFound, - ReceiptPersistenceError, + ReceiptOutboxDeliveryResult, ReceiptOwnerDenied, + ReceiptPersistenceError, ReceiptScopeDenied, - UnauthenticatedActor, + ReceiptSettlementEvidenceSchema, + ReceiptVisualId, type EconomyOperations, - ReceiptCommandRequestSchema, - type ReceiptCommandRequest, + type OwnedReceiptProjectionItem, type ReceiptApprovalFileReadFailure, - type ReceiptCommandPrincipal, + type ReceiptCommandRequest, type ReceiptFailure, - type ReceiptLifecycleEvidenceProjection, type ReceiptFile, + type ReceiptLifecycleEvidenceProjection, + type ReceiptSettlementCommandRequest, type ReceiptSettlementEvidence, type ReceiptStatus, type ReceiptSubmissionAllocation, } from "@vektorprogrammet/domain/receipt"; -import { Predicate, DateTime, Effect, Layer, Schema, Struct } from "effect"; -import { describe, expect, it } from "@effect/vitest"; -import { jsonText } from "../http-api/problem.js"; +import { + InternalNativeRpcs, + NativeRpcs, + ReceiptListItem, + ReceiptResource, + internalNativeRpcPath, +} from "@vektorprogrammet/rpc"; +import { IdempotencyKey, isProblem, type Problem, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Layer, Match, Predicate, Schema, Struct } from "effect"; +import { Etag, HttpClient, HttpClientResponse, HttpRouter } from "effect/unstable/http"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { backendTestConfig } from "../../test/config.js"; +import { backendDatabase } from "../../test/database.js"; import { deriveHttpIdentity, deriveStrongETag } from "../http-semantics.js"; +import { InternalNativeRpcRouterLive } from "../router.js"; +import { jsonText } from "../rpc/problem.js"; import { - makeInternalReceiptTestHttp, - makeReceiptTestHttp as makeReceiptApiHttp, -} from "../test/native-http.js"; - + NativeHttpCommandOutcome, + executeNativeHttpCommandPostgres, +} from "../rpc/receipt-transaction.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; +import { TestPlatform } from "../test/platform.js"; import type { ReceiptApiConfig } from "./config.js"; -import { resolveRequestCredentialAtInstant } from "../authority.js"; +import { RECEIPT_E2E_CONCURRENCY_REQUEST_HEADER } from "./e2e-support.js"; import { ReceiptFileStoreError, type ReceiptFileStore } from "./filesystem.js"; -import type { ReceiptTestHttpOptions } from "../test/native-http.js"; -import type { BackendHttpHandler } from "../router.js"; - -type ReceiptApiHttp = { readonly fetch: BackendHttpHandler }; type ProjectionRow = Omit; @@ -95,7 +92,7 @@ type ReceiptAccessRow = { readonly revision: number; }; -const personId = PersonId.make("person-receipt-http"); +const personId = PersonId.make("person-receipt-rpc"); const departmentOne = DepartmentId.make("department-one"); @@ -109,15 +106,20 @@ const serviceBearer = "receipt-service-token"; const personBearer = "receipt-person-credential"; -const config: ReceiptApiConfig = { - stagingRoot: "/tmp/receipt-http-test-staging", - committedRoot: "/tmp/receipt-http-test-committed", +const sessionCookie = "better-auth.session_token=receipt-test-session"; + +const receiptConfig: ReceiptApiConfig = { + stagingRoot: "/tmp/receipt-rpc-test-staging", + committedRoot: "/tmp/receipt-rpc-test-committed", maxFileBytes: 1024, now: () => evaluatedAt, nextReceiptId: () => receiptId, nextVisualId: () => visualId, + e2e: {}, }; +const config = { ...backendTestConfig, receipt: receiptConfig }; + const pendingReceipt = (overrides: Partial = {}): ProjectionRow => ({ receiptId, visualId, @@ -152,34 +154,22 @@ const settlementEvidence = ( ...overrides, }); -const receiptEtag = (id: string, revision: number): string => +const receiptEtag = (id: string, revision: number) => deriveStrongETag({ representationKind: "ReceiptResource", resourceIdentity: id, version: revision, }); -const fileService = { - stage: () => Effect.void, - apply: () => Effect.void, -}; +const key = (value: string) => IdempotencyKey.make(value.padEnd(22, "0")); -const fileStore: ReceiptFileStore = { - readCommitted: () => Effect.die("unexpected file read"), - service: fileService, - layer: Layer.succeed(ReceiptFileService, fileService), - stageBytes: () => - Effect.succeed({ - file: { - fileRef: "staging/file-one", - objectKey: "committed/file-one", - contentType: "image/png", - byteLength: 4, - sha256: "aa".repeat(32), - }, - created: true, - }), - cleanupStage: () => Effect.void, +const pngBytes = new Uint8Array([137, 80, 78, 71]); + +const submission = { + description: "bus ticket", + amountOre: 1200, + receiptDate: "2026-08-01", + file: { contentType: "image/png", bytes: pngBytes }, }; interface HarnessOptions { @@ -197,9 +187,8 @@ interface HarnessOptions { readonly settlementEvidence?: ReceiptSettlementEvidence; readonly evidenceAccessRows?: ReadonlyArray; readonly evidenceResult?: ReceiptLifecycleEvidenceProjection; - readonly revokeSessionAfterSnapshotRead?: boolean; - readonly initialCommittedVersion?: number; readonly identitySnapshotFailure?: IdentityEngineError; + readonly transportHeaders?: Readonly>; } type RevocableReceiptAuthority = "Owner" | "Approval"; @@ -218,6 +207,62 @@ const transactionIsolation = Database.use( Effect.map((rows) => rows[0]!.isolation), ); +const fileService = { + stage: () => Effect.void, + apply: () => Effect.void, +}; + +const actorOf = () => + IdentityActor.make({ + personId, + sessionId: "receipt-rpc-session", + expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), + }); + +/** Serves the internal RPC routes alone, as the internal ingress composes them. */ +const makeInternalTestRpc = ( + services: Layer.Layer< + | Database + | Economy + | Identity + | IdentitySnapshot + | OAuthCredentialAuthority + | ServicePrincipalGrantAuthority + | ReceiptFileService + >, +) => { + const routerLayer = InternalNativeRpcRouterLive({ config, now: () => evaluatedAt }).pipe( + Layer.provideMerge(services), + Layer.provideMerge(Layer.mergeAll(TestPlatform, Etag.layer)), + ); + + const httpClient = HttpClient.make((request) => + Effect.acquireUseRelease( + Effect.sync(() => HttpRouter.toWebHandler(routerLayer, { disableLogger: true })), + ({ handler }) => + Effect.promise(() => + handler( + new Request(new URL(internalNativeRpcPath, "http://internal-rpc.test"), { + method: request.method, + headers: request.headers, + body: Predicate.isTagged(request.body, "Uint8Array") ? request.body.body : undefined, + }), + ), + ).pipe(Effect.map((response) => HttpClientResponse.fromWeb(request, response))), + ({ dispose }) => Effect.promise(() => dispose()), + ), + ); + + return RpcClient.make(InternalNativeRpcs).pipe( + Effect.provide( + RpcClient.layerProtocolHttp({ url: `http://internal-rpc.test${internalNativeRpcPath}` }).pipe( + Layer.provide(RpcSerialization.layerJson), + Layer.provide(Layer.succeed(HttpClient.HttpClient)(httpClient)), + ), + ), + ); +}; + const harness = (options: HarnessOptions = {}) => { const serviceCredential: AcceptedOAuthServiceCredential | undefined = options.serviceApproval === undefined @@ -242,25 +287,20 @@ const harness = (options: HarnessOptions = {}) => { }> = []; const commands: Array = []; - const principals: Array = []; const allocations: Array = []; const settlementCommands: Array = []; const settlementReads: Array<{ readonly receiptId: string; readonly personId: string }> = []; const approvalQueries: Array<{ - readonly personId: typeof personId; + readonly personId: string; readonly authorizationInstant: string; readonly status: ReceiptStatus | undefined; }> = []; const evidenceReads: Array<{ readonly receiptId: string; readonly personId: string }> = []; - const commandTransactionIds: Array = []; - const identitySnapshotIsolations: Array = []; const identitySnapshotVersions: Array = []; - - let authorizationPrincipalCalls = 0; const authorizationChecks: Array = []; let revokedAuthority: RevocableReceiptAuthority | undefined; let revokedServiceBearer = false; @@ -285,7 +325,7 @@ const harness = (options: HarnessOptions = {}) => { }; const receiptFromProjection = (row: ProjectionRow): Receipt => - new Receipt({ + Receipt.make({ ...row, amountOre: Number(row.amountOre), approvedAt: row.status === "Approved" ? "2026-08-24T12:00:00.000Z" : null, @@ -300,16 +340,15 @@ const harness = (options: HarnessOptions = {}) => { }); const makeEconomy = (sql: DatabaseOperations) => { - const executeReceipt: EconomyOperations["executeReceipt"] = (input, principal, allocation) => + const executeReceipt: EconomyOperations["executeReceipt"] = (input, _principal, allocation) => Effect.gen(function* () { if (options.commandFailure !== undefined) return yield* options.commandFailure; const command = yield* Schema.decodeEffect(ReceiptCommandRequestSchema)(input).pipe( - Effect.mapError((cause) => new ReceiptDecodeError({ message: cause.message })), + Effect.mapError((cause) => ReceiptDecodeError.make({ message: cause.message })), ); commands.push(command); - principals.push(principal); allocations.push(allocation); commandTransactionIds.push(yield* transactionId); const source = sourceReceipt(command); @@ -383,22 +422,22 @@ const harness = (options: HarnessOptions = {}) => { if (source === undefined) { return Effect.fail( approval - ? new ReceiptScopeDenied({ + ? ReceiptScopeDenied.make({ receiptId: target.receiptId, departmentId: departmentOne, }) - : new ReceiptNotFound({ receiptId: target.receiptId }), + : ReceiptNotFound.make({ receiptId: target.receiptId }), ); } if (revokedAuthority === (approval ? "Approval" : "Owner")) { return Effect.fail( approval - ? new ReceiptScopeDenied({ + ? ReceiptScopeDenied.make({ receiptId: target.receiptId, departmentId: source.departmentId, }) - : new ReceiptOwnerDenied({ + : ReceiptOwnerDenied.make({ receiptId: target.receiptId, personId: principal.personId, }), @@ -438,7 +477,7 @@ const harness = (options: HarnessOptions = {}) => { ); return source === undefined - ? Effect.fail(new ReceiptNotFound({ receiptId: requestedReceiptId })) + ? Effect.fail(ReceiptNotFound.make({ receiptId: requestedReceiptId })) : Effect.succeed(source.revision); }); @@ -452,7 +491,7 @@ const harness = (options: HarnessOptions = {}) => { ); if (source === undefined) { - return Effect.fail(new ReceiptNotFound({ receiptId: command.receiptId })); + return Effect.fail(ReceiptNotFound.make({ receiptId: command.receiptId })); } settlementCommands.push(command); @@ -506,7 +545,7 @@ const harness = (options: HarnessOptions = {}) => { const evidence = options.settlementEvidence; return evidence === undefined || evidence.receiptId !== requestedReceiptId - ? Effect.fail(new ReceiptNotFound({ receiptId: requestedReceiptId })) + ? Effect.fail(ReceiptNotFound.make({ receiptId: requestedReceiptId })) : Effect.succeed(evidence); }), listOwnedReceipts: () => @@ -538,17 +577,17 @@ const harness = (options: HarnessOptions = {}) => { if (options.approvalFileFailure === "Decode") { return Effect.fail( - new ReceiptDecodeError({ message: "malformed stored file metadata" }), + ReceiptDecodeError.make({ message: "malformed stored file metadata" }), ); } if (options.approvalFileFailure === "Inactive") { - return Effect.fail(new InactiveActor({ personId: queryPersonId })); + return Effect.fail(InactiveActor.make({ personId: queryPersonId })); } if (options.approvalFileFailure === "Scope") { return Effect.fail( - new ReceiptScopeDenied({ + ReceiptScopeDenied.make({ receiptId: requestedReceiptId, departmentId: source?.departmentId ?? departmentOne, }), @@ -556,7 +595,7 @@ const harness = (options: HarnessOptions = {}) => { } if (source === undefined || source.receiptId !== requestedReceiptId) { - return Effect.fail(new ReceiptNotFound({ receiptId: requestedReceiptId })); + return Effect.fail(ReceiptNotFound.make({ receiptId: requestedReceiptId })); } return Effect.succeed({ @@ -597,7 +636,6 @@ const harness = (options: HarnessOptions = {}) => { const evidenceRows = (options.evidenceAccessRows ?? []).map((row) => ({ ...row, receiptId, - ownerPersonId: row.ownerPersonId, })); const privateRows = @@ -639,13 +677,9 @@ const harness = (options: HarnessOptions = {}) => { return yield* options.identitySnapshotFailure; if (cookieHeader === undefined || options.unauthenticated === true || observedVersion === 2) - return yield* new IdentitySessionNotFound(); + return yield* IdentitySessionNotFound.make({}); - return new IdentityActor({ - personId, - sessionId: "receipt-http-session", - expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), - }); + return actorOf(); }), revokeCurrentSession: () => Effect.die("unexpected session mutation"), revokeSession: () => Effect.die("unexpected session mutation"), @@ -655,24 +689,21 @@ const harness = (options: HarnessOptions = {}) => { const servicePrincipalGrantAuthority = ServicePrincipalGrantAuthority.of({ readReceiptApprovalCandidates: () => - options.serviceApproval - ? Effect.succeed(options.serviceApproval) - : Effect.die("unexpected service receipt read"), + options.serviceApproval === undefined + ? Effect.die("unexpected service receipt read") + : Effect.succeed(options.serviceApproval), createGrant: () => Effect.die("unexpected grant write"), endGrant: () => Effect.die("unexpected grant write"), revokeGrant: () => Effect.die("unexpected grant write"), }); + // The ingress session check: the transaction re-resolves it through IdentitySnapshot. const identity = Identity.of({ signIn: () => Effect.die("unexpected sign-in"), resolveSession: () => - Effect.succeed( - new IdentityActor({ - personId, - sessionId: "receipt-http-session", - expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), - }), - ), + options.unauthenticated === true + ? Effect.fail(IdentitySessionNotFound.make({})) + : Effect.succeed(actorOf()), readCurrentSession: () => Effect.die("unexpected session read"), listSessions: () => Effect.die("unexpected session list"), revokeCurrentSession: () => Effect.die("unexpected session mutation"), @@ -697,31 +728,16 @@ const harness = (options: HarnessOptions = {}) => { }); } - return options.serviceApproval !== undefined && + return serviceCredential !== undefined && request.headers.get("authorization") === `Bearer ${serviceBearer}` && expected === "Either" && !revokedServiceBearer - ? serviceCredential! + ? serviceCredential : CredentialOutcomeSchema.cases.Rejected.make({ reason: "Revoked" as const }); }), resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), }); - const run = ( - effect: Effect.Effect< - A, - E, - Database | Economy | IdentitySnapshot | ServicePrincipalGrantAuthority - >, - ): Effect.Effect => - database.run( - effect.pipe( - Effect.provide(economyLayer), - Effect.provideService(IdentitySnapshot, identitySnapshot), - Effect.provideService(ServicePrincipalGrantAuthority, servicePrincipalGrantAuthority), - ), - ); - const services = Layer.mergeAll( database.layer, economyLayer, @@ -729,62 +745,62 @@ const harness = (options: HarnessOptions = {}) => { Layer.succeed(ServicePrincipalGrantAuthority, servicePrincipalGrantAuthority), Layer.succeed(Identity, identity), Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), + Layer.succeed(ReceiptFileService, fileService), ); - const httpOptions = { - config: { ...config, e2e: {} }, - identity: { - resolveApprovalCredential: - serviceCredential === undefined - ? undefined - : (request: Request) => - resolveRequestCredentialAtInstant(request, "Either", { now: () => evaluatedAt }).pipe( - Effect.provideService(Identity, identity), - Effect.provideService(OAuthCredentialAuthority, oauthCredentialAuthority), - ), - resolveAuthorizationPrincipal: () => - Effect.suspend(() => { - authorizationPrincipalCalls += 1; + const receiptFileStore: ReceiptFileStore = { + service: fileService, + layer: Layer.succeed(ReceiptFileService, fileService), + stageBytes: () => + Effect.succeed({ + file: { + fileRef: "staging/file-one", + objectKey: "committed/file-one", + contentType: "image/png", + byteLength: 4, + sha256: "aa".repeat(32), + }, + created: true, + }), + cleanupStage: () => Effect.void, + readCommitted: () => + Effect.suspend(() => { + privateFileReads++; - return options.unauthenticated === true - ? Effect.fail(new UnauthenticatedActor({ message: "no session" })) - : Effect.succeed({ personId, authorizationInstant: evaluatedAt }); - }), - resolvePersonId: () => Effect.succeed(personId), - }, - now: () => evaluatedAt, - fileStore: { - ...fileStore, - readCommitted: () => - Effect.suspend(() => { - privateFileReads++; + return options.privateFileUnavailable === true + ? Effect.fail( + new ReceiptFileStoreError({ + operation: "readCommitted", + message: "private bytes unavailable", + }), + ) + : Effect.succeed(new Uint8Array([1, 2, 3, 4])); + }), + }; - return options.privateFileUnavailable - ? Effect.fail( - new ReceiptFileStoreError({ - operation: "readCommitted", - message: "private bytes unavailable", - }), - ) - : Effect.succeed(new Uint8Array([1, 2, 3, 4])); - }), - }, - } satisfies ReceiptTestHttpOptions; + const backend = makeBackendTestRpc({ + config, + services, + options: { now: () => evaluatedAt, receiptFileStore }, + transportHeaders: options.transportHeaders, + }); + + const run = (effect: Effect.Effect): Effect.Effect => + database.run(effect); return { - http: makeReceiptApiHttp(httpOptions, services), - internalHttp: makeInternalReceiptTestHttp(httpOptions, services), + client: backend.client, + internalClient: makeInternalTestRpc(services), commands, settlementCommands, settlementReads: () => settlementReads, privateFileReads: () => privateFileReads, - principals, allocations, approvalQueries, approvalFileQueries: () => approvalFileQueries, evidenceReads, nativeReceiptCount: () => - database.run( + run( Database.use( (sql) => sql<{ @@ -797,8 +813,8 @@ const harness = (options: HarnessOptions = {}) => { mutationTransactions: () => Effect.gen(function* () { return { - commandTransactionIds, - receiptWriteTransactionIds: yield* database.run( + commandTransactionIds: [...commandTransactionIds], + receiptWriteTransactionIds: yield* run( Database.use( (sql) => sql<{ @@ -808,13 +824,12 @@ const harness = (options: HarnessOptions = {}) => { ), }; }), - evidenceCounts: () => ({ evidenceReads }), snapshotObservations: () => Effect.gen(function* () { return { - identitySnapshotIsolations, - identitySnapshotVersions, - committedVersion: yield* database.run( + identitySnapshotIsolations: [...identitySnapshotIsolations], + identitySnapshotVersions: [...identitySnapshotVersions], + committedVersion: yield* run( Database.use( (sql) => sql<{ version: number }>`SELECT version FROM test_credential`, ).pipe(Effect.map((rows) => rows[0]!.version)), @@ -822,9 +837,7 @@ const harness = (options: HarnessOptions = {}) => { }; }), revokeCredential: () => - database.run( - Database.use((sql) => sql`UPDATE test_credential SET version=2`).pipe(Effect.asVoid), - ), + run(Database.use((sql) => sql`UPDATE test_credential SET version=2`).pipe(Effect.asVoid)), revokeAuthority: (authority: RevocableReceiptAuthority) => { revokedAuthority = authority; }, @@ -832,100 +845,35 @@ const harness = (options: HarnessOptions = {}) => { revokedServiceBearer = true; }, authorizationChecks: () => authorizationChecks, - authorizationPrincipalCalls: () => authorizationPrincipalCalls, }; }; -const request = ( - http: ReceiptApiHttp, - pathname: string, - init?: RequestInit, - includeCookie = true, -): Effect.Effect => { - const headers = new Headers(init?.headers); - - if (includeCookie) headers.set("cookie", "better-auth.session_token=receipt-test-session"); - - if (init?.method !== undefined && init.method !== "GET") { - headers.set("origin", "http://127.0.0.1:5174"); - } - - return http.fetch(new Request(`http://backend.test${pathname}`, { ...init, headers })); -}; - -const submitRequest = (http: ReceiptApiHttp, idempotencyKey: string): Effect.Effect => { - const boundary = "receipt-http-test-boundary"; - - const body = [ - `--${boundary}\r\nContent-Disposition: form-data; name="description"\r\n\r\nbus ticket\r\n`, - `--${boundary}\r\nContent-Disposition: form-data; name="amountOre"\r\n\r\n1200\r\n`, - `--${boundary}\r\nContent-Disposition: form-data; name="receiptDate"\r\n\r\n2026-08-01\r\n`, - `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="receipt.png"\r\nContent-Type: image/png\r\n\r\ntest\r\n`, - `--${boundary}--\r\n`, - ].join(""); - - return request(http, "/api/receipts", { - method: "POST", - headers: { - "content-type": `multipart/form-data; boundary=${boundary}`, - "content-length": String(Buffer.byteLength(body)), - "idempotency-key": idempotencyKey, - }, - body, - }); -}; +/** Runs one call with the person's session cookie. */ +const withCookie = (effect: Effect.Effect) => + RpcClient.withHeaders(effect, { cookie: sessionCookie }); -const actionRequest = ( - http: ReceiptApiHttp, - pathname: string, - idempotencyKey: string, - revision = 0, - body: Schema.Json = {}, -): Effect.Effect => - request(http, pathname, { - method: "POST", - headers: { - "content-type": "application/json", - "content-length": String(Buffer.byteLength(JSON.stringify(body))), - "idempotency-key": idempotencyKey, - "if-match": receiptEtag(receiptId, revision), - }, - body: JSON.stringify(body), - }); +/** Runs one call with an OAuth bearer. */ +const withBearer = (effect: Effect.Effect, bearer: string) => + RpcClient.withHeaders(effect, { authorization: `Bearer ${bearer}` }); -const readJson = (response: Response) => - Effect.promise(() => response.json()).pipe( - Effect.flatMap(Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Json))), - ); +/** The problem code of a failed call; a transport failure or defect has none. */ +const codeOf = (failure: Problem | RpcClientError) => + isProblem(failure) ? failure.code : "defect"; -const expectProblem = ( - response: Response, - expected: { - readonly code: string; - readonly title: string; - readonly status: number; - readonly detail: string; - }, -) => - Effect.gen(function* () { - expect(response.status).toBe(expected.status); - expect(response.headers.get("content-type")).toContain("application/problem+json"); - expect(response.headers.get("cache-control")).toBe("no-store"); - expect(yield* Effect.promise(() => response.json())).toEqual({ - type: `urn:vektorprogrammet:problem:v0.2:${expected.code}`, - ...expected, - }); - }); +const transition = (idempotencyKey: string, revision = 0) => ({ + receiptId, + idempotencyKey: key(idempotencyKey), + ifMatch: receiptEtag(receiptId, revision), +}); -describe("receipt v0.2 HTTP contract", () => { - it.live("lists only the authenticated person's receipts with private projection ETags", () => +describe("receipt RPCs", () => { + it.live("lists only the authenticated person's receipts with projection entity tags", () => Effect.gen(function* () { const state = harness({ ownedRows: [pendingReceipt()] }); - const response = yield* request(state.http, "/api/receipts?status=Pending"); - const body = yield* readJson(response); + const client = yield* state.client; + + const body = yield* withCookie(client["receipts.listReceipts"]({ status: "Pending" })); - expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe("private, no-store"); expect(body).toEqual({ items: [ yield* Schema.decodeEffect(ReceiptListItem)({ @@ -936,11 +884,10 @@ describe("receipt v0.2 HTTP contract", () => { }), ], }); - expect(state.authorizationPrincipalCalls()).toBe(1); }), ); - it.live("lists only approved unsettled receipts in the private settlement queue", () => + it.live("lists only approved unsettled receipts in the settlement queue", () => Effect.gen(function* () { const queueRow = pendingReceipt({ status: "Approved", @@ -949,12 +896,9 @@ describe("receipt v0.2 HTTP contract", () => { }); const state = harness({ settlementRows: [queueRow] }); - const response = yield* request(state.http, "/api/receipt-settlement-queue"); + const client = yield* state.client; - expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe("private, no-store"); - expect(response.headers.get("vary")).toBe("Origin"); - expect(yield* readJson(response)).toEqual({ + expect(yield* withCookie(client["receipts.listReceiptsForSettlement"]({}))).toEqual({ items: [ { receiptId, @@ -975,51 +919,7 @@ describe("receipt v0.2 HTTP contract", () => { }), ); - it.live("rejects settlement media types outside the declared JSON contract", () => - Effect.gen(function* () { - const state = harness(); - - const response = yield* request(state.http, `/api/receipts/${receiptId}:settle`, { - method: "POST", - headers: { - "content-type": "text/plain", - "idempotency-key": "settle-wrong-media-type-0001", - "if-match": receiptEtag(receiptId, 2), - }, - body: "not-json", - }); - - expect(response.status).toBe(415); - expect(yield* Effect.promise(() => response.json())).toMatchObject({ - code: "media-type.unsupported", - }); - expect(state.settlementCommands).toHaveLength(0); - }), - ); - - it.live("stops reading an undeclared oversized settlement body", () => - Effect.gen(function* () { - const state = harness(); - - const response = yield* request(state.http, `/api/receipts/${receiptId}:settle`, { - method: "POST", - headers: { - "content-type": "application/json", - "idempotency-key": "settle-oversized-stream-0001", - "if-match": receiptEtag(receiptId, 2), - }, - body: "x".repeat(65_537), - }); - - expect(response.status).toBe(413); - expect(yield* Effect.promise(() => response.json())).toMatchObject({ - code: "request.too-large", - }); - expect(state.settlementCommands).toHaveLength(0); - }), - ); - - it.live("records private immutable settlement evidence with revision and idempotent replay", () => + it.live("records immutable settlement evidence with revision and idempotent replay", () => Effect.gen(function* () { const queueRow = pendingReceipt({ status: "Approved", @@ -1029,103 +929,69 @@ describe("receipt v0.2 HTTP contract", () => { const evidence = settlementEvidence({ receiptRevision: 3 }); const state = harness({ settlementRows: [queueRow], settlementEvidence: evidence }); + const client = yield* state.client; - const payload = { + const request = { expectedRevision: 2, externalAuthority: evidence.externalAuthority, externalReference: evidence.externalReference, settledAt: evidence.settledAt, }; - const key = "settle-receipt-idempotency-key-0001"; - - const recorded = yield* actionRequest( - state.http, - `/api/receipts/${receiptId}:settle`, - key, - 2, - payload, - ); - - const recordedBody = yield* readJson(recorded); - - const replay = yield* actionRequest( - state.http, - `/api/receipts/${receiptId}:settle`, - key, - 2, - payload, - ); + const settle = (body: typeof request) => + withCookie( + client["receipts.settleReceipt"]({ ...transition("settleReceiptKey", 2), request: body }), + ); - const changedReplay = yield* actionRequest( - state.http, - `/api/receipts/${receiptId}:settle`, - key, - 2, - { - ...payload, - externalReference: "changed-external-reference", - }, - ); + const recorded = yield* settle(request); + const replay = yield* settle(request); - expect(changedReplay.status).toBe(409); - expect(changedReplay.headers.get("cache-control")).toBe("no-store"); - expect(yield* Effect.promise(() => changedReplay.json())).toMatchObject({ - code: "idempotency.digest-conflict", - }); + const changedReplay = yield* settle({ + ...request, + externalReference: "changed-external-reference", + }).pipe(Effect.flip); - expect(recorded.status).toBe(200); - expect(recorded.headers.get("cache-control")).toBe("private, no-store"); - expect(recorded.headers.get("vary")).toBe("Origin"); - expect(recorded.headers.get("etag")).toBe(receiptEtag(receiptId, 3)); - expect(recordedBody).toMatchObject(evidence); - expect(yield* Effect.promise(() => replay.json())).toEqual(recordedBody); + expect(codeOf(changedReplay)).toBe("idempotency.digest-conflict"); + expect(recorded).toMatchObject(evidence); + expect(replay).toEqual(recorded); expect(state.settlementCommands).toHaveLength(1); }), ); - it.live("reads finance settlement evidence privately and conceals a missing settlement", () => + it.live("reads finance settlement evidence and conceals a missing settlement", () => Effect.gen(function* () { const evidence = settlementEvidence(); const state = harness({ settlementEvidence: evidence }); + const client = yield* state.client; - const visible = yield* request( - state.http, - `/api/receipt-settlement-queue/${encodeURIComponent(receiptId)}`, + const visible = yield* withCookie( + client["receipts.readReceiptSettlementForFinance"]({ receiptId }), ); - const concealed = yield* request(state.http, "/api/receipt-settlement-queue/not-visible"); + const concealed = yield* withCookie( + client["receipts.readReceiptSettlementForFinance"]({ + receiptId: ReceiptId.make("not-visible"), + }), + ).pipe(Effect.flip); - expect(visible.status).toBe(200); - expect(visible.headers.get("cache-control")).toBe("private, no-store"); - expect(yield* readJson(visible)).toMatchObject(evidence); + expect(visible).toMatchObject(evidence); expect(state.settlementReads()).toEqual([ { receiptId, personId }, { receiptId: ReceiptId.make("not-visible"), personId }, ]); - expect(concealed.status).toBe(404); - expect(concealed.headers.get("cache-control")).toBe("no-store"); - expect(yield* Effect.promise(() => concealed.json())).toMatchObject({ - code: "receipt.not-found", - }); + expect(codeOf(concealed)).toBe("receipt.not-found"); }), ); - it.live("returns the frozen RFC 9457 credential problem", () => + it.live("answers credential.missing without a credential", () => Effect.gen(function* () { - const response = yield* request( - harness({ unauthenticated: true }).http, - "/api/receipts", - undefined, - false, - ); + const client = yield* harness({ unauthenticated: true }).client; + const failure = yield* client["receipts.listReceipts"]({}).pipe(Effect.flip); - yield* expectProblem(response, { - code: "credential.missing", - title: "Credential required", - status: 401, - detail: "A credential is required for this operation.", - }); + expect(isProblem(failure) && [failure.code, failure.status]).toEqual([ + "credential.missing", + 401, + ]); }), ); @@ -1235,47 +1101,120 @@ describe("receipt v0.2 HTTP contract", () => { }), ); - it.live("preserves exact 201 and 200 statuses through the public HTTP replay path", () => + it.live("replays a submission and a withdrawal with their first answers", () => Effect.gen(function* () { const submitState = harness(); - const submitted = yield* submitRequest(submitState.http, "submit-http-replay-key-0001"); - const submittedBody = yield* readJson(submitted); - expect( - (yield* Schema.decodeUnknownEffect(ReceiptResource)(submittedBody)).approvedAt, - ).toBeNull(); - const submitReplay = yield* submitRequest(submitState.http, "submit-http-replay-key-0001"); - expect(submitted.status).toBe(201); - expect(submitReplay.status).toBe(201); - expect(yield* Effect.promise(() => submitReplay.json())).toEqual(submittedBody); - expect(submitReplay.headers.get("location")).toBe(submitted.headers.get("location")); - expect(submitReplay.headers.get("etag")).toBe(submitted.headers.get("etag")); - expect(submitted.headers.get("cache-control")).toBe("no-store"); - expect(submitReplay.headers.get("cache-control")).toBe("no-store"); + const submitClient = yield* submitState.client; + + const submit = withCookie( + submitClient["receipts.submitReceipt"]({ + idempotencyKey: key("submitReplayKey"), + request: submission, + }), + ); + + const submitted = yield* submit; + expect(submitted.approvedAt).toBeNull(); + expect(submitted.etag).toBe(receiptEtag(receiptId, 0)); + expect(yield* submit).toEqual(submitted); expect(submitState.commands).toHaveLength(1); + // The receipt stores the HTTP capsule, so a replay across the cutover answers the same. + const stored = yield* submitState.run( + Database.use( + (sql) => + sql<{ readonly status: number; readonly headers: unknown }>` + SELECT status, headers_json AS headers FROM public.native_http_idempotency_receipts + `, + ), + ); + + expect(stored).toEqual([ + { + status: 201, + headers: { + "content-type": "application/json", + etag: receiptEtag(receiptId, 0), + location: `/api/receipts/${receiptId}`, + }, + }, + ]); + const actionState = harness({ ownedRows: [pendingReceipt()] }); + const actionClient = yield* actionState.client; - const withdrawn = yield* actionRequest( - actionState.http, - `/api/receipts/${receiptId}:withdraw`, - "withdraw-http-replay-key-0001", + const withdraw = withCookie( + actionClient["receipts.withdrawReceipt"](transition("withdrawReplayKey")), ); - const withdrawnBody = yield* readJson(withdrawn); + const withdrawn = yield* withdraw; + expect(withdrawn.status).toBe("Withdrawn"); + expect(withdrawn.etag).toBe(receiptEtag(receiptId, 1)); + expect(yield* withdraw).toEqual(withdrawn); + expect(actionState.commands).toHaveLength(1); + }), + ); + + it.live("checks the upload bounds of the multipart reader before any transaction", () => + Effect.gen(function* () { + const state = harness({ ownedRows: [pendingReceipt()] }); + const client = yield* state.client; + + const submit = (request: typeof submission, departmentId?: string) => + withCookie( + client["receipts.submitReceipt"]({ + idempotencyKey: key("submitBounds"), + departmentId, + request, + }), + ).pipe(Effect.flip, Effect.map(codeOf)); + + const rejected = [ + yield* submit({ ...submission, file: { contentType: "image/gif", bytes: pngBytes } }), + yield* submit({ + ...submission, + file: { contentType: "image/png", bytes: new Uint8Array(1025) }, + }), + yield* submit({ + ...submission, + file: { contentType: "image/png", bytes: new Uint8Array() }, + }), + yield* submit({ ...submission, description: "" }), + yield* submit({ ...submission, amountOre: 12.5 }), + yield* submit({ ...submission, amountOre: 0 }), + yield* submit({ ...submission, receiptDate: "2026-02-30" }), + yield* submit(submission, " "), + ]; + + const revision = yield* withCookie( + client["receipts.reviseReceipt"]({ ...transition("reviseNothing"), request: {} }), + ).pipe(Effect.flip, Effect.map(codeOf)); + + expect([...rejected, revision]).toEqual(Array(9).fill("validation.failed")); + expect(state.authorizationChecks()).toEqual([]); + expect(yield* state.nativeReceiptCount()).toBe(0); + }), + ); + + it.live("revises a pending receipt with a replacement file", () => + Effect.gen(function* () { + const state = harness({ ownedRows: [pendingReceipt()] }); + const client = yield* state.client; - const withdrawReplay = yield* actionRequest( - actionState.http, - `/api/receipts/${receiptId}:withdraw`, - "withdraw-http-replay-key-0001", + const revised = yield* withCookie( + client["receipts.reviseReceipt"]({ + ...transition("reviseWithFile"), + request: { amountOre: 1500, file: { contentType: "application/pdf", bytes: pngBytes } }, + }), ); - expect(withdrawn.status).toBe(200); - expect(withdrawReplay.status).toBe(200); - expect(withdrawn.headers.get("cache-control")).toBe("no-store"); - expect(withdrawReplay.headers.get("cache-control")).toBe("no-store"); - expect(yield* Effect.promise(() => withdrawReplay.json())).toEqual(withdrawnBody); - expect(withdrawReplay.headers.get("etag")).toBe(withdrawn.headers.get("etag")); - expect(actionState.commands).toHaveLength(1); + expect(revised).toMatchObject({ amountOre: 1500, revision: 1 }); + expect(Predicate.isTagged(state.commands[0], "RevisePendingReceipt")).toBe(true); + expect(state.commands[0]).toMatchObject({ + amountOre: 1500, + description: "bus ticket", + file: { fileRef: "staging/file-one" }, + }); }), ); @@ -1284,15 +1223,16 @@ describe("receipt v0.2 HTTP contract", () => { () => Effect.gen(function* () { const state = harness({ ownedRows: [pendingReceipt()] }); - const pathname = `/api/receipts/${receiptId}:withdraw`; - const idempotencyKey = "withdraw-current-auth-key-0001"; + const client = yield* state.client; - const accepted = yield* actionRequest(state.http, pathname, idempotencyKey); - expect(accepted.status).toBe(200); + const withdraw = withCookie( + client["receipts.withdrawReceipt"](transition("withdrawCurrentAuth")), + ); + + yield* withdraw; expect(yield* state.snapshotObservations()).toEqual({ identitySnapshotIsolations: ["serializable"], identitySnapshotVersions: [1], - committedVersion: 1, }); const committedTransactions = yield* state.mutationTransactions(); @@ -1300,116 +1240,66 @@ describe("receipt v0.2 HTTP contract", () => { expect(committedTransactions.receiptWriteTransactionIds).toEqual( committedTransactions.commandTransactionIds, ); - expect(state.authorizationPrincipalCalls()).toBe(0); yield* state.revokeCredential(); - const revokedReplay = yield* actionRequest(state.http, pathname, idempotencyKey); - expect(revokedReplay.status).toBe(401); + const revokedReplay = yield* withdraw.pipe(Effect.flip); + expect(codeOf(revokedReplay)).toBe("credential.invalid"); expect(state.commands).toHaveLength(1); expect(yield* state.mutationTransactions()).toEqual(committedTransactions); expect(yield* state.snapshotObservations()).toEqual({ identitySnapshotIsolations: ["serializable", "serializable"], identitySnapshotVersions: [1, 2], - committedVersion: 2, }); }), ); - it.live( - "denies a matching owner replay after owner authority revocation without another transition", - () => - Effect.gen(function* () { - const state = harness({ ownedRows: [pendingReceipt()] }); - const pathname = `/api/receipts/${receiptId}:withdraw`; - const idempotencyKey = "withdraw-owner-revocation-key-0001"; + it.live("denies a matching replay after authority revocation without another transition", () => + Effect.gen(function* () { + const cases = [ + ["withdraw", "Owner", pendingReceipt(), "WithdrawPendingReceipt"], + ["approve", "Approval", pendingReceipt(), "ApproveReceipt"], + ["reopen", "Approval", pendingReceipt({ status: "Rejected" }), "ReopenRejectedReceipt"], + ] as const; - const accepted = yield* actionRequest(state.http, pathname, idempotencyKey); - expect(accepted.status).toBe(200); - expect(state.commands).toHaveLength(1); - expect(state.authorizationChecks()).toEqual(["WithdrawPendingReceipt"]); + for (const [action, authority, row, target] of cases) { + const state = harness( + authority === "Owner" ? { ownedRows: [row] } : { approvalRows: [row] }, + ); - const committedTransactions = yield* state.mutationTransactions(); - state.revokeAuthority("Owner"); - const revokedReplay = yield* actionRequest(state.http, pathname, idempotencyKey); - yield* expectProblem(revokedReplay, { - code: "authority.denied", - title: "Authority denied", - status: 403, - detail: "The authenticated principal is not permitted to perform this operation.", - }); - expect(state.authorizationChecks()).toEqual([ - "WithdrawPendingReceipt", - "WithdrawPendingReceipt", - ]); - expect(state.commands).toHaveLength(1); - expect(yield* state.nativeReceiptCount()).toBe(1); - expect(yield* state.mutationTransactions()).toEqual(committedTransactions); - }), - ); + const client = yield* state.client; + const payload = transition(`${action}Revocation`); - it.live( - "denies a matching approval replay after grant revocation without another transition", - () => - Effect.gen(function* () { - const state = harness({ approvalRows: [pendingReceipt()] }); - const pathname = `/api/receipts/${receiptId}:approve`; - const idempotencyKey = "approve-approval-revocation-key-0001"; + const command = withCookie( + Match.value(action).pipe( + Match.when("withdraw", () => client["receipts.withdrawReceipt"](payload)), + Match.when("approve", () => client["receipts.approveReceipt"](payload)), + Match.when("reopen", () => client["receipts.reopenReceipt"](payload)), + Match.exhaustive, + ), + ); - const accepted = yield* actionRequest(state.http, pathname, idempotencyKey); - expect(accepted.status).toBe(200); + yield* command; expect(state.commands).toHaveLength(1); - expect(state.authorizationChecks()).toEqual(["ApproveReceipt"]); + expect(state.authorizationChecks()).toEqual([target]); const committedTransactions = yield* state.mutationTransactions(); - state.revokeAuthority("Approval"); - const revokedReplay = yield* actionRequest(state.http, pathname, idempotencyKey); - yield* expectProblem(revokedReplay, { - code: "authority.denied", - title: "Authority denied", - status: 403, - detail: "The authenticated principal is not permitted to perform this operation.", - }); - expect(state.authorizationChecks()).toEqual(["ApproveReceipt", "ApproveReceipt"]); - expect(state.commands).toHaveLength(1); - expect(yield* state.nativeReceiptCount()).toBe(1); - expect(yield* state.mutationTransactions()).toEqual(committedTransactions); - }), - ); + state.revokeAuthority(authority); + const revokedReplay = yield* command.pipe(Effect.flip); - it.live( - "denies a matching reopening replay after grant revocation without another transition", - () => - Effect.gen(function* () { - const state = harness({ approvalRows: [{ ...pendingReceipt(), status: "Rejected" }] }); - const pathname = `/api/receipts/${receiptId}:reopen`; - const idempotencyKey = "reopen-approval-revocation-key-0001"; - - const accepted = yield* actionRequest(state.http, pathname, idempotencyKey); - expect(accepted.status).toBe(200); - expect(state.commands).toHaveLength(1); - expect(state.authorizationChecks()).toEqual(["ReopenRejectedReceipt"]); - - const committedTransactions = yield* state.mutationTransactions(); - state.revokeAuthority("Approval"); - const revokedReplay = yield* actionRequest(state.http, pathname, idempotencyKey); - yield* expectProblem(revokedReplay, { - code: "authority.denied", - title: "Authority denied", - status: 403, - detail: "The authenticated principal is not permitted to perform this operation.", - }); - expect(state.authorizationChecks()).toEqual([ - "ReopenRejectedReceipt", - "ReopenRejectedReceipt", + expect(isProblem(revokedReplay) && [revokedReplay.code, revokedReplay.status]).toEqual([ + "authority.denied", + 403, ]); + expect(state.authorizationChecks()).toEqual([target, target]); expect(state.commands).toHaveLength(1); expect(yield* state.nativeReceiptCount()).toBe(1); expect(yield* state.mutationTransactions()).toEqual(committedTransactions); - }), + } + }), ); - it.live("registers and executes only the exact frozen action suffixes", () => + it.live("executes each transition as its own command", () => Effect.gen(function* () { const actions = [ ["withdraw", "WithdrawPendingReceipt"], @@ -1419,118 +1309,109 @@ describe("receipt v0.2 HTTP contract", () => { for (const [action, commandTag] of actions) { const state = harness({ ownedRows: [pendingReceipt()], approvalRows: [pendingReceipt()] }); - - const exact = yield* actionRequest( - state.http, - `/api/receipts/${receiptId}:${action}`, - `${action}-receipt-idempotency-key`, + const client = yield* state.client; + const payload = transition(`${action}ReceiptKey`); + + const answered = yield* withCookie( + Match.value(action).pipe( + Match.when("withdraw", () => client["receipts.withdrawReceipt"](payload)), + Match.when("approve", () => client["receipts.approveReceipt"](payload)), + Match.when("reject", () => client["receipts.rejectReceipt"](payload)), + Match.exhaustive, + ), ); - expect(exact.status).toBe(200); - expect( - (yield* Schema.decodeUnknownEffect(ReceiptResource)( - yield* Effect.promise(() => exact.json()), - )).approvedAt, - ).toBe(action === "approve" ? "2026-08-24T12:00:00.000Z" : null); + expect(answered.approvedAt).toBe(action === "approve" ? "2026-08-24T12:00:00.000Z" : null); expect(state.commands).toHaveLength(1); - { - const observed = state.commands[0]; - expect(observed).toHaveProperty("_tag", commandTag); - expect(observed).toMatchObject({ receiptId, expectedRevision: 0 }); - } - + expect(state.commands[0]).toMatchObject({ + _tag: commandTag, + receiptId, + expectedRevision: 0, + }); expect(state.commands[0]?.commandId).toMatch(/^httpv2_[A-Za-z0-9_-]{43}$/u); } - const aliases = harness({ ownedRows: [pendingReceipt()], approvalRows: [pendingReceipt()] }); - expect( - (yield* actionRequest( - aliases.http, - `/api/receipts/${receiptId}/withdraw`, - "slash-alias-key", - )).status, - ).toBe(404); - expect( - (yield* actionRequest( - aliases.http, - `/api/admin/receipts/${receiptId}/refund`, - "admin-alias-key", - )).status, - ).toBe(404); + // The internal evidence RPC is not part of the external contract. + expect(NativeRpcs.requests.has("receipts.readReceiptEvidence")).toBe(false); + expect(InternalNativeRpcs.requests.has("receipts.readReceiptEvidence")).toBe(true); }), ); - it.live("returns RFC 9457 malformed-request problems for manual and schema query failures", () => + it.live("keeps the HTTP route as the normalized target of each command identity", () => Effect.gen(function* () { - for (const pathname of [ - "/api/receipts?status=Pending&status=Approved", - "/api/receipts?status=Unknown", - "/api/receipt-approval-queue?status=Pending&unexpected=1", - "/api/receipt-approval-queue?status=Unknown", - ]) { - const response = yield* request(harness().http, pathname); - yield* expectProblem(response, { - code: "request.malformed", - title: "Malformed request", - status: 400, - detail: "The request is malformed.", - }); - } + const state = harness({ approvalRows: [pendingReceipt()] }); + const client = yield* state.client; + + yield* withCookie(client["receipts.approveReceipt"](transition("approveTargetKey"))); + + const derived = deriveHttpIdentity({ + credentialSubject: `Person:${personId}`, + qualifiedOperationId: "receipts.approveReceipt", + normalizedTarget: `/api/receipts/${receiptId}/approve`, + idempotencyKey: key("approveTargetKey"), + }); + + expect(state.commands[0]?.commandId).toBe(derived.commandId); }), ); - it.live("rejects query parameters on semantic receipt commands before execution", () => + it.live("answers a stale If-Match with precondition.failed before any transition", () => Effect.gen(function* () { - const state = harness(); + const state = harness({ approvalRows: [pendingReceipt({ revision: 1 })] }); + const client = yield* state.client; - const response = yield* actionRequest( - state.http, - `/api/receipts/${receiptId}:approve?unexpected=1`, - "approve-query-rejected-0109", - ); + const stale = yield* withCookie( + client["receipts.approveReceipt"](transition("approveStaleKey", 0)), + ).pipe(Effect.flip); - yield* expectProblem(response, { - code: "request.malformed", - title: "Malformed request", - status: 400, - detail: "The request is malformed.", - }); + expect(codeOf(stale)).toBe("precondition.failed"); expect(state.commands).toEqual([]); }), ); - it.live( - "projects receipt persistence failure through the reopening endpoint's declared problem schema", - () => - Effect.gen(function* () { - const state = harness({ - approvalRows: [{ ...pendingReceipt(), status: "Rejected" }], - commandFailure: new ReceiptPersistenceError({ - operation: "synthetic", - message: "private SQL details", - }), - }); + it.live("answers receipts.unavailable for a failed reopening without its private detail", () => + Effect.gen(function* () { + const state = harness({ + approvalRows: [pendingReceipt({ status: "Rejected" })], + commandFailure: ReceiptPersistenceError.make({ + operation: "synthetic", + message: "private SQL details", + }), + }); - const response = yield* actionRequest( - state.http, - `/api/receipts/${receiptId}:reopen`, - "reopen-failed-command-0102", - ); + const client = yield* state.client; - expect(response.status, yield* Effect.promise(() => response.clone().text())).toBe(503); + const failure = yield* withCookie( + client["receipts.reopenReceipt"](transition("reopenFailedCommand")), + ).pipe(Effect.flip); - const body = yield* Schema.decodeUnknownEffect(ReceiptsReopenReceiptProblem)( - yield* Effect.promise(() => response.json()), - ); + expect(codeOf(failure)).toBe("receipts.unavailable"); + expect(yield* jsonText(failure)).not.toContain("private SQL details"); + expect(yield* state.nativeReceiptCount()).toBe(0); + }), + ); - expect(body).toMatchObject({ code: "receipts.unavailable" }); - expect(yield* jsonText(body)).not.toContain("private SQL details"); - expect(yield* state.nativeReceiptCount()).toBe(0); - }), + it.live("routes an E2E probe header of the HTTP request to the approval barrier", () => + Effect.gen(function* () { + // A probe that names another lane is a defect of the driver, answered internal.error. + const state = harness({ + approvalRows: [pendingReceipt()], + transportHeaders: { [RECEIPT_E2E_CONCURRENCY_REQUEST_HEADER]: "reject" }, + }); + + const client = yield* state.client; + + const failure = yield* withCookie( + client["receipts.approveReceipt"](transition("approveProbeKey")), + ).pipe(Effect.flip); + + expect(codeOf(failure)).toBe("internal.error"); + expect(state.commands).toEqual([]); + }), ); it.live( - "service-principal queue items expose the same entity condition consumed by person decisions", + "uses the queue's entity tag for approval and reopening, from a person or a service", () => Effect.gen(function* () { for (const action of ["approve", "reopen"] as const) { @@ -1574,206 +1455,167 @@ describe("receipt v0.2 HTTP contract", () => { }, }); - const listed = yield* request( - service.http, - `/api/receipt-approval-queue?status=${receipt.status}`, - { headers: { authorization: `Bearer ${serviceBearer}` } }, - false, + const serviceClient = yield* service.client; + + const serviceQueue = yield* withBearer( + serviceClient["receipts.listReceiptsForApproval"]({ status: receipt.status }), + serviceBearer, ); - expect(listed.status).toBe(200); + expect(serviceQueue.items[0]?.etag).toBe(receiptEtag(receiptId, 2)); - const body = yield* Schema.decodeUnknownEffect(ReceiptApprovalQueueResponse)( - yield* Effect.promise(() => listed.json()), + const person = harness({ approvalRows: [receipt] }); + const personClient = yield* person.client; + + const personQueue = yield* withCookie( + personClient["receipts.listReceiptsForApproval"]({ status: receipt.status }), ); - const item = body.items[0]!; - expect(item.etag).toBe(receiptEtag(receiptId, 2)); - const person = harness({ approvalRows: [receipt] }); + const etag = personQueue.items[0]?.etag ?? StrongETag.make('"missing"'); + expect(etag).toBe(receiptEtag(receiptId, 2)); - const accepted = yield* request(person.http, `/api/receipts/${receiptId}:${action}`, { - method: "POST", - headers: { - "content-type": "application/json", - "idempotency-key": `${action}-service-queue0102`, - "if-match": item.etag, - }, - body: "{}", - }); + const payload = { + receiptId, + idempotencyKey: key(`${action}FromQueue`), + ifMatch: etag, + }; - expect(accepted.status).toBe(200); + yield* withCookie( + action === "approve" + ? personClient["receipts.approveReceipt"](payload) + : personClient["receipts.reopenReceipt"](payload), + ); + + expect(person.commands).toHaveLength(1); } }), ); - it.live( - "authenticates scoped service bearers at HTTP ingress without widening person access", - () => - Effect.gen(function* () { - const scoped = pendingReceipt(); + it.live("authenticates scoped service bearers without widening person access", () => + Effect.gen(function* () { + const scoped = pendingReceipt(); - const excluded = pendingReceipt({ - receiptId: ReceiptId.make("receipt-other"), - visualId: ReceiptVisualId.make("visual-other"), - }); + const excluded = pendingReceipt({ + receiptId: ReceiptId.make("receipt-other"), + visualId: ReceiptVisualId.make("visual-other"), + }); - const grant = makeServicePrincipalReceiptGrant({ - grantId: "service-scoped", - servicePrincipalId: "service0102", - clientId: "client0102", - protectedResource: NATIVE_API_PROTECTED_RESOURCE, - operationId: RECEIPT_APPROVAL_QUEUE_OPERATION, - capabilityId: "approveReceipt", - resourceKind: "receipt", - receiptId, - startAt: "2026-01-01T00:00:00Z", - endAt: null, - revokedAt: null, - revision: 0, - }); + const grant = makeServicePrincipalReceiptGrant({ + grantId: "service-scoped", + servicePrincipalId: "service0102", + clientId: "client0102", + protectedResource: NATIVE_API_PROTECTED_RESOURCE, + operationId: RECEIPT_APPROVAL_QUEUE_OPERATION, + capabilityId: "approveReceipt", + resourceKind: "receipt", + receiptId, + startAt: "2026-01-01T00:00:00Z", + endAt: null, + revokedAt: null, + revision: 0, + }); - const candidate = (row: ProjectionRow, currentGrant: typeof grant) => ({ - grant: currentGrant, - receipt: { - ...row, - receiptId: Schema.decodeSync(ReceiptResource.fields.receiptId)(row.receiptId), - visualId: Schema.decodeSync(ReceiptResource.fields.visualId)(row.visualId), - ownerPersonId: personId, - }, - }); + const candidate = (row: ProjectionRow, currentGrant: typeof grant) => ({ + grant: currentGrant, + receipt: { + ...row, + receiptId: Schema.decodeSync(ReceiptResource.fields.receiptId)(row.receiptId), + visualId: Schema.decodeSync(ReceiptResource.fields.visualId)(row.visualId), + ownerPersonId: personId, + }, + }); - const serviceApproval = { - servicePrincipalId: grant.servicePrincipalId, - clientId: grant.clientId, - protectedResource: NATIVE_API_PROTECTED_RESOURCE, - candidates: [ - candidate(scoped, grant), - candidate( - excluded, - makeServicePrincipalReceiptGrant({ - ...grant, - grantId: "service-revoked", - receiptId: ReceiptId.make("receipt-other"), - revokedAt: evaluatedAt, - }), - ), - ], - rules: [], - } satisfies ServicePrincipalReceiptGrantAuthority; - - const state = harness({ serviceApproval, approvalRows: [scoped] }); - const bearer = { headers: { authorization: `Bearer ${serviceBearer}` } }; - const listed = yield* request(state.http, "/api/receipt-approval-queue", bearer, false); - expect(listed.status).toBe(200); - expect(yield* readJson(listed)).toMatchObject({ - items: [{ receiptId }], - }); - expect(state.authorizationPrincipalCalls()).toBe(0); - const serviceOnPersonOnly = yield* request(state.http, "/api/receipts", bearer, false); - expect(serviceOnPersonOnly.status).toBe(401); + const serviceApproval = { + servicePrincipalId: grant.servicePrincipalId, + clientId: grant.clientId, + protectedResource: NATIVE_API_PROTECTED_RESOURCE, + candidates: [ + candidate(scoped, grant), + candidate( + excluded, + makeServicePrincipalReceiptGrant({ + ...grant, + grantId: "service-revoked", + receiptId: ReceiptId.make("receipt-other"), + revokedAt: evaluatedAt, + }), + ), + ], + rules: [], + } satisfies ServicePrincipalReceiptGrantAuthority; - const unscoped = harness({ - serviceApproval: { ...serviceApproval, candidates: [serviceApproval.candidates[1]!] }, - }); + const state = harness({ serviceApproval, approvalRows: [scoped] }); + const client = yield* state.client; + const queue = client["receipts.listReceiptsForApproval"]({}); - const denied = yield* request(unscoped.http, "/api/receipt-approval-queue", bearer, false); - expect(denied.status).toBe(403); - yield* expectProblem(denied, { - code: "authority.denied", - title: "Authority denied", - status: 403, - detail: "The authenticated principal is not permitted to perform this operation.", - }); - const mixed = yield* request(state.http, "/api/receipt-approval-queue", bearer); - expect(mixed.status).toBe(401); - state.revokeServiceBearer(); - const revoked = yield* request(state.http, "/api/receipt-approval-queue", bearer, false); - expect(revoked.status).toBe(401); - const person = harness({ approvalRows: [scoped] }); - const human = yield* request(person.http, "/api/receipt-approval-queue"); - expect(human.status).toBe(200); - expect(yield* readJson(human)).toMatchObject({ items: [{ receiptId }] }); - - const userBearer = yield* request( - state.http, - "/api/receipt-approval-queue", - { headers: { authorization: `Bearer ${personBearer}` } }, - false, - ); + expect(yield* withBearer(queue, serviceBearer)).toMatchObject({ + items: [{ receiptId }], + }); + expect(state.approvalQueries).toEqual([]); - expect(userBearer.status).toBe(200); - expect(yield* readJson(userBearer)).toMatchObject({ items: [{ receiptId }] }); - }), - ); + const serviceOnPersonOnly = yield* withBearer( + client["receipts.listReceipts"]({}), + serviceBearer, + ).pipe(Effect.flip); - it.live("uses the queue's canonical receipt ETag for approval and reopening commands", () => - Effect.gen(function* () { - for (const action of ["approve", "reopen"] as const) { - const row = pendingReceipt({ - status: action === "reopen" ? "Rejected" : "Pending", - revision: 2, - }); + expect(codeOf(serviceOnPersonOnly)).toBe("credential.invalid"); - const state = harness({ approvalRows: [row] }); + const unscoped = harness({ + serviceApproval: { ...serviceApproval, candidates: [serviceApproval.candidates[1]!] }, + }); - const listed = yield* request( - state.http, - `/api/receipt-approval-queue?status=${row.status}`, - ); + const unscopedClient = yield* unscoped.client; - const body = yield* Schema.decodeUnknownEffect(ReceiptApprovalQueueResponse)( - yield* Effect.promise(() => listed.json()), - ); + const denied = yield* withBearer( + unscopedClient["receipts.listReceiptsForApproval"]({}), + serviceBearer, + ).pipe(Effect.flip); - const item = body.items[0]!; - expect(item.etag).toBe(receiptEtag(receiptId, 2)); + expect(codeOf(denied)).toBe("authority.denied"); - const accepted = yield* request(state.http, `/api/receipts/${receiptId}:${action}`, { - method: "POST", - headers: { - "content-type": "application/json", - "idempotency-key": `${action}-from-queue-0102`, - "if-match": item.etag, - }, - body: "{}", - }); + const mixed = yield* RpcClient.withHeaders(queue, { + cookie: sessionCookie, + authorization: `Bearer ${serviceBearer}`, + }).pipe(Effect.flip); - expect(accepted.status).toBe(200); - } + expect(codeOf(mixed)).toBe("credential.invalid"); + state.revokeServiceBearer(); + expect(codeOf(yield* withBearer(queue, serviceBearer).pipe(Effect.flip))).toBe( + "credential.invalid", + ); + + const person = harness({ approvalRows: [scoped] }); + const personClient = yield* person.client; + + expect(yield* withCookie(personClient["receipts.listReceiptsForApproval"]({}))).toMatchObject( + { items: [{ receiptId }] }, + ); + + expect(yield* withBearer(queue, personBearer)).toMatchObject({ items: [{ receiptId }] }); }), ); - it.live( - "uses the frozen approval-queue path and passes one canonical authorization instant", - () => - Effect.gen(function* () { - const row = pendingReceipt({ revision: 2 }); - const state = harness({ approvalRows: [row] }); - const response = yield* request(state.http, "/api/receipt-approval-queue?status=Pending"); - const body = yield* readJson(response); - - expect(response.status).toBe(200); - expect(body).toMatchObject({ - items: [ - { - receiptId, - amountOre: 1200, - status: "Pending", - revision: 2, - }, - ], - }); - expect(state.approvalQueries).toEqual([ - { personId, authorizationInstant: evaluatedAt, status: "Pending" }, - ]); - expect((yield* request(harness().http, "/api/admin/receipts")).status).toBe(404); - }), + it.live("passes one canonical authorization instant to the approval queue", () => + Effect.gen(function* () { + const row = pendingReceipt({ revision: 2 }); + const state = harness({ approvalRows: [row] }); + const client = yield* state.client; + + const body = yield* withCookie( + client["receipts.listReceiptsForApproval"]({ status: "Pending" }), + ); + + expect(body).toMatchObject({ + items: [{ receiptId, amountOre: 1200, status: "Pending", revision: 2 }], + }); + expect(state.approvalQueries).toEqual([ + { personId, authorizationInstant: evaluatedAt, status: "Pending" }, + ]); + }), ); }); -describe("internal receipt evidence separation", () => { - const internalPath = `/api/receipt-lifecycle-evidence-records/${receiptId}`; - +describe("internal receipt evidence", () => { const evidence: ReceiptLifecycleEvidenceProjection = { receiptId, file: { @@ -1788,7 +1630,7 @@ describe("internal receipt evidence separation", () => { audit: [], }; - it.live("registers internal.readReceiptEvidence only on the internal Cookie surface", () => + it.live("reads evidence with the Cookie credential on the internal surface only", () => Effect.gen(function* () { const state = harness({ evidenceAccessRows: [ @@ -1802,61 +1644,47 @@ describe("internal receipt evidence separation", () => { evidenceResult: evidence, }); - expect((yield* request(state.http, internalPath)).status).toBe(404); - expect((yield* request(state.internalHttp, "/api/receipts")).status).toBe(404); + const client = yield* state.internalClient; - const bearerOnly = yield* request( - state.internalHttp, - internalPath, - { headers: { authorization: "Bearer service-token" } }, - false, - ); + const bearerOnly = yield* withBearer( + client["receipts.readReceiptEvidence"]({ receiptId }), + "service-token", + ).pipe(Effect.flip); - expect(bearerOnly.headers.get("www-authenticate")).toBe('VektorSession realm="native-api"'); - yield* expectProblem(bearerOnly, { - code: "credential.invalid", - title: "Invalid credential", - status: 401, - detail: "The supplied credential is invalid.", - }); + expect(codeOf(bearerOnly)).toBe("credential.invalid"); - const response = yield* request(state.internalHttp, internalPath); - expect({ - status: response.status, - body: yield* Effect.promise(() => response.json()), - }).toEqual({ - status: 200, - body: evidence, - }); + expect(yield* withCookie(client["receipts.readReceiptEvidence"]({ receiptId }))).toEqual( + evidence, + ); }), ); it.live("does not read receipt state when the internal Cookie credential is unavailable", () => Effect.gen(function* () { const missing = harness({ unauthenticated: true }); - const missingResponse = yield* request(missing.internalHttp, internalPath); - yield* expectProblem(missingResponse, { - code: "credential.invalid", - title: "Invalid credential", - status: 401, - detail: "The supplied credential is invalid.", - }); + const missingClient = yield* missing.internalClient; + + const missingFailure = yield* withCookie( + missingClient["receipts.readReceiptEvidence"]({ receiptId }), + ).pipe(Effect.flip); + + expect(codeOf(missingFailure)).toBe("credential.invalid"); expect(missing.evidenceReads).toEqual([]); const unavailable = harness({ - identitySnapshotFailure: new IdentityEngineError({ + identitySnapshotFailure: IdentityEngineError.make({ operation: "resolveSnapshotSession", message: "database unavailable", }), }); - const unavailableResponse = yield* request(unavailable.internalHttp, internalPath); - yield* expectProblem(unavailableResponse, { - code: "receipts.unavailable", - title: "Receipts unavailable", - status: 503, - detail: "The receipt service is temporarily unavailable.", - }); + const unavailableClient = yield* unavailable.internalClient; + + const unavailableFailure = yield* withCookie( + unavailableClient["receipts.readReceiptEvidence"]({ receiptId }), + ).pipe(Effect.flip); + + expect(codeOf(unavailableFailure)).toBe("receipts.unavailable"); expect(unavailable.evidenceReads).toEqual([]); }), ); @@ -1866,180 +1694,122 @@ describe("private receipt owner reads", () => { it.live("uses canonical owner authorization before binary storage IO", () => Effect.gen(function* () { const owner = harness({ privateFileOwner: personId }); + const ownerClient = yield* owner.client; + const privateId = ReceiptId.make("private"); - const response = yield* owner.http.fetch( - new Request("http://localhost/api/receipts/private/file", { - headers: { cookie: "better-auth.session_token=fixture" }, - }), + const file = yield* withCookie( + ownerClient["receipts.readReceiptFile"]({ receiptId: privateId }), ); - expect(response.status).toBe(200); - expect([...new Uint8Array(yield* Effect.promise(() => response.arrayBuffer()))]).toEqual([ - 1, 2, 3, 4, - ]); - expect({ - contentType: response.headers.get("content-type"), - contentLength: response.headers.get("content-length"), - contentDisposition: response.headers.get("content-disposition"), - contentTypeOptions: response.headers.get("x-content-type-options"), - cacheControl: response.headers.get("cache-control"), - vary: response.headers.get("vary"), - }).toEqual({ + expect({ contentType: file.contentType, bytes: [...file.bytes] }).toEqual({ contentType: "application/pdf", - contentLength: "4", - contentDisposition: 'inline; filename="receipt.pdf"', - contentTypeOptions: "nosniff", - cacheControl: "private, no-store", - vary: "Origin", + bytes: [1, 2, 3, 4], }); expect(owner.privateFileReads()).toBe(1); + const foreign = harness({ privateFileOwner: "different-person" }); + const foreignClient = yield* foreign.client; - const denied = yield* foreign.http.fetch( - new Request("http://localhost/api/receipts/private/file", { - headers: { cookie: "better-auth.session_token=fixture" }, - }), - ); + const denied = yield* withCookie( + foreignClient["receipts.readReceiptFile"]({ receiptId: privateId }), + ).pipe(Effect.flip); - expect(denied.status).toBe(404); + expect(codeOf(denied)).toBe("resource.not-found"); expect(foreign.privateFileReads()).toBe(0); }), ); - it.live("does not expose a successful attachment for unavailable private bytes", () => + + it.live("answers receipts.unavailable for unavailable private bytes", () => Effect.gen(function* () { const owner = harness({ privateFileOwner: personId, privateFileUnavailable: true }); + const client = yield* owner.client; - const response = yield* owner.http.fetch( - new Request("http://localhost/api/receipts/private/file", { - headers: { cookie: "better-auth.session_token=fixture" }, - }), - ); + const failure = yield* withCookie( + client["receipts.readReceiptFile"]({ receiptId: ReceiptId.make("private") }), + ).pipe(Effect.flip); - expect(response.status).toBe(503); - expect(response.headers.get("content-disposition")).toBeNull(); + expect(codeOf(failure)).toBe("receipts.unavailable"); }), ); }); describe("scoped receipt approval file reads", () => { - it.live( - "reads an active scoped terminal receipt in the credential snapshot with exact file headers", - () => - Effect.gen(function* () { - const state = harness({ - approvalFileRow: pendingReceipt({ - receiptId: ReceiptId.make("terminal-approval-file"), - status: "Approved", - }), - approvalFileContentType: "application/pdf", - }); + it.live("reads an active scoped terminal receipt in the credential snapshot", () => + Effect.gen(function* () { + const fileId = ReceiptId.make("terminal-approval-file"); - const response = yield* request( - state.http, - "/api/receipt-approval-queue/terminal-approval-file/file", - ); + const state = harness({ + approvalFileRow: pendingReceipt({ receiptId: fileId, status: "Approved" }), + approvalFileContentType: "application/pdf", + }); - expect(response.status).toBe(200); - expect([...new Uint8Array(yield* Effect.promise(() => response.arrayBuffer()))]).toEqual([ - 1, 2, 3, 4, - ]); - expect({ - contentType: response.headers.get("content-type"), - contentLength: response.headers.get("content-length"), - contentDisposition: response.headers.get("content-disposition"), - contentTypeOptions: response.headers.get("x-content-type-options"), - cacheControl: response.headers.get("cache-control"), - vary: response.headers.get("vary"), - }).toEqual({ - contentType: "application/pdf", - contentLength: "4", - contentDisposition: 'inline; filename="receipt.pdf"', - contentTypeOptions: "nosniff", - cacheControl: "private, no-store", - vary: "Origin", - }); - expect(state.approvalFileQueries()).toEqual([ - { - receiptId: ReceiptId.make("terminal-approval-file"), - personId, - authorizationInstant: evaluatedAt, - snapshotIsolation: "repeatable read", - }, - ]); - expect((yield* state.snapshotObservations()).identitySnapshotIsolations).toEqual([ - "repeatable read", - ]); - expect(state.privateFileReads()).toBe(1); - expect(state.commands).toEqual([]); - expect(yield* state.nativeReceiptCount()).toBe(0); - }), + const client = yield* state.client; + + const file = yield* withCookie( + client["receipts.readReceiptFileForApproval"]({ receiptId: fileId }), + ); + + expect({ contentType: file.contentType, bytes: [...file.bytes] }).toEqual({ + contentType: "application/pdf", + bytes: [1, 2, 3, 4], + }); + expect(state.approvalFileQueries()).toEqual([ + { + receiptId: fileId, + personId, + authorizationInstant: evaluatedAt, + snapshotIsolation: "repeatable read", + }, + ]); + expect((yield* state.snapshotObservations()).identitySnapshotIsolations).toEqual([ + "repeatable read", + ]); + expect(state.privateFileReads()).toBe(1); + expect(state.commands).toEqual([]); + expect(yield* state.nativeReceiptCount()).toBe(0); + }), ); - it.live("does not widen scoped approver access into the owner route", () => + it.live("does not widen scoped approver access into the owner read", () => Effect.gen(function* () { - const state = harness({ - approvalFileRow: pendingReceipt({ receiptId: ReceiptId.make("approver-only-file") }), - }); + const fileId = ReceiptId.make("approver-only-file"); + const state = harness({ approvalFileRow: pendingReceipt({ receiptId: fileId }) }); + const client = yield* state.client; - const approval = yield* request( - state.http, - "/api/receipt-approval-queue/approver-only-file/file", - ); + yield* withCookie(client["receipts.readReceiptFileForApproval"]({ receiptId: fileId })); - expect(approval.status).toBe(200); + const owner = yield* withCookie( + client["receipts.readReceiptFile"]({ receiptId: fileId }), + ).pipe(Effect.flip); - const owner = yield* request(state.http, "/api/receipts/approver-only-file/file"); - yield* expectProblem(owner, { - code: "resource.not-found", - title: "Resource not found", - status: 404, - detail: "The requested resource was not found.", - }); + expect(codeOf(owner)).toBe("resource.not-found"); expect(state.privateFileReads()).toBe(1); }), ); it.live("keeps credential, absence, foreign scope, and inactive authority failures typed", () => Effect.gen(function* () { + const read = (state: ReturnType, id: string, cookie: boolean) => + Effect.gen(function* () { + const client = yield* state.client; + + const call = client["receipts.readReceiptFileForApproval"]({ + receiptId: ReceiptId.make(id), + }); + + return codeOf(yield* (cookie ? withCookie(call) : call).pipe(Effect.flip)); + }); + const unauthenticated = harness({ unauthenticated: true }); - yield* expectProblem( - yield* request( - unauthenticated.http, - "/api/receipt-approval-queue/approval-file/file", - undefined, - false, - ), - { - code: "credential.missing", - title: "Credential required", - status: 401, - detail: "A credential is required for this operation.", - }, - ); + expect(yield* read(unauthenticated, "approval-file", false)).toBe("credential.missing"); expect(unauthenticated.privateFileReads()).toBe(0); const invalid = harness({ unauthenticated: true }); - yield* expectProblem( - yield* request(invalid.http, "/api/receipt-approval-queue/approval-file/file"), - { - code: "credential.invalid", - title: "Invalid credential", - status: 401, - detail: "The supplied credential is invalid.", - }, - ); + expect(yield* read(invalid, "approval-file", true)).toBe("credential.invalid"); expect(invalid.privateFileReads()).toBe(0); const missing = harness(); - yield* expectProblem( - yield* request(missing.http, "/api/receipt-approval-queue/missing-file/file"), - { - code: "resource.not-found", - title: "Resource not found", - status: 404, - detail: "The requested resource was not found.", - }, - ); + expect(yield* read(missing, "missing-file", true)).toBe("resource.not-found"); expect(missing.privateFileReads()).toBe(0); for (const approvalFileFailure of ["Scope", "Inactive"] as const) { @@ -2048,81 +1818,46 @@ describe("scoped receipt approval file reads", () => { approvalFileFailure, }); - yield* expectProblem( - yield* request(denied.http, "/api/receipt-approval-queue/foreign-file/file"), - { - code: "authority.denied", - title: "Authority denied", - status: 403, - detail: "The authenticated principal is not permitted to perform this operation.", - }, - ); + expect(yield* read(denied, "foreign-file", true)).toBe("authority.denied"); expect(denied.privateFileReads()).toBe(0); } }), ); - it.live("does not expose file headers when the approved object is unavailable", () => + it.live("answers receipts.unavailable when the object or its stored metadata is unreadable", () => Effect.gen(function* () { - const state = harness({ - approvalFileRow: pendingReceipt({ receiptId: ReceiptId.make("missing-approval-object") }), + const unavailable = harness({ + approvalFileRow: pendingReceipt({ receiptId: ReceiptId.make("missing-object") }), privateFileUnavailable: true, }); - const response = yield* request( - state.http, - "/api/receipt-approval-queue/missing-approval-object/file", - ); - - yield* expectProblem(response, { - code: "receipts.unavailable", - title: "Receipts unavailable", - status: 503, - detail: "The receipt service is temporarily unavailable.", - }); - expect(response.headers.get("content-disposition")).toBeNull(); - expect(state.privateFileReads()).toBe(1); - expect(state.commands).toEqual([]); - }), - ); + const unavailableClient = yield* unavailable.client; - it.live("maps malformed stored file metadata to the declared unavailable response", () => - Effect.gen(function* () { - const state = harness({ - approvalFileRow: pendingReceipt({ receiptId: ReceiptId.make("malformed-approval-file") }), - approvalFileFailure: "Decode", - }); + const objectFailure = yield* withCookie( + unavailableClient["receipts.readReceiptFileForApproval"]({ + receiptId: ReceiptId.make("missing-object"), + }), + ).pipe(Effect.flip); - const response = yield* request( - state.http, - "/api/receipt-approval-queue/malformed-approval-file/file", - ); + expect(codeOf(objectFailure)).toBe("receipts.unavailable"); + expect(unavailable.privateFileReads()).toBe(1); - yield* expectProblem(response, { - code: "receipts.unavailable", - title: "Receipts unavailable", - status: 503, - detail: "The receipt service is temporarily unavailable.", + const malformed = harness({ + approvalFileRow: pendingReceipt({ receiptId: ReceiptId.make("malformed-file") }), + approvalFileFailure: "Decode", }); - expect(state.privateFileReads()).toBe(0); - expect(state.commands).toEqual([]); - }), - ); -}); -it.live( - "owned list response preserves the canonical private cache contract consumed by the SDK", - () => - Effect.gen(function* () { - const owner = harness(); + const malformedClient = yield* malformed.client; - const response = yield* owner.http.fetch( - new Request("http://localhost/api/receipts", { - headers: { cookie: "better-auth.session_token=fixture" }, + const metadataFailure = yield* withCookie( + malformedClient["receipts.readReceiptFileForApproval"]({ + receiptId: ReceiptId.make("malformed-file"), }), - ); + ).pipe(Effect.flip); - expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(codeOf(metadataFailure)).toBe("receipts.unavailable"); + expect(malformed.privateFileReads()).toBe(0); + expect(malformed.commands).toEqual([]); }), -); + ); +}); diff --git a/apps/backend/src/receipt/rpc.ts b/apps/backend/src/receipt/rpc.ts new file mode 100644 index 000000000..7a47b1c67 --- /dev/null +++ b/apps/backend/src/receipt/rpc.ts @@ -0,0 +1,123 @@ +/** + * The ReceiptsRpcs and InternalReceiptsRpcs handlers. + * + * Reads resolve the caller at one instant, as the HTTP handlers did; the file reads resolve the + * credential and evaluate owner or approver authority in one repeatable-read snapshot. Commands + * resolve the credential and authorize the mutation inside the serializable transaction that + * commits them (`commands.ts`). The receipt file store is composed here, from the options, as the + * HTTP composition did. + */ +import { InternalReceiptsRpcs, ReceiptsRpcs } from "@vektorprogrammet/rpc"; +import { Effect, Layer } from "effect"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { unreachable } from "../rpc/problem.js"; +import { + approvalCommand, + reviseReceipt, + settleReceipt, + submitReceipt, + withdrawReceipt, +} from "./commands.js"; +import { receiptE2ETransactionBarrierFor } from "./e2e-support.js"; +import { ReceiptFileStoreLive, ReceiptFileStoreResource } from "./filesystem.js"; +import { + listOwnedReceipts, + listReceiptsForApproval, + listReceiptsForSettlement, + readApprovalReceiptFile, + readOwnerReceiptFile, + readReceiptLifecycleEvidence, + readSettlementForFinance, +} from "./reads.js"; + +/** The composition-owned private receipt store, or the filesystem store of the receipt config. */ +const receiptFileStoreLayer = (options: NativeRpcOptions) => { + const config = options.config.receipt; + + return options.receiptFileStore === undefined + ? ReceiptFileStoreLive({ + stagingRoot: config.stagingRoot, + committedRoot: config.committedRoot, + failNextPromotionEffectId: config.e2e?.failNextPromotionEffectId, + }) + : Layer.succeed(ReceiptFileStoreResource, options.receiptFileStore); +}; + +/** The ReceiptsRpcs handlers. */ +export const ReceiptsRpcHandlers = (options: NativeRpcOptions) => + ReceiptsRpcs.toLayer( + Effect.gen(function* () { + const fileStore = yield* ReceiptFileStoreResource; + const barrier = yield* receiptE2ETransactionBarrierFor(options.config.receipt); + + return ReceiptsRpcs.of({ + "receipts.readReceiptFile": ({ receiptId }, { headers }) => + readOwnerReceiptFile({ context: { headers, options, fileStore }, receiptId }), + + "receipts.readReceiptFileForApproval": ({ receiptId }, { headers }) => + readApprovalReceiptFile({ + context: { headers, options, fileStore }, + receiptId, + barrier, + }).pipe( + // A barrier probe that names another lane or receipt is a defect of the test driver. + unreachable("request.malformed"), + ), + + "receipts.submitReceipt": (payload, { headers }) => + submitReceipt({ context: { headers, options, fileStore }, payload }), + + "receipts.reviseReceipt": (payload, { headers }) => + reviseReceipt({ context: { headers, options, fileStore }, payload }), + + "receipts.withdrawReceipt": (payload, { headers }) => + withdrawReceipt({ context: { headers, options, fileStore }, payload }), + + "receipts.listReceipts": (query, { headers }) => + listOwnedReceipts({ caller: { headers, options }, query }), + + "receipts.listReceiptsForApproval": (query, { headers }) => + listReceiptsForApproval({ caller: { headers, options }, query }), + + "receipts.approveReceipt": (payload, { headers }) => + approvalCommand({ + context: { headers, options, fileStore }, + action: "approve", + payload, + barrier, + }), + + "receipts.listReceiptsForSettlement": ({ cursor }, { headers }) => + listReceiptsForSettlement({ caller: { headers, options }, cursor }), + + "receipts.readReceiptSettlementForFinance": ({ receiptId }, { headers }) => + readSettlementForFinance({ caller: { headers, options }, receiptId }), + + "receipts.settleReceipt": (payload, { headers }) => + settleReceipt({ context: { headers, options, fileStore }, payload }), + + "receipts.rejectReceipt": (payload, { headers }) => + approvalCommand({ + context: { headers, options, fileStore }, + action: "reject", + payload, + barrier, + }), + + "receipts.reopenReceipt": (payload, { headers }) => + approvalCommand({ + context: { headers, options, fileStore }, + action: "reopen", + payload, + barrier, + }), + }); + }), + ).pipe(Layer.provide(receiptFileStoreLayer(options))); + +/** The InternalReceiptsRpcs handlers, which only the internal ingress serves. */ +export const InternalReceiptsRpcHandlers = (options: NativeRpcOptions) => + InternalReceiptsRpcs.toLayer({ + "receipts.readReceiptEvidence": ({ receiptId }, { headers }) => + readReceiptLifecycleEvidence({ caller: { headers, options }, receiptId }), + }); diff --git a/apps/backend/src/receipt/validate.ts b/apps/backend/src/receipt/validate.ts new file mode 100644 index 000000000..78681b998 --- /dev/null +++ b/apps/backend/src/receipt/validate.ts @@ -0,0 +1,141 @@ +/** + * Receipt payload checks: the bounds that the multipart reader enforced, now on decoded RPC + * payloads. A field outside its bound answers validation.failed at the root, as before. + */ +import { DepartmentId } from "@vektorprogrammet/domain/organization"; +import { decodeReceiptCursor, isIsoDate } from "@vektorprogrammet/domain/receipt"; +import type { + ReceiptFileMediaType, + ReceiptFileUpload, + ReviseReceiptRequest, + SubmitReceiptRequest, +} from "@vektorprogrammet/rpc"; +import type { Problem } from "@vektorprogrammet/rpc/problem"; +import { Effect } from "effect"; +import { requestInvalid } from "../rpc/problem.js"; + +const SUPPORTED_CONTENT_TYPES: ReadonlyArray = [ + "image/jpeg", + "image/png", + "application/pdf", +]; + +const isSupportedContentType = (value: string): value is ReceiptFileMediaType => + SUPPORTED_CONTENT_TYPES.some((contentType) => contentType === value); + +/** One receipt file within its media types and byte bound, as a `File` that staging reads. */ +export interface ValidatedReceiptFile { + readonly file: File; + readonly contentType: ReceiptFileMediaType; +} + +type Invalid = Problem<"validation.failed">; + +const invalid = (): Effect.Effect => Effect.fail(requestInvalid()); + +const validDescription = (description: string) => + description.length >= 1 && description.length <= 5000; + +const validAmountOre = (amountOre: number) => Number.isSafeInteger(amountOre) && amountOre > 0; + +/** The web `File` of uploaded bytes, which the staging store reads as it read a multipart part. */ +const uploadedFile = (bytes: Uint8Array, contentType: ReceiptFileMediaType): File => + new File( + [ + bytes.buffer instanceof ArrayBuffer + ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) + : Uint8Array.from(bytes), + ], + "receipt", + { type: contentType }, + ); + +const validateFile = (upload: ReceiptFileUpload, maxFileBytes: number) => + upload.bytes.byteLength <= 0 || + upload.bytes.byteLength > maxFileBytes || + !isSupportedContentType(upload.contentType) + ? invalid() + : Effect.succeed({ + file: uploadedFile(upload.bytes, upload.contentType), + contentType: upload.contentType, + }); + +/** Checks a submission's fields and file against the bounds of the multipart reader. */ +export const validateSubmission = (input: { + readonly request: SubmitReceiptRequest; + readonly maxFileBytes: number; +}) => { + const { request } = input; + + return !validDescription(request.description) || + !validAmountOre(request.amountOre) || + !isIsoDate(request.receiptDate) + ? invalid() + : Effect.map(validateFile(request.file, input.maxFileBytes), (file) => ({ + description: request.description, + amountOre: request.amountOre, + receiptDate: request.receiptDate, + file, + })); +}; + +/** Checks a revision's present fields; a revision that changes no field fails validation. */ +export const validateRevision = (input: { + readonly request: ReviseReceiptRequest; + readonly maxFileBytes: number; +}) => + Effect.gen(function* () { + const { request } = input; + + if ( + request.description === undefined && + request.amountOre === undefined && + request.receiptDate === undefined && + request.file === undefined + ) { + return yield* invalid(); + } + + if (request.description !== undefined && !validDescription(request.description)) { + return yield* invalid(); + } + + if (request.amountOre !== undefined && !validAmountOre(request.amountOre)) { + return yield* invalid(); + } + + if (request.receiptDate !== undefined && !isIsoDate(request.receiptDate)) { + return yield* invalid(); + } + + const file = + request.file === undefined + ? undefined + : yield* validateFile(request.file, input.maxFileBytes); + + return { + description: request.description, + amountOre: request.amountOre, + receiptDate: request.receiptDate, + file, + }; + }); + +/** The optional submission department; an empty one fails validation, as its query did. */ +export const validateSubmitDepartment = (departmentId: string | undefined) => + Effect.gen(function* () { + if (departmentId === undefined) return undefined; + + if (departmentId.trim().length === 0) return yield* invalid(); + + return DepartmentId.make(departmentId); + }); + +/** + * A list cursor must decode before any read. The payload schema admits only cursor text, so a + * cursor whose text decodes to no position was not issued by a list: a defect. + */ +export const requireReceiptCursor = (cursor: string | undefined) => + cursor === undefined + ? Effect.void + : decodeReceiptCursor(cursor).pipe(Effect.orDie, Effect.asVoid); diff --git a/apps/backend/src/receipt/worker.ts b/apps/backend/src/receipt/worker.ts index d5d18a39b..e06d6a827 100644 --- a/apps/backend/src/receipt/worker.ts +++ b/apps/backend/src/receipt/worker.ts @@ -13,11 +13,14 @@ export const runReceiptDeliveryWorker = (pollIntervalMilliseconds: number) => // Match request-time and operator recovery. Provider timeout is at most 30 seconds. const cutoff = DateTime.formatIso(DateTime.subtract(yield* DateTime.now, { minutes: 1 })); - for (const claim of yield* listStaleReceiptOutboxClaimIds(cutoff)) { + for (const claim of yield* listStaleReceiptOutboxClaimIds({ claimedBefore: cutoff })) { yield* recoverStaleReceiptOutbox(claim, cutoff); } - yield* deliverNextReceiptOutbox(randomUUID(), DateTime.formatIso(yield* DateTime.now)); + yield* deliverNextReceiptOutbox({ + claimId: randomUUID(), + claimedAt: DateTime.formatIso(yield* DateTime.now), + }); }), { interval: Duration.millis(pollIntervalMilliseconds) }, ); diff --git a/apps/backend/src/recruitment/AGENTS.md b/apps/backend/src/recruitment/AGENTS.md index e37638631..52bf9f5cd 100644 --- a/apps/backend/src/recruitment/AGENTS.md +++ b/apps/backend/src/recruitment/AGENTS.md @@ -3,7 +3,7 @@ # apps/backend/src/recruitment This folder holds the Recruitment bounded context in `apps/backend/src`. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The Recruitment context in other folders: @@ -70,11 +70,10 @@ No `exports` entry of [apps/backend/package.json](../../package.json) points int The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../../../docs/constructs.md) indexes them all. -- [`authorizeInvitationOperation`](../../../../docs/constructs/http-problem.md#authorizeinvitationoperation) (http-problem): Authorizes the holder of an invitation's response capability. -- [`interviewAuthorizationInTransaction`](../../../../docs/constructs/http-problem.md#interviewauthorizationintransaction) (http-problem): Resolves the current person and authorizes one interview inside the caller's transaction; a rejected credential is answered from the request's evidence. -- [`readRecruitmentBody`](../../../../docs/constructs/http-problem.md#readrecruitmentbody) (http-problem): Every recruitment request body is one bounded `application/json` document. -- [`recruitmentProblems`](../../../../docs/constructs/http-problem.md#recruitmentproblems) (http-problem): The one answer for every recruitment failure. -- [`raceProblems`](../../../../docs/constructs/http-problem.md#raceproblems) (http-problem): A failure that lost a serialization or deadlock race answers transaction.conflict, whatever failure carried it. +- [`authorizeInvitationOperation`](../../../../docs/constructs/rpc-problem.md#authorizeinvitationoperation) (rpc-problem): Authorizes the holder of an invitation's response capability. +- [`interviewAuthorizationInTransaction`](../../../../docs/constructs/rpc-problem.md#interviewauthorizationintransaction) (rpc-problem): Resolves the current person and authorizes one interview inside the caller's transaction; a rejected credential is answered from the request's evidence. +- [`recruitmentProblems`](../../../../docs/constructs/rpc-problem.md#recruitmentproblems) (rpc-problem): The one answer for every recruitment failure. +- [`raceProblems`](../../../../docs/constructs/rpc-problem.md#raceproblems) (rpc-problem): A failure that lost a serialization or deadlock race answers transaction.conflict, whatever failure carried it. Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. diff --git a/apps/backend/src/recruitment/http-access.ts b/apps/backend/src/recruitment/access.ts similarity index 77% rename from apps/backend/src/recruitment/http-access.ts rename to apps/backend/src/recruitment/access.ts index dd085fd24..081fc6fa9 100644 --- a/apps/backend/src/recruitment/http-access.ts +++ b/apps/backend/src/recruitment/access.ts @@ -1,4 +1,4 @@ -/** Recruitment HTTP access: invitation-capability authorization and person access contexts. */ +/** Recruitment access: invitation-capability authorization and person access contexts. */ import type { Database, IdentitySnapshot, @@ -34,22 +34,17 @@ import { type RecruitmentInterviewId, type RecruitmentInvitationHttpSource, } from "@vektorprogrammet/domain/recruitment"; -import { - reflectAccessSpec, - type CancelInterviewEndpoint, - type CorrectInterviewAssessmentEndpoint, - type FinalizeInterviewEndpoint, - type ReadInterviewConductEndpoint, - type ScheduleInterviewEndpoint, -} from "@vektorprogrammet/http-api"; -import { Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Option, Predicate, type Schema } from "effect"; +import { reflectAccessSpec } from "@vektorprogrammet/rpc"; +import { Problem } from "@vektorprogrammet/rpc/problem"; +import { Effect, Option, Predicate, Schema } from "effect"; +import type { Headers } from "effect/unstable/http"; +import type { Rpc } from "effect/unstable/rpc"; import { type OrganizationResolutionError, resolveRequestPersonAuthorityInTransaction, } from "../authority.js"; -import { authorizePerson, personPresentation } from "../http-api/problem.js"; -import type { RecruitmentApiHttpOptions } from "./http-context.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import { authorizePerson, personPresentation } from "../rpc/problem.js"; type GenericFacts = Schema.JsonObject; @@ -67,14 +62,14 @@ type GenericFacts = Schema.JsonObject; * * @example * ```ts - * yield* authorizeInvitationOperation({ spec: Option.getOrThrow(reflectAccessSpec(ReadInvitationResponseEndpoint)), source: snapshot.source, authorizationInstant: now }); + * yield* authorizeInvitationOperation({ spec: Option.getOrThrow(reflectAccessSpec(ReadInvitationResponse)), source: snapshot.source, authorizationInstant: now }); * ``` * * @avoid Checking the capability digest against the invitation in the handler: the AccessSpec of * the contract then stops being the authority, and a denial can reveal that the invitation * exists. Authorize the holder with this. * - * @construct http-problem + * @construct rpc-problem */ export const authorizeInvitationOperation = (input: { readonly spec: AccessSpec; @@ -162,7 +157,15 @@ export const authorizeInvitationOperation = (input: { export const actorDepartment = (actor: RecruitmentActor): DepartmentId | null => Predicate.isTagged(actor, "GlobalAdmin") ? null : actor.departmentId; -export const boardContext = (actor: RecruitmentActor, facts: GenericFacts, version: string) => ({ +export const boardContext = ({ + actor, + facts, + version, +}: { + readonly actor: RecruitmentActor; + readonly facts: GenericFacts; + readonly version: string; +}) => ({ domainId: DomainId.make("recruitment"), departmentId: actorDepartment(actor), resource: null, @@ -186,12 +189,21 @@ export const applicationContext = (input: { authorityVersion: AuthorityVersion.make(input.version), }); -export const recruitmentInterviewAccessContext = ( - source: RecruitmentInterviewHttpSource, - actor: RecruitmentActor, - allowLeader: boolean, - activeMember: boolean, -) => ({ +/** The JSON text of a co-interviewer identity, as the authority version spells it. */ +const coInterviewerJson = Schema.encodeSync(Schema.fromJsonString(Schema.NullOr(Schema.String))); + +/** The recruitment context of one interview, as `actor` reaches it. */ +export const recruitmentInterviewAccessContext = ({ + source, + actor, + allowLeader, + activeMember, +}: { + readonly source: RecruitmentInterviewHttpSource; + readonly actor: RecruitmentActor; + readonly allowLeader: boolean; + readonly activeMember: boolean; +}) => ({ domainId: DomainId.make("recruitment"), departmentId: source.departmentId, resource: { @@ -214,12 +226,12 @@ export const recruitmentInterviewAccessContext = ( : [], }, authorityVersion: AuthorityVersion.make( - `${source.interviewRevision}:${JSON.stringify(source.coInterviewerPersonId)}:${source.linkedApplicantPersonId ?? "Unknown"}:${source.authority.map((item) => `${item.kind}:${item.identity}:${item.revisions.join(".")}`).join("|")}`, + `${source.interviewRevision}:${coInterviewerJson(source.coInterviewerPersonId)}:${source.linkedApplicantPersonId ?? "Unknown"}:${source.authority.map((item) => `${item.kind}:${item.identity}:${item.revisions.join(".")}`).join("|")}`, ), }); /** The interview that `interviewAuthorizationInTransaction` authorized, with its actor and instant. */ -interface InterviewAuthorization { +export interface InterviewAuthorization { readonly actor: RecruitmentActor; readonly authorizationInstant: AuthorizationInstant; readonly source: RecruitmentInterviewHttpSource; @@ -232,7 +244,7 @@ interface InterviewAuthorization { * @remarks * It resolves the request's person credential and organization authority at one instant, lets * recruitment prepare the interview, which takes applicant custody before interview or receipt - * locks, and evaluates the endpoint's AccessSpec over the interview's access context, with the + * locks, and evaluates the RPC's AccessSpec over the interview's access context, with the * interview as the grant scope. `allowLeader` admits the department's administrator. It answers * the actor, the instant, and the interview source that the command uses. * @@ -241,27 +253,22 @@ interface InterviewAuthorization { * * @example * ```ts - * const authorization = yield* interviewAuthorizationInTransaction(request, interviewId, ScheduleInterviewEndpoint, true, input); + * const authorization = yield* interviewAuthorizationInTransaction({ headers, interviewId, rpc: ScheduleInterview, allowLeader: true, now }); * ``` * * @avoid Authorizing outside the command's transaction, or before recruitment prepares the * interview: the authority or the interview can change before the command writes. Call it first * inside the transaction that writes. * - * @construct http-problem + * @construct rpc-problem */ -export const interviewAuthorizationInTransaction = ( - request: Request, - interviewId: RecruitmentInterviewId, - endpoint: - | typeof ScheduleInterviewEndpoint - | typeof ReadInterviewConductEndpoint - | typeof FinalizeInterviewEndpoint - | typeof CancelInterviewEndpoint - | typeof CorrectInterviewAssessmentEndpoint, - allowLeader: boolean, - input: RecruitmentApiHttpOptions, -): Effect.Effect< +export const interviewAuthorizationInTransaction = (input: { + readonly headers: Headers.Headers; + readonly interviewId: RecruitmentInterviewId; + readonly rpc: Pick; + readonly allowLeader: boolean; + readonly now: (() => string) | undefined; +}): Effect.Effect< InterviewAuthorization, | RecruitmentFailure | IdentityEngineError @@ -274,13 +281,14 @@ export const interviewAuthorizationInTransaction = ( Database | IdentitySnapshot | OAuthCredentialAuthority | Organization | Recruitment > => Effect.gen(function* () { - const authorization = yield* resolveRequestPersonAuthorityInTransaction(request, { - now: input.config.now, - }); + const authorization = yield* resolveRequestPersonAuthorityInTransaction( + credentialRequestOf(input.headers), + { now: input.now }, + ); const { source, actor, activeMember } = yield* Recruitment.use((service) => service.prepareInterview({ - interviewId, + interviewId: input.interviewId, personId: authorization.authority.personId, authorizationInstant: authorization.authorizationInstant, }), @@ -288,22 +296,29 @@ export const interviewAuthorizationInTransaction = ( const resource = { kind: ResourceKind.make("recruitment-interview"), - id: ResourceId.make(interviewId), + id: ResourceId.make(input.interviewId), }; yield* authorizePerson( { - spec: Option.getOrThrow(reflectAccessSpec(endpoint)), + spec: Option.getOrThrow(reflectAccessSpec(input.rpc)), credential: authorization.credential, personId: actor.personId, resolution: { selection: "ExactlyOne", - contexts: [recruitmentInterviewAccessContext(source, actor, allowLeader, activeMember)], + contexts: [ + recruitmentInterviewAccessContext({ + source, + actor, + allowLeader: input.allowLeader, + activeMember, + }), + ], }, grantScopes: [Scope.Resource({ resource })], now: authorization.authorizationInstant, }, - personPresentation(request), + personPresentation(input.headers), ); return { diff --git a/apps/backend/src/recruitment/authority.rpc.test.ts b/apps/backend/src/recruitment/authority.rpc.test.ts new file mode 100644 index 000000000..c54089074 --- /dev/null +++ b/apps/backend/src/recruitment/authority.rpc.test.ts @@ -0,0 +1,343 @@ +import { Admissions } from "@vektorprogrammet/domain"; +import { + AdmissionPeriodActorSchema, + AdmissionPeriodId, +} from "@vektorprogrammet/domain/admission-period"; +import { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; +import { + DepartmentId, + MembershipId, + Organization, + PersonId, + TeamId, + type OrganizationOperations, +} from "@vektorprogrammet/domain/organization"; +import { + Recruitment, + RecruitmentRoleDenied, + type RecruitmentActor, + type RecruitmentAssignmentBoardQuery, + type RecruitmentOperations, +} from "@vektorprogrammet/domain/recruitment"; +import { isProblem, type Problem } from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Layer, Predicate } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { beforeEach, describe, expect, it } from "@effect/vitest"; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; + +const leaderToken = "leader-session-token"; + +const memberToken = "member-session-token"; + +const inactiveToken = "inactive-session-token"; + +const unassignedToken = "unassigned-session-token"; + +// Leads department-1 after the person's global-administrator grant has ended. +const formerAdministratorToken = "former-administrator-session-token"; + +interface AuthorityMembershipRow { + readonly departmentId: string; + readonly active: boolean; + /** Leads the board of the (independent) department; an ordinary team leader has no reach. */ + readonly boardLeader: boolean; +} + +/** One authority projection per session token, selected by the cookie value. */ +const membershipsByToken = new Map>([ + [leaderToken, [{ departmentId: "department-1", active: true, boardLeader: true }]], + [memberToken, [{ departmentId: "department-1", active: true, boardLeader: false }]], + [inactiveToken, [{ departmentId: "department-1", active: false, boardLeader: false }]], + [unassignedToken, []], + [formerAdministratorToken, [{ departmentId: "department-1", active: true, boardLeader: true }]], +]); + +const personIdsByToken = new Map([ + [leaderToken, "leader-1"], + [memberToken, "member-1"], + [inactiveToken, "inactive-1"], + [unassignedToken, "unassigned-1"], + [formerAdministratorToken, "former-administrator-1"], +]); + +const personIdForToken = (tokenValue: string): string => + personIdsByToken.get(tokenValue) ?? "unknown-person"; + +const organization = { + resolvePersonAuthority: (personId: PersonId) => { + const row = membershipsByToken + .entries() + .find(([token]) => personIdForToken(token) === personId); + + return Effect.succeed({ + personId, + evaluatedAt: "2031-09-15T12:00:00.000Z", + globalAdministrator: + personId === personIdForToken(formerAdministratorToken) ? "Inactive" : "Absent", + memberships: (row?.[1] ?? []).map((membership, index) => ({ + membershipId: MembershipId.make(`membership-${index}`), + teamId: TeamId.make(`team-${index}`), + departmentId: DepartmentId.make(membership.departmentId), + active: membership.active, + unitLeader: membership.boardLeader, + unitKind: membership.boardLeader ? "DepartmentBoard" : "Team", + teamScope: "HomeDepartment", + departmentIndependent: true, + })), + nationalBoardSeats: [], + delegations: [], + }); + }, +} satisfies Partial; + +const recruitmentCalls: Array<{ readonly operation: string; readonly actor: unknown }> = []; + +const admissions = { + listAdmissionPeriodsForManagement: ({ actor }: { readonly actor: unknown }) => + Effect.sync(() => { + recruitmentCalls.push({ operation: "listAdmissionPeriodsForManagement", actor }); + + return []; + }), +}; + +const assignmentBoard = { + admissionPeriodId: AdmissionPeriodId.make("period-1"), + departmentId: DepartmentId.make("department-1"), + candidates: [], + interviewers: [], + interviewSchemas: [], +}; + +const schedulingBoard = { departmentId: DepartmentId.make("department-1"), interviews: [] }; + +// Models the frozen domain laws: assignment reads require an active DepartmentAdministrator; +// scheduling reads require an active department member. +const recruitment = { + readPersonAuthoritySources: () => Effect.succeed([]), + readAssignmentBoard: ( + query: RecruitmentAssignmentBoardQuery, + context: { readonly actor: RecruitmentActor }, + ) => + context.actor.active && Predicate.isTagged(context.actor, "DepartmentAdministrator") + ? Effect.sync(() => { + recruitmentCalls.push({ operation: "readAssignmentBoard", actor: context.actor }); + void query; + + return assignmentBoard; + }) + : Effect.fail( + RecruitmentRoleDenied.make({ personId: PersonId.make(context.actor.personId) }), + ), + readSchedulingBoard: (context: { readonly actor: RecruitmentActor }) => + !Predicate.isTagged(context.actor, "GlobalAdmin") && context.actor.active + ? Effect.sync(() => { + recruitmentCalls.push({ operation: "readSchedulingBoard", actor: context.actor }); + + return schedulingBoard; + }) + : Effect.fail( + RecruitmentRoleDenied.make({ personId: PersonId.make(context.actor.personId) }), + ), +} satisfies Partial; + +const identity = Identity.of({ + signIn: () => Effect.die("unexpected sign-in"), + resolveSession: (cookieHeader: string | undefined) => { + const tokenValue = cookieHeader + ?.split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith("better-auth.session_token=")) + ?.slice("better-auth.session_token=".length); + + return tokenValue !== undefined && membershipsByToken.has(tokenValue) + ? Effect.succeed( + IdentityActor.make({ + personId: PersonId.make(personIdForToken(tokenValue)), + sessionId: "session-1", + expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), + }), + ) + : Effect.fail(IdentitySessionNotFound.make({})); + }, + readCurrentSession: () => Effect.die("unexpected session read"), + listSessions: () => Effect.die("unexpected session list"), + revokeCurrentSession: () => Effect.die("unexpected session mutation"), + revokeSession: () => Effect.die("unexpected session mutation"), + revokeOtherSessions: () => Effect.die("unexpected session mutation"), + revokeAllSessions: () => Effect.die("unexpected session mutation"), + recordSecurityEvent: () => Effect.die("unexpected identity audit"), + signOut: () => Effect.succeed({ setCookies: [] }), +} satisfies IdentityOperations); + +const backend = makeBackendTestRpc({ + config: backendTestConfig, + services: Layer.mergeAll( + Layer.mock(Organization, organization), + Layer.mock(Recruitment, recruitment), + Layer.mock(Admissions, admissions), + Layer.succeed(Identity, identity), + ), +}); + +/** Runs one call with the session cookie of `token`; an empty token presents none. */ +const as = (effect: Effect.Effect, token: string) => + token === "" + ? effect + : RpcClient.withHeaders(effect, { cookie: `better-auth.session_token=${token}` }); + +/** The problem code of a failed call; a transport failure has none. */ +const codeOf = (failure: Problem | RpcClientError) => + isProblem(failure) ? failure.code : "defect"; + +const leaderOf = (personId: string) => + expect.objectContaining( + AdmissionPeriodActorSchema.cases.DepartmentAdministrator.make({ + personId: PersonId.make(personId), + departmentId: DepartmentId.make("department-1"), + active: true, + }), + ); + +describe("recruitment actors from authorized departments (spec 0055)", () => { + beforeEach(() => { + recruitmentCalls.length = 0; + }); + + it.live("allows a DepartmentAdministrator to read the canonical assignment board once", () => + Effect.gen(function* () { + const client = yield* backend.client; + + expect( + yield* as(client["recruitment.readAssignmentBoard"]({ status: "new" }), leaderToken), + ).toEqual(assignmentBoard); + expect(recruitmentCalls).toEqual([ + { operation: "readAssignmentBoard", actor: leaderOf("leader-1") }, + ]); + }), + ); + + it.live("denies a plain active member from the assignment board", () => + Effect.gen(function* () { + const client = yield* backend.client; + + const denied = yield* as( + client["recruitment.readAssignmentBoard"]({ status: "new" }), + memberToken, + ).pipe(Effect.flip); + + expect(codeOf(denied)).toBe("authority.denied"); + expect(recruitmentCalls).toEqual([]); + }), + ); + + it.live("denies an anonymous assignment-board caller before any domain call", () => + Effect.gen(function* () { + const client = yield* backend.client; + + const denied = yield* as( + client["recruitment.readAssignmentBoard"]({ status: "new" }), + "", + ).pipe(Effect.flip); + + expect(codeOf(denied)).toBe("credential.missing"); + expect(recruitmentCalls).toEqual([]); + }), + ); + + it.live("allows an active department member to read the scheduling board once", () => + Effect.gen(function* () { + const client = yield* backend.client; + + expect(yield* as(client["recruitment.readSchedulingBoard"](), memberToken)).toEqual( + schedulingBoard, + ); + expect(recruitmentCalls).toEqual([ + { + operation: "readSchedulingBoard", + actor: expect.objectContaining( + AdmissionPeriodActorSchema.cases.Member.make({ + personId: PersonId.make("member-1"), + departmentId: DepartmentId.make("department-1"), + active: true, + }), + ), + }, + ]); + }), + ); + + it.live("denies an inactive department member from the scheduling board", () => + Effect.gen(function* () { + const client = yield* backend.client; + + const denied = yield* as(client["recruitment.readSchedulingBoard"](), inactiveToken).pipe( + Effect.flip, + ); + + expect(codeOf(denied)).toBe("authority.denied"); + expect(recruitmentCalls).toEqual([]); + }), + ); + + // A credential problem tells the dashboard that the session expired, and it signs the person out. + it.live( + "denies an authenticated person without a department instead of rejecting the session", + () => + Effect.gen(function* () { + const client = yield* backend.client; + + const [board, admissionPeriods] = yield* Effect.all( + [ + as(client["recruitment.readSchedulingBoard"](), unassignedToken).pipe(Effect.flip), + as(client["admissions.listAdmissionPeriods"](), unassignedToken).pipe(Effect.flip), + ], + { concurrency: "unbounded" }, + ); + + expect([codeOf(board), codeOf(admissionPeriods)]).toEqual([ + "authority.denied", + "authority.denied", + ]); + expect(recruitmentCalls).toEqual([]); + }), + ); + + it.live("keeps a department leadership after the leader's administrator grant has ended", () => + Effect.gen(function* () { + const client = yield* backend.client; + + yield* as(client["admissions.listAdmissionPeriods"](), formerAdministratorToken); + yield* as( + client["recruitment.readAssignmentBoard"]({ status: "new" }), + formerAdministratorToken, + ); + + expect(recruitmentCalls).toEqual([ + { + operation: "listAdmissionPeriodsForManagement", + actor: leaderOf("former-administrator-1"), + }, + { operation: "readAssignmentBoard", actor: leaderOf("former-administrator-1") }, + ]); + }), + ); + + it.live("denies an anonymous scheduling-board caller before any domain call", () => + Effect.gen(function* () { + const client = yield* backend.client; + + const denied = yield* client["recruitment.readSchedulingBoard"]().pipe(Effect.flip); + + expect(codeOf(denied)).toBe("credential.missing"); + expect(recruitmentCalls).toEqual([]); + }), + ); +}); diff --git a/apps/backend/src/recruitment/commands.http.test.ts b/apps/backend/src/recruitment/commands.rpc.test.ts similarity index 64% rename from apps/backend/src/recruitment/commands.http.test.ts rename to apps/backend/src/recruitment/commands.rpc.test.ts index ed1a72061..863c0a643 100644 --- a/apps/backend/src/recruitment/commands.http.test.ts +++ b/apps/backend/src/recruitment/commands.rpc.test.ts @@ -6,7 +6,12 @@ import { RecruitmentLive } from "@vektorprogrammet/database/recruitment"; import { DatabaseRuntimeLive } from "@vektorprogrammet/database/runtime"; import { AdmissionPeriodActorSchema } from "@vektorprogrammet/domain/admission-period"; import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; -import { IdentityActor, IdentitySessionNotFound } from "@vektorprogrammet/domain/identity"; +import { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; import { InterviewSchemaId, @@ -19,11 +24,14 @@ import { RecruitmentInvitationTransition, RecruitmentScheduleCommandId, } from "@vektorprogrammet/domain/recruitment"; -import { CorrectInterviewAssessmentResponse } from "@vektorprogrammet/http-api"; -import { DateTime, Deferred, Effect, Fiber, Layer, Schedule, Schema } from "effect"; +import { IdempotencyKey, isProblem, type Problem } from "@vektorprogrammet/rpc/problem"; +import { DateTime, Deferred, Effect, Fiber, Layer, Schedule } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; import { describe, expect, it } from "@effect/vitest"; import { backendDatabase } from "../../test/database.js"; -import { makeRecruitmentTestHttp } from "../test/native-http.js"; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; const departmentId = DepartmentId.make("commands-department"); @@ -142,26 +150,47 @@ const finalizedInterview = Effect.gen(function* () { ); }).pipe(Effect.provide(domain)); +const sessionActor = (cookie: string | undefined) => { + const person = /better-auth\.session_token=([^;]+)/u.exec(cookie ?? "")?.[1]; + + return person === undefined + ? undefined + : IdentityActor.make({ + personId: PersonId.make(person), + sessionId: `session-${person}`, + expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), + }); +}; + +const resolveSession = (cookie: string | undefined) => { + const actor = sessionActor(cookie); + + return actor === undefined + ? Effect.fail(IdentitySessionNotFound.make({})) + : Effect.succeed(actor); +}; + const identitySnapshot = IdentitySnapshot.of({ - resolveSession: (cookie) => { - const person = /better-auth\.session_token=([^;]+)/u.exec(cookie ?? "")?.[1]; - - return person === undefined - ? Effect.fail(new IdentitySessionNotFound()) - : Effect.succeed( - new IdentityActor({ - personId: PersonId.make(person), - sessionId: `session-${person}`, - expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), - }), - ); - }, + resolveSession, revokeCurrentSession: () => Effect.die("unexpected session mutation"), revokeSession: () => Effect.die("unexpected session mutation"), revokeOtherSessions: () => Effect.die("unexpected session mutation"), revokeAllSessions: () => Effect.die("unexpected session mutation"), }); +const identity = Identity.of({ + signIn: () => Effect.die("unexpected sign-in"), + resolveSession, + readCurrentSession: () => Effect.die("unexpected session read"), + listSessions: () => Effect.die("unexpected session list"), + revokeCurrentSession: () => Effect.die("unexpected session mutation"), + revokeSession: () => Effect.die("unexpected session mutation"), + revokeOtherSessions: () => Effect.die("unexpected session mutation"), + revokeAllSessions: () => Effect.die("unexpected session mutation"), + recordSecurityEvent: () => Effect.die("unexpected identity audit"), + signOut: () => Effect.succeed({ setCookies: [] }), +} satisfies IdentityOperations); + const persistedCorrection = Database.use( (sql) => sql<{ readonly corrections: number; @@ -179,77 +208,78 @@ const persistedCorrection = Database.use( `, ).pipe(Effect.map((rows) => rows[0])); -const correctionBody = JSON.stringify({ +const correctionRequest = { expectedRevision: 2, answers: questionIds.map((questionId) => ({ questionId, answer: `Corrected ${questionId}` })), score: { explanatoryPower: 7, roleModel: 8, suitability: 9 }, recommendation: "Kanskje", -}); +} as const; -const fixture = () => { - const database = backendDatabase(finalizedInterview); +const fixture = () => + Effect.gen(function* () { + const database = backendDatabase(finalizedInterview); - const http = makeRecruitmentTestHttp( - { + const backend = makeBackendTestRpc({ config: { - maxBodyBytes: 65_536, - now: () => "2031-09-15T12:05:00.000Z", - nextInterviewId: () => RecruitmentInterviewId.make("commands-unexpected-interview"), - nextInvitationId: () => RecruitmentInvitationId.make("commands-unexpected-invitation"), - nextResponseCapability: () => "unexpected".padEnd(43, "_"), + ...backendTestConfig, + recruitment: { + maxBodyBytes: 65_536, + now: () => "2031-09-15T12:05:00.000Z", + nextInterviewId: () => RecruitmentInterviewId.make("commands-unexpected-interview"), + nextInvitationId: () => RecruitmentInvitationId.make("commands-unexpected-invitation"), + nextResponseCapability: () => "unexpected".padEnd(43, "_"), + }, }, - resolveActor: () => Effect.die("unexpected recruitment board actor"), - }, - Layer.mergeAll( - database.layer, - domain.pipe(Layer.provide(database.layer)), - Layer.succeed(IdentitySnapshot, identitySnapshot), - ), - ); + services: Layer.mergeAll( + domain, + Layer.succeed(IdentitySnapshot, identitySnapshot), + Layer.succeed(Identity, identity), + ).pipe(Layer.provideMerge(database.layer)), + }); + + const client = yield* backend.client; + + /** Runs one call as the assigned interviewer. */ + const asInterviewer = (effect: Effect.Effect) => + RpcClient.withHeaders(effect, { + cookie: `better-auth.session_token=${interviewerPersonId}`, + }); + + /** A second session on the fixture database, outside the handler's single connection. */ + const competitor = Effect.gen(function* () { + const [target] = yield* database.run( + Database.use( + (sql) => + sql<{ + readonly host: string; + readonly port: number; + readonly database: string; + readonly username: string; + }>`SELECT current_setting('unix_socket_directories') AS host, current_setting('port')::integer AS port, current_database() AS database, current_user AS username`, + ), + ); + + if (target === undefined) throw new Error("Missing PostgreSQL connection configuration"); - const request = (pathname: string, init: RequestInit = {}) => { - const headers = new Headers(init.headers); - headers.set("cookie", `better-auth.session_token=${interviewerPersonId}`); - - if (init.method !== undefined && init.method !== "GET") { - headers.set("origin", "http://127.0.0.1:5174"); - } - - return http.fetch(new Request(`http://backend.test${pathname}`, { ...init, headers })); - }; - - /** A second session on the fixture database, outside the handler's single connection. */ - const competitor = Effect.gen(function* () { - const [target] = yield* database.run( - Database.use( - (sql) => - sql<{ - readonly host: string; - readonly port: number; - readonly database: string; - readonly username: string; - }>`SELECT current_setting('unix_socket_directories') AS host, current_setting('port')::integer AS port, current_database() AS database, current_user AS username`, - ), - ); - - if (target === undefined) throw new Error("Missing PostgreSQL connection configuration"); - - return DatabaseRuntimeLive({ ...target, maxConnections: 1 }).pipe(Layer.orDie); + return DatabaseRuntimeLive({ ...target, maxConnections: 1 }).pipe(Layer.orDie); + }); + + return { database, client, asInterviewer, competitor }; }); - return { database, request, competitor }; -}; +/** The problem code of a failed call; a transport failure has none. */ +const codeOf = (failure: Problem | RpcClientError) => + isProblem(failure) ? failure.code : "defect"; -describe("recruitment commands over HTTP and PostgreSQL", () => { +describe("recruitment commands over RPC and PostgreSQL", () => { it.live("retries an assessment correction once after a PostgreSQL serialization failure", () => Effect.gen(function* () { - const { database, request, competitor } = fixture(); - const conduct = yield* request(`/api/recruitment/interviews/${interviewId}`); - const etag = conduct.headers.get("etag"); + const { database, client, asInterviewer, competitor } = yield* fixture(); - expect(conduct.status).toBe(200); + const conduct = yield* asInterviewer( + client["recruitment.readInterviewConduct"]({ interviewId }), + ); - if (etag === null) throw new Error("The conduct read returned no entity tag"); expect(yield* database.run(persistedCorrection)).toEqual({ corrections: 0, correctionReceipts: 0, @@ -261,9 +291,9 @@ describe("recruitment commands over HTTP and PostgreSQL", () => { const competing = yield* competitor; const rowHeld = yield* Deferred.make(); - // The concurrent transaction commits only after the command waits on its - // row lock, so the command's SERIALIZABLE snapshot is already older than - // the committed update and its first attempt fails with SQLSTATE 40001. + // The concurrent transaction commits only after the command waits on its row lock, so the + // command's SERIALIZABLE snapshot is already older than the committed update and its first + // attempt fails with SQLSTATE 40001. const concurrentUpdate = yield* Database.use((sql) => sql.withTransaction( Effect.gen(function* () { @@ -288,29 +318,25 @@ describe("recruitment commands over HTTP and PostgreSQL", () => { yield* Deferred.await(rowHeld); - const correction = yield* request(`/api/recruitment/interviews/${interviewId}:correct`, { - method: "POST", - headers: { - "content-type": "application/json", - "idempotency-key": "commands-correction".padEnd(22, "0"), - "if-match": etag, - }, - body: correctionBody, - }); + const correct = asInterviewer( + client["recruitment.correctInterviewAssessment"]({ + interviewId, + idempotencyKey: IdempotencyKey.make("commands-correction".padEnd(22, "0")), + ifMatch: conduct.etag, + request: correctionRequest, + }), + ); + + const corrected = yield* correct; expect(yield* Fiber.join(concurrentUpdate)).toBe(1); - expect(correction.status).toBe(200); - expect( - yield* Schema.decodeUnknownEffect(CorrectInterviewAssessmentResponse)( - yield* Effect.promise(() => correction.json()), - { onExcessProperty: "error" }, - ), - ).toMatchObject({ + expect(corrected.result).toMatchObject({ interviewId, predecessorRevision: 2, resultingRevision: 3, replayed: false, }); + expect(corrected.etag).not.toBe(conduct.etag); expect(yield* database.run(persistedCorrection)).toEqual({ corrections: 1, @@ -319,38 +345,27 @@ describe("recruitment commands over HTTP and PostgreSQL", () => { httpReceipts: 1, revision: 3, }); - }), - ); - it.live("rejects an exponent Content-Length before the correction starts", () => - Effect.gen(function* () { - const { database, request } = fixture(); - const conduct = yield* request(`/api/recruitment/interviews/${interviewId}`); - const etag = conduct.headers.get("etag"); - - if (etag === null) throw new Error("The conduct read returned no entity tag"); - - const correction = yield* request(`/api/recruitment/interviews/${interviewId}:correct`, { - method: "POST", - headers: { - "content-length": "1e3", - "content-type": "application/json", - "idempotency-key": "commands-exponent".padEnd(22, "0"), - "if-match": etag, - }, - body: correctionBody, - }); + // A retry with the same key replays the first answer, entity tag included. + expect(yield* correct).toEqual(corrected); - expect(correction.status).toBe(400); - expect(yield* Effect.promise(() => correction.json())).toMatchObject({ - code: "request.malformed", - }); + // The first tag no longer names the interview, so another correction is refused. + const stale = yield* asInterviewer( + client["recruitment.correctInterviewAssessment"]({ + interviewId, + idempotencyKey: IdempotencyKey.make("commands-stale".padEnd(22, "0")), + ifMatch: conduct.etag, + request: { ...correctionRequest, expectedRevision: 3 }, + }), + ).pipe(Effect.flip); + + expect(codeOf(stale)).toBe("precondition.failed"); expect(yield* database.run(persistedCorrection)).toEqual({ - corrections: 0, - correctionReceipts: 0, - audits: 0, - httpReceipts: 0, - revision: 2, + corrections: 1, + correctionReceipts: 1, + audits: 1, + httpReceipts: 1, + revision: 3, }); }), ); diff --git a/apps/backend/src/recruitment/http-commands.ts b/apps/backend/src/recruitment/commands.ts similarity index 50% rename from apps/backend/src/recruitment/http-commands.ts rename to apps/backend/src/recruitment/commands.ts index 058907fcd..2b952c7b3 100644 --- a/apps/backend/src/recruitment/http-commands.ts +++ b/apps/backend/src/recruitment/commands.ts @@ -1,4 +1,12 @@ -/** Recruitment command handlers: invitation responses, assignment, scheduling, correction, finalize, and cancel. */ +/** + * Recruitment commands: invitation responses, assignment, scheduling, correction, finalization, + * and cancellation. + * + * An interview command resolves the credential and authority inside the serializable transaction + * that commits it and stores its answer as a command receipt, byte for byte what the HTTP contract + * stored: the response body, and the interview's new entity tag as the `etag` header. A retry that + * straddles the cutover from HTTP therefore replays its first answer. + */ import type { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; import { ResourceId, ResourceKind, Scope } from "@vektorprogrammet/domain/authz"; import type { PersonId } from "@vektorprogrammet/domain/organization"; @@ -13,65 +21,60 @@ import { type RecruitmentInterviewId, } from "@vektorprogrammet/domain/recruitment"; import { - AssignApplicantEndpoint, - CancelInterviewEndpoint, - CancelInterviewRequest, + CancelInterview, CancelInterviewResponse, - ConfirmInvitationEndpoint, - ConfirmInvitationPayload, - CorrectInterviewAssessmentEndpoint, - CorrectInterviewAssessmentRequest, + ConfirmInvitation, + CorrectInterviewAssessment, CorrectInterviewAssessmentResponse, - CreateApplicationInterviewRequest, - FinalizeInterviewEndpoint, - FinalizeInterviewRequest, + CreateApplicationInterview, + FinalizeInterview, FinalizeInterviewResponse, - InvitationRejectInput, - InvitationRequestNewTimeInput, RecruitmentInterviewResource, - RejectInvitationEndpoint, - RequestNewInvitationTimeEndpoint, - ScheduleInterviewEndpoint, - ScheduleInterviewRequest, + RejectInvitation, + RequestNewInvitationTime, + ScheduleInterview, ScheduleInterviewResponse, reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { isProblem, Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Option, Predicate, Schema } from "effect"; + type CorrectInterviewAssessmentRequest, + type CreateApplicationInterviewRequest, + type FinalizeInterviewRequest, + type ScheduleInterviewRequest, +} from "@vektorprogrammet/rpc"; +import { type IdempotencyKey, Problem, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { Effect, Match, Option, Predicate, Schema } from "effect"; +import type { Headers } from "effect/unstable/http"; +import type { Rpc } from "effect/unstable/rpc"; import { currentInstant, resolveRequestPersonAuthorityInTransaction } from "../authority.js"; +import { + jsonBodyBytes, + normalizeTarget, + semanticMutationRequest, + semanticRequestDigest, + type CanonicalSemanticRequest, + type CredentialSubject, +} from "../http-semantics.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; import { authorizePerson, - commandOutcomeResponse, + commandIdentity, commandReceiptProblems, - decodeRequest, - httpIdentity, - idempotencyKeyOf, personPresentation, requireCurrentETag, - requiredIfMatchOf, - requireNoQuery, - semanticProblem, strictOutput, unreachable, - jsonText, -} from "../http-api/problem.js"; -import { executeNativeHttpCommandPostgres } from "../http-api/receipt-transaction.js"; +} from "../rpc/problem.js"; import { - NO_STORE, - normalizeTarget, - responseCapsule, - semanticMutationRequest, - semanticRequestDigest, - type CanonicalSemanticRequest, - type CredentialSubject, -} from "../http-semantics.js"; + executeNativeHttpCommandPostgres, + NativeHttpReceiptInvalid, + type NativeHttpCommandOutcome, + type NativeHttpResponseCapsule, +} from "../rpc/receipt-transaction.js"; import { applicationContext, authorizeInvitationOperation, interviewAuthorizationInTransaction, -} from "./http-access.js"; -import type { RecruitmentApiHttpOptions } from "./http-context.js"; -import { invitationCapability, readRecruitmentBody } from "./http-decode.js"; +} from "./access.js"; import { admissionPeriodProblems, assignmentProblems, @@ -79,27 +82,91 @@ import { raceProblems, recruitmentProblems, schedulingProblems, -} from "./http-problem.js"; -import { interviewETag, invitationETag } from "./http-representation.js"; +} from "./problem.js"; +import { type RecruitmentCall } from "./context.js"; +import { invitationCapability } from "./reads.js"; +import { interviewETag, invitationETag } from "./representation.js"; + +/** A schema whose JSON codec a command receipt stores. */ +type ReceiptSchema = Schema.Codec; + +/** + * The receipt of an interview command: its body as JSON, and the interview's entity tag as the + * `etag` header. An assignment also keeps its 201 status and the interview's `location`. + */ +const interviewCapsule = + (schema: S) => + ( + value: S["Type"], + etag: StrongETag, + created?: { readonly location: string }, + ): Effect.Effect => + Schema.encodeEffect(Schema.toCodecJson(schema))(value).pipe( + Effect.orDie, + Effect.map( + (body): NativeHttpResponseCapsule => ({ + status: created === undefined ? 200 : 201, + mediaType: "application/json", + headers: + created === undefined + ? { "content-type": "application/json", etag } + : { "content-type": "application/json", etag, location: created.location }, + bodyBytes: jsonBodyBytes(body), + }), + ), + ); + +/** The body and entity tag that an interview command receipt stores; a mismatch is a defect. */ +const storedInterviewCommand = + (schema: S) => + (capsule: NativeHttpResponseCapsule) => + Effect.gen(function* () { + if (capsule.bodyBytes === null || capsule.headers.etag === undefined) { + return yield* Effect.die( + new NativeHttpReceiptInvalid({ reason: "an interview receipt stores no entity" }), + ); + } + + const body: S["Type"] = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.toCodecJson(schema)), + )(new TextDecoder().decode(capsule.bodyBytes)).pipe(Effect.orDie); + + const etag = yield* Schema.decodeEffect(StrongETag)(capsule.headers.etag).pipe(Effect.orDie); + + return { body, etag }; + }); + +/** + * The value of an interview command outcome: the committed or replayed body beside its entity + * tag, or an idempotency problem. + */ +const interviewCommandOutcome = + (schema: S) => + (outcome: NativeHttpCommandOutcome) => + Match.value(outcome).pipe( + Match.tag("Committed", "Replay", ({ response }) => storedInterviewCommand(schema)(response)), + Match.tag("InFlight", () => Effect.fail(Problem.make("idempotency.in-flight"))), + Match.tag("DigestConflict", () => Effect.fail(Problem.make("idempotency.digest-conflict"))), + Match.tag("ResponseExpired", () => Effect.fail(Problem.make("idempotency.response-expired"))), + Match.exhaustive, + ); /** - * Runs one idempotent command: current authority first, then the stored - * receipt, then the command, all in one serializable transaction that is - * retried once after a lost race. + * Runs one idempotent command: current authority first, then the stored receipt, then the + * command, all in one serializable transaction that is retried once after a lost race. */ const executeCommand = (input: { - readonly request: Request; readonly operationId: string; - readonly routeTemplate: string; - readonly identities: Readonly>; + readonly normalizedTarget: string; + readonly idempotencyKey: IdempotencyKey; readonly semanticRequest: CanonicalSemanticRequest; - readonly commandIdSchema: Schema.ConstraintDecoder; + readonly commandIdSchema: Schema.Codec; readonly prepare: Effect.Effect< { readonly credentialSubject: CredentialSubject; readonly execute: ( commandId: NoInfer, - ) => Effect.Effect; + ) => Effect.Effect; }, EPrepare, RPrepare @@ -108,21 +175,19 @@ const executeCommand = (input: { executeNativeHttpCommandPostgres( Effect.gen(function* () { const prepared = yield* input.prepare; - const idempotencyKey = yield* idempotencyKeyOf(input.request); - - const normalizedTarget = yield* semanticProblem( - () => normalizeTarget(input.routeTemplate, input.identities), - ["request.malformed"], - ); - const derived = yield* httpIdentity({ + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const derived = yield* commandIdentity({ credentialSubject: prepared.credentialSubject, qualifiedOperationId: input.operationId, - normalizedTarget, - idempotencyKey, + normalizedTarget: input.normalizedTarget, + idempotencyKey: input.idempotencyKey, }); - const commandId = yield* decodeRequest(input.commandIdSchema)(derived.commandId); + // A derived command ID always fits the domain's command ID. + const commandId = yield* Schema.decodeEffect(input.commandIdSchema)(derived.commandId).pipe( + Effect.orDie, + ); return { identity: { @@ -130,84 +195,65 @@ const executeCommand = (input: { requestSha256: semanticRequestDigest(input.semanticRequest), operationId: input.operationId, }, - execute: prepared - .execute(commandId) - .pipe(Effect.flatMap((response) => Effect.promise(() => responseCapsule(response)))), + execute: prepared.execute(commandId), }; }), { retry: "serialization-once" }, - ).pipe(Effect.flatMap(commandOutcomeResponse)); + ); + +/** The route of one interview, as the HTTP contract named it. A path it cannot spell is a defect. */ +const interviewTarget = (routeTemplate: string, interviewId: RecruitmentInterviewId) => + Effect.sync(() => normalizeTarget(routeTemplate, { interviewId })); -/** An interview command's committed representation, with the interview's new validator. */ -const interviewCommandResponse = ( +/** An interview command's committed answer, with the interview's new entity tag. */ +const interviewCommandCapsule = ( + schema: S, interviewId: RecruitmentInterviewId, personId: PersonId, - output: - | typeof ScheduleInterviewResponse.Type - | typeof CorrectInterviewAssessmentResponse.Type - | typeof FinalizeInterviewResponse.Type - | typeof CancelInterviewResponse.Type, + output: S["Type"], ) => Recruitment.use((service) => service.readInterviewSource(interviewId, personId)).pipe( - Effect.map( - (updated) => - new Response(JSON.stringify(output), { - status: 200, - headers: { - "cache-control": NO_STORE, - "content-type": "application/json", - etag: interviewETag(updated), - }, - }), - ), + Effect.flatMap((updated) => interviewCapsule(schema)(output, interviewETag(updated))), ); -/** Answers one invitation through its response capability with the transition its body names. */ -const respondToInvitation = ( - request: Request, - input: RecruitmentApiHttpOptions, - endpoint: - | typeof ConfirmInvitationEndpoint - | typeof RejectInvitationEndpoint - | typeof RequestNewInvitationTimeEndpoint, - readTransition: Effect.Effect, -) => +/** Answers one invitation through its response capability with the transition its payload names. */ +const respondToInvitation = (input: { + readonly headers: Headers.Headers; + readonly options: NativeRpcOptions; + readonly rpc: Pick; + readonly capability: string; + readonly ifMatch: StrongETag; + readonly transition: RecruitmentInvitationTransition; +}) => Effect.gen(function* () { - yield* requireNoQuery(request); - const ifMatch = yield* requiredIfMatchOf(request); - const capability = yield* invitationCapability(request); - const transition = yield* readTransition; - const now = yield* currentInstant(input.config.now); + const capability = yield* invitationCapability(input.capability); + const now = yield* currentInstant(input.options.config.recruitment.now); const source = yield* Recruitment.use((service) => service.readInvitationSnapshot(capability), ).pipe(Effect.map((snapshot) => snapshot.source)); yield* authorizeInvitationOperation({ - spec: Option.getOrThrow(reflectAccessSpec(endpoint)), + spec: Option.getOrThrow(reflectAccessSpec(input.rpc)), source, authorizationInstant: now, }); // An answered invitation fails as already responded, whatever its validator. if (source.responseState === "Pending") { - yield* requireCurrentETag(invitationETag(source), ifMatch); + yield* requireCurrentETag(invitationETag(source), input.ifMatch); } const updated = yield* Recruitment.use((service) => - service.transitionInvitation({ capability, transition, now }), + service.transitionInvitation({ capability, transition: input.transition, now }), ); - return new Response(null, { - status: 204, - headers: { - "cache-control": NO_STORE, - etag: invitationETag(updated.source), - vary: "Origin", - }, - }); + return { etag: invitationETag(updated.source) }; }).pipe( - recruitmentProblems(personPresentation(request), "dependency.unavailable"), + recruitmentProblems({ + presentation: personPresentation(input.headers), + unavailable: "dependency.unavailable", + }), // A capability holder is no person, and an invitation answers only its own problems. unreachable( "credential.missing", @@ -222,83 +268,94 @@ const respondToInvitation = ( ); /** Accepts one invitation. */ -export const confirmInvitation = (request: Request, input: RecruitmentApiHttpOptions) => - respondToInvitation( - request, - input, - ConfirmInvitationEndpoint, - readRecruitmentBody(request, input.config.maxBodyBytes).pipe( - Effect.flatMap((body) => - Schema.decodeUnknownEffect(ConfirmInvitationPayload)(body, { onExcessProperty: "error" }), - ), - // request.malformed is the only client problem the confirmation contract declares. - Effect.mapError((failure) => - isProblem(failure) && failure.code === "internal.error" - ? Problem.make("internal.error") - : Problem.make("request.malformed"), - ), - Effect.as(RecruitmentInvitationTransition.Confirm()), - ), - ); +export const confirmInvitation = ({ + headers, + payload, + options, +}: RecruitmentCall<{ readonly capability: string; readonly ifMatch: StrongETag }>) => + respondToInvitation({ + headers, + options, + rpc: ConfirmInvitation, + capability: payload.capability, + ifMatch: payload.ifMatch, + transition: RecruitmentInvitationTransition.Confirm(), + }); /** Rejects one invitation with an optional message. */ -export const rejectInvitation = (request: Request, input: RecruitmentApiHttpOptions) => - respondToInvitation( - request, - input, - RejectInvitationEndpoint, - readRecruitmentBody(request, input.config.maxBodyBytes).pipe( - Effect.flatMap(decodeRequest(InvitationRejectInput)), - Effect.map((body) => - body.message === undefined - ? RecruitmentInvitationTransition.Reject({}) - : RecruitmentInvitationTransition.Reject({ message: body.message }), - ), - ), - ); +export const rejectInvitation = ({ + headers, + payload, + options, +}: RecruitmentCall<{ + readonly capability: string; + readonly ifMatch: StrongETag; + readonly request: { readonly message?: string | undefined }; +}>) => + respondToInvitation({ + headers, + options, + rpc: RejectInvitation, + capability: payload.capability, + ifMatch: payload.ifMatch, + transition: + payload.request.message === undefined + ? RecruitmentInvitationTransition.Reject({}) + : RecruitmentInvitationTransition.Reject({ message: payload.request.message }), + }); /** Asks for another interview time with a message. */ -export const requestNewInvitationTime = ( - request: Request, - input: RecruitmentApiHttpOptions, -) => - respondToInvitation( - request, - input, - RequestNewInvitationTimeEndpoint, - readRecruitmentBody(request, input.config.maxBodyBytes).pipe( - Effect.flatMap(decodeRequest(InvitationRequestNewTimeInput)), - Effect.map((body) => - RecruitmentInvitationTransition.RequestNewTime({ message: body.message }), - ), - ), - ); - -export const createApplicationInterview = ( - request: Request, - applicationId: typeof PublicApplicationIdSchema.Type, - input: RecruitmentApiHttpOptions, -) => { - const presentation = personPresentation(request); +export const requestNewInvitationTime = ({ + headers, + payload, + options, +}: RecruitmentCall<{ + readonly capability: string; + readonly ifMatch: StrongETag; + readonly request: { readonly message: string }; +}>) => + respondToInvitation({ + headers, + options, + rpc: RequestNewInvitationTime, + capability: payload.capability, + ifMatch: payload.ifMatch, + transition: RecruitmentInvitationTransition.RequestNewTime({ + message: payload.request.message, + }), + }); + +export const createApplicationInterview = ({ + headers, + payload, + options, +}: RecruitmentCall<{ + readonly applicationId: typeof PublicApplicationIdSchema.Type; + readonly idempotencyKey: IdempotencyKey; + readonly request: CreateApplicationInterviewRequest; +}>) => { + const presentation = personPresentation(headers); + const { applicationId, request: body } = payload; + const config = options.config.recruitment; return Effect.gen(function* () { - yield* requireNoQuery(request); - - const body = yield* readRecruitmentBody(request, input.config.maxBodyBytes).pipe( - Effect.flatMap(decodeRequest(CreateApplicationInterviewRequest)), + const normalizedTarget = yield* Effect.sync(() => + normalizeTarget("/api/recruitment/applications/{applicationId}/interviews", { + applicationId, + }), ); - return yield* executeCommand({ - request, + const outcome = yield* executeCommand({ operationId: "recruitment.createApplicationInterview", - routeTemplate: "/api/recruitment/applications/{applicationId}/interviews", - identities: { applicationId }, + normalizedTarget, + idempotencyKey: payload.idempotencyKey, semanticRequest: { body }, commandIdSchema: RecruitmentAssignmentCommandId, prepare: Effect.gen(function* () { - const authorization = yield* resolveRequestPersonAuthorityInTransaction(request, { - now: input.config.now, - }); + const authorization = yield* resolveRequestPersonAuthorityInTransaction( + credentialRequestOf(headers), + { now: config.now }, + ); const { access, actor } = yield* Recruitment.use((service) => service.prepareAssignment({ @@ -316,7 +373,7 @@ export const createApplicationInterview = ( yield* authorizePerson( { - spec: Option.getOrThrow(reflectAccessSpec(AssignApplicantEndpoint)), + spec: Option.getOrThrow(reflectAccessSpec(CreateApplicationInterview)), credential: authorization.credential, personId: actor.personId, resolution: { @@ -354,7 +411,7 @@ export const createApplicationInterview = ( { actor, now: authorization.authorizationInstant, - interviewId: input.config.nextInterviewId(), + interviewId: config.nextInterviewId(), }, ), ); @@ -366,29 +423,30 @@ export const createApplicationInterview = ( service.readInterviewSource(interview.interviewId, actor.personId), ); - const location = normalizeTarget("/api/recruitment/interviews/{interviewId}", { - interviewId: interview.interviewId, - }); + const location = yield* interviewTarget( + "/api/recruitment/interviews/{interviewId}", + interview.interviewId, + ); - return new Response(yield* jsonText(output), { - status: 201, - headers: { - "cache-control": NO_STORE, - "content-type": "application/json", - etag: interviewETag(source), - location, - }, - }); + return yield* interviewCapsule(RecruitmentInterviewResource)( + output, + interviewETag(source), + { location }, + ); }), }; }), }); + + const stored = yield* interviewCommandOutcome(RecruitmentInterviewResource)(outcome); + + return { interview: stored.body, etag: stored.etag }; }).pipe( raceProblems, - recruitmentProblems(presentation, "dependency.unavailable"), + recruitmentProblems({ presentation: presentation, unavailable: "dependency.unavailable" }), commandReceiptProblems, - // The interview was created or replayed in this transaction, and assignment - // answers no scheduling, conduct, or invitation problem. + // The interview was created or replayed in this transaction, and assignment answers no + // scheduling, conduct, or invitation problem. unreachable( "recruitment.interview-not-found", "precondition.failed", @@ -400,35 +458,37 @@ export const createApplicationInterview = ( ); }; -export const scheduleInterview = ( - request: Request, - interviewId: RecruitmentInterviewId, - input: RecruitmentApiHttpOptions, -) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - const body = yield* readRecruitmentBody(request, input.config.maxBodyBytes).pipe( - Effect.flatMap(decodeRequest(ScheduleInterviewRequest)), - ); - - const ifMatch = yield* requiredIfMatchOf(request); +export const scheduleInterview = ({ + headers, + payload, + options, +}: RecruitmentCall<{ + readonly interviewId: RecruitmentInterviewId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; + readonly request: ScheduleInterviewRequest; +}>) => { + const { interviewId, ifMatch, request: body } = payload; + const config = options.config.recruitment; - return yield* executeCommand({ - request, + return Effect.gen(function* () { + const outcome = yield* executeCommand({ operationId: "recruitment.scheduleInterview", - routeTemplate: "/api/recruitment/interviews/{interviewId}:schedule", - identities: { interviewId }, + normalizedTarget: yield* interviewTarget( + "/api/recruitment/interviews/{interviewId}:schedule", + interviewId, + ), + idempotencyKey: payload.idempotencyKey, semanticRequest: semanticMutationRequest(body, ifMatch), commandIdSchema: RecruitmentScheduleCommandId, prepare: Effect.gen(function* () { - const authorization = yield* interviewAuthorizationInTransaction( - request, + const authorization = yield* interviewAuthorizationInTransaction({ + headers, interviewId, - ScheduleInterviewEndpoint, - true, - input, - ); + rpc: ScheduleInterview, + allowLeader: true, + now: config.now, + }); return { credentialSubject: `Person:${authorization.actor.personId}` as const, @@ -447,8 +507,8 @@ export const scheduleInterview = ( { actor: authorization.actor, now: authorization.authorizationInstant, - invitationId: input.config.nextInvitationId(), - responseCapability: input.config.nextResponseCapability(), + invitationId: config.nextInvitationId(), + responseCapability: config.nextResponseCapability(), }, ), ); @@ -460,7 +520,8 @@ export const scheduleInterview = ( notificationState: observation.notificationState, }); - return yield* interviewCommandResponse( + return yield* interviewCommandCapsule( + ScheduleInterviewResponse, interviewId, authorization.actor.personId, output, @@ -469,12 +530,19 @@ export const scheduleInterview = ( }; }), }); + + const stored = yield* interviewCommandOutcome(ScheduleInterviewResponse)(outcome); + + return { result: stored.body, etag: stored.etag }; }).pipe( raceProblems, - recruitmentProblems(personPresentation(request), "dependency.unavailable"), + recruitmentProblems({ + presentation: personPresentation(headers), + unavailable: "dependency.unavailable", + }), commandReceiptProblems, - // Scheduling reads its interview's own application, which cannot vanish, and - // answers no assignment, conduct, or invitation problem. + // Scheduling reads its interview's own application, which cannot vanish, and answers no + // assignment, conduct, or invitation problem. unreachable( ...admissionPeriodProblems, ...assignmentProblems, @@ -483,36 +551,39 @@ export const scheduleInterview = ( "invitation.already-responded", ), ); +}; -export const correctInterviewAssessment = ( - request: Request, - interviewId: RecruitmentInterviewId, - input: RecruitmentApiHttpOptions, -) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - const body = yield* readRecruitmentBody(request, input.config.maxBodyBytes).pipe( - Effect.flatMap(decodeRequest(CorrectInterviewAssessmentRequest)), - ); - - const ifMatch = yield* requiredIfMatchOf(request); +export const correctInterviewAssessment = ({ + headers, + payload, + options, +}: RecruitmentCall<{ + readonly interviewId: RecruitmentInterviewId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; + readonly request: CorrectInterviewAssessmentRequest; +}>) => { + const { interviewId, ifMatch, request: body } = payload; + const config = options.config.recruitment; - return yield* executeCommand({ - request, + return Effect.gen(function* () { + const outcome = yield* executeCommand({ operationId: "recruitment.correctInterviewAssessment", - routeTemplate: "/api/recruitment/interviews/{interviewId}:correct", - identities: { interviewId }, + normalizedTarget: yield* interviewTarget( + "/api/recruitment/interviews/{interviewId}:correct", + interviewId, + ), + idempotencyKey: payload.idempotencyKey, semanticRequest: semanticMutationRequest(body, ifMatch), commandIdSchema: RecruitmentInterviewCorrectionCommandId, prepare: Effect.gen(function* () { - const authorization = yield* interviewAuthorizationInTransaction( - request, + const authorization = yield* interviewAuthorizationInTransaction({ + headers, interviewId, - CorrectInterviewAssessmentEndpoint, - false, - input, - ); + rpc: CorrectInterviewAssessment, + allowLeader: false, + now: config.now, + }); return { credentialSubject: `Person:${authorization.actor.personId}` as const, @@ -544,7 +615,8 @@ export const correctInterviewAssessment = ( replayed, }); - return yield* interviewCommandResponse( + return yield* interviewCommandCapsule( + CorrectInterviewAssessmentResponse, interviewId, authorization.actor.personId, output, @@ -553,9 +625,16 @@ export const correctInterviewAssessment = ( }; }), }); + + const stored = yield* interviewCommandOutcome(CorrectInterviewAssessmentResponse)(outcome); + + return { result: stored.body, etag: stored.etag }; }).pipe( raceProblems, - recruitmentProblems(personPresentation(request), "dependency.unavailable"), + recruitmentProblems({ + presentation: personPresentation(headers), + unavailable: "dependency.unavailable", + }), commandReceiptProblems, unreachable( ...admissionPeriodProblems, @@ -565,33 +644,39 @@ export const correctInterviewAssessment = ( "invitation.already-responded", ), ); +}; -export const finalizeInterview = ( - request: Request, - interviewId: RecruitmentInterviewId, - input: RecruitmentApiHttpOptions, -) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const rawBody = yield* readRecruitmentBody(request, input.config.maxBodyBytes); - const ifMatch = yield* requiredIfMatchOf(request); - const body = yield* decodeRequest(FinalizeInterviewRequest)(rawBody); +export const finalizeInterview = ({ + headers, + payload, + options, +}: RecruitmentCall<{ + readonly interviewId: RecruitmentInterviewId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; + readonly request: FinalizeInterviewRequest; +}>) => { + const { interviewId, ifMatch, request: body } = payload; + const config = options.config.recruitment; - return yield* executeCommand({ - request, + return Effect.gen(function* () { + const outcome = yield* executeCommand({ operationId: "recruitment.finalizeInterview", - routeTemplate: "/api/recruitment/interviews/{interviewId}:finalize", - identities: { interviewId }, + normalizedTarget: yield* interviewTarget( + "/api/recruitment/interviews/{interviewId}:finalize", + interviewId, + ), + idempotencyKey: payload.idempotencyKey, semanticRequest: semanticMutationRequest(body, ifMatch), commandIdSchema: RecruitmentConductCommandId, prepare: Effect.gen(function* () { - const authorization = yield* interviewAuthorizationInTransaction( - request, + const authorization = yield* interviewAuthorizationInTransaction({ + headers, interviewId, - FinalizeInterviewEndpoint, - false, - input, - ); + rpc: FinalizeInterview, + allowLeader: false, + now: config.now, + }); return { credentialSubject: `Person:${authorization.actor.personId}` as const, @@ -622,7 +707,8 @@ export const finalizeInterview = ( cancellationState: observation.cancellationState, }); - return yield* interviewCommandResponse( + return yield* interviewCommandCapsule( + FinalizeInterviewResponse, interviewId, authorization.actor.personId, output, @@ -631,9 +717,16 @@ export const finalizeInterview = ( }; }), }); + + const stored = yield* interviewCommandOutcome(FinalizeInterviewResponse)(outcome); + + return { result: stored.body, etag: stored.etag }; }).pipe( raceProblems, - recruitmentProblems(personPresentation(request), "dependency.unavailable"), + recruitmentProblems({ + presentation: personPresentation(headers), + unavailable: "dependency.unavailable", + }), commandReceiptProblems, unreachable( ...admissionPeriodProblems, @@ -643,33 +736,39 @@ export const finalizeInterview = ( "invitation.already-responded", ), ); +}; -export const cancelInterview = ( - request: Request, - interviewId: RecruitmentInterviewId, - input: RecruitmentApiHttpOptions, -) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const rawBody = yield* readRecruitmentBody(request, input.config.maxBodyBytes); - const ifMatch = yield* requiredIfMatchOf(request); - const body = yield* decodeRequest(CancelInterviewRequest)(rawBody); +export const cancelInterview = ({ + headers, + payload, + options, +}: RecruitmentCall<{ + readonly interviewId: RecruitmentInterviewId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; +}>) => { + const { interviewId, ifMatch } = payload; + const config = options.config.recruitment; - return yield* executeCommand({ - request, + return Effect.gen(function* () { + const outcome = yield* executeCommand({ operationId: "recruitment.cancelInterview", - routeTemplate: "/api/recruitment/interviews/{interviewId}:cancel", - identities: { interviewId }, - semanticRequest: semanticMutationRequest(body, ifMatch), + normalizedTarget: yield* interviewTarget( + "/api/recruitment/interviews/{interviewId}:cancel", + interviewId, + ), + idempotencyKey: payload.idempotencyKey, + // The HTTP contract took an exact empty JSON object, which the digest keeps. + semanticRequest: semanticMutationRequest({}, ifMatch), commandIdSchema: RecruitmentCancellationCommandId, prepare: Effect.gen(function* () { - const authorization = yield* interviewAuthorizationInTransaction( - request, + const authorization = yield* interviewAuthorizationInTransaction({ + headers, interviewId, - CancelInterviewEndpoint, - false, - input, - ); + rpc: CancelInterview, + allowLeader: false, + now: config.now, + }); return { credentialSubject: `Person:${authorization.actor.personId}` as const, @@ -699,7 +798,8 @@ export const cancelInterview = ( cancellationState: observation.cancellationState, }); - return yield* interviewCommandResponse( + return yield* interviewCommandCapsule( + CancelInterviewResponse, interviewId, authorization.actor.personId, output, @@ -708,9 +808,16 @@ export const cancelInterview = ( }; }), }); + + const stored = yield* interviewCommandOutcome(CancelInterviewResponse)(outcome); + + return { result: stored.body, etag: stored.etag }; }).pipe( raceProblems, - recruitmentProblems(personPresentation(request), "dependency.unavailable"), + recruitmentProblems({ + presentation: personPresentation(headers), + unavailable: "dependency.unavailable", + }), commandReceiptProblems, // Cancellation checks no invitation, and its instant always fits the clock. unreachable( @@ -723,3 +830,4 @@ export const cancelInterview = ( "invitation.already-responded", ), ); +}; diff --git a/apps/backend/src/recruitment/context.ts b/apps/backend/src/recruitment/context.ts new file mode 100644 index 000000000..ca36b290f --- /dev/null +++ b/apps/backend/src/recruitment/context.ts @@ -0,0 +1,46 @@ +/** Recruitment actors: the board actor of one person's organization authority. */ +import { InactiveActor } from "@vektorprogrammet/domain/admission-period"; +import { + RecruitmentInactiveActor, + RecruitmentRoleDenied, + type RecruitmentActor, +} from "@vektorprogrammet/domain/recruitment"; +import { Cause, Effect, Schema } from "effect"; +import type { Headers } from "effect/unstable/http"; +import { recruitmentBoardActorFrom, resolveRequestPersonAuthority } from "../authority.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +/** One recruitment RPC call: the request headers, the decoded payload, and the handler options. */ +export interface RecruitmentCall { + readonly headers: Headers.Headers; + readonly payload: Payload; + readonly options: NativeRpcOptions; +} + +const isRecruitmentActorDenial = Schema.is( + Schema.Union([InactiveActor, RecruitmentInactiveActor, RecruitmentRoleDenied]), +); + +/** + * The recruitment board actor of the request's person: the one department where the person is an + * active member or administrator, or the global administrator. + * + * @remarks + * It resolves the request's person and organization authority at `now`, or at the Clock's instant + * without it, and maps the authority to its board actor. The mapping throws only its denials, + * which become failures; anything else that it throws is an unknown failure, which the recruitment + * problems answer as internal.error. + */ +export const recruitmentBoardActor = (input: { + readonly headers: Headers.Headers; + readonly now: (() => string) | undefined; +}) => + resolveRequestPersonAuthority(credentialRequestOf(input.headers), { now: input.now }).pipe( + Effect.flatMap((authority) => + Effect.try({ + try: (): RecruitmentActor => recruitmentBoardActorFrom(authority), + catch: (cause) => (isRecruitmentActorDenial(cause) ? cause : new Cause.UnknownError(cause)), + }), + ), + ); diff --git a/apps/backend/src/recruitment/delivery.ts b/apps/backend/src/recruitment/delivery.ts index fe071e6df..ef5e1ed4d 100644 --- a/apps/backend/src/recruitment/delivery.ts +++ b/apps/backend/src/recruitment/delivery.ts @@ -32,7 +32,13 @@ export const recruitmentNotificationConfig = ( return undefined; } - if (mode !== "http" || !endpointValue || !token) { + if ( + mode !== "http" || + endpointValue === undefined || + endpointValue === "" || + token === undefined || + token === "" + ) { throw new Error("Recruitment notification HTTP configuration is incomplete"); } @@ -41,10 +47,10 @@ export const recruitmentNotificationConfig = ( if ( (endpoint.protocol !== "https:" && !(endpoint.protocol === "http:" && loopback)) || - endpoint.username || - endpoint.password || - endpoint.search || - endpoint.hash + endpoint.username !== "" || + endpoint.password !== "" || + endpoint.search !== "" || + endpoint.hash !== "" ) { throw new Error( "Recruitment delivery requires HTTPS or loopback HTTP without URL credentials, query, or fragment", @@ -105,12 +111,11 @@ export const HttpRecruitmentNotificationsLive = ( providerReference: `http:${request.effectId}`, }), ), - Effect.mapError( - () => - new RecruitmentNotificationDeliveryError({ - effectId: request.effectId, - message: "Recruitment notification submission was not acknowledged", - }), + Effect.mapError(() => + RecruitmentNotificationDeliveryError.make({ + effectId: request.effectId, + message: "Recruitment notification submission was not acknowledged", + }), ), ); diff --git a/apps/backend/src/recruitment/http-context.ts b/apps/backend/src/recruitment/http-context.ts deleted file mode 100644 index 7cade79af..000000000 --- a/apps/backend/src/recruitment/http-context.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** Recruitment HTTP composition options. */ -import type { - InactiveActor, - UnauthenticatedActor, -} from "@vektorprogrammet/domain/admission-period"; -import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import type { - OrganizationDecodeError, - OrganizationPersistenceError, -} from "@vektorprogrammet/domain/organization"; -import type { - RecruitmentActor, - RecruitmentInactiveActor, - RecruitmentRoleDenied, -} from "@vektorprogrammet/domain/recruitment"; -import type { Cause, Effect } from "effect"; -import type { RecruitmentApiConfig } from "./config.js"; - -/** Every way a request's recruitment board actor can fail to resolve. */ -export type RecruitmentActorFailure = - | IdentityEngineError - | UnauthenticatedActor - | OrganizationDecodeError - | OrganizationPersistenceError - | InactiveActor - | RecruitmentInactiveActor - | RecruitmentRoleDenied - | Cause.UnknownError; - -export interface RecruitmentApiHttpOptions { - readonly config: RecruitmentApiConfig; - readonly resolveActor: ( - request: Request, - ) => Effect.Effect; -} diff --git a/apps/backend/src/recruitment/http-decode.ts b/apps/backend/src/recruitment/http-decode.ts deleted file mode 100644 index 269d9de16..000000000 --- a/apps/backend/src/recruitment/http-decode.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** Recruitment request decoding: query strings, invitation capabilities, and JSON bodies. */ -import { - InterviewReportQuery, - RecruitmentAssignmentBoardQuerySchema, - RecruitmentInvitationCapabilitySchema, -} from "@vektorprogrammet/domain/recruitment"; -import { Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Schema } from "effect"; -import { decodeRequest, readJsonBody } from "../http-api/problem.js"; - -/** - * Every recruitment request body is one bounded `application/json` document. - * - * @remarks - * It reads the body with `readJsonBody` for the media type `application/json`, with or without - * parameters, up to `maxBodyBytes`. `Effect.suspend` takes the body reader when the effect runs, - * not when it is built, so a handler that builds the effect and never runs it leaves the body - * unread. - * - * @sideEffects Reads and consumes the request body when the effect runs. - * - * @example - * ```ts - * const body = yield* readRecruitmentBody(request, input.config.maxBodyBytes); - * ``` - * - * @avoid A recruitment handler that reads its body with its own media type pattern or bound: the - * recruitment endpoints then accept different bodies. Read it with this. - * - * @construct http-problem - */ -export const readRecruitmentBody = ( - request: Request, - maxBodyBytes: number, -): Effect.Effect< - Schema.Json, - | Problem<"internal.error"> - | Problem<"media-type.unsupported"> - | Problem<"request.malformed"> - | Problem<"request.too-large"> -> => Effect.suspend(() => readJsonBody(request, /^application\/json(?:\s*;|$)/iu, maxBodyBytes)); - -/** Decodes the single `status` assignment-board query parameter. */ -export const decodeBoardQuery = (request: Request) => { - const parameters = [...new URL(request.url).searchParams]; - - return parameters.length !== 1 || parameters[0]?.[0] !== "status" - ? Effect.fail(Problem.make("request.malformed")) - : decodeRequest(RecruitmentAssignmentBoardQuerySchema)({ status: parameters[0][1] }); -}; - -/** Decodes interview-report query parameters; each parameter may appear once. */ -export const decodeInterviewReportQuery = (request: Request) => { - const values = new URL(request.url).searchParams; - - return [...values.keys()].some((key) => values.getAll(key).length !== 1) - ? Effect.fail(Problem.make("request.malformed")) - : decodeRequest(InterviewReportQuery)(Object.fromEntries(values)); -}; - -/** The invitation response capability; an absent or malformed one names no invitation. */ -export const invitationCapability = (request: Request) => - Schema.decodeUnknownEffect(RecruitmentInvitationCapabilitySchema)( - request.headers.get("x-recruitment-invitation-capability"), - { onExcessProperty: "error" }, - ).pipe(Effect.mapError(() => Problem.make("resource.not-found"))); diff --git a/apps/backend/src/recruitment/http.test.ts b/apps/backend/src/recruitment/http.test.ts deleted file mode 100644 index 4551b5002..000000000 --- a/apps/backend/src/recruitment/http.test.ts +++ /dev/null @@ -1,996 +0,0 @@ -import { IdentitySnapshot } from "@vektorprogrammet/database"; -import { - AdmissionPeriodActorSchema, - InactiveActor, - UnauthenticatedActor, -} from "@vektorprogrammet/domain/admission-period"; -import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; -import { - PrincipalSchema, - Scope, - CredentialOutcomeSchema, - CredentialMechanismSchema, - evaluateAccess, - decodeGrant, - GrantId, - AuthorityRef, - AuthorityVersion, - AuthorizationInstant, - CredentialEvidenceRef, - DomainId, -} from "@vektorprogrammet/domain/authz"; -import { evaluateRequirement, RequirementId } from "@vektorprogrammet/domain/authz"; -import { IdentityActor, IdentitySessionNotFound } from "@vektorprogrammet/domain/identity"; -import { - DepartmentId, - Organization, - OrganizationPersistenceError, - PersonId, - type OrganizationOperations, -} from "@vektorprogrammet/domain/organization"; -import { ProfileContactNotFound } from "@vektorprogrammet/domain/profile"; -import { - InterviewQuestionsUnavailable, - InterviewSchemaId, - Recruitment, - RecruitmentAdmissionPeriodNotFound, - RecruitmentAmbiguousAdmissionPeriod, - RecruitmentApplicationAlreadyAssigned, - RecruitmentApplicationNotFound, - RecruitmentAssignmentCommandConflict, - RecruitmentAssignmentCommandId, - RecruitmentConductCommandId, - RecruitmentConductValidationError, - RecruitmentDecodeError, - RecruitmentInactiveActor, - RecruitmentInterviewAlreadyCancelled, - RecruitmentInterviewAlreadyFinalized, - RecruitmentInterviewAlreadyScheduled, - RecruitmentInterviewerNotEligible, - RecruitmentInterviewId, - RecruitmentInterviewNotFound, - RecruitmentInterviewNotScheduled, - RecruitmentInterviewSchemaInactive, - RecruitmentInterviewSchemaNotFound, - RecruitmentInterviewStaleRevision, - RecruitmentInvalidContext, - RecruitmentInvitationAlreadyResponded, - RecruitmentInvitationHttpSnapshotSchema, - RecruitmentInvitationId, - RecruitmentInvitationNotAccepted, - RecruitmentInvitationNotFound, - RecruitmentInvitationTransition, - RecruitmentLifecycleCommandConflict, - RecruitmentPersistenceError, - RecruitmentRoleDenied, - RecruitmentScheduleCommandConflict, - RecruitmentScheduleCommandId, - RecruitmentScheduleInPast, - RecruitmentSchedulingBoardSchema, - RecruitmentScopeDenied, - type RecruitmentFailure, - type RecruitmentOperations, -} from "@vektorprogrammet/domain/recruitment"; -import { - NativeProblem, - ReadInterviewReportEndpoint, - SchedulingBoard, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { DateTime, Effect, Layer, Option, Predicate, Schema } from "effect"; -import { describe, expect, it } from "@effect/vitest"; -import { jsonText } from "../http-api/problem.js"; -import { PreconditionDecision, evaluateMutationPrecondition } from "../http-semantics.js"; -import { makeRecruitmentTestHttp } from "../test/native-http.js"; -import { recruitmentInterviewAccessContext } from "./http-access.js"; -import type { RecruitmentApiHttpOptions } from "./http-context.js"; -import { interviewETag, invitationETag, schedulingBoardWithETags } from "./http-representation.js"; - -const at = "2031-09-15T12:00:00.000Z"; - -const departmentId = DepartmentId.make("http-department"); - -const leader = AdmissionPeriodActorSchema.cases.DepartmentAdministrator.make({ - personId: PersonId.make("http-leader"), - departmentId, - active: true, -}); - -const observation = { - scheduledAt: "2031-09-20T10:00:00.000Z", - room: "A101", - campus: "Gløshaugen", - responseState: "Pending", - responseMessage: null, -} as const; - -const snapshot = Schema.decodeSync(RecruitmentInvitationHttpSnapshotSchema)({ - source: { - capabilitySha256: "a".repeat(64), - invitationId: "http-invitation", - interviewId: "http-interview", - departmentId, - scheduleRevision: 2, - responseRevision: 0, - responseState: "Pending", - supersededAt: null, - }, - observation, -}); - -/** The failure every domain call answers; without one, an invitation reads and answers. */ -let injected: RecruitmentFailure | undefined; - -const transitions: Array = []; - -const injectedFailure = () => - Effect.suspend(() => - injected === undefined ? Effect.die("no recruitment failure injected") : Effect.fail(injected), - ); - -const recruitment = { - readInvitationSnapshot: () => - Effect.suspend(() => - injected === undefined ? Effect.succeed(snapshot) : Effect.fail(injected), - ), - transitionInvitation: ({ - transition, - }: { - readonly transition: RecruitmentInvitationTransition; - }) => - Effect.sync(() => { - transitions.push(transition); - - return { ...snapshot, source: { ...snapshot.source, responseRevision: 1 } }; - }), - readAssignmentBoard: injectedFailure, - readSchedulingBoard: injectedFailure, - resolveInterviewReportLeader: injectedFailure, - prepareAssignment: injectedFailure, - prepareInterview: injectedFailure, -} satisfies Partial; - -const organization = { - resolvePersonAuthority: (personId: PersonId) => - Effect.succeed({ - personId, - evaluatedAt: at, - globalAdministrator: "Absent", - memberships: [], - nationalBoardSeats: [], - delegations: [], - }), -} satisfies Partial; - -const identitySnapshot = IdentitySnapshot.of({ - resolveSession: (cookie) => { - const person = /better-auth\.session_token=([^;]+)/u.exec(cookie ?? "")?.[1]; - - return person === undefined - ? Effect.fail(new IdentitySessionNotFound()) - : Effect.succeed( - new IdentityActor({ - personId: PersonId.make(person), - sessionId: `session-${person}`, - expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), - }), - ); - }, - revokeCurrentSession: () => Effect.die("unexpected session mutation"), - revokeSession: () => Effect.die("unexpected session mutation"), - revokeOtherSessions: () => Effect.die("unexpected session mutation"), - revokeAllSessions: () => Effect.die("unexpected session mutation"), -}); - -/** The recruitment ingress as a department leader reaches it. */ -const ingress = ( - resolveActor: RecruitmentApiHttpOptions["resolveActor"] = () => Effect.succeed(leader), -) => - makeRecruitmentTestHttp( - { - config: { - maxBodyBytes: 256, - now: () => at, - nextInterviewId: () => RecruitmentInterviewId.make("http-unexpected-interview"), - nextInvitationId: () => RecruitmentInvitationId.make("http-unexpected-invitation"), - nextResponseCapability: () => "unexpected".padEnd(43, "_"), - }, - resolveActor, - }, - Layer.mergeAll( - Layer.mock(Recruitment, recruitment), - Layer.mock(Organization, organization), - Layer.succeed(IdentitySnapshot, identitySnapshot), - ), - ); - -/** An invitation capability holder's read (`""`) or response. */ -const invitation = ( - action: "" | ":confirm" | ":reject" | ":request-new-time", - init: { - readonly headers?: Record; - readonly body?: string | ReadableStream; - } = {}, -) => { - const headers = new Headers({ "x-recruitment-invitation-capability": "http".padEnd(43, "_") }); - - if (action !== "") { - headers.set("content-type", "application/json"); - headers.set("if-match", invitationETag(snapshot.source)); - headers.set("origin", "http://127.0.0.1:5174"); - } - - for (const [name, value] of Object.entries(init.headers ?? {})) headers.set(name, value); - - return new Request(`http://backend.test/api/recruitment/invitation-response${action}`, { - method: action === "" ? "GET" : "POST", - headers, - body: init.body, - duplex: "half", - } satisfies RequestInit & { readonly duplex: "half" }); -}; - -/** A request of a person with a current session; a method makes it a command. */ -const person = ( - path: string, - init: { - readonly method?: string; - readonly body?: string; - readonly headers?: Record; - } = {}, -) => { - const headers = new Headers({ cookie: "better-auth.session_token=http-leader" }); - - if (init.method !== undefined) { - headers.set("content-type", "application/json"); - headers.set("idempotency-key", "http-command".padEnd(22, "0")); - headers.set("origin", "http://127.0.0.1:5174"); - } - - for (const [name, value] of Object.entries(init.headers ?? {})) headers.set(name, value); - - return new Request(`http://backend.test${path}`, { - method: init.method ?? "GET", - headers, - body: init.body, - }); -}; - -const anyValidator = '"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"'; - -const schedulingBoard = () => person("/api/recruitment/interviews"); - -const assignmentBoard = () => person("/api/recruitment/application-assignments?status=new"); - -const interviewConduct = () => person("/api/recruitment/interviews/http-interview"); - -const interviewReport = () => person("/api/recruitment/interview-report"); - -const createInterview = () => - person("/api/recruitment/applications/http-application/interviews", { - method: "POST", - body: JSON.stringify({ interviewerPersonId: "http-leader", interviewSchemaId: "http-schema" }), - }); - -const scheduleInterview = () => - person("/api/recruitment/interviews/http-interview:schedule", { - method: "POST", - headers: { "if-match": anyValidator }, - body: JSON.stringify({ - scheduledAt: "2031-09-20T10:00:00.000Z", - room: "A101", - campus: null, - mapLink: null, - message: "Velkommen.", - }), - }); - -const finalizeInterview = () => - person("/api/recruitment/interviews/http-interview:finalize", { - method: "POST", - headers: { "if-match": anyValidator }, - body: JSON.stringify({ - answers: [{ questionId: "q-1", answer: "Svar" }], - score: { explanatoryPower: 4, roleModel: 5, suitability: 6 }, - recommendation: "Ja", - }), - }); - -const invitationResponse = () => invitation(""); - -const rejectInvitation = () => invitation(":reject", { body: "{}" }); - -const problemOf = (response: Response) => - Effect.gen(function* () { - const problem = yield* Schema.decodeUnknownEffect(NativeProblem)( - yield* Effect.promise(() => response.json()), - ); - - return { status: response.status, code: problem.code }; - }); - -describe("native recruitment HTTP boundary", () => { - it.live("accepts one bounded JSON object and rejects invalid transport bodies", () => - Effect.gen(function* () { - const http = ingress(); - transitions.length = 0; - - const accepted = yield* http.fetch( - invitation(":reject", { body: yield* jsonText({ message: "Another time" }) }), - ); - - expect(accepted.status).toBe(204); - expect(transitions).toEqual([ - RecruitmentInvitationTransition.Reject({ message: "Another time" }), - ]); - - expect( - yield* problemOf( - yield* http.fetch( - invitation(":reject", { body: '{"message":"first","message":"second"}' }), - ), - ), - ).toEqual({ status: 400, code: "request.malformed" }); - - expect( - yield* problemOf( - yield* http.fetch( - invitation(":reject", { headers: { "content-type": "text/plain" }, body: "{}" }), - ), - ), - ).toEqual({ status: 415, code: "media-type.unsupported" }); - - // request.malformed is the only client problem a confirmation declares. - expect( - yield* problemOf( - yield* http.fetch( - invitation(":confirm", { body: yield* jsonText({ unexpected: "x".repeat(256) }) }), - ), - ), - ).toEqual({ status: 400, code: "request.malformed" }); - - let cancelled = false; - let chunks = 0; - - const streamed = new ReadableStream({ - pull: (controller) => { - chunks += 1; - - if (chunks > 32) controller.close(); - else controller.enqueue(new Uint8Array(12)); - }, - cancel: () => { - cancelled = true; - }, - }); - - expect( - yield* problemOf(yield* http.fetch(invitation(":reject", { body: streamed }))), - ).toEqual({ - status: 413, - code: "request.too-large", - }); - expect(cancelled).toBe(true); - }), - ); - - it.live("applies strong validators only after the private representation exists", () => - Effect.gen(function* () { - const http = ingress(); - const etag = invitationETag(snapshot.source); - const fresh = yield* http.fetch(invitation("")); - - expect({ - status: fresh.status, - etag: fresh.headers.get("etag"), - cacheControl: fresh.headers.get("cache-control"), - vary: fresh.headers.get("vary"), - body: yield* Effect.promise(() => fresh.json()), - }).toEqual({ - status: 200, - etag, - cacheControl: "private, no-store", - vary: "Origin", - body: observation, - }); - - const notModified = yield* http.fetch(invitation("", { headers: { "if-none-match": etag } })); - - expect({ - status: notModified.status, - etag: notModified.headers.get("etag"), - cacheControl: notModified.headers.get("cache-control"), - vary: notModified.headers.get("vary"), - body: yield* Effect.promise(() => notModified.text()), - }).toEqual({ - status: 304, - etag, - cacheControl: "private, no-store", - vary: "Origin", - body: "", - }); - - expect( - yield* problemOf( - yield* http.fetch(invitation("", { headers: { "if-match": '"vkr2.stale"' } })), - ), - ).toEqual({ status: 412, code: "precondition.failed" }); - }), - ); - - it.live("rejects an invitation capability presented beside a session cookie or a bearer", () => - Effect.gen(function* () { - const http = ingress(); - transitions.length = 0; - - // The capability is the request's one credential in every invitation operation. - const operations = [ - ["", undefined], - [":confirm", "{}"], - [":reject", "{}"], - [":request-new-time", yield* jsonText({ message: "Kan vi møtes torsdag?" })], - ] as const; - - const answer = (response: Response) => - Effect.gen(function* () { - return response.ok - ? { status: response.status } - : { - ...(yield* problemOf(response)), - challenge: response.headers.get("www-authenticate"), - }; - }); - - for (const [header, credential] of [ - ["cookie", "theme=dark; better-auth.session_token=http-leader"], - ["authorization", "Bearer http-leader-bearer"], - ] as const) { - for (const [action, body] of operations) { - const response = yield* http.fetch( - invitation(action, { headers: { [header]: credential }, body }), - ); - - expect({ action, header, ...(yield* answer(response)) }).toEqual({ - action, - header, - status: 401, - code: "credential.invalid", - challenge: 'RecruitmentInvitationCapability realm="native-api"', - }); - } - } - - expect(transitions).toEqual([]); - // A cookie without a session is no second credential. - expect( - yield* answer(yield* http.fetch(invitation("", { headers: { cookie: "theme=dark" } }))), - ).toEqual({ status: 200 }); - }), - ); - - it("exposes mutation-compatible strong ETags on scheduling items", () => { - const board = Schema.decodeSync(RecruitmentSchedulingBoardSchema)( - { - departmentId: "department-1", - interviews: [ - { - interviewId: "interview-1", - applicationId: "application-1", - departmentId: "department-1", - interviewer: { - personId: "interviewer-1", - displayName: "Ivar Interviewer", - email: "ivar@example.org", - phone: "+47 900 00 001", - }, - coInterviewer: null, - applicant: { - applicationId: "application-1", - applicantId: "applicant-1", - firstName: "Ada", - lastName: "Applicant", - email: "ada@example.org", - phone: "+47 900 00 002", - }, - revision: 2, - schedule: null, - notificationState: null, - responseState: null, - responseMessage: null, - }, - ], - }, - { onExcessProperty: "error" }, - ); - - const authority = [ - { - kind: "Membership" as const, - identity: "membership-1", - revisions: [4, 5, 6], - }, - ]; - - expect(() => - Schema.decodeUnknownSync(SchedulingBoard)(board, { onExcessProperty: "error" }), - ).toThrow(); - - const tagged = schedulingBoardWithETags(board, authority); - - const decoded = Schema.decodeSync(SchedulingBoard)(tagged, { - onExcessProperty: "error", - }); - - const boardTag = decoded.interviews[0]!.etag; - - const mutationTag = interviewETag({ - interviewId: board.interviews[0]!.interviewId, - departmentId: board.interviews[0]!.departmentId, - interviewerPersonId: board.interviews[0]!.interviewer.personId, - coInterviewerPersonId: null, - interviewRevision: board.interviews[0]!.revision, - authority, - }); - - const afterInterviewRevision = schedulingBoardWithETags( - { - ...board, - interviews: [{ ...board.interviews[0]!, revision: 3 }], - }, - authority, - ).interviews[0]!.etag; - - const afterCoInterviewerChange = schedulingBoardWithETags( - { - ...board, - interviews: [ - { - ...board.interviews[0]!, - coInterviewer: { - personId: PersonId.make("co-interviewer-1"), - displayName: "Cora Co-interviewer", - }, - }, - ], - }, - authority, - ).interviews[0]!.etag; - - const afterAuthorityRevision = schedulingBoardWithETags(board, [ - { ...authority[0]!, revisions: [4, 5, 7] }, - ]).interviews[0]!.etag; - - expect(boardTag).toMatch(/^"vkr2\.[A-Za-z0-9_-]{43}"$/u); - expect(boardTag).toBe(mutationTag); - expect(afterInterviewRevision).not.toBe(boardTag); - expect(afterCoInterviewerChange).not.toBe(boardTag); - expect(afterAuthorityRevision).not.toBe(boardTag); - expect(evaluateMutationPrecondition(mutationTag, boardTag)).toEqual( - PreconditionDecision.Proceed(), - ); - expect(evaluateMutationPrecondition(afterInterviewRevision, boardTag)).toEqual( - PreconditionDecision.Failed({ code: "precondition.failed", status: 412 }), - ); - expect("etag" in tagged).toBe(false); - }); - - it.live("maps recruitment failures to the frozen RFC 9457 problem vocabulary", () => - Effect.gen(function* () { - const http = ingress(); - const personId = PersonId.make("fixture-person"); - const interviewId = RecruitmentInterviewId.make("fixture-id"); - const applicationId = PublicApplicationIdSchema.make("fixture-id"); - const interviewSchemaId = InterviewSchemaId.make("fixture-id"); - - // Each failure is answered by an operation that declares its problem. - const cases = [ - [new RecruitmentInactiveActor({ personId }), schedulingBoard, 403, "authority.denied"], - [new InactiveActor({ personId }), schedulingBoard, 403, "authority.denied"], - [new RecruitmentRoleDenied({ personId }), schedulingBoard, 403, "authority.denied"], - [ - new RecruitmentScopeDenied({ personId, departmentId }), - interviewConduct, - 403, - "authority.denied", - ], - [ - new RecruitmentInterviewerNotEligible({ personId, departmentId }), - createInterview, - 403, - "authority.denied", - ], - [ - new RecruitmentAdmissionPeriodNotFound({ departmentId }), - assignmentBoard, - 404, - "recruitment.admission-period-not-found", - ], - [ - new RecruitmentAmbiguousAdmissionPeriod({ departmentId }), - assignmentBoard, - 409, - "application.ambiguous-period", - ], - [ - new RecruitmentApplicationNotFound({ applicationId }), - createInterview, - 404, - "recruitment.application-not-found", - ], - [ - new RecruitmentInterviewSchemaNotFound({ interviewSchemaId }), - createInterview, - 404, - "recruitment.interview-schema-not-found", - ], - [ - new RecruitmentApplicationAlreadyAssigned({ applicationId }), - createInterview, - 409, - "recruitment.application-already-assigned", - ], - [ - new RecruitmentInterviewSchemaInactive({ interviewSchemaId }), - createInterview, - 422, - "recruitment.interview-schema-inactive", - ], - [ - new RecruitmentAssignmentCommandConflict({ - commandId: RecruitmentAssignmentCommandId.make("fixture-id"), - }), - createInterview, - 409, - "idempotency.digest-conflict", - ], - [ - new InterviewQuestionsUnavailable({ interviewSchemaId, reason: "fixture" }), - createInterview, - 503, - "dependency.unavailable", - ], - [ - new RecruitmentInterviewNotFound({ interviewId }), - interviewConduct, - 404, - "recruitment.interview-not-found", - ], - [ - new RecruitmentInterviewNotScheduled({ interviewId }), - interviewConduct, - 409, - "recruitment.interview-not-scheduled", - ], - [ - new RecruitmentInvitationNotAccepted({ interviewId, responseState: "fixture" }), - interviewConduct, - 409, - "recruitment.invitation-not-accepted", - ], - [ - new RecruitmentInterviewAlreadyScheduled({ interviewId }), - scheduleInterview, - 409, - "recruitment.already-scheduled", - ], - [ - new RecruitmentInterviewStaleRevision({ - interviewId, - expectedRevision: 1, - actualRevision: 2, - }), - scheduleInterview, - 412, - "precondition.failed", - ], - [ - new RecruitmentScheduleInPast({ interviewId }), - scheduleInterview, - 422, - "recruitment.schedule-in-past", - ], - [ - new RecruitmentScheduleCommandConflict({ - commandId: RecruitmentScheduleCommandId.make("fixture-id"), - }), - scheduleInterview, - 409, - "idempotency.digest-conflict", - ], - [ - new RecruitmentInterviewAlreadyFinalized({ interviewId }), - finalizeInterview, - 409, - "recruitment.already-finalized", - ], - [ - new RecruitmentInterviewAlreadyCancelled({ interviewId }), - finalizeInterview, - 409, - "recruitment.already-cancelled", - ], - [ - new RecruitmentConductValidationError({ interviewId, message: "fixture" }), - finalizeInterview, - 422, - "recruitment.conduct-invalid", - ], - [ - new RecruitmentLifecycleCommandConflict({ - commandId: RecruitmentConductCommandId.make("fixture-id"), - }), - finalizeInterview, - 409, - "idempotency.digest-conflict", - ], - [new RecruitmentInvitationNotFound({}), invitationResponse, 404, "resource.not-found"], - [ - new RecruitmentInvitationAlreadyResponded({}), - rejectInvitation, - 409, - "invitation.already-responded", - ], - [ - new RecruitmentPersistenceError({ operation: "fixture", message: "fixture" }), - schedulingBoard, - 503, - "recruitment.unavailable", - ], - [ - new RecruitmentPersistenceError({ operation: "fixture", message: "fixture" }), - rejectInvitation, - 503, - "dependency.unavailable", - ], - [new ProfileContactNotFound({ personId }), rejectInvitation, 503, "dependency.unavailable"], - [ - new RecruitmentDecodeError({ message: "fixture" }), - schedulingBoard, - 500, - "internal.error", - ], - [ - new RecruitmentInvalidContext({ message: "fixture" }), - assignmentBoard, - 500, - "internal.error", - ], - [ - new OrganizationPersistenceError({ operation: "fixture", message: "fixture" }), - schedulingBoard, - 500, - "internal.error", - ], - ] as const; - - for (const [failure, request, status, code] of cases) { - injected = failure; - - expect({ - failure: failure._tag, - ...(yield* problemOf(yield* http.fetch(request()))), - }).toEqual({ - failure: failure._tag, - status, - code, - }); - } - - injected = undefined; - - const unauthenticated = yield* ingress(() => - Effect.fail(new UnauthenticatedActor({ message: "Authentication required" })), - ).fetch(schedulingBoard()); - - expect(unauthenticated.headers.get("www-authenticate")).toBe( - 'VektorSession realm="native-api", Bearer realm="native-api"', - ); - expect(yield* problemOf(unauthenticated)).toEqual({ - status: 401, - code: "credential.invalid", - }); - - expect( - yield* problemOf(yield* http.fetch(person("/api/recruitment/interviews?x=1"))), - ).toEqual({ - status: 400, - code: "request.malformed", - }); - }), - ); -}); - -it.live( - "preserves the native conflict protocol for PostgreSQL snapshot and deadlock failures", - () => - Effect.gen(function* () { - const http = ingress(); - - for (const code of ["40001", "40P01"]) { - injected = new RecruitmentPersistenceError({ - operation: "fixture", - message: "Transaction failed", - cause: { cause: { code } }, - }); - - expect(yield* problemOf(yield* http.fetch(interviewReport()))).toEqual({ - status: 409, - code: "transaction.conflict", - }); - } - - injected = undefined; - }), -); - -it("denies a suspended assigned member in the HTTP access context used before receipt replay", () => { - const personId = PersonId.make("suspended-assigned-person"), - departmentId = DepartmentId.make("department-assigned"); - - const source = { - interviewId: RecruitmentInterviewId.make("assigned-interview"), - departmentId, - interviewerPersonId: personId, - coInterviewerPersonId: null, - interviewRevision: 1, - linkedApplicantPersonId: null, - authority: [], - }; - - const requirement = { - id: RequirementId.make("recruitment.assigned-interviewer"), - parameters: {}, - }; - - const principal = PrincipalSchema.cases.Person.make({ personId }); - expect( - evaluateRequirement( - requirement, - principal, - recruitmentInterviewAccessContext( - source, - AdmissionPeriodActorSchema.cases.Member.make({ personId, departmentId, active: false }), - false, - false, - ), - )._tag, - ).toBe("Failed"); - expect( - evaluateRequirement( - requirement, - principal, - recruitmentInterviewAccessContext( - source, - AdmissionPeriodActorSchema.cases.GlobalAdmin.make({ personId, active: true }), - false, - false, - ), - )._tag, - ).toBe("Failed"); - expect( - evaluateRequirement( - requirement, - principal, - recruitmentInterviewAccessContext( - source, - AdmissionPeriodActorSchema.cases.Member.make({ personId, departmentId, active: true }), - false, - true, - ), - )._tag, - ).toBe("Satisfied"); -}); - -it("authorizes a current co-interviewer only through the participant requirement", () => { - const primaryPersonId = PersonId.make("primary-interviewer"), - coInterviewerPersonId = PersonId.make("co-interviewer"), - departmentId = DepartmentId.make("department-co-interviewer"); - - const source = { - interviewId: RecruitmentInterviewId.make("co-interviewer-interview"), - departmentId, - interviewerPersonId: primaryPersonId, - coInterviewerPersonId, - interviewRevision: 1, - linkedApplicantPersonId: null, - authority: [], - }; - - const principal = PrincipalSchema.cases.Person.make({ personId: coInterviewerPersonId }); - - const context = recruitmentInterviewAccessContext( - source, - AdmissionPeriodActorSchema.cases.Member.make({ - personId: coInterviewerPersonId, - departmentId, - active: true, - }), - false, - true, - ); - - expect( - evaluateRequirement( - { - id: RequirementId.make("recruitment.assigned-interviewer-or-co-interviewer"), - parameters: {}, - }, - principal, - context, - )._tag, - ).toBe("Satisfied"); - expect( - evaluateRequirement( - { id: RequirementId.make("recruitment.assigned-interviewer"), parameters: {} }, - principal, - context, - )._tag, - ).toBe("Failed"); - expect( - recruitmentInterviewAccessContext( - { ...source, coInterviewerPersonId: null }, - AdmissionPeriodActorSchema.cases.Member.make({ - personId: coInterviewerPersonId, - departmentId, - active: true, - }), - false, - true, - ).authorityVersion, - ).not.toBe(context.authorityVersion); - expect(interviewETag({ ...source, coInterviewerPersonId: null })).not.toBe(interviewETag(source)); -}); - -it("authorizes the report collection for its current scoped leader and rejects missing leadership", () => { - const spec = Option.getOrThrow(reflectAccessSpec(ReadInterviewReportEndpoint)); - - if (!Predicate.isTagged(spec.capabilities, "One")) - throw new Error("report requires one capability"); - const personId = PersonId.make("report-access-leader"); - const departmentId = DepartmentId.make("report-access-department"); - const principal = PrincipalSchema.cases.Person.make({ personId }); - const instant = AuthorizationInstant.make("2031-09-15T12:00:00.000Z"); - - const grant = decodeGrant({ - grantId: GrantId.make("report-access-grant"), - subject: principal, - capability: spec.capabilities.capability, - scope: Scope.Department({ departmentId }), - startAt: instant, - endAt: null, - requirements: [], - source: AuthorityRef.make("native-recruitment-actor"), - revision: 0, - }); - - const evaluate = (leaders: ReadonlyArray) => - evaluateAccess({ - spec, - credential: CredentialOutcomeSchema.cases.Accepted.make({ - mechanism: CredentialMechanismSchema.cases.BetterAuthCookie.make({}), - principal, - evidenceRef: CredentialEvidenceRef.make("report-access-session"), - }), - resolution: { - selection: "AllMatching", - contexts: [ - { - domainId: DomainId.make("recruitment"), - departmentId, - resource: null, - facts: { departmentAdministratorPersonIds: leaders }, - authorityVersion: AuthorityVersion.make(instant), - }, - ], - }, - grants: [grant], - authorizationInstant: instant, - }); - - expect(evaluate([personId])._tag).toBe("Allow"); - { - const observed = evaluate([]); - expect(observed).toHaveProperty("_tag", "Deny"); - expect(observed).toMatchObject({ stage: "Requirement", reason: "RequirementFailed" }); - } -}); diff --git a/apps/backend/src/recruitment/http.ts b/apps/backend/src/recruitment/http.ts deleted file mode 100644 index 564728df0..000000000 --- a/apps/backend/src/recruitment/http.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** Native HttpApi composition for recruitment endpoints. */ -import { ExternalNativeApi } from "@vektorprogrammet/http-api"; -import { Effect } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { webHandler } from "../http-api/problem.js"; -import { - cancelInterview, - confirmInvitation, - correctInterviewAssessment, - createApplicationInterview, - finalizeInterview, - rejectInvitation, - requestNewInvitationTime, - scheduleInterview, -} from "./http-commands.js"; -import type { RecruitmentApiHttpOptions } from "./http-context.js"; -import { - readAssignmentBoard, - readInterviewConduct, - readInterviewReport, - readInvitationResponse, - readSchedulingBoard, -} from "./http-reads.js"; -import { maintainRecruitmentHttp, readRecruitmentMaintenanceHttp } from "./maintenance-http.js"; - -/** Native HttpApi implementations for all frozen recruitment operations. */ -export const RecruitmentApiHandlers = (input: RecruitmentApiHttpOptions) => - HttpApiBuilder.group(ExternalNativeApi, "recruitment", (handlers) => - Effect.succeed( - handlers - .handleRaw("readQuestionnaires", ({ request }) => - webHandler(request, (webRequest) => - readRecruitmentMaintenanceHttp(webRequest, "questionnaires"), - ), - ) - .handleRaw("readInterviewStaffing", ({ request }) => - webHandler(request, (webRequest) => - readRecruitmentMaintenanceHttp(webRequest, "staffing"), - ), - ) - .handleRaw("maintainRecruitment", ({ request }) => - webHandler(request, maintainRecruitmentHttp), - ) - .handleRaw("readInterviewReport", ({ request }) => - webHandler(request, (webRequest) => readInterviewReport(webRequest, input)), - ) - .handleRaw("readInvitationResponse", ({ request }) => - webHandler(request, (webRequest) => readInvitationResponse(webRequest, input)), - ) - .handleRaw("confirmInvitation", ({ request }) => - webHandler(request, (webRequest) => confirmInvitation(webRequest, input)), - ) - .handleRaw("rejectInvitation", ({ request }) => - webHandler(request, (webRequest) => rejectInvitation(webRequest, input)), - ) - .handleRaw("requestNewInvitationTime", ({ request }) => - webHandler(request, (webRequest) => requestNewInvitationTime(webRequest, input)), - ) - .handleRaw("readAssignmentBoard", ({ request }) => - webHandler(request, (webRequest) => readAssignmentBoard(webRequest, input)), - ) - .handleRaw("readSchedulingBoard", ({ request }) => - webHandler(request, (webRequest) => readSchedulingBoard(webRequest, input)), - ) - .handleRaw("createApplicationInterview", ({ request, params }) => - webHandler(request, (webRequest) => - createApplicationInterview(webRequest, params.applicationId, input), - ), - ) - .handleRaw("scheduleInterview", ({ request, params }) => - webHandler(request, (webRequest) => - scheduleInterview(webRequest, params.interviewId, input), - ), - ) - .handleRaw("readInterviewConduct", ({ request, params }) => - webHandler(request, (webRequest) => - readInterviewConduct(webRequest, params.interviewId, input), - ), - ) - .handleRaw("finalizeInterview", ({ request, params }) => - webHandler(request, (webRequest) => - finalizeInterview(webRequest, params.interviewId, input), - ), - ) - .handleRaw("correctInterviewAssessment", ({ request, params }) => - webHandler(request, (webRequest) => - correctInterviewAssessment(webRequest, params.interviewId, input), - ), - ) - .handleRaw("cancelInterview", ({ request, params }) => - webHandler(request, (webRequest) => - cancelInterview(webRequest, params.interviewId, input), - ), - ), - ), - ); diff --git a/apps/backend/src/recruitment/maintenance-http.ts b/apps/backend/src/recruitment/maintenance-http.ts deleted file mode 100644 index 484ae6388..000000000 --- a/apps/backend/src/recruitment/maintenance-http.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { Effect, Match, Option, Predicate, Schema } from "effect"; -import { - Recruitment, - RecruitmentMaintenanceCommand, - RecruitmentMaintenanceResult, - RecruitmentMaintenanceFailure, - QuestionnaireManagement, - InterviewStaffingManagement, -} from "@vektorprogrammet/domain/recruitment"; -import { Scope } from "@vektorprogrammet/domain/authz"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { - ReadQuestionnairesEndpoint, - ReadInterviewStaffingEndpoint, - MaintainRecruitmentEndpoint, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { resolveRequestCredentialInTransaction } from "../authority.js"; -import { genericContext } from "../native-operation.js"; -import { - authorizePerson, - commandOutcomeResponse, - commandReceiptProblems, - idempotencyKeyOf, - personPresentation, - requireNoQuery, - unreachable, - jsonText, -} from "../http-api/problem.js"; -import { executeNativeHttpCommandPostgres } from "../http-api/receipt-transaction.js"; -import { - deriveHttpIdentity, - deriveStrongETag, - PRIVATE_NO_STORE, - semanticRequestDigest, -} from "../http-semantics.js"; -import { readRecruitmentBody } from "./http-decode.js"; -import { - admissionPeriodProblems, - assignmentProblems, - conductProblems, - maintenanceProblems, - recruitmentProblems, - schedulingProblems, -} from "./http-problem.js"; - -const authorizeTransport = Effect.fn("Recruitment.authorizeMaintenanceTransport")(function* ( - request: Request, - operation: "questionnaires" | "staffing" | "command", -) { - const resolved = yield* resolveRequestCredentialInTransaction(request, "OAuthUserBearer"); - - if (!Predicate.isTagged(resolved.credential.principal, "Person")) - return yield* new UnauthenticatedActor({ message: "authentication required" }); - const personId = resolved.credential.principal.personId; - yield* authorizePerson( - { - spec: Option.getOrThrow( - reflectAccessSpec( - Match.value(operation).pipe( - Match.when("command", () => MaintainRecruitmentEndpoint), - Match.when("questionnaires", () => ReadQuestionnairesEndpoint), - Match.when("staffing", () => ReadInterviewStaffingEndpoint), - Match.exhaustive, - ), - ), - ), - credential: resolved.credential, - personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - genericContext({ domainId: "recruitment", authorityVersion: "recruitment-maintenance" }), - ], - }, - grantScopes: [Scope.Global()], - now: resolved.authorizationInstant, - }, - personPresentation(request), - ); - - return personId; -}); - -export const readRecruitmentMaintenanceHttp = ( - request: Request, - operation: "questionnaires" | "staffing", -) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const personId = yield* authorizeTransport(request, operation); - - // The domain decoded each view with its own schema, so encoding it cannot fail. - const encoded = - operation === "questionnaires" - ? yield* Recruitment.use((service) => service.readQuestionnaires(personId)).pipe( - Effect.flatMap((view) => - Schema.encodeEffect(QuestionnaireManagement)(view).pipe(Effect.orDie), - ), - ) - : yield* Recruitment.use((service) => service.readInterviewStaffing(personId)).pipe( - Effect.flatMap((view) => - Schema.encodeEffect(InterviewStaffingManagement)(view).pipe(Effect.orDie), - ), - ); - - return new Response(yield* jsonText(encoded), { - headers: { - "content-type": "application/json", - "cache-control": PRIVATE_NO_STORE, - vary: "Origin", - }, - }); - }).pipe( - maintenanceProblems, - recruitmentProblems(personPresentation(request), "recruitment.unavailable"), - // Maintenance answers no assignment, scheduling, conduct, or invitation problem. - unreachable( - ...admissionPeriodProblems, - ...assignmentProblems, - ...schedulingProblems, - ...conductProblems, - "invitation.already-responded", - ), - ); - -export const maintainRecruitmentHttp = (request: Request) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const body = yield* readRecruitmentBody(request, 1_048_576); - - const command = yield* Schema.decodeUnknownEffect(RecruitmentMaintenanceCommand)(body, { - onExcessProperty: "error", - }).pipe(Effect.mapError(() => new RecruitmentMaintenanceFailure({ code: "Invalid" }))); - - const key = yield* idempotencyKeyOf(request); - - if (key !== command.commandId) - return yield* new RecruitmentMaintenanceFailure({ code: "Conflict" }); - - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const personId = yield* authorizeTransport(request, "command"); - // Current authority is resolved inside the receipt transaction, before replay. - yield* Recruitment.use((service) => service.authorizeMaintenance(command, personId)); - - // A person subject, this fixed operation and target, and a parsed key always derive. - const identity = deriveHttpIdentity({ - credentialSubject: `Person:${personId}`, - qualifiedOperationId: "recruitment.maintainRecruitment", - normalizedTarget: "/api/recruitment/maintenance/commands", - idempotencyKey: key, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({ body: command }), - operationId: "recruitment.maintainRecruitment", - }, - execute: Effect.gen(function* () { - const result = yield* Recruitment.use((service) => - service.maintainRecruitment(command, personId), - ); - - // The domain produced the result, so encoding it cannot fail. - const encoded = yield* Schema.encodeEffect(RecruitmentMaintenanceResult)(result).pipe( - Effect.orDie, - ); - - return { - status: 200, - mediaType: "application/json", - headers: { - "content-type": "application/json", - etag: deriveStrongETag({ - representationKind: "RecruitmentMaintenanceResult", - resourceIdentity: Predicate.isTagged(result, "QuestionnaireSaved") - ? result.interviewSchemaId - : result.interviewId, - version: result.revision, - }), - }, - bodyBytes: new TextEncoder().encode(yield* jsonText(encoded)), - }; - }), - }; - }), - { retry: "serialization-once" }, - ); - - return yield* commandOutcomeResponse(outcome); - }).pipe( - commandReceiptProblems, - maintenanceProblems, - recruitmentProblems(personPresentation(request), "recruitment.unavailable"), - // Maintenance answers no assignment, scheduling, conduct, or invitation problem. - unreachable( - ...admissionPeriodProblems, - ...assignmentProblems, - ...schedulingProblems, - ...conductProblems, - "invitation.already-responded", - ), - ); diff --git a/apps/backend/src/recruitment/maintenance.ts b/apps/backend/src/recruitment/maintenance.ts new file mode 100644 index 000000000..0626714cc --- /dev/null +++ b/apps/backend/src/recruitment/maintenance.ts @@ -0,0 +1,215 @@ +/** + * Recruitment maintenance: questionnaire and interview staffing reads, and the maintenance + * command, for a current global administrator or coordinator. + */ +import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import { Scope } from "@vektorprogrammet/domain/authz"; +import { + Recruitment, + RecruitmentMaintenanceFailure, + RecruitmentMaintenanceResult, + type RecruitmentMaintenanceCommand, +} from "@vektorprogrammet/domain/recruitment"; +import { + MaintainRecruitment, + ReadInterviewStaffing, + ReadQuestionnaires, + reflectAccessSpec, +} from "@vektorprogrammet/rpc"; +import { type IdempotencyKey, Problem } from "@vektorprogrammet/rpc/problem"; +import { Effect, Match, Option, Predicate, Schema } from "effect"; +import type { Headers } from "effect/unstable/http"; +import type { Rpc } from "effect/unstable/rpc"; +import { resolveRequestCredentialInTransaction } from "../authority.js"; +import { deriveStrongETag, semanticRequestDigest } from "../http-semantics.js"; +import { genericContext } from "../native-operation.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import { + authorizePerson, + commandIdentity, + commandReceiptProblems, + jsonText, + personPresentation, + unreachable, +} from "../rpc/problem.js"; +import { + executeNativeHttpCommandPostgres, + NativeHttpReceiptInvalid, + type NativeHttpCommandOutcome, + type NativeHttpResponseCapsule, +} from "../rpc/receipt-transaction.js"; +import { + admissionPeriodProblems, + assignmentProblems, + conductProblems, + maintenanceProblems, + recruitmentProblems, + schedulingProblems, +} from "./problem.js"; +import type { RecruitmentCall } from "./context.js"; + +/** + * Resolves the request's person inside the caller's transaction and evaluates the maintenance + * AccessSpec of `rpc` over the global recruitment context. + */ +const authorizeMaintenanceTransport = Effect.fn("Recruitment.authorizeMaintenanceTransport")( + function* (headers: Headers.Headers, rpc: Pick) { + const resolved = yield* resolveRequestCredentialInTransaction( + credentialRequestOf(headers), + "OAuthUserBearer", + ); + + if (!Predicate.isTagged(resolved.credential.principal, "Person")) + return yield* UnauthenticatedActor.make({ message: "authentication required" }); + + const personId = resolved.credential.principal.personId; + + yield* authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(rpc)), + credential: resolved.credential, + personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "recruitment", + authorityVersion: "recruitment-maintenance", + }), + ], + }, + grantScopes: [Scope.Global()], + now: resolved.authorizationInstant, + }, + personPresentation(headers), + ); + + return personId; + }, +); + +/** Maintenance answers no assignment, scheduling, conduct, or invitation problem. */ +const maintenanceAnswers = + (headers: Headers.Headers) => + (effect: Effect.Effect) => + effect.pipe( + maintenanceProblems, + recruitmentProblems({ + presentation: personPresentation(headers), + unavailable: "recruitment.unavailable", + }), + unreachable( + ...admissionPeriodProblems, + ...assignmentProblems, + ...schedulingProblems, + ...conductProblems, + "invitation.already-responded", + ), + ); + +export const readQuestionnaires = ({ headers }: RecruitmentCall) => + Effect.gen(function* () { + const personId = yield* authorizeMaintenanceTransport(headers, ReadQuestionnaires); + + return yield* Recruitment.use((service) => service.readQuestionnaires(personId)); + }).pipe(maintenanceAnswers(headers)); + +export const readInterviewStaffing = ({ headers }: RecruitmentCall) => + Effect.gen(function* () { + const personId = yield* authorizeMaintenanceTransport(headers, ReadInterviewStaffing); + + return yield* Recruitment.use((service) => service.readInterviewStaffing(personId)); + }).pipe(maintenanceAnswers(headers)); + +const MaintenanceResultJson = Schema.fromJsonString(RecruitmentMaintenanceResult); + +/** + * The result that a maintenance receipt stores, byte for byte what the HTTP contract stored: the + * result as JSON, with its entity tag as the `etag` header. A mismatch is a defect. + */ +const storedMaintenanceResult = (capsule: NativeHttpResponseCapsule) => + capsule.bodyBytes === null + ? Effect.die(new NativeHttpReceiptInvalid({ reason: "a maintenance receipt stores no result" })) + : Schema.decodeEffect(MaintenanceResultJson)(new TextDecoder().decode(capsule.bodyBytes)).pipe( + Effect.orDie, + ); + +const maintenanceOutcome = (outcome: NativeHttpCommandOutcome) => + Match.value(outcome).pipe( + Match.tag("Committed", "Replay", ({ response }) => storedMaintenanceResult(response)), + Match.tag("InFlight", () => Effect.fail(Problem.make("idempotency.in-flight"))), + Match.tag("DigestConflict", () => Effect.fail(Problem.make("idempotency.digest-conflict"))), + Match.tag("ResponseExpired", () => Effect.fail(Problem.make("idempotency.response-expired"))), + Match.exhaustive, + ); + +export const maintainRecruitment = ({ + headers, + payload, +}: RecruitmentCall<{ + readonly idempotencyKey: IdempotencyKey; + readonly request: RecruitmentMaintenanceCommand; +}>) => + Effect.gen(function* () { + const command = payload.request; + const key = payload.idempotencyKey; + + if (key !== command.commandId) + return yield* RecruitmentMaintenanceFailure.make({ code: "Conflict" }); + + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const personId = yield* authorizeMaintenanceTransport(headers, MaintainRecruitment); + + // Current authority is resolved inside the receipt transaction, before replay. + const authorization = yield* Recruitment.use((service) => + service.authorizeMaintenance(command, personId), + ); + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: `Person:${personId}`, + qualifiedOperationId: "recruitment.maintainRecruitment", + normalizedTarget: "/api/recruitment/maintenance/commands", + idempotencyKey: key, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest({ body: command }), + operationId: "recruitment.maintainRecruitment", + }, + execute: Effect.gen(function* () { + const result = yield* Recruitment.use((service) => + service.maintainRecruitment(authorization), + ); + + // The domain produced the result, so encoding it cannot fail. + const encoded = yield* Schema.encodeEffect(RecruitmentMaintenanceResult)(result).pipe( + Effect.orDie, + ); + + return { + status: 200, + mediaType: "application/json", + headers: { + "content-type": "application/json", + etag: deriveStrongETag({ + representationKind: "RecruitmentMaintenanceResult", + resourceIdentity: Predicate.isTagged(result, "QuestionnaireSaved") + ? result.interviewSchemaId + : result.interviewId, + version: result.revision, + }), + }, + bodyBytes: new TextEncoder().encode(yield* jsonText(encoded)), + } satisfies NativeHttpResponseCapsule; + }), + }; + }), + { retry: "serialization-once" }, + ); + + return yield* maintenanceOutcome(outcome); + }).pipe(commandReceiptProblems, maintenanceAnswers(headers)); diff --git a/apps/backend/src/recruitment/http-problem.ts b/apps/backend/src/recruitment/problem.ts similarity index 85% rename from apps/backend/src/recruitment/http-problem.ts rename to apps/backend/src/recruitment/problem.ts index 787308170..a4cb565e4 100644 --- a/apps/backend/src/recruitment/http-problem.ts +++ b/apps/backend/src/recruitment/problem.ts @@ -1,13 +1,11 @@ -/** - * Recruitment failures answered as the problems each endpoint declares. - */ +/** Recruitment problems: the one declared answer for every recruitment failure. */ import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; import type { RecruitmentFailure, RecruitmentInterviewNotFound, RecruitmentMaintenanceFailure, } from "@vektorprogrammet/domain/recruitment"; -import { type CredentialPresentation, Problem } from "@vektorprogrammet/http-api/http-semantics"; +import { type CredentialPresentation, Problem } from "@vektorprogrammet/rpc/problem"; import { type Cause, Effect, Match } from "effect"; import { type CredentialCases, @@ -16,15 +14,15 @@ import { type ProblemCases, type ProblemMapper, problemMapper, -} from "../http-api/problem.js"; +} from "../rpc/problem.js"; /** Every failure a recruitment handler answers: the domain's, and resolving the request's person. */ -type RecruitmentHttpFailure = RecruitmentFailure | IdentityEngineError | Cause.UnknownError; +type RecruitmentRpcFailure = RecruitmentFailure | IdentityEngineError | Cause.UnknownError; -/** The unavailable problem of a recruitment endpoint: a read or a mutation. */ +/** The unavailable problem of a recruitment RPC: a read or a mutation. */ type RecruitmentUnavailable = "recruitment.unavailable" | "dependency.unavailable"; -/** The failures of a store or dependency, which answer the endpoint's unavailable problem. */ +/** The failures of a store or dependency, which answer the RPC's unavailable problem. */ type RecruitmentOutageTag = | "RecruitmentPersistenceError" | "InterviewQuestionsUnavailable" @@ -91,7 +89,7 @@ const recruitmentFixedCases = { IdentityEngineError: fault, UnknownError: fault, } satisfies ProblemCases< - Exclude + Exclude >; /** The cases of `recruitmentProblems`, which answer an outage with `Unavailable`. */ @@ -114,22 +112,24 @@ type RecruitmentCases = typeof recru * * @example * ```ts - * read.pipe(recruitmentProblems(personPresentation(request), "recruitment.unavailable")); + * read.pipe(recruitmentProblems({ presentation: personPresentation(headers), unavailable: "recruitment.unavailable" })); * ``` * - * @avoid Mapping a recruitment failure in a handler: the recruitment endpoints then answer one - * failure differently. Pipe the handler's effect through this, with the endpoint's unavailable - * problem. + * @avoid Mapping a recruitment failure in a handler: the recruitment RPCs then answer one failure + * differently. Pipe the handler's effect through this, with the RPC's unavailable problem. * - * @construct http-problem + * @construct rpc-problem */ -export const recruitmentProblems = ( - presentation: CredentialPresentation, - unavailable: Unavailable, -): ProblemMapper> => { +export const recruitmentProblems = ({ + presentation, + unavailable, +}: { + readonly presentation: CredentialPresentation; + readonly unavailable: Unavailable; +}): ProblemMapper> => { const outage = () => Problem.make(unavailable); - return problemMapper()>({ + return problemMapper()>({ ...recruitmentFixedCases, UnauthenticatedActor: () => Problem.unauthenticated(presentation), RecruitmentPersistenceError: outage, @@ -151,13 +151,13 @@ export const recruitmentProblems = ( effect: Effect.Effect, @@ -167,8 +167,8 @@ export const raceProblems = ( ); /** - * The maintenance API answers its own failures, an unknown interview, and an - * identity outage in its own vocabulary; everything else as recruitment does. + * The maintenance RPCs answer their own failures, an unknown interview, and an identity outage in + * their own vocabulary; everything else as recruitment does. */ export const maintenanceProblems = problemMapper< RecruitmentMaintenanceFailure | RecruitmentInterviewNotFound | IdentityEngineError diff --git a/apps/backend/src/recruitment/http-reads.ts b/apps/backend/src/recruitment/reads.ts similarity index 57% rename from apps/backend/src/recruitment/http-reads.ts rename to apps/backend/src/recruitment/reads.ts index 0d3e996e3..02645fc77 100644 --- a/apps/backend/src/recruitment/http-reads.ts +++ b/apps/backend/src/recruitment/reads.ts @@ -1,47 +1,39 @@ -/** Recruitment read handlers: invitation response, boards, interview report, and interview conduct. */ +/** Recruitment reads: invitation response, boards, interview report, and interview conduct. */ import { Database } from "@vektorprogrammet/database"; import { DomainId, Scope } from "@vektorprogrammet/domain/authz"; import { InterviewReport, Recruitment, + RecruitmentInvitationCapabilitySchema, + type InterviewReportQuery, + type RecruitmentAssignmentBoardQuery, type RecruitmentInterviewId, } from "@vektorprogrammet/domain/recruitment"; import { AssignmentBoard, ConductObservation, InvitationResponseObservation, - ReadAssignmentBoardEndpoint, - ReadInterviewConductEndpoint, - ReadInterviewReportEndpoint, - ReadInvitationResponseEndpoint, - ReadSchedulingBoardEndpoint, + ReadAssignmentBoard, + ReadInterviewConduct, + ReadInterviewReport, + ReadInvitationResponse, + ReadSchedulingBoard, SchedulingBoard, reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { Problem } from "@vektorprogrammet/http-api/http-semantics"; + type InvitationResponseResource, +} from "@vektorprogrammet/rpc"; +import { Problem } from "@vektorprogrammet/rpc/problem"; import { Effect, Option, Predicate, Schema } from "effect"; import { currentInstant } from "../authority.js"; -import { - authorizePerson, - conditionalJson, - personPresentation, - requireNoQuery, - strictOutput, - unreachable, -} from "../http-api/problem.js"; -import { PRIVATE_NO_STORE } from "../http-semantics.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import { authorizePerson, personPresentation, strictOutput, unreachable } from "../rpc/problem.js"; import { actorDepartment, authorizeInvitationOperation, boardContext, interviewAuthorizationInTransaction, -} from "./http-access.js"; -import type { RecruitmentApiHttpOptions } from "./http-context.js"; -import { - decodeBoardQuery, - decodeInterviewReportQuery, - invitationCapability, -} from "./http-decode.js"; +} from "./access.js"; +import { type RecruitmentCall, recruitmentBoardActor } from "./context.js"; import { admissionPeriodProblems, assignmentProblems, @@ -49,50 +41,56 @@ import { raceProblems, recruitmentProblems, schedulingProblems, -} from "./http-problem.js"; -import { interviewETag, invitationETag, schedulingBoardWithETags } from "./http-representation.js"; +} from "./problem.js"; +import { interviewETag, invitationETag, schedulingBoardWithETags } from "./representation.js"; -/** A credential-selected read that carries no validator. */ -const privateJson = ( - body: typeof AssignmentBoard.Type | InterviewReport | typeof SchedulingBoard.Type, -) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "cache-control": PRIVATE_NO_STORE, "content-type": "application/json" }, - }); +/** + * The invitation that a response capability names. A malformed or empty capability names no + * invitation, so it answers resource.not-found, as an unknown one does. + */ +export const invitationCapability = (capability: string) => + Schema.decodeEffect(RecruitmentInvitationCapabilitySchema)(capability).pipe( + Effect.mapError(() => Problem.make("resource.not-found")), + ); -export const readInvitationResponse = (request: Request, input: RecruitmentApiHttpOptions) => +export const readInvitationResponse = ({ + headers, + payload, + options, +}: RecruitmentCall<{ readonly capability: string }>): Effect.Effect< + InvitationResponseResource, + Problem<"resource.not-found"> | Problem<"internal.error"> | Problem<"recruitment.unavailable">, + Recruitment +> => Effect.gen(function* () { - yield* requireNoQuery(request); - const capability = yield* invitationCapability(request); - const now = yield* currentInstant(input.config.now); + const capability = yield* invitationCapability(payload.capability); + const now = yield* currentInstant(options.config.recruitment.now); const snapshot = yield* Recruitment.use((service) => service.readInvitationSnapshot(capability), ); yield* authorizeInvitationOperation({ - spec: Option.getOrThrow(reflectAccessSpec(ReadInvitationResponseEndpoint)), + spec: Option.getOrThrow(reflectAccessSpec(ReadInvitationResponse)), source: snapshot.source, authorizationInstant: now, }); - const output = yield* strictOutput(InvitationResponseObservation)(snapshot.observation); - return yield* conditionalJson({ - request, - body: output, - etag: invitationETag(snapshot.source), - cacheControl: PRIVATE_NO_STORE, - contentType: "application/json", - }); + const observation = yield* strictOutput(InvitationResponseObservation)(snapshot.observation); + + return { observation, etag: invitationETag(snapshot.source) }; }).pipe( - recruitmentProblems(personPresentation(request), "recruitment.unavailable"), - // A capability holder is no person, and the snapshot fails only as an unknown - // invitation, an undecodable row, or an outage. + recruitmentProblems({ + presentation: personPresentation(headers), + unavailable: "recruitment.unavailable", + }), + // A capability holder is no person, and the snapshot fails only as an unknown invitation, an + // undecodable row, or an outage. unreachable( "credential.missing", "credential.invalid", "authority.denied", + "precondition.failed", ...admissionPeriodProblems, ...assignmentProblems, "recruitment.interview-not-found", @@ -103,33 +101,36 @@ export const readInvitationResponse = (request: Request, input: RecruitmentAp ), ); -export const readAssignmentBoard = (request: Request, input: RecruitmentApiHttpOptions) => { - const presentation = personPresentation(request); +export const readAssignmentBoard = ({ + headers, + payload: query, + options, +}: RecruitmentCall) => { + const presentation = personPresentation(headers); return Effect.gen(function* () { - const query = yield* decodeBoardQuery(request); - const actor = yield* input.resolveActor(request); - const now = yield* currentInstant(input.config.now); + const actor = yield* recruitmentBoardActor({ headers, now: options.now }); + const now = yield* currentInstant(options.config.recruitment.now); const departmentId = actorDepartment(actor); yield* authorizePerson( { - spec: Option.getOrThrow(reflectAccessSpec(ReadAssignmentBoardEndpoint)), - request, + spec: Option.getOrThrow(reflectAccessSpec(ReadAssignmentBoard)), + request: credentialRequestOf(headers), personId: actor.personId, resolution: { selection: "AllMatching", contexts: [ - boardContext( + boardContext({ actor, - { + facts: { departmentAdministratorPersonIds: Predicate.isTagged(actor, "DepartmentAdministrator") && actor.active ? [actor.personId] : [], }, - now, - ), + version: now, + }), ], }, grantScopes: @@ -145,13 +146,11 @@ export const readAssignmentBoard = (request: Request, input: RecruitmentApiHt read(query, { actor, now }), ); - return privateJson(yield* strictOutput(AssignmentBoard)(observation)); + return yield* strictOutput(AssignmentBoard)(observation); }).pipe( - recruitmentProblems(presentation, "recruitment.unavailable"), - // The endpoint's query schema rejects every other status value before the handler, - // and a board answers no assignment, interview, or invitation problem. + recruitmentProblems({ presentation: presentation, unavailable: "recruitment.unavailable" }), + // A board answers no assignment, interview, or invitation problem. unreachable( - "validation.failed", ...assignmentProblems, "recruitment.interview-not-found", "precondition.failed", @@ -164,13 +163,16 @@ export const readAssignmentBoard = (request: Request, input: RecruitmentApiHt ); }; -export const readInterviewReport = (request: Request, input: RecruitmentApiHttpOptions) => { - const presentation = personPresentation(request); +export const readInterviewReport = ({ + headers, + payload: query, + options, +}: RecruitmentCall) => { + const presentation = personPresentation(headers); return Effect.gen(function* () { - const query = yield* decodeInterviewReportQuery(request); - const caller = yield* input.resolveActor(request); - const now = yield* currentInstant(input.config.now); + const caller = yield* recruitmentBoardActor({ headers, now: options.now }); + const now = yield* currentInstant(options.config.recruitment.now); const actor = yield* Recruitment.use((service) => service.resolveInterviewReportLeader(caller.personId, now), @@ -178,13 +180,17 @@ export const readInterviewReport = (request: Request, input: RecruitmentApiHt yield* authorizePerson( { - spec: Option.getOrThrow(reflectAccessSpec(ReadInterviewReportEndpoint)), - request, + spec: Option.getOrThrow(reflectAccessSpec(ReadInterviewReport)), + request: credentialRequestOf(headers), personId: actor.personId, resolution: { selection: "AllMatching", contexts: [ - boardContext(actor, { departmentAdministratorPersonIds: [actor.personId] }, now), + boardContext({ + actor, + facts: { departmentAdministratorPersonIds: [actor.personId] }, + version: now, + }), ], }, grantScopes: [Scope.Department({ departmentId: actor.departmentId })], @@ -197,11 +203,11 @@ export const readInterviewReport = (request: Request, input: RecruitmentApiHt service.readCompletedInterviewReport(actor.personId, now, query), ); - return privateJson(yield* strictOutput(InterviewReport)(observation)); + return yield* strictOutput(InterviewReport)(observation); }).pipe( // The report locks applicant custody, so it answers a lost race as a conflict. raceProblems, - recruitmentProblems(presentation, "recruitment.unavailable"), + recruitmentProblems({ presentation: presentation, unavailable: "recruitment.unavailable" }), unreachable( ...admissionPeriodProblems, ...assignmentProblems, @@ -216,31 +222,30 @@ export const readInterviewReport = (request: Request, input: RecruitmentApiHt ); }; -export const readSchedulingBoard = (request: Request, input: RecruitmentApiHttpOptions) => { - const presentation = personPresentation(request); +export const readSchedulingBoard = ({ headers, options }: RecruitmentCall) => { + const presentation = personPresentation(headers); return Effect.gen(function* () { - yield* requireNoQuery(request); - const actor = yield* input.resolveActor(request); - const now = yield* currentInstant(input.config.now); + const actor = yield* recruitmentBoardActor({ headers, now: options.now }); + const now = yield* currentInstant(options.config.recruitment.now); const departmentId = actorDepartment(actor); yield* authorizePerson( { - spec: Option.getOrThrow(reflectAccessSpec(ReadSchedulingBoardEndpoint)), - request, + spec: Option.getOrThrow(reflectAccessSpec(ReadSchedulingBoard)), + request: credentialRequestOf(headers), personId: actor.personId, resolution: { selection: "AllMatching", contexts: [ - boardContext( + boardContext({ actor, - { + facts: { departmentMemberPersonIds: !Predicate.isTagged(actor, "GlobalAdmin") && actor.active ? [actor.personId] : [], }, - now, - ), + version: now, + }), ], }, grantScopes: @@ -260,11 +265,11 @@ export const readSchedulingBoard = (request: Request, input: RecruitmentApiHt service.readPersonAuthoritySources(actor.personId), ); - return privateJson( - yield* strictOutput(SchedulingBoard)(schedulingBoardWithETags(observation, authority)), + return yield* strictOutput(SchedulingBoard)( + schedulingBoardWithETags({ board: observation, authority }), ); }).pipe( - recruitmentProblems(presentation, "recruitment.unavailable"), + recruitmentProblems({ presentation: presentation, unavailable: "recruitment.unavailable" }), // Every interview on the board belongs to an application that cannot vanish. unreachable( ...admissionPeriodProblems, @@ -280,26 +285,24 @@ export const readSchedulingBoard = (request: Request, input: RecruitmentApiHt ); }; -export const readInterviewConduct = ( - request: Request, - interviewId: RecruitmentInterviewId, - input: RecruitmentApiHttpOptions, -) => +export const readInterviewConduct = ({ + headers, + payload: { interviewId }, + options, +}: RecruitmentCall<{ readonly interviewId: RecruitmentInterviewId }>) => Effect.gen(function* () { - yield* requireNoQuery(request); - const snapshot = yield* Database.use((sql) => sql.withTransaction( Effect.gen(function* () { - const authorization = yield* interviewAuthorizationInTransaction( - request, + const authorization = yield* interviewAuthorizationInTransaction({ + headers, interviewId, - ReadInterviewConductEndpoint, - false, - input, - ); + rpc: ReadInterviewConduct, + allowLeader: false, + now: options.config.recruitment.now, + }); - const now = yield* currentInstant(input.config.now); + const now = yield* currentInstant(options.config.recruitment.now); const observation = yield* Recruitment.use((service) => service.readInterviewConduct(interviewId, { @@ -318,27 +321,20 @@ export const readInterviewConduct = ( ), ).pipe(Effect.catchTag("SqlError", () => Effect.fail(Problem.make("internal.error")))); - const output = yield* strictOutput(ConductObservation)(snapshot.observation); + const detail = yield* strictOutput(ConductObservation)(snapshot.observation); - // The observation fits its own schema, so encoding it cannot fail. - const encoded = yield* Schema.encodeEffect(Schema.toCodecJson(ConductObservation))(output).pipe( - Effect.orDie, - ); - - return yield* conditionalJson({ - request, - body: encoded, - etag: interviewETag(snapshot.source), - cacheControl: PRIVATE_NO_STORE, - contentType: "application/json", - }); + return { detail, etag: interviewETag(snapshot.source) }; }).pipe( - recruitmentProblems(personPresentation(request), "recruitment.unavailable"), + recruitmentProblems({ + presentation: personPresentation(headers), + unavailable: "recruitment.unavailable", + }), // A conduct read answers no assignment, scheduling, lifecycle, or invitation problem. unreachable( ...admissionPeriodProblems, ...assignmentProblems, ...schedulingProblems, + "precondition.failed", "recruitment.already-finalized", "recruitment.already-cancelled", "recruitment.conduct-invalid", diff --git a/apps/backend/src/recruitment/http-representation.ts b/apps/backend/src/recruitment/representation.ts similarity index 67% rename from apps/backend/src/recruitment/http-representation.ts rename to apps/backend/src/recruitment/representation.ts index b125a95e8..d4442bb24 100644 --- a/apps/backend/src/recruitment/http-representation.ts +++ b/apps/backend/src/recruitment/representation.ts @@ -1,13 +1,15 @@ -/** Recruitment HTTP representations: ETags and scheduling items. */ +/** Recruitment representations: the strong entity tags of invitations and interviews. */ import type { RecruitmentAuthorityHttpSource, RecruitmentInterviewHttpSource, RecruitmentInvitationHttpSource, RecruitmentSchedulingBoard, } from "@vektorprogrammet/domain/recruitment"; -import type { StrongETag } from "@vektorprogrammet/http-api"; +import type { SchedulingBoard } from "@vektorprogrammet/rpc"; +import type { StrongETag } from "@vektorprogrammet/rpc/problem"; import { deriveStrongETag } from "../http-semantics.js"; +/** The tag of an invitation, which an invitation response takes as `ifMatch`. */ export const invitationETag = (source: RecruitmentInvitationHttpSource): StrongETag => deriveStrongETag({ representationKind: "InvitationResponseObservation", @@ -15,6 +17,7 @@ export const invitationETag = (source: RecruitmentInvitationHttpSource): StrongE version: [source.scheduleRevision, source.responseRevision], }); +/** The tag of an interview, which scheduling and the conduct commands take as `ifMatch`. */ export const interviewETag = ( source: Omit, ): StrongETag => @@ -28,10 +31,14 @@ export const interviewETag = ( ], }); -export const schedulingBoardWithETags = ( - board: RecruitmentSchedulingBoard, - authority: ReadonlyArray, -) => ({ +/** The scheduling board with the tag of each interview, as the caller's authority sees it. */ +export const schedulingBoardWithETags = ({ + board, + authority, +}: { + readonly board: RecruitmentSchedulingBoard; + readonly authority: ReadonlyArray; +}): SchedulingBoard => ({ ...board, interviews: board.interviews.map((interview) => ({ ...interview, diff --git a/apps/backend/src/recruitment/rpc.test.ts b/apps/backend/src/recruitment/rpc.test.ts new file mode 100644 index 000000000..1f54c5aea --- /dev/null +++ b/apps/backend/src/recruitment/rpc.test.ts @@ -0,0 +1,948 @@ +import { IdentitySnapshot } from "@vektorprogrammet/database"; +import { + AdmissionPeriodActorSchema, + InactiveActor, +} from "@vektorprogrammet/domain/admission-period"; +import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; +import { + AuthorityRef, + AuthorityVersion, + AuthorizationInstant, + CredentialEvidenceRef, + CredentialMechanismSchema, + CredentialOutcomeSchema, + DomainId, + GrantId, + PrincipalSchema, + RequirementId, + Scope, + decodeGrant, + evaluateAccess, + evaluateRequirement, +} from "@vektorprogrammet/domain/authz"; +import { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; +import { + DepartmentId, + MembershipId, + Organization, + OrganizationPersistenceError, + PersonId, + TeamId, + type OrganizationOperations, +} from "@vektorprogrammet/domain/organization"; +import { ProfileContactNotFound } from "@vektorprogrammet/domain/profile"; +import { + InterviewQuestionsUnavailable, + InterviewSchemaId, + Recruitment, + RecruitmentAdmissionPeriodNotFound, + RecruitmentAmbiguousAdmissionPeriod, + RecruitmentApplicationAlreadyAssigned, + RecruitmentApplicationNotFound, + RecruitmentAssignmentCommandConflict, + RecruitmentAssignmentCommandId, + RecruitmentConductCommandId, + RecruitmentConductValidationError, + RecruitmentDecodeError, + RecruitmentInactiveActor, + RecruitmentInterviewAlreadyCancelled, + RecruitmentInterviewAlreadyFinalized, + RecruitmentInterviewAlreadyScheduled, + RecruitmentInterviewerNotEligible, + RecruitmentInterviewId, + RecruitmentInterviewNotFound, + RecruitmentInterviewNotScheduled, + RecruitmentInterviewSchemaInactive, + RecruitmentInterviewSchemaNotFound, + RecruitmentInterviewStaleRevision, + RecruitmentInvalidContext, + RecruitmentInvitationAlreadyResponded, + RecruitmentInvitationHttpSnapshotSchema, + RecruitmentInvitationId, + RecruitmentInvitationNotAccepted, + RecruitmentInvitationNotFound, + RecruitmentInvitationTransition, + RecruitmentLifecycleCommandConflict, + RecruitmentPersistenceError, + RecruitmentRoleDenied, + RecruitmentScheduleCommandConflict, + RecruitmentScheduleCommandId, + RecruitmentScheduleInPast, + RecruitmentSchedulingBoardSchema, + RecruitmentScopeDenied, + type RecruitmentFailure, + type RecruitmentOperations, +} from "@vektorprogrammet/domain/recruitment"; +import { ReadInterviewReport, SchedulingBoard, reflectAccessSpec } from "@vektorprogrammet/rpc"; +import { IdempotencyKey, isProblem, type Problem, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Layer, Option, Predicate, Schema } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"; +import { describe, expect, it } from "@effect/vitest"; +import { backendTestConfig } from "../../test/config.js"; +import { PreconditionDecision, evaluateMutationPrecondition } from "../http-semantics.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; +import { recruitmentInterviewAccessContext } from "./access.js"; +import { interviewETag, invitationETag, schedulingBoardWithETags } from "./representation.js"; + +const at = "2031-09-15T12:00:00.000Z"; + +const departmentId = DepartmentId.make("rpc-department"); + +const leaderId = PersonId.make("rpc-leader"); + +const observation = { + scheduledAt: "2031-09-20T10:00:00.000Z", + room: "A101", + campus: "Gløshaugen", + responseState: "Pending", + responseMessage: null, +} as const; + +const snapshot = Schema.decodeSync(RecruitmentInvitationHttpSnapshotSchema)({ + source: { + capabilitySha256: "a".repeat(64), + invitationId: "rpc-invitation", + interviewId: "rpc-interview", + departmentId, + scheduleRevision: 2, + responseRevision: 0, + responseState: "Pending", + supersededAt: null, + }, + observation, +}); + +/** The failure every domain call answers; without one, an invitation reads and answers. */ +let injected: RecruitmentFailure | undefined; + +const transitions: Array = []; + +const injectedFailure = () => + Effect.suspend(() => + injected === undefined ? Effect.die("no recruitment failure injected") : Effect.fail(injected), + ); + +const recruitment = { + readInvitationSnapshot: () => + Effect.suspend(() => + injected === undefined ? Effect.succeed(snapshot) : Effect.fail(injected), + ), + transitionInvitation: ({ + transition, + }: { + readonly transition: RecruitmentInvitationTransition; + }) => + Effect.suspend(() => { + if (injected !== undefined) return Effect.fail(injected); + + transitions.push(transition); + + return Effect.succeed({ ...snapshot, source: { ...snapshot.source, responseRevision: 1 } }); + }), + readAssignmentBoard: injectedFailure, + readSchedulingBoard: injectedFailure, + resolveInterviewReportLeader: injectedFailure, + prepareAssignment: injectedFailure, + prepareInterview: injectedFailure, +} satisfies Partial; + +/** The session's person leads the board of the one department. */ +const organization = { + resolvePersonAuthority: (personId: PersonId) => + Effect.succeed({ + personId, + evaluatedAt: at, + globalAdministrator: "Absent", + memberships: [ + { + membershipId: MembershipId.make("rpc-board-membership"), + teamId: TeamId.make("rpc-board"), + departmentId, + active: true, + unitLeader: true, + unitKind: "DepartmentBoard", + teamScope: "HomeDepartment", + departmentIndependent: true, + }, + ], + nationalBoardSeats: [], + delegations: [], + }), +} satisfies Partial; + +/** A session cookie whose session has ended. */ +const expiredSession = "rpc-expired"; + +const sessionActor = (cookie: string | undefined) => { + const person = /better-auth\.session_token=([^;]+)/u.exec(cookie ?? "")?.[1]; + + return person === undefined || person === expiredSession + ? undefined + : IdentityActor.make({ + personId: PersonId.make(person), + sessionId: `session-${person}`, + expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), + }); +}; + +const resolveSession = (cookie: string | undefined) => { + const actor = sessionActor(cookie); + + return actor === undefined + ? Effect.fail(IdentitySessionNotFound.make({})) + : Effect.succeed(actor); +}; + +const identitySnapshot = IdentitySnapshot.of({ + resolveSession, + revokeCurrentSession: () => Effect.die("unexpected session mutation"), + revokeSession: () => Effect.die("unexpected session mutation"), + revokeOtherSessions: () => Effect.die("unexpected session mutation"), + revokeAllSessions: () => Effect.die("unexpected session mutation"), +}); + +const identity = Identity.of({ + signIn: () => Effect.die("unexpected sign-in"), + resolveSession, + readCurrentSession: () => Effect.die("unexpected session read"), + listSessions: () => Effect.die("unexpected session list"), + revokeCurrentSession: () => Effect.die("unexpected session mutation"), + revokeSession: () => Effect.die("unexpected session mutation"), + revokeOtherSessions: () => Effect.die("unexpected session mutation"), + revokeAllSessions: () => Effect.die("unexpected session mutation"), + recordSecurityEvent: () => Effect.die("unexpected identity audit"), + signOut: () => Effect.succeed({ setCookies: [] }), +} satisfies IdentityOperations); + +/** The recruitment RPCs as a department leader reaches them. */ +const fixture = () => + Effect.gen(function* () { + const backend = makeBackendTestRpc({ + config: { + ...backendTestConfig, + recruitment: { + maxBodyBytes: 256, + now: () => at, + nextInterviewId: () => RecruitmentInterviewId.make("rpc-unexpected-interview"), + nextInvitationId: () => RecruitmentInvitationId.make("rpc-unexpected-invitation"), + nextResponseCapability: () => "unexpected".padEnd(43, "_"), + }, + }, + services: Layer.mergeAll( + Layer.mock(Recruitment, recruitment), + Layer.mock(Organization, organization), + Layer.succeed(IdentitySnapshot, identitySnapshot), + Layer.succeed(Identity, identity), + ), + }); + + const client = yield* backend.client; + + /** Runs one call with the leader's session cookie. */ + const asLeader = (effect: Effect.Effect) => + RpcClient.withHeaders(effect, { cookie: `better-auth.session_token=${leaderId}` }); + + return { client, asLeader }; + }); + +type Client = Effect.Success>["client"]; + +const capability = "rpc".padEnd(43, "_"); + +const anyValidator = StrongETag.make('"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"'); + +const key = IdempotencyKey.make("rpc-command".padEnd(22, "0")); + +const interviewId = RecruitmentInterviewId.make("rpc-interview"); + +/** + * Every call that a failure case exercises, by name: a person call with the leader's session, and + * an invitation response with its capability alone. + */ +const calls = ( + client: Client, + asLeader: (effect: Effect.Effect) => Effect.Effect, +) => ({ + schedulingBoard: () => asLeader(client["recruitment.readSchedulingBoard"]()), + assignmentBoard: () => asLeader(client["recruitment.readAssignmentBoard"]({ status: "new" })), + interviewConduct: () => asLeader(client["recruitment.readInterviewConduct"]({ interviewId })), + interviewReport: () => asLeader(client["recruitment.readInterviewReport"]({})), + createInterview: () => + asLeader( + client["recruitment.createApplicationInterview"]({ + applicationId: PublicApplicationIdSchema.make("rpc-application"), + idempotencyKey: key, + request: { + interviewerPersonId: leaderId, + interviewSchemaId: InterviewSchemaId.make("rpc-schema"), + }, + }), + ), + scheduleInterview: () => + asLeader( + client["recruitment.scheduleInterview"]({ + interviewId, + idempotencyKey: key, + ifMatch: anyValidator, + request: { + scheduledAt: "2031-09-20T10:00:00.000Z", + room: "A101", + campus: null, + mapLink: null, + message: "Velkommen.", + }, + }), + ), + finalizeInterview: () => + asLeader( + client["recruitment.finalizeInterview"]({ + interviewId, + idempotencyKey: key, + ifMatch: anyValidator, + request: { + answers: [{ questionId: "q-1", answer: "Svar" }], + score: { explanatoryPower: 4, roleModel: 5, suitability: 6 }, + recommendation: "Ja", + }, + }), + ), + invitationResponse: () => client["recruitment.readInvitationResponse"]({ capability }), + rejectInvitation: () => + client["recruitment.rejectInvitation"]({ + capability, + ifMatch: invitationETag(snapshot.source), + request: {}, + }), +}); + +/** One call of any recruitment RPC. */ +type AnyCall = Effect.Effect; + +/** The problem code of a failed call; a transport failure has none. */ +const codeOf = (failure: Problem | RpcClientError) => + isProblem(failure) ? failure.code : "defect"; + +/** The problem code that a call answers, or `success`. */ +const answeredCode = (call: AnyCall) => + call.pipe(Effect.match({ onFailure: codeOf, onSuccess: () => "success" })); + +describe("native recruitment RPC boundary", () => { + it.live("answers an invitation response with its transition and the new entity tag", () => + Effect.gen(function* () { + const { client } = yield* fixture(); + injected = undefined; + transitions.length = 0; + + const rejected = yield* client["recruitment.rejectInvitation"]({ + capability, + ifMatch: invitationETag(snapshot.source), + request: { message: "Another time" }, + }); + + expect(transitions).toEqual([ + RecruitmentInvitationTransition.Reject({ message: "Another time" }), + ]); + expect(rejected.etag).toBe(invitationETag({ ...snapshot.source, responseRevision: 1 })); + + const stale = yield* client["recruitment.confirmInvitation"]({ + capability, + ifMatch: anyValidator, + }).pipe(Effect.flip); + + expect(codeOf(stale)).toBe("precondition.failed"); + + // A malformed capability names no invitation, as an unknown one does. + const malformed = yield* client["recruitment.readInvitationResponse"]({ + capability: "", + }).pipe(Effect.flip); + + expect(codeOf(malformed)).toBe("resource.not-found"); + expect(transitions).toHaveLength(1); + }), + ); + + it.live("reads the invitation with the strong entity tag that a response takes", () => + Effect.gen(function* () { + const { client } = yield* fixture(); + injected = undefined; + + expect(yield* client["recruitment.readInvitationResponse"]({ capability })).toEqual({ + observation, + etag: invitationETag(snapshot.source), + }); + }), + ); + + it.live("rejects an invitation capability presented beside a session cookie or a bearer", () => + Effect.gen(function* () { + const { client } = yield* fixture(); + injected = undefined; + transitions.length = 0; + + const ifMatch = invitationETag(snapshot.source); + + // The capability is the request's one credential in every invitation operation. + const operations: ReadonlyArray = [ + ["read", client["recruitment.readInvitationResponse"]({ capability })], + ["confirm", client["recruitment.confirmInvitation"]({ capability, ifMatch })], + ["reject", client["recruitment.rejectInvitation"]({ capability, ifMatch, request: {} })], + [ + "request-new-time", + client["recruitment.requestNewInvitationTime"]({ + capability, + ifMatch, + request: { message: "Kan vi møtes torsdag?" }, + }), + ], + ]; + + for (const [header, credential] of [ + ["cookie", `theme=dark; better-auth.session_token=${leaderId}`], + ["authorization", "Bearer rpc-leader-bearer"], + ] as const) { + for (const [operation, call] of operations) { + const code = yield* RpcClient.withHeaders(call, { [header]: credential }).pipe( + answeredCode, + ); + + expect({ operation, header, code }).toEqual({ + operation, + header, + code: "credential.invalid", + }); + } + } + + expect(transitions).toEqual([]); + + // A cookie without a session is no second credential. + const read = yield* RpcClient.withHeaders( + client["recruitment.readInvitationResponse"]({ capability }), + { cookie: "theme=dark" }, + ); + + expect(read.etag).toBe(ifMatch); + }), + ); + + it("exposes mutation-compatible strong ETags on scheduling items", () => { + const board = Schema.decodeSync(RecruitmentSchedulingBoardSchema)( + { + departmentId: "department-1", + interviews: [ + { + interviewId: "interview-1", + applicationId: "application-1", + departmentId: "department-1", + interviewer: { + personId: "interviewer-1", + displayName: "Ivar Interviewer", + email: "ivar@example.org", + phone: "+47 900 00 001", + }, + coInterviewer: null, + applicant: { + applicationId: "application-1", + applicantId: "applicant-1", + firstName: "Ada", + lastName: "Applicant", + email: "ada@example.org", + phone: "+47 900 00 002", + }, + revision: 2, + schedule: null, + notificationState: null, + responseState: null, + responseMessage: null, + }, + ], + }, + { onExcessProperty: "error" }, + ); + + const authority = [ + { kind: "Membership" as const, identity: "membership-1", revisions: [4, 5, 6] }, + ]; + + expect(() => + Schema.decodeUnknownSync(SchedulingBoard)(board, { onExcessProperty: "error" }), + ).toThrow(); + + const tagged = schedulingBoardWithETags({ board, authority }); + + const decoded = Schema.decodeSync(SchedulingBoard)(tagged, { onExcessProperty: "error" }); + + const boardTag = decoded.interviews[0]!.etag; + + const mutationTag = interviewETag({ + interviewId: board.interviews[0]!.interviewId, + departmentId: board.interviews[0]!.departmentId, + interviewerPersonId: board.interviews[0]!.interviewer.personId, + coInterviewerPersonId: null, + interviewRevision: board.interviews[0]!.revision, + authority, + }); + + const afterInterviewRevision = schedulingBoardWithETags({ + board: { ...board, interviews: [{ ...board.interviews[0]!, revision: 3 }] }, + authority, + }).interviews[0]!.etag; + + const afterCoInterviewerChange = schedulingBoardWithETags({ + board: { + ...board, + interviews: [ + { + ...board.interviews[0]!, + coInterviewer: { + personId: PersonId.make("co-interviewer-1"), + displayName: "Cora Co-interviewer", + }, + }, + ], + }, + authority, + }).interviews[0]!.etag; + + const afterAuthorityRevision = schedulingBoardWithETags({ + board, + authority: [{ ...authority[0]!, revisions: [4, 5, 7] }], + }).interviews[0]!.etag; + + expect(boardTag).toMatch(/^"vkr2\.[A-Za-z0-9_-]{43}"$/u); + expect(boardTag).toBe(mutationTag); + expect(afterInterviewRevision).not.toBe(boardTag); + expect(afterCoInterviewerChange).not.toBe(boardTag); + expect(afterAuthorityRevision).not.toBe(boardTag); + expect(evaluateMutationPrecondition(mutationTag, boardTag)).toEqual( + PreconditionDecision.Proceed(), + ); + expect(evaluateMutationPrecondition(afterInterviewRevision, boardTag)).toEqual( + PreconditionDecision.Failed({ code: "precondition.failed", status: 412 }), + ); + expect("etag" in tagged).toBe(false); + }); + + it.live("maps recruitment failures to the declared problem vocabulary", () => + Effect.gen(function* () { + const { client, asLeader } = yield* fixture(); + const call = calls(client, asLeader); + const personId = PersonId.make("fixture-person"); + const failingInterviewId = RecruitmentInterviewId.make("fixture-id"); + const applicationId = PublicApplicationIdSchema.make("fixture-id"); + const interviewSchemaId = InterviewSchemaId.make("fixture-id"); + + // Each failure is answered by an operation that declares its problem. + const cases: ReadonlyArray AnyCall, string]> = [ + [RecruitmentInactiveActor.make({ personId }), call.schedulingBoard, "authority.denied"], + [InactiveActor.make({ personId }), call.schedulingBoard, "authority.denied"], + [RecruitmentRoleDenied.make({ personId }), call.schedulingBoard, "authority.denied"], + [ + RecruitmentScopeDenied.make({ personId, departmentId }), + call.interviewConduct, + "authority.denied", + ], + [ + RecruitmentInterviewerNotEligible.make({ personId, departmentId }), + call.createInterview, + "authority.denied", + ], + [ + RecruitmentAdmissionPeriodNotFound.make({ departmentId }), + call.assignmentBoard, + "recruitment.admission-period-not-found", + ], + [ + RecruitmentAmbiguousAdmissionPeriod.make({ departmentId }), + call.assignmentBoard, + "application.ambiguous-period", + ], + [ + RecruitmentApplicationNotFound.make({ applicationId }), + call.createInterview, + "recruitment.application-not-found", + ], + [ + RecruitmentInterviewSchemaNotFound.make({ interviewSchemaId }), + call.createInterview, + "recruitment.interview-schema-not-found", + ], + [ + RecruitmentApplicationAlreadyAssigned.make({ applicationId }), + call.createInterview, + "recruitment.application-already-assigned", + ], + [ + RecruitmentInterviewSchemaInactive.make({ interviewSchemaId }), + call.createInterview, + "recruitment.interview-schema-inactive", + ], + [ + RecruitmentAssignmentCommandConflict.make({ + commandId: RecruitmentAssignmentCommandId.make("fixture-id"), + }), + call.createInterview, + "idempotency.digest-conflict", + ], + [ + InterviewQuestionsUnavailable.make({ interviewSchemaId, reason: "fixture" }), + call.createInterview, + "dependency.unavailable", + ], + [ + RecruitmentInterviewNotFound.make({ interviewId: failingInterviewId }), + call.interviewConduct, + "recruitment.interview-not-found", + ], + [ + RecruitmentInterviewNotScheduled.make({ interviewId: failingInterviewId }), + call.interviewConduct, + "recruitment.interview-not-scheduled", + ], + [ + RecruitmentInvitationNotAccepted.make({ + interviewId: failingInterviewId, + responseState: "fixture", + }), + call.interviewConduct, + "recruitment.invitation-not-accepted", + ], + [ + RecruitmentInterviewAlreadyScheduled.make({ interviewId: failingInterviewId }), + call.scheduleInterview, + "recruitment.already-scheduled", + ], + [ + RecruitmentInterviewStaleRevision.make({ + interviewId: failingInterviewId, + expectedRevision: 1, + actualRevision: 2, + }), + call.scheduleInterview, + "precondition.failed", + ], + [ + RecruitmentScheduleInPast.make({ interviewId: failingInterviewId }), + call.scheduleInterview, + "recruitment.schedule-in-past", + ], + [ + RecruitmentScheduleCommandConflict.make({ + commandId: RecruitmentScheduleCommandId.make("fixture-id"), + }), + call.scheduleInterview, + "idempotency.digest-conflict", + ], + [ + RecruitmentInterviewAlreadyFinalized.make({ interviewId: failingInterviewId }), + call.finalizeInterview, + "recruitment.already-finalized", + ], + [ + RecruitmentInterviewAlreadyCancelled.make({ interviewId: failingInterviewId }), + call.finalizeInterview, + "recruitment.already-cancelled", + ], + [ + RecruitmentConductValidationError.make({ + interviewId: failingInterviewId, + message: "fixture", + }), + call.finalizeInterview, + "recruitment.conduct-invalid", + ], + [ + RecruitmentLifecycleCommandConflict.make({ + commandId: RecruitmentConductCommandId.make("fixture-id"), + }), + call.finalizeInterview, + "idempotency.digest-conflict", + ], + [RecruitmentInvitationNotFound.make({}), call.invitationResponse, "resource.not-found"], + [ + RecruitmentInvitationAlreadyResponded.make({}), + call.rejectInvitation, + "invitation.already-responded", + ], + [ + RecruitmentPersistenceError.make({ operation: "fixture", message: "fixture" }), + call.schedulingBoard, + "recruitment.unavailable", + ], + [ + RecruitmentPersistenceError.make({ operation: "fixture", message: "fixture" }), + call.rejectInvitation, + "dependency.unavailable", + ], + [ + ProfileContactNotFound.make({ personId }), + call.rejectInvitation, + "dependency.unavailable", + ], + [ + RecruitmentDecodeError.make({ message: "fixture" }), + call.schedulingBoard, + "internal.error", + ], + [ + RecruitmentInvalidContext.make({ message: "fixture" }), + call.assignmentBoard, + "internal.error", + ], + [ + OrganizationPersistenceError.make({ operation: "fixture", message: "fixture" }), + call.schedulingBoard, + "internal.error", + ], + ]; + + for (const [failure, request, code] of cases) { + injected = failure; + + const answered = yield* request().pipe(answeredCode); + + expect({ failure: failure._tag, code: answered }).toEqual({ + failure: failure._tag, + code, + }); + } + + injected = undefined; + + // A cookie without a current session is a rejected credential, and no cookie is a missing one. + const endedSession = yield* RpcClient.withHeaders( + client["recruitment.readSchedulingBoard"](), + { + cookie: `better-auth.session_token=${expiredSession}`, + }, + ).pipe(Effect.flip); + + expect(codeOf(endedSession)).toBe("credential.invalid"); + + const anonymous = yield* client["recruitment.readSchedulingBoard"]().pipe(Effect.flip); + + expect(codeOf(anonymous)).toBe("credential.missing"); + }), + ); + + it.live( + "preserves the native conflict protocol for PostgreSQL snapshot and deadlock failures", + () => + Effect.gen(function* () { + const { client, asLeader } = yield* fixture(); + + for (const code of ["40001", "40P01"]) { + injected = RecruitmentPersistenceError.make({ + operation: "fixture", + message: "Transaction failed", + cause: { cause: { code } }, + }); + + const answered = yield* calls(client, asLeader).interviewReport().pipe(Effect.flip); + + expect(codeOf(answered)).toBe("transaction.conflict"); + } + + injected = undefined; + }), + ); +}); + +it("denies a suspended assigned member in the access context used before receipt replay", () => { + const personId = PersonId.make("suspended-assigned-person"), + assignedDepartmentId = DepartmentId.make("department-assigned"); + + const source = { + interviewId: RecruitmentInterviewId.make("assigned-interview"), + departmentId: assignedDepartmentId, + interviewerPersonId: personId, + coInterviewerPersonId: null, + interviewRevision: 1, + linkedApplicantPersonId: null, + authority: [], + }; + + const requirement = { + id: RequirementId.make("recruitment.assigned-interviewer"), + parameters: {}, + }; + + const principal = PrincipalSchema.cases.Person.make({ personId }); + + const evaluate = ( + actor: Parameters[0]["actor"], + activeMember: boolean, + ) => + evaluateRequirement( + requirement, + principal, + recruitmentInterviewAccessContext({ source, actor, allowLeader: false, activeMember }), + )._tag; + + expect( + evaluate( + AdmissionPeriodActorSchema.cases.Member.make({ + personId, + departmentId: assignedDepartmentId, + active: false, + }), + false, + ), + ).toBe("Failed"); + expect( + evaluate(AdmissionPeriodActorSchema.cases.GlobalAdmin.make({ personId, active: true }), false), + ).toBe("Failed"); + expect( + evaluate( + AdmissionPeriodActorSchema.cases.Member.make({ + personId, + departmentId: assignedDepartmentId, + active: true, + }), + true, + ), + ).toBe("Satisfied"); +}); + +it("authorizes a current co-interviewer only through the participant requirement", () => { + const primaryPersonId = PersonId.make("primary-interviewer"), + coInterviewerPersonId = PersonId.make("co-interviewer"), + coDepartmentId = DepartmentId.make("department-co-interviewer"); + + const source = { + interviewId: RecruitmentInterviewId.make("co-interviewer-interview"), + departmentId: coDepartmentId, + interviewerPersonId: primaryPersonId, + coInterviewerPersonId, + interviewRevision: 1, + linkedApplicantPersonId: null, + authority: [], + }; + + const principal = PrincipalSchema.cases.Person.make({ personId: coInterviewerPersonId }); + + const actor = AdmissionPeriodActorSchema.cases.Member.make({ + personId: coInterviewerPersonId, + departmentId: coDepartmentId, + active: true, + }); + + const context = recruitmentInterviewAccessContext({ + source, + actor, + allowLeader: false, + activeMember: true, + }); + + expect( + evaluateRequirement( + { + id: RequirementId.make("recruitment.assigned-interviewer-or-co-interviewer"), + parameters: {}, + }, + principal, + context, + )._tag, + ).toBe("Satisfied"); + expect( + evaluateRequirement( + { id: RequirementId.make("recruitment.assigned-interviewer"), parameters: {} }, + principal, + context, + )._tag, + ).toBe("Failed"); + expect( + recruitmentInterviewAccessContext({ + source: { ...source, coInterviewerPersonId: null }, + actor, + allowLeader: false, + activeMember: true, + }).authorityVersion, + ).not.toBe(context.authorityVersion); + expect(interviewETag({ ...source, coInterviewerPersonId: null })).not.toBe(interviewETag(source)); +}); + +it("keeps the interview authority version that the HTTP handler derived", () => { + const source = { + interviewId: RecruitmentInterviewId.make("version-interview"), + departmentId, + interviewerPersonId: PersonId.make("version-interviewer"), + coInterviewerPersonId: PersonId.make('co"interviewer'), + interviewRevision: 3, + linkedApplicantPersonId: null, + authority: [{ kind: "Membership" as const, identity: "membership-1", revisions: [1, 2] }], + }; + + expect( + recruitmentInterviewAccessContext({ + source, + actor: AdmissionPeriodActorSchema.cases.Member.make({ + personId: source.interviewerPersonId, + departmentId, + active: true, + }), + allowLeader: false, + activeMember: true, + }).authorityVersion, + ).toBe('3:"co\\"interviewer":Unknown:Membership:membership-1:1.2'); +}); + +it("authorizes the report collection for its current scoped leader and rejects missing leadership", () => { + const spec = Option.getOrThrow(reflectAccessSpec(ReadInterviewReport)); + + if (!Predicate.isTagged(spec.capabilities, "One")) + throw new Error("report requires one capability"); + const personId = PersonId.make("report-access-leader"); + const reportDepartmentId = DepartmentId.make("report-access-department"); + const principal = PrincipalSchema.cases.Person.make({ personId }); + const instant = AuthorizationInstant.make("2031-09-15T12:00:00.000Z"); + + const grant = decodeGrant({ + grantId: GrantId.make("report-access-grant"), + subject: principal, + capability: spec.capabilities.capability, + scope: Scope.Department({ departmentId: reportDepartmentId }), + startAt: instant, + endAt: null, + requirements: [], + source: AuthorityRef.make("native-recruitment-actor"), + revision: 0, + }); + + const evaluate = (leaders: ReadonlyArray) => + evaluateAccess({ + spec, + credential: CredentialOutcomeSchema.cases.Accepted.make({ + mechanism: CredentialMechanismSchema.cases.BetterAuthCookie.make({}), + principal, + evidenceRef: CredentialEvidenceRef.make("report-access-session"), + }), + resolution: { + selection: "AllMatching", + contexts: [ + { + domainId: DomainId.make("recruitment"), + departmentId: reportDepartmentId, + resource: null, + facts: { departmentAdministratorPersonIds: leaders }, + authorityVersion: AuthorityVersion.make(instant), + }, + ], + }, + grants: [grant], + authorizationInstant: instant, + }); + + expect(evaluate([personId])._tag).toBe("Allow"); + { + const observed = evaluate([]); + expect(observed).toHaveProperty("_tag", "Deny"); + expect(observed).toMatchObject({ stage: "Requirement", reason: "RequirementFailed" }); + } +}); diff --git a/apps/backend/src/recruitment/rpc.ts b/apps/backend/src/recruitment/rpc.ts new file mode 100644 index 000000000..fe057e076 --- /dev/null +++ b/apps/backend/src/recruitment/rpc.ts @@ -0,0 +1,64 @@ +/** + * The RecruitmentRpcs handlers. A board read resolves the caller's recruitment actor and evaluates + * the RPC's AccessSpec; an interview read or command resolves the credential and authority inside + * its transaction, and a command stores its answer as a command receipt, so a retry with the same + * idempotency key replays the first answer. An invitation response is authorized by the response + * capability in its payload, and conceals every denial as resource.not-found. + */ +import { RecruitmentRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { + cancelInterview, + confirmInvitation, + correctInterviewAssessment, + createApplicationInterview, + finalizeInterview, + rejectInvitation, + requestNewInvitationTime, + scheduleInterview, +} from "./commands.js"; +import { maintainRecruitment, readInterviewStaffing, readQuestionnaires } from "./maintenance.js"; +import { + readAssignmentBoard, + readInterviewConduct, + readInterviewReport, + readInvitationResponse, + readSchedulingBoard, +} from "./reads.js"; + +/** The RecruitmentRpcs handlers. */ +export const RecruitmentRpcHandlers = (options: NativeRpcOptions) => + RecruitmentRpcs.toLayer({ + "recruitment.readInvitationResponse": (payload, { headers }) => + readInvitationResponse({ headers, payload, options }), + "recruitment.confirmInvitation": (payload, { headers }) => + confirmInvitation({ headers, payload, options }), + "recruitment.rejectInvitation": (payload, { headers }) => + rejectInvitation({ headers, payload, options }), + "recruitment.requestNewInvitationTime": (payload, { headers }) => + requestNewInvitationTime({ headers, payload, options }), + "recruitment.readAssignmentBoard": (payload, { headers }) => + readAssignmentBoard({ headers, payload, options }), + "recruitment.readSchedulingBoard": (payload, { headers }) => + readSchedulingBoard({ headers, payload, options }), + "recruitment.readInterviewReport": (payload, { headers }) => + readInterviewReport({ headers, payload, options }), + "recruitment.createApplicationInterview": (payload, { headers }) => + createApplicationInterview({ headers, payload, options }), + "recruitment.scheduleInterview": (payload, { headers }) => + scheduleInterview({ headers, payload, options }), + "recruitment.readInterviewConduct": (payload, { headers }) => + readInterviewConduct({ headers, payload, options }), + "recruitment.finalizeInterview": (payload, { headers }) => + finalizeInterview({ headers, payload, options }), + "recruitment.correctInterviewAssessment": (payload, { headers }) => + correctInterviewAssessment({ headers, payload, options }), + "recruitment.cancelInterview": (payload, { headers }) => + cancelInterview({ headers, payload, options }), + "recruitment.readQuestionnaires": (payload, { headers }) => + readQuestionnaires({ headers, payload, options }), + "recruitment.readInterviewStaffing": (payload, { headers }) => + readInterviewStaffing({ headers, payload, options }), + "recruitment.maintainRecruitment": (payload, { headers }) => + maintainRecruitment({ headers, payload, options }), + }); diff --git a/apps/backend/src/router.oauth.test.ts b/apps/backend/src/router.oauth.test.ts deleted file mode 100644 index 554fecc1a..000000000 --- a/apps/backend/src/router.oauth.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, expect, it, vi } from "@effect/vitest"; -import { Effect } from "effect"; -import { - backendHttpHandler, - internalBackendHttpHandler, - type BackendAuthHandler, -} from "./router.js"; -import type { NativeSessionBoundaryPolicy } from "./session-security.js"; - -const sessionBoundary: NativeSessionBoundaryPolicy = { - deployment: "local", - trustedOrigins: ["http://127.0.0.1:4173"], - secureCookies: false, -}; - -const makeAuth = (): BackendAuthHandler => ({ - handler: vi.fn(() => Effect.succeed(Response.json({ surface: "identity" }))), - oauthHandler: vi.fn(() => Effect.succeed(Response.json({ surface: "oauth" }))), - oauthIntrospectionHandler: vi.fn(() => Effect.succeed(Response.json({ active: true }))), - exactRedirectAccepted: vi.fn((_clientId, redirectUri) => - Effect.succeed(redirectUri === "http://127.0.0.1:4173/dashboard/oauth/callback"), - ), - recordTrustedOriginRejection: vi.fn(() => Effect.void), -}); - -const authorizeUrl = new URL("http://127.0.0.1:4173/api/auth/oauth2/authorize"); - -authorizeUrl.searchParams.set("client_id", "delegated-client"); - -authorizeUrl.searchParams.set("redirect_uri", "http://127.0.0.1:4173/dashboard/oauth/callback"); - -authorizeUrl.searchParams.set("state", "s".repeat(43)); - -authorizeUrl.searchParams.set("code_challenge", "a".repeat(43)); - -authorizeUrl.searchParams.set("code_challenge_method", "S256"); - -authorizeUrl.searchParams.set("resource", "urn:vektorprogrammet:native-api"); - -authorizeUrl.searchParams.set("response_type", "code"); - -authorizeUrl.searchParams.set("scope", "native-api offline_access"); - -const allowedRoutes = [ - ["GET", "http://127.0.0.1:4173/.well-known/oauth-authorization-server/api/auth"], - ["GET", "http://127.0.0.1:4173/api/auth/jwks"], - ["GET", authorizeUrl.toString()], - ["GET", "http://127.0.0.1:4173/api/auth/oauth2/public-client"], - ["POST", "http://127.0.0.1:4173/api/auth/oauth2/consent"], - ["POST", "http://127.0.0.1:4173/api/auth/oauth2/token"], - ["POST", "http://127.0.0.1:4173/api/auth/oauth2/revoke"], - ["GET", "http://127.0.0.1:4173/api/auth/oauth2/get-consents"], - ["POST", "http://127.0.0.1:4173/api/auth/oauth2/delete-consent"], -] as const; - -describe("frozen external OAuth ingress", () => { - it.effect.each(allowedRoutes)("dispatches only %s %s to the OAuth graph", ([method, url]) => - Effect.gen(function* () { - const auth = makeAuth(); - - const http = backendHttpHandler( - vi.fn(() => Effect.succeed(new Response("native"))), - auth, - sessionBoundary, - ); - - const browserMutation = url.endsWith("/consent") || url.endsWith("/delete-consent"); - - const response = yield* http( - new Request(url, { - method, - headers: browserMutation ? { origin: "http://127.0.0.1:4173" } : undefined, - }), - ); - - expect(response.status).toBe(200); - expect(auth.oauthHandler).toHaveBeenCalledTimes(1); - expect(auth.handler).not.toHaveBeenCalled(); - }), - ); - - it.effect.each([ - ["POST", "http://127.0.0.1:4173/api/auth/oauth2/authorize"], - ["POST", "http://127.0.0.1:4173/api/auth/oauth2/introspect"], - ["POST", "http://127.0.0.1:4173/api/auth/oauth2/register"], - ["POST", "http://127.0.0.1:4173/api/auth/admin/oauth2/create-client"], - ["GET", "http://127.0.0.1:4173/api/auth/userinfo"], - ] as const)("returns route-not-found for %s %s", ([method, url]) => - Effect.gen(function* () { - const auth = makeAuth(); - const http = backendHttpHandler(vi.fn(), auth, sessionBoundary); - - const response = yield* http(new Request(url, { method })); - - expect(response.status).toBe(404); - expect(auth.oauthHandler).not.toHaveBeenCalled(); - expect(auth.handler).not.toHaveBeenCalled(); - }), - ); - - it.effect("does not dispatch an unregistered redirect and never reflects OAuth CORS", () => - Effect.gen(function* () { - const auth = makeAuth(); - const http = backendHttpHandler(vi.fn(), auth, sessionBoundary); - const wrong = new URL(authorizeUrl); - wrong.searchParams.set("redirect_uri", "http://127.0.0.1:4173/other"); - - const denied = yield* http(new Request(wrong)); - - const token = yield* http( - new Request("http://127.0.0.1:4173/api/auth/oauth2/token", { - method: "POST", - headers: { origin: "http://127.0.0.1:4173" }, - }), - ); - - expect(denied.status).toBe(400); - expect(denied.headers.get("location")).toBeNull(); - expect(auth.oauthHandler).toHaveBeenCalledTimes(1); - expect(token.headers.get("access-control-allow-origin")).toBeNull(); - }), - ); -}); - -describe("independent internal OAuth ingress", () => { - it.effect("reveals no token signal to a wrong network", () => - Effect.gen(function* () { - const auth = makeAuth(); - const http = internalBackendHttpHandler(vi.fn(), auth, ["10.20.0.0/16"]); - - const response = yield* http( - new Request("http://127.0.0.1:4173/api/auth/oauth2/introspect", { - method: "POST", - headers: { "x-real-ip": "10.21.0.1" }, - }), - ); - - expect(yield* Effect.promise(() => response.json())).toEqual({ active: false }); - expect(auth.oauthIntrospectionHandler).not.toHaveBeenCalled(); - }), - ); - - it.effect("dispatches only POST introspection from an allowed source", () => - Effect.gen(function* () { - const auth = makeAuth(); - const http = internalBackendHttpHandler(vi.fn(), auth, ["10.20.0.0/16"]); - const url = "http://127.0.0.1:4173/api/auth/oauth2/introspect"; - - const accepted = yield* http( - new Request(url, { method: "POST", headers: { "x-real-ip": "10.20.4.5" } }), - ); - - const rejectedMethod = yield* http( - new Request(url, { headers: { "x-real-ip": "10.20.4.5" } }), - ); - - expect(accepted.status).toBe(200); - expect(rejectedMethod.status).toBe(404); - expect(auth.oauthIntrospectionHandler).toHaveBeenCalledTimes(1); - expect(auth.oauthHandler).not.toHaveBeenCalled(); - }), - ); -}); diff --git a/apps/backend/src/router.recruitment-authority.test.ts b/apps/backend/src/router.recruitment-authority.test.ts deleted file mode 100644 index db774875a..000000000 --- a/apps/backend/src/router.recruitment-authority.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { backendDatabase } from "../test/database.js"; -import { - AdmissionPeriodId, - AdmissionPeriodActorSchema, -} from "@vektorprogrammet/domain/admission-period"; -import type { RecruitmentAssignmentBoardQuery } from "@vektorprogrammet/domain/recruitment"; -import { OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { - Identity, - IdentityActor, - IdentitySessionNotFound, - type IdentityOperations, -} from "@vektorprogrammet/domain/identity"; - -import { - DepartmentId, - MembershipId, - TeamId, - Organization, - PersonId, - type OrganizationOperations, -} from "@vektorprogrammet/domain/organization"; -import { - Recruitment as RecruitmentService, - RecruitmentRoleDenied, - type RecruitmentActor, - type RecruitmentOperations, -} from "@vektorprogrammet/domain/recruitment"; -import { SocialEvents } from "@vektorprogrammet/domain/social-events"; -import { Admissions } from "@vektorprogrammet/domain"; -import { NativeProblem } from "@vektorprogrammet/http-api"; -import { Predicate, DateTime, Effect, Layer, Schema } from "effect"; -import { beforeEach, describe, expect, it } from "@effect/vitest"; -import { decodeBackendConfig } from "./config.js"; -import { makeBackendTestHttp as backendHttpHandler } from "./test/native-http.js"; - -const leaderToken = "leader-session-token"; - -const memberToken = "member-session-token"; - -const inactiveToken = "inactive-session-token"; - -const unassignedToken = "unassigned-session-token"; - -// Leads department-1 after the person's global-administrator grant has ended. -const formerAdministratorToken = "former-administrator-session-token"; - -const environment = { - BACKEND_PG_URL: "postgres://test.invalid/vektorprogrammet", - BETTER_AUTH_SECRET: "router-test-secret-with-at-least-32-characters!", - NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify(["http://127.0.0.1:5174"]), - OAUTH_CANONICAL_ORIGIN: "http://127.0.0.1:5174", - OAUTH_DASHBOARD_ORIGIN: "http://127.0.0.1:5174", - OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - PUBLIC_APPLICATION_EFFECT_MODE: "disabled", - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_MODE: "disabled", -} as const; - -const config = Effect.runSync(decodeBackendConfig(environment)); - -const database = backendDatabase(); - -interface AuthorityMembershipRow { - readonly departmentId: string; - readonly active: boolean; - /** Leads the board of the (independent) department; an ordinary team leader has no reach. */ - readonly boardLeader: boolean; -} - -/** One authority projection per session token, selected by the cookie value. */ -const membershipsByToken = new Map>([ - [ - leaderToken, - [{ departmentId: DepartmentId.make("department-1"), active: true, boardLeader: true }], - ], - [ - memberToken, - [{ departmentId: DepartmentId.make("department-1"), active: true, boardLeader: false }], - ], - [ - inactiveToken, - [{ departmentId: DepartmentId.make("department-1"), active: false, boardLeader: false }], - ], - [unassignedToken, []], - [ - formerAdministratorToken, - [{ departmentId: DepartmentId.make("department-1"), active: true, boardLeader: true }], - ], -]); - -const personIdsByToken = new Map([ - [leaderToken, "leader-1"], - [memberToken, "member-1"], - [inactiveToken, "inactive-1"], - [unassignedToken, "unassigned-1"], - [formerAdministratorToken, "former-administrator-1"], -]); - -const personIdForToken = (tokenValue: string): string => - personIdsByToken.get(tokenValue) ?? "unknown-person"; - -const organization = { - listDepartments: Effect.succeed([]), - listTeams: () => Effect.succeed([]), - listFieldOfStudies: Effect.succeed([]), - resolvePersonAuthority: (personId: PersonId) => { - const row = membershipsByToken - .entries() - .find(([token]) => personIdForToken(token) === personId); - - return Effect.succeed({ - personId, - evaluatedAt: "2031-09-15T12:00:00.000Z", - globalAdministrator: - personId === personIdForToken(formerAdministratorToken) ? "Inactive" : "Absent", - memberships: (row?.[1] ?? []).map((membership, index) => ({ - membershipId: MembershipId.make(`membership-${index}`), - teamId: TeamId.make(`team-${index}`), - departmentId: DepartmentId.make(membership.departmentId), - active: membership.active, - unitLeader: membership.boardLeader, - unitKind: membership.boardLeader ? "DepartmentBoard" : "Team", - teamScope: "HomeDepartment", - departmentIndependent: true, - })), - nationalBoardSeats: [], - delegations: [], - }); - }, -} satisfies Partial; - -const recruitmentCalls: Array<{ - readonly operation: string; - readonly actor: unknown; -}> = []; - -const admissions = { - listAdmissionPeriodsForManagement: ({ actor }: { readonly actor: unknown }) => - Effect.sync(() => { - recruitmentCalls.push({ operation: "listAdmissionPeriodsForManagement", actor }); - - return []; - }), -}; - -const assignmentBoard = { - admissionPeriodId: AdmissionPeriodId.make("period-1"), - departmentId: DepartmentId.make("department-1"), - candidates: [], - interviewers: [], - interviewSchemas: [], -}; - -const schedulingBoard = { - departmentId: DepartmentId.make("department-1"), - interviews: [], -}; - -// Models the frozen domain laws: assignment reads require an active DepartmentAdministrator; -// scheduling reads require an active department member. -const recruitment = { - readPersonAuthoritySources: () => Effect.succeed([]), - readAssignmentBoard: ( - query: RecruitmentAssignmentBoardQuery, - context: { readonly actor: RecruitmentActor }, - ) => - context.actor.active && Predicate.isTagged(context.actor, "DepartmentAdministrator") - ? Effect.sync(() => { - recruitmentCalls.push({ operation: "readAssignmentBoard", actor: context.actor }); - void query; - - return assignmentBoard; - }) - : Effect.fail(new RecruitmentRoleDenied({ personId: PersonId.make(context.actor.personId) })), - assignApplicant: () => Effect.die("unexpected assignApplicant"), - readSchedulingBoard: (context: { readonly actor: RecruitmentActor }) => - !Predicate.isTagged(context.actor, "GlobalAdmin") && context.actor.active - ? Effect.sync(() => { - recruitmentCalls.push({ operation: "readSchedulingBoard", actor: context.actor }); - - return schedulingBoard; - }) - : Effect.fail(new RecruitmentRoleDenied({ personId: PersonId.make(context.actor.personId) })), - scheduleInterview: () => Effect.die("unexpected scheduleInterview"), - readInvitationResponse: () => Effect.die("unexpected readInvitationResponse"), - confirmInvitation: () => Effect.die("unexpected confirmInvitation"), - rejectInvitation: () => Effect.die("unexpected rejectInvitation"), - requestNewInvitationTime: () => Effect.die("unexpected requestNewInvitationTime"), -} satisfies Partial; - -const socialEvents = SocialEvents.of({ - readSnapshotInstant: Effect.die("unexpected social-event read"), - readScope: () => Effect.die("unexpected social-event read"), - readList: () => Effect.die("unexpected social-event read"), - validateScope: () => Effect.die("unexpected social-event validation"), - create: () => Effect.die("unexpected social-event create"), -}); - -const oauthCredentialAuthority = OAuthCredentialAuthority.of({ - resolve: () => Effect.die("unexpected OAuth credential resolution"), - resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), -}); - -const identity = Identity.of({ - signIn: () => Effect.die("unexpected sign-in"), - resolveSession: (cookieHeader: string | undefined) => { - const tokenValue = cookieHeader - ?.split(";") - .map((part) => part.trim()) - .find((part) => part.startsWith("better-auth.session_token=")) - ?.slice("better-auth.session_token=".length); - - return tokenValue !== undefined && membershipsByToken.has(tokenValue) - ? Effect.succeed( - new IdentityActor({ - personId: PersonId.make(personIdForToken(tokenValue)), - sessionId: "session-1", - expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), - }), - ) - : Effect.fail(new IdentitySessionNotFound()); - }, - readCurrentSession: () => Effect.die("unexpected session read"), - listSessions: () => Effect.die("unexpected session list"), - revokeCurrentSession: () => Effect.die("unexpected session mutation"), - revokeSession: () => Effect.die("unexpected session mutation"), - revokeOtherSessions: () => Effect.die("unexpected session mutation"), - revokeAllSessions: () => Effect.die("unexpected session mutation"), - recordSecurityEvent: () => Effect.die("unexpected identity audit"), - signOut: () => Effect.succeed({ setCookies: [] }), -} satisfies IdentityOperations); - -const backendServices = Layer.mergeAll( - database.layer, - Layer.mock(Organization, organization), - Layer.mock(RecruitmentService, recruitment), - Layer.mock(Admissions, admissions), - Layer.succeed(SocialEvents, socialEvents), - Layer.succeed(Identity, identity), - Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), -); - -const backend = backendHttpHandler(config, backendServices, { - handler: () => Effect.succeed(new Response(null, { status: 404 })), - recordTrustedOriginRejection: () => Effect.void, -}); - -const request = (pathname: string, sessionValue: string): Effect.Effect => - backend.fetch( - new Request(`http://backend.test${pathname}`, { - headers: { cookie: `better-auth.session_token=${sessionValue}` }, - }), - ); - -const problem = (response: Response) => - Effect.gen(function* () { - const decoded = yield* Schema.decodeUnknownEffect(NativeProblem)( - yield* Effect.promise(() => response.json()), - ); - - return { status: response.status, code: decoded.code }; - }); - -describe("recruitment actors from authorized departments (spec 0055)", () => { - beforeEach(() => { - recruitmentCalls.length = 0; - }); - - it.live("allows a DepartmentAdministrator to read the canonical assignment board once", () => - Effect.gen(function* () { - const response = yield* request( - "/api/recruitment/application-assignments?status=new", - leaderToken, - ); - - expect(response.status).toBe(200); - expect(yield* Effect.promise(() => response.json())).toEqual(assignmentBoard); - expect(recruitmentCalls).toEqual([ - { - operation: "readAssignmentBoard", - actor: expect.objectContaining( - AdmissionPeriodActorSchema.cases.DepartmentAdministrator.make({ - personId: PersonId.make("leader-1"), - departmentId: DepartmentId.make("department-1"), - active: true, - }), - ), - }, - ]); - }), - ); - - it.live("denies a plain active member from the assignment board", () => - Effect.gen(function* () { - const response = yield* request( - "/api/recruitment/application-assignments?status=new", - memberToken, - ); - - expect(response.status).toBe(403); - expect(yield* Effect.promise(() => response.json())).toEqual({ - type: "urn:vektorprogrammet:problem:v0.2:authority.denied", - title: "Authority denied", - status: 403, - detail: "The authenticated principal is not permitted to perform this operation.", - code: "authority.denied", - }); - expect(recruitmentCalls).toEqual([]); - }), - ); - - it.live("denies an anonymous assignment-board caller before any domain call", () => - Effect.gen(function* () { - const response = yield* request("/api/recruitment/application-assignments?status=new", ""); - expect(response.status).toBe(401); - expect(yield* Effect.promise(() => response.json())).toEqual({ - type: "urn:vektorprogrammet:problem:v0.2:credential.missing", - title: "Credential required", - status: 401, - detail: "A credential is required for this operation.", - code: "credential.missing", - }); - expect(recruitmentCalls).toEqual([]); - }), - ); - - it.live("allows an active department member to read the scheduling board once", () => - Effect.gen(function* () { - const response = yield* request("/api/recruitment/interviews", memberToken); - - expect(response.status).toBe(200); - expect(yield* Effect.promise(() => response.json())).toEqual(schedulingBoard); - expect(recruitmentCalls).toEqual([ - { - operation: "readSchedulingBoard", - actor: expect.objectContaining( - AdmissionPeriodActorSchema.cases.Member.make({ - personId: PersonId.make("member-1"), - departmentId: DepartmentId.make("department-1"), - active: true, - }), - ), - }, - ]); - }), - ); - - it.live("denies an inactive department member from the scheduling board", () => - Effect.gen(function* () { - const response = yield* request("/api/recruitment/interviews", inactiveToken); - - expect(yield* problem(response)).toEqual({ status: 403, code: "authority.denied" }); - expect(recruitmentCalls).toEqual([]); - }), - ); - - // A 401 tells the dashboard that the session expired, and it signs the person out. - it.live( - "denies an authenticated person without a department instead of rejecting the session", - () => - Effect.gen(function* () { - const [board, admissionPeriods] = yield* Effect.all( - [ - request("/api/recruitment/interviews", unassignedToken), - request("/api/admission-periods", unassignedToken), - ], - { concurrency: "unbounded" }, - ); - - expect([yield* problem(board), yield* problem(admissionPeriods)]).toEqual([ - { status: 403, code: "authority.denied" }, - { status: 403, code: "authority.denied" }, - ]); - expect(recruitmentCalls).toEqual([]); - }), - ); - - it.live("lists admission periods in a department leader's own department without a scope", () => - Effect.gen(function* () { - const response = yield* request("/api/admission-periods", leaderToken); - - expect(response.status).toBe(200); - expect(recruitmentCalls).toEqual([ - { - operation: "listAdmissionPeriodsForManagement", - actor: expect.objectContaining( - AdmissionPeriodActorSchema.cases.DepartmentAdministrator.make({ - personId: PersonId.make("leader-1"), - departmentId: DepartmentId.make("department-1"), - active: true, - }), - ), - }, - ]); - }), - ); - - it.live("keeps a department leadership after the leader's administrator grant has ended", () => - Effect.gen(function* () { - const [admissionPeriods, board] = [ - yield* request("/api/admission-periods", formerAdministratorToken), - yield* request( - "/api/recruitment/application-assignments?status=new", - formerAdministratorToken, - ), - ]; - - const leader = expect.objectContaining( - AdmissionPeriodActorSchema.cases.DepartmentAdministrator.make({ - personId: PersonId.make("former-administrator-1"), - departmentId: DepartmentId.make("department-1"), - active: true, - }), - ); - - expect([admissionPeriods.status, board.status]).toEqual([200, 200]); - expect(recruitmentCalls).toEqual([ - { operation: "listAdmissionPeriodsForManagement", actor: leader }, - { operation: "readAssignmentBoard", actor: leader }, - ]); - }), - ); - - it.live("denies an anonymous scheduling-board caller before any domain call", () => - Effect.gen(function* () { - const response = yield* request("/api/recruitment/interviews", ""); - expect(response.status).toBe(401); - expect(yield* Effect.promise(() => response.json())).toEqual({ - type: "urn:vektorprogrammet:problem:v0.2:credential.missing", - title: "Credential required", - status: 401, - detail: "A credential is required for this operation.", - code: "credential.missing", - }); - expect(recruitmentCalls).toEqual([]); - }), - ); -}); diff --git a/apps/backend/src/router.test.ts b/apps/backend/src/router.test.ts deleted file mode 100644 index 074a599a5..000000000 --- a/apps/backend/src/router.test.ts +++ /dev/null @@ -1,1368 +0,0 @@ -import { backendDatabase } from "../test/database.js"; -import { - IdentitySnapshot, - OAuthCredentialAuthority, - type OAuthExpectedMechanism, -} from "@vektorprogrammet/database"; -import { - Identity, - IdentityEngineError, - IdentityActor, - IdentitySession, - IdentitySessionExpired, - IdentityOwnedSessionNotFound, - IdentitySessionNotFound, - type IdentityOperations, -} from "@vektorprogrammet/domain/identity"; -import { Database } from "@vektorprogrammet/database"; -import { - MembershipId, - TeamId, - DepartmentId, - Organization, - PersonId, - type OrganizationAuthorityInstant, - type OrganizationOperations, -} from "@vektorprogrammet/domain/organization"; -import { - PersonContactProfile, - PersonProfile, - Profile, - type ProfileOperations, -} from "@vektorprogrammet/domain/profile"; -import { Schools } from "@vektorprogrammet/domain/schools"; -import { SocialEvents } from "@vektorprogrammet/domain/social-events"; -import { - CredentialEvidenceRef, - CredentialMechanismSchema, - CredentialOutcomeSchema, - PrincipalSchema, -} from "@vektorprogrammet/domain/authz"; -import { Economy } from "@vektorprogrammet/domain/receipt"; -import { NativeProblem, SocialEventsReadScopeProblem } from "@vektorprogrammet/http-api"; -import { DateTime, Effect, Layer, Schema } from "effect"; -import { describe, expect, it } from "@effect/vitest"; -import { decodeBackendConfig } from "./config.js"; -import { contactConfig } from "./contact/config.js"; -import { - externalNativePreflightAttachmentGaps, - externalNativePreflightMethodsForPath, -} from "./native-api-preflight.js"; -import { jsonText } from "./http-api/problem.js"; -import { makeBackendTestHttp as backendHttpHandler } from "./test/native-http.js"; - -const token = "better-auth.session_token"; - -const environment = { - BACKEND_PG_URL: "postgres://test.invalid/vektorprogrammet", - BETTER_AUTH_SECRET: "router-test-secret-with-at-least-32-characters!", - NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify(["http://127.0.0.1:5174"]), - OAUTH_CANONICAL_ORIGIN: "http://127.0.0.1:5174", - OAUTH_DASHBOARD_ORIGIN: "http://127.0.0.1:5174", - OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - PUBLIC_APPLICATION_EFFECT_MODE: "disabled", - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_MODE: "disabled", - ADMISSION_FIXED_NOW: "2031-09-15T12:00:00.000Z", -} as const; - -const config = Effect.runSync(decodeBackendConfig(environment)); - -const database = backendDatabase( - Database.use((sql) => - Effect.gen(function* () { - yield* sql`INSERT INTO person_profiles VALUES ('member-1','Member','One',0)`; - yield* sql`INSERT INTO person_contact_profiles VALUES ('member-1','member@example.invalid','90000000',0)`; - }), - ), -); - -const profile: ProfileOperations = { - readProfiles: (personIds) => - Effect.succeed( - personIds.map( - (personId) => - new PersonProfile({ personId, firstName: "Member", lastName: "One", revision: 0 }), - ), - ), - readContacts: (personIds) => - Effect.succeed( - personIds.map( - (personId) => - new PersonContactProfile({ - personId, - email: "member@example.invalid", - phone: "90000000", - revision: 0, - }), - ), - ), - readOwnProfile: (personId) => - Effect.succeed({ - personId, - firstName: "Member", - lastName: "One", - email: "member@example.invalid", - phone: "90000000", - nameRevision: 0, - contactRevision: 0, - }), - updateOwnProfile: (input) => - Effect.as(profile.readOwnProfile(input.actorPersonId), { - personId: PersonId.make(input.actorPersonId), - firstName: input.command.firstName, - lastName: input.command.lastName, - email: input.command.email, - phone: input.command.phone, - nameRevision: input.command.expectedNameRevision + 1, - contactRevision: input.command.expectedContactRevision + 1, - }), - readDirectoryPage: () => Effect.succeed({ entries: [], nextCursor: undefined }), -}; - -const organization = { - listDepartments: Effect.succeed([]), - listTeams: () => Effect.succeed([]), - listFieldOfStudies: Effect.succeed([]), - resolvePersonAuthority: ( - _personId: PersonId, - _authorizationInstant: OrganizationAuthorityInstant, - ) => - Effect.succeed({ - personId: PersonId.make("member-1"), - evaluatedAt: "2031-09-15T12:00:00.000Z", - globalAdministrator: "Absent", - memberships: [ - { - membershipId: MembershipId.make("membership-1"), - teamId: TeamId.make("team-1"), - departmentId: DepartmentId.make("department-1"), - active: true, - unitLeader: false, - unitKind: "Team", - teamScope: "HomeDepartment", - departmentIndependent: false, - }, - ], - nationalBoardSeats: [], - delegations: [], - }), - resolvePersonAuthorityForRead: ( - personId: PersonId, - authorizationInstant: OrganizationAuthorityInstant, - ) => - Effect.succeed({ - personId, - evaluatedAt: authorizationInstant, - globalAdministrator: "Absent", - memberships: [ - { - membershipId: MembershipId.make("membership-1"), - teamId: TeamId.make("team-1"), - departmentId: DepartmentId.make("department-1"), - active: true, - unitLeader: false, - unitKind: "Team", - teamScope: "HomeDepartment", - departmentIndependent: false, - }, - ], - nationalBoardSeats: [], - delegations: [], - }), -} satisfies Partial; - -const schools = Schools.of({ - readManagement: () => Effect.die("unexpected school management read"), - authorizeCommand: () => Effect.die("unexpected school command authorization"), - executeCommand: () => Effect.die("unexpected school command"), - listDirectory: () => Effect.succeed({ activeSchools: [], inactiveSchools: [] }), -}); - -const socialEvents = SocialEvents.of({ - readSnapshotInstant: Effect.die("unexpected social-event read"), - readScope: () => Effect.die("unexpected social-event read"), - readList: () => Effect.die("unexpected social-event read"), - validateScope: () => Effect.die("unexpected social-event validation"), - create: () => Effect.die("unexpected social-event create"), -}); - -const oauthCredentialAuthority = OAuthCredentialAuthority.of({ - resolve: () => Effect.die("unexpected OAuth credential resolution"), - resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), -}); - -const makeBackendServices = ( - identity: IdentityOperations, - organizationService: Partial = organization, -) => - Layer.mergeAll( - database.layer, - Layer.succeed(Profile, profile), - Layer.mock(Organization, organizationService), - Layer.succeed(Schools, schools), - Layer.succeed(Identity, identity), - Layer.succeed(SocialEvents, socialEvents), - Layer.succeed( - IdentitySnapshot, - IdentitySnapshot.of({ - resolveSession: (cookieHeader) => - identity.resolveSession(cookieHeader).pipe( - Effect.catchTag("IdentitySessionExpired", () => - Effect.fail( - new IdentityEngineError({ - operation: "resolveSnapshotSession", - message: "test identity failure", - }), - ), - ), - ), - revokeCurrentSession: () => Effect.succeed({ setCookies: [] }), - revokeSession: (_actor, sessionId, request) => - identity.revokeSession(undefined, sessionId, request).pipe( - Effect.catchTag( - ["IdentitySessionNotFound", "IdentitySessionExpired", "IdentityEngineError"], - () => - Effect.fail( - new IdentityEngineError({ - operation: "revokeSession", - message: "Session revocation failed", - }), - ), - ), - ), - revokeOtherSessions: () => Effect.succeed({ setCookies: [] }), - revokeAllSessions: () => Effect.succeed({ setCookies: [] }), - }), - ), - Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), - ); - -const currentSession = new IdentitySession({ - sessionId: "session-1", - createdAt: DateTime.makeUnsafe("2031-09-15T12:00:00.000Z"), - updatedAt: DateTime.makeUnsafe("2031-09-15T12:00:00.000Z"), - expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), - ipAddress: "127.0.0.1", - userAgent: "router-test", - current: true, -}); - -const successfulIdentity = Identity.of({ - signIn: () => Effect.die("unexpected sign-in"), - resolveSession: (cookieHeader: string | undefined) => - cookieHeader !== undefined && cookieHeader.includes(`${token}=`) - ? Effect.succeed( - new IdentityActor({ - personId: PersonId.make("member-1"), - sessionId: "session-1", - expiresAt: currentSession.expiresAt, - }), - ) - : Effect.fail(new IdentitySessionNotFound()), - readCurrentSession: () => Effect.succeed(currentSession), - listSessions: () => Effect.succeed([currentSession]), - revokeCurrentSession: () => Effect.succeed({ setCookies: [] }), - revokeSession: () => Effect.succeed({ setCookies: [] }), - revokeOtherSessions: () => Effect.succeed({ setCookies: [] }), - revokeAllSessions: () => Effect.succeed({ setCookies: [] }), - recordSecurityEvent: () => Effect.void, - signOut: () => Effect.succeed({ setCookies: [] }), -} satisfies IdentityOperations); - -const unavailableAuthHandler = { - handler: () => Effect.succeed(new Response(null, { status: 404 })), - recordTrustedOriginRejection: () => Effect.void, -}; - -const successfulServices = makeBackendServices(successfulIdentity); - -const backend = backendHttpHandler(config, successfulServices, unavailableAuthHandler); - -const request = (pathname: string, init?: RequestInit): Effect.Effect => - backend.fetch(new Request(`http://backend.test${pathname}`, init)); - -const expectedProblem = (code: string, title: string, status: number, detail: string) => ({ - type: `urn:vektorprogrammet:problem:v0.2:${code}`, - title, - status, - detail, - code, -}); - -describe("unified backend router", () => { - it("derives preflight methods with every generated operation attached", () => { - expect(externalNativePreflightMethodsForPath("/api/session")).toEqual(["GET", "DELETE"]); - expect(externalNativePreflightAttachmentGaps).toEqual([]); - }); - it.live( - "owns health, Profile, Organization, Schools, Admission, Receipt, and Recruitment routes", - () => - Effect.gen(function* () { - const [ - health, - profile, - organizationResponse, - schoolsResponse, - admission, - receipt, - recruitment, - publicRecruitment, - missing, - internalEvidence, - ] = yield* Effect.all( - [ - request("/health"), - request("/api/profile", { headers: { cookie: `${token}=value` } }), - request("/api/departments"), - request("/api/schools", { headers: { cookie: `${token}=value` } }), - request("/api/admission-periods"), - request("/api/receipts"), - request("/api/recruitment/application-assignments?status=new"), - request("/api/recruitment/invitation-response"), - request("/api/not-a-capability"), - request("/api/e2e/receipts/receipt-one/evidence", { - headers: { cookie: `${token}=value` }, - }), - ], - { concurrency: "unbounded" }, - ); - - expect({ status: health.status, body: yield* Effect.promise(() => health.json()) }).toEqual( - { - status: 200, - body: { status: "ok" }, - }, - ); - expect({ - status: profile.status, - body: yield* Effect.promise(() => profile.json()), - }).toEqual({ - status: 200, - body: { - personId: PersonId.make("member-1"), - firstName: "Member", - lastName: "One", - email: "member@example.invalid", - phone: "90000000", - role: "ROLE_TEAM_MEMBER", - nameRevision: 0, - contactRevision: 0, - }, - }); - expect({ - status: organizationResponse.status, - body: yield* Effect.promise(() => organizationResponse.json()), - }).toEqual({ - status: 200, - body: [], - }); - expect({ - status: schoolsResponse.status, - body: yield* Effect.promise(() => schoolsResponse.json()), - }).toEqual({ - status: 200, - body: { activeSchools: [], inactiveSchools: [] }, - }); - - for (const response of [admission, receipt, recruitment]) { - expect({ - status: response.status, - body: yield* Effect.promise(() => response.json()), - }).toEqual({ - status: 401, - body: expectedProblem( - "credential.missing", - "Credential required", - 401, - "A credential is required for this operation.", - ), - }); - } - - expect({ - status: publicRecruitment.status, - body: yield* Effect.promise(() => publicRecruitment.json()), - }).toEqual({ - status: 404, - body: expectedProblem( - "resource.not-found", - "Resource not found", - 404, - "The requested resource was not found.", - ), - }); - - for (const response of [missing, internalEvidence]) { - expect({ - status: response.status, - body: yield* Effect.promise(() => response.json()), - }).toEqual({ - status: 404, - body: expectedProblem( - "resource.not-found", - "Resource not found", - 404, - "The requested resource was not found.", - ), - }); - } - }), - ); - - it.live("leaves every off-spec content alias at the unified 404 boundary", () => - Effect.gen(function* () { - const responses = yield* Effect.all( - [ - request("/api/admin/content/drafts", { method: "POST" }), - request("/api/admin/content/drafts/7", { method: "PUT" }), - request("/api/admin/content", { method: "POST" }), - request("/api/articles", { method: "GET" }), - request("/articles/7", { method: "GET" }), - ], - { concurrency: "unbounded" }, - ); - - for (const response of responses) { - expect({ - status: response.status, - body: yield* Effect.promise(() => response.json()), - }).toEqual({ - status: 404, - body: expectedProblem( - "resource.not-found", - "Resource not found", - 404, - "The requested resource was not found.", - ), - }); - } - }), - ); - - it.live("dispatches team-interest and mailing-list reads through Organization", () => - Effect.gen(function* () { - const [teamInterest, mailingLists] = yield* Effect.all( - [request("/api/team-interest-registrations"), request("/api/mailing-lists")], - { concurrency: "unbounded" }, - ); - - for (const response of [teamInterest, mailingLists]) { - expect({ - status: response.status, - body: yield* Effect.promise(() => response.json()), - }).toEqual({ - status: 401, - body: expectedProblem( - "credential.missing", - "Credential required", - 401, - "A credential is required for this operation.", - ), - }); - } - }), - ); - - it.live("exposes exactly the six safe native session resources and removes the old path", () => - Effect.gen(function* () { - const cookieHeaders = { cookie: `${token}=value; other=1` }; - - const mutationHeaders = { - ...cookieHeaders, - origin: "http://127.0.0.1:5174", - "idempotency-key": "session-mutation-key-0001", - }; - - const current = yield* request("/api/session", { headers: cookieHeaders }); - expect(current.headers.get("cache-control")).toBe("private, no-store"); - expect({ status: current.status, body: yield* Effect.promise(() => current.json()) }).toEqual( - { - status: 200, - body: { - sessionId: "session-1", - personId: PersonId.make("member-1"), - createdAt: "2031-09-15T12:00:00.000Z", - updatedAt: "2031-09-15T12:00:00.000Z", - expiresAt: "2031-09-16T12:00:00.000Z", - ipAddress: "127.0.0.1", - userAgent: "router-test", - current: true, - }, - }, - ); - const listed = yield* request("/api/sessions", { headers: cookieHeaders }); - expect(listed.headers.get("cache-control")).toBe("private, no-store"); - expect({ status: listed.status, body: yield* Effect.promise(() => listed.json()) }).toEqual({ - status: 200, - body: [ - { - sessionId: "session-1", - createdAt: "2031-09-15T12:00:00.000Z", - updatedAt: "2031-09-15T12:00:00.000Z", - expiresAt: "2031-09-16T12:00:00.000Z", - ipAddress: "127.0.0.1", - userAgent: "router-test", - personId: PersonId.make("member-1"), - current: true, - }, - ], - }); - - for (const [path, method] of [ - ["/api/session", "DELETE"], - ["/api/sessions/session-1", "DELETE"], - ["/api/sessions:revoke-others", "POST"], - ["/api/sessions:revoke-all", "POST"], - ] as const) { - const response = yield* request(path, { method, headers: mutationHeaders }); - expect(response.status).toBe(204); - expect(yield* Effect.promise(() => response.text())).toBe(""); - expect(response.headers.getSetCookie()).toHaveLength(0); - } - - expect((yield* request("/api/session")).status).toBe(401); - expect((yield* request("/api/me/session", { headers: cookieHeaders })).status).toBe(404); - }), - ); - - it.live( - "requires a recognized Better Auth session cookie before authoritative handlers run", - () => - Effect.gen(function* () { - let currentReads = 0; - - const guardedBackend = backendHttpHandler( - config, - makeBackendServices({ - ...successfulIdentity, - readCurrentSession: () => - Effect.sync(() => { - currentReads += 1; - - return currentSession; - }), - }), - unavailableAuthHandler, - ); - - for (const cookie of [undefined, "", "theme=dark", "vp.session_token=opaque"]) { - const response = yield* guardedBackend.fetch( - new Request("http://backend.test/api/session", { - headers: cookie === undefined ? undefined : { cookie }, - }), - ); - - expect(response.status).toBe(401); - } - - expect(currentReads).toBe(0); - - for (const cookie of [ - "better-auth.session_token=opaque", - "__Secure-better-auth.session_token=opaque", - ]) { - const response = yield* guardedBackend.fetch( - new Request("http://backend.test/api/session", { headers: { cookie } }), - ); - - expect(response.status).toBe(200); - } - - expect(currentReads).toBe(2); - }), - ); - - describe("classifies absent and rejected credentials at ingress", () => { - const personChallenge = 'VektorSession realm="native-api", Bearer realm="native-api"'; - - const decodeProblemCode = Schema.decodeUnknownSync(Schema.Struct({ code: Schema.String })); - - const classifyingBackend = backendHttpHandler( - config, - Layer.mergeAll( - makeBackendServices({ - ...successfulIdentity, - resolveSession: (cookieHeader: string | undefined) => - cookieHeader?.split(/;\s*/u).includes(`${token}=valid-session`) - ? Effect.succeed( - new IdentityActor({ - personId: PersonId.make("member-1"), - sessionId: "session-1", - expiresAt: currentSession.expiresAt, - }), - ) - : Effect.fail(new IdentitySessionNotFound()), - }), - Layer.succeed( - OAuthCredentialAuthority, - OAuthCredentialAuthority.of({ - resolve: () => - Effect.succeed(CredentialOutcomeSchema.cases.Rejected.make({ reason: "Invalid" })), - resolveInTransaction: () => - Effect.succeed(CredentialOutcomeSchema.cases.Rejected.make({ reason: "Invalid" })), - }), - ), - Layer.mock(Economy, { - listReceiptsForApproval: () => Effect.succeed({ items: [] }), - }), - ), - unavailableAuthHandler, - ); - - it.live.each([ - ["Session", "/api/session", 'VektorSession realm="native-api"'], - ["Person", "/api/profile", personChallenge], - ["PersonOrService", "/api/receipt-approval-queue", personChallenge], - ] as const)("for a %s-secured operation", ([_security, path, challenge]) => - Effect.gen(function* () { - const fetchWith = (headers: Record) => - classifyingBackend.fetch(new Request(`http://backend.test${path}`, { headers })); - - for (const [headers, code] of [ - [{}, "credential.missing"], - [{ cookie: "theme=dark; vp.session_token=opaque" }, "credential.missing"], - [{ cookie: `theme=dark; ${token}=unknown-session` }, "credential.invalid"], - [{ authorization: "Bearer unknown-token" }, "credential.invalid"], - ] as const) { - const response = yield* fetchWith(headers); - - expect({ - status: response.status, - code: decodeProblemCode(yield* Effect.promise(() => response.json())).code, - challenge: response.headers.get("www-authenticate"), - }).toEqual({ status: 401, code, challenge }); - } - - expect((yield* fetchWith({ cookie: `theme=dark; ${token}=valid-session` })).status).toBe( - 200, - ); - }), - ); - - it.live("for the contact server credential", () => - Effect.gen(function* () { - const contactBackend = backendHttpHandler( - { - ...config, - contact: contactConfig({ - CONTACT_BACKEND_TOKEN: "router-contact-credential-00000000000000", - CONTACT_DELIVERY_TOKEN: "router-contact-delivery", - CONTACT_SENDER: "contact@example.org", - CONTACT_DELIVERY_URL: "http://127.0.0.1:9", - CONTACT_DELIVERY_TIMEOUT_MS: "100", - }), - }, - successfulServices, - unavailableAuthHandler, - ); - - for (const [headers, code] of [ - [{}, "credential.missing"], - [{ "x-vektor-contact-backend": "wrong" }, "credential.invalid"], - ] as const) { - const response = yield* contactBackend.fetch( - new Request("http://backend.test/api/contact-messages", { - method: "POST", - headers: { - "content-type": "application/json", - origin: "http://127.0.0.1:5174", - "x-vektor-contact-ip": "127.0.0.1", - ...headers, - }, - body: yield* jsonText({ - departmentId: "one", - name: "Ola", - email: "ola@example.org", - subject: "Hei", - message: "Hei", - }), - }), - ); - - const { code: problemCode } = yield* Schema.decodeUnknownEffect(NativeProblem)( - yield* Effect.promise(() => response.json()), - ); - - expect({ - status: response.status, - code: problemCode, - challenge: response.headers.get("www-authenticate"), - }).toEqual({ status: 401, code, challenge: 'ContactSSR realm="native-contact"' }); - } - }), - ); - }); - - describe("accepts exactly one credential per request", () => { - const personChallenge = 'VektorSession realm="native-api", Bearer realm="native-api"'; - - // The session cookie names member-1; each bearer names the Person beside it. - const sessionCookie = `${token}=member-1-session`; - - const bearers = [ - ["Bearer member-1-bearer", "member-1"], - ["Bearer member-2-bearer", "member-2"], - ] as const; - - const bearerOutcome = (request: Request, expected: OAuthExpectedMechanism) => { - const personId = bearers.find( - ([bearer]) => bearer === request.headers.get("authorization"), - )?.[1]; - - return personId === undefined || expected === "OAuthServiceBearer" - ? CredentialOutcomeSchema.cases.Rejected.make({ reason: "Invalid" }) - : CredentialOutcomeSchema.cases.Accepted.make({ - mechanism: CredentialMechanismSchema.cases.OAuthUserBearer.make({}), - principal: PrincipalSchema.cases.Person.make({ personId: PersonId.make(personId) }), - evidenceRef: CredentialEvidenceRef.make(`oauth:Person:${personId}`), - }); - }; - - // The projection names whichever Person the resolved credential names. - const actingOrganization: Partial = { - ...organization, - resolvePersonAuthority: (personId, authorizationInstant) => - Effect.map( - organization.resolvePersonAuthority(personId, authorizationInstant), - (authority) => ({ ...authority, personId }), - ), - }; - - const oneCredentialBackend = backendHttpHandler( - config, - Layer.mergeAll( - makeBackendServices( - { - ...successfulIdentity, - resolveSession: (cookieHeader: string | undefined) => - cookieHeader?.split(/;\s*/u).includes(sessionCookie) === true - ? Effect.succeed( - new IdentityActor({ - personId: PersonId.make("member-1"), - sessionId: "session-1", - expiresAt: currentSession.expiresAt, - }), - ) - : Effect.fail(new IdentitySessionNotFound()), - }, - actingOrganization, - ), - Layer.succeed( - OAuthCredentialAuthority, - OAuthCredentialAuthority.of({ - resolve: (request, expected) => Effect.succeed(bearerOutcome(request, expected)), - resolveInTransaction: (request, expected) => - Effect.succeed(bearerOutcome(request, expected)), - }), - ), - Layer.mock(Economy, { - listReceiptsForApproval: () => Effect.succeed({ items: [] }), - }), - ), - unavailableAuthHandler, - ); - - // A problem names its code; a profile names the Person who acted. - const answer = (path: string, headers: Record) => - Effect.gen(function* () { - const response = yield* oneCredentialBackend.fetch( - new Request(`http://backend.test${path}`, { headers }), - ); - - const body = yield* Schema.decodeUnknownEffect( - Schema.Struct({ - code: Schema.optional(Schema.String), - personId: Schema.optional(Schema.String), - }), - )(yield* Effect.promise(() => response.json())); - - return { - status: response.status, - challenge: response.headers.get("www-authenticate"), - ...body, - }; - }); - - // Each test case gets a fresh database, so both cases seed the bearer's Person. - const seedBearerPerson = () => - database.run( - Database.use((sql) => - Effect.gen(function* () { - yield* sql`INSERT INTO person_profiles VALUES ('member-2','Member','Two',0)`; - yield* sql`INSERT INTO person_contact_profiles VALUES ('member-2','member-2@example.invalid','90000001',0)`; - }), - ), - ); - - it.live.each([ - ["Session", "/api/session", 'VektorSession realm="native-api"'], - ["Person", "/api/profile", personChallenge], - ["PersonOrService", "/api/receipt-approval-queue", personChallenge], - ] as const)( - "rejects a session cookie with a bearer at a %s-secured operation", - ([_security, path, challenge]) => - Effect.gen(function* () { - yield* seedBearerPerson(); - - // The first bearer names another Person than the session; the second names the same one. - for (const bearer of ["Bearer member-2-bearer", "Bearer member-1-bearer"]) { - expect({ - bearer, - ...(yield* answer(path, { - cookie: `theme=dark; ${sessionCookie}`, - authorization: bearer, - })), - }).toEqual({ bearer, status: 401, code: "credential.invalid", challenge }); - } - }), - ); - - it.live("lets either person credential alone name the acting Person", () => - Effect.gen(function* () { - yield* seedBearerPerson(); - - expect(yield* answer("/api/profile", { cookie: sessionCookie })).toEqual({ - status: 200, - challenge: null, - personId: "member-1", - }); - expect(yield* answer("/api/profile", { authorization: "Bearer member-2-bearer" })).toEqual({ - status: 200, - challenge: null, - personId: "member-2", - }); - }), - ); - }); - - it.live("answers a handler defect with the frozen internal.error problem", () => - Effect.gen(function* () { - // The router's SocialEvents service dies on every call. - const response = yield* request("/api/social-events/scope", { - headers: { cookie: `${token}=value` }, - }); - - expect(response.status).toBe(500); - expect(response.headers.get("content-type")).toBe("application/problem+json"); - expect(response.headers.get("cache-control")).toBe("no-store"); - - const problem = yield* Schema.decodeUnknownEffect(SocialEventsReadScopeProblem)( - yield* Effect.promise(() => response.json()), - { onExcessProperty: "error" }, - ); - - expect(problem.code).toBe("internal.error"); - }), - ); - - it.live("conceals missing, non-owned, and already-revoked session ids identically", () => - Effect.gen(function* () { - const owned = new Set(["owned-session"]); - let revokeCalls = 0; - - const ownerBackend = backendHttpHandler( - config, - makeBackendServices({ - ...successfulIdentity, - revokeSession: (_cookie, sessionId) => - Effect.suspend(() => { - revokeCalls += 1; - - return owned.delete(sessionId) - ? Effect.succeed({ setCookies: [] }) - : Effect.fail(new IdentityOwnedSessionNotFound({ sessionId })); - }), - }), - unavailableAuthHandler, - ); - - const headers = { - cookie: `${token}=value`, - origin: "http://127.0.0.1:5174", - "idempotency-key": "owned-session-delete-key-01", - }; - - expect( - (yield* ownerBackend.fetch( - new Request("http://backend.test/api/sessions/owned-session", { - method: "DELETE", - headers, - }), - )).status, - ).toBe(204); - - const replay = yield* ownerBackend.fetch( - new Request("http://backend.test/api/sessions/owned-session", { - method: "DELETE", - headers, - }), - ); - - expect(replay.status).toBe(204); - expect(revokeCalls).toBe(1); - - for (const sessionId of ["owned-session", "missing-session", "another-person-session"]) { - const response = yield* ownerBackend.fetch( - new Request(`http://backend.test/api/sessions/${sessionId}`, { - method: "DELETE", - headers: { ...headers, "idempotency-key": `conceal-session-${sessionId}-key` }, - }), - ); - - expect({ - status: response.status, - body: yield* Effect.promise(() => response.json()), - }).toEqual({ - status: 404, - body: expectedProblem( - "resource.not-found", - "Resource not found", - 404, - "The requested resource was not found.", - ), - }); - } - - expect(revokeCalls).toBe(4); - }), - ); - - it.live("centralizes trusted-origin, CSRF rejection, audit, and credentialed CORS", () => - Effect.gen(function* () { - const handled: string[] = []; - const rejectedCorrelations: string[] = []; - - const originBackend = backendHttpHandler(config, successfulServices, { - handler: (request) => - Effect.sync(() => { - handled.push(new URL(request.url).pathname); - - return new Response(null, { status: 204 }); - }), - recordTrustedOriginRejection: (context) => - Effect.sync(() => { - rejectedCorrelations.push(context.requestCorrelation); - }), - }); - - const trustedOrigin = "http://127.0.0.1:5174"; - - const trusted = yield* originBackend.fetch( - new Request("http://backend.test/api/auth/sign-in/email", { - method: "POST", - headers: { origin: trustedOrigin }, - }), - ); - - expect(trusted.status).toBe(204); - expect(trusted.headers.get("access-control-allow-origin")).toBe(trustedOrigin); - expect(trusted.headers.get("access-control-allow-credentials")).toBe("true"); - expect(trusted.headers.get("access-control-allow-origin")).not.toBe("*"); - - for (const headers of [ - new Headers({ origin: "https://untrusted.example.invalid" }), - new Headers(), - ]) { - const rejected = yield* originBackend.fetch( - new Request("http://backend.test/api/auth/sign-in/email", { - method: "POST", - headers, - }), - ); - - expect({ - status: rejected.status, - body: yield* Effect.promise(() => rejected.json()), - }).toEqual({ - status: 403, - body: { - type: "urn:vektorprogrammet:problem:v0.2:origin.denied", - title: "Origin denied", - status: 403, - code: "origin.denied", - detail: "The browser origin is not trusted for this operation.", - }, - }); - expect(rejected.headers.get("access-control-allow-origin")).toBeNull(); - } - - const protectedCrossOrigin = yield* originBackend.fetch( - new Request("http://backend.test/api/session", { - headers: { - cookie: `${token}=value`, - origin: "https://untrusted.example.invalid", - }, - }), - ); - - expect(protectedCrossOrigin.status).toBe(403); - - const preflight = yield* originBackend.fetch( - new Request("http://backend.test/api/session", { - method: "OPTIONS", - headers: { origin: trustedOrigin, "access-control-request-method": "GET" }, - }), - ); - - expect(preflight.status).toBe(204); - expect(preflight.headers.get("access-control-allow-origin")).toBe(trustedOrigin); - expect(preflight.headers.get("access-control-allow-credentials")).toBe("true"); - expect(handled).toEqual(["/api/auth/sign-in/email"]); - expect(rejectedCorrelations).toHaveLength(3); - expect(new Set(rejectedCorrelations).size).toBe(3); - }), - ); - it.live("keeps OAuth protocol errors outside the native origin problem boundary", () => - Effect.gen(function* () { - const oauthCalls: string[] = []; - const rejectedCorrelations: string[] = []; - - const oauthBackend = backendHttpHandler(config, successfulServices, { - handler: () => Effect.succeed(new Response(null, { status: 404 })), - oauthHandler: (request) => - Effect.sync(() => { - oauthCalls.push(`${request.method} ${new URL(request.url).pathname}`); - - return Response.json( - { error: "invalid_request" }, - { - status: 400, - headers: { - "cache-control": "no-store", - "content-type": "application/json", - }, - }, - ); - }), - recordTrustedOriginRejection: (context) => - Effect.sync(() => { - rejectedCorrelations.push(context.requestCorrelation); - }), - }); - - const response = yield* oauthBackend.fetch( - new Request("http://backend.test/api/auth/oauth2/consent", { - method: "POST", - headers: { origin: "https://untrusted.example.invalid" }, - }), - ); - - expect({ - status: response.status, - body: yield* Effect.promise(() => response.json()), - }).toEqual({ - status: 400, - body: { error: "invalid_request" }, - }); - expect(response.headers.get("access-control-allow-origin")).toBeNull(); - expect(oauthCalls).toEqual(["POST /api/auth/oauth2/consent"]); - expect(rejectedCorrelations).toEqual([]); - }), - ); - - it.live("allows only the centralized native browser request headers before dispatch", () => - Effect.gen(function* () { - const dispatched: string[] = []; - const rejectedCorrelations: string[] = []; - const origin = "http://127.0.0.1:5174"; - - const backend = backendHttpHandler(config, successfulServices, { - handler: (request) => - Effect.sync(() => { - dispatched.push(new URL(request.url).pathname); - - return new Response(null, { status: 204 }); - }), - recordTrustedOriginRejection: (context) => - Effect.sync(() => { - rejectedCorrelations.push(context.requestCorrelation); - }), - }); - - const allowed = yield* backend.fetch( - new Request("http://backend.test/api/session", { - method: "OPTIONS", - headers: { - origin, - "access-control-request-method": "DELETE", - "access-control-request-headers": - "CONTENT-type, idempotency-KEY, IF-match, If-None-Match, x-Recruitment-Invitation-Capability", - }, - }), - ); - - expect(allowed.status).toBe(204); - expect(allowed.headers.get("access-control-allow-headers")).toBe( - "Authorization, Content-Type, Idempotency-Key, If-Match, If-None-Match, X-Recruitment-Invitation-Capability", - ); - expect(allowed.headers.get("access-control-allow-methods")).toBe( - "GET, HEAD, DELETE, OPTIONS", - ); - expect(allowed.headers.get("vary")).toBe( - "Origin, Access-Control-Request-Method, Access-Control-Request-Headers", - ); - - const unknown = yield* backend.fetch( - new Request("http://backend.test/api/session", { - method: "OPTIONS", - headers: { - origin, - "access-control-request-method": "DELETE", - "access-control-request-headers": "Content-Type, X-Unknown-Native-Header", - }, - }), - ); - - expect({ status: unknown.status, body: yield* Effect.promise(() => unknown.json()) }).toEqual( - { - status: 400, - body: { - type: "urn:vektorprogrammet:problem:v0.2:header.malformed", - title: "Malformed header", - status: 400, - code: "header.malformed", - detail: "A request header is malformed.", - }, - }, - ); - - const wrongMethod = yield* backend.fetch( - new Request("http://backend.test/api/session", { - method: "OPTIONS", - headers: { - origin, - "access-control-request-method": "POST", - }, - }), - ); - - expect(wrongMethod.status).toBe(405); - expect(wrongMethod.headers.get("allow")).toBe("GET, HEAD, DELETE, OPTIONS"); - expect(dispatched).toEqual([]); - expect(rejectedCorrelations).toHaveLength(0); - }), - ); - - it("composes local and production cookie policy without invented origins", () => { - expect(config.sessionBoundary).toEqual({ - deployment: "local", - trustedOrigins: ["http://127.0.0.1:5174"], - secureCookies: false, - }); - expect( - Effect.runSync( - decodeBackendConfig({ - ...environment, - NATIVE_IDENTITY_DEPLOYMENT: "production", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify(["https://dashboard.example.invalid"]), - OAUTH_CANONICAL_ORIGIN: "https://dashboard.example.invalid", - OAUTH_DASHBOARD_ORIGIN: "https://dashboard.example.invalid", - }), - ).sessionBoundary, - ).toEqual({ - deployment: "production", - trustedOrigins: ["https://dashboard.example.invalid"], - secureCookies: true, - }); - expect(() => - Effect.runSync( - decodeBackendConfig({ - ...environment, - NATIVE_IDENTITY_DEPLOYMENT: "production", - NATIVE_IDENTITY_TRUSTED_ORIGINS: undefined, - }), - ), - ).toThrow(); - }); - - it.each([ - ["BETTER_AUTH_URL", "http://127.0.0.1:5174"], - ["BETTER_AUTH_URL", ""], - ["BETTER_AUTH_TRUSTED_ORIGINS", "http://127.0.0.1:5174"], - ["BETTER_AUTH_TRUSTED_ORIGINS", ""], - ] as const)("rejects unsupported %s even when its value is %j", (name, value) => { - expect(() => Effect.runSync(decodeBackendConfig({ ...environment, [name]: value }))).toThrow( - "unsupported", - ); - }); - - it.live("forwards an evidence-only clock to protected authority resolution", () => - Effect.gen(function* () { - const authorizationInstants: string[] = []; - const pinnedInstant = "2037-01-15T12:00:00.000Z"; - - const observedOrganization: Partial = { - ...organization, - resolvePersonAuthority: (personId, authorizationInstant) => { - authorizationInstants.push(authorizationInstant); - - return organization.resolvePersonAuthority(personId, authorizationInstant); - }, - }; - - const pinnedBackend = backendHttpHandler( - config, - makeBackendServices(successfulIdentity, observedOrganization), - unavailableAuthHandler, - { now: () => pinnedInstant }, - ); - - const response = yield* pinnedBackend.fetch( - new Request("http://backend.test/api/profile", { - headers: { cookie: `${token}=value` }, - }), - ); - - expect(response.status).toBe(200); - expect(authorizationInstants).toEqual([pinnedInstant]); - }), - ); - - it.live.each([ - [ - "expired session", - new IdentitySessionExpired(), - 401, - "credential.invalid", - "Invalid credential", - "The supplied credential is invalid.", - ], - [ - "typed provider failure", - new IdentityEngineError({ - operation: "getSession", - message: "authentication provider unavailable", - }), - 503, - "identity.unavailable", - "Identity unavailable", - "The identity service is temporarily unavailable.", - ], - ] as const)( - "maps %s at the session HTTP boundary", - ([_name, failure, status, code, title, detail]) => - Effect.gen(function* () { - const failingBackend = backendHttpHandler( - config, - makeBackendServices({ - ...successfulIdentity, - readCurrentSession: () => Effect.fail(failure), - }), - unavailableAuthHandler, - ); - - const response = yield* failingBackend.fetch( - new Request("http://backend.test/api/session", { - headers: { cookie: "better-auth.session_token=session-value" }, - }), - ); - - expect({ - status: response.status, - body: yield* Effect.promise(() => response.json()), - }).toEqual({ - status, - body: expectedProblem(code, title, status, detail), - }); - }), - ); - - it.live("mounts the auth engine handler over the /api/auth/* surface", () => - Effect.gen(function* () { - const probingBackend = backendHttpHandler(config, successfulServices, { - handler: (request) => - Effect.sync(() => new Response(`auth-saw:${new URL(request.url).pathname}`)), - recordTrustedOriginRejection: () => Effect.void, - }); - - for (const path of ["/api/auth/get-session", "/api/auth/sign-in/email", "/api/auth/"]) { - const response = yield* probingBackend.fetch( - new Request(`http://backend.test${path}`, { - method: "POST", - headers: { origin: "http://127.0.0.1:5174" }, - }), - ); - - expect(yield* Effect.promise(() => response.text())).toBe(`auth-saw:${path}`); - } - }), - ); - - it("requires TLS for non-loopback application effect providers", () => { - expect(() => - Effect.runSync( - decodeBackendConfig({ - ...environment, - PUBLIC_APPLICATION_EFFECT_MODE: "http", - PUBLIC_APPLICATION_EFFECT_ENDPOINT: "http://provider.example.invalid/effects", - PUBLIC_APPLICATION_EFFECT_TOKEN: "provider-token", - }), - ), - ).toThrow("must use HTTPS unless it targets loopback"); - - expect( - Effect.runSync( - decodeBackendConfig({ - ...environment, - PUBLIC_APPLICATION_EFFECT_MODE: "http", - PUBLIC_APPLICATION_EFFECT_ENDPOINT: "http://127.0.0.1:8898/effects", - PUBLIC_APPLICATION_EFFECT_TOKEN: "provider-token", - }), - ).publicApplicationEffects?.endpoint.href, - ).toBe("http://127.0.0.1:8898/effects"); - }); - - it("requires an explicit application effect mode", () => { - const { PUBLIC_APPLICATION_EFFECT_MODE: _, ...implicitEnvironment } = environment; - expect(() => Effect.runSync(decodeBackendConfig(implicitEnvironment))).toThrow( - "PUBLIC_APPLICATION_EFFECT_MODE must be disabled or http", - ); - expect(() => - Effect.runSync( - decodeBackendConfig({ - ...environment, - PUBLIC_APPLICATION_EFFECT_ENDPOINT: "https://provider.example.invalid/effects", - PUBLIC_APPLICATION_EFFECT_TOKEN: "provider-token", - }), - ), - ).toThrow("require PUBLIC_APPLICATION_EFFECT_MODE=http"); - }); -}); - -it.live("classifies only exact password recovery method/path origin rejections", () => - Effect.gen(function* () { - const observed: Array = []; - - const backend = backendHttpHandler(config, successfulServices, { - handler: () => Effect.die(new Error("Rejected origin must not reach engine")), - recordTrustedOriginRejection: (_context, flow) => - Effect.sync(() => { - observed.push(flow); - }), - }); - - const cases = [ - ["POST", "/api/auth/request-password-reset", "PasswordRecovery"], - ["POST", "/api/auth/reset-password", "PasswordRecovery"], - ["GET", "/api/auth/reset-password/opaque", "PasswordRecovery"], - ["GET", "/api/auth/request-password-reset", undefined], - ["POST", "/api/auth/reset-password/opaque", undefined], - ["GET", "/api/auth/reset-password/opaque/extra", undefined], - ["POST", "/api/auth/sign-in/email", undefined], - ] as const; - - for (const [method, path, flow] of cases) { - expect( - (yield* backend.fetch( - new Request(`http://backend.test${path}`, { - method, - headers: { origin: "https://untrusted.example.invalid" }, - }), - )).status, - ).toBe(403); - expect(observed.at(-1)).toBe(flow); - } - }), -); diff --git a/apps/backend/src/router.ts b/apps/backend/src/router.ts index 230766ff0..d0450b316 100644 --- a/apps/backend/src/router.ts +++ b/apps/backend/src/router.ts @@ -1,57 +1,40 @@ -import { OnboardingApiHandlers } from "./onboarding/http.js"; -import { PlacementsApiHandlers } from "./placements/http.js"; -import { CertificateFontsLive } from "./placements/certificate-pdf.js"; -import { CertificatesApiHandlers } from "./placements/certificates-http.js"; -import { ContactApiHandlers } from "./contact/http.js"; import { BlockList, isIP } from "node:net"; -import type { AuthEngineService, OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { InactiveActor, UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import { databaseHealth, type AuthEngineService } from "@vektorprogrammet/database"; +import { CONTACT_BACKEND_HEADER, CONTACT_IP_HEADER } from "@vektorprogrammet/domain/contact"; +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import { RECEIPT_FILE_MAX_BYTES } from "@vektorprogrammet/domain/receipt"; import { - RecruitmentInactiveActor, - RecruitmentRoleDenied, -} from "@vektorprogrammet/domain/recruitment"; -import { type Identity, type IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import { ARTICLE_SLUG_MAX_LENGTH } from "@vektorprogrammet/domain/content"; -import { ExternalNativeApi, InternalNativeApi } from "@vektorprogrammet/http-api"; -import { Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Schema, Cause, Predicate, Effect, Layer } from "effect"; + InternalNativeRpcs, + internalNativeRpcPath, + isInternalNativeRpcPath, + isNativeRpcPath, + NativeRpcs, + nativeRpcPath, +} from "@vektorprogrammet/rpc"; +import { Problem, problemBody, problemHeaders } from "@vektorprogrammet/rpc/problem"; +import { Effect, Layer, Option, Predicate, Schema } from "effect"; +import { dual } from "effect/Function"; import { HttpEffect, HttpRouter, HttpServerResponse } from "effect/unstable/http"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { AdmissionsApiHandlers } from "./admission/http.js"; -import { admissionActorForAuthority } from "./admission/http-context.js"; -import { AdmissionOutcomesApiHandlers } from "./admission/outcome-http.js"; -import { DirectoryApiHandlers } from "./directory/http.js"; -import { - organizationActorFrom, - recruitmentBoardActorFrom, - resolveAuthenticatedPerson, - resolveAuthenticatedPersonAtInstant, - resolveRequestPerson, - resolveRequestPersonAtInstant, - resolveRequestCredentialAtInstant, - resolveRequestPersonAuthority, -} from "./authority.js"; -import type { BackendConfig } from "./config.js"; -import { ContentApiHandlers } from "./content/http.js"; -import { SystemApiHandlers } from "./http-api/system.js"; -import { ProblemBoundaryLive, problemWebResponse } from "./http-api/problem.js"; -import { nativeHttpApiMiddlewareLayer } from "./http-api/transport.js"; +import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; +import { AdmissionsRpcHandlers } from "./admission/rpc.js"; +import { AdmissionOutcomesRpcHandlers } from "./admission/outcome-rpc.js"; +import { ContactRpcHandlers } from "./contact/rpc.js"; +import { ContentRpcHandlers } from "./content/rpc.js"; +import { DirectoryRpcHandlers } from "./directory/rpc.js"; import { allowHeader } from "./http-semantics.js"; -import { externalNativePreflightMethodsForPath } from "./native-api-preflight.js"; - import { decideNativePreflight } from "./native-preflight.js"; -import { OrganizationApiHandlers } from "./organization/http.js"; -import { ProfileApiHandlers } from "./profile/http.js"; -import { InternalReceiptApiHandlers, ReceiptApiHandlers } from "./receipt/http.js"; -import type { ReceiptIdentityResolvers } from "./receipt/http-context.js"; -import { - ReceiptFileStoreResource, - ReceiptFileStoreLive, - type ReceiptFileStore, -} from "./receipt/filesystem.js"; -import { RecruitmentApiHandlers } from "./recruitment/http.js"; -import { SocialEventsApiHandlers, type SocialEventTransactionHook } from "./social-events/http.js"; -import { TeamApplicationsApiHandlers } from "./team-application/http.js"; +import { OnboardingRpcHandlers } from "./onboarding/rpc.js"; +import { OrganizationRpcHandlers } from "./organization/rpc.js"; +import { CertificatesRpcHandlers } from "./placements/certificates-rpc.js"; +import { PlacementsRpcHandlers } from "./placements/rpc.js"; +import { ProfileRpcHandlers } from "./profile/rpc.js"; +import { InternalReceiptsRpcHandlers, ReceiptsRpcHandlers } from "./receipt/rpc.js"; +import { RecruitmentRpcHandlers } from "./recruitment/rpc.js"; +import { nativeRpcCredentialLayer } from "./rpc/credential.js"; +import type { NativeRpcOptions } from "./rpc/options.js"; +import { ProblemBoundaryLive } from "./rpc/problem.js"; +import { readBoundedJson } from "./rpc/read-json.js"; +import { SystemRpcHandlers } from "./rpc/system.js"; import { allowsNativePreflightHeaders, decideTrustedOrigin, @@ -60,6 +43,19 @@ import { withTrustedOriginCors, type NativeSessionBoundaryPolicy, } from "./session-security.js"; +import { SocialEventsRpcHandlers } from "./social-events/rpc.js"; +import { TeamApplicationsRpcHandlers } from "./team-application/rpc.js"; + +/** + * Renders one problem at the HTTP ingress, outside any RPC: an origin denial, a method outside a + * path's methods, an unknown path, an unavailable health check, and an RPC body that is too large + * or not JSON. + */ +const problemWebResponse = (problem: Problem): Response => + new Response(JSON.stringify(problemBody(problem)), { + status: problem.status, + headers: { ...problemHeaders(problem), "content-type": "application/problem+json" }, + }); /** A browser request from an origin the session boundary does not trust; the answer varies by Origin. */ const originRejected = (): Response => { @@ -69,7 +65,7 @@ const originRejected = (): Response => { return response; }; -/** A method outside the resource's frozen method set, with the path's Allow header. */ +/** A method outside the path's methods, with the path's Allow header. */ const methodNotAllowed = (methods: ReadonlyArray): Response => { const response = problemWebResponse(Problem.make("method.not-allowed")); response.headers.set("allow", allowHeader(methods)); @@ -77,11 +73,102 @@ const methodNotAllowed = (methods: ReadonlyArray): Response => { return response; }; -export const nativeHttpRouterConfig = { - // FindMyWay compares the encoded path segment. The longest routed identifier is an article - // slug; the other identifiers are shorter. - maxParamLength: ARTICLE_SLUG_MAX_LENGTH, -} as const; +/** The HTTP methods of each native path: the RPC endpoint and the health probe. */ +export const nativePreflightMethodsForPath = (pathname: string): ReadonlyArray => + isNativeRpcPath(pathname) ? ["POST"] : pathname === "/health" ? ["GET"] : []; + +/** + * The headers a caller may set inside an RPC message. The RPC server lays message headers over + * the HTTP headers, so any other message header could forge an ingress fact: the source address, + * the user agent, or the request correlation of an audit record. A server that calls for a person + * forwards that person's credential, and the homepage its contact secret and the visitor address, + * which only the contact RPC reads behind its secret. + */ +const callerMessageHeaders: ReadonlySet = new Set([ + "authorization", + "cookie", + CONTACT_BACKEND_HEADER, + CONTACT_IP_HEADER, +]); + +// A request message: its tag and headers, and every other member unchanged. +const RpcRequestMessage = Schema.StructWithRest( + Schema.TaggedStruct("Request", { + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), + }), + [Schema.Record(Schema.String, Schema.Json)], +); + +const decodeRpcRequestMessage = Schema.decodeUnknownOption(RpcRequestMessage); + +/** + * Keeps only the caller message headers of every RPC request message, so the handlers see every + * other header from the HTTP request alone. A value that is not a request message passes + * unchanged: the RPC server rejects it. + */ +const restrictRpcMessageHeaders = (body: Schema.Json): Schema.Json => { + const restrict = (message: Schema.Json): Schema.Json => + Option.match(decodeRpcRequestMessage(message), { + onNone: () => message, + onSome: (request) => ({ + ...request, + headers: request.headers.filter(([name]) => callerMessageHeaders.has(name.toLowerCase())), + }), + }); + + return Array.isArray(body) ? body.map(restrict) : restrict(body); +}; + +/** + * The largest RPC request body the ingress reads: a receipt file at its size bound, as the base64 + * text that JSON serialization carries, plus 1 MiB for the rest of the message. + */ +export const nativeRpcMaxBodyBytes = Math.ceil((RECEIPT_FILE_MAX_BYTES * 4) / 3) + 1024 * 1024; + +/** + * The RPC request, read within `nativeRpcMaxBodyBytes`, parsed without duplicate members, and with + * its message headers restricted; any other request unchanged. A body over the bound is + * request.too-large and one that is not JSON, or repeats a member, request.malformed: two parsers + * could disagree on which of two members wins, so the ingress admits neither reading. + */ +const restrictedNativeRequest = (request: Request, pathname: string) => + request.method !== "POST" || !(isNativeRpcPath(pathname) || isInternalNativeRpcPath(pathname)) + ? Effect.succeed(request) + : readBoundedJson(request, nativeRpcMaxBodyBytes).pipe( + Effect.map((body) => { + const headers = new Headers(request.headers); + + headers.delete("content-length"); + + return new Request(request.url, { + method: request.method, + headers, + body: JSON.stringify(restrictRpcMessageHeaders(body)), + }); + }), + ); + +/** + * Serves one native request: the RPC endpoint, the health probe, or a not-found answer. No answer + * is stored by a cache, because an RPC answer is private to its caller, and an ingress problem is + * rendered before any handler runs. + */ +const serveNative = (nativeHandler: BackendHttpHandler, request: Request, pathname: string) => + restrictedNativeRequest(request, pathname).pipe( + Effect.flatMap(nativeHandler), + Effect.catch((problem) => Effect.succeed(problemWebResponse(problem))), + Effect.map((response) => { + const headers = new Headers(response.headers); + + headers.set("cache-control", "private, no-store"); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + }), + ); /** * Answers one web request. A failure is either rendered as a response or dies, and the server @@ -101,7 +188,7 @@ const jsonResponse = (body: Schema.Json, status = 200): Response => /** * The identity engine operations of the HTTP boundary: the Better Auth handler mounted only at * `/api/auth/*`, the trusted-origin rejection audit, and the optional OAuth surfaces. They share - * the process-owned identity engine with the native API. + * the process-owned identity engine with the native RPC endpoint. */ export type BackendAuthHandler = Pick< AuthEngineService, @@ -111,169 +198,88 @@ export type BackendAuthHandler = Pick< Pick >; -const isRecruitmentActorDenial = Schema.is( - Schema.Union([InactiveActor, RecruitmentInactiveActor, RecruitmentRoleDenied]), +export type BackendHttpOptions = Omit; + +/** `GET /health`: the database answers, or health.unavailable. Infrastructure probes it. */ +const healthRoute = HttpRouter.use((router) => + router.add( + "GET", + "/health", + databaseHealth.pipe( + // A web Response's body is read once, so each probe gets its own answer. + Effect.map(() => HttpServerResponse.fromWeb(jsonResponse({ status: "ok" }))), + Effect.orElseSucceed(() => + HttpServerResponse.fromWeb(problemWebResponse(Problem.make("health.unavailable"))), + ), + ), + ), ); -export interface BackendHttpOptions { - /** Evidence compositions can pin one authorization instant without patching the global clock. */ - readonly now?: () => string; - /** Test-only social-event transaction coordination for real concurrent snapshot evidence. */ - readonly socialEventsTransactionHook?: SocialEventTransactionHook; - /** Selects the composition-owned private receipt store; Bun keeps its filesystem default. */ - readonly receiptFileStore?: ReceiptFileStore; -} +const notFound = HttpRouter.use((router) => + router.add( + "*", + "*", + Effect.sync(() => + HttpServerResponse.fromWeb(problemWebResponse(Problem.make("resource.not-found"))), + ), + ), +); /** - * Builds every external native handler group from the process-owned capability - * graph. This function constructs Layers once at the composition root. + * The external native routes: every native RPC at `nativeRpcPath`, and the health probe. The + * handlers of each context take the same options; the composition root builds them once. */ -export const ExternalNativeApiRouterLive = ( - config: BackendConfig, - options: BackendHttpOptions = {}, -) => { - const receiptIdentity: ReceiptIdentityResolvers< - IdentityEngineError | UnauthenticatedActor, - Identity | OAuthCredentialAuthority - > = { - resolveAuthorizationPrincipal: (request: Request) => - resolveRequestPersonAtInstant(request, { now: options.now }), - resolvePersonId: (request: Request) => resolveRequestPerson(request), - resolveApprovalCredential: (request: Request) => - resolveRequestCredentialAtInstant(request, "Either", { now: options.now }), - }; - - const receiptOptions = { - config: config.receipt, - identity: receiptIdentity, - now: options.now, - }; - - const middlewareLayer = nativeHttpApiMiddlewareLayer(config.contact); +export const ExternalNativeRpcRouterLive = (rpcOptions: NativeRpcOptions) => { + const config = rpcOptions.config; const handlers = Layer.mergeAll( - AdmissionOutcomesApiHandlers({ now: options.now }), - PlacementsApiHandlers({ now: options.now }), - OnboardingApiHandlers({ now: options.now, delivery: config.onboarding }), - ContactApiHandlers(config.contact), - SystemApiHandlers(options), - AdmissionsApiHandlers({ - config: config.admission, - resolveActor: (request, departmentScope) => - resolveRequestPersonAuthority(request, { now: options.now }).pipe( - Effect.flatMap((authority) => admissionActorForAuthority(authority, departmentScope)), - ), - }), - ReceiptApiHandlers(receiptOptions).pipe( - Layer.provide( - options.receiptFileStore === undefined - ? ReceiptFileStoreLive({ - stagingRoot: config.receipt.stagingRoot, - committedRoot: config.receipt.committedRoot, - failNextPromotionEffectId: config.receipt.e2e?.failNextPromotionEffectId, - }) - : Layer.succeed(ReceiptFileStoreResource, options.receiptFileStore), - ), - ), - RecruitmentApiHandlers({ - config: config.recruitment, - resolveActor: (request) => - resolveRequestPersonAuthority(request, { now: options.now }).pipe( - Effect.flatMap((authority) => - Effect.try({ - try: () => recruitmentBoardActorFrom(authority), - catch: (cause) => - isRecruitmentActorDenial(cause) ? cause : new Cause.UnknownError(cause), - }), - ), - ), - }), - OrganizationApiHandlers({ - config: config.organization, - resolveActor: (request) => - resolveRequestPersonAuthority(request, { now: options.now }).pipe( - Effect.map(organizationActorFrom), - ), - resolveAuthority: (request) => resolveRequestPersonAuthority(request, { now: options.now }), - }), - DirectoryApiHandlers( - { - resolveAuthority: (request) => resolveRequestPersonAuthority(request, { now: options.now }), - }, - { - resolveActor: (request) => resolveRequestPersonAtInstant(request, { now: options.now }), - }, - ), - ContentApiHandlers((request) => resolveRequestPersonAtInstant(request, { now: options.now })), - ProfileApiHandlers({ - config, - resolveActor: (request) => resolveRequestPersonAuthority(request, { now: options.now }), - }), - SocialEventsApiHandlers({ transactionHook: options.socialEventsTransactionHook }), - TeamApplicationsApiHandlers(config.teamApplication), - CertificatesApiHandlers.pipe(Layer.provide(CertificateFontsLive)), - ).pipe(Layer.provide(middlewareLayer)); - - const nativeRoutes = HttpApiBuilder.layer(ExternalNativeApi).pipe( - Layer.provide(handlers), - Layer.provide(middlewareLayer), + AdmissionOutcomesRpcHandlers(rpcOptions), + AdmissionsRpcHandlers(rpcOptions), + CertificatesRpcHandlers(rpcOptions), + ContactRpcHandlers(rpcOptions), + ContentRpcHandlers(rpcOptions), + DirectoryRpcHandlers(rpcOptions), + OnboardingRpcHandlers(rpcOptions), + OrganizationRpcHandlers(rpcOptions), + PlacementsRpcHandlers(rpcOptions), + ProfileRpcHandlers(rpcOptions), + ReceiptsRpcHandlers(rpcOptions), + RecruitmentRpcHandlers(rpcOptions), + SocialEventsRpcHandlers(rpcOptions), + SystemRpcHandlers(rpcOptions), + TeamApplicationsRpcHandlers(rpcOptions), ); - const notFound = HttpRouter.use((router) => - router.add( - "*", - "*", - Effect.sync(() => - HttpServerResponse.fromWeb(problemWebResponse(Problem.make("resource.not-found"))), - ), - ), + const rpcRoute = RpcServer.layerHttp({ + group: NativeRpcs, + path: nativeRpcPath, + protocol: "http", + }).pipe( + Layer.provide(handlers), + Layer.provide(nativeRpcCredentialLayer(config.contact)), + Layer.provide(ProblemBoundaryLive), + Layer.provide(RpcSerialization.layerJson), ); - return Layer.mergeAll(nativeRoutes, notFound, ProblemBoundaryLive); + return Layer.mergeAll(rpcRoute, healthRoute, notFound); }; -/** Builds the isolated internal API root for an explicitly selected ingress. */ -export const InternalNativeApiRouterLive = ( - config: BackendConfig, - options: BackendHttpOptions = {}, -) => { - const receiptIdentity: ReceiptIdentityResolvers< - IdentityEngineError | UnauthenticatedActor, - Identity - > = { - resolveAuthorizationPrincipal: (request: Request) => - resolveAuthenticatedPersonAtInstant(request.headers.get("cookie") ?? undefined, { - now: options.now, - }), - resolvePersonId: (request: Request) => - resolveAuthenticatedPerson(request.headers.get("cookie") ?? undefined), - }; - - const receiptOptions = { - config: config.receipt, - identity: receiptIdentity, - now: options.now, - }; - - const middlewareLayer = nativeHttpApiMiddlewareLayer(config.contact); - const handlers = InternalReceiptApiHandlers(receiptOptions).pipe(Layer.provide(middlewareLayer)); - - const internalRoutes = HttpApiBuilder.layer(InternalNativeApi).pipe( - Layer.provide(handlers), - Layer.provide(middlewareLayer), +/** The isolated internal RPC routes for an explicitly selected ingress. */ +export const InternalNativeRpcRouterLive = (rpcOptions: NativeRpcOptions) => { + const config = rpcOptions.config; + + const rpcRoute = RpcServer.layerHttp({ + group: InternalNativeRpcs, + path: internalNativeRpcPath, + protocol: "http", + }).pipe( + Layer.provide(InternalReceiptsRpcHandlers(rpcOptions)), + Layer.provide(nativeRpcCredentialLayer(config.contact)), + Layer.provide(ProblemBoundaryLive), + Layer.provide(RpcSerialization.layerJson), ); - const notFound = HttpRouter.use((router) => - router.add( - "*", - "*", - Effect.sync(() => - HttpServerResponse.fromWeb(problemWebResponse(Problem.make("resource.not-found"))), - ), - ), - ); - - return Layer.mergeAll(internalRoutes, notFound, ProblemBoundaryLive); + return Layer.mergeAll(rpcRoute, notFound); }; const oauthAuthorizationServerMetadataPath = "/.well-known/oauth-authorization-server/api/auth"; @@ -383,68 +389,71 @@ export const nativeRouterWebHandler = (router: HttpRouter.HttpRouter): BackendHt }; /** - * Explicit external boundary around the native HttpApi handler. - * Better Auth remains the only external path family outside `ExternalNativeApi`. + * Explicit external boundary around the native RPC handler. Better Auth and the OAuth routes are + * the only external path families outside `NativeRpcs` and the health probe. */ -export const backendHttpHandler = +export const backendHttpHandler: { + ( + authHandler: BackendAuthHandler, + sessionBoundary: NativeSessionBoundaryPolicy, + ): (nativeHandler: BackendHttpHandler) => BackendHttpHandler; + ( + nativeHandler: BackendHttpHandler, + authHandler: BackendAuthHandler, + sessionBoundary: NativeSessionBoundaryPolicy, + ): BackendHttpHandler; +} = dual( + 3, ( nativeHandler: BackendHttpHandler, authHandler: BackendAuthHandler, sessionBoundary: NativeSessionBoundaryPolicy, ): BackendHttpHandler => - (request) => - Effect.gen(function* () { - const prepared = prepareIdentityBoundaryRequest(request); - const pathname = new URL(prepared.request.url).pathname; - const oauthNamespace = isOAuthProviderNamespace(pathname); - - if (oauthNamespace && prepared.request.method === "OPTIONS") { - return jsonResponse({ error: { tag: "RouteNotFound" } }, 404); - } - - const oauthRouteKey = `${prepared.request.method} ${pathname}`; - - if (oauthNamespace && !externalOAuthRoutes.has(oauthRouteKey)) { - return jsonResponse({ error: { tag: "RouteNotFound" } }, 404); - } - - if (oauthNamespace) { - if ( - pathname === "/api/auth/oauth2/authorize" && - !(yield* authorizationRequestAccepted(prepared.request, authHandler)) - ) { - return invalidAuthorizationRequest(); + (request) => + Effect.gen(function* () { + const prepared = prepareIdentityBoundaryRequest(request); + const pathname = new URL(prepared.request.url).pathname; + const oauthNamespace = isOAuthProviderNamespace(pathname); + + if (oauthNamespace && prepared.request.method === "OPTIONS") { + return jsonResponse({ error: { tag: "RouteNotFound" } }, 404); } - if (authHandler.oauthHandler === undefined) { + const oauthRouteKey = `${prepared.request.method} ${pathname}`; + + if (oauthNamespace && !externalOAuthRoutes.has(oauthRouteKey)) { return jsonResponse({ error: { tag: "RouteNotFound" } }, 404); } - return yield* authHandler.oauthHandler(prepared.request, prepared.context); - } + if (oauthNamespace) { + if ( + pathname === "/api/auth/oauth2/authorize" && + !(yield* authorizationRequestAccepted(prepared.request, authHandler)) + ) { + return invalidAuthorizationRequest(); + } - const credentialFlow = - (prepared.request.method === "POST" && - (pathname === "/api/auth/request-password-reset" || - pathname === "/api/auth/reset-password")) || - (prepared.request.method === "GET" && /^\/api\/auth\/reset-password\/[^/]+$/.test(pathname)) - ? ("PasswordRecovery" as const) - : undefined; + if (authHandler.oauthHandler === undefined) { + return jsonResponse({ error: { tag: "RouteNotFound" } }, 404); + } - const decision = decideTrustedOrigin(sessionBoundary, prepared.request); - const acceptedOrigin = Predicate.isTagged(decision, "Allowed") ? decision.origin : null; + return yield* authHandler.oauthHandler(prepared.request, prepared.context); + } - if (Predicate.isTagged(decision, "Rejected")) { - // The audit is best effort: whatever its outcome, the origin is rejected. - yield* Effect.ignoreCause( - authHandler.recordTrustedOriginRejection(prepared.context, credentialFlow), - ); + const credentialFlow = + (prepared.request.method === "POST" && + (pathname === "/api/auth/request-password-reset" || + pathname === "/api/auth/reset-password")) || + (prepared.request.method === "GET" && + /^\/api\/auth\/reset-password\/[^/]+$/.test(pathname)) + ? ("PasswordRecovery" as const) + : undefined; - return originRejected(); - } + const decision = decideTrustedOrigin(sessionBoundary, prepared.request); + const acceptedOrigin = Predicate.isTagged(decision, "Allowed") ? decision.origin : null; - if (prepared.request.method === "OPTIONS") { - if (acceptedOrigin === null) { + if (Predicate.isTagged(decision, "Rejected")) { + // The audit is best effort: whatever its outcome, the origin is rejected. yield* Effect.ignoreCause( authHandler.recordTrustedOriginRejection(prepared.context, credentialFlow), ); @@ -452,98 +461,121 @@ export const backendHttpHandler = return originRejected(); } - const requestedMethod = prepared.request.headers.get("access-control-request-method"); - - const preflight = decideNativePreflight({ - pathname, - requestedMethod, - headersAllowed: allowsNativePreflightHeaders(prepared.request), - methodsForPath: externalNativePreflightMethodsForPath, - }); + if (prepared.request.method === "OPTIONS") { + if (acceptedOrigin === null) { + yield* Effect.ignoreCause( + authHandler.recordTrustedOriginRejection(prepared.context, credentialFlow), + ); - if (Predicate.isTagged(preflight, "HeaderMalformed")) { - return withTrustedOriginCors( - problemWebResponse(Problem.make("header.malformed")), - acceptedOrigin, - ); - } + return originRejected(); + } - if (Predicate.isTagged(preflight, "MethodNotAllowed")) { - return withTrustedOriginCors(methodNotAllowed(preflight.methods), acceptedOrigin); - } + const requestedMethod = prepared.request.headers.get("access-control-request-method"); - if (Predicate.isTagged(preflight, "Ready")) { - return trustedPreflightResponse(acceptedOrigin, preflight.methods); - } + const preflight = decideNativePreflight({ + pathname, + requestedMethod, + headersAllowed: allowsNativePreflightHeaders(prepared.request), + methodsForPath: nativePreflightMethodsForPath, + }); - if ( - Predicate.isTagged(preflight, "RouteNotFound") && - (pathname === "/api/auth/" || pathname.startsWith("/api/auth/")) - ) { - if (!allowsNativePreflightHeaders(prepared.request)) { + if (Predicate.isTagged(preflight, "HeaderMalformed")) { return withTrustedOriginCors( problemWebResponse(Problem.make("header.malformed")), acceptedOrigin, ); } - const authResponse = yield* authHandler.handler(prepared.request, prepared.context); + if (Predicate.isTagged(preflight, "MethodNotAllowed")) { + return withTrustedOriginCors(methodNotAllowed(preflight.methods), acceptedOrigin); + } - return authResponse.status >= 200 && authResponse.status < 300 - ? trustedPreflightResponse(acceptedOrigin, [preflight.requestedMethod]) - : withTrustedOriginCors(authResponse, acceptedOrigin); - } + if (Predicate.isTagged(preflight, "Ready")) { + return trustedPreflightResponse(acceptedOrigin, preflight.methods); + } - return withTrustedOriginCors( - problemWebResponse(Problem.make("resource.not-found")), - acceptedOrigin, - ); - } + if ( + Predicate.isTagged(preflight, "RouteNotFound") && + (pathname === "/api/auth/" || pathname.startsWith("/api/auth/")) + ) { + if (!allowsNativePreflightHeaders(prepared.request)) { + return withTrustedOriginCors( + problemWebResponse(Problem.make("header.malformed")), + acceptedOrigin, + ); + } + + const authResponse = yield* authHandler.handler(prepared.request, prepared.context); + + return authResponse.status >= 200 && authResponse.status < 300 + ? trustedPreflightResponse(acceptedOrigin, [preflight.requestedMethod]) + : withTrustedOriginCors(authResponse, acceptedOrigin); + } - const response = - pathname === "/api/auth/" || pathname.startsWith("/api/auth/") - ? yield* authHandler.handler(prepared.request, prepared.context) - : yield* nativeHandler(prepared.request); + return withTrustedOriginCors( + problemWebResponse(Problem.make("resource.not-found")), + acceptedOrigin, + ); + } - return withTrustedOriginCors(response, acceptedOrigin); - }).pipe(Effect.orDie); + const response = + pathname === "/api/auth/" || pathname.startsWith("/api/auth/") + ? yield* authHandler.handler(prepared.request, prepared.context) + : yield* serveNative(nativeHandler, prepared.request, pathname); -/** Independent internal ingress: native internal API plus one non-fallthrough OAuth route. */ -export const internalBackendHttpHandler = ( - nativeHandler: BackendHttpHandler, - authHandler: BackendAuthHandler, - allowedSourceNetworks: ReadonlyArray, -): BackendHttpHandler => { - const allowedSources = sourceNetworkList(allowedSourceNetworks); + return withTrustedOriginCors(response, acceptedOrigin); + }).pipe(Effect.orDie), +); - return (request) => - Effect.gen(function* () { - const prepared = prepareIdentityBoundaryRequest(request); - const pathname = new URL(prepared.request.url).pathname; +/** Independent internal ingress: native internal API plus one non-fallthrough OAuth route. */ +export const internalBackendHttpHandler: { + ( + authHandler: BackendAuthHandler, + allowedSourceNetworks: ReadonlyArray, + ): (nativeHandler: BackendHttpHandler) => BackendHttpHandler; + ( + nativeHandler: BackendHttpHandler, + authHandler: BackendAuthHandler, + allowedSourceNetworks: ReadonlyArray, + ): BackendHttpHandler; +} = dual( + 3, + ( + nativeHandler: BackendHttpHandler, + authHandler: BackendAuthHandler, + allowedSourceNetworks: ReadonlyArray, + ): BackendHttpHandler => { + const allowedSources = sourceNetworkList(allowedSourceNetworks); + + return (request) => + Effect.gen(function* () { + const prepared = prepareIdentityBoundaryRequest(request); + const pathname = new URL(prepared.request.url).pathname; + + if (isOAuthProviderNamespace(pathname)) { + if (prepared.request.method !== "POST" || pathname !== "/api/auth/oauth2/introspect") { + return jsonResponse({ error: { tag: "RouteNotFound" } }, 404); + } - if (isOAuthProviderNamespace(pathname)) { - if (prepared.request.method !== "POST" || pathname !== "/api/auth/oauth2/introspect") { - return jsonResponse({ error: { tag: "RouteNotFound" } }, 404); - } + const sourceIp = prepared.context.sourceIp; + const family = sourceIp === null ? 0 : isIP(sourceIp); + + if ( + sourceIp === null || + (family !== 4 && family !== 6) || + !allowedSources.check(sourceIp, family === 4 ? "ipv4" : "ipv6") || + authHandler.oauthIntrospectionHandler === undefined + ) { + return Response.json( + { active: false }, + { status: 200, headers: { "cache-control": "no-store", pragma: "no-cache" } }, + ); + } - const sourceIp = prepared.context.sourceIp; - const family = sourceIp === null ? 0 : isIP(sourceIp); - - if ( - sourceIp === null || - (family !== 4 && family !== 6) || - !allowedSources.check(sourceIp, family === 4 ? "ipv4" : "ipv6") || - authHandler.oauthIntrospectionHandler === undefined - ) { - return Response.json( - { active: false }, - { status: 200, headers: { "cache-control": "no-store", pragma: "no-cache" } }, - ); + return yield* authHandler.oauthIntrospectionHandler(prepared.request, prepared.context); } - return yield* authHandler.oauthIntrospectionHandler(prepared.request, prepared.context); - } - - return yield* nativeHandler(prepared.request); - }).pipe(Effect.orDie); -}; + return yield* serveNative(nativeHandler, prepared.request, pathname); + }).pipe(Effect.orDie); + }, +); diff --git a/apps/backend/src/rpc/AGENTS.md b/apps/backend/src/rpc/AGENTS.md new file mode 100644 index 000000000..062fc858e --- /dev/null +++ b/apps/backend/src/rpc/AGENTS.md @@ -0,0 +1,32 @@ +[//]: # "guide: generated from docs/model/contexts.cml, the @construct tags, and package.json exports by just guides write; do not edit" + +# apps/backend/src/rpc + +This folder is not a bounded context. Transport shared by every context: credential middlewares, problems, rate limits, command receipts. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. + +## Entry points + +No `exports` entry of [apps/backend/package.json](../../package.json) points into this folder, so other packages do not import it. + +## Constructs + +The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../../../docs/constructs.md) indexes them all. + +- [`jsonText`](../../../../docs/constructs/rpc-problem.md#jsontext) (rpc-problem): The JSON text of a representation, byte for byte what `JSON.stringify` writes. +- [`problemMapper`](../../../../docs/constructs/rpc-problem.md#problemmapper) (rpc-problem): Builds the one failure-to-problem mapper of a domain. +- [`commandIdentity`](../../../../docs/constructs/rpc-problem.md#commandidentity) (rpc-problem): Derives a command's idempotency identity: the receipt digest and the domain command ID. +- [`requireCurrentETag`](../../../../docs/constructs/rpc-problem.md#requirecurrentetag) (rpc-problem): Fails a mutation whose If-Match no longer names the current representation. +- [`isSerializationConflict`](../../../../docs/constructs/rpc-problem.md#isserializationconflict) (rpc-problem): Whether a failure, or one of its causes, is a lost serialization or deadlock race: a transaction.conflict the client may retry. +- [`requestInvalid`](../../../../docs/constructs/rpc-problem.md#requestinvalid) (rpc-problem): The request as a whole fails validation; no single member is singled out. +- [`strictOutput`](../../../../docs/constructs/rpc-problem.md#strictoutput) (rpc-problem): Decodes one response value strictly. +- [`commandReceiptProblems`](../../../../docs/constructs/rpc-problem.md#commandreceiptproblems) (rpc-problem): Command receipts: the transport's own persistence failures. +- [`personPresentation`](../../../../docs/constructs/rpc-problem.md#personpresentation) (rpc-problem): The credential evidence of an RPC request: which credential headers it presented. +- [`commandOutcome`](../../../../docs/constructs/rpc-problem.md#commandoutcome) (rpc-problem): The value of a command receipt outcome: the committed or replayed success, or an idempotency problem. +- [`authorizeAnonymous`](../../../../docs/constructs/rpc-problem.md#authorizeanonymous) (rpc-problem): An anonymous AccessSpec grants every caller and conceals nothing, so a denial means the spec and its scope resolution disagree: a defect. +- [`authorizePerson`](../../../../docs/constructs/rpc-problem.md#authorizeperson) (rpc-problem): A rejected person credential is answered from the ingress evidence, never by string choice. +- [`unreachable`](../../../../docs/constructs/rpc-problem.md#unreachable) (rpc-problem): Marks problems a shared mapper can produce but this operation cannot, such as a serialization conflict inside a read-only snapshot. + +Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. + +[//]: # "guide: end" diff --git a/apps/backend/src/http-api/CLAUDE.md b/apps/backend/src/rpc/CLAUDE.md similarity index 100% rename from apps/backend/src/http-api/CLAUDE.md rename to apps/backend/src/rpc/CLAUDE.md diff --git a/apps/backend/src/rpc/credential.ts b/apps/backend/src/rpc/credential.ts new file mode 100644 index 000000000..317314d7e --- /dev/null +++ b/apps/backend/src/rpc/credential.ts @@ -0,0 +1,195 @@ +/** + * The backend implementations of the credential middlewares of `@vektorprogrammet/rpc`. + * + * Each authenticates the credential that its RPCs accept from the request headers that RPC over + * HTTP carries, and answers a rejected credential from the ingress evidence, as the HTTP security + * middleware did. None grants authority: a handler resolves the credential and the authority again + * inside the transaction that commits its command. + */ +import { timingSafeEqual } from "node:crypto"; +import { OAuthCredentialAuthority } from "@vektorprogrammet/database"; +import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import { CONTACT_BACKEND_HEADER } from "@vektorprogrammet/domain/contact"; +import { Identity } from "@vektorprogrammet/domain/identity"; +import { + ContactBackendCredential, + InvitationCapabilityCredential, + PersonCredential, + PersonOrServiceCredential, + SessionCredential, +} from "@vektorprogrammet/rpc"; +import { + credentialPresentation, + invitationCapabilityChallenge, + nativeCookieChallenge, + nativeUserChallenges, + Problem, +} from "@vektorprogrammet/rpc/problem"; +import { Effect, Layer, Result, Schema } from "effect"; +import type { Headers } from "effect/unstable/http"; +import { + headerCredentialCount, + resolveAuthenticatedSession, + resolveRequestCredentialAtInstant, + resolveRequestPerson, +} from "../authority.js"; +import type { ContactConfig } from "../contact/config.js"; +import { classifyCredential } from "./problem.js"; + +/** + * The web request that the credential resolvers of `authority.ts` read. They read only the + * `cookie` and `authorization` headers; the URL names no route. + */ +export const credentialRequestOf = (headers: Headers.Headers): Request => + new Request("http://native-rpc.invalid/api/rpc", { method: "POST", headers }); + +const isUnauthenticated = Schema.is(UnauthenticatedActor); + +/** Absence is read from the headers: a presented credential that failed is invalid. */ +const rejectCredential = (headers: Headers.Headers, challenge: string) => + Effect.fail( + Problem.unauthenticated(classifyCredential(headers.authorization, headers.cookie, challenge)), + ); + +/** + * Runs `authenticate` only when the request presents a credential. A request with neither a + * session cookie nor an Authorization header is answered credential.missing without asking the + * identity engine. + */ +const whenPresented = ( + headers: Headers.Headers, + challenge: string, + authenticate: Effect.Effect, +) => + headerCredentialCount(headers.cookie, headers.authorization) === 0 + ? rejectCredential(headers, challenge) + : authenticate; + +const SessionCredentialLive = Layer.effect(SessionCredential)( + Effect.map(Identity, (identity) => + SessionCredential.of((effect, { headers }) => + whenPresented( + headers, + nativeCookieChallenge, + Effect.gen(function* () { + const authentication = yield* Effect.result( + resolveAuthenticatedSession(headers.cookie, headers.authorization).pipe( + Effect.provideService(Identity, identity), + ), + ); + + if (Result.isFailure(authentication) && isUnauthenticated(authentication.failure)) { + return yield* rejectCredential(headers, nativeCookieChallenge); + } + + return yield* effect; + }), + ), + ), + ), +); + +const PersonCredentialLive = Layer.effect(PersonCredential)( + Effect.gen(function* () { + const identity = yield* Identity; + const oauthCredentialAuthority = yield* OAuthCredentialAuthority; + + return PersonCredential.of((effect, { headers }) => + whenPresented( + headers, + nativeUserChallenges(), + Effect.gen(function* () { + const authentication = yield* Effect.result( + resolveRequestPerson(credentialRequestOf(headers)).pipe( + Effect.provideService(Identity, identity), + Effect.provideService(OAuthCredentialAuthority, oauthCredentialAuthority), + ), + ); + + if (Result.isFailure(authentication) && isUnauthenticated(authentication.failure)) { + return yield* rejectCredential(headers, nativeUserChallenges()); + } + + return yield* effect; + }), + ), + ); + }), +); + +const PersonOrServiceCredentialLive = Layer.effect(PersonOrServiceCredential)( + Effect.gen(function* () { + const identity = yield* Identity; + const oauthCredentialAuthority = yield* OAuthCredentialAuthority; + + return PersonOrServiceCredential.of((effect, { headers }) => + whenPresented( + headers, + nativeUserChallenges(), + Effect.gen(function* () { + const authentication = yield* Effect.result( + resolveRequestCredentialAtInstant(credentialRequestOf(headers), "Either").pipe( + Effect.provideService(Identity, identity), + Effect.provideService(OAuthCredentialAuthority, oauthCredentialAuthority), + ), + ); + + if (Result.isFailure(authentication) && isUnauthenticated(authentication.failure)) { + return yield* rejectCredential(headers, nativeUserChallenges()); + } + + return yield* effect; + }), + ), + ); + }), +); + +/** The capability travels in the payload; a cookie or bearer beside it is a second credential. */ +const InvitationCapabilityCredentialLive = Layer.succeed(InvitationCapabilityCredential)( + InvitationCapabilityCredential.of((effect, { headers }) => + headerCredentialCount(headers.cookie, headers.authorization) > 0 + ? Effect.fail( + Problem.credentialInvalid( + credentialPresentation({ presented: true, challenge: invitationCapabilityChallenge }), + ), + ) + : effect, + ), +); + +const contactBackendCredentialLayer = (contact: ContactConfig | undefined) => { + const expected = contact === undefined ? undefined : Buffer.from(contact.backendToken); + + return Layer.succeed(ContactBackendCredential)( + ContactBackendCredential.of((effect, { headers }) => { + if (expected === undefined) return effect; + + const header = headers[CONTACT_BACKEND_HEADER]; + + const supplied = Buffer.from(header ?? ""); + + // A request without the header presented no credential; a wrong token presented an invalid one. + return supplied.length === expected.length && timingSafeEqual(supplied, expected) + ? effect + : Effect.fail( + Problem.unauthenticated( + credentialPresentation({ + presented: header !== undefined, + challenge: 'ContactSSR realm="native-contact"', + }), + ), + ); + }), + ); +}; + +/** Every credential middleware, for the composition root. */ +export const nativeRpcCredentialLayer = (contact?: ContactConfig) => + Layer.mergeAll( + SessionCredentialLive, + PersonCredentialLive, + PersonOrServiceCredentialLive, + InvitationCapabilityCredentialLive, + contactBackendCredentialLayer(contact), + ); diff --git a/apps/backend/src/rpc/options.ts b/apps/backend/src/rpc/options.ts new file mode 100644 index 000000000..42ed54875 --- /dev/null +++ b/apps/backend/src/rpc/options.ts @@ -0,0 +1,17 @@ +/** + * The options that the composition root passes to every context's RPC handlers. One shape for + * every context keeps the router a list, and lets each context read only what it needs. + */ +import type { BackendConfig } from "../config.js"; +import type { ReceiptFileStore } from "../receipt/filesystem.js"; +import type { SocialEventTransactionHook } from "../social-events/rpc.js"; + +export interface NativeRpcOptions { + readonly config: BackendConfig; + /** Evidence compositions can pin one authorization instant without patching the global clock. */ + readonly now?: () => string; + /** Test-only social-event transaction coordination for real concurrent snapshot evidence. */ + readonly socialEventsTransactionHook?: SocialEventTransactionHook; + /** Selects the composition-owned private receipt store; Bun keeps its filesystem default. */ + readonly receiptFileStore?: ReceiptFileStore; +} diff --git a/apps/backend/src/rpc/problem.ts b/apps/backend/src/rpc/problem.ts new file mode 100644 index 000000000..b5b8023fe --- /dev/null +++ b/apps/backend/src/rpc/problem.ts @@ -0,0 +1,671 @@ +/** + * Typed failures and command primitives of the native RPC handlers. A handler fails with `Problem` + * values, and each RPC encodes them against its declared problem union (`rpcProblems`), so an + * undeclared code, an unmapped domain failure, or a credential code chosen without ingress evidence + * does not compile. + */ +import type { AccessSpec, CanonicalScopeResolution } from "@vektorprogrammet/domain/authz"; +import { ProblemBoundary } from "@vektorprogrammet/rpc"; +import { + credentialPresentation, + type CredentialPresentation, + isProblem, + makeNativeValidationError, + type NativeProblemCode, + nativeUserChallenges, + type PlainProblemCode, + Problem, + type StrongETag, +} from "@vektorprogrammet/rpc/problem"; +import { Cause, Effect, ErrorReporter, Layer, Match, Predicate, Schema } from "effect"; +import type { Headers } from "effect/unstable/http"; +import { + type DerivedHttpIdentity, + deriveHttpIdentity, + evaluateMutationPrecondition, + type NativeIdempotencyIdentity, +} from "../http-semantics.js"; +import { + authorizeAnonymousNativeOperation, + authorizePersonNativeOperation, + type NativePersonAuthorization, +} from "../native-operation.js"; +import { hasBetterAuthSessionCredential } from "../session-security.js"; +import { + decodeStoredSuccess, + type NativeHttpCommandOutcome, + type NativeHttpReceiptInvalid, + type NativeHttpReceiptPersistenceError, +} from "./receipt-transaction.js"; +import { dual } from "effect/Function"; + +const JsonText = Schema.fromJsonString(Schema.Unknown); + +/** + * The JSON text of a representation, byte for byte what `JSON.stringify` writes. + * + * @remarks + * It encodes `value` with `Schema.fromJsonString(Schema.Unknown)`. A value that JSON cannot + * represent, such as a `bigint`, fails the encoding, and the failure becomes a defect, which + * `ProblemBoundaryLive` answers as internal.error. + * + * @sideEffects none + * + * @example + * ```ts + * return new Response(yield* jsonText(body), { headers: { "content-type": "application/json" } }); + * ``` + * + * @avoid `JSON.stringify` in a handler: it throws outside the error channel, and + * `effecttsgo/prefer-schema-over-json` rejects it. Write the text with `jsonText`. + * + * @construct rpc-problem + */ +export const jsonText = (value: A): Effect.Effect => + Schema.encodeEffect(JsonText)(value).pipe(Effect.orDie); + +/** + * Records, from the raw request only, whether person credential material was presented. + */ +export const classifyCredential: { + ( + cookie: string | null | undefined, + challenge: string, + ): (authorization: string | null | undefined) => CredentialPresentation; + ( + authorization: string | null | undefined, + cookie: string | null | undefined, + challenge: string, + ): CredentialPresentation; +} = dual( + 3, + ( + authorization: string | null | undefined, + cookie: string | null | undefined, + challenge: string, + ): CredentialPresentation => + credentialPresentation({ + presented: + Predicate.isNotNullish(authorization) || hasBetterAuthSessionCredential(cookie ?? null), + challenge, + }), +); + +/** + * Runs a throwing semantic parser. + * + * @remarks + * `parse` runs when the effect runs. A thrown `Problem` whose code is in `codes` becomes a failure + * with that code; any other throw, a problem of another code included, is a defect. The HTTP + * semantic parsers, such as `normalizeTarget`, throw their problems, so an Effect handler calls + * them through this. + * + * @sideEffects none of its own; it runs `parse`. + * + * @example + * ```ts + * const normalizedTarget = yield* semanticProblem(() => normalizeTarget(routeTemplate, identities), ["request.malformed"]); + * ``` + * + * @avoid `Effect.try` with a hand-written catch around a parser: an unexpected throw then becomes + * a failure that the endpoint does not declare. List the codes that the endpoint declares here. + */ +export const semanticProblem: { + ( + codes: ReadonlyArray, + ): (parse: () => A) => Effect.Effect>; + ( + parse: () => A, + codes: ReadonlyArray, + ): Effect.Effect>; +} = dual( + 2, + ( + parse: () => A, + codes: ReadonlyArray, + ): Effect.Effect> => + Effect.suspend(() => { + try { + return Effect.succeed(parse()); + } catch (cause) { + const code = isProblem(cause) + ? codes.find((declared) => declared === cause.code) + : undefined; + + return code === undefined ? Effect.die(cause) : Effect.fail(Problem.make(code)); + } + }), +); + +interface TaggedFailure { + readonly _tag: string; +} + +/** One case per tag of `Failure`: the problem that answers a failure with that tag. */ +export type ProblemCases = { + readonly [Tag in Failure["_tag"]]: (failure: Extract) => Problem; +}; + +/** `Cases` with no case for a tag outside `Failure`. */ +type ExactCases = Cases & { + readonly [Extra in Exclude]: never; +}; + +type MappedFailure> = + | Exclude + | ReturnType["_tag"]]>; + +/** The mapper that `problemMapper` builds from the cases `Cases` of `Failure`. */ +export type ProblemMapper> = < + A, + E, + R, +>( + effect: Effect.Effect, +) => Effect.Effect, R>; + +/** Cases that answer each failure tagged `Tag` from the credential that the request presented. */ +export type CredentialCases = { + readonly [Case in Tag]: () => Problem<"credential.missing"> | Problem<"credential.invalid">; +}; + +/** Cases that answer each failure tagged `Tag` with the endpoint's unavailable problem `Code`. */ +export type OutageCases = { + readonly [Case in Tag]: () => Problem; +}; + +/** + * Builds the one failure-to-problem mapper of a domain. + * + * @remarks + * `problemMapper()(cases)` takes one case per tag of `Failure` and no other key. The + * mapper maps each failure of an effect whose tag has a case to the problem that its case + * returns, and it passes every other failure through unchanged, so an unmapped failure still + * reaches, and fails, the endpoint's type check. The mapped error channel keeps only the problems + * that the effect's own failures map to. A mapper constant declares its type as + * `ProblemMapper`, with its cases in a constant that `satisfies` + * `ProblemCases`. A mapper that a function builds per request adds `CredentialCases` + * and `OutageCases` to its constant cases, for the answers that depend on its arguments. + * + * @sideEffects none + * + * @example + * ```ts + * const storedReceiptProblems = problemMapper()({ ReceiptDecodeError: () => Problem.make("receipts.unavailable") }); + * ``` + * + * @avoid Mapping a domain's failures with `Effect.mapError` or `Effect.catchTag` in each handler: + * the copies drift apart, so one failure gets different problems on different endpoints. Map each + * domain once and pipe its mapper. + * + * @construct rpc-problem + */ +export const problemMapper = + (): (>( + cases: ExactCases, + ) => ProblemMapper) => + >( + cases: ExactCases, + ): ProblemMapper => + ( + effect: Effect.Effect, + ): Effect.Effect, R> => { + // `cases` has exactly one handler per `Failure` tag and no other member. + const handlers = new Map Problem>(Object.entries(cases)); + + const mapped = Effect.mapError(effect, (error) => { + const tag = Predicate.hasProperty(error, "_tag") ? error._tag : undefined; + const handle = Predicate.isString(tag) ? handlers.get(tag) : undefined; + + return handle === undefined ? error : handle(error); + }); + + // SAFETY: failures with a mapped tag became the problem their case returns; the rest are unchanged. + // oxlint-disable-next-line effecttsgo/unsafe-effect-type-assertion -- EX-0002: Each failure tag maps to the problem type its case returns; TypeScript cannot index the generic `Cases` by the failure tag to infer that union, so the mapped error channel is asserted once here. + return mapped as Effect.Effect, R>; + }; + +/** + * Derives a command's idempotency identity: the receipt digest and the domain command ID. + * + * @remarks + * It runs `deriveHttpIdentity` over the credential subject, the operation id, the normalized + * target, and the idempotency key. The RPC payload schema has already decoded the key, and a + * handler passes its operation id and the path of the HTTP route it replaced as constants, so a + * tuple outside the grammar is a defect. Keeping the old path keeps the receipts and command IDs + * of commands that straddle the cutover stable. + * + * @sideEffects none + * + * @example + * ```ts + * const identity = yield* commandIdentity({ credentialSubject: `Person:${personId}`, qualifiedOperationId: "social-events.create", normalizedTarget: "/api/social-events", idempotencyKey }); + * ``` + * + * @avoid Deriving a new target from the RPC tag: a replay across the cutover then misses its + * receipt, and the domain command runs twice. Pass the old route path. + * + * @construct rpc-problem + */ +export const commandIdentity = ( + identity: NativeIdempotencyIdentity, +): Effect.Effect => + // The payload schema already decoded the key, and the operation and target are constants, so a + // throw here is a defect. + Effect.sync(() => deriveHttpIdentity(identity)); + +/** + * Fails a mutation whose If-Match no longer names the current representation. + * + * @remarks + * `evaluateMutationPrecondition` compares the strong tag of the current representation with the + * one that If-Match named: equal tags proceed, and different ones fail with precondition.failed. + * A command runs it in its transaction, after authorization and concealment, on the state that + * it is about to change. + * + * @sideEffects none + * + * @example + * ```ts + * service.reviseIntake(command, staff.principal, (current) => requireCurrentETag(intakeETag(teamId, current.revision), ifMatch)); + * ``` + * + * @avoid Comparing tags before the transaction, as a preflight read: a concurrent change can + * commit between the check and the write. Compare inside the transaction that writes. + * + * @construct rpc-problem + */ +export const requireCurrentETag: { + ( + ifMatch: StrongETag, + ): (current: StrongETag) => Effect.Effect>; + (current: StrongETag, ifMatch: StrongETag): Effect.Effect>; +} = dual( + 2, + ( + current: StrongETag, + ifMatch: StrongETag, + ): Effect.Effect> => + Predicate.isTagged(evaluateMutationPrecondition(current, ifMatch), "Failed") + ? Effect.fail(Problem.make("precondition.failed")) + : Effect.void, +); + +/** Whether a serialization failure or deadlock sits at most eight causes below `depth`. */ +const serializationConflictWithin = (cause: unknown, depth: number): boolean => + depth < 8 && + Predicate.isObjectOrArray(cause) && + (("code" in cause && (cause.code === "40001" || cause.code === "40P01")) || + (Predicate.hasProperty(cause, "reason") && + (Predicate.isTagged(cause.reason, "SerializationError") || + Predicate.isTagged(cause.reason, "DeadlockError"))) || + (Predicate.hasProperty(cause, "cause") && serializationConflictWithin(cause.cause, depth + 1))); + +/** + * Whether a failure, or one of its causes, is a lost serialization or deadlock race: a + * transaction.conflict the client may retry. + * + * @remarks + * It follows `cause` through its `cause` members, at most eight levels deep. A `code` of SQLSTATE + * 40001 (serialization failure) or 40P01 (deadlock detected), or a `reason` tagged + * `SerializationError` or `DeadlockError`, is such a race. + * + * @sideEffects none + * + * @example + * ```ts + * isSerializationConflict(failure) ? Problem.make("transaction.conflict") : failure; + * ``` + * + * @avoid Matching SQLSTATEs or messages in each handler: a race that a wrapper carries one level + * deeper then answers internal.error instead of a retryable conflict. Classify it with this. + * + * @construct rpc-problem + */ +export const isSerializationConflict = (cause: unknown): boolean => + serializationConflictWithin(cause, 0); + +/** + * The request as a whole fails validation; no single member is singled out. + * + * @remarks + * It is validation.failed with one diagnostic, code `invalid`, at the root pointer `""`. A domain + * that decodes a whole command at once cannot name the member that failed, so it answers this. + * + * @sideEffects none + * + * @example + * ```ts + * AdmissionPeriodDecodeError: requestInvalid, + * ``` + * + * @avoid Echoing the schema issue, or pointing at a guessed member: the answer then leaks decoding + * internals or misleads the client. Answer the whole request with this, or build + * `Problem.validation` with the pointers that the decoder knows. + * + * @construct rpc-problem + */ +export const requestInvalid = (): Problem<"validation.failed"> => + Problem.validation("validation.failed", [makeNativeValidationError("", "invalid")]); + +/** + * Decodes one response value strictly. + * + * @remarks + * It decodes the value that a handler is about to answer with the response schema and rejects + * excess properties, so a private field that the domain value carries cannot reach the response. + * A mismatch is a defect, which `ProblemBoundaryLive` answers as internal.error. + * + * @sideEffects none of its own; it runs the decoding services of `schema`. + * + * @example + * ```ts + * return yield* strictOutput(SocialEventListResource)(body); + * ``` + * + * @avoid Answering a domain value as it is, or through a spread: its private fields then reach the + * client. Decode it with the response schema first. + * + * @construct rpc-problem + */ +export const strictOutput = + >( + schema: S, + ): ((value: S["Type"]) => Effect.Effect) => + (value: S["Type"]) => + Schema.decodeUnknownEffect(schema)(value, { onExcessProperty: "error" }).pipe(Effect.orDie); + +const commandReceiptCases = { + NativeHttpReceiptInvalid: () => Problem.make("internal.error"), + NativeHttpReceiptPersistenceError: (failure: NativeHttpReceiptPersistenceError) => + isSerializationConflict(failure) + ? Problem.make("transaction.conflict") + : Problem.make("idempotency.unavailable"), +} satisfies ProblemCases; + +/** + * Command receipts: the transport's own persistence failures. + * + * @remarks + * An invalid stored receipt answers internal.error. A receipt persistence failure answers + * transaction.conflict when it lost a serialization race, and idempotency.unavailable otherwise. + * A replayable mutation pipes its command effect through its domain mapper, then through this. + * + * @sideEffects none + * + * @example + * ```ts + * command.pipe(teamApplicationProblems, commandReceiptProblems); + * ``` + * + * @avoid Mapping receipt failures in a domain mapper: each domain would answer the transport's own + * failures differently. Pipe this after the domain's mapper. + * + * @construct rpc-problem + */ +export const commandReceiptProblems: ProblemMapper< + NativeHttpReceiptInvalid | NativeHttpReceiptPersistenceError, + typeof commandReceiptCases +> = problemMapper()( + commandReceiptCases, +); + +/** + * The credential evidence of an RPC request: which credential headers it presented. + * + * @remarks + * `authorizePerson` answers a credential rejection from this evidence, never by string choice. + * + * @sideEffects none + * + * @example + * ```ts + * yield* authorizePerson(input, personPresentation(headers)); + * ``` + * + * @avoid Deciding between credential.missing and credential.invalid in a handler. Pass this. + * + * @construct rpc-problem + */ +export const personPresentation: { + (headers: Headers.Headers, challenge?: string): CredentialPresentation; + (challenge?: string): (headers: Headers.Headers) => CredentialPresentation; +} = dual( + (args) => Predicate.isObject(args[0]), + (headers: Headers.Headers, challenge: string = nativeUserChallenges()): CredentialPresentation => + classifyCredential(headers.authorization, headers.cookie, challenge), +); + +/** + * The value of a command receipt outcome: the committed or replayed success, or an idempotency + * problem. + * + * @remarks + * A committed or replayed outcome answers the success that its receipt stores, decoded with the + * RPC's success schema, so a replay returns the value of the first answer. A command that still + * runs under the key fails with idempotency.in-flight, a key reused for another request with + * idempotency.digest-conflict, and a result that is no longer kept with + * idempotency.response-expired. + * + * @sideEffects none + * + * @example + * ```ts + * return yield* commandOutcome(SocialEventResource)(outcome); + * ``` + * + * @avoid Rebuilding the result of a replay from the current state: a retry then sees another + * answer than the first attempt. Answer every outcome through this. + * + * @construct rpc-problem + */ +export const commandOutcome = + >( + success: S, + ): (( + outcome: NativeHttpCommandOutcome, + ) => Effect.Effect< + S["Type"], + | Problem<"idempotency.in-flight"> + | Problem<"idempotency.digest-conflict"> + | Problem<"idempotency.response-expired"> + >) => + ( + outcome: NativeHttpCommandOutcome, + ): Effect.Effect< + S["Type"], + | Problem<"idempotency.in-flight"> + | Problem<"idempotency.digest-conflict"> + | Problem<"idempotency.response-expired"> + > => + Match.value(outcome).pipe( + Match.tag("Committed", "Replay", ({ response }) => decodeStoredSuccess(success, response)), + Match.tag("InFlight", () => Effect.fail(Problem.make("idempotency.in-flight"))), + Match.tag("DigestConflict", () => Effect.fail(Problem.make("idempotency.digest-conflict"))), + Match.tag("ResponseExpired", () => Effect.fail(Problem.make("idempotency.response-expired"))), + Match.exhaustive, + ); + +/** + * The implementation of the `ProblemBoundary` middleware that every native RPC runs inside. + * + * @remarks + * A cause with a typed failure, or of interrupts only, passes through unchanged. Any other cause + * is a defect: it is reported through `ErrorReporter` and answered as internal.error, so no + * defect, stack, or message reaches a client. + * + * @sideEffects Reports each defect that it answers through `ErrorReporter`. + * + * @example + * ```ts + * RpcServer.layerHttp({ group: NativeRpcs, path: nativeRpcPath, protocol: "http" }).pipe(Layer.provide(ProblemBoundaryLive)); + * ``` + * + * @avoid Catching defects or rendering causes in a handler. Leave causes to this boundary. + */ +export const ProblemBoundaryLive: Layer.Layer = Layer.succeed(ProblemBoundary)( + ProblemBoundary.of((effect) => + Effect.catchCause(effect, (cause) => + Cause.hasFails(cause) || Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : ErrorReporter.report(cause).pipe( + Effect.andThen(Effect.fail(Problem.make("internal.error"))), + ), + ), + ), +); + +/** + * An anonymous AccessSpec grants every caller and conceals nothing, so a denial means the spec and + * its scope resolution disagree: a defect. + * + * @remarks + * It evaluates `spec` for the anonymous principal, with no grants, over `resolution` at `now`. A + * public operation calls it with the scope that it resolved, so the AccessSpec of the contract + * stays the one authority. A denial dies with the HTTP status of the evaluation. + * + * @sideEffects none + * + * @example + * ```ts + * yield* authorizeAnonymous(Option.getOrThrow(reflectAccessSpec(ReadTeamApplicationIntakeEndpoint)), resolution, instant); + * ``` + * + * @avoid Skipping the evaluation because the operation is public: the declared AccessSpec and the + * handler then drift apart unnoticed. Evaluate it, so that a mismatch is a defect. + * + * @construct rpc-problem + */ +export const authorizeAnonymous: { + ( + resolution: CanonicalScopeResolution, + now: string, + ): (spec: AccessSpec) => Effect.Effect; + ( + spec: AccessSpec, + resolution: CanonicalScopeResolution, + now: string, + ): Effect.Effect; +} = dual( + 3, + ( + spec: AccessSpec, + resolution: CanonicalScopeResolution, + now: string, + ): Effect.Effect => + authorizeAnonymousNativeOperation(spec, resolution, now).pipe( + Effect.catch((failure) => + Effect.die(new Error(`anonymous access denied with HTTP ${failure.status}`)), + ), + ), +); + +/** + * A rejected person credential is answered from the ingress evidence, never by string choice. + * + * @remarks + * It evaluates the AccessSpec of `input` for the person, with a grant for each capability of the + * spec in each grant scope. An evaluation of 401 answers `Problem.unauthenticated(presentation)`, + * 404 answers resource.not-found for a concealed resource, and 403 answers authority.denied. An + * operation whose spec reveals every denial pipes `unreachable("resource.not-found")` after it. + * + * @sideEffects none + * + * @example + * ```ts + * yield* authorizePerson({ spec, credential: authorization.credential, personId, resolution, grantScopes, now }, personPresentation(request)); + * ``` + * + * @avoid Choosing the answer to a denial in the handler: a concealed resource would answer 403 + * and reveal that it exists, or a credential answer would ignore the ingress evidence. Map the + * evaluation with this. + * + * @construct rpc-problem + */ +export const authorizePerson: { + ( + presentation: CredentialPresentation, + ): ( + input: NativePersonAuthorization, + ) => Effect.Effect< + void, + | Problem<"authority.denied"> + | Problem<"credential.invalid"> + | Problem<"credential.missing"> + | Problem<"resource.not-found"> + >; + ( + input: NativePersonAuthorization, + presentation: CredentialPresentation, + ): Effect.Effect< + void, + | Problem<"authority.denied"> + | Problem<"credential.invalid"> + | Problem<"credential.missing"> + | Problem<"resource.not-found"> + >; +} = dual( + 2, + ( + input: NativePersonAuthorization, + presentation: CredentialPresentation, + ): Effect.Effect< + void, + | Problem<"authority.denied"> + | Problem<"credential.invalid"> + | Problem<"credential.missing"> + | Problem<"resource.not-found"> + > => + authorizePersonNativeOperation(input).pipe( + Effect.mapError((failure) => + Match.value(failure.status).pipe( + Match.when(401, () => Problem.unauthenticated(presentation)), + Match.when(404, () => Problem.make("resource.not-found")), + Match.orElse(() => Problem.make("authority.denied")), + ), + ), + ), +); + +/** + * Marks problems a shared mapper can produce but this operation cannot, such as a serialization + * conflict inside a read-only snapshot. + * + * @remarks + * It turns each failure that is a problem with one of the listed codes into a defect, which + * `ProblemBoundaryLive` answers as internal.error, and removes those problems from the error + * channel, so the endpoint need not declare them. At least one code is required: an empty list + * would widen `Code` to every code and erase every problem. + * + * @sideEffects none + * + * @example + * ```ts + * authorizePerson(input, presentation).pipe(unreachable("resource.not-found")); + * ``` + * + * @avoid Declaring a problem on an endpoint only because a shared mapper can produce it: the + * contract then promises an answer that the operation never gives. Mark it unreachable here. + * + * @construct rpc-problem + */ +export const unreachable = + ( + code: Code, + ...codes: ReadonlyArray + ): (( + effect: Effect.Effect, + ) => Effect.Effect>>, R>) => + (effect: Effect.Effect) => { + const listed: ReadonlyArray = [code, ...codes]; + + // Inferred apart from the declared return type, which would select another overload of catchIf. + const unlisted = Effect.catchIf( + effect, + (error): error is Extract> => + isProblem(error) && listed.includes(error.code), + (error) => Effect.die(error), + ); + + return unlisted; + }; diff --git a/apps/backend/src/http-api/public-rate-limit.ts b/apps/backend/src/rpc/public-rate-limit.ts similarity index 81% rename from apps/backend/src/http-api/public-rate-limit.ts rename to apps/backend/src/rpc/public-rate-limit.ts index e1a0dff2c..a7900c60f 100644 --- a/apps/backend/src/http-api/public-rate-limit.ts +++ b/apps/backend/src/rpc/public-rate-limit.ts @@ -1,3 +1,5 @@ +import { dual } from "effect/Function"; + /** A fixed-window request count per key, held in one backend process. */ export interface PublicRateLimit { readonly consume: (key: string, now: string) => boolean; @@ -9,7 +11,10 @@ export interface PublicRateLimit { */ export const publicRateLimitKey = (_request: Request): string => "public"; -export const publicRateLimit = (maxRequests = 5, windowMilliseconds = 60_000): PublicRateLimit => { +export const publicRateLimit: { + (windowMilliseconds: number): (maxRequests: number) => PublicRateLimit; + (maxRequests: number, windowMilliseconds: number): PublicRateLimit; +} = dual(2, (maxRequests: number, windowMilliseconds: number): PublicRateLimit => { if (!Number.isSafeInteger(maxRequests) || maxRequests < 1) { throw new Error("public rate limit must be a positive safe integer"); } @@ -39,4 +44,4 @@ export const publicRateLimit = (maxRequests = 5, windowMilliseconds = 60_000): P return true; }, }; -}; +}); diff --git a/apps/backend/src/http-api/read-json.ts b/apps/backend/src/rpc/read-json.ts similarity index 79% rename from apps/backend/src/http-api/read-json.ts rename to apps/backend/src/rpc/read-json.ts index bb8007a47..1dfa97eb9 100644 --- a/apps/backend/src/http-api/read-json.ts +++ b/apps/backend/src/rpc/read-json.ts @@ -1,6 +1,7 @@ -import { Problem } from "@vektorprogrammet/http-api/http-semantics"; +import { Problem } from "@vektorprogrammet/rpc/problem"; import { Effect, Schema } from "effect"; import { parseJsonWithoutDuplicateMembers } from "../http-semantics.js"; +import { dual } from "effect/Function"; type ReadJsonProblem = | Problem<"request.malformed"> @@ -12,10 +13,10 @@ type ReadJsonProblem = * body that cannot be read at all is an internal error; everything else the * client sent wrong is request.malformed or request.too-large. */ -export const readBoundedJson = ( - request: Request, - maxBytes: number, -): Effect.Effect => { +export const readBoundedJson: { + (maxBytes: number): (request: Request) => Effect.Effect; + (request: Request, maxBytes: number): Effect.Effect; +} = dual(2, (request: Request, maxBytes: number): Effect.Effect => { const declared = request.headers.get("content-length"); if (declared !== null && (!/^\d+$/u.test(declared) || !Number.isSafeInteger(Number(declared)))) @@ -25,7 +26,7 @@ export const readBoundedJson = ( return Effect.fail(Problem.make("request.too-large")); const reader = request.body?.getReader(); - if (!reader) return Effect.fail(Problem.make("request.malformed")); + if (reader === undefined) return Effect.fail(Problem.make("request.malformed")); return Effect.gen(function* () { const chunks: Uint8Array[] = []; @@ -66,4 +67,4 @@ export const readBoundedJson = ( catch: () => Problem.make("request.malformed"), }); }).pipe(Effect.ensuring(Effect.sync(() => reader.releaseLock()))); -}; +}); diff --git a/apps/backend/src/http-api/receipt-transaction.test.ts b/apps/backend/src/rpc/receipt-transaction.test.ts similarity index 100% rename from apps/backend/src/http-api/receipt-transaction.test.ts rename to apps/backend/src/rpc/receipt-transaction.test.ts diff --git a/apps/backend/src/http-api/receipt-transaction.ts b/apps/backend/src/rpc/receipt-transaction.ts similarity index 62% rename from apps/backend/src/http-api/receipt-transaction.ts rename to apps/backend/src/rpc/receipt-transaction.ts index 2e5be1e92..46a534b7b 100644 --- a/apps/backend/src/http-api/receipt-transaction.ts +++ b/apps/backend/src/rpc/receipt-transaction.ts @@ -1,7 +1,9 @@ import { Schema, Predicate, Data, Effect } from "effect"; import { isSqlError } from "effect/unstable/sql/SqlError"; import { Database, type DatabaseOperations } from "@vektorprogrammet/database"; +import { jsonBodyBytes } from "../http-semantics.js"; import { AdvisoryLockKey, tryLockAdvisory } from "@vektorprogrammet/database/advisory-lock"; +import { dual } from "effect/Function"; const sha256Pattern = /^[a-f0-9]{64}$/u; @@ -247,86 +249,107 @@ const writeCompleteReceipt = ( * prepared program. Domain state, audit, outbox, and receipt writes therefore * commit or roll back as one unit. */ -export const executeNativeHttpCommandPostgres = ( - prepare: Effect.Effect, EPrepare, RPrepare>, - options: NativeHttpCommandExecutionOptions = {}, -): Effect.Effect< - NativeHttpCommandOutcome, - EPrepare | EExecute | NativeHttpReceiptInvalid | NativeHttpReceiptPersistenceError, - RPrepare | RExecute | Database -> => { - const transaction = Database.use((sql) => - sql.withTransaction( - Effect.gen(function* () { - yield* sql`SET TRANSACTION ISOLATION LEVEL SERIALIZABLE`; - const plan = yield* prepare; - const identity = plan.identity; - yield* Effect.try({ - try: () => validateIdentity(identity), - catch: (cause) => - cause instanceof NativeHttpReceiptInvalid - ? cause - : new NativeHttpReceiptInvalid({ reason: "invalid receipt identity" }), - }); - - const acquired = yield* tryLockAdvisory( - sql, - AdvisoryLockKey.httpCommandReceipt(identity.identitySha256), - ); - - if (!acquired) { - return NativeHttpCommandOutcome.InFlight({ retryAfterSeconds: 1 }); - } - - yield* redactIdentityIfExpired(sql, identity.identitySha256); - const stored = yield* readReceipt(sql, identity.identitySha256); - - if (stored !== undefined) { - if ( - stored.requestSha256 !== identity.requestSha256 || - stored.operationId !== identity.operationId - ) { - return NativeHttpCommandOutcome.DigestConflict(); +export const executeNativeHttpCommandPostgres: { + ( + prepare: Effect.Effect, EPrepare, RPrepare>, + options?: NativeHttpCommandExecutionOptions, + ): Effect.Effect< + NativeHttpCommandOutcome, + EPrepare | EExecute | NativeHttpReceiptInvalid | NativeHttpReceiptPersistenceError, + RPrepare | RExecute | Database + >; + ( + options?: NativeHttpCommandExecutionOptions, + ): ( + prepare: Effect.Effect, EPrepare, RPrepare>, + ) => Effect.Effect< + NativeHttpCommandOutcome, + EPrepare | EExecute | NativeHttpReceiptInvalid | NativeHttpReceiptPersistenceError, + RPrepare | RExecute | Database + >; +} = dual( + (args) => Effect.isEffect(args[0]), + ( + prepare: Effect.Effect, EPrepare, RPrepare>, + options: NativeHttpCommandExecutionOptions = {}, + ): Effect.Effect< + NativeHttpCommandOutcome, + EPrepare | EExecute | NativeHttpReceiptInvalid | NativeHttpReceiptPersistenceError, + RPrepare | RExecute | Database + > => { + const transaction = Database.use((sql) => + sql.withTransaction( + Effect.gen(function* () { + yield* sql`SET TRANSACTION ISOLATION LEVEL SERIALIZABLE`; + const plan = yield* prepare; + const identity = plan.identity; + yield* Effect.try({ + try: () => validateIdentity(identity), + catch: (cause) => + cause instanceof NativeHttpReceiptInvalid + ? cause + : new NativeHttpReceiptInvalid({ reason: "invalid receipt identity" }), + }); + + const acquired = yield* tryLockAdvisory( + sql, + AdvisoryLockKey.httpCommandReceipt(identity.identitySha256), + ); + + if (!acquired) { + return NativeHttpCommandOutcome.InFlight({ retryAfterSeconds: 1 }); } - if (stored.state === "Tombstone") return NativeHttpCommandOutcome.ResponseExpired(); + yield* redactIdentityIfExpired(sql, identity.identitySha256); + const stored = yield* readReceipt(sql, identity.identitySha256); - return NativeHttpCommandOutcome.Replay({ response: capsuleFromRow(stored) }); - } + if (stored !== undefined) { + if ( + stored.requestSha256 !== identity.requestSha256 || + stored.operationId !== identity.operationId + ) { + return NativeHttpCommandOutcome.DigestConflict(); + } - const response = yield* plan.execute.pipe(Effect.provideService(Database, sql)); - yield* Effect.try({ - try: () => validateCapsule(response), - catch: (cause) => - cause instanceof NativeHttpReceiptInvalid - ? cause - : new NativeHttpReceiptInvalid({ reason: "invalid response capsule" }), - }); - yield* writeCompleteReceipt(sql, identity, response); + if (stored.state === "Tombstone") return NativeHttpCommandOutcome.ResponseExpired(); - return NativeHttpCommandOutcome.Committed({ response }); - }), - ), - ); - - const retryUniqueConstraints = new Set( - options.retry === "serialization-or-unique-once" ? options.retryUniqueConstraints : [], - ); - - const executed = - options.retry === undefined - ? transaction - : Effect.retry(transaction, { - times: 1, - while: (cause) => isRetryableCommandRace(cause, retryUniqueConstraints), - }); - - return executed.pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(new NativeHttpReceiptPersistenceError({ operation: "execute", cause })), - ), - ); -}; + return NativeHttpCommandOutcome.Replay({ response: capsuleFromRow(stored) }); + } + + const response = yield* plan.execute.pipe(Effect.provideService(Database, sql)); + yield* Effect.try({ + try: () => validateCapsule(response), + catch: (cause) => + cause instanceof NativeHttpReceiptInvalid + ? cause + : new NativeHttpReceiptInvalid({ reason: "invalid response capsule" }), + }); + yield* writeCompleteReceipt(sql, identity, response); + + return NativeHttpCommandOutcome.Committed({ response }); + }), + ), + ); + + const retryUniqueConstraints = new Set( + options.retry === "serialization-or-unique-once" ? options.retryUniqueConstraints : [], + ); + + const executed = + options.retry === undefined + ? transaction + : Effect.retry(transaction, { + times: 1, + while: (cause) => isRetryableCommandRace(cause, retryUniqueConstraints), + }); + + return executed.pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(new NativeHttpReceiptPersistenceError({ operation: "execute", cause })), + ), + ); + }, +); /** Redacts all expired response capsules while retaining durable tombstones. */ export const redactExpiredNativeHttpReceipts = Database.use((sql) => @@ -355,3 +378,44 @@ export const redactExpiredNativeHttpReceipts = Database.use((sql) => ), ), ); + +/** + * The receipt of an RPC command's success: its value, encoded with the RPC's success schema as + * canonical JSON. A replay decodes it with the same schema (`decodeStoredSuccess`). + */ +export const successCapsule = + >(success: S) => + (value: S["Type"]): Effect.Effect => + Schema.encodeEffect(Schema.toCodecJson(success))(value).pipe( + Effect.orDie, + Effect.map((json) => ({ + status: 200, + mediaType: "application/json", + headers: { "content-type": "application/json" }, + bodyBytes: jsonBodyBytes(json), + })), + ); + +/** The success that `successCapsule` stored. A stored value that no longer decodes is a defect. */ +export const decodeStoredSuccess: { + ( + capsule: NativeHttpResponseCapsule, + ): >( + success: S, + ) => Effect.Effect; + >( + success: S, + capsule: NativeHttpResponseCapsule, + ): Effect.Effect; +} = dual( + 2, + >( + success: S, + capsule: NativeHttpResponseCapsule, + ): Effect.Effect => + capsule.bodyBytes === null + ? Effect.die(new NativeHttpReceiptInvalid({ reason: "a command receipt stores no success" })) + : Schema.decodeEffect(Schema.fromJsonString(Schema.toCodecJson(success)))( + new TextDecoder().decode(capsule.bodyBytes), + ).pipe(Effect.orDie), +); diff --git a/apps/backend/src/rpc/router.test.ts b/apps/backend/src/rpc/router.test.ts new file mode 100644 index 000000000..00481042a --- /dev/null +++ b/apps/backend/src/rpc/router.test.ts @@ -0,0 +1,212 @@ +import { expect, it } from "@effect/vitest"; +import { Identity, IdentitySessionNotFound } from "@vektorprogrammet/domain/identity"; +import { isProblem } from "@vektorprogrammet/rpc/problem"; +import { DepartmentId } from "@vektorprogrammet/domain/organization"; +import { SemesterId } from "@vektorprogrammet/domain"; +import { Effect, Layer, Schema } from "effect"; +import { HttpRouter } from "effect/unstable/http"; +import { RpcClient } from "effect/unstable/rpc"; +import { backendTestConfig } from "../../test/config.js"; +import { backendHttpHandler, nativeRpcMaxBodyBytes } from "../router.js"; +import { backendTestRouterLayer, makeBackendTestRpc, testAuthHandler } from "../test/native-rpc.js"; + +// The identity engine rejects every session, as it does a forged or expired one. +const rejectingIdentity = Layer.mock(Identity, { + resolveSession: () => Effect.fail(IdentitySessionNotFound.make({})), +}); + +const backend = makeBackendTestRpc({ config: backendTestConfig, services: rejectingIdentity }); + +it.effect("an RPC without a credential answers credential.missing from the middleware", () => + Effect.gen(function* () { + const client = yield* backend.client; + + const failure = yield* Effect.flip(client["social-events.readScope"]()); + + expect(isProblem(failure) && failure.code).toBe("credential.missing"); + }), +); + +it.effect("a malformed Better Auth cookie answers credential.invalid", () => + Effect.gen(function* () { + const client = yield* backend.client; + + const failure = yield* Effect.flip( + RpcClient.withHeaders( + client["social-events.list"]({ + departmentId: DepartmentId.make("department-a"), + semesterId: SemesterId.make("semester-a"), + }), + { cookie: "better-auth.session_token=forged" }, + ), + ); + + expect(isProblem(failure) && failure.code).toBe("credential.invalid"); + }), +); + +it.effect("the health probe stays plain HTTP", () => + Effect.gen(function* () { + const response = yield* backend.fetch(new Request("http://native-rpc.test/health")); + + expect(response.status).toBe(200); + expect(yield* Effect.promise(() => response.json())).toEqual({ status: "ok" }); + }), +); + +it.effect("the health probe answers every probe with its own body", () => + Effect.gen(function* () { + // The process serves every request on one router, and a readiness loop probes many times: a + // body shared between answers is locked after the first. + const { handler, dispose } = HttpRouter.toWebHandler( + backendTestRouterLayer({ config: backendTestConfig, services: rejectingIdentity }), + { disableLogger: true }, + ); + + const bodies = yield* Effect.forEach([1, 2, 3], () => + Effect.promise(() => + handler(new Request("http://native-rpc.test/health")).then((response) => response.json()), + ), + ).pipe(Effect.ensuring(Effect.promise(() => dispose()))); + + expect(bodies).toEqual([{ status: "ok" }, { status: "ok" }, { status: "ok" }]); + }), +); + +it.effect("a path outside the RPC endpoint and the probe answers resource.not-found", () => + Effect.gen(function* () { + const response = yield* backend.fetch(new Request("http://native-rpc.test/api/social-events")); + + expect(response.status).toBe(404); + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + code: "resource.not-found", + }); + }), +); + +it.effect("an RPC from an untrusted origin answers origin.denied before any handler", () => + Effect.gen(function* () { + const response = yield* backend.fetch( + new Request("http://native-rpc.test/api/rpc", { + method: "POST", + headers: { + origin: "https://attacker.example", + cookie: "better-auth.session_token=x", + "content-type": "application/json", + }, + body: "[]", + }), + ); + + expect(response.status).toBe(403); + expect(yield* Effect.promise(() => response.json())).toMatchObject({ code: "origin.denied" }); + }), +); + +// Node requires `duplex` for a stream body. +const streamingInit = { duplex: "half" } as const; + +const rpcPost = (body: string | ReadableStream) => + new Request("http://native-rpc.test/api/rpc", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + ...streamingInit, + }); + +it.effect("an RPC answer is never stored by a cache", () => + Effect.gen(function* () { + const response = yield* backend.fetch( + rpcPost( + '[{"_tag":"Request","id":"1","tag":"social-events.readScope","payload":null,"headers":[]}]', + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(response.headers.get("vary")).toContain("Origin"); + }), +); + +it.effect("the ingress refuses an RPC body over its bound while reading it", () => + Effect.gen(function* () { + const chunk = new Uint8Array(1024 * 1024).fill(0x20); + let sent = 0; + + // A stream without Content-Length that would never end: the bound, not the stream, stops it. + const endless = new ReadableStream({ + pull: (controller) => { + sent += chunk.byteLength; + controller.enqueue(chunk); + }, + }); + + const response = yield* backend.fetch(rpcPost(endless)); + + expect(response.status).toBe(413); + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + code: "request.too-large", + }); + expect(sent).toBeLessThanOrEqual(nativeRpcMaxBodyBytes + 2 * chunk.byteLength); + }), +); + +it.effect("the ingress refuses an RPC body with a duplicate JSON member", () => + Effect.gen(function* () { + // Two parsers could disagree on which `tag` wins; the ingress admits neither reading. + const response = yield* backend.fetch( + rpcPost( + '[{"_tag":"Request","id":"1","tag":"social-events.readScope",' + + '"tag":"social-events.create","payload":null,"headers":[]}]', + ), + ); + + expect(response.status).toBe(400); + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + code: "request.malformed", + }); + }), +); + +it.effect("the ingress drops message headers that would forge an ingress fact", () => + Effect.gen(function* () { + const received: Array = []; + + const recordingNative = (request: Request) => + Effect.promise(() => request.text()).pipe( + Effect.map((body) => { + received.push(body); + + return new Response("[]", { headers: { "content-type": "application/json" } }); + }), + ); + + const ingress = backendHttpHandler( + recordingNative, + testAuthHandler, + backendTestConfig.sessionBoundary, + ); + + // RPC wire text: one request message with forged ingress headers, and one ack. + const sent = + '[{"_tag":"Request","id":"1","tag":"social-events.readScope","payload":null,"headers":' + + '[["cookie","better-auth.session_token=forwarded"],["cf-connecting-ip","203.0.113.9"],' + + '["User-Agent","forged"],["x-vektor-request-id","forged"]]},{"_tag":"Ack","requestId":"1"}]'; + + const expected = + '[{"_tag":"Request","id":"1","tag":"social-events.readScope","payload":null,"headers":' + + '[["cookie","better-auth.session_token=forwarded"]]},{"_tag":"Ack","requestId":"1"}]'; + + yield* ingress( + new Request("http://native-rpc.test/api/rpc", { + method: "POST", + headers: { "content-type": "application/json" }, + body: sent, + }), + ); + + const wire = Schema.decodeEffect(Schema.fromJsonString(Schema.Json)); + + expect(yield* Effect.forEach(received, (body) => wire(body))).toEqual([yield* wire(expected)]); + }), +); diff --git a/apps/backend/src/rpc/system.test.ts b/apps/backend/src/rpc/system.test.ts new file mode 100644 index 000000000..c8c265386 --- /dev/null +++ b/apps/backend/src/rpc/system.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "@effect/vitest"; +import { IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; +import { + Identity, + IdentityActor, + IdentityOwnedSessionNotFound, + type IdentityOperations, + type IdentityRequestContext, + IdentitySession, + IdentitySessionNotFound, +} from "@vektorprogrammet/domain/identity"; +import { PersonId } from "@vektorprogrammet/domain/organization"; +import { IdempotencyKey, isProblem } from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Layer } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import { backendDatabase } from "../../test/database.js"; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; + +const token = "better-auth.session_token"; + +const currentSession = IdentitySession.make({ + sessionId: "session-1", + createdAt: DateTime.makeUnsafe("2031-09-15T12:00:00.000Z"), + updatedAt: DateTime.makeUnsafe("2031-09-15T12:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), + ipAddress: "127.0.0.1", + userAgent: "router-test", + current: true, +}); + +const actor = IdentityActor.make({ + personId: PersonId.make("member-1"), + sessionId: "session-1", + expiresAt: currentSession.expiresAt, +}); + +/** An identity engine that knows one session cookie, `valid-session`. */ +const identityOperations = (overrides: Partial = {}): IdentityOperations => ({ + signIn: () => Effect.die("unexpected sign-in"), + resolveSession: (cookieHeader) => + cookieHeader?.split(/;\s*/u).includes(`${token}=valid-session`) === true + ? Effect.succeed(actor) + : Effect.fail(IdentitySessionNotFound.make()), + readCurrentSession: () => Effect.succeed(currentSession), + listSessions: () => Effect.succeed([currentSession]), + revokeCurrentSession: () => Effect.succeed({ setCookies: [] }), + revokeSession: () => Effect.succeed({ setCookies: [] }), + revokeOtherSessions: () => Effect.succeed({ setCookies: [] }), + revokeAllSessions: () => Effect.succeed({ setCookies: [] }), + recordSecurityEvent: () => Effect.void, + signOut: () => Effect.succeed({ setCookies: [] }), + ...overrides, +}); + +/** Every session command's request evidence, as the identity snapshot received it. */ +interface Recorded { + readonly revoked: Array<{ readonly operation: string; readonly request: IdentityRequestContext }>; +} + +const backendFor = (identity: IdentityOperations, recorded: Recorded = { revoked: [] }) => { + const database = backendDatabase(); + + const snapshot = IdentitySnapshot.of({ + resolveSession: (cookieHeader) => + identity + .resolveSession(cookieHeader) + .pipe( + Effect.catchTag("IdentitySessionExpired", () => + Effect.fail(IdentitySessionNotFound.make()), + ), + ), + revokeCurrentSession: (_actor, request) => + Effect.sync(() => { + recorded.revoked.push({ operation: "current", request }); + + return { setCookies: [] }; + }), + revokeSession: (_actor, sessionId, request) => + identity.revokeSession(undefined, sessionId, request).pipe( + Effect.tap(() => + Effect.sync(() => recorded.revoked.push({ operation: sessionId, request })), + ), + Effect.catchTag(["IdentitySessionNotFound", "IdentitySessionExpired"], () => + Effect.fail(IdentityOwnedSessionNotFound.make({ sessionId })), + ), + ), + revokeOtherSessions: (_actor, request) => + Effect.sync(() => { + recorded.revoked.push({ operation: "others", request }); + + return { setCookies: [] }; + }), + revokeAllSessions: (_actor, request) => + Effect.sync(() => { + recorded.revoked.push({ operation: "all", request }); + + return { setCookies: [] }; + }), + }); + + return makeBackendTestRpc({ + config: backendTestConfig, + services: Layer.mergeAll( + database.layer, + Layer.succeed(Identity, identity), + Layer.succeed(IdentitySnapshot, snapshot), + Layer.succeed( + OAuthCredentialAuthority, + OAuthCredentialAuthority.of({ + resolve: () => Effect.die("unexpected OAuth credential resolution"), + resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), + }), + ), + ), + // The browser's user agent is an HTTP header; the audit records it from the request. + transportHeaders: { "user-agent": "session-rpc-test" }, + }); +}; + +const cookie = { cookie: `theme=dark; ${token}=valid-session` }; + +const key = (value: string) => IdempotencyKey.make(value.padEnd(22, "0")); + +const problemCode = (failure: E) => (isProblem(failure) ? failure.code : failure); + +describe("session RPCs", () => { + it.live("read, list, and revoke the caller's own sessions", () => + Effect.gen(function* () { + const recorded: Recorded = { revoked: [] }; + const client = yield* backendFor(identityOperations(), recorded).client; + const as = RpcClient.withHeaders(cookie); + + expect(yield* client["system.readSession"]().pipe(as)).toEqual({ + sessionId: "session-1", + personId: PersonId.make("member-1"), + createdAt: "2031-09-15T12:00:00.000Z", + updatedAt: "2031-09-15T12:00:00.000Z", + expiresAt: "2031-09-16T12:00:00.000Z", + ipAddress: "127.0.0.1", + userAgent: "router-test", + current: true, + }); + + expect(yield* client["system.listSessions"]().pipe(as)).toEqual([ + { + sessionId: "session-1", + personId: PersonId.make("member-1"), + createdAt: "2031-09-15T12:00:00.000Z", + updatedAt: "2031-09-15T12:00:00.000Z", + expiresAt: "2031-09-16T12:00:00.000Z", + ipAddress: "127.0.0.1", + userAgent: "router-test", + current: true, + }, + ]); + + yield* client["system.deleteSession"]({ idempotencyKey: key("delete-current") }).pipe(as); + + yield* client["system.deleteOwnedSession"]({ + idempotencyKey: key("delete-owned"), + sessionId: "session-2", + }).pipe(as); + + yield* client["system.revokeOtherSessions"]({ idempotencyKey: key("revoke-others") }).pipe( + as, + ); + + yield* client["system.revokeAllSessions"]({ idempotencyKey: key("revoke-all") }).pipe(as); + + expect( + recorded.revoked.map(({ operation, request }) => [operation, request.userAgent]), + ).toEqual([ + ["current", "session-rpc-test"], + ["session-2", "session-rpc-test"], + ["others", "session-rpc-test"], + ["all", "session-rpc-test"], + ]); + + expect(problemCode(yield* Effect.flip(client["system.readSession"]()))).toBe( + "credential.missing", + ); + }), + ); + + it.live("asks the identity engine only for a Better Auth session cookie", () => + Effect.gen(function* () { + let currentReads = 0; + + const client = yield* backendFor( + identityOperations({ + resolveSession: () => Effect.succeed(actor), + readCurrentSession: () => + Effect.sync(() => { + currentReads += 1; + + return currentSession; + }), + }), + ).client; + + for (const value of ["", "theme=dark", "vp.session_token=opaque"]) { + const failure = yield* Effect.flip( + client["system.readSession"]().pipe(RpcClient.withHeaders({ cookie: value })), + ); + + expect(problemCode(failure)).toBe("credential.missing"); + } + + expect(currentReads).toBe(0); + + for (const value of [ + "better-auth.session_token=opaque", + "__Secure-better-auth.session_token=opaque", + ]) { + yield* client["system.readSession"]().pipe(RpcClient.withHeaders({ cookie: value })); + } + + expect(currentReads).toBe(2); + }), + ); + + it.live("classifies absent and rejected session credentials", () => + Effect.gen(function* () { + const client = yield* backendFor(identityOperations()).client; + + for (const [headers, code] of [ + [{}, "credential.missing"], + [{ cookie: "theme=dark; vp.session_token=opaque" }, "credential.missing"], + [{ cookie: `theme=dark; ${token}=unknown-session` }, "credential.invalid"], + [{ authorization: "Bearer unknown-token" }, "credential.invalid"], + [{ ...cookie, authorization: "Bearer second-credential" }, "credential.invalid"], + ] as const) { + const failure = yield* Effect.flip( + client["system.listSessions"]().pipe(RpcClient.withHeaders(headers)), + ); + + expect(problemCode(failure)).toBe(code); + } + }), + ); + + it.live("conceals missing, non-owned, and already-revoked session ids identically", () => + Effect.gen(function* () { + const owned = new Set(["owned-session"]); + let revokeCalls = 0; + + const client = yield* backendFor( + identityOperations({ + revokeSession: (_actor, sessionId) => + Effect.suspend(() => { + revokeCalls += 1; + + return owned.delete(sessionId) + ? Effect.succeed({ setCookies: [] }) + : Effect.fail(IdentityOwnedSessionNotFound.make({ sessionId })); + }), + }), + ).client; + + const as = RpcClient.withHeaders(cookie); + const first = { idempotencyKey: key("owned-session-delete"), sessionId: "owned-session" }; + + yield* client["system.deleteOwnedSession"](first).pipe(as); + + // The replay answers the stored receipt without revoking again. + yield* client["system.deleteOwnedSession"](first).pipe(as); + + expect(revokeCalls).toBe(1); + + for (const sessionId of ["owned-session", "missing-session", "another-person-session"]) { + const failure = yield* Effect.flip( + client["system.deleteOwnedSession"]({ + idempotencyKey: key(`conceal-${sessionId}`), + sessionId, + }).pipe(as), + ); + + expect(problemCode(failure)).toBe("resource.not-found"); + } + + expect(revokeCalls).toBe(4); + + // The same key for another session is another request. + const conflict = yield* Effect.flip( + client["system.deleteOwnedSession"]({ ...first, sessionId: "missing-session" }).pipe(as), + ); + + expect(problemCode(conflict)).toBe("resource.not-found"); + }), + ); + + it.live("replays a session command whatever its key names", () => + Effect.gen(function* () { + const recorded: Recorded = { revoked: [] }; + const client = yield* backendFor(identityOperations(), recorded).client; + const as = RpcClient.withHeaders(cookie); + const command = { idempotencyKey: key("revoke-others-replay") }; + + yield* client["system.revokeOtherSessions"](command).pipe(as); + yield* client["system.revokeOtherSessions"](command).pipe(as); + + expect(recorded.revoked.map(({ operation }) => operation)).toEqual(["others"]); + + // Another operation under the same key is a different command identity. + yield* client["system.revokeAllSessions"](command).pipe(as); + + expect(recorded.revoked.map(({ operation }) => operation)).toEqual(["others", "all"]); + }), + ); +}); diff --git a/apps/backend/src/rpc/system.ts b/apps/backend/src/rpc/system.ts new file mode 100644 index 000000000..b49278539 --- /dev/null +++ b/apps/backend/src/rpc/system.ts @@ -0,0 +1,351 @@ +/** + * The SystemRpcs handlers: the caller's first-party session and session management. + * + * Every session RPC takes the session cookie alone. A read authenticates the session and + * evaluates the AccessSpec over the caller's own session; a command resolves the credential and + * the session inside the serializable transaction that commits it, and stores its no-content + * answer as a command receipt, so a retry with the same idempotency key replays it. + */ +import { + IdentitySnapshot, + type Database, + type IdentitySnapshotService, +} from "@vektorprogrammet/database"; +import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import { Scope } from "@vektorprogrammet/domain/authz"; +import { + Identity, + type IdentityActor, + type IdentityEngineError, + type IdentityOwnedSessionNotFound, + type IdentitySession, + type IdentitySessionExpired, + type IdentitySessionNotFound, +} from "@vektorprogrammet/domain/identity"; +import type { PersonId } from "@vektorprogrammet/domain/organization"; +import { + DeleteOwnedSession, + DeleteSession, + ListSessions, + ReadSession, + RevokeAllSessions, + RevokeOtherSessions, + SystemRpcs, + reflectAccessSpec, + type SessionResponse, +} from "@vektorprogrammet/rpc"; +import { + type CredentialPresentation, + type IdempotencyKey, + nativeCookieChallenge, + Problem, +} from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Match, Option } from "effect"; +import type { Headers } from "effect/unstable/http"; +import type { Rpc } from "effect/unstable/rpc"; +import { + resolveAuthenticatedPersonAtInstant, + resolveRequestCredentialInTransaction, + type AuthenticatedPersonAtInstant, +} from "../authority.js"; +import { encodePathIdentity, semanticRequestDigest } from "../http-semantics.js"; +import { genericContext } from "../native-operation.js"; +import { identityRequestContext } from "../session-security.js"; +import { credentialRequestOf } from "./credential.js"; +import type { NativeRpcOptions } from "./options.js"; +import { + authorizePerson, + commandIdentity, + commandReceiptProblems, + personPresentation, + problemMapper, + unreachable, +} from "./problem.js"; +import { + executeNativeHttpCommandPostgres, + type NativeHttpCommandOutcome, + type NativeHttpResponseCapsule, +} from "./receipt-transaction.js"; + +/** A session resource is secured by the session cookie alone, so it challenges only that scheme. */ +const sessionPresentation = (headers: Headers.Headers) => + personPresentation(headers, nativeCookieChallenge); + +/** + * The one answer for every identity failure of a session resource. A rejected session is answered + * from the request's evidence; an identity engine failure is the operation's own unavailability + * problem. + */ +const sessionProblems = < + const Unavailable extends Problem<"identity.unavailable"> | Problem<"dependency.unavailable">, +>( + presentation: CredentialPresentation, + unavailable: Unavailable, +) => + problemMapper< + | IdentityEngineError + | IdentitySessionNotFound + | IdentitySessionExpired + | IdentityOwnedSessionNotFound + | UnauthenticatedActor + >()({ + IdentityEngineError: () => unavailable, + IdentitySessionNotFound: () => Problem.unauthenticated(presentation), + IdentitySessionExpired: () => Problem.unauthenticated(presentation), + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + IdentityOwnedSessionNotFound: () => Problem.make("resource.not-found"), + }); + +const projection = (personId: PersonId, session: IdentitySession): SessionResponse => ({ + sessionId: session.sessionId, + personId, + createdAt: DateTime.toDateUtc(session.createdAt).toISOString(), + updatedAt: DateTime.toDateUtc(session.updatedAt).toISOString(), + expiresAt: DateTime.toDateUtc(session.expiresAt).toISOString(), + ipAddress: session.ipAddress, + userAgent: session.userAgent, + current: session.current, +}); + +const principalFor = (headers: Headers.Headers, options: NativeRpcOptions) => + resolveAuthenticatedPersonAtInstant(headers.cookie, { now: options.now }); + +const authorizeSessionRead = (input: { + readonly headers: Headers.Headers; + readonly principal: AuthenticatedPersonAtInstant; + readonly rpc: Pick; + readonly resourceId?: string; + readonly collection?: boolean; +}) => + authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(input.rpc)), + request: credentialRequestOf(input.headers), + personId: input.principal.personId, + resolution: { + selection: input.collection === true ? "AllMatching" : "ExactlyOne", + contexts: [ + genericContext({ + domainId: "identity", + resourceKind: input.resourceId === undefined ? undefined : "identity-session", + resourceId: input.resourceId, + facts: { ownerPersonId: input.principal.personId }, + authorityVersion: `identity:${input.principal.authorizationInstant}`, + }), + ], + }, + grantScopes: [Scope.Global()], + now: input.principal.authorizationInstant, + }, + sessionPresentation(input.headers), + ); + +/** The receipt of a session command: the no-content answer the HTTP contract stored. */ +const noContent: NativeHttpResponseCapsule = { + status: 204, + mediaType: null, + headers: {}, + bodyBytes: null, +}; + +/** + * A session command answers nothing: a committed or replayed receipt, whichever the HTTP contract + * or the RPC stored, succeeds with no value, and every other outcome is its idempotency problem. + */ +const noContentOutcome = (outcome: NativeHttpCommandOutcome) => + Match.value(outcome).pipe( + Match.tag("Committed", "Replay", () => Effect.void), + Match.tag("InFlight", () => Effect.fail(Problem.make("idempotency.in-flight"))), + Match.tag("DigestConflict", () => Effect.fail(Problem.make("idempotency.digest-conflict"))), + Match.tag("ResponseExpired", () => Effect.fail(Problem.make("idempotency.response-expired"))), + Match.exhaustive, + ); + +const executeSessionCommand = (input: { + readonly headers: Headers.Headers; + readonly options: NativeRpcOptions; + readonly rpc: Pick; + readonly operationId: string; + readonly normalizedTarget: string; + readonly idempotencyKey: IdempotencyKey; + readonly resourceId?: string; + readonly mutate: ( + identity: IdentitySnapshotService, + actor: IdentityActor, + ) => Effect.Effect< + unknown, + IdentityEngineError | IdentitySessionNotFound | IdentityOwnedSessionNotFound, + Database + >; +}) => { + const presentation = sessionPresentation(input.headers); + const request = credentialRequestOf(input.headers); + + return Effect.gen(function* () { + // Failures are mapped after the executor, whose retry reads their causes. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const authenticated = yield* resolveRequestCredentialInTransaction( + request, + "OAuthUserBearer", + { now: input.options.now }, + ); + + const identity = yield* IdentitySnapshot; + + const actor = yield* identity.resolveSession( + input.headers.cookie, + authenticated.authorizationInstant, + ); + + yield* authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(input.rpc)), + credential: authenticated.credential, + personId: actor.personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "identity", + resourceKind: input.resourceId === undefined ? undefined : "identity-session", + resourceId: input.resourceId, + facts: { ownerPersonId: actor.personId }, + authorityVersion: `identity:${authenticated.authorizationInstant}`, + }), + ], + }, + grantScopes: [Scope.Global()], + now: authenticated.authorizationInstant, + }, + presentation, + ); + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const derived = yield* commandIdentity({ + credentialSubject: `Person:${actor.personId}`, + qualifiedOperationId: input.operationId, + normalizedTarget: input.normalizedTarget, + idempotencyKey: input.idempotencyKey, + }); + + return { + identity: { + identitySha256: derived.identitySha256, + requestSha256: semanticRequestDigest({}), + operationId: input.operationId, + }, + execute: input.mutate(identity, actor).pipe(Effect.as(noContent)), + }; + }), + ).pipe( + // The frozen command table answers an identity engine failure as an unavailable dependency. + sessionProblems(presentation, Problem.make("dependency.unavailable")), + commandReceiptProblems, + ); + + return yield* noContentOutcome(outcome); + }); +}; + +/** The SystemRpcs handlers. */ +export const SystemRpcHandlers = (options: NativeRpcOptions) => + SystemRpcs.toLayer({ + "system.readSession": (_payload, { headers }) => + Effect.gen(function* () { + const principal = yield* principalFor(headers, options); + + const session = yield* Identity.use(({ readCurrentSession }) => + readCurrentSession(headers.cookie), + ); + + yield* authorizeSessionRead({ + headers, + principal, + rpc: ReadSession, + resourceId: session.sessionId, + }); + + return projection(principal.personId, session); + }).pipe( + sessionProblems(sessionPresentation(headers), Problem.make("identity.unavailable")), + // The owner is granted their own session; only a mismatched credential is rejected. + unreachable("authority.denied", "resource.not-found"), + ), + + "system.listSessions": (_payload, { headers }) => + Effect.gen(function* () { + const principal = yield* principalFor(headers, options); + + yield* authorizeSessionRead({ headers, principal, rpc: ListSessions, collection: true }); + + const sessions = yield* Identity.use(({ listSessions }) => listSessions(headers.cookie)); + + return sessions.map((session) => projection(principal.personId, session)); + }).pipe( + sessionProblems(sessionPresentation(headers), Problem.make("identity.unavailable")), + // The owner is granted their own sessions; only a mismatched credential is rejected. + unreachable("authority.denied", "resource.not-found"), + ), + + "system.deleteSession": ({ idempotencyKey }, { headers }) => + executeSessionCommand({ + headers, + options, + rpc: DeleteSession, + operationId: "system.deleteSession", + normalizedTarget: "/api/session", + idempotencyKey, + mutate: (identity, actor) => + identity.revokeCurrentSession( + actor, + identityRequestContext(credentialRequestOf(headers)), + ), + }).pipe( + // Only the owned-session delete names a session other than the caller's own. + unreachable("resource.not-found"), + ), + + "system.deleteOwnedSession": ({ idempotencyKey, sessionId }, { headers }) => + executeSessionCommand({ + headers, + options, + rpc: DeleteOwnedSession, + operationId: "system.deleteOwnedSession", + normalizedTarget: `/api/sessions/${encodePathIdentity(sessionId)}`, + idempotencyKey, + resourceId: sessionId, + mutate: (identity, actor) => + identity.revokeSession( + actor, + sessionId, + identityRequestContext(credentialRequestOf(headers)), + ), + }), + + "system.revokeOtherSessions": ({ idempotencyKey }, { headers }) => + executeSessionCommand({ + headers, + options, + rpc: RevokeOtherSessions, + operationId: "system.revokeOtherSessions", + // The HTTP handler's target, verbatim, so receipts and command IDs stay stable. + normalizedTarget: "/api/sessions::revoke-others", + idempotencyKey, + mutate: (identity, actor) => + identity.revokeOtherSessions(actor, identityRequestContext(credentialRequestOf(headers))), + }).pipe(unreachable("resource.not-found")), + + "system.revokeAllSessions": ({ idempotencyKey }, { headers }) => + executeSessionCommand({ + headers, + options, + rpc: RevokeAllSessions, + operationId: "system.revokeAllSessions", + // The HTTP handler's target, verbatim, so receipts and command IDs stay stable. + normalizedTarget: "/api/sessions::revoke-all", + idempotencyKey, + mutate: (identity, actor) => + identity.revokeAllSessions(actor, identityRequestContext(credentialRequestOf(headers))), + }).pipe(unreachable("resource.not-found")), + }); diff --git a/apps/backend/src/schools/AGENTS.md b/apps/backend/src/schools/AGENTS.md deleted file mode 100644 index 46acdd83e..000000000 --- a/apps/backend/src/schools/AGENTS.md +++ /dev/null @@ -1,57 +0,0 @@ -[//]: # "guide: generated from docs/model/contexts.cml, the @construct tags, and package.json exports by just guides write; do not edit" - -# apps/backend/src/schools - -This folder holds the Schools bounded context in `apps/backend/src`. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. - -The Schools context in other folders: - -- [apps/dashboard/app/foldkit/schools](../../../dashboard/app/foldkit/schools/AGENTS.md) -- [packages/database/src/schools](../../../../packages/database/src/schools/AGENTS.md) -- [packages/domain/src/schools](../../../../packages/domain/src/schools/AGENTS.md) - -## Bounded context: Schools - -From [docs/model/contexts.cml](../../../../docs/model/contexts.cml). - -Partner-school identities, contacts, language, activity and department associations, and capacity plans per school, department and semester. There is no school deletion. Directory membership grants no maintenance authority. - -Responsibilities: - -- Schools -- Department associations -- Capacity plans - -### Owns - -- `School` -- `CapacityPlan` - -### Uses but does not own - -- `PrincipalAlgebra` and `CapabilityRegistry` of AccessControl -- `SemesterCatalogue` of AcademicCalendar -- `Department` of [Organization](../organization/AGENTS.md) - -### Upstream - -| Context | Relationship | Integration | -| ----------------------------------------- | ------------------------------------------------- | ---------------------------------------------------------------------- | -| AccessControl | open host service, published language, conformist | Scope resolver: school -> associated departments (all must be covered) | -| AcademicCalendar | open host service, published language, conformist | | -| [Organization](../organization/AGENTS.md) | open host service, published language, conformist | | - -### Downstream - -| Context | Relationship | Exposes | Integration | -| ------------------------------------- | ------------------ | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Placements](../placements/AGENTS.md) | supplier, customer | `School`, `CapacityPlan` | Active school, department association and capacity, which bounds the scheduling function; Placements answers the dependent-records query Schools needs before an association is removed | - -## Entry points - -No `exports` entry of [apps/backend/package.json](../../package.json) points into this folder, so other packages do not import it. - -Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. - -[//]: # "guide: end" diff --git a/apps/backend/src/schools/administration-http.ts b/apps/backend/src/schools/administration-http.ts deleted file mode 100644 index 025f1c1e6..000000000 --- a/apps/backend/src/schools/administration-http.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Effect, Option, Predicate, Schema } from "effect"; -import { - Schools, - SchoolCommand, - SchoolCommandResult, - SchoolManagement, - SchoolCommandFailure, -} from "@vektorprogrammet/domain/schools"; -import { Scope } from "@vektorprogrammet/domain/authz"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { - ReadSchoolManagementEndpoint, - ExecuteSchoolCommandEndpoint, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import type { CredentialPresentation } from "@vektorprogrammet/http-api/http-semantics"; -import { resolveRequestCredentialInTransaction } from "../authority.js"; -import { genericContext } from "../native-operation.js"; -import { - authorizePerson, - commandOutcomeResponse, - commandReceiptProblems, - httpIdentity, - idempotencyKeyOf, - personPresentation, - readJsonBody, - requireNoQuery, - jsonText, -} from "../http-api/problem.js"; -import { executeNativeHttpCommandPostgres } from "../http-api/receipt-transaction.js"; -import { deriveStrongETag, semanticRequestDigest } from "../http-semantics.js"; -import { schoolsCredentialProblems, schoolsProblems } from "./http.js"; - -const operationId = "directory.executeSchoolCommand"; - -const authorizeTransport = Effect.fn("Schools.authorizeTransport")(function* ( - request: Request, - mutation: boolean, - presentation: CredentialPresentation, -) { - const resolved = yield* resolveRequestCredentialInTransaction(request, "OAuthUserBearer"); - - if (!Predicate.isTagged(resolved.credential.principal, "Person")) - return yield* new UnauthenticatedActor({ message: "authentication required" }); - const personId = resolved.credential.principal.personId; - yield* authorizePerson( - { - spec: Option.getOrThrow( - reflectAccessSpec(mutation ? ExecuteSchoolCommandEndpoint : ReadSchoolManagementEndpoint), - ), - credential: resolved.credential, - personId, - resolution: { - selection: "ExactlyOne", - contexts: [genericContext({ domainId: "schools", authorityVersion: "school-management" })], - }, - grantScopes: [Scope.Global()], - now: resolved.authorizationInstant, - }, - presentation, - ); - - return personId; -}); - -export const readSchoolManagementHttp = (request: Request) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - yield* requireNoQuery(request); - const personId = yield* authorizeTransport(request, false, presentation); - const snapshot = yield* Schools.use((schools) => schools.readManagement(personId)); - const encoded = yield* Schema.encodeEffect(SchoolManagement)(snapshot); - - return new Response(yield* jsonText(encoded), { - headers: { - "content-type": "application/json", - "cache-control": "private, no-store", - vary: "Origin", - }, - }); - }).pipe(schoolsProblems, schoolsCredentialProblems(presentation)); -}; - -export const executeSchoolCommandHttp = (request: Request) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - yield* requireNoQuery(request); - - const body = yield* readJsonBody(request, /^application\/json(?:\s*;|$)/iu, 32_768); - - const command = yield* Schema.decodeUnknownEffect(SchoolCommand)(body, { - onExcessProperty: "error", - }).pipe(Effect.mapError(() => new SchoolCommandFailure({ code: "Invalid" }))); - - const key = yield* idempotencyKeyOf(request); - - if (key !== command.commandId) return yield* new SchoolCommandFailure({ code: "Conflict" }); - - // Domain and credential failures are mapped after the executor, whose retry reads their causes. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const personId = yield* authorizeTransport(request, true, presentation); - yield* Schools.use((schools) => schools.authorizeCommand(command, personId)); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${personId}`, - qualifiedOperationId: operationId, - normalizedTarget: "/api/schools/commands", - idempotencyKey: key, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({ body: command }), - operationId, - }, - execute: Effect.gen(function* () { - const result = yield* Schools.use((schools) => - schools.executeCommand(command, personId), - ); - - const encoded = yield* Schema.encodeEffect(SchoolCommandResult)(result); - - return { - status: 200, - mediaType: "application/json", - headers: { - "content-type": "application/json", - etag: deriveStrongETag({ - representationKind: "SchoolCommandResult", - resourceIdentity: `${result.schoolId}:${result.capacityId ?? "school"}`, - version: result.revision, - }), - }, - bodyBytes: new TextEncoder().encode(yield* jsonText(encoded)), - }; - }), - }; - }), - { retry: "serialization-once" }, - ); - - return yield* commandOutcomeResponse(outcome); - }).pipe(schoolsProblems, commandReceiptProblems, schoolsCredentialProblems(presentation)); -}; diff --git a/apps/backend/src/schools/http.ts b/apps/backend/src/schools/http.ts deleted file mode 100644 index 5205f8440..000000000 --- a/apps/backend/src/schools/http.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Scope } from "@vektorprogrammet/domain/authz"; -import type { OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { readSchoolsDirectory } from "@vektorprogrammet/database/schools"; -import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import type { Identity, IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import { - SchoolDirectoryQuerySchema, - SchoolDirectorySchema, - type ReadSchoolsDirectoryFailure, - type SchoolCommandFailure, - type SchoolDirectoryQuery, -} from "@vektorprogrammet/domain/schools"; -import type { OrganizationAuthorityInstant, PersonId } from "@vektorprogrammet/domain/organization"; -import { ListSchoolsEndpoint, reflectAccessSpec } from "@vektorprogrammet/http-api"; -import { type CredentialPresentation, Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Match, Option, Schema } from "effect"; -import { - authorizePerson, - personPresentation, - problemMapper, - unreachable, -} from "../http-api/problem.js"; -import { genericContext } from "../native-operation.js"; - -export interface SchoolsRequestActor { - readonly personId: PersonId; - readonly authorizationInstant: OrganizationAuthorityInstant; -} - -export interface SchoolsApiHttpOptions { - /** Cookie -> PersonId and the request's single authorization instant. */ - readonly resolveActor: ( - request: Request, - ) => Effect.Effect< - SchoolsRequestActor, - IdentityEngineError | UnauthenticatedActor, - Identity | OAuthCredentialAuthority - >; -} - -const privateJsonResponse = (body: Schema.Json): Response => - new Response(JSON.stringify(body), { - status: 200, - headers: { - "content-type": "application/json; charset=utf-8", - "cache-control": "private, no-store", - vary: "Origin", - }, - }); - -/** - * The one answer for every Schools failure. A representation that does not - * fit its schema leaves Schools unavailable, as a failed Schools read does. - */ -export const schoolsProblems = problemMapper< - ReadSchoolsDirectoryFailure | SchoolCommandFailure | Schema.SchemaError ->()({ - AuthorityInactive: () => Problem.make("authority.denied"), - NotInScope: () => Problem.make("authority.denied"), - SchoolsDepartmentOutOfScope: () => Problem.make("authority.denied"), - SchoolsDepartmentNotFound: () => Problem.make("schools.invalid-department"), - SchoolsDecodeError: () => Problem.make("schools.unavailable"), - SchoolsPersistenceError: () => Problem.make("schools.unavailable"), - SchemaError: () => Problem.make("schools.unavailable"), - SchoolCommandFailure: ({ code }) => - Match.value(code).pipe( - Match.when("Denied", () => Problem.make("authority.denied")), - Match.when("NotFound", () => Problem.make("resource.not-found")), - Match.when("Stale", () => Problem.make("precondition.failed")), - Match.when("Conflict", () => Problem.make("idempotency.digest-conflict")), - Match.when("AssociationInUse", () => Problem.make("schools.association-in-use")), - Match.when("InactiveSchool", () => Problem.make("schools.inactive")), - Match.when("CapacityExists", () => Problem.make("schools.capacity-exists")), - Match.whenOr("InvalidReference", "Invalid", () => Problem.make("schools.invalid-command")), - Match.exhaustive, - ), -}); - -/** - * A person credential rejected after ingress is answered from the request's - * own evidence; an unavailable identity provider leaves Schools unavailable. - */ -export const schoolsCredentialProblems = (presentation: CredentialPresentation) => - problemMapper()({ - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - IdentityEngineError: () => Problem.make("schools.unavailable"), - }); - -/** The directory accepts one optional department and no other query member. */ -const decodeQuery = ( - request: Request, -): Effect.Effect> => { - const parameters = [...new URL(request.url).searchParams]; - - const encoded = - parameters.length === 0 - ? {} - : parameters.length === 1 && parameters[0]![0] === "department" - ? { departmentId: parameters[0]![1] } - : undefined; - - if (encoded === undefined) return Effect.fail(Problem.make("request.malformed")); - - return Schema.decodeEffect(SchoolDirectoryQuerySchema)(encoded, { - onExcessProperty: "error", - }).pipe(Effect.mapError(() => Problem.make("request.malformed"))); -}; - -/** Native Schools directory adapter. It owns transport only, never SQL or authority policy. */ -export const listSchools = (request: Request, options: SchoolsApiHttpOptions) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - const query = yield* decodeQuery(request); - const actor = yield* options.resolveActor(request); - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(ListSchoolsEndpoint)), - request, - personId: actor.personId, - resolution: { - selection: "AllMatching", - contexts: [ - genericContext({ - domainId: "schools", - departmentId: query.departmentId ?? null, - authorityVersion: `schools:${actor.authorizationInstant}`, - }), - ], - }, - grantScopes: [Scope.Global()], - now: actor.authorizationInstant, - }, - presentation, - ); - - const directory = yield* readSchoolsDirectory( - actor.personId, - actor.authorizationInstant, - query, - ); - - const response = yield* Schema.decodeEffect(SchoolDirectorySchema)(directory, { - onExcessProperty: "error", - }); - - return privateJsonResponse(response); - }).pipe( - schoolsProblems, - schoolsCredentialProblems(presentation), - // A revealing AccessSpec answers every authority failure as a denial, never as 404. - unreachable("resource.not-found"), - ); -}; diff --git a/apps/backend/src/session-security.ts b/apps/backend/src/session-security.ts index 89fad93d3..ccfe6a39c 100644 --- a/apps/backend/src/session-security.ts +++ b/apps/backend/src/session-security.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { IdentityRequestContext } from "@vektorprogrammet/domain/identity"; import { Data, Schema } from "effect"; import { allowHeader } from "./http-semantics.js"; +import { dual } from "effect/Function"; const Deployment = Schema.Literals(["local", "production"]); @@ -113,7 +114,7 @@ const RequestCorrelationHeader = "x-vektorprogrammet-request-correlation"; /** Creates bounded, non-secret request evidence for audit persistence. */ export const identityRequestContext = (request: Request): IdentityRequestContext => - new IdentityRequestContext({ + IdentityRequestContext.make({ requestCorrelation: safeCorrelation(request.headers.get(RequestCorrelationHeader)), sourceIp: safeSourceIp(request), userAgent: safeUserAgent(request.headers.get("user-agent")), @@ -157,10 +158,10 @@ const isIdentityMutation = (request: Request): boolean => { }; /** Decides origin authority before credentialed or identity-mutating dispatch. */ -export const decideTrustedOrigin = ( - policy: NativeSessionBoundaryPolicy, - request: Request, -): OriginDecision => { +export const decideTrustedOrigin: { + (request: Request): (policy: NativeSessionBoundaryPolicy) => OriginDecision; + (policy: NativeSessionBoundaryPolicy, request: Request): OriginDecision; +} = dual(2, (policy: NativeSessionBoundaryPolicy, request: Request): OriginDecision => { const origin = request.headers.get("origin"); if (origin !== null) { @@ -186,21 +187,14 @@ export const decideTrustedOrigin = ( return sameOriginBrowserRequest ? OriginDecision.Allowed({ origin: null }) : OriginDecision.Rejected(); -}; +}); /** * Browser-controlled headers supported by the current native API contract. * Cookie and CORS-safelisted headers are intentionally absent because browsers * do not include them in Access-Control-Request-Headers. */ -export const NativeBrowserRequestHeaders = [ - "Authorization", - "Content-Type", - "Idempotency-Key", - "If-Match", - "If-None-Match", - "X-Recruitment-Invitation-Capability", -] as const; +export const NativeBrowserRequestHeaders = ["Authorization", "Content-Type"] as const; const nativeBrowserRequestHeaderSet = new Set( NativeBrowserRequestHeaders.map((header) => header.toLowerCase()), @@ -225,22 +219,25 @@ export const allowsNativePreflightHeaders = (request: Request): boolean => { * Emits preflight headers for the methods selected by authoritative route * metadata. The caller must not pass a process-wide method superset. */ -export const trustedPreflightResponse = ( - origin: string, - allowedMethods: ReadonlyArray, -): Response => - new Response(null, { - status: 204, - headers: { - "access-control-allow-credentials": "true", - "access-control-allow-headers": NativeBrowserRequestHeaders.join(", "), - "access-control-allow-methods": allowHeader(allowedMethods), - "access-control-allow-origin": origin, - "access-control-max-age": "600", - "cache-control": "no-store", - vary: "Origin, Access-Control-Request-Method, Access-Control-Request-Headers", - }, - }); +export const trustedPreflightResponse: { + (allowedMethods: ReadonlyArray): (origin: string) => Response; + (origin: string, allowedMethods: ReadonlyArray): Response; +} = dual( + 2, + (origin: string, allowedMethods: ReadonlyArray): Response => + new Response(null, { + status: 204, + headers: { + "access-control-allow-credentials": "true", + "access-control-allow-headers": NativeBrowserRequestHeaders.join(", "), + "access-control-allow-methods": allowHeader(allowedMethods), + "access-control-allow-origin": origin, + "access-control-max-age": "600", + "cache-control": "no-store", + vary: "Origin, Access-Control-Request-Method, Access-Control-Request-Headers", + }, + }), +); const mergeVary = (headers: Headers, values: ReadonlyArray): void => { const seen = new Map(); @@ -256,7 +253,10 @@ const mergeVary = (headers: Headers, values: ReadonlyArray): void => { }; /** Adds request-relative CORS while every external response varies by Origin. */ -export const withTrustedOriginCors = (response: Response, origin: string | null): Response => { +export const withTrustedOriginCors: { + (origin: string | null): (response: Response) => Response; + (response: Response, origin: string | null): Response; +} = dual(2, (response: Response, origin: string | null): Response => { const headers = new Headers(response.headers); mergeVary(headers, ["Origin"]); @@ -271,4 +271,4 @@ export const withTrustedOriginCors = (response: Response, origin: string | null) statusText: response.statusText, headers, }); -}; +}); diff --git a/apps/backend/src/social-events/AGENTS.md b/apps/backend/src/social-events/AGENTS.md index bcd9b246b..b6be1cb60 100644 --- a/apps/backend/src/social-events/AGENTS.md +++ b/apps/backend/src/social-events/AGENTS.md @@ -3,7 +3,7 @@ # apps/backend/src/social-events This folder holds the SocialEvents bounded context in `apps/backend/src`. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The SocialEvents context in other folders: diff --git a/apps/backend/src/social-events/http.ts b/apps/backend/src/social-events/http.ts deleted file mode 100644 index faea169b4..000000000 --- a/apps/backend/src/social-events/http.ts +++ /dev/null @@ -1,403 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { - SocialEventCommandId, - SocialEventId, - SocialEvents, - socialEventCandidateGrantScopes, - socialEventDepartmentAccessContext, - socialEventScopeAccessContext, - type SocialEventFailure, - type SocialEventObservedAt, -} from "@vektorprogrammet/domain"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { - SOCIAL_EVENTS_CREATE_CAPABILITY, - SOCIAL_EVENTS_READ_CAPABILITY, - SOCIAL_EVENTS_READ_SCOPE_CAPABILITY, - type CanonicalResourceContext, - type CapabilityTypeId, -} from "@vektorprogrammet/domain/authz"; -import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import { OrganizationAuthorityInstantSchema } from "@vektorprogrammet/domain/organization"; -import { Database } from "@vektorprogrammet/database"; -import { - resolveOrganizationPersonAuthorityWithSql, - type OrganizationAuthorityRowLockMode, -} from "@vektorprogrammet/database/organization"; -import { - CreateSocialEventEndpoint, - CreateSocialEventRequest, - ExternalNativeApi, - ListSocialEventsEndpoint, - ReadSocialEventScopeEndpoint, - SocialEventListResource, - SocialEventResource, - SocialEventScope, - SocialEventScopeResource, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { type CredentialPresentation, Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Option, Predicate, Schema } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { - resolveRequestCredentialInTransaction, - type OrganizationResolutionError, - type TransactionPersonAuthority, -} from "../authority.js"; -import { - authorizePerson, - commandOutcomeResponse, - commandReceiptProblems, - decodeRequest, - httpIdentity, - idempotencyKeyOf, - personPresentation, - problemMapper, - readJsonBody, - requireNoQuery, - strictOutput, - unreachable, - webHandler, - jsonText, -} from "../http-api/problem.js"; -import { executeNativeHttpCommandPostgres } from "../http-api/receipt-transaction.js"; -import { - deriveStrongETag, - encodePathIdentity, - jsonBodyBytes, - semanticRequestDigest, -} from "../http-semantics.js"; - -const maxCreateBodyBytes = 32_768; - -const socialEventScopeQueryKeys = { - departmentId: true, - semesterId: true, -} as const; - -type Endpoint = - | typeof ReadSocialEventScopeEndpoint - | typeof ListSocialEventsEndpoint - | typeof CreateSocialEventEndpoint; - -type SocialEventAccessContext = CanonicalResourceContext>; - -export type SocialEventTransactionStage = - | "before-authority-snapshot" - | "after-authority-snapshot" - | "before-create-authorization" - | "after-create-authorization"; - -export type SocialEventTransactionHook = (input: { - readonly request: Request; - readonly operation: "readScope" | "list" | "create"; - readonly stage: SocialEventTransactionStage; -}) => Effect.Effect; - -export interface SocialEventsApiHttpOptions { - /** - * Test-only coordination seam for proving snapshot and create-authorization - * behavior against real concurrent transactions. - */ - readonly transactionHook?: SocialEventTransactionHook; -} - -/** - * The one answer for every social-event, Organization projection, and - * transaction credential failure. - */ -const socialEventProblems = (presentation: CredentialPresentation) => - problemMapper< - SocialEventFailure | OrganizationResolutionError | IdentityEngineError | UnauthenticatedActor - >()({ - SocialEventScopeInvalid: () => Problem.make("scope.invalid"), - SocialEventDecodeError: () => Problem.make("internal.error"), - SocialEventPersistenceError: () => Problem.make("dependency.unavailable"), - OrganizationDecodeError: () => Problem.make("organization.unavailable"), - OrganizationPersistenceError: () => Problem.make("organization.unavailable"), - IdentityEngineError: () => Problem.make("dependency.unavailable"), - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - }); - -/** The list scope is exactly one departmentId and one semesterId. */ -const strictScope = (request: Request) => - Effect.suspend(() => { - const values = [...new URL(request.url).searchParams]; - - return values.length !== 2 || - values.some(([name]) => !Object.hasOwn(socialEventScopeQueryKeys, name)) || - values.filter(([name]) => name === "departmentId").length !== 1 || - values.filter(([name]) => name === "semesterId").length !== 1 - ? Effect.fail(Problem.make("request.malformed")) - : Schema.decodeUnknownEffect(SocialEventScope)(Object.fromEntries(values), { - onExcessProperty: "error", - }).pipe(Effect.mapError(() => Problem.make("request.malformed"))); - }); - -/** Evaluates the endpoint's AccessSpec against the grants the Organization projection yields. */ -const authorize = (input: { - readonly request: Request; - readonly endpoint: Endpoint; - readonly capability: CapabilityTypeId; - readonly authorization: TransactionPersonAuthority; - readonly context: SocialEventAccessContext; -}) => - authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(input.endpoint)), - credential: input.authorization.credential, - personId: input.authorization.authority.personId, - resolution: { selection: "ExactlyOne", contexts: [input.context] }, - grantScopes: socialEventCandidateGrantScopes(input.authorization.authority, input.capability), - now: input.authorization.authorizationInstant, - }, - personPresentation(input.request), - ).pipe( - // Social-event AccessSpecs reveal every denial, so none is answered as not found. - unreachable("resource.not-found"), - ); - -/** Every answer `authorize` can give a caller it does not admit. */ -type AuthorizationProblem = - | Problem<"credential.missing"> - | Problem<"credential.invalid"> - | Problem<"authority.denied">; - -const runTransactionHook = ( - options: SocialEventsApiHttpOptions, - request: Request, - operation: "readScope" | "list" | "create", - stage: SocialEventTransactionStage, -) => { - const hook = options.transactionHook; - - return hook === undefined ? Effect.void : hook({ request, operation, stage }); -}; - -const resolveSocialEventAuthority = ( - request: Request, - observedAt: string, - lockMode: OrganizationAuthorityRowLockMode, -) => - Effect.gen(function* () { - const authenticated = yield* resolveRequestCredentialInTransaction(request, "OAuthUserBearer", { - now: () => observedAt, - }); - - if (!Predicate.isTagged(authenticated.credential.principal, "Person")) { - return yield* new UnauthenticatedActor({ message: "authentication required" }); - } - - const personId = authenticated.credential.principal.personId; - - const authority = yield* Database.use((sql) => - resolveOrganizationPersonAuthorityWithSql( - sql, - personId, - OrganizationAuthorityInstantSchema.make(observedAt), - lockMode, - ), - ); - - return { ...authenticated, authority } satisfies TransactionPersonAuthority; - }); - -/** - * Reads one private JSON body inside a repeatable-read, read-only snapshot - * whose instant also resolves the caller's credential and authority. - */ -const snapshotRead = ( - request: Request, - options: SocialEventsApiHttpOptions, - operation: "readScope" | "list", - read: ( - observedAt: SocialEventObservedAt, - authorization: TransactionPersonAuthority, - ) => Effect.Effect, -) => - Database.use((sql) => - sql.withTransaction( - Effect.gen(function* () { - yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`; - yield* runTransactionHook(options, request, operation, "before-authority-snapshot"); - - const observedAt = yield* SocialEvents.use( - ({ readSnapshotInstant }) => readSnapshotInstant, - ); - - const authorization = yield* resolveSocialEventAuthority(request, observedAt, "None"); - yield* runTransactionHook(options, request, operation, "after-authority-snapshot"); - - const body = yield* read(observedAt, authorization); - - return new Response(yield* jsonText(body), { - headers: { - "content-type": "application/json", - "cache-control": "private, no-store", - vary: "Origin", - }, - }); - }), - ), - ).pipe( - socialEventProblems(personPresentation(request)), - Effect.catchTag("SqlError", () => Effect.fail(Problem.make("internal.error"))), - ); - -const readScope = (request: Request, options: SocialEventsApiHttpOptions) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - return yield* snapshotRead(request, options, "readScope", (observedAt, authorization) => - Effect.gen(function* () { - yield* authorize({ - request, - endpoint: ReadSocialEventScopeEndpoint, - capability: SOCIAL_EVENTS_READ_SCOPE_CAPABILITY, - authorization, - context: socialEventScopeAccessContext(authorization.authority), - }); - - const body = yield* SocialEvents.use((events) => - events.readScope({ authority: authorization.authority, observedAt }), - ); - - return yield* strictOutput(SocialEventScopeResource)(body); - }), - ).pipe( - // The scope read selects no department or semester, so it never validates one. - unreachable("scope.invalid"), - ); - }); - -const list = (request: Request, options: SocialEventsApiHttpOptions) => - Effect.gen(function* () { - const scope = yield* strictScope(request); - - return yield* snapshotRead(request, options, "list", (observedAt, authorization) => - Effect.gen(function* () { - yield* authorize({ - request, - endpoint: ListSocialEventsEndpoint, - capability: SOCIAL_EVENTS_READ_CAPABILITY, - authorization, - context: socialEventDepartmentAccessContext(authorization.authority, scope.departmentId), - }); - yield* SocialEvents.use(({ validateScope }) => validateScope(scope)); - - const body = yield* SocialEvents.use(({ readList }) => readList({ ...scope, observedAt })); - - return yield* strictOutput(SocialEventListResource)(body); - }), - ); - }); - -const create = (request: Request, options: SocialEventsApiHttpOptions) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - const json = yield* readJsonBody( - request, - /^\s*application\/json\s*(?:;|$)/iu, - maxCreateBodyBytes, - ); - - const body = yield* decodeRequest(CreateSocialEventRequest)(json); - - const idempotencyKey = yield* idempotencyKeyOf(request); - - const operationId = "social-events.create"; - - // Domain and credential failures are mapped after the executor, whose retry reads their causes. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - yield* runTransactionHook(options, request, "create", "before-authority-snapshot"); - - const observedAt = yield* SocialEvents.use( - ({ readSnapshotInstant }) => readSnapshotInstant, - ); - - const authorization = yield* resolveSocialEventAuthority(request, observedAt, "ForShare"); - yield* runTransactionHook(options, request, "create", "after-authority-snapshot"); - yield* runTransactionHook(options, request, "create", "before-create-authorization"); - yield* authorize({ - request, - endpoint: CreateSocialEventEndpoint, - capability: SOCIAL_EVENTS_CREATE_CAPABILITY, - authorization, - context: socialEventDepartmentAccessContext(authorization.authority, body.departmentId), - }); - yield* runTransactionHook(options, request, "create", "after-create-authorization"); - yield* SocialEvents.use(({ validateScope }) => - validateScope({ departmentId: body.departmentId, semesterId: body.semesterId }), - ); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${authorization.authority.personId}`, - qualifiedOperationId: operationId, - normalizedTarget: "/api/social-events", - idempotencyKey, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({ body }), - operationId, - }, - execute: SocialEvents.use((events) => - events.create({ - commandId: SocialEventCommandId.make(identity.commandId), - actorPersonId: authorization.authority.personId, - occurredAt: observedAt, - eventId: SocialEventId.make(`social_event_${randomUUID()}`), - request: body, - }), - ).pipe( - Effect.flatMap(strictOutput(SocialEventResource)), - Effect.map((event) => ({ - status: 201, - mediaType: "application/json", - headers: { - "content-type": "application/json", - etag: deriveStrongETag({ - representationKind: "social-event", - resourceIdentity: event.eventId, - version: event.revision, - }), - location: `/api/social-events/${encodePathIdentity(event.eventId)}`, - }, - bodyBytes: jsonBodyBytes(event), - })), - ), - }; - }), - { - retry: "serialization-or-unique-once", - retryUniqueConstraints: [ - "social_events_created_command_id_key", - "social_event_command_receipts_pkey", - "native_http_idempotency_receipts_pkey", - ], - }, - ).pipe(socialEventProblems(personPresentation(request)), commandReceiptProblems); - - return yield* commandOutcomeResponse(outcome); - }); - -/** Native HttpApi handlers for the frozen social-event surface. */ -export const SocialEventsApiHandlers = (options: SocialEventsApiHttpOptions) => - HttpApiBuilder.group(ExternalNativeApi, "social-events", (handlers) => - Effect.succeed( - handlers - .handleRaw("readScope", ({ request }) => - webHandler(request, (webRequest) => readScope(webRequest, options)), - ) - .handleRaw("list", ({ request }) => - webHandler(request, (webRequest) => list(webRequest, options)), - ) - .handleRaw("create", ({ request }) => - webHandler(request, (webRequest) => create(webRequest, options)), - ), - ), - ); diff --git a/apps/backend/src/social-events/rpc.ts b/apps/backend/src/social-events/rpc.ts new file mode 100644 index 000000000..a96b909ff --- /dev/null +++ b/apps/backend/src/social-events/rpc.ts @@ -0,0 +1,338 @@ +/** + * The SocialEventsRpcs handlers: the precedent that every context's RPC handlers follow. + * + * A read resolves the credential and the authority in one repeatable-read snapshot and evaluates + * the RPC's AccessSpec there. A command resolves them inside the serializable transaction that + * commits it, mints its evidence, and stores its success as a command receipt, so a retry with the + * same idempotency key replays the first answer. + */ +import { randomUUID } from "node:crypto"; +import { + SocialEventCommandId, + SocialEventId, + SocialEvents, + socialEventCandidateGrantScopes, + requireSocialEventCreation, + socialEventDepartmentAccessContext, + socialEventScopeAccessContext, + type SocialEventFailure, + type SocialEventObservedAt, +} from "@vektorprogrammet/domain"; +import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import { + SOCIAL_EVENTS_CREATE_CAPABILITY, + SOCIAL_EVENTS_READ_CAPABILITY, + SOCIAL_EVENTS_READ_SCOPE_CAPABILITY, + type CanonicalResourceContext, + type CapabilityTypeId, +} from "@vektorprogrammet/domain/authz"; +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import { OrganizationAuthorityInstantSchema } from "@vektorprogrammet/domain/organization"; +import { Database } from "@vektorprogrammet/database"; +import { + resolveOrganizationPersonAuthorityWithSql, + type OrganizationAuthorityRowLockMode, +} from "@vektorprogrammet/database/organization"; +import { + CreateSocialEvent, + ListSocialEvents, + ReadSocialEventScope, + SocialEventListResource, + SocialEventResource, + SocialEventScopeResource, + SocialEventsRpcs, + reflectAccessSpec, +} from "@vektorprogrammet/rpc"; +import { type CredentialPresentation, Problem } from "@vektorprogrammet/rpc/problem"; +import { Effect, Option, Predicate } from "effect"; +import type { Headers } from "effect/unstable/http"; +import type { Rpc } from "effect/unstable/rpc"; +import { + resolveRequestCredentialInTransaction, + type OrganizationResolutionError, + type TransactionPersonAuthority, +} from "../authority.js"; +import { semanticRequestDigest } from "../http-semantics.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; +import { + authorizePerson, + commandIdentity, + commandOutcome, + commandReceiptProblems, + personPresentation, + problemMapper, + strictOutput, + unreachable, +} from "../rpc/problem.js"; +import { executeNativeHttpCommandPostgres, successCapsule } from "../rpc/receipt-transaction.js"; + +type SocialEventAccessContext = CanonicalResourceContext>; + +export type SocialEventTransactionStage = + | "before-authority-snapshot" + | "after-authority-snapshot" + | "before-create-authorization" + | "after-create-authorization"; + +export type SocialEventTransactionHook = (input: { + readonly operation: "readScope" | "list" | "create"; + readonly stage: SocialEventTransactionStage; +}) => Effect.Effect; + +/** + * The one answer for every social-event, Organization projection, and transaction credential + * failure. + */ +const socialEventProblems = (presentation: CredentialPresentation) => + problemMapper< + SocialEventFailure | OrganizationResolutionError | IdentityEngineError | UnauthenticatedActor + >()({ + SocialEventScopeInvalid: () => Problem.make("scope.invalid"), + SocialEventDecodeError: () => Problem.make("internal.error"), + SocialEventPersistenceError: () => Problem.make("dependency.unavailable"), + OrganizationDecodeError: () => Problem.make("organization.unavailable"), + OrganizationPersistenceError: () => Problem.make("organization.unavailable"), + IdentityEngineError: () => Problem.make("dependency.unavailable"), + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + }); + +/** Evaluates the RPC's AccessSpec against the grants the Organization projection yields. */ +const authorize = (input: { + readonly headers: Headers.Headers; + readonly rpc: Pick; + readonly capability: CapabilityTypeId; + readonly authorization: TransactionPersonAuthority; + readonly context: SocialEventAccessContext; +}) => + authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(input.rpc)), + credential: input.authorization.credential, + personId: input.authorization.authority.personId, + resolution: { selection: "ExactlyOne", contexts: [input.context] }, + grantScopes: socialEventCandidateGrantScopes(input.authorization.authority, input.capability), + now: input.authorization.authorizationInstant, + }, + personPresentation(input.headers), + ).pipe( + // Social-event AccessSpecs reveal every denial, so none is answered as not found. + unreachable("resource.not-found"), + ); + +/** Every answer `authorize` can give a caller it does not admit. */ +type AuthorizationProblem = + | Problem<"credential.missing"> + | Problem<"credential.invalid"> + | Problem<"authority.denied">; + +const runTransactionHook = ( + options: NativeRpcOptions, + operation: "readScope" | "list" | "create", + stage: SocialEventTransactionStage, +) => { + const hook = options.socialEventsTransactionHook; + + return hook === undefined ? Effect.void : hook({ operation, stage }); +}; + +const resolveSocialEventAuthority = ( + headers: Headers.Headers, + observedAt: string, + lockMode: OrganizationAuthorityRowLockMode, +) => + Effect.gen(function* () { + const authenticated = yield* resolveRequestCredentialInTransaction( + credentialRequestOf(headers), + "OAuthUserBearer", + { now: () => observedAt }, + ); + + if (!Predicate.isTagged(authenticated.credential.principal, "Person")) { + return yield* UnauthenticatedActor.make({ message: "authentication required" }); + } + + const personId = authenticated.credential.principal.personId; + + const authority = yield* Database.use((sql) => + resolveOrganizationPersonAuthorityWithSql( + sql, + personId, + OrganizationAuthorityInstantSchema.make(observedAt), + lockMode, + ), + ); + + return { ...authenticated, authority } satisfies TransactionPersonAuthority; + }); + +/** + * Runs one read inside a repeatable-read, read-only snapshot whose instant also resolves the + * caller's credential and authority. + */ +const snapshotRead = ( + headers: Headers.Headers, + options: NativeRpcOptions, + operation: "readScope" | "list", + read: ( + observedAt: SocialEventObservedAt, + authorization: TransactionPersonAuthority, + ) => Effect.Effect, +) => + Database.use((sql) => + sql.withTransaction( + Effect.gen(function* () { + yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`; + + yield* runTransactionHook(options, operation, "before-authority-snapshot"); + + const observedAt = yield* SocialEvents.use( + ({ readSnapshotInstant }) => readSnapshotInstant, + ); + + const authorization = yield* resolveSocialEventAuthority(headers, observedAt, "None"); + + yield* runTransactionHook(options, operation, "after-authority-snapshot"); + + return yield* read(observedAt, authorization); + }), + ), + ).pipe( + socialEventProblems(personPresentation(headers)), + Effect.catchTag("SqlError", () => Effect.fail(Problem.make("internal.error"))), + ); + +/** The SocialEventsRpcs handlers. */ +export const SocialEventsRpcHandlers = (options: NativeRpcOptions) => + SocialEventsRpcs.toLayer({ + "social-events.readScope": (_payload, { headers }) => + snapshotRead(headers, options, "readScope", (observedAt, authorization) => + Effect.gen(function* () { + yield* authorize({ + headers, + rpc: ReadSocialEventScope, + capability: SOCIAL_EVENTS_READ_SCOPE_CAPABILITY, + authorization, + context: socialEventScopeAccessContext(authorization.authority), + }); + + const scope = yield* SocialEvents.use((events) => + events.readScope({ authority: authorization.authority, observedAt }), + ); + + return yield* strictOutput(SocialEventScopeResource)(scope); + }), + ).pipe( + // The scope read selects no department or semester, so it never validates one. + unreachable("scope.invalid"), + ), + + "social-events.list": (scope, { headers }) => + snapshotRead(headers, options, "list", (observedAt, authorization) => + Effect.gen(function* () { + yield* authorize({ + headers, + rpc: ListSocialEvents, + capability: SOCIAL_EVENTS_READ_CAPABILITY, + authorization, + context: socialEventDepartmentAccessContext( + authorization.authority, + scope.departmentId, + ), + }); + + yield* SocialEvents.use(({ validateScope }) => validateScope(scope)); + + const list = yield* SocialEvents.use(({ readList }) => + readList({ ...scope, observedAt }), + ); + + return yield* strictOutput(SocialEventListResource)(list); + }), + ), + + "social-events.create": ({ idempotencyKey, request }, { headers }) => + Effect.gen(function* () { + const operationId = "social-events.create"; + + // Domain and credential failures are mapped after the executor, whose retry reads their causes. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + yield* runTransactionHook(options, "create", "before-authority-snapshot"); + + const observedAt = yield* SocialEvents.use( + ({ readSnapshotInstant }) => readSnapshotInstant, + ); + + const authorization = yield* resolveSocialEventAuthority( + headers, + observedAt, + "ForShare", + ); + + yield* runTransactionHook(options, "create", "after-authority-snapshot"); + + yield* runTransactionHook(options, "create", "before-create-authorization"); + + yield* authorize({ + headers, + rpc: CreateSocialEvent, + capability: SOCIAL_EVENTS_CREATE_CAPABILITY, + authorization, + context: socialEventDepartmentAccessContext( + authorization.authority, + request.departmentId, + ), + }); + + // The AccessSpec admitted the candidate grants; the command takes them as evidence. + const creator = yield* Effect.fromResult( + requireSocialEventCreation(authorization.authority, request.departmentId), + ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); + + yield* runTransactionHook(options, "create", "after-create-authorization"); + + yield* SocialEvents.use(({ validateScope }) => + validateScope({ departmentId: request.departmentId, semesterId: request.semesterId }), + ); + + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ + credentialSubject: `Person:${authorization.authority.personId}`, + qualifiedOperationId: operationId, + normalizedTarget: "/api/social-events", + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest({ body: request }), + operationId, + }, + execute: SocialEvents.use((events) => + events.create(creator, { + commandId: SocialEventCommandId.make(identity.commandId), + occurredAt: observedAt, + eventId: SocialEventId.make(`social_event_${randomUUID()}`), + request, + }), + ).pipe( + Effect.flatMap(strictOutput(SocialEventResource)), + Effect.flatMap(successCapsule(SocialEventResource)), + ), + }; + }), + { + retry: "serialization-or-unique-once", + retryUniqueConstraints: [ + "social_events_created_command_id_key", + "social_event_command_receipts_pkey", + "native_http_idempotency_receipts_pkey", + ], + }, + ).pipe(socialEventProblems(personPresentation(headers)), commandReceiptProblems); + + return yield* commandOutcome(SocialEventResource)(outcome); + }), + }); diff --git a/apps/backend/src/team-application/AGENTS.md b/apps/backend/src/team-application/AGENTS.md index dce32f044..7a3f5f4a1 100644 --- a/apps/backend/src/team-application/AGENTS.md +++ b/apps/backend/src/team-application/AGENTS.md @@ -3,7 +3,7 @@ # apps/backend/src/team-application This folder holds TeamApplications code under another name. Team applications under the singular folder name. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. The TeamApplications context in other folders: diff --git a/apps/backend/src/team-application/http.test.ts b/apps/backend/src/team-application/http.test.ts deleted file mode 100644 index 44281740a..000000000 --- a/apps/backend/src/team-application/http.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { IdentitySnapshot, OAuthCredentialAuthority, Database } from "@vektorprogrammet/database"; -import { TeamApplicationsLive } from "@vektorprogrammet/database/team-application"; -import { - Identity, - IdentityActor, - IdentitySessionNotFound, - type IdentityOperations, -} from "@vektorprogrammet/domain/identity"; -import { PersonId } from "@vektorprogrammet/domain/organization"; -import { - PublicTeamApplicationIntake, - SessionUnauthorizedProblem, - TeamApplicationConfirmation, - TeamApplicationIntakeResource, - TeamApplicationListResponse, - TeamApplicationResource, - TeamApplicationsDeleteProblem, - TeamApplicationsReviseIntakeProblem, - TeamApplicationsStaffReadProblem, - TeamApplicationsSubmitProblem, -} from "@vektorprogrammet/http-api"; -import { DateTime, Effect, Layer, Schema } from "effect"; -import { describe, expect, it } from "@effect/vitest"; -import { backendDatabase } from "../../test/database.js"; -import { decodeTeamApplicationApiConfig } from "../config.js"; -import { makeTeamApplicationsTestHttp } from "../test/native-http.js"; - -const expiresAt = DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"); - -const sessionPerson = (cookie: string | undefined) => - /better-auth\.session_token=([^;]+)/u.exec(cookie ?? "")?.[1]; - -const actorFor = (personId: string) => - new IdentityActor({ - personId: PersonId.make(personId), - sessionId: `session-${personId}`, - expiresAt, - }); - -const identity = Identity.of({ - signIn: () => Effect.die("unexpected sign-in"), - resolveSession: (cookie) => { - const person = sessionPerson(cookie); - - return person === undefined - ? Effect.fail(new IdentitySessionNotFound()) - : Effect.succeed(actorFor(person)); - }, - readCurrentSession: () => Effect.die("unexpected session read"), - listSessions: () => Effect.die("unexpected session list"), - revokeCurrentSession: () => Effect.die("unexpected session mutation"), - revokeSession: () => Effect.die("unexpected session mutation"), - revokeOtherSessions: () => Effect.die("unexpected session mutation"), - revokeAllSessions: () => Effect.die("unexpected session mutation"), - recordSecurityEvent: () => Effect.die("unexpected identity audit"), - signOut: () => Effect.succeed({ setCookies: [] }), -} satisfies IdentityOperations); - -const identitySnapshot = IdentitySnapshot.of({ - resolveSession: (cookie) => { - const person = sessionPerson(cookie); - - return person === undefined - ? Effect.fail(new IdentitySessionNotFound()) - : Effect.succeed(actorFor(person)); - }, - revokeCurrentSession: () => Effect.die("unexpected session mutation"), - revokeSession: () => Effect.die("unexpected session mutation"), - revokeOtherSessions: () => Effect.die("unexpected session mutation"), - revokeAllSessions: () => Effect.die("unexpected session mutation"), -}); - -const oauth = OAuthCredentialAuthority.of({ - resolve: () => Effect.die("unexpected OAuth credential resolution"), - resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), -}); - -type TestRequest = ( - pathname: string, - init?: RequestInit & { readonly person?: string }, -) => Effect.Effect; - -/** Each fixture is one backend process: its own database and its own public rate limit. */ -const fixture = (environment: Readonly> = {}) => { - const database = backendDatabase( - Database.use((sql) => - Effect.gen(function* () { - yield* sql` - INSERT INTO organization_departments (department_id, name, short_name, email, city) - VALUES ('http-department', 'Trondheim', 'NTNU', 'department@example.invalid', 'Trondheim') - `; - yield* sql` - INSERT INTO organization_teams (team_id, department_id, name, email, accept_application, active) - VALUES - ('http-open', 'http-department', 'IT', 'it@example.invalid', true, true), - ('http-closed', 'http-department', 'Lukket', NULL, false, true) - `; - yield* sql` - INSERT INTO person_profiles (person_id, first_name, last_name) - VALUES ('http-leader', 'Lea', 'Leder'), ('http-member', 'Mia', 'Medlem'), - ('http-outsider', 'Ola', 'Utenfor') - `; - yield* sql` - INSERT INTO organization_memberships (membership_id, person_id, team_id, start_at, is_team_leader) - VALUES - ('http-m-leader', 'http-leader', 'http-open', '2020-01-01', true), - ('http-m-member', 'http-member', 'http-open', '2020-01-01', false), - ('http-m-outsider', 'http-outsider', 'http-closed', '2020-01-01', true) - `; - }), - ), - ); - - const http = makeTeamApplicationsTestHttp( - Effect.runSync(decodeTeamApplicationApiConfig(environment)), - Layer.mergeAll( - database.layer, - TeamApplicationsLive().pipe(Layer.provide(database.layer)), - Layer.succeed(Identity, identity), - Layer.succeed(IdentitySnapshot, identitySnapshot), - Layer.succeed(OAuthCredentialAuthority, oauth), - ), - ); - - const request: TestRequest = (pathname, init = {}) => { - const headers = new Headers(init.headers); - - if (init.person !== undefined) { - headers.set("cookie", `better-auth.session_token=${init.person}`); - } - - if (init.method !== undefined && init.method !== "GET") { - headers.set("origin", "http://127.0.0.1:5174"); - } - - return http.fetch(new Request(`http://backend.test${pathname}`, { ...init, headers })); - }; - - const count = (table: string) => - database.run( - Database.use((sql) => - sql.unsafe<{ readonly count: number }>(`SELECT count(*)::integer AS count FROM ${table}`), - ).pipe(Effect.map((rows) => rows[0]?.count ?? 0)), - ); - - return { request, count }; -}; - -const application = { - name: "Ada Søker", - email: "ada@example.invalid", - phone: "+47 900 00 000", - yearOfStudy: "2. klasse", - fieldOfStudy: "Informatikk", - biography: "Liker å programmere.", - motivation: "Vil hjelpe elever.", -}; - -const key = (value: string) => value.padEnd(22, "0"); - -/** Decodes a response body through its declared contract schema. */ -const decoded = >( - response: Response, - schema: S, -) => - Effect.promise(() => response.json()).pipe( - Effect.flatMap((body) => - Schema.decodeUnknownEffect(schema)(body, { onExcessProperty: "error" }), - ), - ); - -const submit = ( - request: TestRequest, - team: string, - idempotencyKey: string, - body: Readonly> = application, -) => - request(`/api/teams/${team}/applications`, { - method: "POST", - headers: { "content-type": "application/json", "idempotency-key": key(idempotencyKey) }, - body: JSON.stringify(body), - }); - -describe("team application submission over HTTP", () => { - it.live("replays one key, rejects a changed request, and keeps a new key distinct", () => - Effect.gen(function* () { - const { request, count } = fixture(); - const first = yield* submit(request, "http-open", "submitA"); - const firstBody = yield* Effect.promise(() => first.text()); - - const confirmation = yield* Schema.decodeEffect( - Schema.fromJsonString(TeamApplicationConfirmation), - )(firstBody); - - expect(first.status).toBe(201); - expect(first.headers.get("location")).toBe( - `/api/team-applications/${confirmation.applicationId}`, - ); - - const replay = yield* submit(request, "http-open", "submitA"); - - expect(replay.status).toBe(201); - expect(yield* Effect.promise(() => replay.text())).toBe(firstBody); - - const changed = yield* submit(request, "http-open", "submitA", { - ...application, - name: "Bea", - }); - - expect(changed.status).toBe(409); - expect((yield* decoded(changed, TeamApplicationsSubmitProblem)).code).toBe( - "idempotency.digest-conflict", - ); - - const second = yield* submit(request, "http-open", "submitB"); - - expect(second.status).toBe(201); - expect((yield* decoded(second, TeamApplicationConfirmation)).applicationId).not.toBe( - confirmation.applicationId, - ); - expect(yield* count("team_applications")).toBe(2); - expect(yield* count("team_application_outbox")).toBe(4); - expect(yield* count("effect_queue")).toBe(4); - expect(yield* count("native_http_idempotency_receipts")).toBe(2); - }), - ); - - it.live("rejects closed and unknown teams and invalid fields without receipts", () => - Effect.gen(function* () { - const { request, count } = fixture(); - const closed = yield* submit(request, "http-closed", "closed"); - const unknown = yield* submit(request, "http-unknown", "unknown"); - - const invalid = yield* submit(request, "http-open", "invalid", { - ...application, - fieldOfStudy: "x".repeat(46), - }); - - expect(closed.status).toBe(409); - expect((yield* decoded(closed, TeamApplicationsSubmitProblem)).code).toBe( - "team-application.intake-closed", - ); - expect(unknown.status).toBe(404); - expect((yield* decoded(unknown, TeamApplicationsSubmitProblem)).code).toBe( - "resource.not-found", - ); - expect(invalid.status).toBe(422); - expect(yield* decoded(invalid, TeamApplicationsSubmitProblem)).toMatchObject({ - code: "validation.failed", - validation: { errors: [{ pointer: "/fieldOfStudy", code: "invalid" }] }, - }); - - expect(yield* count("team_applications")).toBe(0); - expect(yield* count("native_http_idempotency_receipts")).toBe(0); - }), - ); - - it.live("counts every public submission before reading its body", () => - Effect.gen(function* () { - const { request, count } = fixture({ - TEAM_APPLICATION_RATE_LIMIT_MAX: "2", - TEAM_APPLICATION_RATE_LIMIT_WINDOW_MS: "90000", - }); - - expect((yield* submit(request, "http-open", "limited")).status).toBe(201); - // A replay uses the window like any other submission. - expect((yield* submit(request, "http-open", "limited")).status).toBe(201); - - // Over the limit, even an unreadable body is refused before parsing. - const limited = yield* request("/api/teams/http-open/applications", { - method: "POST", - headers: { "content-type": "application/json", "idempotency-key": key("overLimit") }, - body: "{", - }); - - expect(limited.status).toBe(429); - expect(limited.headers.get("retry-after")).toBe("90"); - expect((yield* decoded(limited, TeamApplicationsSubmitProblem)).code).toBe( - "rate-limit.exceeded", - ); - expect((yield* submit(request, "http-open", "afterLimit")).status).toBe(429); - expect(yield* count("team_applications")).toBe(1); - expect(yield* count("native_http_idempotency_receipts")).toBe(1); - }), - ); -}); - -describe("team application staff routes over HTTP", () => { - it.live("applies current team authority to reads and leader-only deletion", () => - Effect.gen(function* () { - const { request, count } = fixture(); - - const { applicationId } = yield* decoded( - yield* submit(request, "http-open", "staff"), - TeamApplicationConfirmation, - ); - - const anonymous = yield* request("/api/teams/http-open/applications"); - - const outsider = yield* request("/api/teams/http-open/applications", { - person: "http-outsider", - }); - - const member = yield* request("/api/teams/http-open/applications", { person: "http-member" }); - - expect(anonymous.status).toBe(401); - // The security middleware declares credential problems for every staff route. - expect((yield* decoded(anonymous, SessionUnauthorizedProblem)).code).toBe( - "credential.missing", - ); - expect(outsider.status).toBe(403); - expect((yield* decoded(outsider, TeamApplicationsStaffReadProblem)).code).toBe( - "authority.denied", - ); - expect(member.status).toBe(200); - expect(yield* decoded(member, TeamApplicationListResponse)).toMatchObject({ - teamId: "http-open", - items: [{ applicationId, name: application.name }], - intake: { acceptApplication: true, open: true }, - canManage: false, - }); - - const detail = yield* request(`/api/team-applications/${applicationId}`, { - person: "http-member", - }); - - expect(detail.status).toBe(200); - expect(yield* decoded(detail, TeamApplicationResource)).toMatchObject({ - ...application, - canManage: false, - }); - - const remove = (person: string, idempotencyKey: string) => - request(`/api/team-applications/${applicationId}`, { - method: "DELETE", - person, - headers: { "idempotency-key": key(idempotencyKey) }, - }); - - const memberDelete = yield* remove("http-member", "memberDelete"); - - expect(memberDelete.status).toBe(403); - expect((yield* decoded(memberDelete, TeamApplicationsDeleteProblem)).code).toBe( - "authority.denied", - ); - expect(yield* count("team_applications")).toBe(1); - expect((yield* remove("http-leader", "leaderDelete")).status).toBe(204); - expect((yield* remove("http-leader", "leaderDelete")).status).toBe(204); - - const deletedAgain = yield* remove("http-leader", "leaderDeleteAgain"); - - expect(deletedAgain.status).toBe(404); - expect((yield* decoded(deletedAgain, TeamApplicationsDeleteProblem)).code).toBe( - "resource.not-found", - ); - expect(yield* count("team_applications")).toBe(0); - expect(yield* count("team_application_audit")).toBe(1); - }), - ); - - it.live("revises intake only at the observed entity tag and replays the result", () => - Effect.gen(function* () { - const { request } = fixture(); - - const list = yield* decoded( - yield* request("/api/teams/http-open/applications", { person: "http-leader" }), - TeamApplicationListResponse, - ); - - const revise = (ifMatch: string, idempotencyKey: string) => - request("/api/teams/http-open/application-intake", { - method: "PATCH", - person: "http-leader", - headers: { - "content-type": "application/merge-patch+json", - "idempotency-key": key(idempotencyKey), - "if-match": ifMatch, - }, - body: JSON.stringify({ acceptApplication: false }), - }); - - const stale = yield* revise('"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"', "stale"); - - expect(stale.status).toBe(412); - expect((yield* decoded(stale, TeamApplicationsReviseIntakeProblem)).code).toBe( - "precondition.failed", - ); - - const revised = yield* revise(list.intake.etag, "revise"); - const body = yield* Effect.promise(() => revised.text()); - - expect(revised.status).toBe(200); - expect( - yield* Schema.decodeEffect(Schema.fromJsonString(TeamApplicationIntakeResource))(body), - ).toEqual({ - acceptApplication: false, - deadline: null, - open: false, - revision: list.intake.revision + 1, - etag: revised.headers.get("etag"), - }); - - const replay = yield* revise(list.intake.etag, "revise"); - - expect(replay.status).toBe(200); - expect(yield* Effect.promise(() => replay.text())).toBe(body); - - const intake = yield* request("/api/teams/http-open/application-intake"); - - expect(yield* decoded(intake, PublicTeamApplicationIntake)).toMatchObject({ - teamId: "http-open", - open: false, - }); - - const afterClose = yield* submit(request, "http-open", "afterClose"); - - expect(afterClose.status).toBe(409); - expect((yield* decoded(afterClose, TeamApplicationsSubmitProblem)).code).toBe( - "team-application.intake-closed", - ); - }), - ); -}); diff --git a/apps/backend/src/team-application/rpc.test.ts b/apps/backend/src/team-application/rpc.test.ts new file mode 100644 index 000000000..ac48044af --- /dev/null +++ b/apps/backend/src/team-application/rpc.test.ts @@ -0,0 +1,409 @@ +/** + * Team applications over RPC and PostgreSQL: the public submission with its receipts and rate + * limit, and the staff reads, leader-only deletion, and intake revision at the observed entity tag. + */ +import { Database, IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; +import { TeamApplicationsLive } from "@vektorprogrammet/database/team-application"; +import { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; +import { PersonId, TeamId } from "@vektorprogrammet/domain/organization"; +import { nativeRpcPath, TeamApplicationInput } from "@vektorprogrammet/rpc"; +import { IdempotencyKey, isProblem, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Exit, Layer, Schema } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import { describe, expect, it } from "@effect/vitest"; +import { backendDatabase } from "../../test/database.js"; +import { backendTestConfig } from "../../test/config.js"; +import { decodeTeamApplicationApiConfig } from "../config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; + +const expiresAt = DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"); + +const sessionPerson = (cookie: string | undefined) => + /better-auth\.session_token=([^;]+)/u.exec(cookie ?? "")?.[1]; + +const actorFor = (personId: string) => + IdentityActor.make({ + personId: PersonId.make(personId), + sessionId: `session-${personId}`, + expiresAt, + }); + +const resolveSession = (cookie: string | undefined) => { + const person = sessionPerson(cookie); + + return person === undefined + ? Effect.fail(IdentitySessionNotFound.make({})) + : Effect.succeed(actorFor(person)); +}; + +const identity = Identity.of({ + signIn: () => Effect.die("unexpected sign-in"), + resolveSession, + readCurrentSession: () => Effect.die("unexpected session read"), + listSessions: () => Effect.die("unexpected session list"), + revokeCurrentSession: () => Effect.die("unexpected session mutation"), + revokeSession: () => Effect.die("unexpected session mutation"), + revokeOtherSessions: () => Effect.die("unexpected session mutation"), + revokeAllSessions: () => Effect.die("unexpected session mutation"), + recordSecurityEvent: () => Effect.die("unexpected identity audit"), + signOut: () => Effect.succeed({ setCookies: [] }), +} satisfies IdentityOperations); + +const identitySnapshot = IdentitySnapshot.of({ + resolveSession, + revokeCurrentSession: () => Effect.die("unexpected session mutation"), + revokeSession: () => Effect.die("unexpected session mutation"), + revokeOtherSessions: () => Effect.die("unexpected session mutation"), + revokeAllSessions: () => Effect.die("unexpected session mutation"), +}); + +const oauth = OAuthCredentialAuthority.of({ + resolve: () => Effect.die("unexpected OAuth credential resolution"), + resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), +}); + +const seed = Database.use((sql) => + Effect.gen(function* () { + yield* sql` + INSERT INTO organization_departments (department_id, name, short_name, email, city) + VALUES ('rpc-department', 'Trondheim', 'NTNU', 'department@example.invalid', 'Trondheim') + `; + yield* sql` + INSERT INTO organization_teams (team_id, department_id, name, email, accept_application, active) + VALUES + ('rpc-open', 'rpc-department', 'IT', 'it@example.invalid', true, true), + ('rpc-closed', 'rpc-department', 'Lukket', NULL, false, true) + `; + yield* sql` + INSERT INTO person_profiles (person_id, first_name, last_name) + VALUES ('rpc-leader', 'Lea', 'Leder'), ('rpc-member', 'Mia', 'Medlem'), + ('rpc-outsider', 'Ola', 'Utenfor') + `; + yield* sql` + INSERT INTO organization_memberships (membership_id, person_id, team_id, start_at, is_team_leader) + VALUES + ('rpc-m-leader', 'rpc-leader', 'rpc-open', '2020-01-01', true), + ('rpc-m-member', 'rpc-member', 'rpc-open', '2020-01-01', false), + ('rpc-m-outsider', 'rpc-outsider', 'rpc-closed', '2020-01-01', true) + `; + }), +); + +/** Each fixture is one backend process: its own database and its own public rate limit. */ +const fixture = (environment: Readonly> = {}) => + Effect.gen(function* () { + const database = backendDatabase(seed); + + const backend = makeBackendTestRpc({ + config: { + ...backendTestConfig, + teamApplication: yield* decodeTeamApplicationApiConfig(environment), + }, + services: Layer.mergeAll( + TeamApplicationsLive(), + Layer.succeed(Identity, identity), + Layer.succeed(IdentitySnapshot, identitySnapshot), + Layer.succeed(OAuthCredentialAuthority, oauth), + ).pipe(Layer.provideMerge(database.layer)), + }); + + const client = yield* backend.client; + + const count = (table: string) => + database.run( + Database.use((sql) => + sql.unsafe<{ readonly count: number }>(`SELECT count(*)::integer AS count FROM ${table}`), + ).pipe(Effect.map((rows) => rows[0]?.count ?? 0)), + ); + + return { backend, client, count }; + }); + +const as = (person: string) => + RpcClient.withHeaders({ cookie: `better-auth.session_token=${person}` }); + +const application = Schema.decodeSync(TeamApplicationInput)({ + name: "Ada Søker", + email: "ada@example.invalid", + phone: "+47 900 00 000", + yearOfStudy: "2. klasse", + fieldOfStudy: "Informatikk", + biography: "Liker å programmere.", + motivation: "Vil hjelpe elever.", +}); + +const key = (value: string) => IdempotencyKey.make(value.padEnd(22, "0")); + +const team = (value: string) => TeamId.make(value); + +/** The problem code of a failed call; a transport failure or a defect has none. */ +const codeOf = (failure: E) => (isProblem(failure) ? failure.code : "not-a-problem"); + +type Client = Effect.Success>["client"]; + +const submit = ( + client: Client, + teamId: string, + idempotencyKey: string, + request: TeamApplicationInput = application, +) => + client["team-applications.submitTeamApplication"]({ + teamId: team(teamId), + idempotencyKey: key(idempotencyKey), + request, + }); + +const RpcRequest = Schema.TaggedStruct("Request", { + id: Schema.String, + tag: Schema.String, + payload: Schema.Json, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +}); + +const RpcDefectExit = Schema.Array( + Schema.TaggedStruct("Exit", { + requestId: Schema.String, + exit: Schema.TaggedStruct("Failure", { + cause: Schema.Array(Schema.TaggedStruct("Die", { defect: Schema.Json })), + }), + }), +); + +describe("team application submission over RPC", () => { + it.live("replays one key, rejects a changed request, and keeps a new key distinct", () => + Effect.gen(function* () { + const { client, count } = yield* fixture(); + const first = yield* submit(client, "rpc-open", "submitA"); + const replay = yield* submit(client, "rpc-open", "submitA"); + + expect(replay).toEqual(first); + + const changed = yield* submit(client, "rpc-open", "submitA", { + ...application, + name: "Bea", + }).pipe(Effect.flip); + + expect(codeOf(changed)).toBe("idempotency.digest-conflict"); + + const second = yield* submit(client, "rpc-open", "submitB"); + + expect(second.applicationId).not.toBe(first.applicationId); + expect(yield* count("team_applications")).toBe(2); + expect(yield* count("team_application_outbox")).toBe(4); + expect(yield* count("effect_queue")).toBe(4); + expect(yield* count("native_http_idempotency_receipts")).toBe(2); + }), + ); + + it.live("rejects closed and unknown teams without receipts", () => + Effect.gen(function* () { + const { client, count } = yield* fixture(); + const closed = yield* submit(client, "rpc-closed", "closed").pipe(Effect.flip); + const unknown = yield* submit(client, "rpc-unknown", "unknown").pipe(Effect.flip); + + expect(codeOf(closed)).toBe("team-application.intake-closed"); + expect(codeOf(unknown)).toBe("resource.not-found"); + expect(yield* count("team_applications")).toBe(0); + expect(yield* count("native_http_idempotency_receipts")).toBe(0); + }), + ); + + it.live("fails an undecodable application in the RPC server, before quota and storage", () => + Effect.gen(function* () { + const { backend, client, count } = yield* fixture({ + TEAM_APPLICATION_RATE_LIMIT_MAX: "1", + TEAM_APPLICATION_RATE_LIMIT_WINDOW_MS: "90000", + }); + + const body = yield* Schema.encodeEffect(Schema.fromJsonString(RpcRequest))( + RpcRequest.make({ + id: "1", + tag: "team-applications.submitTeamApplication", + payload: { + teamId: "rpc-open", + idempotencyKey: key("invalid"), + request: { ...application, fieldOfStudy: "x".repeat(46) }, + }, + headers: [], + }), + ); + + const response = yield* backend.fetch( + new Request(`http://native-rpc.test${nativeRpcPath}`, { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:5174" }, + body, + }), + ); + + // The RPC server answers an undecodable payload as a defect of that request. + const answer = yield* Schema.decodeUnknownEffect(RpcDefectExit)( + yield* Effect.promise(() => response.json()), + ); + + expect(answer).toHaveLength(1); + expect(yield* count("team_applications")).toBe(0); + expect(yield* count("native_http_idempotency_receipts")).toBe(0); + + // The refused payload consumed no quota: the one submission of the window still passes. + const stored = yield* Effect.exit(submit(client, "rpc-open", "afterInvalid")); + + expect(Exit.isSuccess(stored)).toBe(true); + }), + ); + + it.live("counts every public submission, a replay included, against the rate limit", () => + Effect.gen(function* () { + const { client, count } = yield* fixture({ + TEAM_APPLICATION_RATE_LIMIT_MAX: "2", + TEAM_APPLICATION_RATE_LIMIT_WINDOW_MS: "90000", + }); + + yield* submit(client, "rpc-open", "limited"); + // A replay uses the window like any other submission. + yield* submit(client, "rpc-open", "limited"); + + const limited = yield* submit(client, "rpc-open", "afterLimit").pipe(Effect.flip); + + expect(codeOf(limited)).toBe("rate-limit.exceeded"); + expect(yield* count("team_applications")).toBe(1); + expect(yield* count("native_http_idempotency_receipts")).toBe(1); + }), + ); +}); + +describe("team application staff operations over RPC", () => { + it.live("applies current team authority to reads and leader-only deletion", () => + Effect.gen(function* () { + const { client, count } = yield* fixture(); + const { applicationId } = yield* submit(client, "rpc-open", "staff"); + + const list = (person?: string) => { + const call = client["team-applications.listTeamApplications"]({ teamId: team("rpc-open") }); + + return person === undefined ? call : call.pipe(as(person)); + }; + + expect(codeOf(yield* list().pipe(Effect.flip))).toBe("credential.missing"); + expect(codeOf(yield* list("rpc-outsider").pipe(Effect.flip))).toBe("authority.denied"); + expect(yield* list("rpc-member")).toMatchObject({ + teamId: "rpc-open", + items: [{ applicationId, name: application.name }], + intake: { acceptApplication: true, open: true }, + canManage: false, + }); + + const detail = yield* client["team-applications.readTeamApplication"]({ + applicationId, + }).pipe(as("rpc-member")); + + expect(detail).toMatchObject({ ...application, canManage: false }); + + const remove = (person: string, idempotencyKey: string) => + client["team-applications.deleteTeamApplication"]({ + applicationId, + idempotencyKey: key(idempotencyKey), + }).pipe(as(person)); + + expect(codeOf(yield* remove("rpc-member", "memberDelete").pipe(Effect.flip))).toBe( + "authority.denied", + ); + expect(yield* count("team_applications")).toBe(1); + + yield* remove("rpc-leader", "leaderDelete"); + // The replay answers the stored no-content receipt. + yield* remove("rpc-leader", "leaderDelete"); + + expect(codeOf(yield* remove("rpc-leader", "leaderDeleteAgain").pipe(Effect.flip))).toBe( + "resource.not-found", + ); + expect(yield* count("team_applications")).toBe(0); + expect(yield* count("team_application_audit")).toBe(1); + }), + ); + + it.live("revises intake only at the observed entity tag and replays the result", () => + Effect.gen(function* () { + const { client } = yield* fixture(); + + const list = yield* client["team-applications.listTeamApplications"]({ + teamId: team("rpc-open"), + }).pipe(as("rpc-leader")); + + const revise = ( + ifMatch: StrongETag, + idempotencyKey: string, + request: { readonly acceptApplication?: boolean | null } = { acceptApplication: false }, + ) => + client["team-applications.reviseTeamApplicationIntake"]({ + teamId: team("rpc-open"), + idempotencyKey: key(idempotencyKey), + ifMatch, + request, + }).pipe(as("rpc-leader")); + + const stale = yield* revise( + StrongETag.make('"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"'), + "stale", + ).pipe(Effect.flip); + + expect(codeOf(stale)).toBe("precondition.failed"); + expect(codeOf(yield* revise(list.intake.etag, "noChange", {}).pipe(Effect.flip))).toBe( + "validation.no-change", + ); + expect( + codeOf( + yield* revise(list.intake.etag, "deleteAccept", { acceptApplication: null }).pipe( + Effect.flip, + ), + ), + ).toBe("validation.field-not-deletable"); + + const revised = yield* revise(list.intake.etag, "revise"); + + expect(revised).toEqual({ + acceptApplication: false, + deadline: null, + open: false, + revision: list.intake.revision + 1, + etag: revised.etag, + }); + expect(revised.etag).not.toBe(list.intake.etag); + expect(yield* revise(list.intake.etag, "revise")).toEqual(revised); + + // A member reads the intake but cannot revise it. + expect( + codeOf( + yield* client["team-applications.reviseTeamApplicationIntake"]({ + teamId: team("rpc-open"), + idempotencyKey: key("memberRevise"), + ifMatch: revised.etag, + request: { acceptApplication: true }, + }).pipe(as("rpc-member"), Effect.flip), + ), + ).toBe("authority.denied"); + + const intake = yield* client["team-applications.readTeamApplicationIntake"]({ + teamId: team("rpc-open"), + }); + + expect(intake).toMatchObject({ teamId: "rpc-open", open: false }); + + const intakes = yield* client["team-applications.listTeamApplicationIntakes"](); + + expect(intakes.map(({ teamId, open }) => [teamId, open])).toEqual([ + ["rpc-closed", false], + ["rpc-open", false], + ]); + + const afterClose = yield* submit(client, "rpc-open", "afterClose").pipe(Effect.flip); + + expect(codeOf(afterClose)).toBe("team-application.intake-closed"); + }), + ); +}); diff --git a/apps/backend/src/team-application/http.ts b/apps/backend/src/team-application/rpc.ts similarity index 53% rename from apps/backend/src/team-application/http.ts rename to apps/backend/src/team-application/rpc.ts index ce0cbbb46..a9b2bbe84 100644 --- a/apps/backend/src/team-application/http.ts +++ b/apps/backend/src/team-application/rpc.ts @@ -1,3 +1,15 @@ +/** + * The TeamApplicationsRpcs handlers, ported from the HTTP handlers of + * `apps/backend/src/team-application/http.ts` at the base commit that docs/specs/rpc-only.md names. + * + * The two public reads and the public submission evaluate their anonymous AccessSpec with + * `authorizeAnonymous`; the submission also counts against the per-process public rate limit + * before it runs. A staff read resolves the credential and the team authority in one + * repeatable-read snapshot. A staff command resolves them inside the serializable transaction that + * commits it, where `TeamApplications.authorize` mints its `TeamApplicationAuthorization` evidence + * before any stored response can be replayed. Every command stores its first answer as a command + * receipt under the HTTP route it replaced, so a retry that straddles the cutover replays it. + */ import { Database } from "@vektorprogrammet/database"; import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; import { @@ -9,10 +21,10 @@ import { import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; import type { TeamId } from "@vektorprogrammet/domain/organization"; import { + ReviseTeamApplicationIntakeCommand, TeamApplicationAction, TeamApplicationCommandId, TeamApplications, - ReviseTeamApplicationIntakeCommand, type TeamApplicationActor, type TeamApplicationDeleteFailure, type TeamApplicationId, @@ -24,65 +36,65 @@ import { type TeamApplicationSubmitFailure, } from "@vektorprogrammet/domain/team-application"; import { - DeleteTeamApplicationEndpoint, - ExternalNativeApi, - ListTeamApplicationIntakesEndpoint, - ListTeamApplicationsEndpoint, - ReadTeamApplicationEndpoint, - ReadTeamApplicationIntakeEndpoint, + DeleteTeamApplication, + ListTeamApplicationIntakes, + ListTeamApplications, + PublicTeamApplicationIntake, + ReadTeamApplication, + ReadTeamApplicationIntake, reflectAccessSpec, - ReviseTeamApplicationIntakeEndpoint, - SubmitTeamApplicationEndpoint, - TeamApplicationInput, - TeamApplicationIntakeMergePatch, -} from "@vektorprogrammet/http-api"; + ReviseTeamApplicationIntake, + SubmitTeamApplication, + TeamApplicationConfirmation, + TeamApplicationIntakeListResponse, + TeamApplicationIntakeResource, + TeamApplicationListResponse, + TeamApplicationResource, + TeamApplicationsRpcs, + type TeamApplicationInput, + type TeamApplicationIntakeMergePatch, +} from "@vektorprogrammet/rpc"; import { type CredentialPresentation, + type IdempotencyKey, makeNativeValidationError, - type NativeValidationError, Problem, -} from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Option, Predicate, Schema } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; + type StrongETag, +} from "@vektorprogrammet/rpc/problem"; +import { Effect, Match, Option, Predicate, Schema } from "effect"; +import type { Headers } from "effect/unstable/http"; +import type { Rpc } from "effect/unstable/rpc"; import { currentInstant, resolveRequestCredentialInTransaction } from "../authority.js"; -import type { TeamApplicationApiConfig } from "../config.js"; +import { + deriveStrongETag, + encodePathIdentity, + jsonBodyBytes, + normalizeTarget, + semanticMutationRequest, + semanticRequestDigest, +} from "../http-semantics.js"; +import { genericContext } from "../native-operation.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import type { NativeRpcOptions } from "../rpc/options.js"; import { authorizeAnonymous, authorizePerson, - commandOutcomeResponse, + commandIdentity, + commandOutcome, commandReceiptProblems, - httpIdentity, - idempotencyKeyOf, personPresentation, problemMapper, - readJsonBody, + requestInvalid, requireCurrentETag, - requiredIfMatchOf, - requireNoQuery, + strictOutput, unreachable, - webHandler, -} from "../http-api/problem.js"; -import { publicRateLimitKey } from "../http-api/public-rate-limit.js"; +} from "../rpc/problem.js"; +import { publicRateLimitKey } from "../rpc/public-rate-limit.js"; import { executeNativeHttpCommandPostgres, + type NativeHttpCommandOutcome, type NativeHttpResponseCapsule, -} from "../http-api/receipt-transaction.js"; -import { - deriveStrongETag, - encodePathIdentity, - jsonBodyBytes, - normalizeTarget, - NO_STORE, - PRIVATE_NO_STORE, - semanticMutationRequest, - semanticRequestDigest, -} from "../http-semantics.js"; -import { genericContext } from "../native-operation.js"; - -/** Seven bounded fields; the free-text fields dominate at 10 000 characters each. */ -const MAX_SUBMISSION_BYTES = 131_072; - -const MAX_INTAKE_PATCH_BYTES = 4_096; +} from "../rpc/receipt-transaction.js"; type AcceptedCredential = Extract; @@ -91,12 +103,6 @@ interface StaffPrincipal { readonly principal: TeamApplicationPrincipal; } -type StaffEndpoint = - | typeof ListTeamApplicationsEndpoint - | typeof ReadTeamApplicationEndpoint - | typeof DeleteTeamApplicationEndpoint - | typeof ReviseTeamApplicationIntakeEndpoint; - /** The one answer for every team-application domain failure. */ const teamApplicationProblems = problemMapper< | TeamApplicationPublicReadFailure @@ -122,15 +128,6 @@ const staffCredentialProblems = (presentation: CredentialPresentation) => IdentityEngineError: () => Problem.make("internal.error"), }); -const json = (body: Schema.Json, cacheControl: string) => - new Response(JSON.stringify(body), { - headers: { - "content-type": "application/json", - "cache-control": cacheControl, - vary: "Origin", - }, - }); - const intakeETag = (teamId: TeamId, revision: number) => deriveStrongETag({ representationKind: "TeamApplicationIntake", @@ -138,11 +135,15 @@ const intakeETag = (teamId: TeamId, revision: number) => version: revision, }); -const intakeResource = (teamId: TeamId, intake: TeamApplicationIntake) => ({ +const intakeResource = ( + teamId: TeamId, + intake: TeamApplicationIntake, +): typeof TeamApplicationIntakeResource.Type => ({ ...intake, etag: intakeETag(teamId, intake.revision), }); +/** The HTTP capsule a command receipt stores, byte for byte what the HTTP handler stored. */ const jsonCapsule = ( status: 200 | 201, body: Schema.Json, @@ -154,15 +155,39 @@ const jsonCapsule = ( bodyBytes: jsonBodyBytes(body), }); +/** The receipt of a deletion: the no-content answer the HTTP contract stored. */ +const noContent: NativeHttpResponseCapsule = { + status: 204, + mediaType: null, + headers: {}, + bodyBytes: null, +}; + +/** + * A deletion answers nothing: a committed or replayed receipt succeeds with no value, and every + * other outcome is its idempotency problem. + */ +const noContentOutcome = (outcome: NativeHttpCommandOutcome) => + Match.value(outcome).pipe( + Match.tag("Committed", "Replay", () => Effect.void), + Match.tag("InFlight", () => Effect.fail(Problem.make("idempotency.in-flight"))), + Match.tag("DigestConflict", () => Effect.fail(Problem.make("idempotency.digest-conflict"))), + Match.tag("ResponseExpired", () => Effect.fail(Problem.make("idempotency.response-expired"))), + Match.exhaustive, + ); + /** Resolves the current Person credential through the caller's transaction at one instant. */ -const staffPrincipal = (request: Request) => +const staffPrincipal = (headers: Headers.Headers) => Effect.gen(function* () { - const authenticated = yield* resolveRequestCredentialInTransaction(request, "OAuthUserBearer"); + const authenticated = yield* resolveRequestCredentialInTransaction( + credentialRequestOf(headers), + "OAuthUserBearer", + ); const principal = authenticated.credential.principal; if (!Predicate.isTagged(principal, "Person")) { - return yield* new UnauthenticatedActor({ message: "authentication required" }); + return yield* UnauthenticatedActor.make({ message: "authentication required" }); } return { @@ -175,26 +200,26 @@ const staffPrincipal = (request: Request) => }); /** Evaluates the declared AccessSpec against the team the domain actor was resolved for. */ -const authorizeStaff = ( - endpoint: StaffEndpoint, - staff: StaffPrincipal, - actor: TeamApplicationActor, - selection: "ExactlyOne" | "AllMatching", - presentation: CredentialPresentation, -) => +const authorizeStaff = (input: { + readonly rpc: Pick; + readonly staff: StaffPrincipal; + readonly actor: TeamApplicationActor; + readonly selection: "ExactlyOne" | "AllMatching"; + readonly presentation: CredentialPresentation; +}) => authorizePerson( { - spec: Option.getOrThrow(reflectAccessSpec(endpoint)), - credential: staff.credential, - personId: staff.principal.personId, + spec: Option.getOrThrow(reflectAccessSpec(input.rpc)), + credential: input.staff.credential, + personId: input.staff.principal.personId, resolution: { - selection, + selection: input.selection, contexts: [ genericContext({ domainId: "team-applications", resourceKind: "organization-team", - resourceId: actor.teamId, - authorityVersion: `team-applications:${actor._tag}:${staff.principal.authorizationInstant}`, + resourceId: input.actor.teamId, + authorityVersion: `team-applications:${input.actor._tag}:${input.staff.principal.authorizationInstant}`, }), ], }, @@ -202,13 +227,13 @@ const authorizeStaff = ( Scope.Resource({ resource: { kind: ResourceKind.make("organization-team"), - id: ResourceId.make(actor.teamId), + id: ResourceId.make(input.actor.teamId), }, }), ], - now: staff.principal.authorizationInstant, + now: input.staff.principal.authorizationInstant, }, - presentation, + input.presentation, ); const snapshotRead = (effect: Effect.Effect) => @@ -222,38 +247,12 @@ const snapshotRead = (effect: Effect.Effect) => ), ).pipe(Effect.catchTag("SqlError", () => Effect.fail(Problem.make("internal.error")))); -const JsonObject = Schema.Record(Schema.String, Schema.Json); - -/** Field-level pointers for a rejected submission, without echoing rejected values. */ -const submissionErrors = (body: Schema.Json): ReadonlyArray => { - const record = Schema.decodeUnknownOption(JsonObject)(body); - - if (Option.isNone(record)) return [makeNativeValidationError("", "invalid")]; - - const errors: Array = []; - - if (Object.keys(record.value).some((key) => !Object.hasOwn(TeamApplicationInput.fields, key))) { - errors.push(makeNativeValidationError("", "unknown")); - } - - for (const [field, schema] of Object.entries(TeamApplicationInput.fields)) { - if (!Object.hasOwn(record.value, field)) { - errors.push(makeNativeValidationError(`/${field}`, "missing")); - } else if (!Schema.is(schema)(record.value[field])) { - errors.push(makeNativeValidationError(`/${field}`, "invalid")); - } - } - - return errors; -}; - -const readIntake = (request: Request, teamId: TeamId) => +const readIntake = (teamId: TeamId) => Effect.gen(function* () { - yield* requireNoQuery(request); const instant = yield* currentInstant(undefined); yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(ReadTeamApplicationIntakeEndpoint)), + Option.getOrThrow(reflectAccessSpec(ReadTeamApplicationIntake)), { selection: "ExactlyOne", contexts: [ @@ -274,63 +273,53 @@ const readIntake = (request: Request, teamId: TeamId) => unreachable("transaction.conflict"), ); - return json(intake, NO_STORE); + return yield* strictOutput(PublicTeamApplicationIntake)(intake); }); -const listIntakes = (request: Request) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const instant = yield* currentInstant(undefined); +const listIntakes = Effect.gen(function* () { + const instant = yield* currentInstant(undefined); - yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(ListTeamApplicationIntakesEndpoint)), - { - selection: "AllMatching", - contexts: [ - genericContext({ - domainId: "team-applications", - authorityVersion: `team-application-intakes:${instant}`, - }), - ], - }, - instant, - ); + yield* authorizeAnonymous( + Option.getOrThrow(reflectAccessSpec(ListTeamApplicationIntakes)), + { + selection: "AllMatching", + contexts: [ + genericContext({ + domainId: "team-applications", + authorityVersion: `team-application-intakes:${instant}`, + }), + ], + }, + instant, + ); - const intakes = yield* TeamApplications.use((service) => service.listPublicIntakes).pipe( - teamApplicationProblems, - unreachable("transaction.conflict"), - ); + const intakes = yield* TeamApplications.use((service) => service.listPublicIntakes).pipe( + teamApplicationProblems, + unreachable("transaction.conflict"), + ); - return json(intakes, NO_STORE); - }); + return yield* strictOutput(TeamApplicationIntakeListResponse)(intakes); +}); -const submit = (request: Request, teamId: TeamId, config: TeamApplicationApiConfig) => +const submit = ( + headers: Headers.Headers, + payload: { + readonly teamId: TeamId; + readonly idempotencyKey: IdempotencyKey; + readonly request: TeamApplicationInput; + }, + options: NativeRpcOptions, +) => Effect.gen(function* () { - yield* requireNoQuery(request); - + const { teamId, idempotencyKey, request } = payload; + const config = options.config.teamApplication; const arrivedAt = yield* currentInstant(undefined); - // Counted before the body is read, so an over-limit caller costs no parsing or storage. - if (!config.rateLimit.consume(publicRateLimitKey(request), arrivedAt)) { + // Counted before the command runs; a replay uses the window like any other submission. + if (!config.rateLimit.consume(publicRateLimitKey(credentialRequestOf(headers)), arrivedAt)) { return yield* Problem.rateLimited(config.retryAfterSeconds); } - const body = yield* readJsonBody( - request, - /^application\/json(?:\s*;|$)/iu, - MAX_SUBMISSION_BYTES, - ); - - const decoded = Schema.decodeUnknownOption(TeamApplicationInput)(body, { - onExcessProperty: "error", - }); - - if (Option.isNone(decoded)) { - return yield* Problem.validation("validation.failed", submissionErrors(body)); - } - - const idempotencyKey = yield* idempotencyKeyOf(request); - const operationId = "team-applications.submitTeamApplication"; // Domain failures are mapped after the executor, whose retry reads their causes. @@ -339,7 +328,7 @@ const submit = (request: Request, teamId: TeamId, config: TeamApplicationApiConf const instant = yield* currentInstant(undefined); yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(SubmitTeamApplicationEndpoint)), + Option.getOrThrow(reflectAccessSpec(SubmitTeamApplication)), { selection: "ExactlyOne", contexts: [ @@ -354,7 +343,8 @@ const submit = (request: Request, teamId: TeamId, config: TeamApplicationApiConf instant, ); - const identity = yield* httpIdentity({ + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ credentialSubject: "Anonymous", qualifiedOperationId: operationId, normalizedTarget: normalizeTarget("/api/teams/{teamId}/applications", { teamId }), @@ -364,17 +354,20 @@ const submit = (request: Request, teamId: TeamId, config: TeamApplicationApiConf return { identity: { identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({ body: decoded.value }), + requestSha256: semanticRequestDigest({ body: request }), operationId, }, execute: TeamApplications.use((service) => service.submit({ commandId: TeamApplicationCommandId.make(identity.commandId), teamId, - application: decoded.value, + application: request, }), ).pipe( - Effect.map(({ confirmation }) => + Effect.flatMap(({ confirmation }) => + strictOutput(TeamApplicationConfirmation)(confirmation), + ), + Effect.map((confirmation) => jsonCapsule(201, confirmation, { etag: deriveStrongETag({ representationKind: "TeamApplicationConfirmation", @@ -390,39 +383,34 @@ const submit = (request: Request, teamId: TeamId, config: TeamApplicationApiConf { retry: "serialization-once" }, ).pipe(teamApplicationProblems, commandReceiptProblems); - return yield* commandOutcomeResponse(outcome); - }); - -const cursorQuery = (request: Request) => - Effect.suspend(() => { - const parameters = new URL(request.url).searchParams; - const keys = [...parameters.keys()]; - - return keys.some((key) => key !== "cursor") || parameters.getAll("cursor").length > 1 - ? Effect.fail(Problem.make("request.malformed")) - : Effect.succeed(parameters.get("cursor") ?? undefined); - }); + return yield* commandOutcome(TeamApplicationConfirmation)(outcome); + }).pipe( + // A submission reads no page. + unreachable("request.malformed"), + ); -const listApplications = (request: Request, teamId: TeamId) => { - const presentation = personPresentation(request); +const listApplications = ( + headers: Headers.Headers, + payload: { readonly teamId: TeamId; readonly cursor?: string | undefined }, +) => { + const presentation = personPresentation(headers); return snapshotRead( Effect.gen(function* () { - const cursor = yield* cursorQuery(request); - const staff = yield* staffPrincipal(request); + const staff = yield* staffPrincipal(headers); // A read-only snapshot cannot lose a serialization conflict. const page = yield* TeamApplications.use((service) => - service.listApplications(staff.principal, teamId, cursor), + service.listApplications(staff.principal, payload.teamId, payload.cursor), ).pipe(teamApplicationProblems, unreachable("transaction.conflict")); - yield* authorizeStaff( - ListTeamApplicationsEndpoint, + yield* authorizeStaff({ + rpc: ListTeamApplications, staff, - page.actor, - "AllMatching", + actor: page.actor, + selection: "AllMatching", presentation, - ); + }); const body = { teamId: page.teamId, @@ -432,78 +420,76 @@ const listApplications = (request: Request, teamId: TeamId) => { canManage: Predicate.isTagged(page.actor, "TeamLeader"), }; - return json( + return yield* strictOutput(TeamApplicationListResponse)( page.nextCursor === undefined ? body : { ...body, nextCursor: page.nextCursor }, - PRIVATE_NO_STORE, ); }), ).pipe(staffCredentialProblems(presentation)); }; -const readApplication = (request: Request, applicationId: TeamApplicationId) => { - const presentation = personPresentation(request); +const readApplication = (headers: Headers.Headers, applicationId: TeamApplicationId) => { + const presentation = personPresentation(headers); return snapshotRead( Effect.gen(function* () { - yield* requireNoQuery(request); - const staff = yield* staffPrincipal(request); + const staff = yield* staffPrincipal(headers); - // A read-only snapshot cannot lose a serialization conflict. + // A read-only snapshot cannot lose a serialization conflict, and a read by identifier + // takes no cursor. const view = yield* TeamApplications.use((service) => service.readApplication(staff.principal, applicationId), - ).pipe(teamApplicationProblems, unreachable("transaction.conflict")); + ).pipe(teamApplicationProblems, unreachable("transaction.conflict", "request.malformed")); - yield* authorizeStaff( - ReadTeamApplicationEndpoint, + yield* authorizeStaff({ + rpc: ReadTeamApplication, staff, - view.actor, - "ExactlyOne", + actor: view.actor, + selection: "ExactlyOne", presentation, - ); + }); - return json( - { - ...view.application, - teamName: view.teamName, - canManage: Predicate.isTagged(view.actor, "TeamLeader"), - }, - PRIVATE_NO_STORE, - ); + return yield* strictOutput(TeamApplicationResource)({ + ...view.application, + teamName: view.teamName, + canManage: Predicate.isTagged(view.actor, "TeamLeader"), + }); }), ).pipe(staffCredentialProblems(presentation)); }; -const deleteApplication = (request: Request, applicationId: TeamApplicationId) => +const deleteApplication = ( + headers: Headers.Headers, + payload: { readonly applicationId: TeamApplicationId; readonly idempotencyKey: IdempotencyKey }, +) => Effect.gen(function* () { - yield* requireNoQuery(request); - - const idempotencyKey = yield* idempotencyKeyOf(request); - const presentation = personPresentation(request); + const { applicationId, idempotencyKey } = payload; + const presentation = personPresentation(headers); const operationId = "team-applications.deleteTeamApplication"; // Domain and credential failures are mapped after the executor, whose retry reads their causes. const outcome = yield* executeNativeHttpCommandPostgres( Effect.gen(function* () { - const staff = yield* staffPrincipal(request); + const staff = yield* staffPrincipal(headers); // Authority is current before any stored response can be replayed. - const actor = yield* TeamApplications.use((service) => + const authorization = yield* TeamApplications.use((service) => service.authorize( staff.principal, TeamApplicationAction.DeleteTeamApplication({ applicationId }), ), ); - yield* authorizeStaff( - DeleteTeamApplicationEndpoint, + yield* authorizeStaff({ + rpc: DeleteTeamApplication, staff, - actor, - "ExactlyOne", + actor: authorization.actor, + selection: "ExactlyOne", presentation, - ); + }); - const identity = yield* httpIdentity({ + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ credentialSubject: `Person:${staff.principal.personId}`, qualifiedOperationId: operationId, normalizedTarget: normalizeTarget("/api/team-applications/{applicationId}", { @@ -519,89 +505,86 @@ const deleteApplication = (request: Request, applicationId: TeamApplicationId) = operationId, }, execute: TeamApplications.use((service) => - service.deleteApplication( - { commandId: TeamApplicationCommandId.make(identity.commandId), applicationId }, - staff.principal, - ), - ).pipe( - Effect.as({ - status: 204, - mediaType: null, - headers: {}, - bodyBytes: null, + service.deleteApplication(authorization, { + commandId: TeamApplicationCommandId.make(identity.commandId), }), - ), + ).pipe(Effect.as(noContent)), }; }), { retry: "serialization-once" }, ).pipe(teamApplicationProblems, commandReceiptProblems, staffCredentialProblems(presentation)); - return yield* commandOutcomeResponse(outcome); - }); - -const reviseIntake = (request: Request, teamId: TeamId) => - Effect.gen(function* () { - yield* requireNoQuery(request); + return yield* noContentOutcome(outcome); + }).pipe( + // A deletion reads no page. + unreachable("request.malformed"), + ); - const body = yield* readJsonBody( - request, - /^application\/merge-patch\+json(?:\s*;|$)/iu, - MAX_INTAKE_PATCH_BYTES, +/** The merge-patch rules the HTTP handler applied before any authority or receipt work. */ +const requireApplicablePatch = ( + patch: TeamApplicationIntakeMergePatch, +): Effect.Effect< + void, + Problem<"validation.no-change"> | Problem<"validation.field-not-deletable"> +> => { + if (patch.acceptApplication === undefined && patch.deadline === undefined) { + return Effect.fail( + Problem.validation("validation.no-change", [makeNativeValidationError("", "no-change")]), ); + } - const decoded = Schema.decodeUnknownOption(TeamApplicationIntakeMergePatch)(body, { - onExcessProperty: "error", - }); - - if (Option.isNone(decoded)) { - return yield* Problem.validation("validation.failed", [ - makeNativeValidationError("", "invalid"), - ]); - } - - const patch = decoded.value; + if (patch.acceptApplication === null) { + return Effect.fail( + Problem.validation("validation.field-not-deletable", [ + makeNativeValidationError("/acceptApplication", "field-not-deletable"), + ]), + ); + } - if (patch.acceptApplication === undefined && patch.deadline === undefined) { - return yield* Problem.validation("validation.no-change", [ - makeNativeValidationError("", "no-change"), - ]); - } + return Effect.void; +}; - if (patch.acceptApplication === null) { - return yield* Problem.validation("validation.field-not-deletable", [ - makeNativeValidationError("/acceptApplication", "field-not-deletable"), - ]); - } +const reviseIntake = ( + headers: Headers.Headers, + payload: { + readonly teamId: TeamId; + readonly idempotencyKey: IdempotencyKey; + readonly ifMatch: StrongETag; + readonly request: TeamApplicationIntakeMergePatch; + }, +) => + Effect.gen(function* () { + const { teamId, idempotencyKey, ifMatch, request: patch } = payload; - const ifMatch = yield* requiredIfMatchOf(request); + yield* requireApplicablePatch(patch); - const idempotencyKey = yield* idempotencyKeyOf(request); - const presentation = personPresentation(request); + const presentation = personPresentation(headers); const operationId = "team-applications.reviseTeamApplicationIntake"; // Domain and credential failures are mapped after the executor, whose retry reads their causes. const outcome = yield* executeNativeHttpCommandPostgres( Effect.gen(function* () { - const staff = yield* staffPrincipal(request); + const staff = yield* staffPrincipal(headers); // Authority is current before any stored response can be replayed. - const actor = yield* TeamApplications.use((service) => + const authorization = yield* TeamApplications.use((service) => service.authorize( staff.principal, TeamApplicationAction.ReviseTeamApplicationIntake({ teamId }), ), ); - yield* authorizeStaff( - ReviseTeamApplicationIntakeEndpoint, + yield* authorizeStaff({ + rpc: ReviseTeamApplicationIntake, staff, - actor, - "ExactlyOne", + actor: authorization.actor, + selection: "ExactlyOne", presentation, - ); + }); - const identity = yield* httpIdentity({ + // The HTTP route stays the normalized target, so receipts and command IDs are stable. + const identity = yield* commandIdentity({ credentialSubject: `Person:${staff.principal.personId}`, qualifiedOperationId: operationId, normalizedTarget: normalizeTarget("/api/teams/{teamId}/application-intake", { @@ -613,20 +596,18 @@ const reviseIntake = (request: Request, teamId: TeamId) => const command = yield* Schema.decodeUnknownEffect(ReviseTeamApplicationIntakeCommand)( { ...patch, commandId: identity.commandId, teamId }, { onExcessProperty: "error" }, - ).pipe( - Effect.mapError(() => - Problem.validation("validation.failed", [makeNativeValidationError("", "invalid")]), - ), - ); + ).pipe(Effect.mapError(requestInvalid)); return { identity: { identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest(semanticMutationRequest(body, ifMatch)), + // The decoded patch is the JSON body that the HTTP handler digested, for every body + // that the schema accepts without excess members. + requestSha256: semanticRequestDigest(semanticMutationRequest(patch, ifMatch)), operationId, }, execute: TeamApplications.use((service) => - service.reviseIntake(command, staff.principal, (current) => + service.reviseIntake(authorization, command, (current) => requireCurrentETag(intakeETag(teamId, current.revision), ifMatch), ), ).pipe( @@ -643,36 +624,27 @@ const reviseIntake = (request: Request, teamId: TeamId) => teamApplicationProblems, commandReceiptProblems, staffCredentialProblems(presentation), - // authorize answers an unknown team on revise with an authority denial. - unreachable("resource.not-found"), + // authorize answers an unknown team on revise with an authority denial, and a revision + // reads no page. + unreachable("resource.not-found", "request.malformed"), ); - return yield* commandOutcomeResponse(outcome); + return yield* commandOutcome(TeamApplicationIntakeResource)(outcome); }); -/** Native HttpApi handlers for public team intake and staff review. */ -export const TeamApplicationsApiHandlers = (config: TeamApplicationApiConfig) => - HttpApiBuilder.group(ExternalNativeApi, "team-applications", (handlers) => - Effect.succeed( - handlers - .handleRaw("readTeamApplicationIntake", ({ request, params }) => - webHandler(request, (webRequest) => readIntake(webRequest, params.teamId)), - ) - .handleRaw("listTeamApplicationIntakes", ({ request }) => webHandler(request, listIntakes)) - .handleRaw("submitTeamApplication", ({ request, params }) => - webHandler(request, (webRequest) => submit(webRequest, params.teamId, config)), - ) - .handleRaw("listTeamApplications", ({ request, params }) => - webHandler(request, (webRequest) => listApplications(webRequest, params.teamId)), - ) - .handleRaw("readTeamApplication", ({ request, params }) => - webHandler(request, (webRequest) => readApplication(webRequest, params.applicationId)), - ) - .handleRaw("deleteTeamApplication", ({ request, params }) => - webHandler(request, (webRequest) => deleteApplication(webRequest, params.applicationId)), - ) - .handleRaw("reviseTeamApplicationIntake", ({ request, params }) => - webHandler(request, (webRequest) => reviseIntake(webRequest, params.teamId)), - ), - ), - ); +/** The TeamApplicationsRpcs handlers. */ +export const TeamApplicationsRpcHandlers = (options: NativeRpcOptions) => + TeamApplicationsRpcs.toLayer({ + "team-applications.readTeamApplicationIntake": ({ teamId }) => readIntake(teamId), + "team-applications.listTeamApplicationIntakes": () => listIntakes, + "team-applications.submitTeamApplication": (payload, { headers }) => + submit(headers, payload, options), + "team-applications.listTeamApplications": (payload, { headers }) => + listApplications(headers, payload), + "team-applications.readTeamApplication": ({ applicationId }, { headers }) => + readApplication(headers, applicationId), + "team-applications.deleteTeamApplication": (payload, { headers }) => + deleteApplication(headers, payload), + "team-applications.reviseTeamApplicationIntake": (payload, { headers }) => + reviseIntake(headers, payload), + }); diff --git a/apps/backend/src/team-application/worker.ts b/apps/backend/src/team-application/worker.ts index 1c10ced3d..0a5b5374b 100644 --- a/apps/backend/src/team-application/worker.ts +++ b/apps/backend/src/team-application/worker.ts @@ -5,6 +5,7 @@ import { } from "@vektorprogrammet/domain/team-application"; import { Duration, Effect, Predicate, Schedule } from "effect"; import type { TeamApplicationDeliveryConfig } from "../config.js"; +import { dual } from "effect/Function"; /** * Queue settings of a delivering process. The lease refresh runs three times per stale @@ -38,12 +39,7 @@ export const runTeamApplicationDeliveryWorker = (sender: string) => ), ); -/** - * Attempts at most `limit` notifications for a scheduler that starts delivery instead of - * a resident worker, and stops early when no notification was taken within `idle`. Then - * it removes completed queue items past their retention. - */ -export const drainTeamApplicationOutbox = ( +const drainTeamApplicationOutboxEffect = ( sender: string, options: { readonly limit: number; readonly idle: Duration.Duration }, ) => @@ -63,3 +59,19 @@ export const drainTeamApplicationOutbox = ( return outcomes; }); + +/** + * Attempts at most `limit` notifications for a scheduler that starts delivery instead of + * a resident worker, and stops early when no notification was taken within `idle`. Then + * it removes completed queue items past their retention. + */ +export const drainTeamApplicationOutbox: { + (options: { + readonly limit: number; + readonly idle: Duration.Duration; + }): (sender: string) => ReturnType; + ( + sender: string, + options: { readonly limit: number; readonly idle: Duration.Duration }, + ): ReturnType; +} = dual(2, drainTeamApplicationOutboxEffect); diff --git a/apps/backend/src/test/AGENTS.md b/apps/backend/src/test/AGENTS.md index 7e0df3fee..9cf3a3ae0 100644 --- a/apps/backend/src/test/AGENTS.md +++ b/apps/backend/src/test/AGENTS.md @@ -2,8 +2,8 @@ # apps/backend/src/test -This folder is not a bounded context. Native HTTP composition for backend tests. -The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit. +This folder is not a bounded context. Native RPC composition for backend tests. +The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit. ## Entry points diff --git a/apps/backend/src/test/native-http.ts b/apps/backend/src/test/native-http.ts deleted file mode 100644 index 51564e43b..000000000 --- a/apps/backend/src/test/native-http.ts +++ /dev/null @@ -1,484 +0,0 @@ -import { Database, IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { Placements } from "@vektorprogrammet/domain/placements"; -import { TeamApplications } from "@vektorprogrammet/domain/team-application"; -import { - Admissions, - ReturningAssistants, - Organization, - Profile, - Schools, - Recruitment, - SocialEvents, - Mail, - ServicePrincipalGrantAuthority, -} from "@vektorprogrammet/domain"; -import { Identity, IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import { - Economy, - ReceiptAuxiliaryEffects, - ReceiptFileService, -} from "@vektorprogrammet/domain/receipt"; -import { Content, ContentManagement } from "@vektorprogrammet/domain/content"; -import { backendDatabase } from "../../test/database.js"; -import * as BunHttpPlatform from "@effect/platform-bun/BunHttpPlatform"; -import { TestPlatform } from "./platform.js"; - -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { - CertificatesApi, - ContentApi, - DirectoryApi, - InternalReceiptsApi, - OrganizationApi, - ProfileApi, - ReceiptsApi, - RecruitmentApi, - RequestSchemaErrorMiddleware, - TeamApplicationsApi, -} from "@vektorprogrammet/http-api"; -import { TeamApplicationsApiHandlers } from "../team-application/http.js"; -import { CertificateFontsLive } from "../placements/certificate-pdf.js"; -import { CertificatesApiHandlers } from "../placements/certificates-http.js"; -import { Context, Effect, Layer, Option, type Crypto, type FileSystem, type Path } from "effect"; -import { - Etag, - HttpRouter, - HttpServerResponse, - type HttpClient, - type HttpPlatform, -} from "effect/unstable/http"; -import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; -import { DirectoryApiHandlers, type DirectoryApiHttpOptions } from "../directory/http.js"; -import { ContentApiHandlers } from "../content/http.js"; -import type { ContentRequestActor } from "../content/http-context.js"; -import { ProblemBoundaryLive } from "../http-api/problem.js"; -import { NativeHttpApiMiddlewareLive } from "../http-api/transport.js"; -import { OrganizationApiHandlers, type OrganizationApiHttpOptions } from "../organization/http.js"; -import { ProfileApiHandlers, type ProfileApiHttpOptions } from "../profile/http.js"; -import { InternalReceiptApiHandlers, ReceiptApiHandlers } from "../receipt/http.js"; -import type { ReceiptApiHttpOptions, ReceiptIdentityFailure } from "../receipt/http-context.js"; -import { ReceiptFileStoreResource, type ReceiptFileStore } from "../receipt/filesystem.js"; -import { RecruitmentApiHandlers } from "../recruitment/http.js"; -import type { RecruitmentApiHttpOptions } from "../recruitment/http-context.js"; -import { - backendHttpHandler, - ExternalNativeApiRouterLive, - InternalNativeApiRouterLive, - nativeHttpRouterConfig, - type BackendAuthHandler, - type BackendHttpHandler, - type BackendHttpOptions, -} from "../router.js"; -import { type SchoolsApiHttpOptions } from "../schools/http.js"; -import type { BackendConfig, TeamApplicationApiConfig } from "../config.js"; - -// Handlers reach the platform per request, so the router serves it from its own context. -const platform = Layer.mergeAll(TestPlatform, BunHttpPlatform.layer, Etag.layer); - -const testSessionBoundary = { - deployment: "local", - trustedOrigins: ["http://127.0.0.1:5174"], - secureCookies: false, -} as const; - -const testAuthHandler: BackendAuthHandler = { - handler: () => Effect.succeed(new Response(null, { status: 404 })), - recordTrustedOriginRejection: () => Effect.void, -}; - -const notFound = HttpRouter.use((router) => - router.add( - "*", - "*", - Effect.sync(() => - HttpServerResponse.fromWeb( - Response.json( - { error: { tag: "RouteNotFound" } }, - { status: 404, headers: { "content-type": "application/json; charset=utf-8" } }, - ), - ), - ), - ), -); - -const organizationContract = HttpApi.make("external-native-api") - .add(OrganizationApi) - .middleware(RequestSchemaErrorMiddleware); - -const profileContract = HttpApi.make("external-native-api") - .add(ProfileApi) - .middleware(RequestSchemaErrorMiddleware); - -const directoryContract = HttpApi.make("external-native-api") - .add(DirectoryApi) - .middleware(RequestSchemaErrorMiddleware); - -const recruitmentContract = HttpApi.make("external-native-api") - .add(RecruitmentApi) - .middleware(RequestSchemaErrorMiddleware); - -const receiptContract = HttpApi.make("external-native-api") - .add(ReceiptsApi) - .middleware(RequestSchemaErrorMiddleware); - -const internalReceiptContract = HttpApi.make("internal-native-api") - .add(InternalReceiptsApi) - .middleware(RequestSchemaErrorMiddleware); - -const contentContract = HttpApi.make("external-native-api") - .add(ContentApi) - .middleware(RequestSchemaErrorMiddleware); - -const teamApplicationsContract = HttpApi.make("external-native-api") - .add(TeamApplicationsApi) - .middleware(RequestSchemaErrorMiddleware); - -const certificatesContract = HttpApi.make("external-native-api") - .add(CertificatesApi) - .middleware(RequestSchemaErrorMiddleware); - -type BackendTestServices = - | Database - | IdentitySnapshot - | OAuthCredentialAuthority - | Identity - | Admissions - | ReturningAssistants - | Economy - | Organization - | Profile - | Schools - | Recruitment - | Placements - | Content - | ContentManagement - | SocialEvents - | TeamApplications - | Mail - | ReceiptAuxiliaryEffects - | ReceiptFileService - | ServicePrincipalGrantAuthority; - -type TestServiceLayer = Layer.Layer; - -type TestPlatformServices = - | FileSystem.FileSystem - | Path.Path - | Crypto.Crypto - | HttpClient.HttpClient; - -type TestApplicationRequirement = - | BackendTestServices - | TestPlatformServices - | HttpRouter.HttpRouter - | HttpRouter.Request.From<"Requires", BackendTestServices | TestPlatformServices> - | Etag.Generator - | HttpPlatform.HttpPlatform; - -const unavailableIdentity = () => - Effect.fail( - new IdentityEngineError({ operation: "test", message: "Unexpected identity operation" }), - ); - -const unimplementedServices = Layer.mergeAll( - Layer.mock(IdentitySnapshot, {}), - Layer.mock(OAuthCredentialAuthority, {}), - Layer.succeed(Identity, { - signIn: unavailableIdentity, - resolveSession: unavailableIdentity, - readCurrentSession: unavailableIdentity, - listSessions: unavailableIdentity, - revokeCurrentSession: unavailableIdentity, - revokeSession: unavailableIdentity, - revokeOtherSessions: unavailableIdentity, - revokeAllSessions: unavailableIdentity, - recordSecurityEvent: unavailableIdentity, - signOut: unavailableIdentity, - }), - Layer.mock(Admissions, {}), - Layer.mock(ReturningAssistants, {}), - Layer.mock(Economy, {}), - Layer.mock(Organization, {}), - Layer.mock(Profile, {}), - Layer.mock(Schools, {}), - Layer.mock(Recruitment, {}), - Layer.mock(Placements, {}), - Layer.mock(Content, {}), - Layer.mock(ContentManagement, {}), - Layer.mock(SocialEvents, {}), - Layer.mock(TeamApplications, {}), - Layer.mock(Mail, {}), - Layer.mock(ReceiptAuxiliaryEffects, {}), - Layer.mock(ReceiptFileService, {}), - Layer.mock(ServicePrincipalGrantAuthority, {}), -); - -const fallbackDatabase = backendDatabase(); - -const completeServices = (services: TestServiceLayer) => - Layer.effectContext( - Effect.gen(function* () { - const provided = yield* Layer.build(services); - const defaults = yield* Layer.build(unimplementedServices); - const database = Context.getOption(provided, Database); - - const sql = Option.isSome(database) - ? database.value - : yield* Effect.provide(Database, fallbackDatabase.layer); - - return Context.add(Context.merge(defaults, provided), Database, sql); - }), - ); - -const provideTestServices = ( - layer: Layer.Layer, - services: TestServiceLayer, -) => layer.pipe(Layer.provideMerge(completeServices(services))); - -/** Serves each request on a fresh web handler, whose runtime is disposed after the response. */ -const serveEachRequest = - ( - makeWebHandler: () => { - readonly handler: (request: Request) => Promise; - readonly dispose: () => Promise; - }, - ): BackendHttpHandler => - (request) => - Effect.acquireUseRelease( - Effect.sync(makeWebHandler), - ({ handler }) => Effect.promise(() => handler(request)), - ({ dispose }) => Effect.promise(() => dispose()), - ); - -const testRouterFetch = ( - app: Layer.Layer, - services: TestServiceLayer, -) => { - const routerLayer = Layer.mergeAll(app, notFound, ProblemBoundaryLive).pipe( - Layer.provideMerge(completeServices(services)), - Layer.provideMerge(platform), - ); - - return serveEachRequest(() => - HttpRouter.toWebHandler(routerLayer, { - disableLogger: true, - routerConfig: nativeHttpRouterConfig, - }), - ); -}; - -const testFetch = ( - app: Layer.Layer, - services: TestServiceLayer, -) => backendHttpHandler(testRouterFetch(app, services), testAuthHandler, testSessionBoundary); - -export const makeOrganizationTestHttp = ( - options: OrganizationApiHttpOptions, - services: S, -) => ({ - fetch: testFetch( - HttpApiBuilder.layer(organizationContract).pipe( - Layer.provide(OrganizationApiHandlers(options)), - Layer.provide(NativeHttpApiMiddlewareLive), - ), - services, - ), -}); - -export const makeProfileTestHttp = ( - options: ProfileApiHttpOptions, - services: S, -) => ({ - fetch: testFetch( - HttpApiBuilder.layer(profileContract).pipe( - Layer.provide(ProfileApiHandlers(options)), - Layer.provide(NativeHttpApiMiddlewareLive), - ), - services, - ), -}); - -export const makeRecruitmentTestHttp = ( - options: RecruitmentApiHttpOptions, - services: S, -) => ({ - fetch: testFetch( - HttpApiBuilder.layer(recruitmentContract).pipe( - Layer.provide(RecruitmentApiHandlers(options)), - Layer.provide(NativeHttpApiMiddlewareLive), - ), - services, - ), -}); - -export interface ReceiptTestHttpOptions< - E extends ReceiptIdentityFailure, - R, -> extends ReceiptApiHttpOptions { - readonly fileStore: ReceiptFileStore; -} - -export const makeReceiptTestHttp = ( - options: ReceiptTestHttpOptions, - services: S, -) => ({ - fetch: testFetch( - HttpApiBuilder.layer(receiptContract).pipe( - Layer.provide( - ReceiptApiHandlers(options).pipe( - Layer.provide(Layer.succeed(ReceiptFileStoreResource, options.fileStore)), - ), - ), - Layer.provide(NativeHttpApiMiddlewareLive), - ), - services, - ), -}); - -export const makeInternalReceiptTestHttp = < - E extends ReceiptIdentityFailure, - S extends TestServiceLayer, ->( - options: ReceiptApiHttpOptions, - services: S, -) => ({ - fetch: testRouterFetch( - HttpApiBuilder.layer(internalReceiptContract).pipe( - Layer.provide(InternalReceiptApiHandlers(options)), - Layer.provide(NativeHttpApiMiddlewareLive), - ), - services, - ), -}); - -export const makeContentManagementTestHttp = ( - resolveActor: (request: Request) => Effect.Effect, - services: S, -) => ({ - fetch: testFetch( - HttpApiBuilder.layer(contentContract).pipe( - Layer.provide(ContentApiHandlers(resolveActor)), - Layer.provide(NativeHttpApiMiddlewareLive), - ), - services, - ), -}); - -export const makePublicNewsTestHttp = (services: S) => ({ - fetch: testFetch( - HttpApiBuilder.layer(contentContract).pipe( - Layer.provide( - ContentApiHandlers(() => - Effect.die(new Error("staff actor resolution is unavailable in public-news tests")), - ), - ), - Layer.provide(NativeHttpApiMiddlewareLive), - ), - services, - ), -}); - -export const makeDirectoryTestHttp = ( - options: DirectoryApiHttpOptions, - services: S, -) => ({ - fetch: testFetch( - HttpApiBuilder.layer(directoryContract).pipe( - Layer.provide( - DirectoryApiHandlers(options, { - resolveActor: () => - Effect.fail( - new UnauthenticatedActor({ message: "school actor resolution is unavailable" }), - ), - }), - ), - Layer.provide(NativeHttpApiMiddlewareLive), - ), - services, - ), -}); - -export const makeSchoolsTestHttp = ( - options: SchoolsApiHttpOptions, - services: S, -) => ({ - fetch: testFetch( - HttpApiBuilder.layer(directoryContract).pipe( - Layer.provide( - DirectoryApiHandlers( - { - resolveAuthority: () => - Effect.fail( - new UnauthenticatedActor({ message: "people directory authority is unavailable" }), - ), - }, - options, - ), - ), - Layer.provide(NativeHttpApiMiddlewareLive), - ), - services, - ), -}); - -export const makeTeamApplicationsTestHttp = ( - config: TeamApplicationApiConfig, - services: S, -) => ({ - fetch: testFetch( - HttpApiBuilder.layer(teamApplicationsContract).pipe( - Layer.provide(TeamApplicationsApiHandlers(config)), - Layer.provide(NativeHttpApiMiddlewareLive), - ), - services, - ), -}); - -export const makeCertificatesTestHttp = (services: S) => ({ - fetch: testFetch( - HttpApiBuilder.layer(certificatesContract).pipe( - Layer.provide(CertificatesApiHandlers.pipe(Layer.provide(CertificateFontsLive))), - Layer.provide(NativeHttpApiMiddlewareLive), - ), - services, - ), -}); - -export const makeBackendTestHttp = ( - config: BackendConfig, - services: TestServiceLayer, - authHandler: BackendAuthHandler, - options: BackendHttpOptions = {}, -) => { - const routerLayer = provideTestServices( - ExternalNativeApiRouterLive(config, options), - services, - ).pipe(Layer.provideMerge(platform)); - - const native = serveEachRequest(() => - HttpRouter.toWebHandler(routerLayer, { - disableLogger: true, - }), - ); - - return { fetch: backendHttpHandler(native, authHandler, config.sessionBoundary) }; -}; - -export const makeBackendInternalTestHttp = ( - config: BackendConfig, - services: TestServiceLayer, - options: BackendHttpOptions = {}, -) => { - const routerLayer = provideTestServices( - InternalNativeApiRouterLive(config, options), - services, - ).pipe(Layer.provideMerge(platform)); - - return { - fetch: serveEachRequest(() => - HttpRouter.toWebHandler(routerLayer, { - disableLogger: true, - }), - ), - }; -}; diff --git a/apps/backend/src/test/native-rpc.ts b/apps/backend/src/test/native-rpc.ts new file mode 100644 index 000000000..43c4bdc94 --- /dev/null +++ b/apps/backend/src/test/native-rpc.ts @@ -0,0 +1,198 @@ +/** + * The backend test harness for native RPCs. A test builds the complete backend over the services it + * supplies, with every other service unimplemented, and calls it through the RPC client over the + * real HTTP ingress: trusted origins, CORS, the credential middlewares, and the defect boundary. + */ +import { Database, IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; +import { Placements } from "@vektorprogrammet/domain/placements"; +import { TeamApplications } from "@vektorprogrammet/domain/team-application"; +import { + Admissions, + ReturningAssistants, + Organization, + Profile, + Schools, + Recruitment, + SocialEvents, + Mail, + ServicePrincipalGrantAuthority, +} from "@vektorprogrammet/domain"; +import { Identity, IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import { + Economy, + ReceiptAuxiliaryEffects, + ReceiptFileService, +} from "@vektorprogrammet/domain/receipt"; +import { Content, ContentManagement } from "@vektorprogrammet/domain/content"; +import { NativeRpcs, nativeRpcPath } from "@vektorprogrammet/rpc"; +import * as BunHttpPlatform from "@effect/platform-bun/BunHttpPlatform"; +import { Context, Effect, Layer, Option, Predicate } from "effect"; +import { Etag, HttpClient, HttpClientResponse, HttpRouter } from "effect/unstable/http"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import { backendDatabase } from "../../test/database.js"; +import type { BackendConfig } from "../config.js"; +import { + backendHttpHandler, + ExternalNativeRpcRouterLive, + type BackendAuthHandler, + type BackendHttpHandler, + type BackendHttpOptions, +} from "../router.js"; +import { TestPlatform } from "./platform.js"; + +// Handlers reach the platform per request, so the router serves it from its own context. +const platform = Layer.mergeAll(TestPlatform, BunHttpPlatform.layer, Etag.layer); + +/** An identity engine whose Better Auth routes answer 404. */ +export const testAuthHandler: BackendAuthHandler = { + handler: () => Effect.succeed(new Response(null, { status: 404 })), + recordTrustedOriginRejection: () => Effect.void, +}; + +/** The services a test supplies; every service it leaves out is unimplemented. */ +export type TestServiceLayer = Layer.Layer; + +const unavailableIdentity = () => + Effect.fail( + IdentityEngineError.make({ operation: "test", message: "Unexpected identity operation" }), + ); + +const unimplementedServices = Layer.mergeAll( + Layer.mock(IdentitySnapshot, {}), + Layer.mock(OAuthCredentialAuthority, {}), + Layer.succeed(Identity, { + signIn: unavailableIdentity, + resolveSession: unavailableIdentity, + readCurrentSession: unavailableIdentity, + listSessions: unavailableIdentity, + revokeCurrentSession: unavailableIdentity, + revokeSession: unavailableIdentity, + revokeOtherSessions: unavailableIdentity, + revokeAllSessions: unavailableIdentity, + recordSecurityEvent: unavailableIdentity, + signOut: unavailableIdentity, + }), + Layer.mock(Admissions, {}), + Layer.mock(ReturningAssistants, {}), + Layer.mock(Economy, {}), + Layer.mock(Organization, {}), + Layer.mock(Profile, {}), + Layer.mock(Schools, {}), + Layer.mock(Recruitment, {}), + Layer.mock(Placements, {}), + Layer.mock(Content, {}), + Layer.mock(ContentManagement, {}), + Layer.mock(SocialEvents, {}), + Layer.mock(TeamApplications, {}), + Layer.mock(Mail, {}), + Layer.mock(ReceiptAuxiliaryEffects, {}), + Layer.mock(ReceiptFileService, {}), + Layer.mock(ServicePrincipalGrantAuthority, {}), +); + +const fallbackDatabase = backendDatabase(); + +const completeServices = (services: TestServiceLayer) => + Layer.effectContext( + Effect.gen(function* () { + const provided = yield* Layer.build(services); + const defaults = yield* Layer.build(unimplementedServices); + const database = Context.getOption(provided, Database); + + const sql = Option.isSome(database) + ? database.value + : yield* Effect.provide(Database, fallbackDatabase.layer); + + return Context.add(Context.merge(defaults, provided), Database, sql); + }), + ); + +/** Serves each request on a fresh web handler, whose runtime is disposed after the response. */ +const serveEachRequest = + ( + makeWebHandler: () => { + readonly handler: (request: Request) => Promise; + readonly dispose: () => Promise; + }, + ): BackendHttpHandler => + (request) => + Effect.acquireUseRelease( + Effect.sync(makeWebHandler), + ({ handler }) => Effect.promise(() => handler(request)), + ({ dispose }) => Effect.promise(() => dispose()), + ); + +export interface BackendTestRpcOptions { + readonly config: BackendConfig; + /** The services the test supplies; every other service is unimplemented. */ + readonly services: TestServiceLayer; + readonly authHandler?: BackendAuthHandler; + readonly options?: BackendHttpOptions; + /** + * HTTP headers that every request of the client carries, as a browser's `user-agent` does. A + * header set with `RpcClient.withHeaders` travels in the RPC message instead, and the ingress + * keeps only the credential and contact headers of a message. + */ + readonly transportHeaders?: Readonly>; +} + +/** + * The complete external backend over `services`: `fetch` answers one web request through the + * ingress, and `client` is the native RPC client whose requests take that path. + * + * A test sends a person's credential per call with `RpcClient.withHeaders`, as a server does. + */ +/** + * The external native router over `services`, every other service unimplemented. A test that + * serves several requests on one router, as the process does, builds its web handler once. + */ +export const backendTestRouterLayer = ({ + config, + services, + options = {}, +}: Pick) => + ExternalNativeRpcRouterLive({ ...options, config }).pipe( + Layer.provideMerge(completeServices(services)), + Layer.provideMerge(platform), + ); + +export const makeBackendTestRpc = ({ + config, + services, + authHandler = testAuthHandler, + options = {}, + transportHeaders = {}, +}: BackendTestRpcOptions) => { + const routerLayer = backendTestRouterLayer({ config, services, options }); + + const native = serveEachRequest(() => + HttpRouter.toWebHandler(routerLayer, { disableLogger: true }), + ); + + const fetch: BackendHttpHandler = backendHttpHandler(native, authHandler, config.sessionBoundary); + + const origin = config.sessionBoundary.trustedOrigins[0] ?? "http://127.0.0.1:5174"; + + const httpClient = HttpClient.make((request) => + Effect.gen(function* () { + const web = new Request(new URL(nativeRpcPath, "http://native-rpc.test"), { + method: request.method, + headers: { ...request.headers, ...transportHeaders, origin }, + body: Predicate.isTagged(request.body, "Uint8Array") ? request.body.body : undefined, + }); + + return HttpClientResponse.fromWeb(request, yield* fetch(web)); + }), + ); + + const client = RpcClient.make(NativeRpcs).pipe( + Effect.provide( + RpcClient.layerProtocolHttp({ url: `http://native-rpc.test${nativeRpcPath}` }).pipe( + Layer.provide(RpcSerialization.layerJson), + Layer.provide(Layer.succeed(HttpClient.HttpClient)(httpClient)), + ), + ), + ); + + return { fetch, client }; +}; diff --git a/apps/backend/src/worker-support.ts b/apps/backend/src/worker-support.ts index c447b069a..25ea10db7 100644 --- a/apps/backend/src/worker-support.ts +++ b/apps/backend/src/worker-support.ts @@ -6,6 +6,7 @@ * `TestClock` instead of real time. */ import { Duration, Effect, Schedule } from "effect"; +import { dual } from "effect/Function"; export interface PollOptions { /** Delay between the end of one tick and the start of the next. */ @@ -41,22 +42,25 @@ export interface PollOptions { * * @construct worker */ -export const pollForever = ( - tick: Effect.Effect, - options: PollOptions, -): Effect.Effect => { - const { skipDelay } = options; - const spaced: Schedule.Schedule = Schedule.spaced(options.interval); +export const pollForever: { + (options: PollOptions): (tick: Effect.Effect) => Effect.Effect; + (tick: Effect.Effect, options: PollOptions): Effect.Effect; +} = dual( + 2, + (tick: Effect.Effect, options: PollOptions): Effect.Effect => { + const { skipDelay } = options; + const spaced: Schedule.Schedule = Schedule.spaced(options.interval); - const schedule = - skipDelay === undefined - ? spaced - : spaced.pipe( - Schedule.modifyDelay(({ input, duration }) => - Effect.succeed(skipDelay(input) ? Duration.zero : duration), - ), - ); + const schedule = + skipDelay === undefined + ? spaced + : spaced.pipe( + Schedule.modifyDelay(({ input, duration }) => + Effect.succeed(skipDelay(input) ? Duration.zero : duration), + ), + ); - // `Schedule.spaced` never completes, so only failure or interruption ends the repeat. - return Effect.repeat(tick, schedule).pipe(Effect.andThen(Effect.never)); -}; + // `Schedule.spaced` never completes, so only failure or interruption ends the repeat. + return Effect.repeat(tick, schedule).pipe(Effect.andThen(Effect.never)); + }, +); diff --git a/apps/backend/test/postgres.ts b/apps/backend/test/postgres.ts index bf413f11e..fe5e056cb 100644 --- a/apps/backend/test/postgres.ts +++ b/apps/backend/test/postgres.ts @@ -41,7 +41,11 @@ const startCluster = Effect.gen(function* () { Database.use((sql) => sql`CREATE DATABASE ${sql(name)}`), primary, ); - yield* Effect.provide(health, connection(name), { local: true }); + yield* Effect.scoped( + Effect.flatMap(buildIsolated(connection(name)), (context) => + Effect.provide(health, context), + ), + ); return name; }), @@ -72,7 +76,7 @@ const startCluster = Effect.gen(function* () { class BackendCluster extends Context.Service< BackendCluster, Effect.Success ->()("apps/backend/test/BackendCluster") {} +>()("@vektorprogrammet/backend/test/postgres/BackendCluster") {} /** * Independent PostgreSQL sessions on a private Unix socket, with no TCP listener. The cluster diff --git a/apps/dashboard/AGENTS.md b/apps/dashboard/AGENTS.md index a28a7c168..841f40e09 100644 --- a/apps/dashboard/AGENTS.md +++ b/apps/dashboard/AGENTS.md @@ -32,8 +32,9 @@ The package has no `exports`, so other packages do not import it. The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../docs/constructs.md) indexes them all. -- [`isNativeRequest`](../../docs/constructs/request-ledger.md#isnativerequest) (request-ledger): Whether a dashboard-to-backend request stays on the native surface: an operation of the native HTTP contract or an email-password route of the identity engine. +- [`isNativeRequest`](../../docs/constructs/request-ledger.md#isnativerequest) (request-ledger): Whether a dashboard-to-backend request stays on the native surface: an operation of the native RPC contract or an email-password route of the identity engine. - [`addressesAnyRoute`](../../docs/constructs/request-ledger.md#addressesanyroute) (request-ledger): Whether a request path addresses any of the routes, each matched by whole path segments. +- [`auditSettledPage`](../../docs/constructs/browser-audit.md#auditsettledpage) (browser-audit): Audits a page with axe once every finite animation on it has finished. Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. diff --git a/apps/dashboard/app/__tests__/home.spec.ts b/apps/dashboard/app/__tests__/home.spec.ts index d593b2b69..f95d13644 100644 --- a/apps/dashboard/app/__tests__/home.spec.ts +++ b/apps/dashboard/app/__tests__/home.spec.ts @@ -1,4 +1,4 @@ -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "../../e2e/settled-axe.js"; import { expect, test } from "@playwright/test"; test.describe("Dashboard home page", () => { @@ -7,9 +7,9 @@ test.describe("Dashboard home page", () => { }) => { await page.goto("/dashboard"); - const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); + const accessibilityScanResults = await auditSettledPage(page); - expect(accessibilityScanResults.violations).toEqual([]); + expect(accessibilityScanResults).toEqual([]); }); test("has title", async ({ page }) => { diff --git a/apps/dashboard/app/components/receipts/ApprovalReceiptRow.test.ts b/apps/dashboard/app/components/receipts/ApprovalReceiptRow.test.ts index 7984cff8a..3ba514344 100644 --- a/apps/dashboard/app/components/receipts/ApprovalReceiptRow.test.ts +++ b/apps/dashboard/app/components/receipts/ApprovalReceiptRow.test.ts @@ -1,5 +1,5 @@ import { createElement } from "react"; -import { StrongETag } from "@vektorprogrammet/http-api"; +import { StrongETag } from "@vektorprogrammet/rpc"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import type { ApprovalReceiptView } from "../../lib/receipt-view"; diff --git a/apps/dashboard/app/components/receipts/ReceiptSubmitForm.tsx b/apps/dashboard/app/components/receipts/ReceiptSubmitForm.tsx index 2ab0e4f3b..18eb20c33 100644 --- a/apps/dashboard/app/components/receipts/ReceiptSubmitForm.tsx +++ b/apps/dashboard/app/components/receipts/ReceiptSubmitForm.tsx @@ -1,4 +1,4 @@ -import type { StrongETag } from "@vektorprogrammet/http-api"; +import type { StrongETag } from "@vektorprogrammet/rpc"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; diff --git a/apps/dashboard/app/foldkit/content/AGENTS.md b/apps/dashboard/app/foldkit/content/AGENTS.md index 5bd71c3bb..cefcbb7b4 100644 --- a/apps/dashboard/app/foldkit/content/AGENTS.md +++ b/apps/dashboard/app/foldkit/content/AGENTS.md @@ -3,7 +3,7 @@ # apps/dashboard/app/foldkit/content This folder holds the Content bounded context in `apps/dashboard/app/foldkit`. -The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the generated SDK. +The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the RPC client. The Content context in other folders: diff --git a/apps/dashboard/app/foldkit/content/bridge-route.test.ts b/apps/dashboard/app/foldkit/content/bridge-route.test.ts index 5164d273f..20de89546 100644 --- a/apps/dashboard/app/foldkit/content/bridge-route.test.ts +++ b/apps/dashboard/app/foldkit/content/bridge-route.test.ts @@ -1,92 +1,214 @@ -import { ArticleDetailExample, DepartmentExample } from "@vektorprogrammet/http-api"; +import { Exit, Schema } from "effect"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { conditionalReadHeaders, nativeProblemResponse, nativeSessionResponse, privateReadHeaders, routeArgs, sessionCookie } from "../../../test/native-http"; +import { routeArgs, sessionCookie } from "../../../test/native-http"; +import { + isNativeRpcRequest, + nativeRpcProblem, + nativeRpcSuccess, + nativeSession, + readNativeRpcCall, + type NativeRpcCall, +} from "../../../test/native-rpc"; vi.hoisted(() => vi.stubEnv("API_URL", "http://api.test")); import { action, loader } from "../../routes/__foldkit.content"; -const article = { ...ArticleDetailExample, articleId: 7 }; +const etag = `"vkr2.${"A".repeat(43)}"`; + +/** One draft as `content.readArticle` encodes it. */ +const article = { + articleId: 7, + title: "Opptak 2026", + slug: "opptak-2026", + status: "Draft", + bodyHtml: "

Informasjon om opptak.

", + sticky: false, + createdAt: "2026-08-20T09:00:00.000Z", + updatedAt: "2026-08-24T09:00:00.000Z", + currentVersionNumber: null, + revision: 0, + departmentIds: ["department-a"], + canRevise: true, + canPublish: true, + authorDisplayName: "Kari Penerbit", +}; + +/** A department as `organization.listDepartments` encodes it. */ +const department = (departmentId: string, name: string, active: boolean) => ({ + departmentId, + name, + shortName: name.slice(0, 3), + email: `${departmentId}@example.invalid`, + address: null, + city: name, + latitude: null, + longitude: null, + slackChannel: null, + logoPath: null, + active, + revision: 0, +}); const departments = [ - { ...DepartmentExample, departmentId: "department-a", name: "Trondheim" }, - { ...DepartmentExample, departmentId: "department-b", name: "Bergen" }, - { ...DepartmentExample, departmentId: "department-old", name: "Tidligere", active: false }, + department("department-a", "Trondheim", true), + department("department-b", "Bergen", true), + department("department-old", "Tidligere", false), ]; -const requests: Request[] = []; - -let workspaceResponse = () => Response.json({ entries: [] }, { headers: privateReadHeaders }); - -let createResponse = () => nativeProblemResponse("authority.denied"); - -const loadWorkspace = () => loader(routeArgs(new Request("http://dashboard.test/content", { headers: { cookie: sessionCookie } }), {})); +const RpcExitMessage = Schema.TaggedStruct("Exit", { + requestId: Schema.Union([Schema.String, Schema.Finite]), + exit: Schema.Json, +}); -const post = (body: string) => action(routeArgs(new Request("http://dashboard.test/content", { - method: "POST", headers: { "content-type": "application/json", cookie: sessionCookie }, body, -}), {})); +const encodeJsonExit = Schema.encodeSync( + Schema.toCodecJson(Schema.Exit(Schema.Json, Schema.Json, Schema.Json)), +); + +/** Answers `call` with a failure that is no declared problem. */ +const undeclaredFailure = (call: NativeRpcCall, failure: Schema.Json) => + Response.json([ + RpcExitMessage.make({ requestId: call.id, exit: encodeJsonExit(Exit.fail(failure)) }), + ]); + +const calls: NativeRpcCall[] = []; + +let workspaceAnswer = (call: NativeRpcCall) => nativeRpcSuccess(call, { entries: [] }); + +let createAnswer = (call: NativeRpcCall) => nativeRpcProblem(call, "authority.denied"); + +const loadWorkspace = () => + loader( + routeArgs( + new Request("http://dashboard.test/content", { headers: { cookie: sessionCookie } }), + {}, + ), + ); + +const post = (body: string) => + action( + routeArgs( + new Request("http://dashboard.test/content", { + method: "POST", + headers: { "content-type": "application/json", cookie: sessionCookie }, + body, + }), + {}, + ), + ); beforeEach(() => { - requests.length = 0; - workspaceResponse = () => Response.json({ entries: [] }, { headers: privateReadHeaders }); - createResponse = () => nativeProblemResponse("authority.denied"); - vi.stubGlobal("fetch", vi.fn(async (input, init) => { - const request = new Request(input, init); - requests.push(request.clone()); - const path = new URL(request.url).pathname; - - if (path === "/api/session") return nativeSessionResponse(); - - if (path === "/api/departments") return Response.json(departments, { headers: { ...conditionalReadHeaders, "cache-control": "public, max-age=60, s-maxage=300, must-revalidate" } }); - - if (path === "/api/content/articles/7") return Response.json(article, { headers: conditionalReadHeaders }); - - if (path === "/api/content/articles" && request.method === "POST") return createResponse(); - - if (path === "/api/content/articles") return workspaceResponse(); - throw new Error(`Unexpected content request: ${path}`); - })); + calls.length = 0; + workspaceAnswer = (call) => nativeRpcSuccess(call, { entries: [] }); + createAnswer = (call) => nativeRpcProblem(call, "authority.denied"); + vi.stubGlobal( + "fetch", + vi.fn(async (input, init) => { + if (!isNativeRpcRequest(input)) { + throw new Error(`Unexpected content request: ${new Request(input, init).url}`); + } + + const call = await readNativeRpcCall(input, init); + calls.push(call); + + switch (call.tag) { + case "system.readSession": + return nativeRpcSuccess(call, nativeSession); + case "organization.listDepartments": + return nativeRpcSuccess(call, departments); + case "content.readContentWorkspace": + return workspaceAnswer(call); + case "content.readArticle": + return nativeRpcSuccess(call, { article, etag }); + case "content.createArticle": + return createAnswer(call); + default: + throw new Error(`Unexpected content call: ${call.tag}`); + } + }), + ); }); afterEach(() => vi.unstubAllGlobals()); describe("Content bridge denial decoding", () => { - it("maps canonical authority denial across the real SDK boundary", async () => { - workspaceResponse = () => nativeProblemResponse("authority.denied"); + it("maps canonical authority denial across the RPC client", async () => { + workspaceAnswer = (call) => nativeRpcProblem(call, "authority.denied"); const result = await loadWorkspace(); expect(result.init?.status).toBe(403); expect(result.data).toEqual({ error: { tag: "NotInScope" } }); }); - it.each(['{"_tag":"AuthorityInactive"}', '{"code":"made-up.failure"}'])("does not grant authority to malformed problem bytes %s", async body => { - workspaceResponse = () => new Response(body, { status: 403, headers: { "content-type": "application/problem+json", "cache-control": "no-store", vary: "Origin" } }); - const result = await loadWorkspace(); - expect(result.init?.status).toBe(503); - expect(result.data).toEqual({ error: { tag: "ContentPersistenceError" } }); - }); + + it.each([ + Schema.TaggedStruct("AuthorityInactive", {}).make({}), + { code: "made-up.failure" }, + ])( + "does not grant authority to a failure that is no declared problem: %o", + async (failure) => { + workspaceAnswer = (call) => undeclaredFailure(call, failure); + const result = await loadWorkspace(); + expect(result.init?.status).toBe(503); + expect(result.data).toEqual({ error: { tag: "ContentPersistenceError" } }); + }, + ); + it("returns only active department choices even when the workspace is empty", async () => { const result = await loadWorkspace(); - expect(result.data).toEqual({ workspace: { entries: [] }, knownDepartments: [{ departmentId: "department-a", name: "Trondheim" }, { departmentId: "department-b", name: "Bergen" }] }); + expect(result.data).toEqual({ + workspace: { entries: [] }, + knownDepartments: [ + { departmentId: "department-a", name: "Trondheim" }, + { departmentId: "department-b", name: "Bergen" }, + ], + }); }); + it("does not let a visible department choice override server authority", async () => { await loadWorkspace(); - const result = await post(JSON.stringify({ operation: "createDraft", commandId: "AAAAAAAAAAAAAAAAAAAAAA", title: "Tittel", bodyHtml: "

Brødtekst

", departmentIds: ["department-b"], sticky: false })); + + const result = await post( + JSON.stringify({ + operation: "createDraft", + commandId: "AAAAAAAAAAAAAAAAAAAAAA", + title: "Tittel", + bodyHtml: "

Brødtekst

", + departmentIds: ["department-b"], + sticky: false, + }), + ); + expect(result.init?.status).toBe(403); expect(result.data).toEqual({ error: { tag: "NotInScope" } }); - const request = requests.find(request => request.method === "POST"); - expect(await request!.json()).toMatchObject({ departmentIds: ["department-b"] }); + expect(calls.find((call) => call.tag === "content.createArticle")?.payload).toEqual({ + idempotencyKey: "AAAAAAAAAAAAAAAAAAAAAA", + request: { + title: "Tittel", + bodyHtml: "

Brødtekst

", + departmentIds: ["department-b"], + sticky: false, + }, + }); }); - it("reads private detail but rejects caller-supplied authority fields before HTTP dispatch", async () => { + + it("reads private detail but rejects caller-supplied authority fields before dispatch", async () => { const result = await post('{"operation":"readArticle","articleId":7}'); - expect(result.data).toEqual({ body: article, etag: conditionalReadHeaders.etag }); - const before = requests.filter(request => new URL(request.url).pathname === "/api/content/articles/7").length; - const polluted = await post('{"operation":"readArticle","articleId":7,"createdByPersonId":"person-secret"}'); + expect(result.data).toEqual({ body: article, etag }); + + const reads = () => calls.filter((call) => call.tag === "content.readArticle").length; + const before = reads(); + + const polluted = await post( + '{"operation":"readArticle","articleId":7,"createdByPersonId":"person-secret"}', + ); + expect(polluted.init?.status).toBe(422); expect(polluted.data).toEqual({ error: { tag: "ContentDecodeError" } }); - expect(requests.filter(request => new URL(request.url).pathname === "/api/content/articles/7")).toHaveLength(before); + expect(reads()).toBe(before); }); + it("rejects unknown operations without a content write", async () => { const result = await post('{"operation":"saveDraft","articleId":7}'); expect(result.init?.status).toBe(422); - expect(requests.some(request => request.method === "POST")).toBe(false); + expect(calls.some((call) => call.tag.startsWith("content."))).toBe(false); }); }); diff --git a/apps/dashboard/app/foldkit/content/bridge.ts b/apps/dashboard/app/foldkit/content/bridge.ts index 2f4e71056..488096777 100644 --- a/apps/dashboard/app/foldkit/content/bridge.ts +++ b/apps/dashboard/app/foldkit/content/bridge.ts @@ -1,5 +1,5 @@ -import { ArticleId, ContentArticleDetailSchema, ContentWorkspaceSchema } from "@vektorprogrammet/http-api" -import { ArticleMergePatch, CreateArticleRequest, IdempotencyKey, StrongETag } from "@vektorprogrammet/http-api"; +import { ArticleId, ContentArticleDetailSchema, ContentWorkspaceSchema } from "@vektorprogrammet/rpc" +import { ArticleMergePatch, CreateArticleRequest, IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; import { KnownDepartmentSchema } from "./model"; diff --git a/apps/dashboard/app/foldkit/content/browser-client.ts b/apps/dashboard/app/foldkit/content/browser-client.ts index 062424678..22ecc0369 100644 --- a/apps/dashboard/app/foldkit/content/browser-client.ts +++ b/apps/dashboard/app/foldkit/content/browser-client.ts @@ -1,4 +1,4 @@ -import { ArticleId } from "@vektorprogrammet/http-api"; +import { ArticleId } from "@vektorprogrammet/rpc"; import { Effect, Schema as S } from "effect"; import { ContentArticleObservationSchema, ContentBridgeFailureSchema, ContentCreateCommandSchema, ContentReviseCommandSchema, ContentTransitionCommandSchema, ContentWorkspaceBootstrapSchema, contentBridgeFailure, type ContentArticleObservation, type ContentBridgeFailure, type ContentCreateCommand, type ContentReviseCommand, type ContentTransitionCommand, type ContentWorkspaceBootstrap, ContentBridgeAction } from "./bridge"; diff --git a/apps/dashboard/app/foldkit/content/command.test.ts b/apps/dashboard/app/foldkit/content/command.test.ts index d8697401f..e1e04355b 100644 --- a/apps/dashboard/app/foldkit/content/command.test.ts +++ b/apps/dashboard/app/foldkit/content/command.test.ts @@ -1,8 +1,8 @@ import { ContentFailure } from "./model"; import { FailedCommand } from "./message"; -import { ArticleId } from "@vektorprogrammet/http-api"; -import { DepartmentId } from "@vektorprogrammet/http-api"; -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { ArticleId } from "@vektorprogrammet/rpc"; +import { DepartmentId } from "@vektorprogrammet/domain"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Effect } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; import { contentBridgeFailure } from "./bridge"; diff --git a/apps/dashboard/app/foldkit/content/command.ts b/apps/dashboard/app/foldkit/content/command.ts index 2aba2f976..374bd5827 100644 --- a/apps/dashboard/app/foldkit/content/command.ts +++ b/apps/dashboard/app/foldkit/content/command.ts @@ -1,4 +1,4 @@ -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import type { ContentBridgeFailure } from "./bridge"; import { Effect } from "effect"; import type { ContentWorkspaceClient } from "./browser-client"; diff --git a/apps/dashboard/app/foldkit/content/message.ts b/apps/dashboard/app/foldkit/content/message.ts index 9d6b839d7..fc0094cc2 100644 --- a/apps/dashboard/app/foldkit/content/message.ts +++ b/apps/dashboard/app/foldkit/content/message.ts @@ -1,5 +1,5 @@ -import { ArticleId, ContentWorkspaceSchema } from "@vektorprogrammet/http-api" -import { DepartmentId } from "@vektorprogrammet/http-api" +import { ArticleId, ContentWorkspaceSchema } from "@vektorprogrammet/rpc" +import { DepartmentId } from "@vektorprogrammet/domain" import { Schema as S } from "effect"; import { taggedStruct } from "foldkit/schema"; import { ContentArticleObservationSchema } from "./bridge"; diff --git a/apps/dashboard/app/foldkit/content/model.ts b/apps/dashboard/app/foldkit/content/model.ts index 27cdba959..3da4dfba6 100644 --- a/apps/dashboard/app/foldkit/content/model.ts +++ b/apps/dashboard/app/foldkit/content/model.ts @@ -1,6 +1,6 @@ -import { ArticleId, ContentWorkspaceSchema, type ContentWorkspace } from "@vektorprogrammet/http-api" -import { DepartmentId } from "@vektorprogrammet/http-api" -import { StrongETag } from "@vektorprogrammet/http-api"; +import { ArticleId, ContentWorkspaceSchema, type ContentWorkspace } from "@vektorprogrammet/rpc" +import { DepartmentId } from "@vektorprogrammet/domain" +import { StrongETag } from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; import { AsyncData } from "foldkit"; diff --git a/apps/dashboard/app/foldkit/content/update.test.ts b/apps/dashboard/app/foldkit/content/update.test.ts index d53c7c2cd..c92596e13 100644 --- a/apps/dashboard/app/foldkit/content/update.test.ts +++ b/apps/dashboard/app/foldkit/content/update.test.ts @@ -1,6 +1,6 @@ -import { ArticleId, ArticleSlug, type ContentWorkspace } from "@vektorprogrammet/http-api" -import { DepartmentId } from "@vektorprogrammet/http-api" -import { StrongETag } from "@vektorprogrammet/http-api"; +import { ArticleId, ArticleSlug, type ContentWorkspace } from "@vektorprogrammet/rpc" +import { DepartmentId } from "@vektorprogrammet/domain" +import { StrongETag } from "@vektorprogrammet/rpc"; import { describe, expect, it } from "vitest"; import { ChangedDepartmentFilter, RetriedWorkspace, LoadedWorkspace, FailedWorkspace, SelectedArticle, LoadedArticleDetail, EditedField, SubmittedRevise, SucceededSave, SubmittedPublish, SubmittedUnpublish, FailedCommand, DismissedBanner } from "./message"; import { init, type Model, ContentFailure, ContentWorkspaceData } from "./model"; diff --git a/apps/dashboard/app/foldkit/content/update.ts b/apps/dashboard/app/foldkit/content/update.ts index 6509de16e..3b41c3cd8 100644 --- a/apps/dashboard/app/foldkit/content/update.ts +++ b/apps/dashboard/app/foldkit/content/update.ts @@ -1,7 +1,7 @@ import { Predicate } from "effect"; -import type { ArticleId, ContentWorkspace } from "@vektorprogrammet/http-api" -import type { DepartmentId } from "@vektorprogrammet/http-api" -import type { StrongETag } from "@vektorprogrammet/http-api"; +import type { ArticleId, ContentWorkspace } from "@vektorprogrammet/rpc" +import type { DepartmentId } from "@vektorprogrammet/domain" +import type { StrongETag } from "@vektorprogrammet/rpc"; import { Match as M } from "effect"; import { Command, Update } from "foldkit"; import type { Message } from "./message"; diff --git a/apps/dashboard/app/foldkit/content/view.test.ts b/apps/dashboard/app/foldkit/content/view.test.ts index 2365cfe6e..1cc0893d1 100644 --- a/apps/dashboard/app/foldkit/content/view.test.ts +++ b/apps/dashboard/app/foldkit/content/view.test.ts @@ -9,9 +9,9 @@ import { updateFor } from "./update"; const config = {update: updateFor(commandsFor(createBrowserContentWorkspaceClient("/content"))), view}; -import { ArticleId, type ContentWorkspace } from "@vektorprogrammet/http-api" -import { DepartmentId } from "@vektorprogrammet/http-api" -import { StrongETag } from "@vektorprogrammet/http-api"; +import { ArticleId, type ContentWorkspace } from "@vektorprogrammet/rpc" +import { DepartmentId } from "@vektorprogrammet/domain" +import { StrongETag } from "@vektorprogrammet/rpc"; import { describe, expect, it, vi } from "vitest"; import { init, type Model, ContentWorkspaceData } from "./model"; import { view } from "./view"; diff --git a/apps/dashboard/app/foldkit/content/view.ts b/apps/dashboard/app/foldkit/content/view.ts index 5a6068a26..e8141109c 100644 --- a/apps/dashboard/app/foldkit/content/view.ts +++ b/apps/dashboard/app/foldkit/content/view.ts @@ -1,5 +1,5 @@ import { Predicate } from "effect"; -import { DepartmentId as DepartmentIdSchema } from "@vektorprogrammet/http-api" +import { DepartmentId as DepartmentIdSchema } from "@vektorprogrammet/domain" import type { Html, HtmlBuilder } from "foldkit/html"; import type { Message } from "./message"; import { ChangedDepartmentFilter, ChangedDepartmentSelection, DeselectedArticle } from "./message"; diff --git a/apps/dashboard/app/foldkit/dashboard/AGENTS.md b/apps/dashboard/app/foldkit/dashboard/AGENTS.md index 867ef0473..013c7c5b4 100644 --- a/apps/dashboard/app/foldkit/dashboard/AGENTS.md +++ b/apps/dashboard/app/foldkit/dashboard/AGENTS.md @@ -3,7 +3,7 @@ # apps/dashboard/app/foldkit/dashboard This folder is not a bounded context. The application shell: navigation and the routes to each context page. -The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the generated SDK. +The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the RPC client. ## Entry points diff --git a/apps/dashboard/app/foldkit/dashboard/model.ts b/apps/dashboard/app/foldkit/dashboard/model.ts index 61034cfbe..5fd212049 100644 --- a/apps/dashboard/app/foldkit/dashboard/model.ts +++ b/apps/dashboard/app/foldkit/dashboard/model.ts @@ -1,5 +1,5 @@ import { Schema as S } from "effect"; -import { UserRoleSchema } from "@vektorprogrammet/http-api"; +import { UserRoleSchema } from "@vektorprogrammet/rpc"; import { RecruitmentInput } from "../recruitment/model"; import { SchedulingInput } from "../scheduling/model"; import { isAdmissionPath } from "./navigation"; diff --git a/apps/dashboard/app/foldkit/dashboard/shell-route.test.ts b/apps/dashboard/app/foldkit/dashboard/shell-route.test.ts index cfb21d277..ca0c714d5 100644 --- a/apps/dashboard/app/foldkit/dashboard/shell-route.test.ts +++ b/apps/dashboard/app/foldkit/dashboard/shell-route.test.ts @@ -1,34 +1,67 @@ +import { PersonId } from "@vektorprogrammet/domain/organization"; +import { OwnProfileResource, StrongETag, type NativeProblemCode } from "@vektorprogrammet/rpc"; +import { Schema } from "effect"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { conditionalReadHeaders, nativeProblemResponse, nativeSessionResponse, sessionCookie } from "../../../test/native-http"; +import { + isNativeRpcRequest, + nativeRpcProblem, + nativeRpcSuccess, + nativeSession, + readNativeRpcCall, +} from "../../../test/native-rpc"; +import { sessionCookie } from "../../../test/native-http"; vi.hoisted(() => vi.stubEnv("API_URL", "http://api.test")); import { dashboardShellVisibility } from "./shell"; import { loadDashboardShell } from "./shell.server"; -const profile = { personId: "person-1", firstName: "Ada", lastName: "Lovelace", email: "ada@example.invalid", phone: "+47 12345678", role: "ROLE_TEAM_MEMBER", nameRevision: 0, contactRevision: 0 }; - -const requests: Request[] = []; - -let profileResponse = () => Response.json(profile, { headers: conditionalReadHeaders }); +const ownProfile = Schema.encodeSync(Schema.toCodecJson(OwnProfileResource))( + OwnProfileResource.make({ + profile: { + personId: PersonId.make("person-1"), + firstName: "Ada", + lastName: "Lovelace", + email: "ada@example.invalid", + phone: "+47 12345678", + role: "ROLE_TEAM_MEMBER", + nameRevision: 0, + contactRevision: 0, + }, + etag: StrongETag.make('"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"'), + }), +); + +/** Every backend request, as an RPC tag or an HTTP path. */ +const requests: string[] = []; + +let profileProblem: NativeProblemCode | undefined; const load = () => loadDashboardShell(new Request("http://dashboard.test/dashboard/skoler", { headers: { cookie: sessionCookie } })); beforeEach(() => { requests.length = 0; - profileResponse = () => Response.json(profile, { headers: conditionalReadHeaders }); + profileProblem = undefined; vi.stubGlobal("fetch", vi.fn(async (input, init) => { - const request = new Request(input, init); - requests.push(request); - const path = new URL(request.url).pathname; + if (!isNativeRpcRequest(input)) { + const path = new URL(new Request(input, init).url).pathname; + requests.push(path); + + return Response.json({ user: { name: "Member Session", email: "member@example.invalid" } }); + } - if (path === "/api/session") return nativeSessionResponse(); + const call = await readNativeRpcCall(input, init); + requests.push(call.tag); - if (path === "/api/auth/get-session") return Response.json({ user: { name: "Member Session", email: "member@example.invalid" } }); + if (call.tag === "system.readSession") return nativeRpcSuccess(call, nativeSession); - if (path === "/api/auth/sign-out") return new Response(null, { status: 204 }); + if (call.tag === "profile.readOwnProfile") { + return profileProblem === undefined + ? nativeRpcSuccess(call, ownProfile) + : nativeRpcProblem(call, profileProblem); + } - return profileResponse(); + return nativeRpcSuccess(call, null); })); }); @@ -36,23 +69,23 @@ afterEach(() => vi.unstubAllGlobals()); describe("parent dashboard authority gate", () => { it("keeps an authenticated authority-denied actor in a shell with session identity", async () => { - profileResponse = () => nativeProblemResponse("authority.denied"); + profileProblem = "authority.denied"; await expect(load()).resolves.toEqual({ user: { name: "Member Session", email: "member@example.invalid" }, isAdmin: false, hasOrganizationContext: false }); - expect(requests.map(request => new URL(request.url).pathname)).toContain("/api/auth/get-session"); - expect(requests.some(request => new URL(request.url).pathname === "/api/auth/sign-out")).toBe(false); + expect(requests).toContain("/api/auth/get-session"); + expect(requests).not.toContain("system.deleteSession"); }); it("returns a canonical profile identity for an active team member", async () => { await expect(load()).resolves.toEqual({ user: { name: "Ada Lovelace", email: "ada@example.invalid" }, isAdmin: false, hasOrganizationContext: true }); - expect(requests.some(request => new URL(request.url).pathname === "/api/auth/get-session")).toBe(false); + expect(requests).toEqual(["system.readSession", "profile.readOwnProfile"]); }); it("redirects only an unauthorized profile request as expired", async () => { - profileResponse = () => nativeProblemResponse("credential.invalid"); + profileProblem = "credential.invalid"; await expect(load()).rejects.toMatchObject({ status: 302 }); }); it("surfaces a profile infrastructure failure without dropping the session", async () => { - profileResponse = () => nativeProblemResponse("profile.unavailable"); + profileProblem = "profile.unavailable"; await expect(load()).rejects.toMatchObject({ status: 503 }); - expect(requests.some(request => new URL(request.url).pathname === "/api/auth/sign-out")).toBe(false); + expect(requests).not.toContain("system.deleteSession"); }); }); diff --git a/apps/dashboard/app/foldkit/dashboard/shell.server.ts b/apps/dashboard/app/foldkit/dashboard/shell.server.ts index fc8c870a5..d2fa88b59 100644 --- a/apps/dashboard/app/foldkit/dashboard/shell.server.ts +++ b/apps/dashboard/app/foldkit/dashboard/shell.server.ts @@ -1,16 +1,15 @@ import { nativeProblemFrom } from "../../lib/native-problem"; -import { createAuthenticatedClient } from "../../lib/api.server"; +import { callNative } from "../../lib/api.server"; import { expiredSessionRedirect, loadSessionIdentity, requireAuth } from "../../lib/auth.server"; import type { DashboardShellData } from "./shell"; export async function loadDashboardShell(request: Request): Promise { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); try { - const { body: profile } = await client.profile.readOwnProfile({ headers: {} }); - - if (profile === undefined) throw new Error("Profile response did not include a body"); + const { profile } = await callNative(cookie, request, (client) => + client["profile.readOwnProfile"](), + ); return { user: { diff --git a/apps/dashboard/app/foldkit/dated-school-service/AGENTS.md b/apps/dashboard/app/foldkit/dated-school-service/AGENTS.md index 478529792..3abca2ee7 100644 --- a/apps/dashboard/app/foldkit/dated-school-service/AGENTS.md +++ b/apps/dashboard/app/foldkit/dated-school-service/AGENTS.md @@ -3,7 +3,7 @@ # apps/dashboard/app/foldkit/dated-school-service This folder holds Placements code under another name. Dated school-service commitments. -The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the generated SDK. +The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the RPC client. The Placements context in other folders: diff --git a/apps/dashboard/app/foldkit/dated-school-service/model.ts b/apps/dashboard/app/foldkit/dated-school-service/model.ts index 98dacf744..7a91f6b24 100644 --- a/apps/dashboard/app/foldkit/dated-school-service/model.ts +++ b/apps/dashboard/app/foldkit/dated-school-service/model.ts @@ -1,4 +1,4 @@ -import { CoverageBoardResource, OwnCoverageResource, PlacementBoardResource } from "@vektorprogrammet/http-api"; +import { CoverageBoardResource, OwnCoverageResource, PlacementBoardResource } from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; export const Input = S.Struct({ diff --git a/apps/dashboard/app/foldkit/interview/AGENTS.md b/apps/dashboard/app/foldkit/interview/AGENTS.md index 5978bcfab..f39e3191d 100644 --- a/apps/dashboard/app/foldkit/interview/AGENTS.md +++ b/apps/dashboard/app/foldkit/interview/AGENTS.md @@ -3,7 +3,7 @@ # apps/dashboard/app/foldkit/interview This folder holds Recruitment code under another name. Interview conduct. -The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the generated SDK. +The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the RPC client. The Recruitment context in other folders: diff --git a/apps/dashboard/app/foldkit/interview/bridge.ts b/apps/dashboard/app/foldkit/interview/bridge.ts index d66637bcb..7609d6960 100644 --- a/apps/dashboard/app/foldkit/interview/bridge.ts +++ b/apps/dashboard/app/foldkit/interview/bridge.ts @@ -1,6 +1,6 @@ import { RecruitmentInvitationResponseMessageSchema, -RecruitmentInvitationResponseObservationSchema, } from "@vektorprogrammet/http-api" -import { StrongETag } from "@vektorprogrammet/http-api"; +RecruitmentInvitationResponseObservationSchema, } from "@vektorprogrammet/rpc" +import { StrongETag } from "@vektorprogrammet/rpc"; import { Schema as S, Match } from "effect"; export const InvitationInteractionIdSchema = S.String.check(S.isPattern(/^[a-f0-9]{32}$/)); diff --git a/apps/dashboard/app/foldkit/interview/browser-client.test.ts b/apps/dashboard/app/foldkit/interview/browser-client.test.ts index 68618d30c..16add18e1 100644 --- a/apps/dashboard/app/foldkit/interview/browser-client.test.ts +++ b/apps/dashboard/app/foldkit/interview/browser-client.test.ts @@ -1,4 +1,4 @@ -import { StrongETag } from "@vektorprogrammet/http-api"; +import { StrongETag } from "@vektorprogrammet/rpc"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Effect } from "effect"; import { createBrowserInterviewClient } from "./browser-client"; diff --git a/apps/dashboard/app/foldkit/interview/browser-client.ts b/apps/dashboard/app/foldkit/interview/browser-client.ts index 2223d5035..6b665e5e6 100644 --- a/apps/dashboard/app/foldkit/interview/browser-client.ts +++ b/apps/dashboard/app/foldkit/interview/browser-client.ts @@ -1,4 +1,4 @@ -import type { StrongETag } from "@vektorprogrammet/http-api"; +import type { StrongETag } from "@vektorprogrammet/rpc"; import { Effect, flow, Option, Schema as S } from "effect"; import { decodeInvitationInteractionId, InvitationBridgeFailureSchema, InvitationResponseResourceSchema, INVITATION_INTERACTION_HEADER, type InvitationBridgeFailure, type InvitationInteractionId, type InvitationBridgeOperation, type InvitationResponseResource } from "./bridge"; diff --git a/apps/dashboard/app/foldkit/interview/command.ts b/apps/dashboard/app/foldkit/interview/command.ts index d727aee8e..9f294fb9a 100644 --- a/apps/dashboard/app/foldkit/interview/command.ts +++ b/apps/dashboard/app/foldkit/interview/command.ts @@ -1,5 +1,5 @@ -import { RecruitmentInvitationResponseMessageSchema } from "@vektorprogrammet/http-api" -import { StrongETag } from "@vektorprogrammet/http-api"; +import { RecruitmentInvitationResponseMessageSchema } from "@vektorprogrammet/rpc" +import { StrongETag } from "@vektorprogrammet/rpc"; import { Effect, Schema as S } from "effect"; import { Command } from "foldkit"; import type { InvitationResponseClient } from "./browser-client"; diff --git a/apps/dashboard/app/foldkit/interview/message.ts b/apps/dashboard/app/foldkit/interview/message.ts index e771b64bc..a891cf080 100644 --- a/apps/dashboard/app/foldkit/interview/message.ts +++ b/apps/dashboard/app/foldkit/interview/message.ts @@ -1,4 +1,4 @@ -import { StrongETag } from "@vektorprogrammet/http-api"; +import { StrongETag } from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; import { taggedStruct } from "foldkit/schema"; import { diff --git a/apps/dashboard/app/foldkit/interview/model.ts b/apps/dashboard/app/foldkit/interview/model.ts index bfdd5fc13..4c6d433cc 100644 --- a/apps/dashboard/app/foldkit/interview/model.ts +++ b/apps/dashboard/app/foldkit/interview/model.ts @@ -1,4 +1,4 @@ -import { StrongETag } from "@vektorprogrammet/http-api"; +import { StrongETag } from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; import { AsyncData, FieldValidation } from "foldkit"; import { diff --git a/apps/dashboard/app/foldkit/interview/update.property.test.ts b/apps/dashboard/app/foldkit/interview/update.property.test.ts index d4ec86bf1..bb0dd769f 100644 --- a/apps/dashboard/app/foldkit/interview/update.property.test.ts +++ b/apps/dashboard/app/foldkit/interview/update.property.test.ts @@ -1,5 +1,5 @@ import { Predicate } from "effect"; -import { StrongETag } from "@vektorprogrammet/http-api"; +import { StrongETag } from "@vektorprogrammet/rpc"; import { expect, it } from "@effect/vitest"; import { Effect, Schema as S } from "effect"; import { Arbitrary } from "effect/unstable/arbitrary"; diff --git a/apps/dashboard/app/foldkit/interview/update.ts b/apps/dashboard/app/foldkit/interview/update.ts index ad80e9d85..369abf860 100644 --- a/apps/dashboard/app/foldkit/interview/update.ts +++ b/apps/dashboard/app/foldkit/interview/update.ts @@ -1,6 +1,6 @@ import { Predicate } from "effect"; -import { containsRecruitmentInvitationCapabilitySequence, -RecruitmentInvitationResponseMessageSchema, } from "@vektorprogrammet/http-api" +import { containsRecruitmentInvitationCapabilitySequence } from "@vektorprogrammet/domain/recruitment"; +import { RecruitmentInvitationResponseMessageSchema } from "@vektorprogrammet/rpc" import { Match as M, Schema as S } from "effect"; import { AsyncData, FieldValidation, Update } from "foldkit"; import type { InterviewCommands } from "./command"; diff --git a/apps/dashboard/app/foldkit/organization/AGENTS.md b/apps/dashboard/app/foldkit/organization/AGENTS.md index 97da6cd99..762219aaf 100644 --- a/apps/dashboard/app/foldkit/organization/AGENTS.md +++ b/apps/dashboard/app/foldkit/organization/AGENTS.md @@ -3,7 +3,7 @@ # apps/dashboard/app/foldkit/organization This folder holds the Organization bounded context in `apps/dashboard/app/foldkit`. -The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the generated SDK. +The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the RPC client. The Organization context in other folders: diff --git a/apps/dashboard/app/foldkit/organization/browser-client.ts b/apps/dashboard/app/foldkit/organization/browser-client.ts index a5cb19168..77994f5b7 100644 --- a/apps/dashboard/app/foldkit/organization/browser-client.ts +++ b/apps/dashboard/app/foldkit/organization/browser-client.ts @@ -1,27 +1,28 @@ -import type { DepartmentJson, -FieldOfStudyJson, -TeamJson, } from "@vektorprogrammet/http-api" -import { createEffectClient, type EffectSdkFailure } from "@vektorprogrammet/sdk/effect"; -import { Data, Effect } from "effect"; -import { resolveBrowserApiUrl } from "../../lib/browser-api"; +import type { + DepartmentJson, + FieldOfStudyJson, + NativeRpcClient, + TeamJson, +} from "@vektorprogrammet/rpc"; +import type { Effect } from "effect"; +import { callBrowserNative, type NativeAnswerInvalid } from "../../lib/browser-native"; -/** A catalog read that sent no cache validators was answered 304 Not Modified. */ -export class OrganizationCatalogNotModified extends Data.TaggedError( - "OrganizationCatalogNotModified", -)<{ readonly message: string }> {} +type NativeClient = NativeRpcClient["Service"]; + +/** The failure of one native RPC call: its declared problems, transport errors, and an invalid answer. */ +type RpcFailure = + | Effect.Error> + | NativeAnswerInvalid; export interface OrganizationCatalogOperations { readonly listDepartments: Effect.Effect< readonly DepartmentJson[], - EffectSdkFailure<"organization", "listDepartments"> | OrganizationCatalogNotModified - >; - readonly listTeams: Effect.Effect< - readonly TeamJson[], - EffectSdkFailure<"organization", "listTeams"> | OrganizationCatalogNotModified + RpcFailure<"organization.listDepartments"> >; + readonly listTeams: Effect.Effect>; readonly listFieldOfStudies: Effect.Effect< readonly FieldOfStudyJson[], - EffectSdkFailure<"organization", "listFieldOfStudies"> | OrganizationCatalogNotModified + RpcFailure<"organization.listFieldOfStudies"> >; } @@ -29,52 +30,10 @@ export interface OrganizationCatalogClient { readonly organization: OrganizationCatalogOperations; } -export const createBrowserOrganizationCatalogClient = (): OrganizationCatalogClient => { - const client = createEffectClient( - resolveBrowserApiUrl(import.meta.env.VITE_API_URL, globalThis.location.origin), - ); - - return { - organization: { - listDepartments: client.organization - .listDepartments({ headers: {} }) - .pipe( - Effect.flatMap(({ body }) => - body === undefined - ? Effect.fail( - new OrganizationCatalogNotModified({ - message: "listDepartments returned 304 without cache validators", - }), - ) - : Effect.succeed(body), - ), - ), - listTeams: client.organization - .listTeams({ headers: {} }) - .pipe( - Effect.flatMap(({ body }) => - body === undefined - ? Effect.fail( - new OrganizationCatalogNotModified({ - message: "listTeams returned 304 without cache validators", - }), - ) - : Effect.succeed(body), - ), - ), - listFieldOfStudies: client.organization - .listFieldOfStudies({ headers: {} }) - .pipe( - Effect.flatMap(({ body }) => - body === undefined - ? Effect.fail( - new OrganizationCatalogNotModified({ - message: "listFieldOfStudies returned 304 without cache validators", - }), - ) - : Effect.succeed(body), - ), - ), - }, - }; -}; +export const createBrowserOrganizationCatalogClient = (): OrganizationCatalogClient => ({ + organization: { + listDepartments: callBrowserNative((client) => client["organization.listDepartments"]()), + listTeams: callBrowserNative((client) => client["organization.listTeams"]()), + listFieldOfStudies: callBrowserNative((client) => client["organization.listFieldOfStudies"]()), + }, +}); diff --git a/apps/dashboard/app/foldkit/organization/delegations.ts b/apps/dashboard/app/foldkit/organization/delegations.ts index d6cf7e9a6..e39956d61 100644 --- a/apps/dashboard/app/foldkit/organization/delegations.ts +++ b/apps/dashboard/app/foldkit/organization/delegations.ts @@ -5,13 +5,10 @@ import type { Html, HtmlBuilder } from "foldkit/html"; import { DelegableCapability, DelegationArea, - DelegationCommand, - DelegationManagement, - IdempotencyKey, type OrganizationCapability, -} from "@vektorprogrammet/http-api"; -import { createEffectClient } from "@vektorprogrammet/sdk/effect"; -import { resolveBrowserApiUrl } from "../../lib/browser-api"; +} from "@vektorprogrammet/domain/authz"; +import { DelegationCommand, DelegationManagement, IdempotencyKey } from "@vektorprogrammet/rpc"; +import { callBrowserNative } from "../../lib/browser-native"; import { nativeProblemFrom } from "../../lib/native-problem"; import "./styles.css"; @@ -404,16 +401,15 @@ const view = (model: Model, h: HtmlBuilder): Html => { }; export const embedDelegationManagement = (container: HTMLElement): (() => void) => { - const client = createEffectClient( - resolveBrowserApiUrl(import.meta.env.VITE_API_URL, globalThis.location.origin), - ); - const Load = Command.define("LoadDelegationManagement", { args: { requestId: S.Int }, messages: [Loaded, LoadFailed], execute: ({ requestId }) => Effect.gen(function* () { - const { body } = yield* client.organization.readDelegationManagement(); + const body = yield* callBrowserNative((client) => + client["organization.readDelegationManagement"](), + ); + const now = DateTime.formatIso(yield* DateTime.now); return Loaded({ requestId, snapshot: body, commandId: crypto.randomUUID(), now }); @@ -424,23 +420,13 @@ export const embedDelegationManagement = (container: HTMLElement): (() => void) ), }); - // The pinned HttpApi client distributes a union payload over whole requests. - // Narrow only at this transport boundary; the domain owns all transitions. - const execute = Effect.fn("organization.executeDelegation")(function* ( - command: DelegationCommand, - ) { - const headers = { "idempotency-key": IdempotencyKey.make(command.commandId) }; - - return yield* Match.value(command).pipe( - Match.tag("IssueDelegation", (payload) => - client.organization.executeDelegation({ headers, payload }), - ), - Match.tag("EndDelegation", (payload) => - client.organization.executeDelegation({ headers, payload }), - ), - Match.exhaustive, + const execute = (command: DelegationCommand) => + callBrowserNative((client) => + client["organization.executeDelegation"]({ + idempotencyKey: IdempotencyKey.make(command.commandId), + request: command, + }), ); - }); const Save = Command.define("SaveDelegationCommand", { args: { requestId: S.Int, command: DelegationCommand }, diff --git a/apps/dashboard/app/foldkit/organization/management.ts b/apps/dashboard/app/foldkit/organization/management.ts index e605f9ed6..e32d43e4f 100644 --- a/apps/dashboard/app/foldkit/organization/management.ts +++ b/apps/dashboard/app/foldkit/organization/management.ts @@ -3,9 +3,8 @@ import { Predicate, DateTime, Effect, Schema as S, Option, Match, Struct, flow } import { Command, Runtime, Update } from "foldkit"; import { taggedStruct } from "foldkit/schema"; import type { Html, HtmlBuilder } from "foldkit/html"; -import { AppointmentManagement, OrganizationLifecycleCommand, IdempotencyKey } from "@vektorprogrammet/http-api"; -import { createEffectClient } from "@vektorprogrammet/sdk/effect"; -import { resolveBrowserApiUrl } from "../../lib/browser-api"; +import { AppointmentManagement, OrganizationLifecycleCommand, IdempotencyKey } from "@vektorprogrammet/rpc"; +import { callBrowserNative } from "../../lib/browser-native"; import { nativeProblemFrom } from "../../lib/native-problem"; import "./styles.css"; @@ -161,33 +160,14 @@ const parseClassifyTeam = S.decodeUnknownOption(OrganizationLifecycleCommand.cas const parseRecogniseDepartment = S.decodeUnknownOption(OrganizationLifecycleCommand.cases.RecogniseDepartment.mapFields(Struct.omit(["_tag"]))); export const embedAppointmentManagement=(container:HTMLElement):(()=>void)=>{ - const client=createEffectClient(resolveBrowserApiUrl(import.meta.env.VITE_API_URL,globalThis.location.origin)); - const Load=Command.define("LoadAppointmentManagement",{args:{requestId:S.Int},messages:[Loaded,LoadFailed],execute:({requestId})=>Effect.gen(function* () { - const {body}=yield* client.organization.readAppointmentManagement(); + const body=yield* callBrowserNative((client)=>client["organization.readAppointmentManagement"]()); const now=DateTime.formatIso(yield* DateTime.now); return Loaded({requestId,snapshot:body,commandId:crypto.randomUUID(),now}); }).pipe(Effect.catch(error=>Effect.succeed(LoadFailed({requestId,message:failureMessage(error)}))))}); - // The pinned HttpApi client distributes a union payload over whole requests. - // Narrow only at this transport boundary; the domain owns all transitions. - const execute=Effect.fn("organization.executeLifecycle")(function* (command:OrganizationLifecycleCommand) { - const headers={"idempotency-key":IdempotencyKey.make(command.commandId)}; - - return yield* Match.value(command).pipe( -Match.tag("Appoint", (command) => {return client.organization.executeLifecycle({headers,payload:command});}), -Match.tag("ReviseAppointment", (command) => {return client.organization.executeLifecycle({headers,payload:command});}), -Match.tag("EndAppointment", (command) => {return client.organization.executeLifecycle({headers,payload:command});}), -Match.tag("SuspendAppointment", (command) => {return client.organization.executeLifecycle({headers,payload:command});}), -Match.tag("ReinstateAppointment", (command) => {return client.organization.executeLifecycle({headers,payload:command});}), -Match.tag("CreateNationalBoard", (command) => {return client.organization.executeLifecycle({headers,payload:command});}), -Match.tag("ChangeAccountAccess", (command) => {return client.organization.executeLifecycle({headers,payload:command});}), -Match.tag("ClassifyTeam", (command) => {return client.organization.executeLifecycle({headers,payload:command});}), -Match.tag("RecogniseDepartment", (command) => {return client.organization.executeLifecycle({headers,payload:command});}), -Match.exhaustive -); - }); + const execute=(command:OrganizationLifecycleCommand)=>callBrowserNative((client)=>client["organization.executeLifecycle"]({idempotencyKey:IdempotencyKey.make(command.commandId),request:command})); const Save=Command.define("SaveOrganizationLifecycle",{args:{requestId:S.Int,command:OrganizationLifecycleCommand},messages:[Saved,Failed],execute:({requestId,command})=>execute(command).pipe( Effect.map(()=>Saved({requestId,commandId:crypto.randomUUID()})), diff --git a/apps/dashboard/app/foldkit/organization/model.ts b/apps/dashboard/app/foldkit/organization/model.ts index 35afd7f3c..4f5b4ea22 100644 --- a/apps/dashboard/app/foldkit/organization/model.ts +++ b/apps/dashboard/app/foldkit/organization/model.ts @@ -1,6 +1,6 @@ import { DepartmentJsonSchema, FieldOfStudyJsonSchema, -TeamJsonSchema, } from "@vektorprogrammet/http-api" +TeamJsonSchema, } from "@vektorprogrammet/rpc" import { Schema as S } from "effect"; import { AsyncData } from "foldkit"; diff --git a/apps/dashboard/app/foldkit/organization/update.test.ts b/apps/dashboard/app/foldkit/organization/update.test.ts index 870f6c52b..fbd3e2708 100644 --- a/apps/dashboard/app/foldkit/organization/update.test.ts +++ b/apps/dashboard/app/foldkit/organization/update.test.ts @@ -1,7 +1,7 @@ import { Predicate } from "effect"; import { DepartmentJsonSchema, FieldOfStudyJsonSchema, -TeamJsonSchema, } from "@vektorprogrammet/http-api" +TeamJsonSchema, } from "@vektorprogrammet/rpc" import { Effect, Schema as S } from "effect"; import { AsyncData } from "foldkit"; import { describe, expect, it } from "vitest"; diff --git a/apps/dashboard/app/foldkit/organization/view.test.ts b/apps/dashboard/app/foldkit/organization/view.test.ts index 44a8e6c50..7d20b0d29 100644 --- a/apps/dashboard/app/foldkit/organization/view.test.ts +++ b/apps/dashboard/app/foldkit/organization/view.test.ts @@ -1,7 +1,7 @@ import { Scene } from "foldkit/test"; import { DepartmentJsonSchema, FieldOfStudyJsonSchema, -TeamJsonSchema, } from "@vektorprogrammet/http-api" +TeamJsonSchema, } from "@vektorprogrammet/rpc" import { Schema as S } from "effect"; import { describe, it } from "vitest"; import { OrganizationCatalogData, init, type Model, TeamCatalogSnapshot, FieldOfStudyCatalogSnapshot } from "./model"; diff --git a/apps/dashboard/app/foldkit/profile/AGENTS.md b/apps/dashboard/app/foldkit/profile/AGENTS.md index b7e3dbbb4..d2bd3a8ac 100644 --- a/apps/dashboard/app/foldkit/profile/AGENTS.md +++ b/apps/dashboard/app/foldkit/profile/AGENTS.md @@ -3,7 +3,7 @@ # apps/dashboard/app/foldkit/profile This folder holds People code under another name. Profile self-service under its older name. -The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the generated SDK. +The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the RPC client. The People context in other folders: diff --git a/apps/dashboard/app/foldkit/profile/command.test.ts b/apps/dashboard/app/foldkit/profile/command.test.ts index 1d264614e..943bcea57 100644 --- a/apps/dashboard/app/foldkit/profile/command.test.ts +++ b/apps/dashboard/app/foldkit/profile/command.test.ts @@ -1,4 +1,4 @@ -import { StrongETag } from "@vektorprogrammet/http-api"; +import { StrongETag } from "@vektorprogrammet/rpc"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Effect } from "effect"; import { ProfileBridgeFailure } from "./bridge"; diff --git a/apps/dashboard/app/foldkit/profile/message.ts b/apps/dashboard/app/foldkit/profile/message.ts index 648171d09..9856c209e 100644 --- a/apps/dashboard/app/foldkit/profile/message.ts +++ b/apps/dashboard/app/foldkit/profile/message.ts @@ -1,4 +1,4 @@ -import { StrongETag, UserProfileResponse } from "@vektorprogrammet/http-api"; +import { StrongETag, UserProfileResponse } from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; import { taggedStruct } from "foldkit/schema"; import { ProfileBridgeFailure, ProfileRequestId } from "./bridge"; diff --git a/apps/dashboard/app/foldkit/profile/model.ts b/apps/dashboard/app/foldkit/profile/model.ts index f9ab78228..b06ea3be5 100644 --- a/apps/dashboard/app/foldkit/profile/model.ts +++ b/apps/dashboard/app/foldkit/profile/model.ts @@ -1,4 +1,4 @@ -import { ProfileMergePatch, StrongETag, UserProfileResponse } from "@vektorprogrammet/http-api"; +import { ProfileMergePatch, StrongETag, UserProfileResponse } from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; import { FieldValidation } from "foldkit"; import { ProfileBridgeFailure, ProfileRequestId } from "./bridge"; diff --git a/apps/dashboard/app/foldkit/recruitment-maintenance/AGENTS.md b/apps/dashboard/app/foldkit/recruitment-maintenance/AGENTS.md index 70ccbddd9..11ba1becc 100644 --- a/apps/dashboard/app/foldkit/recruitment-maintenance/AGENTS.md +++ b/apps/dashboard/app/foldkit/recruitment-maintenance/AGENTS.md @@ -3,7 +3,7 @@ # apps/dashboard/app/foldkit/recruitment-maintenance This folder holds Recruitment code under another name. Questionnaire and interview staffing maintenance. -The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the generated SDK. +The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the RPC client. The Recruitment context in other folders: diff --git a/apps/dashboard/app/foldkit/recruitment-maintenance/command.ts b/apps/dashboard/app/foldkit/recruitment-maintenance/command.ts index 9c2544c54..44b975ffd 100644 --- a/apps/dashboard/app/foldkit/recruitment-maintenance/command.ts +++ b/apps/dashboard/app/foldkit/recruitment-maintenance/command.ts @@ -1,8 +1,7 @@ import { Effect, Match, Schema as S } from "effect"; import { Command } from "foldkit"; -import { IdempotencyKey, RecruitmentMaintenanceCommand } from "@vektorprogrammet/http-api"; -import { createEffectClient } from "@vektorprogrammet/sdk/effect"; -import { resolveBrowserApiUrl } from "../../lib/browser-api"; +import { IdempotencyKey, RecruitmentMaintenanceCommand } from "@vektorprogrammet/rpc"; +import { callBrowserNative } from "../../lib/browser-native"; import { nativeProblemFrom } from "../../lib/native-problem"; import { Mode } from "./model"; import { @@ -22,10 +21,6 @@ export interface MaintenanceCommands { } export const commandsFor = (): MaintenanceCommands => { - const client = createEffectClient( - resolveBrowserApiUrl(import.meta.env.VITE_API_URL, globalThis.location.origin), - ); - const Load = Command.define("LoadRecruitmentMaintenance", { args: { mode: Mode, requestId: S.Int }, messages: [SucceededQuestionnaires, SucceededStaffing, FailedLoad], @@ -34,14 +29,18 @@ export const commandsFor = (): MaintenanceCommands => { const commandId = crypto.randomUUID(); if (mode === "Questionnaires") { - const result = yield* client.recruitment.readQuestionnaires(); + const data = yield* callBrowserNative((client) => + client["recruitment.readQuestionnaires"](), + ); - return SucceededQuestionnaires({ requestId, data: result.body, commandId }); + return SucceededQuestionnaires({ requestId, data, commandId }); } - const result = yield* client.recruitment.readInterviewStaffing(); + const data = yield* callBrowserNative((client) => + client["recruitment.readInterviewStaffing"](), + ); - return SucceededStaffing({ requestId, data: result.body, commandId }); + return SucceededStaffing({ requestId, data, commandId }); }).pipe( Effect.catch((error) => Effect.succeed( @@ -58,13 +57,13 @@ export const commandsFor = (): MaintenanceCommands => { args: { command: RecruitmentMaintenanceCommand }, messages: [SucceededSave, FailedSave], execute: ({ command }) => - client.recruitment - .maintainRecruitment({ - payload: command, - headers: { "idempotency-key": IdempotencyKey.make(command.commandId) }, - }) - .pipe( - Effect.map(({ body }) => SucceededSave({ commandId: command.commandId, result: body })), + callBrowserNative((client) => + client["recruitment.maintainRecruitment"]({ + idempotencyKey: IdempotencyKey.make(command.commandId), + request: command, + }), + ).pipe( + Effect.map((result) => SucceededSave({ commandId: command.commandId, result })), Effect.catch((error) => { const problem = nativeProblemFrom(error); diff --git a/apps/dashboard/app/foldkit/recruitment-maintenance/message.ts b/apps/dashboard/app/foldkit/recruitment-maintenance/message.ts index ce1c5d61f..7b57ee066 100644 --- a/apps/dashboard/app/foldkit/recruitment-maintenance/message.ts +++ b/apps/dashboard/app/foldkit/recruitment-maintenance/message.ts @@ -5,7 +5,7 @@ import { InterviewStaffingManagement, RecruitmentMaintenanceResult, RecruitmentInterviewQuestionKindSchema, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; export const SelectedRecord = taggedStruct("SelectedRecord", { id: S.String }); diff --git a/apps/dashboard/app/foldkit/recruitment-maintenance/model.ts b/apps/dashboard/app/foldkit/recruitment-maintenance/model.ts index df3bb913d..403590526 100644 --- a/apps/dashboard/app/foldkit/recruitment-maintenance/model.ts +++ b/apps/dashboard/app/foldkit/recruitment-maintenance/model.ts @@ -4,7 +4,7 @@ import { QuestionnaireManagement, InterviewStaffingManagement, RecruitmentInterviewQuestionKindSchema, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; export const Mode = S.Literals(["Questionnaires", "Staffing"]); diff --git a/apps/dashboard/app/foldkit/recruitment-maintenance/update.ts b/apps/dashboard/app/foldkit/recruitment-maintenance/update.ts index 6294a5923..b57d6ac87 100644 --- a/apps/dashboard/app/foldkit/recruitment-maintenance/update.ts +++ b/apps/dashboard/app/foldkit/recruitment-maintenance/update.ts @@ -1,5 +1,6 @@ import { Match, Option, Predicate, Schema } from "effect"; -import { RecruitmentMaintenanceCommand, PersonId } from "@vektorprogrammet/http-api"; +import { PersonId } from "@vektorprogrammet/domain/organization"; +import { RecruitmentMaintenanceCommand } from "@vektorprogrammet/rpc"; import type { Update } from "foldkit"; import type { MaintenanceCommands } from "./command"; import type { Message } from "./message"; diff --git a/apps/dashboard/app/foldkit/recruitment-maintenance/view.ts b/apps/dashboard/app/foldkit/recruitment-maintenance/view.ts index 726275262..8d21cda15 100644 --- a/apps/dashboard/app/foldkit/recruitment-maintenance/view.ts +++ b/apps/dashboard/app/foldkit/recruitment-maintenance/view.ts @@ -1,6 +1,6 @@ import { Input, Select, Textarea } from "@foldkit/ui"; import { Option, Predicate, Schema } from "effect"; -import { RecruitmentInterviewQuestionKindSchema } from "@vektorprogrammet/http-api"; +import { RecruitmentInterviewQuestionKindSchema } from "@vektorprogrammet/rpc"; import type { Html, HtmlBuilder } from "foldkit/html"; import type { Model } from "./model"; import { diff --git a/apps/dashboard/app/foldkit/recruitment/AGENTS.md b/apps/dashboard/app/foldkit/recruitment/AGENTS.md index 957399d09..a731f7f52 100644 --- a/apps/dashboard/app/foldkit/recruitment/AGENTS.md +++ b/apps/dashboard/app/foldkit/recruitment/AGENTS.md @@ -3,7 +3,7 @@ # apps/dashboard/app/foldkit/recruitment This folder holds the Recruitment bounded context in `apps/dashboard/app/foldkit`. -The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the generated SDK. +The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the RPC client. The Recruitment context in other folders: diff --git a/apps/dashboard/app/foldkit/recruitment/bridge-route.test.ts b/apps/dashboard/app/foldkit/recruitment/bridge-route.test.ts index 9b7d73e09..71a33755b 100644 --- a/apps/dashboard/app/foldkit/recruitment/bridge-route.test.ts +++ b/apps/dashboard/app/foldkit/recruitment/bridge-route.test.ts @@ -1,74 +1,112 @@ -import type { NativeProblemCode } from "@vektorprogrammet/http-api"; +import type { NativeProblemCode } from "@vektorprogrammet/rpc"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { nativeProblemResponse, nativeSessionResponse, routeArgs, sessionCookie } from "../../../test/native-http"; +import { routeArgs, sessionCookie } from "../../../test/native-http"; +import { + isNativeRpcRequest, + nativeRpcProblem, + nativeRpcSuccess, + nativeSession, + readNativeRpcCall, + type NativeRpcCall, +} from "../../../test/native-rpc"; import { RecruitmentBridgeFailure } from "./bridge"; vi.hoisted(() => vi.stubEnv("API_URL", "http://api.test")); import { action } from "../../routes/__foldkit.recruitment"; -const cancelPath = "/api/recruitment/interviews/recruitment-interview-50:cancel"; +let cancelAnswer: (call: NativeRpcCall) => Response = (call) => + nativeRpcProblem(call, "precondition.failed"); -let cancelResponse: () => Response = () => nativeProblemResponse("precondition.failed"); - -const requests: Request[] = []; +const calls: NativeRpcCall[] = []; beforeEach(() => { - requests.length = 0; - vi.stubGlobal("fetch", vi.fn(async (input, init) => { - const request = new Request(input, init); - requests.push(request); - const path = new URL(request.url).pathname; + calls.length = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input, init) => { + if (!isNativeRpcRequest(input)) { + throw new Error(`Unexpected recruitment request: ${new Request(input, init).url}`); + } + + const call = await readNativeRpcCall(input, init); + calls.push(call); - if (path === "/api/session") return nativeSessionResponse(); + if (call.tag === "system.readSession") return nativeRpcSuccess(call, nativeSession); - if (path === cancelPath) return cancelResponse(); - throw new Error(`Unexpected recruitment request: ${path}`); - })); + if (call.tag === "recruitment.cancelInterview") return cancelAnswer(call); + throw new Error(`Unexpected recruitment call: ${call.tag}`); + }), + ); }); afterEach(() => vi.unstubAllGlobals()); -const cancel = () => action(routeArgs(new Request("http://dashboard.test/recruitment", { - method: "POST", - headers: { origin: "http://dashboard.test", "content-type": "application/json", cookie: sessionCookie }, - body: JSON.stringify({ - operation: "cancelInterview", - params: { interviewId: "recruitment-interview-50" }, - headers: { - "idempotency-key": "A".repeat(22), - "if-match": `"vkr2.${"A".repeat(43)}"`, - }, - payload: {}, - }), -}), {})); - -describe("Recruitment bridge failures across the real SDK", () => { +const cancel = () => + action( + routeArgs( + new Request("http://dashboard.test/recruitment", { + method: "POST", + headers: { + origin: "http://dashboard.test", + "content-type": "application/json", + cookie: sessionCookie, + }, + body: JSON.stringify({ + operation: "cancelInterview", + params: { interviewId: "recruitment-interview-50" }, + headers: { + "idempotency-key": "A".repeat(22), + "if-match": `"vkr2.${"A".repeat(43)}"`, + }, + payload: {}, + }), + }), + {}, + ), + ); + +describe("Recruitment bridge failures across the RPC client", () => { + it("passes the key and the tag of the browser's command to the RPC", async () => { + cancelAnswer = (call) => nativeRpcProblem(call, "precondition.failed"); + await cancel(); + + const call = calls.find((each) => each.tag === "recruitment.cancelInterview"); + + expect(call?.payload).toEqual({ + interviewId: "recruitment-interview-50", + idempotencyKey: "A".repeat(22), + ifMatch: `"vkr2.${"A".repeat(43)}"`, + }); + expect(call?.headers.get("cookie")).toBe(sessionCookie); + }); + it.each<[NativeProblemCode, RecruitmentBridgeFailure["_tag"], number]>([ ["precondition.failed", "Conflict", 409], ["recruitment.already-finalized", "Conflict", 409], ["credential.invalid", "Unauthorized", 401], ["authority.denied", "Forbidden", 403], ["recruitment.interview-not-found", "NotFound", 404], - ["request.malformed", "Validation", 422], ["dependency.unavailable", "Network", 502], - ])("projects the SDK problem %s as %s", async (code, tag, status) => { - cancelResponse = () => nativeProblemResponse(code); + ])("projects the RPC problem %s as %s", async (code, tag, status) => { + cancelAnswer = (call) => nativeRpcProblem(call, code); const result = await cancel(); - expect(requests.some(request => new URL(request.url).pathname === cancelPath)).toBe(true); - expect(result.init?.status).toBe(status); - expect(result.data).toHaveProperty("_tag", tag); + expect(calls.some((call) => call.tag === "recruitment.cancelInterview")).toBe(true); + expect(result?.init?.status).toBe(status); + expect(result?.data).toHaveProperty("_tag", tag); }); it("answers a transport failure as Network", async () => { - cancelResponse = () => { + cancelAnswer = () => { throw new TypeError("fetch failed"); }; const result = await cancel(); - expect(result.init?.status).toBe(502); - expect(result.data).toEqual(RecruitmentBridgeFailure.cases.Network.make({ message: "Recruitment request failed" })); + expect(result?.init?.status).toBe(502); + expect(result?.data).toEqual( + RecruitmentBridgeFailure.cases.Network.make({ message: "Recruitment request failed" }), + ); }); }); diff --git a/apps/dashboard/app/foldkit/recruitment/bridge.ts b/apps/dashboard/app/foldkit/recruitment/bridge.ts index 2d77a00f8..3c4ddabf5 100644 --- a/apps/dashboard/app/foldkit/recruitment/bridge.ts +++ b/apps/dashboard/app/foldkit/recruitment/bridge.ts @@ -1,14 +1,28 @@ import { nativeProblemFrom } from "../../lib/native-problem"; import { Match, Schema as S } from "effect"; -import { PublicApplicationIdSchema } from "@vektorprogrammet/http-api" +import { PublicApplicationIdSchema } from "@vektorprogrammet/rpc" import { CancelInterviewObservationSchema, FinalizeInterviewObservationSchema, InterviewSchemaId, RecruitmentAssignmentBoardQuerySchema, RecruitmentAssignmentBoardSchema, RecruitmentInterviewConductObservationSchema, -RecruitmentInterviewId, } from "@vektorprogrammet/http-api" -import { CancelInterviewRequest, CancelInterviewResponse, ConditionalReadHeaders, CreateApplicationInterviewRequest, FinalizeInterviewRequest, CorrectInterviewAssessmentRequest, CorrectInterviewAssessmentResponse, FinalizeInterviewResponse, IdempotencyHeaders, IdempotencyIfMatchHeaders, RecruitmentInterviewResource, SchedulingBoard, ScheduleInterviewRequest, ScheduleInterviewResponse, StrongETag } from "@vektorprogrammet/http-api"; +RecruitmentInterviewId, } from "@vektorprogrammet/rpc" +import { CancelInterviewRequest, CancelInterviewResponse, CreateApplicationInterviewRequest, FinalizeInterviewRequest, CorrectInterviewAssessmentRequest, CorrectInterviewAssessmentResponse, FinalizeInterviewResponse, IdempotencyKey, RecruitmentInterviewResource, SchedulingBoard, ScheduleInterviewRequest, ScheduleInterviewResponse, StrongETag } from "@vektorprogrammet/rpc"; + +/** + * The browser names a command's idempotency key and entity tag as the HTTP contract spelled them; + * the bridge route passes them to the RPC as `idempotencyKey` and `ifMatch`. + */ +const IdempotencyHeaders = S.Struct({ "idempotency-key": IdempotencyKey }); + +const IdempotencyIfMatchHeaders = S.Struct({ + "idempotency-key": IdempotencyKey, + "if-match": StrongETag, +}); + +/** A conduct read carries no condition: the RPC answers the full resource every time. */ +const ConditionalReadHeaders = S.Struct({}); export const RecruitmentBoardStatus = RecruitmentAssignmentBoardQuerySchema.fields.status; @@ -159,7 +173,7 @@ const failure = { export const recruitmentNetworkFailure: RecruitmentBridgeFailure = failure.Network; /** - * Projects a generated-SDK failure onto the bridge. Pass the SDK cause unchanged: + * Projects an RPC client failure onto the bridge. Pass the RPC cause unchanged: * the problem's registry status decides the case, and anything else is a transport failure. */ export const recruitmentFailureFromSdk = (cause: unknown): RecruitmentBridgeFailure => { diff --git a/apps/dashboard/app/foldkit/recruitment/browser-client.ts b/apps/dashboard/app/foldkit/recruitment/browser-client.ts index 03cc3adac..bab5b2b97 100644 --- a/apps/dashboard/app/foldkit/recruitment/browser-client.ts +++ b/apps/dashboard/app/foldkit/recruitment/browser-client.ts @@ -1,5 +1,5 @@ import type { RecruitmentAssignmentBoard, -RecruitmentAssignmentBoardQuery, } from "@vektorprogrammet/http-api" +RecruitmentAssignmentBoardQuery, } from "@vektorprogrammet/rpc" import { Effect, Schema as S } from "effect"; import { CancelInterviewInputSchema, @@ -55,7 +55,7 @@ interface RecruitmentOperations { input: CreateApplicationInterviewInput, ) => Effect.Effect; readonly readSchedulingBoard: () => Effect.Effect< - typeof SchedulingBoard.Type, + SchedulingBoard, RecruitmentBridgeFailure >; readonly scheduleInterview: ( diff --git a/apps/dashboard/app/foldkit/recruitment/elements.ts b/apps/dashboard/app/foldkit/recruitment/elements.ts index 56de37829..154c4e045 100644 --- a/apps/dashboard/app/foldkit/recruitment/elements.ts +++ b/apps/dashboard/app/foldkit/recruitment/elements.ts @@ -1,4 +1,4 @@ -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { createBrowserRecruitmentClient } from "./browser-client"; import { embedRecruitment } from "./main"; diff --git a/apps/dashboard/app/foldkit/recruitment/main.ts b/apps/dashboard/app/foldkit/recruitment/main.ts index 44955feb7..fc118f965 100644 --- a/apps/dashboard/app/foldkit/recruitment/main.ts +++ b/apps/dashboard/app/foldkit/recruitment/main.ts @@ -1,4 +1,4 @@ -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; import { Runtime } from "foldkit"; import type { RecruitmentClient } from "./browser-client"; diff --git a/apps/dashboard/app/foldkit/recruitment/message.ts b/apps/dashboard/app/foldkit/recruitment/message.ts index ac63cc6dd..8b47f6b63 100644 --- a/apps/dashboard/app/foldkit/recruitment/message.ts +++ b/apps/dashboard/app/foldkit/recruitment/message.ts @@ -1,6 +1,6 @@ -import { PublicApplicationIdSchema } from "@vektorprogrammet/http-api" +import { PublicApplicationIdSchema } from "@vektorprogrammet/rpc" import { InterviewSchemaId, -RecruitmentInterviewerOptionSchema, } from "@vektorprogrammet/http-api" +RecruitmentInterviewerOptionSchema, } from "@vektorprogrammet/rpc" import { Dialog } from "@foldkit/ui"; import { Schema as S } from "effect"; import { taggedStruct } from "foldkit/schema"; diff --git a/apps/dashboard/app/foldkit/recruitment/model.ts b/apps/dashboard/app/foldkit/recruitment/model.ts index 260358d66..f88be6a37 100644 --- a/apps/dashboard/app/foldkit/recruitment/model.ts +++ b/apps/dashboard/app/foldkit/recruitment/model.ts @@ -1,8 +1,8 @@ import { Predicate } from "effect"; -import { PublicApplicationIdSchema } from "@vektorprogrammet/http-api" +import { PublicApplicationIdSchema } from "@vektorprogrammet/rpc" import { InterviewSchemaId, -RecruitmentInterviewerOptionSchema, } from "@vektorprogrammet/http-api" -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +RecruitmentInterviewerOptionSchema, } from "@vektorprogrammet/rpc" +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Dialog } from "@foldkit/ui"; import { Schema as S } from "effect"; import { AsyncData } from "foldkit"; diff --git a/apps/dashboard/app/foldkit/recruitment/update.test.ts b/apps/dashboard/app/foldkit/recruitment/update.test.ts index 98bfca067..545d73dba 100644 --- a/apps/dashboard/app/foldkit/recruitment/update.test.ts +++ b/apps/dashboard/app/foldkit/recruitment/update.test.ts @@ -1,6 +1,6 @@ import { Predicate } from "effect"; -import { RecruitmentAssignmentBoardSchema } from "@vektorprogrammet/http-api" -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { RecruitmentAssignmentBoardSchema } from "@vektorprogrammet/rpc" +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Effect, Schema as S } from "effect"; import { AsyncData } from "foldkit"; import { describe, expect, it } from "vitest"; diff --git a/apps/dashboard/app/foldkit/recruitment/update.ts b/apps/dashboard/app/foldkit/recruitment/update.ts index 344a14d22..f49e66fe3 100644 --- a/apps/dashboard/app/foldkit/recruitment/update.ts +++ b/apps/dashboard/app/foldkit/recruitment/update.ts @@ -1,5 +1,5 @@ import { Predicate } from "effect"; -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Dialog } from "@foldkit/ui"; import { Match as M, Option, Schema as S } from "effect"; import { AsyncData, Command, Update } from "foldkit"; diff --git a/apps/dashboard/app/foldkit/recruitment/view.ts b/apps/dashboard/app/foldkit/recruitment/view.ts index 963f6b9d5..dc4bb3490 100644 --- a/apps/dashboard/app/foldkit/recruitment/view.ts +++ b/apps/dashboard/app/foldkit/recruitment/view.ts @@ -1,7 +1,7 @@ import { Predicate } from "effect"; import { InterviewSchemaId, type RecruitmentAssignmentBoard, -RecruitmentInterviewerOptionSchema, } from "@vektorprogrammet/http-api" +RecruitmentInterviewerOptionSchema, } from "@vektorprogrammet/rpc" import { Button, Dialog, Select } from "@foldkit/ui"; import { Schema as S } from "effect"; import { AsyncData } from "foldkit"; diff --git a/apps/dashboard/app/foldkit/scheduling/AGENTS.md b/apps/dashboard/app/foldkit/scheduling/AGENTS.md index 85fe78351..a91151783 100644 --- a/apps/dashboard/app/foldkit/scheduling/AGENTS.md +++ b/apps/dashboard/app/foldkit/scheduling/AGENTS.md @@ -3,7 +3,7 @@ # apps/dashboard/app/foldkit/scheduling This folder holds Recruitment code under another name. Interview scheduling. -The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the generated SDK. +The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the RPC client. The Recruitment context in other folders: diff --git a/apps/dashboard/app/foldkit/scheduling/command.ts b/apps/dashboard/app/foldkit/scheduling/command.ts index 8f93625eb..ad930bab2 100644 --- a/apps/dashboard/app/foldkit/scheduling/command.ts +++ b/apps/dashboard/app/foldkit/scheduling/command.ts @@ -1,4 +1,4 @@ -import { RecruitmentInterviewId } from "@vektorprogrammet/http-api" +import { RecruitmentInterviewId } from "@vektorprogrammet/rpc" import { Effect, Predicate } from "effect"; import { Command } from "foldkit"; import type { diff --git a/apps/dashboard/app/foldkit/scheduling/elements.ts b/apps/dashboard/app/foldkit/scheduling/elements.ts index ba38cad03..1adce518e 100644 --- a/apps/dashboard/app/foldkit/scheduling/elements.ts +++ b/apps/dashboard/app/foldkit/scheduling/elements.ts @@ -1,4 +1,4 @@ -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { createBrowserRecruitmentClient } from "../recruitment/browser-client"; import { embedScheduling } from "./main"; diff --git a/apps/dashboard/app/foldkit/scheduling/main.ts b/apps/dashboard/app/foldkit/scheduling/main.ts index f7feb45b1..49ce18c4b 100644 --- a/apps/dashboard/app/foldkit/scheduling/main.ts +++ b/apps/dashboard/app/foldkit/scheduling/main.ts @@ -1,4 +1,4 @@ -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; import { Runtime } from "foldkit"; import type { RecruitmentClient } from "../recruitment/browser-client"; diff --git a/apps/dashboard/app/foldkit/scheduling/message.ts b/apps/dashboard/app/foldkit/scheduling/message.ts index 97def67db..23db1111c 100644 --- a/apps/dashboard/app/foldkit/scheduling/message.ts +++ b/apps/dashboard/app/foldkit/scheduling/message.ts @@ -1,6 +1,6 @@ -import { InterviewRecommendationSchema } from "@vektorprogrammet/http-api" -import { RecruitmentInterviewId } from "@vektorprogrammet/http-api" -import { StrongETag } from "@vektorprogrammet/http-api"; +import { InterviewRecommendationSchema } from "@vektorprogrammet/rpc" +import { RecruitmentInterviewId } from "@vektorprogrammet/rpc" +import { StrongETag } from "@vektorprogrammet/rpc"; import { Dialog } from "@foldkit/ui"; import { Schema as S } from "effect"; import { taggedStruct } from "foldkit/schema"; diff --git a/apps/dashboard/app/foldkit/scheduling/model.ts b/apps/dashboard/app/foldkit/scheduling/model.ts index afe1f0063..c8bdce777 100644 --- a/apps/dashboard/app/foldkit/scheduling/model.ts +++ b/apps/dashboard/app/foldkit/scheduling/model.ts @@ -1,7 +1,7 @@ import { Predicate } from "effect"; -import { InterviewRecommendationSchema } from "@vektorprogrammet/http-api" -import { RecruitmentInterviewId } from "@vektorprogrammet/http-api" -import { IdempotencyKey, StrongETag, SchedulingInterview } from "@vektorprogrammet/http-api"; +import { InterviewRecommendationSchema } from "@vektorprogrammet/rpc" +import { RecruitmentInterviewId } from "@vektorprogrammet/rpc" +import { IdempotencyKey, StrongETag, SchedulingInterview } from "@vektorprogrammet/rpc"; import { Dialog } from "@foldkit/ui"; import { Schema as S } from "effect"; import { AsyncData, FieldValidation } from "foldkit"; diff --git a/apps/dashboard/app/foldkit/scheduling/update.test.ts b/apps/dashboard/app/foldkit/scheduling/update.test.ts index b27e8d7df..4a53477a2 100644 --- a/apps/dashboard/app/foldkit/scheduling/update.test.ts +++ b/apps/dashboard/app/foldkit/scheduling/update.test.ts @@ -1,14 +1,14 @@ import { Dialog } from "@foldkit/ui"; import { RecruitmentBridgeFailure } from "../recruitment/bridge"; import { Predicate } from "effect"; -import { RecruitmentInterviewConductObservationSchema } from "@vektorprogrammet/http-api" +import { RecruitmentInterviewConductObservationSchema } from "@vektorprogrammet/rpc" import { IdempotencyKey, ScheduleInterviewResponse, SchedulingBoard, StrongETag, type ScheduleInterviewRequest, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Effect, Schema as S } from "effect"; import { AsyncData } from "foldkit"; import { describe, expect, it } from "vitest"; diff --git a/apps/dashboard/app/foldkit/scheduling/update.ts b/apps/dashboard/app/foldkit/scheduling/update.ts index 993f4dd38..28c0885a6 100644 --- a/apps/dashboard/app/foldkit/scheduling/update.ts +++ b/apps/dashboard/app/foldkit/scheduling/update.ts @@ -1,6 +1,6 @@ import { Predicate } from "effect"; -import type { RecruitmentInterviewQuestionSnapshot } from "@vektorprogrammet/http-api" -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import type { RecruitmentInterviewQuestionSnapshot } from "@vektorprogrammet/rpc" +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Dialog } from "@foldkit/ui"; import { Match as M, Option, Schema as S } from "effect"; import { AsyncData, Command, FieldValidation, Update } from "foldkit"; @@ -10,7 +10,7 @@ import { CorrectInterviewAssessmentInputSchema, ScheduleInterviewInputSchema, schedulingFailureMessage, - SchedulingBoard, + type SchedulingBoard, } from "../recruitment/bridge"; import type { SchedulingCommands } from "./command"; @@ -168,7 +168,7 @@ const successFeedback = ( } }; -const reconcileCommittedSchedule = (model: ReadyModel, board: typeof SchedulingBoard.Type): Update.Return => { +const reconcileCommittedSchedule = (model: ReadyModel, board: SchedulingBoard): Update.Return => { const scheduledInterview = board.interviews.find( (interview) => interview.interviewId === model.selectedInterviewId, ); diff --git a/apps/dashboard/app/foldkit/scheduling/view.test.ts b/apps/dashboard/app/foldkit/scheduling/view.test.ts index 81a4dd572..c2509de1e 100644 --- a/apps/dashboard/app/foldkit/scheduling/view.test.ts +++ b/apps/dashboard/app/foldkit/scheduling/view.test.ts @@ -1,7 +1,7 @@ import { Dialog } from "@foldkit/ui"; import { Predicate } from "effect"; -import { RecruitmentInterviewConductObservationSchema } from "@vektorprogrammet/http-api" -import { IdempotencyKey, SchedulingBoard, StrongETag } from "@vektorprogrammet/http-api"; +import { RecruitmentInterviewConductObservationSchema } from "@vektorprogrammet/rpc" +import { IdempotencyKey, SchedulingBoard, StrongETag } from "@vektorprogrammet/rpc"; import { Scene } from "foldkit/test"; import { AsyncData, FieldValidation } from "foldkit"; import { Schema as S } from "effect"; diff --git a/apps/dashboard/app/foldkit/scheduling/view.ts b/apps/dashboard/app/foldkit/scheduling/view.ts index cd99f753d..0589aa9e8 100644 --- a/apps/dashboard/app/foldkit/scheduling/view.ts +++ b/apps/dashboard/app/foldkit/scheduling/view.ts @@ -1,8 +1,8 @@ import { Predicate, Match } from "effect"; -import { interviewRecommendations } from "@vektorprogrammet/http-api" +import { interviewRecommendations } from "@vektorprogrammet/domain/recruitment"; import type { RecruitmentInterviewConductObservation, RecruitmentInterviewQuestionSnapshot, -RecruitmentSchedulingInterview, } from "@vektorprogrammet/http-api" +RecruitmentSchedulingInterview, } from "@vektorprogrammet/rpc" import { Button, Dialog, Input } from "@foldkit/ui"; import { AsyncData, FieldValidation } from "foldkit"; import type { Html, HtmlBuilder } from "foldkit/html"; @@ -250,7 +250,7 @@ const interviewCard = ( const successfulBoard = ( model: ReadyModel, - board: typeof SchedulingBoard.Type, + board: SchedulingBoard, h: HtmlBuilder, ): Html => h.section( diff --git a/apps/dashboard/app/foldkit/schools/AGENTS.md b/apps/dashboard/app/foldkit/schools/AGENTS.md index 9ea85a846..3169ca6e6 100644 --- a/apps/dashboard/app/foldkit/schools/AGENTS.md +++ b/apps/dashboard/app/foldkit/schools/AGENTS.md @@ -3,11 +3,10 @@ # apps/dashboard/app/foldkit/schools This folder holds the Schools bounded context in `apps/dashboard/app/foldkit`. -The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the generated SDK. +The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the RPC client. The Schools context in other folders: -- [apps/backend/src/schools](../../../../backend/src/schools/AGENTS.md) - [packages/database/src/schools](../../../../../packages/database/src/schools/AGENTS.md) - [packages/domain/src/schools](../../../../../packages/domain/src/schools/AGENTS.md) diff --git a/apps/dashboard/app/foldkit/schools/browser-client.test.ts b/apps/dashboard/app/foldkit/schools/browser-client.test.ts index 67d6407d0..68a017ae0 100644 --- a/apps/dashboard/app/foldkit/schools/browser-client.test.ts +++ b/apps/dashboard/app/foldkit/schools/browser-client.test.ts @@ -1,7 +1,9 @@ -import { Effect, Fiber } from "effect"; +import { ListSchools } from "@vektorprogrammet/rpc"; +import { Problem } from "@vektorprogrammet/rpc/problem"; +import { Effect, Exit, Fiber, Schema } from "effect"; +import { Rpc } from "effect/unstable/rpc"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createBrowserSchoolsDirectoryClient } from "./browser-client"; -import { nativeProblemResponse, privateReadHeaders } from "../../../test/native-http"; const fetchMock = vi.fn(); @@ -17,7 +19,31 @@ afterEach(() => { vi.unstubAllEnvs(); }); -describe("Schools generated browser client", () => { +/** The one RPC request that the browser client sent, as the JSON serialization writes it. */ +const RequestId = Schema.Union([Schema.String, Schema.Int]); + +const RpcRequestBody = Schema.fromJsonString(Schema.Struct({ id: RequestId })); + +/** The answer that ends one RPC request, as the JSON serialization writes it. */ +const ExitMessage = Schema.TaggedStruct("Exit", { requestId: RequestId, exit: Schema.Unknown }); + +/** A success exit whose value the test chooses, the contract notwithstanding. */ +const SuccessExit = Schema.TaggedStruct("Success", { value: Schema.Json }); + +const encodeListSchoolsExit = Schema.encodeSync(Schema.toCodecJson(Rpc.exitSchema(ListSchools))); + +type EncodedExit = ReturnType | typeof SuccessExit.Type; + +/** A fetch that answers the RPC it receives with `exit`. */ +const rpcAnswer = + (exit: EncodedExit) => + async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + const { id } = Schema.decodeSync(RpcRequestBody)(await new Response(init?.body).text()); + + return Response.json([ExitMessage.make({ requestId: id, exit })]); + }; + +describe("Schools browser RPC client", () => { it("aborts the owning runtime's in-flight request", async () => { const started = Promise.withResolvers(); let signal: AbortSignal | null | undefined; @@ -41,10 +67,11 @@ describe("Schools generated browser client", () => { expect(signal?.aborted).toBe(true); }); it("rejects malformed school facts instead of rendering them", async () => { - fetchMock.mockResolvedValueOnce( - Response.json( - { activeSchools: [{ schoolId: "invalid" }], inactiveSchools: [] }, - { headers: privateReadHeaders }, + fetchMock.mockImplementationOnce( + rpcAnswer( + SuccessExit.make({ + value: { activeSchools: [{ schoolId: "invalid" }], inactiveSchools: [] }, + }), ), ); @@ -55,7 +82,9 @@ describe("Schools generated browser client", () => { expect(failure.error.tag).toBe("SchoolsPersistenceError"); }); it("preserves an authority denial from the native API", async () => { - fetchMock.mockResolvedValueOnce(nativeProblemResponse("authority.denied")); + fetchMock.mockImplementationOnce( + rpcAnswer(encodeListSchoolsExit(Exit.fail(Problem.make("authority.denied")))), + ); const failure = await Effect.runPromise( createBrowserSchoolsDirectoryClient().directory.listSchools().pipe(Effect.flip), diff --git a/apps/dashboard/app/foldkit/schools/browser-client.ts b/apps/dashboard/app/foldkit/schools/browser-client.ts index 2d0afa519..90972d388 100644 --- a/apps/dashboard/app/foldkit/schools/browser-client.ts +++ b/apps/dashboard/app/foldkit/schools/browser-client.ts @@ -1,17 +1,24 @@ +import type { DepartmentId } from "@vektorprogrammet/domain"; import type { - DepartmentId, - SchoolDirectory, + NativeRpcClient, SchoolCommand, SchoolCommandResult, + SchoolDirectory, SchoolManagement, -} from "@vektorprogrammet/http-api"; -import { IdempotencyKey } from "@vektorprogrammet/http-api"; -import { createEffectClient, type EffectSdkFailure } from "@vektorprogrammet/sdk/effect"; +} from "@vektorprogrammet/rpc"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Effect } from "effect"; -import { resolveBrowserApiUrl } from "../../lib/browser-api"; +import { callBrowserNative, type NativeAnswerInvalid } from "../../lib/browser-native"; import { nativeProblemFrom } from "../../lib/native-problem"; import { schoolsBridgeFailure, type SchoolsBridgeFailure } from "./bridge"; +type NativeClient = NativeRpcClient["Service"]; + +/** The failure of one native RPC call: its declared problems, transport errors, and an invalid answer. */ +type RpcFailure = + | Effect.Error> + | NativeAnswerInvalid; + export interface SchoolsListInput { readonly department?: DepartmentId; } @@ -23,47 +30,43 @@ export interface SchoolsDirectoryClient { ) => Effect.Effect; readonly readManagement: () => Effect.Effect< SchoolManagement, - EffectSdkFailure<"directory", "readSchoolManagement"> + RpcFailure<"directory.readSchoolManagement"> >; readonly executeCommand: ( command: SchoolCommand, - ) => Effect.Effect>; + ) => Effect.Effect>; }; } -export const createBrowserSchoolsDirectoryClient = (): SchoolsDirectoryClient => { - const client = createEffectClient( - resolveBrowserApiUrl(import.meta.env.VITE_API_URL, globalThis.location.origin), - ); - - return { - directory: { - listSchools: (input = {}) => - client.directory.listSchools({ query: input }).pipe( - Effect.map(({ body }) => body), - Effect.mapError((error) => { - const code = nativeProblemFrom(error)?.code; - - return schoolsBridgeFailure( - code === "authority.denied" - ? "NotInScope" - : code === "credential.invalid" || code === "credential.missing" - ? "UnauthenticatedActor" - : code === "schools.invalid-department" - ? "SchoolsDepartmentNotFound" - : "SchoolsPersistenceError", - ); - }), +export const createBrowserSchoolsDirectoryClient = (): SchoolsDirectoryClient => ({ + directory: { + listSchools: (input = {}) => + callBrowserNative((client) => + client["directory.listSchools"]( + input.department === undefined ? {} : { departmentId: input.department }, ), - readManagement: () => - client.directory.readSchoolManagement().pipe(Effect.map(({ body }) => body)), - executeCommand: (command) => - client.directory - .executeSchoolCommand({ - payload: command, - headers: { "idempotency-key": IdempotencyKey.make(command.commandId) }, - }) - .pipe(Effect.map(({ body }) => body)), - }, - }; -}; + ).pipe( + Effect.mapError((error) => { + const code = nativeProblemFrom(error)?.code; + + return schoolsBridgeFailure( + code === "authority.denied" + ? "NotInScope" + : code === "credential.invalid" || code === "credential.missing" + ? "UnauthenticatedActor" + : code === "schools.invalid-department" + ? "SchoolsDepartmentNotFound" + : "SchoolsPersistenceError", + ); + }), + ), + readManagement: () => callBrowserNative((client) => client["directory.readSchoolManagement"]()), + executeCommand: (command) => + callBrowserNative((client) => + client["directory.executeSchoolCommand"]({ + idempotencyKey: IdempotencyKey.make(command.commandId), + request: command, + }), + ), + }, +}); diff --git a/apps/dashboard/app/foldkit/schools/command.ts b/apps/dashboard/app/foldkit/schools/command.ts index aa9445a7a..c7a376dfc 100644 --- a/apps/dashboard/app/foldkit/schools/command.ts +++ b/apps/dashboard/app/foldkit/schools/command.ts @@ -1,4 +1,4 @@ -import { DepartmentId } from "@vektorprogrammet/http-api"; +import { DepartmentId } from "@vektorprogrammet/domain"; import { Effect, Match, Schema as S } from "effect"; import { Command } from "foldkit"; import type { SchoolsBridgeFailure } from "./bridge"; @@ -12,7 +12,7 @@ import { FailedSchoolCommand, type Message, } from "./message"; -import { SchoolCommand } from "@vektorprogrammet/http-api"; +import { SchoolCommand } from "@vektorprogrammet/rpc"; import { nativeProblemFrom } from "../../lib/native-problem"; import { SchoolDirectoryFailure, SchoolDirectoryRequestId } from "./model"; diff --git a/apps/dashboard/app/foldkit/schools/management-view.ts b/apps/dashboard/app/foldkit/schools/management-view.ts index dc7bfd1ae..b8b39f513 100644 --- a/apps/dashboard/app/foldkit/schools/management-view.ts +++ b/apps/dashboard/app/foldkit/schools/management-view.ts @@ -1,6 +1,6 @@ import { Predicate } from "effect"; import { Input, Select } from "@foldkit/ui"; -import { SchoolId } from "@vektorprogrammet/http-api"; +import { SchoolId } from "@vektorprogrammet/domain"; import type { Html, HtmlBuilder } from "foldkit/html"; import type { Model } from "./model"; import { diff --git a/apps/dashboard/app/foldkit/schools/message.ts b/apps/dashboard/app/foldkit/schools/message.ts index 62a33a995..569973b9a 100644 --- a/apps/dashboard/app/foldkit/schools/message.ts +++ b/apps/dashboard/app/foldkit/schools/message.ts @@ -1,11 +1,10 @@ -import { DepartmentId } from "@vektorprogrammet/http-api"; +import { DepartmentId, SchoolId } from "@vektorprogrammet/domain"; import { SchoolDirectorySchema, SchoolManagement, SchoolCommand, SchoolCommandResult, - SchoolId, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Tabs } from "@foldkit/ui"; import { Schema as S } from "effect"; import { taggedStruct } from "foldkit/schema"; diff --git a/apps/dashboard/app/foldkit/schools/model.ts b/apps/dashboard/app/foldkit/schools/model.ts index d3ad84d60..d3f8fa88f 100644 --- a/apps/dashboard/app/foldkit/schools/model.ts +++ b/apps/dashboard/app/foldkit/schools/model.ts @@ -1,10 +1,5 @@ -import { - DepartmentId, - SchoolId, - SchoolManagement, - SchoolCommand, -} from "@vektorprogrammet/http-api"; -import { SchoolDirectoryDepartmentSchema, SchoolDirectorySchema } from "@vektorprogrammet/http-api"; +import { DepartmentId, SchoolDirectoryDepartmentSchema, SchoolId } from "@vektorprogrammet/domain"; +import { SchoolCommand, SchoolDirectorySchema, SchoolManagement } from "@vektorprogrammet/rpc"; import { Tabs } from "@foldkit/ui"; import { Schema as S } from "effect"; import { AsyncData } from "foldkit"; diff --git a/apps/dashboard/app/foldkit/schools/update.test.ts b/apps/dashboard/app/foldkit/schools/update.test.ts index ab68ad348..08c88aaff 100644 --- a/apps/dashboard/app/foldkit/schools/update.test.ts +++ b/apps/dashboard/app/foldkit/schools/update.test.ts @@ -1,5 +1,5 @@ -import { DepartmentId, SchoolManagement, SchoolCommandResult } from "@vektorprogrammet/http-api"; -import { SchoolId, type SchoolDirectory } from "@vektorprogrammet/http-api"; +import { DepartmentId, SchoolId } from "@vektorprogrammet/domain"; +import { SchoolManagement, SchoolCommandResult, type SchoolDirectory } from "@vektorprogrammet/rpc"; import { Tabs } from "@foldkit/ui"; import { Effect, Predicate, Schema } from "effect"; import { describe, expect, it } from "vitest"; diff --git a/apps/dashboard/app/foldkit/schools/update.ts b/apps/dashboard/app/foldkit/schools/update.ts index f7e70b74a..e980739c8 100644 --- a/apps/dashboard/app/foldkit/schools/update.ts +++ b/apps/dashboard/app/foldkit/schools/update.ts @@ -1,12 +1,8 @@ -import type { SchoolDirectory, SchoolDirectoryDepartment } from "@vektorprogrammet/http-api"; +import type { SchoolDirectory, SchoolDirectoryDepartment } from "@vektorprogrammet/rpc"; import { Tabs } from "@foldkit/ui"; import { Match as M, Option, Schema, Predicate } from "effect"; -import { - SchoolCommand, - DepartmentId, - SemesterId, - type SchoolManagement, -} from "@vektorprogrammet/http-api"; +import { DepartmentId, SemesterId } from "@vektorprogrammet/domain"; +import { SchoolCommand, type SchoolManagement } from "@vektorprogrammet/rpc"; import { Command, Update } from "foldkit"; import type { SchoolsDirectoryCommands } from "./command"; import { GotDirectoryTabMessage, type Message } from "./message"; diff --git a/apps/dashboard/app/foldkit/schools/view.test.ts b/apps/dashboard/app/foldkit/schools/view.test.ts index c0b2b8253..4ec53fd75 100644 --- a/apps/dashboard/app/foldkit/schools/view.test.ts +++ b/apps/dashboard/app/foldkit/schools/view.test.ts @@ -16,8 +16,8 @@ const config = { view, }; -import { DepartmentId } from "@vektorprogrammet/http-api"; -import { SchoolId, type SchoolDirectory } from "@vektorprogrammet/http-api"; +import { DepartmentId, SchoolId } from "@vektorprogrammet/domain"; +import type { SchoolDirectory } from "@vektorprogrammet/rpc"; import { describe, expect, it } from "vitest"; import { SchoolDirectoryData, init, type Model, SchoolDirectoryFailure } from "./model"; import { view } from "./view"; diff --git a/apps/dashboard/app/foldkit/schools/view.ts b/apps/dashboard/app/foldkit/schools/view.ts index 6643c3c00..1fdd4015a 100644 --- a/apps/dashboard/app/foldkit/schools/view.ts +++ b/apps/dashboard/app/foldkit/schools/view.ts @@ -1,7 +1,7 @@ import { Predicate } from "effect"; import { managementView } from "./management-view"; -import { DepartmentId } from "@vektorprogrammet/http-api"; -import type { SchoolDirectory, SchoolDirectoryEntry } from "@vektorprogrammet/http-api"; +import { DepartmentId } from "@vektorprogrammet/domain"; +import type { SchoolDirectory, SchoolDirectoryEntry } from "@vektorprogrammet/rpc"; import { Input, Select } from "@foldkit/ui"; import { AsyncData } from "foldkit"; import type { Html, HtmlBuilder } from "foldkit/html"; diff --git a/apps/dashboard/app/foldkit/social-events/AGENTS.md b/apps/dashboard/app/foldkit/social-events/AGENTS.md index 9472f04a1..6cd3ec017 100644 --- a/apps/dashboard/app/foldkit/social-events/AGENTS.md +++ b/apps/dashboard/app/foldkit/social-events/AGENTS.md @@ -3,7 +3,7 @@ # apps/dashboard/app/foldkit/social-events This folder holds the SocialEvents bounded context in `apps/dashboard/app/foldkit`. -The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the generated SDK. +The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the RPC client. The SocialEvents context in other folders: diff --git a/apps/dashboard/app/foldkit/social-events/bridge.ts b/apps/dashboard/app/foldkit/social-events/bridge.ts index 34995d5f9..8a1020a49 100644 --- a/apps/dashboard/app/foldkit/social-events/bridge.ts +++ b/apps/dashboard/app/foldkit/social-events/bridge.ts @@ -2,8 +2,8 @@ import { CreateSocialEventRequest, SocialEventAudience, SocialEventListResource, SocialEventResource, -SocialEventScopeResource, } from "@vektorprogrammet/http-api" -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +SocialEventScopeResource, } from "@vektorprogrammet/rpc" +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; export { diff --git a/apps/dashboard/app/foldkit/social-events/message.ts b/apps/dashboard/app/foldkit/social-events/message.ts index 464eda04b..55cc196b1 100644 --- a/apps/dashboard/app/foldkit/social-events/message.ts +++ b/apps/dashboard/app/foldkit/social-events/message.ts @@ -1,4 +1,4 @@ -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; import { taggedStruct } from "foldkit/schema"; import { diff --git a/apps/dashboard/app/foldkit/social-events/update.test.ts b/apps/dashboard/app/foldkit/social-events/update.test.ts index 55f50625b..a3fd77ddd 100644 --- a/apps/dashboard/app/foldkit/social-events/update.test.ts +++ b/apps/dashboard/app/foldkit/social-events/update.test.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; -import { SocialEventId, SocialEventObservedAt } from "@vektorprogrammet/http-api" -import { DepartmentId, SemesterId } from "@vektorprogrammet/http-api" -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { SocialEventId, SocialEventObservedAt } from "@vektorprogrammet/rpc" +import { DepartmentId, SemesterId } from "@vektorprogrammet/domain/organization" +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { describe, expect, it } from "vitest"; import type { SocialEventsCommandFactories } from "./command"; import { LoadedList, LoadedScope, SubmittedCreate, SucceededCreate } from "./message"; diff --git a/apps/dashboard/app/foldkit/social-events/view.ts b/apps/dashboard/app/foldkit/social-events/view.ts index 266fe9b8c..23f18efab 100644 --- a/apps/dashboard/app/foldkit/social-events/view.ts +++ b/apps/dashboard/app/foldkit/social-events/view.ts @@ -1,5 +1,6 @@ import { Predicate } from "effect"; -import { IdempotencyKey, DepartmentId, SemesterId } from "@vektorprogrammet/http-api"; +import { DepartmentId, SemesterId } from "@vektorprogrammet/domain/organization"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import type { Html, HtmlBuilder } from "foldkit/html"; import { ChangedDescription, diff --git a/apps/dashboard/app/foldkit/team-applications/AGENTS.md b/apps/dashboard/app/foldkit/team-applications/AGENTS.md index 9f8f6f075..655580267 100644 --- a/apps/dashboard/app/foldkit/team-applications/AGENTS.md +++ b/apps/dashboard/app/foldkit/team-applications/AGENTS.md @@ -3,7 +3,7 @@ # apps/dashboard/app/foldkit/team-applications This folder holds the TeamApplications bounded context in `apps/dashboard/app/foldkit`. -The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the generated SDK. +The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the RPC client. The TeamApplications context in other folders: diff --git a/apps/dashboard/app/foldkit/team-applications/browser-client.ts b/apps/dashboard/app/foldkit/team-applications/browser-client.ts index 42962e640..0bf4445cc 100644 --- a/apps/dashboard/app/foldkit/team-applications/browser-client.ts +++ b/apps/dashboard/app/foldkit/team-applications/browser-client.ts @@ -1,19 +1,19 @@ import type { IdempotencyKey, + NativeRpcClient, StrongETag, TeamApplicationId, TeamApplicationIntakeMergePatch, TeamApplicationIntakeResource, TeamApplicationListResponse, TeamApplicationResource, -} from "@vektorprogrammet/http-api"; -import { createEffectClient, type EffectSdk } from "@vektorprogrammet/sdk/effect"; +} from "@vektorprogrammet/rpc"; import { Effect } from "effect"; -import { resolveBrowserApiUrl } from "../../lib/browser-api"; +import { callBrowserNative, type NativeAnswerInvalid } from "../../lib/browser-native"; import { nativeProblemFrom, type NativeProblemSummary } from "../../lib/native-problem"; import type { TeamId } from "./model"; -/** A decoded native problem code, or `Transport` when no problem body could be decoded. */ +/** A decoded native problem code, or `Transport` when the call ended without a problem. */ export type TeamApplicationsFailure = NativeProblemSummary["code"] | "Transport"; export interface TeamApplicationsOperations { @@ -36,50 +36,47 @@ export interface TeamApplicationsOperations { }) => Effect.Effect; } +type NativeClient = NativeRpcClient["Service"]; + +/** Calls one native RPC: `callBrowserNative` in the browser, a recording client in tests. */ +export type NativeCall = ( + call: (client: NativeClient) => Effect.Effect, +) => Effect.Effect; + const withFailureCode = ( effect: Effect.Effect, ): Effect.Effect => Effect.mapError(effect, (cause) => nativeProblemFrom(cause)?.code ?? "Transport"); -/** Adapts the generated SDK group; the browser entry supplies the same-origin client. */ -export const teamApplicationsOperations = ( - client: EffectSdk["team-applications"], -): TeamApplicationsOperations => ({ +/** Adapts the team-application RPCs to the operations that the Foldkit commands run. */ +export const teamApplicationsOperations = (call: NativeCall): TeamApplicationsOperations => ({ listApplications: ({ teamId, cursor }) => - client - .listTeamApplications({ params: { teamId }, query: cursor === null ? {} : { cursor } }) - .pipe( - Effect.map(({ body }) => body), - withFailureCode, + call((client) => + client["team-applications.listTeamApplications"]( + cursor === null ? { teamId } : { teamId, cursor }, ), + ).pipe(withFailureCode), readApplication: ({ applicationId }) => - client.readTeamApplication({ params: { applicationId } }).pipe( - Effect.map(({ body }) => body), + call((client) => client["team-applications.readTeamApplication"]({ applicationId })).pipe( withFailureCode, ), deleteApplication: ({ applicationId, commandId }) => - client - .deleteTeamApplication({ - params: { applicationId }, - headers: { "idempotency-key": commandId }, - }) - .pipe(Effect.asVoid, withFailureCode), + call((client) => + client["team-applications.deleteTeamApplication"]({ + applicationId, + idempotencyKey: commandId, + }), + ).pipe(withFailureCode), reviseIntake: ({ teamId, etag, patch, commandId }) => - client - .reviseTeamApplicationIntake({ - params: { teamId }, - headers: { "idempotency-key": commandId, "if-match": etag }, - payload: patch, - }) - .pipe( - Effect.map(({ body }) => body), - withFailureCode, - ), + call((client) => + client["team-applications.reviseTeamApplicationIntake"]({ + teamId, + idempotencyKey: commandId, + ifMatch: etag, + request: patch, + }), + ).pipe(withFailureCode), }); export const createBrowserTeamApplicationsClient = (): TeamApplicationsOperations => - teamApplicationsOperations( - createEffectClient( - resolveBrowserApiUrl(import.meta.env.VITE_API_URL, globalThis.location.origin), - )["team-applications"], - ); + teamApplicationsOperations(callBrowserNative); diff --git a/apps/dashboard/app/foldkit/team-applications/command.ts b/apps/dashboard/app/foldkit/team-applications/command.ts index bde629b4e..4fa6acabc 100644 --- a/apps/dashboard/app/foldkit/team-applications/command.ts +++ b/apps/dashboard/app/foldkit/team-applications/command.ts @@ -4,7 +4,7 @@ import { TeamApplicationCursor, TeamApplicationId, TeamApplicationIntakeMergePatch, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Effect, Match, Schema as S } from "effect"; import { Command, Dom } from "foldkit"; import type { TeamApplicationsFailure, TeamApplicationsOperations } from "./browser-client"; diff --git a/apps/dashboard/app/foldkit/team-applications/elements.ts b/apps/dashboard/app/foldkit/team-applications/elements.ts index dd0b46072..4d8a5b0f9 100644 --- a/apps/dashboard/app/foldkit/team-applications/elements.ts +++ b/apps/dashboard/app/foldkit/team-applications/elements.ts @@ -1,4 +1,4 @@ -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Option, Schema as S } from "effect"; import { createBrowserTeamApplicationsClient } from "./browser-client"; import { embedTeamApplications } from "./main"; diff --git a/apps/dashboard/app/foldkit/team-applications/intake.ts b/apps/dashboard/app/foldkit/team-applications/intake.ts index 8c49502dc..cce9777d1 100644 --- a/apps/dashboard/app/foldkit/team-applications/intake.ts +++ b/apps/dashboard/app/foldkit/team-applications/intake.ts @@ -1,4 +1,4 @@ -import type { TeamApplicationIntakeMergePatch } from "@vektorprogrammet/http-api"; +import type { TeamApplicationIntakeMergePatch } from "@vektorprogrammet/rpc"; import { Data, Option, type Types } from "effect"; import type { IntakeDraft, TeamApplicationIntake } from "./model"; import { instantFromOsloDateTimeLocal, osloDateTimeLocalFromInstant } from "./oslo-time"; diff --git a/apps/dashboard/app/foldkit/team-applications/main.ts b/apps/dashboard/app/foldkit/team-applications/main.ts index 3e2762e52..406df7f36 100644 --- a/apps/dashboard/app/foldkit/team-applications/main.ts +++ b/apps/dashboard/app/foldkit/team-applications/main.ts @@ -1,4 +1,4 @@ -import type { IdempotencyKey } from "@vektorprogrammet/http-api"; +import type { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Runtime } from "foldkit"; import type { TeamApplicationsOperations } from "./browser-client"; import { commandsFor } from "./command"; diff --git a/apps/dashboard/app/foldkit/team-applications/message.ts b/apps/dashboard/app/foldkit/team-applications/message.ts index fd4939e27..1aeac9c63 100644 --- a/apps/dashboard/app/foldkit/team-applications/message.ts +++ b/apps/dashboard/app/foldkit/team-applications/message.ts @@ -4,7 +4,7 @@ import { TeamApplicationIntakeResource, TeamApplicationListResponse, TeamApplicationResource, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; import { taggedStruct } from "foldkit/schema"; import { MutationFailure, ReadFailure, RequestId } from "./model"; diff --git a/apps/dashboard/app/foldkit/team-applications/model.ts b/apps/dashboard/app/foldkit/team-applications/model.ts index 2ee9e961c..fc1c4c260 100644 --- a/apps/dashboard/app/foldkit/team-applications/model.ts +++ b/apps/dashboard/app/foldkit/team-applications/model.ts @@ -7,7 +7,7 @@ import { TeamApplicationIntakeMergePatch, TeamApplicationListResponse, TeamApplicationResource, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; import { AsyncData } from "foldkit"; diff --git a/apps/dashboard/app/foldkit/team-applications/paths.test.ts b/apps/dashboard/app/foldkit/team-applications/paths.test.ts index 553b850a6..ff169b892 100644 --- a/apps/dashboard/app/foldkit/team-applications/paths.test.ts +++ b/apps/dashboard/app/foldkit/team-applications/paths.test.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from "node:url"; import type { RouteConfigEntry } from "@react-router/dev/routes"; -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Option, Schema as S } from "effect"; import { Scene } from "foldkit/test"; import { matchRoutes, type RouteObject } from "react-router"; diff --git a/apps/dashboard/app/foldkit/team-applications/update.test.ts b/apps/dashboard/app/foldkit/team-applications/update.test.ts index 87fc78259..65bfe9cb4 100644 --- a/apps/dashboard/app/foldkit/team-applications/update.test.ts +++ b/apps/dashboard/app/foldkit/team-applications/update.test.ts @@ -1,17 +1,27 @@ import { Dialog } from "@foldkit/ui"; import { IdempotencyKey, - makeNativeProblem, TeamApplicationIntakeResource, TeamApplicationListResponse, TeamApplicationResource, - type NativeProblemCode, -} from "@vektorprogrammet/http-api"; -import { createEffectClient, type FetchCapability } from "@vektorprogrammet/sdk/effect"; +} from "@vektorprogrammet/rpc"; +import { NativeRpcClient, nativeRpcClientLayer } from "@vektorprogrammet/rpc/client"; import { Effect, Option, Predicate, Schema as S } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; import { AsyncData, type Update } from "foldkit"; import { describe, expect, it } from "vitest"; -import { teamApplicationsOperations, type TeamApplicationsOperations } from "./browser-client"; +import { + type NativeRpcCall, + nativeRpcProblem, + nativeRpcSuccess, + readNativeRpcCall, +} from "../../../test/native-rpc"; +import { NativeAnswerInvalid } from "../../lib/browser-native"; +import { + type NativeCall, + teamApplicationsOperations, + type TeamApplicationsOperations, +} from "./browser-client"; import { commandsFor } from "./command"; import { ChangedDeadline, @@ -89,50 +99,33 @@ const inert: TeamApplicationsOperations = { reviseIntake: () => Effect.die("not executed by transition tests"), }; -interface SentRequest { - readonly method: string; - readonly url: string; - readonly headers: Headers; - readonly body: string; -} - -/** The generated SDK over a recording fetch, so tests observe the exact wire request. */ -const wire = (respond: () => Response) => { - const sent: SentRequest[] = []; - - const fetch: FetchCapability = async (input, init) => { - const request = new Request(input, init); - sent.push({ - method: request.method, - url: request.url, - headers: request.headers, - body: await request.text(), - }); +/** + * The operations over the real RPC client, whose fetch records each RPC call and answers it, so + * tests observe the exact wire request. + */ +const wire = (respond: (call: NativeRpcCall) => Response) => { + const sent: NativeRpcCall[] = []; - return respond(); - }; + const fetch: typeof globalThis.fetch = async (input, init) => { + const call = await readNativeRpcCall(input, init); + sent.push(call); - return { - sent, - operations: teamApplicationsOperations( - createEffectClient("https://dashboard.test", { fetch })["team-applications"], - ), + return respond(call); }; -}; -const problem = (code: NativeProblemCode): Response => { - const body = makeNativeProblem(code); + const call: NativeCall = (use) => + NativeRpcClient.use(use).pipe( + Effect.provide(nativeRpcClientLayer("https://dashboard.test")), + Effect.provideService(FetchHttpClient.Fetch, fetch), + Effect.catchDefect((defect) => Effect.fail(new NativeAnswerInvalid({ defect }))), + ); - return new Response(JSON.stringify(body), { - status: body.status, - headers: { - "Content-Type": "application/problem+json", - "Cache-Control": "no-store", - Vary: "Origin", - }, - }); + return { sent, operations: teamApplicationsOperations(call) }; }; +/** The key and the patch of a recorded intake revision. */ +const revisePayload = S.decodeUnknownSync(S.Struct({ idempotencyKey: S.String, request: S.Json })); + const loaded = ( update: (model: Model, message: Message) => Update.Return, page = listPage(), @@ -171,7 +164,7 @@ describe("team application workflow", () => { }); it("sends only the changed setting with the observed entity tag and reloads after a stale revision", async () => { - const { sent, operations } = wire(() => problem("precondition.failed")); + const { sent, operations } = wire((call) => nativeRpcProblem(call, "precondition.failed")); const update = updateFor(commandsFor(operations)); const edited = update(loaded(update), ChangedDeadline({ value: "2026-10-25T03:00" })).model; const submitted = update(edited, SubmittedIntake()); @@ -179,12 +172,14 @@ describe("team application workflow", () => { expect(sent).toHaveLength(1); expect(sent[0]).toMatchObject({ - method: "PATCH", - url: "https://dashboard.test/api/teams/team-it/application-intake", + tag: "team-applications.reviseTeamApplicationIntake", + payload: { + teamId: "team-it", + idempotencyKey: `${seed}-0`, + ifMatch: etag("A"), + request: { deadline: "2026-10-25T02:00:00.000Z" }, + }, }); - expect(sent[0]!.headers.get("if-match")).toBe(etag("A")); - expect(sent[0]!.headers.get("idempotency-key")).toBe(`${seed}-0`); - expect(JSON.parse(sent[0]!.body)).toEqual({ deadline: "2026-10-25T02:00:00.000Z" }); const stale = update(submitted.model, outcome); @@ -236,12 +231,12 @@ describe("team application workflow", () => { const retried = await attempt(unknown); await attempt(update(retried, ChangedDeadline({ value: "2026-11-01T12:00" })).model); - expect(sent.map((request) => request.headers.get("idempotency-key"))).toEqual([ + expect(sent.map(({ payload }) => revisePayload(payload).idempotencyKey)).toEqual([ `${seed}-0`, `${seed}-0`, `${seed}-1`, ]); - expect(sent.map((request) => JSON.parse(request.body))).toEqual([ + expect(sent.map(({ payload }) => revisePayload(payload).request)).toEqual([ { acceptApplication: false }, { acceptApplication: false }, { acceptApplication: false, deadline: "2026-11-01T11:00:00.000Z" }, @@ -260,13 +255,8 @@ describe("team application workflow", () => { }); it("deletes once while the confirmation stays open, then drops the detail and reloads", async () => { - const { sent, operations } = wire( - () => - new Response(null, { - status: 204, - headers: { "Cache-Control": "no-store", Vary: "Origin" }, - }), - ); + // A deletion succeeds with no value. + const { sent, operations } = wire((call) => nativeRpcSuccess(call, null)); const update = updateFor(commandsFor(operations)); @@ -292,10 +282,9 @@ describe("team application workflow", () => { expect(sent).toHaveLength(1); expect(sent[0]).toMatchObject({ - method: "DELETE", - url: `https://dashboard.test/api/team-applications/${application.applicationId}`, + tag: "team-applications.deleteTeamApplication", + payload: { applicationId: application.applicationId, idempotencyKey: `${seed}-0` }, }); - expect(sent[0]!.headers.get("idempotency-key")).toBe(`${seed}-0`); const deleted = update(confirming.model, outcome); diff --git a/apps/dashboard/app/foldkit/team-applications/update.ts b/apps/dashboard/app/foldkit/team-applications/update.ts index 97aa0c5ec..f3b2ed6ba 100644 --- a/apps/dashboard/app/foldkit/team-applications/update.ts +++ b/apps/dashboard/app/foldkit/team-applications/update.ts @@ -1,5 +1,5 @@ import { Dialog } from "@foldkit/ui"; -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Match as M, Option, Predicate } from "effect"; import { AsyncData, Command, Update } from "foldkit"; import type { TeamApplicationsCommands } from "./command"; diff --git a/apps/dashboard/app/foldkit/team-applications/view.ts b/apps/dashboard/app/foldkit/team-applications/view.ts index 88dec5081..ef230ec2e 100644 --- a/apps/dashboard/app/foldkit/team-applications/view.ts +++ b/apps/dashboard/app/foldkit/team-applications/view.ts @@ -3,7 +3,7 @@ import type { TeamApplicationId, TeamApplicationListResponse, TeamApplicationResource, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Option, Predicate } from "effect"; import { AsyncData } from "foldkit"; import type { ChildAttribute, Html, HtmlBuilder } from "foldkit/html"; diff --git a/apps/dashboard/app/lib/admission-period-view.test.ts b/apps/dashboard/app/lib/admission-period-view.test.ts index 1812fdd40..e6c007886 100644 --- a/apps/dashboard/app/lib/admission-period-view.test.ts +++ b/apps/dashboard/app/lib/admission-period-view.test.ts @@ -3,7 +3,7 @@ import { AdmissionPeriodManagementItem, Problem, StrongETag, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Schema } from "effect"; import { describe, expect, it } from "vitest"; import { diff --git a/apps/dashboard/app/lib/admission-period-view.ts b/apps/dashboard/app/lib/admission-period-view.ts index 3b12ad766..3824acc4e 100644 --- a/apps/dashboard/app/lib/admission-period-view.ts +++ b/apps/dashboard/app/lib/admission-period-view.ts @@ -1,7 +1,7 @@ import { Predicate, Schema, Data, flow } from "effect"; import { nativeProblemFrom as decodeNativeProblem, type NativeProblemSummary as DecodedNativeProblem, nativeFailureFrom } from "./native-problem"; import { formText } from "./form-text"; -import { AdmissionPeriodId } from "@vektorprogrammet/http-api" +import { AdmissionPeriodId } from "@vektorprogrammet/rpc" import { AdmissionPeriodManagementItem, AdmissionPeriodMergePatch, @@ -10,7 +10,7 @@ import { StrongETag, type IdempotencyKey as IdempotencyKeyValue, type StrongETag as StrongETagValue, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; export type AdmissionPeriodUiErrorField = "semesterId" | "departmentId" | "startAt" | "endAt"; @@ -357,8 +357,8 @@ export function parseAdmissionPeriodForm( let payload: CreateAdmissionPeriodRequest; - // An empty department is an absent key: the SDK encodes a present undefined as null, - // which the contract rejects. + // An empty department is an absent key, as canonical command encoding requires (the former + // SDK encoded a present undefined as null, which the contract rejected). try { payload = Schema.decodeSync(CreateAdmissionPeriodRequest)( decoded.departmentId === undefined diff --git a/apps/dashboard/app/lib/api.server.ts b/apps/dashboard/app/lib/api.server.ts index 4ebf9cda7..1f9abb4bf 100644 --- a/apps/dashboard/app/lib/api.server.ts +++ b/apps/dashboard/app/lib/api.server.ts @@ -1,21 +1,60 @@ -import { createPromiseClient, type ClientOptions } from "@vektorprogrammet/sdk"; - -export type CookieOption = NonNullable; +/** + * The dashboard server's calls to the native backend. A loader or action calls one native RPC on a + * person's behalf with `callNative`, which forwards that person's cookie and the dashboard origin + * on that call only. A failure rejects with the RPC's `Problem`, which `nativeFailureFrom` decodes. + */ +import { + NativeRpcClient, + nativeRpcClientLayer, + withForwardedHeaders, +} from "@vektorprogrammet/rpc/client"; +import { type Effect, ManagedRuntime } from "effect"; const serverApiUrl = typeof process !== "undefined" ? process.env?.API_URL : undefined; -export function createAuthenticatedClient(cookie: CookieOption, request: Request) { - return createPromiseClient(serverApiUrl, { cookie, origin: new URL(request.url).origin }); +const configuredApiUrl = (): string => { + if (serverApiUrl === undefined || serverApiUrl.trim() === "") { + throw new Error("API URL is not configured"); + } + + return serverApiUrl; +}; + +// The composition root of the dashboard server's backend client, built on first use. +let runtime: ManagedRuntime.ManagedRuntime | undefined; + +const nativeRuntime = () => { + runtime ??= ManagedRuntime.make(nativeRpcClientLayer(configuredApiUrl())); + + return runtime; +}; + +type NativeClient = NativeRpcClient["Service"]; + +/** + * Calls the backend as the person whose `cookie` a loader or action read, from the dashboard + * origin of `request`. + */ +export function callNative( + cookie: string, + request: Request, + call: (client: NativeClient) => Effect.Effect, +): Promise
{ + return nativeRuntime().runPromise( + NativeRpcClient.use(call).pipe( + withForwardedHeaders({ cookie, origin: new URL(request.url).origin }), + ), + ); } -export function createServerClient() { - return createPromiseClient(serverApiUrl); +/** Calls a public native RPC, with no person's credential. */ +export function callNativeAnonymously( + call: (client: NativeClient) => Effect.Effect, +): Promise { + return nativeRuntime().runPromise(NativeRpcClient.use(call)); } +/** The URL of a plain HTTP route of the backend: Better Auth and OAuth. */ export function serverApiEndpoint(path: string): string { - if (serverApiUrl === undefined || serverApiUrl.trim() === "") { - throw new Error("API URL is not configured"); - } - - return new URL(path, serverApiUrl).toString(); + return new URL(path, configuredApiUrl()).toString(); } diff --git a/apps/dashboard/app/lib/auth.server.test.ts b/apps/dashboard/app/lib/auth.server.test.ts index abc240022..e0272916a 100644 --- a/apps/dashboard/app/lib/auth.server.test.ts +++ b/apps/dashboard/app/lib/auth.server.test.ts @@ -1,5 +1,7 @@ -import { Predicate } from "effect"; -import { makeNativeProblem } from "@vektorprogrammet/http-api"; +import { Exit, Predicate, Schema } from "effect"; +import { Rpc } from "effect/unstable/rpc"; +import { DeleteSession, Problem, ReadSession, type SessionResponse } from "@vektorprogrammet/rpc"; +import { PersonId } from "@vektorprogrammet/domain/organization"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.hoisted(() => vi.stubEnv("API_URL", "http://api.test")); @@ -8,6 +10,61 @@ import { loadSessionIdentity, hasAuthenticatedSession, requireAuth, safeRedirect const transport = vi.fn(); +/** The RPC client numbers its requests; the answer echoes the number. */ +const RequestId = Schema.Union([Schema.String, Schema.Finite]); + +const RpcRequestBody = Schema.fromJsonString( + Schema.TaggedStruct("Request", { + id: RequestId, + tag: Schema.String, + payload: Schema.Unknown, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), + }), +); + +const RpcExitMessage = Schema.TaggedStruct("Exit", { + requestId: RequestId, + exit: Schema.Json, +}); + +/** + * The one RPC request that the native client sent in `input`. The headers that `callNative` + * forwards travel in the RPC message, and the backend merges them over the HTTP headers. + */ +async function rpcRequest( + input: Parameters[0], + init?: Parameters[1], +) { + const request = new Request(input, init); + const body = Schema.decodeSync(RpcRequestBody)(await request.text()); + const headers = new Headers(request.headers); + + for (const [name, value] of body.headers) headers.set(name, value); + + return { url: request.url, headers, id: body.id, tag: body.tag, payload: body.payload }; +} + +/** The backend's answer to the request `id`, with its exit encoded as the RPC server encodes it. */ +const rpcAnswer = (id: typeof RequestId.Type, exit: Schema.Json): Response => + Response.json([RpcExitMessage.make({ requestId: id, exit })]); + +const encodeReadSessionExit = Schema.encodeSync(Schema.toCodecJson(Rpc.exitSchema(ReadSession))); + +const encodeDeleteSessionExit = Schema.encodeSync( + Schema.toCodecJson(Rpc.exitSchema(DeleteSession)), +); + +const currentSession: SessionResponse = { + sessionId: "session-1", + personId: PersonId.make("person-1"), + createdAt: "2030-01-01T00:00:00Z", + updatedAt: "2030-01-01T00:00:00Z", + expiresAt: "2030-01-02T00:00:00Z", + ipAddress: null, + userAgent: null, + current: true, +}; + function responseWithCookies( status: number, cookies: ReadonlyArray, @@ -36,7 +93,15 @@ describe("native dashboard authentication", () => { const rawCookie = "theme=dark; better-auth.session_token=session-value; invitation_capability=opaque"; - const fetchMock = vi.fn().mockResolvedValue(Response.json({sessionId: "session-1", personId: "person-1", createdAt: "2030-01-01T00:00:00Z", updatedAt: "2030-01-01T00:00:00Z", expiresAt: "2030-01-02T00:00:00Z", ipAddress: null, userAgent: null, current: true}, {headers: {"cache-control": "private, no-store", vary: "Origin"}})); + const sent: Array<{ readonly tag: string; readonly cookie: string | null; readonly url: string }> = []; + + const fetchMock = vi.fn(async (input, init) => { + const { url, headers, id, tag } = await rpcRequest(input, init); + sent.push({ tag, cookie: headers.get("cookie"), url }); + + return rpcAnswer(id, encodeReadSessionExit(Exit.succeed(currentSession))); + }); + transport.mockImplementation(fetchMock); const request = new Request("http://dashboard.test/dashboard", { @@ -45,8 +110,9 @@ describe("native dashboard authentication", () => { await expect(requireAuth(request)).resolves.toBe(rawCookie); expect(fetchMock).toHaveBeenCalledOnce(); - const [input, init] = fetchMock.mock.calls[0]; - expect(new Request(input, init).headers.get("cookie")).toBe(rawCookie); + expect(sent).toEqual([ + { tag: "system.readSession", cookie: rawCookie, url: "http://api.test/api/rpc/" }, + ]); }); it("reads the Better Auth session identity with the exact incoming Cookie", async () => { @@ -105,15 +171,15 @@ describe("native dashboard authentication", () => { }); it.each([ - ["missing credential problem", { body: makeNativeProblem("credential.missing") }], - ["invalid credential problem", { body: makeNativeProblem("credential.invalid") }], + ["missing credential problem", { problem: Problem.fromWire({ code: "credential.missing" }, {}) }], + ["invalid credential problem", { problem: Problem.fromWire({ code: "credential.invalid" }, {}) }], ] as const)("redirects an invalid session after a %s", async (_name, failure) => { transport.mockImplementation(vi.fn(async (input, init) => { - const url = new Request(input, init).url; + const { id, tag } = await rpcRequest(input, init); - return url.endsWith("/api/session") - ? Response.json(failure.body, {status: 401, headers: {"content-type": "application/problem+json", "cache-control": "no-store", vary: "Origin", "www-authenticate": 'VektorSession realm="native-api"'}}) - : responseWithCookies(200, ["better-auth.session_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"]); + return tag === "system.readSession" + ? rpcAnswer(id, encodeReadSessionExit(Exit.fail(failure.problem))) + : rpcAnswer(id, encodeDeleteSessionExit(Exit.void)); })); const request = new Request("http://dashboard.test/dashboard", { @@ -128,12 +194,19 @@ describe("native dashboard authentication", () => { }); it("preserves a transport failure instead of redirecting or revoking the session", async () => { - const fetchMock = vi.fn().mockRejectedValue(new TypeError("network unavailable")); + const tags: Array = []; + + const fetchMock = vi.fn(async (input, init) => { + tags.push((await rpcRequest(input, init)).tag); + + throw new TypeError("network unavailable"); + }); + transport.mockImplementation(fetchMock); const request = new Request("http://dashboard.test/dashboard", {headers: {Cookie: "better-auth.session_token=session-value"}}); await expect(requireAuth(request)).rejects.not.toBeInstanceOf(Response); await expect(hasAuthenticatedSession(request)).rejects.not.toBeInstanceOf(Response); - expect(fetchMock.mock.calls.every(([input, init]) => new Request(input, init).method === "GET")).toBe(true); + expect(tags).toEqual(["system.readSession", "system.readSession"]); }); it("posts email credentials to Better Auth and preserves every Set-Cookie value", async () => { @@ -278,7 +351,15 @@ describe("native dashboard authentication", () => { }); it("deletes the generated native session and emits local clearing cookies", async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(null, {status: 204, headers: {"cache-control": "no-store", vary: "Origin"}})); + const sent: Array>> = []; + + const fetchMock = vi.fn(async (input, init) => { + const call = await rpcRequest(input, init); + sent.push(call); + + return rpcAnswer(call.id, encodeDeleteSessionExit(Exit.void)); + }); + transport.mockImplementation(fetchMock); const rawCookie = "theme=dark; better-auth.session_token=session-value"; @@ -291,11 +372,10 @@ describe("native dashboard authentication", () => { expect(headers.getSetCookie()).toEqual([ "better-auth.session_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", ]); - const [input, init] = fetchMock.mock.calls[0]; - const sent = new Request(input, init); - expect(sent.method).toBe("DELETE"); - expect(sent.headers.get("cookie")).toBe(rawCookie); - expect(sent.headers.get("idempotency-key")).toEqual(expect.any(String)); + expect(sent).toHaveLength(1); + expect(sent[0]?.tag).toBe("system.deleteSession"); + expect(sent[0]?.headers.get("cookie")).toBe(rawCookie); + expect(sent[0]?.payload).toEqual({ idempotencyKey: expect.any(String) }); }); it("allows only same-origin relative post-login redirects", () => { diff --git a/apps/dashboard/app/lib/auth.server.ts b/apps/dashboard/app/lib/auth.server.ts index 7f84ccbba..4278482a8 100644 --- a/apps/dashboard/app/lib/auth.server.ts +++ b/apps/dashboard/app/lib/auth.server.ts @@ -1,9 +1,9 @@ import { Predicate, Schema as S, Data } from "effect"; import { nativeDashboardRecoveryMode } from "../server/native-account-mode.server"; -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { redirect } from "react-router"; -import { createAuthenticatedClient, serverApiEndpoint } from "./api.server"; +import { callNative, serverApiEndpoint } from "./api.server"; import { nativeProblemFrom } from "./native-problem"; const SESSION_COOKIE_NAMES = [ @@ -70,7 +70,7 @@ async function inspectSession(request: Request): Promise { if (cookie === null || !hasSessionCookie(cookie)) return SessionInspection.Missing(); try { - await createAuthenticatedClient(cookie, request).system.readSession(); + await callNative(cookie, request, (client) => client["system.readSession"]()); return SessionInspection.Authenticated({cookie}); } catch (error) { @@ -210,11 +210,11 @@ export async function signOut(request: Request): Promise { if (cookie === null || !hasSessionCookie(cookie)) return expiredSessionCookieHeaders(request); try { - await createAuthenticatedClient(cookie, request).system.deleteSession({ - headers: { - "idempotency-key": IdempotencyKey.make(crypto.randomUUID()), - }, - }); + await callNative(cookie, request, (client) => + client["system.deleteSession"]({ + idempotencyKey: IdempotencyKey.make(crypto.randomUUID()), + }), + ); } catch (error) { const code = nativeProblemFrom(error)?.code; diff --git a/apps/dashboard/app/lib/browser-native.ts b/apps/dashboard/app/lib/browser-native.ts new file mode 100644 index 000000000..ca6573b6c --- /dev/null +++ b/apps/dashboard/app/lib/browser-native.ts @@ -0,0 +1,41 @@ +/** + * The browser's calls to the native backend. The dashboard origin serves the backend's RPC + * endpoint, and the browser sends the person's session cookie itself (`credentials: "include"`), + * so a Foldkit command calls one native RPC with `callBrowserNative` and handles its declared + * problems. + */ +import { NativeRpcClient, nativeRpcClientLayer } from "@vektorprogrammet/rpc/client"; +import { Data, Effect, ManagedRuntime } from "effect"; +import { resolveBrowserApiUrl } from "./browser-api"; + +/** + * The backend's answer did not fit the contract, or the backend answered a defect: the RPC client + * dies on both, and a Foldkit command answers this failure instead of crashing its program. + */ +export class NativeAnswerInvalid extends Data.TaggedError("NativeAnswerInvalid")<{ + readonly defect: unknown; +}> {} + +type NativeClient = NativeRpcClient["Service"]; + +// The composition root of the browser's backend client, built on the first call. +let runtime: ManagedRuntime.ManagedRuntime | undefined; + +const browserRuntime = () => { + runtime ??= ManagedRuntime.make( + nativeRpcClientLayer( + resolveBrowserApiUrl(import.meta.env.VITE_API_URL, globalThis.location.origin), + ), + ); + + return runtime; +}; + +/** Calls one native RPC from the browser, as the person whose session cookie it holds. */ +export const callBrowserNative = ( + call: (client: NativeClient) => Effect.Effect, +): Effect.Effect => + Effect.suspend(() => browserRuntime().contextEffect).pipe( + Effect.flatMap((context) => NativeRpcClient.use(call).pipe(Effect.provideContext(context))), + Effect.catchDefect((defect) => Effect.fail(new NativeAnswerInvalid({ defect }))), + ); diff --git a/apps/dashboard/app/lib/interview-bridge.server.test.ts b/apps/dashboard/app/lib/interview-bridge.server.test.ts index 880295848..6d7047346 100644 --- a/apps/dashboard/app/lib/interview-bridge.server.test.ts +++ b/apps/dashboard/app/lib/interview-bridge.server.test.ts @@ -1,13 +1,31 @@ -import { StrongETag } from "@vektorprogrammet/http-api"; +import { InvitationResponseResource, StrongETag } from "@vektorprogrammet/rpc"; +import { Schema } from "effect"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { InvitationBridgeFailureSchema, INVITATION_INTERACTION_HEADER } from "../foldkit/interview/bridge"; vi.hoisted(() => vi.stubEnv("API_URL", "http://api.test")); -import { conditionalReadHeaders, nativeProblemResponse } from "../../test/native-http"; +import { + nativeRpcProblem, + nativeRpcSuccess, + readNativeRpcCall, + type NativeRpcCall, +} from "../../test/native-rpc"; const transport = vi.fn(); +/** Every RPC call that the bridge sent, in order. */ +const calls: NativeRpcCall[] = []; + +/** Answers every RPC call with `answer`, and records the call. */ +const answerWith = (answer: (call: NativeRpcCall) => Response) => + transport.mockImplementation(async (input, init) => { + const call = await readNativeRpcCall(input, init); + calls.push(call); + + return answer(call); + }); + import { bridgeFailureFrom, @@ -27,6 +45,7 @@ afterEach(() => vi.unstubAllGlobals()); describe("server-held recruitment invitation bridge", () => { beforeEach(() => { transport.mockReset(); + calls.length = 0; vi.stubGlobal("fetch", transport); }); @@ -205,7 +224,7 @@ describe("server-held recruitment invitation bridge", () => { expect(cancelled).toBe(true); }); - it("rejects missing, malformed, and unknown interaction bindings before creating the SDK", async () => { + it("rejects missing, malformed, and unknown interaction bindings before any RPC", async () => { const readOperation = { operation: "readInvitationResponse" } as const; const validUnknownInteractionId = "c".repeat(32); @@ -240,7 +259,7 @@ describe("server-held recruitment invitation bridge", () => { for (const [request, expectedTag, expectedStatus] of cases) { const failure = await runOperation(request, readOperation).then( () => { - throw new Error("An invalid interaction binding reached the SDK"); + throw new Error("An invalid interaction binding reached the backend"); }, bridgeFailureFrom, ); @@ -269,7 +288,14 @@ describe("server-held recruitment invitation bridge", () => { etag, }; - transport.mockResolvedValue(Response.json(resource.observation, { headers: conditionalReadHeaders })); + answerWith((call) => + nativeRpcSuccess( + call, + Schema.encodeSync(Schema.toCodecJson(InvitationResponseResource))( + Schema.decodeUnknownSync(InvitationResponseResource)(resource), + ), + ), + ); const requestedCookie = createInvitationCapabilityCookie( @@ -294,18 +320,18 @@ describe("server-held recruitment invitation bridge", () => { await expect(runOperation(request, { operation: "readInvitationResponse" })).resolves.toEqual( resource, ); - const [input, init] = transport.mock.calls[0]!; - const nativeRequest = new Request(input, init); - expect(nativeRequest.headers.get("X-Recruitment-Invitation-Capability")).toBe(requestedCapability); - expect(nativeRequest.headers.get("Cookie")).toBeNull(); - expect(nativeRequest.url).not.toContain(requestedCapability); + const [call] = calls; + expect(call?.tag).toBe("recruitment.readInvitationResponse"); + expect(call?.payload).toEqual({ capability: requestedCapability }); + expect(call?.headers.get("Cookie")).toBeNull(); + expect(call?.url).not.toContain(requestedCapability); }); it("returns no representation for a successful native mutation", async () => { const interactionId = "f".repeat(32); const capability = "F".repeat(43); - transport.mockResolvedValue(new Response(null, { status: 204, headers: { "cache-control": "no-store", vary: "Origin", etag } })); + answerWith((call) => nativeRpcSuccess(call, { etag })); const cookie = createInvitationCapabilityCookie(interactionId, capability, "/interview").split( @@ -323,15 +349,13 @@ describe("server-held recruitment invitation bridge", () => { await expect(runOperation(request, { operation: "confirmInvitation", etag })).resolves.toBe( undefined, ); - const [input, init] = transport.mock.calls[0]!; - const nativeRequest = new Request(input, init); - expect(nativeRequest.url).toBe("http://api.test/api/recruitment/invitation-response:confirm"); - expect(nativeRequest.headers.get("idempotency-key")).toBeNull(); - expect(nativeRequest.headers.get("if-match")).toBe(etag); - expect(await nativeRequest.json()).toEqual({}); + const [call] = calls; + expect(call?.tag).toBe("recruitment.confirmInvitation"); + expect(call?.payload).toEqual({ capability, ifMatch: etag }); + expect(call?.headers.get("Cookie")).toBeNull(); }); - it("projects only safe SDK problems and stable statuses", async () => { + it("projects only safe RPC problems and stable statuses", async () => { const interactionId = "a".repeat(32); const cookie = createInvitationCapabilityCookie(interactionId, "A".repeat(43), "/interview").split( @@ -346,13 +370,14 @@ describe("server-held recruitment invitation bridge", () => { const cases = [ ["resource.not-found", "InvitationNotFound", 404], ["invitation.already-responded", "InvitationAlreadyResponded", 409], - ["request.malformed", "InvitationDecodeError", 422], + // The confirmation declares no request.malformed, so the RPC client refuses it as a defect. + ["request.malformed", "InvitationUnavailable", 503], ["precondition.failed", "InvitationUnavailable", 503], ["dependency.unavailable", "InvitationUnavailable", 503], ] as const; for (const [code, bridgeTag, status] of cases) { - transport.mockResolvedValueOnce(nativeProblemResponse(code)); + answerWith((call) => nativeRpcProblem(call, code)); const failure = await runOperation(request, { operation: "confirmInvitation", etag }).then( () => { diff --git a/apps/dashboard/app/lib/interview-bridge.server.ts b/apps/dashboard/app/lib/interview-bridge.server.ts index c0251eff4..1dc48b865 100644 --- a/apps/dashboard/app/lib/interview-bridge.server.ts +++ b/apps/dashboard/app/lib/interview-bridge.server.ts @@ -1,7 +1,7 @@ import { RecruitmentInvitationCapabilitySchema, -RecruitmentInvitationResponseMessageSchema, } from "@vektorprogrammet/http-api" -import { parseJsonWithUniqueMembers } from "@vektorprogrammet/http-api" -import { createConfiguredPromiseClient } from "@vektorprogrammet/sdk"; +RecruitmentInvitationResponseMessageSchema, } from "@vektorprogrammet/rpc" +import { parseJsonWithUniqueMembers } from "@vektorprogrammet/rpc" +import { callNativeAnonymously } from "./api.server"; import { Schema as S, Match, flow, Option, Result } from "effect"; import { nativeProblemFrom } from "./native-problem"; import { InvitationBridgeFailureSchema, decodeInvitationInteractionId, INVITATION_INTERACTION_HEADER, type InvitationBridgeFailure, type InvitationBridgeOperation, type InvitationInteractionId, InvitationBridgeOperationSchema } from "../foldkit/interview/bridge"; @@ -138,18 +138,18 @@ export const createInvitationCapabilityCookie = ( return `${cookieName}=${encodeURIComponent(decodedCapability)}; Path=${bridgePath}; HttpOnly; SameSite=Strict${SecureCookieAttribute}`; }; -const createInvitationClient = (capability: typeof RecruitmentInvitationCapabilitySchema.Type) => - createConfiguredPromiseClient({ - headers: { "X-Recruitment-Invitation-Capability": capability }, - }).recruitment; - +/** + * Reads the invitation that `capability` names. The capability is the call's one credential, so no + * session cookie travels with it. + */ export const readInvitationCapability = async (capability: string) => { - const client = createInvitationClient(decodeExchangeCapability(capability)); - const result = await client.readInvitationResponse({ headers: {} }); + const resource = await callNativeAnonymously((client) => + client["recruitment.readInvitationResponse"]({ + capability: decodeExchangeCapability(capability), + }), + ); - if (result.body === undefined) throw new Error("Invitation response did not include a body"); - - return result.body; + return resource.observation; }; export const decodeOperation = flow( @@ -184,43 +184,46 @@ export const decodeOperationRequest = async ( } }; +/** + * Runs one invitation operation with the capability of the request's interaction cookie. The + * capability travels in the RPC payload, and no session cookie travels with it. + */ export const runOperation = async ( request: Request, operation: InvitationBridgeOperation, ) => { - const client = createInvitationClient(invitationCapability(request)); + const capability = invitationCapability(request); switch (operation.operation) { - case "readInvitationResponse": { - const result = await client.readInvitationResponse({ headers: {} }); - - if (result.body === undefined) throw new Error("Invitation response did not include a body"); - - return { observation: result.body, etag: result.headers.etag }; - } + case "readInvitationResponse": + return callNativeAnonymously((client) => + client["recruitment.readInvitationResponse"]({ capability }), + ); case "confirmInvitation": - await client.confirmInvitation({ - params: {}, - headers: { "if-match": operation.etag }, - payload: {}, - }); + await callNativeAnonymously((client) => + client["recruitment.confirmInvitation"]({ capability, ifMatch: operation.etag }), + ); return undefined; case "rejectInvitation": - await client.rejectInvitation({ - params: {}, - headers: { "if-match": operation.etag }, - payload: operation.message === null ? {} : { message: operation.message }, - }); + await callNativeAnonymously((client) => + client["recruitment.rejectInvitation"]({ + capability, + ifMatch: operation.etag, + request: operation.message === null ? {} : { message: operation.message }, + }), + ); return undefined; case "requestNewInvitationTime": - await client.requestNewInvitationTime({ - params: {}, - headers: { "if-match": operation.etag }, - payload: { message: operation.message }, - }); + await callNativeAnonymously((client) => + client["recruitment.requestNewInvitationTime"]({ + capability, + ifMatch: operation.etag, + request: { message: operation.message }, + }), + ); return undefined; } @@ -232,8 +235,8 @@ const safeFailure = ( ): InvitationBridgeFailure => InvitationBridgeFailureSchema.cases[tag].make({message}); /** - * Projects a bridge rejection or a generated-SDK failure onto the invitation bridge. - * Pass the cause unchanged: an SDK problem is decoded only from the SDK's own value. + * Projects a bridge rejection or an RPC failure onto the invitation bridge. + * Pass the cause unchanged: a problem is decoded only from the RPC client's own value. */ export const bridgeFailureFrom = (cause: unknown): InvitationBridgeFailure => { if (S.is(InvitationBridgeFailureSchema)(cause)) { diff --git a/apps/dashboard/app/lib/interview-response-exchange-route.test.ts b/apps/dashboard/app/lib/interview-response-exchange-route.test.ts index ba64c1250..3e52410b5 100644 --- a/apps/dashboard/app/lib/interview-response-exchange-route.test.ts +++ b/apps/dashboard/app/lib/interview-response-exchange-route.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { conditionalReadHeaders, nativeProblemResponse, routeArgs } from "../../test/native-http"; +import { routeArgs } from "../../test/native-http"; +import { nativeRpcProblem, nativeRpcSuccess, readNativeRpcCall, type NativeRpcCall } from "../../test/native-rpc"; vi.hoisted(() => vi.stubEnv("API_URL", "http://api.test")); @@ -7,8 +8,26 @@ import { loader } from "../routes/interview-response.$capability"; const transport = vi.fn(); +const pending = { scheduledAt: "2031-09-20T13:30:00.000Z", room: "K-101", campus: "Gløshaugen", responseState: "Pending", responseMessage: null }; + +const etag = `"vkr2.${"A".repeat(43)}"`; + +/** Every RPC call of the exchange, in order. */ +const calls: NativeRpcCall[] = []; + +/** Answers each later call with `answer`, and records it. */ +const answerWith = (answer: (call: NativeRpcCall) => Response) => + transport.mockImplementation(async (input, init) => { + const call = await readNativeRpcCall(input, init); + calls.push(call); + + return answer(call); + }); + beforeEach(() => { - transport.mockReset().mockImplementation(async () => Response.json({ scheduledAt: "2031-09-20T13:30:00.000Z", room: "K-101", campus: "Gløshaugen", responseState: "Pending", responseMessage: null }, { headers: conditionalReadHeaders })); + calls.length = 0; + transport.mockReset(); + answerWith((call) => nativeRpcSuccess(call, { observation: pending, etag })); vi.stubGlobal("fetch", transport); }); @@ -46,7 +65,8 @@ describe("recruitment invitation capability exchange", () => { } expect(bindings[0]).not.toBe(bindings[1]); - expect(transport.mock.calls.map(([input, init]) => new Request(input, init).headers.get("X-Recruitment-Invitation-Capability"))).toEqual([firstCapability, secondCapability]); + expect(calls.map((call) => call.payload)).toEqual([{ capability: firstCapability }, { capability: secondCapability }]); + expect(calls.map((call) => call.headers.get("cookie"))).toEqual([null, null]); }); it("keeps the capability cookie inside the configured dashboard mount", async () => { const response = await thrownRedirect("C".repeat(43), "/dashboard/", true); @@ -54,7 +74,7 @@ describe("recruitment invitation capability exchange", () => { expect(response.headers.get("set-cookie")).toContain("Path=/dashboard/interview"); }); it("does not mint or clear bindings when the real native response denies the capability", async () => { - transport.mockResolvedValueOnce(nativeProblemResponse("resource.not-found")); + answerWith((call) => nativeRpcProblem(call, "resource.not-found")); const response = await thrownRedirect("D".repeat(43)); expect(response.status).toBe(302); expect(response.headers.get("location")).toBe("/interview-response/redacted"); @@ -62,7 +82,7 @@ describe("recruitment invitation capability exchange", () => { expect(transport).toHaveBeenCalledOnce(); }); it("does not mint a binding from a malformed native observation", async () => { - transport.mockResolvedValueOnce(Response.json({ responseState: "Invented" }, { headers: conditionalReadHeaders })); + answerWith((call) => nativeRpcSuccess(call, { observation: { responseState: "Invented" }, etag })); const response = await thrownRedirect("E".repeat(43)); expect(response.headers.get("location")).toBe("/interview-response/redacted"); expect(response.headers.get("set-cookie")).toBeNull(); diff --git a/apps/dashboard/app/lib/native-problem.test.ts b/apps/dashboard/app/lib/native-problem.test.ts index 5d70536e0..26aae4e50 100644 --- a/apps/dashboard/app/lib/native-problem.test.ts +++ b/apps/dashboard/app/lib/native-problem.test.ts @@ -3,7 +3,7 @@ import { makeNativeValidationError, type NativeProblemCode, Problem, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { describe, expect, it } from "vitest"; import { nativeFailureFrom, nativeProblemFrom } from "./native-problem"; import { @@ -12,26 +12,26 @@ import { } from "./admission-period-view"; import { isUnauthorizedError, mapOwnedReceiptError, mapApprovalReceiptError } from "./receipt-view"; -/** The value the generated SDK fails with after decoding a declared problem response. */ -const sdkProblem = (code: NativeProblemCode) => Problem.fromWire({ code }, {}); +/** The value the RPC client fails with after decoding a declared problem. */ +const rpcProblem = (code: NativeProblemCode) => Problem.fromWire({ code }, {}); const validation = (pointer: string) => ({ ...makeNativeProblem("validation.failed"), validation: { errors: [makeNativeValidationError(pointer, "invalid")], truncated: false }, }); -describe("canonical SDK problem projection", () => { - it("preserves authorization and conflict decisions through the SDK problem value", () => { - const denied = sdkProblem("credential.invalid"); +describe("canonical RPC problem projection", () => { + it("preserves authorization and conflict decisions through the RPC problem value", () => { + const denied = rpcProblem("credential.invalid"); expect(isAdmissionPeriodUnauthorizedError(denied)).toBe(true); expect(isUnauthorizedError(denied)).toBe(true); - const stale = sdkProblem("precondition.failed"); + const stale = rpcProblem("precondition.failed"); expect(mapAdmissionPeriodError(stale)._tag).toBe("StaleAdmissionPeriodRevision"); expect(mapOwnedReceiptError(stale)._tag).toBe("StaleReceiptRevision"); expect(mapApprovalReceiptError(stale)._tag).toBe("StaleReceiptRevision"); }); - it("decodes an SDK problem as its wire body, not as a transport failure", () => { - expect(nativeFailureFrom(sdkProblem("idempotency.in-flight"))).toEqual( + it("decodes an RPC problem as its wire body, not as a transport failure", () => { + expect(nativeFailureFrom(rpcProblem("idempotency.in-flight"))).toEqual( makeNativeProblem("idempotency.in-flight"), ); expect(nativeFailureFrom(new TypeError("Network unavailable"))).toBeInstanceOf(TypeError); @@ -43,7 +43,7 @@ describe("canonical SDK problem projection", () => { expect(mapOwnedReceiptError(Problem.fromWire(amount, {})).field).toBe("amountNok"); expect(nativeProblemFrom(Problem.fromWire(validation("/file"), {}))).toEqual(validation("/file")); }); - it("reads problems only from the SDK's own value, never from a plain problem-shaped object", () => { + it("reads problems only from the RPC client's own value, never from a plain problem-shaped object", () => { expect(nativeProblemFrom(validation("/file"))).toBeUndefined(); expect(nativeProblemFrom({ code: "credential.invalid", status: 401 })).toBeUndefined(); expect(nativeFailureFrom(makeNativeProblem("credential.invalid"))).toBeUndefined(); diff --git a/apps/dashboard/app/lib/native-problem.ts b/apps/dashboard/app/lib/native-problem.ts index 352c33554..745492e01 100644 --- a/apps/dashboard/app/lib/native-problem.ts +++ b/apps/dashboard/app/lib/native-problem.ts @@ -1,4 +1,4 @@ -import { isProblem, NativeProblem, problemBody, ValidationProblem } from "@vektorprogrammet/http-api"; +import { isProblem, NativeProblem, problemBody, ValidationProblem } from "@vektorprogrammet/rpc"; import { Option, Schema } from "effect"; const ProblemBody = Schema.Union([ValidationProblem, NativeProblem]); @@ -8,7 +8,7 @@ const decodeProblem = Schema.decodeUnknownOption(ProblemBody, { onExcessProperty export type NativeProblemSummary = typeof ProblemBody.Type; /** - * Decode the `Problem` the generated SDK failed with. Pass the SDK cause unchanged: + * Decode the `Problem` the RPC client failed with. Pass the RPC cause unchanged: * a copy, a re-decoded value, or any other problem-shaped object is not problem evidence. */ export const nativeProblemFrom = (cause: unknown): NativeProblemSummary | undefined => diff --git a/apps/dashboard/app/lib/oauth.server.test.ts b/apps/dashboard/app/lib/oauth.server.test.ts index 31c9ed84d..e008b33e4 100644 --- a/apps/dashboard/app/lib/oauth.server.test.ts +++ b/apps/dashboard/app/lib/oauth.server.test.ts @@ -3,14 +3,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; vi.hoisted(() => vi.stubEnv("API_URL", "http://api.test")); -import { nativeSessionResponse, sessionCookie } from "../../test/native-http"; +import { sessionCookie } from "../../test/native-http"; +import { isNativeRpcRequest, nativeSessionAnswer } from "../../test/native-rpc"; const stubOAuthFetch = (next: typeof fetch) => { - const transport: typeof fetch = async (input, init) => { - const request = new Request(input, init); - - return new URL(request.url).pathname === "/api/session" ? nativeSessionResponse() : next(input, init); - }; + const transport: typeof fetch = async (input, init) => + isNativeRpcRequest(input) ? nativeSessionAnswer(input, init) : next(input, init); vi.stubGlobal("fetch", transport); }; diff --git a/apps/dashboard/app/lib/preview-role-override.ts b/apps/dashboard/app/lib/preview-role-override.ts index 24c766d2f..e9bf4bd11 100644 --- a/apps/dashboard/app/lib/preview-role-override.ts +++ b/apps/dashboard/app/lib/preview-role-override.ts @@ -1,6 +1,6 @@ import { Schema as S } from "effect"; import { Option } from "effect"; -import type { UserRoleSchema } from "@vektorprogrammet/http-api"; +import type { UserRoleSchema } from "@vektorprogrammet/rpc"; /** * Client-side role override for preview/devtools ONLY. diff --git a/apps/dashboard/app/lib/profile-view.test.ts b/apps/dashboard/app/lib/profile-view.test.ts index 0bffd722b..71658ee02 100644 --- a/apps/dashboard/app/lib/profile-view.test.ts +++ b/apps/dashboard/app/lib/profile-view.test.ts @@ -1,4 +1,4 @@ -import { UserProfileResponse } from "@vektorprogrammet/http-api"; +import { UserProfileResponse } from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; import { describe, expect, it } from "vitest"; import { loadProfile, projectProfile } from "./profile-view"; diff --git a/apps/dashboard/app/lib/profile-view.ts b/apps/dashboard/app/lib/profile-view.ts index 91be755c2..09f152125 100644 --- a/apps/dashboard/app/lib/profile-view.ts +++ b/apps/dashboard/app/lib/profile-view.ts @@ -1,4 +1,4 @@ -import type { UserProfileResponse } from "@vektorprogrammet/http-api"; +import type { UserProfileResponse } from "@vektorprogrammet/rpc"; export interface ProfileView { readonly firstName: string; diff --git a/packages/http-api/src/receipt-upload.test.ts b/apps/dashboard/app/lib/receipt-upload.server.test.ts similarity index 99% rename from packages/http-api/src/receipt-upload.test.ts rename to apps/dashboard/app/lib/receipt-upload.server.test.ts index 280bcdf0f..28a97aa37 100644 --- a/packages/http-api/src/receipt-upload.test.ts +++ b/apps/dashboard/app/lib/receipt-upload.server.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Scope } from "effect"; import assert from "node:assert/strict"; import { MultipartError } from "effect/unstable/http/Multipart"; -import { ReceiptFileTooLarge, readBoundedReceiptForm } from "./receipt-upload.js"; +import { ReceiptFileTooLarge, readBoundedReceiptForm } from "./receipt-upload.server"; describe("receipt transfer bounds", () => { it.effect.each([undefined, "1"])( diff --git a/packages/http-api/src/receipt-upload.ts b/apps/dashboard/app/lib/receipt-upload.server.ts similarity index 95% rename from packages/http-api/src/receipt-upload.ts rename to apps/dashboard/app/lib/receipt-upload.server.ts index 6669f6dcd..7f7989aa6 100644 --- a/packages/http-api/src/receipt-upload.ts +++ b/apps/dashboard/app/lib/receipt-upload.server.ts @@ -1,3 +1,8 @@ +/** + * The dashboard server's bounded reader of the owner's receipt form. The browser posts a + * multipart form to the dashboard; the dashboard reads it within these bounds and sends the file + * to the backend as bytes in the RPC payload. + */ import { Data, Effect, Stream } from "effect"; import * as Multipart from "effect/unstable/http/Multipart"; diff --git a/apps/dashboard/app/lib/receipt-view.ts b/apps/dashboard/app/lib/receipt-view.ts index 4cde54afe..fd601be0a 100644 --- a/apps/dashboard/app/lib/receipt-view.ts +++ b/apps/dashboard/app/lib/receipt-view.ts @@ -1,17 +1,17 @@ import { Predicate, Data, flow } from "effect"; import { nativeProblemFrom as decodeNativeProblem, type NativeProblemSummary as DecodedNativeProblem, nativeFailureFrom } from "./native-problem"; import { - ReceiptApprovalQueueItem, - ReceiptListItem, - ReceiptSettlementQueueItem, + type ReceiptApprovalQueueItem, + type ReceiptListItem, + type ReceiptSettlementQueueItem, type StrongETag as StrongETagValue, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; -type OwnedReceiptProjection = typeof ReceiptListItem.Type; +type OwnedReceiptProjection = ReceiptListItem; -type ApprovalReceiptProjection = typeof ReceiptApprovalQueueItem.Type; +type ApprovalReceiptProjection = ReceiptApprovalQueueItem; -type SettlementQueueProjection = typeof ReceiptSettlementQueueItem.Type; +type SettlementQueueProjection = ReceiptSettlementQueueItem; type ReceiptSettlementEvidenceProjection = Exclude; diff --git a/apps/dashboard/app/oauth-routes.test.ts b/apps/dashboard/app/oauth-routes.test.ts index 43d1b647f..b6342e943 100644 --- a/apps/dashboard/app/oauth-routes.test.ts +++ b/apps/dashboard/app/oauth-routes.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { nativeSessionResponse, routeArgs, sessionCookie } from "../test/native-http"; +import { routeArgs, sessionCookie } from "../test/native-http"; +import { isNativeRpcRequest, nativeSessionAnswer } from "../test/native-rpc"; vi.hoisted(() => vi.stubEnv("API_URL", "http://api.test")); @@ -31,7 +32,7 @@ beforeEach(() => { requests.push(request.clone()); const path = new URL(request.url).pathname; - if (path === "/api/session") return nativeSessionResponse(); + if (isNativeRpcRequest(request)) return nativeSessionAnswer(request); if (path === "/api/auth/oauth2/public-client") return Response.json({ client_id: "client", client_name: "Dashboard OAuth proof", client_kind: "DelegatedPublic" }); diff --git a/apps/dashboard/app/receipt-file-route.test.ts b/apps/dashboard/app/receipt-file-route.test.ts index 490f20b06..655c930a0 100644 --- a/apps/dashboard/app/receipt-file-route.test.ts +++ b/apps/dashboard/app/receipt-file-route.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { nativeProblemResponse, nativeSessionResponse, routeArgs, sessionCookie } from "../test/native-http"; +import { routeArgs, sessionCookie } from "../test/native-http"; +import { + type NativeRpcCall, + nativeRpcProblem, + nativeRpcSuccess, + nativeSession, + readNativeRpcCall, +} from "../test/native-rpc"; vi.hoisted(() => vi.stubEnv("API_URL", "http://api.test")); @@ -16,16 +23,33 @@ const privateFileHeaders = { "x-content-type-options": "nosniff", }; -const requests: Request[] = []; +/** The RPC calls other than the session check. */ +const calls: NativeRpcCall[] = []; -let fileResponse = () => new Response(fileBytes, { headers: privateFileHeaders }); +/** The receipt RPCs among them; an expired session also signs out. */ +const receiptCalls = () => calls.filter((call) => call.tag.startsWith("receipts.")); -let sessionResponse = nativeSessionResponse; +type Answer = (call: NativeRpcCall) => Response; -const load = (receiptId = "receipt-approval-file") => loader(routeArgs( - new Request(`http://dashboard.test/dashboard/utlegg/${receiptId}/file`, { headers: { cookie: sessionCookie } }), - { receiptId }, -)); +/** A file success, encoded as the RPC server encodes `ReceiptFileContent`: bytes as base64. */ +const fileAnswer = + (contentType: string, bytes: Uint8Array): Answer => + (call) => + nativeRpcSuccess(call, { contentType, bytes: Buffer.from(bytes).toString("base64") }); + +let answerFile: Answer = fileAnswer("image/png", fileBytes); + +let answerSession: Answer = (call) => nativeRpcSuccess(call, nativeSession); + +const load = (receiptId = "receipt-approval-file") => + loader( + routeArgs( + new Request(`http://dashboard.test/dashboard/utlegg/${receiptId}/file`, { + headers: { cookie: sessionCookie }, + }), + { receiptId }, + ), + ); const expectPrivateFailure = async (response: Response, status: number) => { expect(response.status).toBe(status); @@ -37,63 +61,76 @@ const expectPrivateFailure = async (response: Response, status: number) => { }; beforeEach(() => { - requests.length = 0; - sessionResponse = nativeSessionResponse; - fileResponse = () => new Response(fileBytes, { headers: privateFileHeaders }); - vi.stubGlobal("fetch", vi.fn(async (input, init) => { - const request = new Request(input, init); - requests.push(request); - - return new URL(request.url).pathname === "/api/session" ? sessionResponse() : fileResponse(); - })); + calls.length = 0; + answerSession = (call) => nativeRpcSuccess(call, nativeSession); + answerFile = fileAnswer("image/png", fileBytes); + vi.stubGlobal( + "fetch", + vi.fn(async (input, init) => { + const call = await readNativeRpcCall(input, init); + + if (call.tag === "system.readSession") return answerSession(call); + calls.push(call); + + return answerFile(call); + }), + ); }); afterEach(() => vi.unstubAllGlobals()); describe("receipt approval file resource route", () => { - it("forwards the session and exact private bytes through the same-origin route", async () => { + it("forwards the session and answers the exact private bytes at the same-origin route", async () => { const response = await load(); expect(response.status).toBe(200); expect(new Uint8Array(await response.arrayBuffer())).toEqual(fileBytes); expect(Object.fromEntries(response.headers)).toEqual(privateFileHeaders); - const upstream = requests.find(request => new URL(request.url).pathname.endsWith("/file")); - expect(upstream?.headers.get("cookie")).toBe(sessionCookie); + expect(calls.map((call) => [call.tag, call.payload, call.headers.get("cookie")])).toEqual([ + ["receipts.readReceiptFileForApproval", { receiptId: "receipt-approval-file" }, sessionCookie], + ]); }); - it("denies an expired session before fetching private bytes", async () => { - sessionResponse = () => nativeProblemResponse("credential.invalid"); + + it("denies an expired session before reading private bytes", async () => { + answerSession = (call) => nativeRpcProblem(call, "credential.invalid"); await expectPrivateFailure(await load(), 401); - expect(requests.some(request => new URL(request.url).pathname.endsWith("/file"))).toBe(false); + expect(receiptCalls()).toEqual([]); }); - it("conceals invalid receipt paths without a file request", async () => { + + it("conceals invalid receipt paths without a file read", async () => { await expectPrivateFailure(await load(""), 404); - expect(requests.some(request => new URL(request.url).pathname.endsWith("/file"))).toBe(false); + expect(receiptCalls()).toEqual([]); }); + it.each([ - ["credential.invalid", 401], ["authority.denied", 403], ["origin.denied", 403], - ["resource.not-found", 404], ["receipts.unavailable", 503], + ["credential.invalid", 401], + ["authority.denied", 403], + ["resource.not-found", 404], + ["receipts.unavailable", 503], ] as const)("maps %s without exposing its problem body", async (code, status) => { - fileResponse = () => nativeProblemResponse(code); + answerFile = (call) => nativeRpcProblem(call, code); await expectPrivateFailure(await load(), status); }); - it("conceals malformed upstream problem bodies", async () => { - fileResponse = () => Response.json({ code: "credential.invalid", secret: "private-detail" }, { status: 401 }); + + it("conceals an answer outside the contract", async () => { + answerFile = fileAnswer("text/html", fileBytes); await expectPrivateFailure(await load(), 503); }); - it.each([["image/jpeg", "jpg"], ["application/pdf", "pdf"]] as const)("preserves %s media metadata", async (contentType, extension) => { - fileResponse = () => new Response(fileBytes, { headers: { - ...privateFileHeaders, "content-disposition": `inline; filename="receipt.${extension}"`, "content-type": contentType, - } }); + + it.each([ + ["image/jpeg", "jpg"], + ["application/pdf", "pdf"], + ] as const)("keeps the %s media type in its headers", async (contentType, extension) => { + answerFile = fileAnswer(contentType, fileBytes); const response = await load(); expect(response.headers.get("content-type")).toBe(contentType); - expect(response.headers.get("content-disposition")).toBe(`inline; filename="receipt.${extension}"`); + expect(response.headers.get("content-disposition")).toBe( + `inline; filename="receipt.${extension}"`, + ); expect(new Uint8Array(await response.arrayBuffer())).toEqual(fileBytes); }); - it("withholds bytes when upstream headers violate the private-file contract", async () => { - fileResponse = () => new Response(fileBytes, { headers: { ...privateFileHeaders, "content-disposition": "attachment; filename=receipt.png" } }); - await expectPrivateFailure(await load(), 503); - }); - it("rejects an empty body even with a matching zero length", async () => { - fileResponse = () => new Response(null, { headers: { ...privateFileHeaders, "content-length": "0" } }); + + it("withholds an empty file", async () => { + answerFile = fileAnswer("image/png", new Uint8Array()); await expectPrivateFailure(await load(), 503); }); }); diff --git a/apps/dashboard/app/receipt-owner-intake-route.test.ts b/apps/dashboard/app/receipt-owner-intake-route.test.ts index 7932201c3..e28f2dda7 100644 --- a/apps/dashboard/app/receipt-owner-intake-route.test.ts +++ b/apps/dashboard/app/receipt-owner-intake-route.test.ts @@ -1,11 +1,19 @@ import assert from "node:assert/strict"; -import { RECEIPT_FILE_MAX_BYTES } from "@vektorprogrammet/http-api"; +import { ReceiptResource } from "@vektorprogrammet/rpc"; +import { Schema } from "effect"; import { createStaticHandler, RouterContextProvider } from "react-router"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { nativeSessionResponse, sessionCookie } from "../test/native-http"; +import { sessionCookie } from "../test/native-http"; +import { + type NativeRpcCall, + nativeRpcSuccess, + nativeSession, + readNativeRpcCall, +} from "../test/native-rpc"; vi.hoisted(() => vi.stubEnv("API_URL", "http://api.test")); +import { RECEIPT_FILE_MAX_BYTES } from "./lib/receipt-upload.server"; import { action } from "./routes/dashboard.mine-utlegg._index"; const commandId = "receipt-owner-intake-command-0001"; @@ -16,20 +24,41 @@ const etag = '"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"'; const draft = { description: "Oversized receipt", amountNok: "125,50", receiptDate: "2026-09-01" }; -/** Requests that reached the native API, other than the session check. */ -const upstream: Request[] = []; +/** RPC calls that reached the native API, other than the session check. */ +const upstream: NativeRpcCall[] = []; + +/** The submitted receipt, as the RPC server encodes a `ReceiptResource`. */ +const submitted = Schema.encodeSync(Schema.toCodecJson(ReceiptResource))( + Schema.decodeSync(ReceiptResource)({ + receiptId, + visualId: "visual-owner-intake", + ownerPersonId: "person-1", + departmentId: "department-1", + description: draft.description, + amountOre: 12_550, + currency: "NOK", + receiptDate: draft.receiptDate, + status: "Pending", + submittedAt: "2026-09-01T12:00:00.000Z", + approvedAt: null, + revision: 0, + etag, + }), +); beforeEach(() => { upstream.length = 0; vi.stubGlobal( "fetch", vi.fn(async (input, init) => { - const request = new Request(input, init); + const call = await readNativeRpcCall(input, init); - if (new URL(request.url).pathname === "/api/session") return nativeSessionResponse(); - upstream.push(request); + if (call.tag === "system.readSession") return nativeRpcSuccess(call, nativeSession); + upstream.push(call); - return new Response(null, { status: 503 }); + return call.tag === "receipts.submitReceipt" + ? nativeRpcSuccess(call, submitted) + : new Response(null, { status: 503 }); }), ); }); @@ -37,7 +66,7 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()); /** The owner form as the browser sends it: its fields first, then the file part. */ -const ownerForm = (intent: string, fileBytes: number) => { +const ownerForm = (intent: string, fileBytes: Uint8Array) => { const form = new FormData(); form.set("_intent", intent); form.set("commandId", commandId); @@ -46,7 +75,7 @@ const ownerForm = (intent: string, fileBytes: number) => { form.set("description", draft.description); form.set("amountNok", draft.amountNok); form.set("receiptDate", draft.receiptDate); - form.set("file", new File([new Uint8Array(fileBytes)], "receipt.png", { type: "image/png" })); + form.set("file", new File([fileBytes], "receipt.png", { type: "image/png" })); return new Request("http://dashboard.test/dashboard/mine-utlegg", { method: "POST", @@ -69,19 +98,27 @@ const post = async (request: Request) => { /** The error names the file input, so the form shows it beside the file the owner picked. */ const fileError = { field: "file" }; +const oversized = () => new Uint8Array(RECEIPT_FILE_MAX_BYTES + 1); + describe("receipt owner form intake bound", () => { it("answers a file over the limit in the submission form with 413 and submits nothing", async () => { - const { status, answer } = await post(ownerForm("submit", RECEIPT_FILE_MAX_BYTES + 1)); + const { status, answer } = await post(ownerForm("submit", oversized())); expect(status).toBe(413); - expect(answer).toMatchObject({ success: false, intent: "submit", commandId, error: fileError, draft }); + expect(answer).toMatchObject({ + success: false, + intent: "submit", + commandId, + error: fileError, + draft, + }); expect(upstream).toEqual([]); }); it.each(["revise", "withdraw"])( "answers a %s form with a file over the limit in its receipt row with 413 and commits nothing", async (intent) => { - const { status, answer } = await post(ownerForm(intent, RECEIPT_FILE_MAX_BYTES + 1)); + const { status, answer } = await post(ownerForm(intent, oversized())); expect(status).toBe(413); expect(answer).toMatchObject({ @@ -92,4 +129,24 @@ describe("receipt owner form intake bound", () => { expect(upstream).toEqual([]); }, ); + + it("submits a file within the limit as bytes in the RPC payload", async () => { + const bytes = new Uint8Array([137, 80, 78, 71]); + const { status, answer } = await post(ownerForm("submit", bytes)); + + expect(status).toBe(200); + expect(answer).toMatchObject({ success: true, submission: { receiptId, etag } }); + expect(upstream.map((call) => [call.tag, call.headers.get("cookie")])).toEqual([ + ["receipts.submitReceipt", sessionCookie], + ]); + expect(upstream[0]?.payload).toEqual({ + idempotencyKey: commandId, + request: { + description: draft.description, + amountOre: 12_550, + receiptDate: draft.receiptDate, + file: { contentType: "image/png", bytes: Buffer.from(bytes).toString("base64") }, + }, + }); + }); }); diff --git a/apps/dashboard/app/routes/__foldkit.content.ts b/apps/dashboard/app/routes/__foldkit.content.ts index 9c101ee05..bf253e0d5 100644 --- a/apps/dashboard/app/routes/__foldkit.content.ts +++ b/apps/dashboard/app/routes/__foldkit.content.ts @@ -1,9 +1,14 @@ -import { StrongETag } from "@vektorprogrammet/http-api"; import { Schema as S, flow } from "effect"; import { data } from "react-router"; -import { contentBridgeFailure, type ContentBridgeErrorTag, ContentBridgeActionSchema } from "../foldkit/content/bridge"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { + contentBridgeFailure, + type ContentBridgeErrorTag, + ContentArticleObservationSchema, + ContentBridgeActionSchema, + ContentWorkspaceBootstrapSchema, +} from "../foldkit/content/bridge"; +import { callNative } from "../lib/api.server"; import { requireAuth } from "../lib/auth.server"; import { nativeFailureFrom } from "../lib/native-problem"; import type { Route } from "./+types/__foldkit.content"; @@ -78,18 +83,11 @@ const tagFrom = flow(nativeFailureFrom, (error): ContentBridgeErrorTag => { return "ContentPersistenceError"; }); -const articleObservation = < - A extends { - readonly body: unknown; - readonly headers: { readonly etag: StrongETag }; - }, ->( - result: A, -) => { - if (result.body === undefined) throw new Error("Content response did not include a body"); - - return { body: result.body, etag: result.headers.etag }; -}; +/** The article and its entity tag, as the browser client reads them from the bridge. */ +const articleObservation = (resource: { + readonly article: typeof ContentArticleObservationSchema.Type["body"]; + readonly etag: typeof ContentArticleObservationSchema.Type["etag"]; +}) => S.encodeSync(ContentArticleObservationSchema)({ body: resource.article, etag: resource.etag }); export async function loader({ request }: Route.LoaderArgs) { let cookie: string; @@ -106,24 +104,18 @@ export async function loader({ request }: Route.LoaderArgs) { } try { - const client = createAuthenticatedClient(cookie, request); - - const [workspaceResult, departmentsResult] = await Promise.all([ - client.content.readContentWorkspace({ query: {} }), - client.organization.listDepartments({ headers: {} }), + const [workspace, departments] = await Promise.all([ + callNative(cookie, request, (client) => client["content.readContentWorkspace"]({})), + callNative(cookie, request, (client) => client["organization.listDepartments"]()), ]); - if (workspaceResult.body === undefined || departmentsResult.body === undefined) { - throw new Error("Content workspace response did not include a body"); - } - return data( - { - workspace: workspaceResult.body, - knownDepartments: departmentsResult.body + S.encodeSync(ContentWorkspaceBootstrapSchema)({ + workspace, + knownDepartments: departments .filter((department) => department.active) .map(({ departmentId, name }) => ({ departmentId, name })), - }, + }), { headers: responseHeaders }, ); } catch (error) { @@ -165,43 +157,49 @@ export async function action({ request }: Route.ActionArgs) { } try { - const client = createAuthenticatedClient(cookie, request); - switch (command.operation) { - case "readArticle": + case "readArticle": { + const { articleId } = command; + return data( articleObservation( - await client.content.readArticle({ - params: { articleId: command.articleId }, - headers: {}, - }), + await callNative(cookie, request, (client) => + client["content.readArticle"]({ articleId }), + ), ), { headers: responseHeaders }, ); + } + case "createDraft": { - const { operation: _, commandId, ...payload } = command; + const { operation: _, commandId, ...article } = command; return data( articleObservation( - await client.content.createArticle({ - headers: { "idempotency-key": commandId }, - payload, - }), + await callNative(cookie, request, (client) => + client["content.createArticle"]({ + idempotencyKey: commandId, + request: article, + }), + ), ), { headers: responseHeaders }, ); } case "reviseDraft": { - const { operation: _, commandId, articleId, etag, ...payload } = command; + const { operation: _, commandId, articleId, etag, ...patch } = command; return data( articleObservation( - await client.content.reviseArticle({ - params: { articleId }, - headers: { "idempotency-key": commandId, "if-match": etag }, - payload, - }), + await callNative(cookie, request, (client) => + client["content.reviseArticle"]({ + articleId, + idempotencyKey: commandId, + ifMatch: etag, + request: patch, + }), + ), ), { headers: responseHeaders }, ); @@ -209,24 +207,24 @@ export async function action({ request }: Route.ActionArgs) { case "publish": case "unpublish": { - const current = await client.content.readArticle({ - params: { articleId: command.articleId }, - headers: {}, - }); - - const method = - command.operation === "publish" - ? client.content.publishArticle - : client.content.unpublishArticle; - - await method({ - params: { articleId: command.articleId }, - headers: { - "idempotency-key": command.commandId, - "if-match": current.headers.etag, - }, - payload: {}, - }); + const { articleId, commandId, operation } = command; + + // The transition runs under the entity tag of the article as the server reads it now. + const current = await callNative(cookie, request, (client) => + client["content.readArticle"]({ articleId }), + ); + + const transition = { + articleId, + idempotencyKey: commandId, + ifMatch: current.etag, + }; + + await callNative(cookie, request, (client) => + operation === "publish" + ? client["content.publishArticle"](transition) + : client["content.unpublishArticle"](transition), + ); return data({}, { headers: responseHeaders }); } diff --git a/apps/dashboard/app/routes/__foldkit.profile.ts b/apps/dashboard/app/routes/__foldkit.profile.ts index 153159c7a..f51fdb73e 100644 --- a/apps/dashboard/app/routes/__foldkit.profile.ts +++ b/apps/dashboard/app/routes/__foldkit.profile.ts @@ -1,9 +1,9 @@ -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; import { data } from "react-router"; import { toProfileBridgeFailure, ProfileBridgeFailure } from "../foldkit/profile/bridge"; import { ProfileCommand, ProfileInput } from "../foldkit/profile/model"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { requireAuth } from "../lib/auth.server"; import type { Route } from "./+types/__foldkit.profile"; @@ -64,25 +64,20 @@ export async function action({ request }: Route.ActionArgs) { ); } - const { commandId, etag, ...payload } = command; - const client = createAuthenticatedClient(cookie, request); + const { commandId, etag, ...patch } = command; try { - const result = await client.profile.updateOwnProfile({ - headers: { - "idempotency-key": IdempotencyKey.make(commandId), - "if-match": etag, - }, - payload, - }); - - return data( - S.decodeSync(ProfileInput)( - { profile: result.body, etag: result.headers.etag }, - { onExcessProperty: "error" }, - ), - { headers: responseHeaders }, + const result = await callNative(cookie, request, (client) => + client["profile.updateOwnProfile"]({ + idempotencyKey: IdempotencyKey.make(commandId), + ifMatch: etag, + request: patch, + }), ); + + return data(S.decodeSync(ProfileInput)(result, { onExcessProperty: "error" }), { + headers: responseHeaders, + }); } catch (error) { const failure = toProfileBridgeFailure(error); diff --git a/apps/dashboard/app/routes/__foldkit.recruitment.ts b/apps/dashboard/app/routes/__foldkit.recruitment.ts index a7cd21d82..bc827baf1 100644 --- a/apps/dashboard/app/routes/__foldkit.recruitment.ts +++ b/apps/dashboard/app/routes/__foldkit.recruitment.ts @@ -3,7 +3,7 @@ import { Match } from "effect"; import { data } from "react-router"; import { recruitmentFailureFromSdk, RecruitmentBridgeFailure } from "../foldkit/recruitment/bridge"; import { readRecruitmentBridgeOperation } from "../foldkit/recruitment/request.server"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { requireAuth } from "../lib/auth.server"; import type { Route } from "./+types/__foldkit.recruitment"; @@ -46,85 +46,93 @@ export async function action({ request }: Route.ActionArgs) { } const operation = decodedRequest.operation; - const recruitment = createAuthenticatedClient(cookie, request).recruitment; switch (operation.operation) { - case "readAssignmentBoard": { - const result = await recruitment.readAssignmentBoard({ query: operation.query }); - - return data(result.body, { headers: responseHeaders }); - } + case "readAssignmentBoard": + return data( + await callNative(cookie, request, (client) => + client["recruitment.readAssignmentBoard"](operation.query), + ), + { headers: responseHeaders }, + ); case "createApplicationInterview": { - const result = await recruitment.createApplicationInterview({ - params: operation.params, - headers: operation.headers, - payload: operation.payload, - }); + const created = await callNative(cookie, request, (client) => + client["recruitment.createApplicationInterview"]({ + applicationId: operation.params.applicationId, + idempotencyKey: operation.headers["idempotency-key"], + request: operation.payload, + }), + ); - return data(result.body, { headers: responseHeaders }); + return data(created.interview, { headers: responseHeaders }); } - case "readSchedulingBoard": { - const result = await recruitment.readSchedulingBoard(); - - return data(result.body, { headers: responseHeaders }); - } + case "readSchedulingBoard": + return data( + await callNative(cookie, request, (client) => client["recruitment.readSchedulingBoard"]()), + { headers: responseHeaders }, + ); case "scheduleInterview": { - const result = await recruitment.scheduleInterview({ - params: operation.params, - headers: operation.headers, - payload: operation.payload, - }); + const scheduled = await callNative(cookie, request, (client) => + client["recruitment.scheduleInterview"]({ + interviewId: operation.params.interviewId, + idempotencyKey: operation.headers["idempotency-key"], + ifMatch: operation.headers["if-match"], + request: operation.payload, + }), + ); - return data(result.body, { headers: responseHeaders }); + return data(scheduled.result, { headers: responseHeaders }); } - case "readInterviewConduct": { - const result = await recruitment.readInterviewConduct({ - params: operation.params, - headers: operation.headers, - }); - - if (result.body === undefined) { - throw new Error("Interview conduct response did not include a body"); - } - + case "readInterviewConduct": return data( - { detail: result.body, etag: result.headers.etag }, + await callNative(cookie, request, (client) => + client["recruitment.readInterviewConduct"]({ + interviewId: operation.params.interviewId, + }), + ), { headers: responseHeaders }, ); - } case "finalizeInterview": { - const result = await recruitment.finalizeInterview({ - params: operation.params, - headers: operation.headers, - payload: operation.payload, - }); + const finalized = await callNative(cookie, request, (client) => + client["recruitment.finalizeInterview"]({ + interviewId: operation.params.interviewId, + idempotencyKey: operation.headers["idempotency-key"], + ifMatch: operation.headers["if-match"], + request: operation.payload, + }), + ); - return data(result.body, { headers: responseHeaders }); + return data(finalized.result, { headers: responseHeaders }); } case "correctInterviewAssessment": { - const result = await recruitment.correctInterviewAssessment({ - params: operation.params, - headers: operation.headers, - payload: operation.payload, - }); + const corrected = await callNative(cookie, request, (client) => + client["recruitment.correctInterviewAssessment"]({ + interviewId: operation.params.interviewId, + idempotencyKey: operation.headers["idempotency-key"], + ifMatch: operation.headers["if-match"], + request: operation.payload, + }), + ); - return data(result.body, { headers: responseHeaders }); + return data(corrected.result, { headers: responseHeaders }); } case "cancelInterview": { - const result = await recruitment.cancelInterview({ - params: operation.params, - headers: operation.headers, - payload: operation.payload, - }); + const cancelled = await callNative(cookie, request, (client) => + client["recruitment.cancelInterview"]({ + interviewId: operation.params.interviewId, + idempotencyKey: operation.headers["idempotency-key"], + ifMatch: operation.headers["if-match"], + }), + ); - return data(result.body, { headers: responseHeaders }); + return data(cancelled.result, { headers: responseHeaders }); } } } catch (error) { diff --git a/apps/dashboard/app/routes/__foldkit.social-events.ts b/apps/dashboard/app/routes/__foldkit.social-events.ts index 812211c4b..97e8705f6 100644 --- a/apps/dashboard/app/routes/__foldkit.social-events.ts +++ b/apps/dashboard/app/routes/__foldkit.social-events.ts @@ -10,7 +10,8 @@ import { socialEventsBridgeFailure, type SocialEventsBridgeErrorTag, } from "../foldkit/social-events/bridge"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; +import { IdempotencyKey } from "@vektorprogrammet/rpc/problem"; import { requireAuth } from "../lib/auth.server"; import type { Route } from "./+types/__foldkit.social-events"; @@ -89,14 +90,11 @@ export async function loader({ request }: Route.LoaderArgs) { } try { - const result = await createAuthenticatedClient(cookie, request)["social-events"].readScope({}); - - if (result.body === undefined) throw new Error("Social-events scope response did not include a body"); - - return data( - S.decodeSync(SocialEventScopeResource)(result.body, { onExcessProperty: "error" }), - { headers: responseHeaders }, + const scope = await callNative(cookie, request, (client) => + client["social-events.readScope"](), ); + + return data(S.encodeSync(SocialEventScopeResource)(scope), { headers: responseHeaders }); } catch (error) { const tag = tagFrom(error); @@ -136,34 +134,28 @@ export async function action({ request }: Route.ActionArgs) { } try { - const socialEvents = createAuthenticatedClient(cookie, request)["social-events"]; - switch (operation.operation) { case "list": { - const result = await socialEvents.list({ query: operation.query }); - - if (result.body === undefined) throw new Error("Social-events list response did not include a body"); + const scope = operation.query; - return data( - S.decodeSync(SocialEventListResource)(result.body, { onExcessProperty: "error" }), - { headers: responseHeaders }, + const list = await callNative(cookie, request, (client) => + client["social-events.list"](scope), ); + + return data(S.encodeSync(SocialEventListResource)(list), { headers: responseHeaders }); } case "create": { - const { operation: _, commandId, ...payload } = operation; - - const result = await socialEvents.create({ - headers: { "idempotency-key": commandId }, - payload, - }); + const { operation: _, commandId, ...event } = operation; - if (result.body === undefined) throw new Error("Social-events create response did not include a body"); - - return data( - S.decodeSync(SocialEventResource)(result.body, { onExcessProperty: "error" }), - { headers: responseHeaders }, + const created = await callNative(cookie, request, (client) => + client["social-events.create"]({ + idempotencyKey: IdempotencyKey.make(commandId), + request: event, + }), ); + + return data(S.encodeSync(SocialEventResource)(created), { headers: responseHeaders }); } } } catch (error) { diff --git a/apps/dashboard/app/routes/dashboard._index.tsx b/apps/dashboard/app/routes/dashboard._index.tsx index d880a7b02..57969fff3 100644 --- a/apps/dashboard/app/routes/dashboard._index.tsx +++ b/apps/dashboard/app/routes/dashboard._index.tsx @@ -2,7 +2,7 @@ import { Predicate } from "effect"; import { Schema as S } from "effect"; import { useLoaderData } from "react-router"; import { requireAuth } from "../lib/auth.server"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import type { Route } from "./+types/dashboard._index"; /** @@ -20,18 +20,15 @@ export type LandingSummary = S.Schema.Type; export async function loader({ request }: Route.LoaderArgs): Promise<{ summary: LandingSummary }> { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); try { - const result = await client.profile.readOwnProfile({ headers: {} }); - - if (result.body === undefined) { - throw new Error("Profile read returned 304 without cache validators"); - } + const { profile } = await callNative(cookie, request, (client) => + client["profile.readOwnProfile"](), + ); return { summary: LandingSummary.cases.Available.make({ - name: `${result.body.firstName} ${result.body.lastName}`, + name: `${profile.firstName} ${profile.lastName}`, }), }; } catch { diff --git a/apps/dashboard/app/routes/dashboard.assistenter._index.tsx b/apps/dashboard/app/routes/dashboard.assistenter._index.tsx index 124cb2016..b4f865a61 100644 --- a/apps/dashboard/app/routes/dashboard.assistenter._index.tsx +++ b/apps/dashboard/app/routes/dashboard.assistenter._index.tsx @@ -1,7 +1,6 @@ import { AffiliationScope, CoverageCommand, - IdempotencyIfMatchHeaders, OwnAffiliationCommand, OwnCoverageCommand, PlacementCommand, @@ -11,13 +10,14 @@ import { type OwnCoverageResource, type PlacementBoardResource, type PlacementDraftResource, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; +import { IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc/problem"; import { Record, Option, Schema, Match } from "effect"; import { createElement, type ReactNode, useState } from "react"; import { Form, data, useActionData, useFetcher, useLoaderData, useLocation } from "react-router"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { requireAuth } from "../lib/auth.server"; import { nativeProblemFrom } from "../lib/native-problem"; import { semesterLabel } from "../lib/semester-label"; @@ -31,15 +31,18 @@ const privateData = (value: T, status = 200) => /** One chosen draft placement: the JSON of the Create command that applies it. */ const DraftPlacementChoice = Schema.fromJsonString(PlacementCommand); +/** The replay key and the observed entity tag that every placement command carries. */ +const CommandKeys = Schema.Struct({ idempotencyKey: IdempotencyKey, ifMatch: StrongETag }); + export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); const q = new URL(request.url).searchParams; const departmentId = q.get("departmentId") ?? ""; const semesterId = q.get("semesterId") ?? ""; try { - const scopes = (await client.placements.listScopes()).body; + const scopes = await callNative(cookie, request, (client) => client["placements.listScopes"]()); + let own: typeof OwnAffiliationResource.Type | null = null; let board: typeof PlacementBoardResource.Type | null = null; let ownCoverage: typeof OwnCoverageResource.Type | null = null; @@ -48,28 +51,31 @@ export async function loader({ request }: Route.LoaderArgs) { if (departmentId) { const scope = Schema.decodeSync(AffiliationScope)({ departmentId }); - own = (await client.placements.readOwnAffiliation({ query: scope })).body; + own = await callNative(cookie, request, (client) => + client["placements.readOwnAffiliation"](scope), + ); } if (departmentId && semesterId) { const scope = Schema.decodeSync(PlacementScope)({ departmentId, semesterId }); - ownCoverage = (await client.placements.readOwnCoverage({ query: scope })).body; + ownCoverage = await callNative(cookie, request, (client) => + client["placements.readOwnCoverage"](scope), + ); if ( scopes.departments.some( (department) => department.departmentId === departmentId && department.canManage, ) ) { - const [placement, coverageBoard] = await Promise.all([ - client.placements.readBoard({ query: scope }), - client.placements.readCoverageBoard({ query: scope }), + [board, coverage] = await Promise.all([ + callNative(cookie, request, (client) => client["placements.readBoard"](scope)), + callNative(cookie, request, (client) => client["placements.readCoverageBoard"](scope)), ]); - board = placement.body; - coverage = coverageBoard.body; - if (q.get("draft") === "1") { - draft = (await client.placements.readDraft({ query: scope })).body; + draft = await callNative(cookie, request, (client) => + client["placements.readDraft"](scope), + ); } } } @@ -102,13 +108,12 @@ export async function loader({ request }: Route.LoaderArgs) { export async function action({ request }: Route.ActionArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); const form: FormData = await request.formData(); try { - const headers = Schema.decodeUnknownSync(IdempotencyIfMatchHeaders)({ - "if-match": form.get("etag"), - "idempotency-key": form.get("commandId"), + const keys = Schema.decodeUnknownSync(CommandKeys)({ + ifMatch: form.get("etag"), + idempotencyKey: form.get("commandId"), }); const departmentId = form.get("departmentId"); @@ -117,14 +122,16 @@ export async function action({ request }: Route.ActionArgs) { const mode = form.get("mode"); if (action === "Request" || action === "Withdraw") { - await client.placements.commandOwnAffiliation({ - query: Schema.decodeUnknownSync(AffiliationScope)({ departmentId }), - headers, - payload: Schema.decodeSync(OwnAffiliationCommand)( - { action }, - { onExcessProperty: "error" }, - ), - }); + const scope = Schema.decodeUnknownSync(AffiliationScope)({ departmentId }); + + const payload = Schema.decodeSync(OwnAffiliationCommand)( + { action }, + { onExcessProperty: "error" }, + ); + + await callNative(cookie, request, (client) => + client["placements.commandOwnAffiliation"]({ ...scope, ...keys, request: payload }), + ); } else if (mode === "ownCoverage") { const query = Schema.decodeUnknownSync(PlacementScope)({ departmentId, @@ -147,7 +154,9 @@ export async function action({ request }: Route.ActionArgs) { { onExcessProperty: "error" }, ); - await client.placements.commandOwnCoverage({ query, headers, payload }); + await callNative(cookie, request, (client) => + client["placements.commandOwnCoverage"]({ ...query, ...keys, request: payload }), + ); } else if (mode === "coverage") { const query = Schema.decodeUnknownSync(PlacementScope)({ departmentId, @@ -185,7 +194,9 @@ export async function action({ request }: Route.ActionArgs) { { onExcessProperty: "error" }, ); - await client.placements.commandCoverageBoard({ query, headers, payload }); + await callNative(cookie, request, (client) => + client["placements.commandCoverageBoard"]({ ...query, ...keys, request: payload }), + ); } else { const query = Schema.decodeUnknownSync(PlacementScope)({ departmentId, @@ -194,7 +205,7 @@ export async function action({ request }: Route.ActionArgs) { if (action === "ApplyDraft") { const choices = form.getAll("draftPlacement"); - let etag = headers["if-match"]; + let ifMatch = keys.ifMatch; // Each chosen placement is one Create command against the board version the last one left. for (const [index, choice] of choices.entries()) { @@ -204,16 +215,20 @@ export async function action({ request }: Route.ActionArgs) { if (payload.action !== "Create") throw new Error("A draft choice creates a placement"); - const response = await client.placements.commandBoard({ - query, - headers: Schema.decodeSync(IdempotencyIfMatchHeaders)({ - "if-match": etag, - "idempotency-key": `${headers["idempotency-key"]}-${index}`, + const idempotencyKey = Schema.decodeSync(IdempotencyKey)( + `${keys.idempotencyKey}-${index}`, + ); + + const board = await callNative(cookie, request, (client) => + client["placements.commandBoard"]({ + ...query, + idempotencyKey, + ifMatch, + request: payload, }), - payload, - }); + ); - etag = response.body.etag; + ifMatch = board.etag; } return privateData({ @@ -274,32 +289,9 @@ export async function action({ request }: Route.ActionArgs) { onExcessProperty: "error", }); - switch (payload.action) { - case "Affiliation": - await client.placements.commandBoard({ query, headers, payload }); - break; - case "Create": - await client.placements.commandBoard({ query, headers, payload }); - break; - case "Edit": - await client.placements.commandBoard({ query, headers, payload }); - break; - case "Remove": - await client.placements.commandBoard({ query, headers, payload }); - break; - case "SetDemand": - await client.placements.commandBoard({ query, headers, payload }); - break; - case "GenerateProposal": - await client.placements.commandBoard({ query, headers, payload }); - break; - case "ConfirmProposal": - await client.placements.commandBoard({ query, headers, payload }); - break; - case "ScheduleService": - await client.placements.commandBoard({ query, headers, payload }); - break; - } + await callNative(cookie, request, (client) => + client["placements.commandBoard"]({ ...query, ...keys, request: payload }), + ); } return privateData({ diff --git a/apps/dashboard/app/routes/dashboard.brukere._index.tsx b/apps/dashboard/app/routes/dashboard.brukere._index.tsx index dde0323d1..1f01549a3 100644 --- a/apps/dashboard/app/routes/dashboard.brukere._index.tsx +++ b/apps/dashboard/app/routes/dashboard.brukere._index.tsx @@ -5,9 +5,9 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { ColumnDef } from "@tanstack/react-table"; import { useLoaderData } from "react-router"; -import { PeopleDirectoryEntry } from "@vektorprogrammet/http-api"; +import type { PeopleDirectoryEntry } from "@vektorprogrammet/rpc"; import { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import type { Route } from "./+types/dashboard.brukere._index"; export interface BrukerRow { @@ -21,7 +21,7 @@ export interface BrukerRow { readonly departments: string[]; } -function toRow(entry: typeof PeopleDirectoryEntry.Type): BrukerRow { +function toRow(entry: PeopleDirectoryEntry): BrukerRow { return { personId: entry.personId, firstName: entry.firstName, @@ -35,15 +35,14 @@ function toRow(entry: typeof PeopleDirectoryEntry.Type): BrukerRow { export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); try { - const result = await client.directory.listPeople({}); + const result = await callNative(cookie, request, (client) => client["directory.listPeople"]()); return { users: { - activeUsers: result.body.activePeople.map(toRow), - inactiveUsers: result.body.inactivePeople.map(toRow), + activeUsers: result.activePeople.map(toRow), + inactiveUsers: result.inactivePeople.map(toRow), }, }; } catch (error) { diff --git a/apps/dashboard/app/routes/dashboard.epostliste._index.tsx b/apps/dashboard/app/routes/dashboard.epostliste._index.tsx index 4b4ab62ac..8389a5b7b 100644 --- a/apps/dashboard/app/routes/dashboard.epostliste._index.tsx +++ b/apps/dashboard/app/routes/dashboard.epostliste._index.tsx @@ -1,32 +1,36 @@ -import { MailingListQuery, type PlacementScopes } from "@vektorprogrammet/http-api"; -import { Match, Schema } from "effect"; +import { MailingListQuery, type NativeRpcClient } from "@vektorprogrammet/rpc"; +import { type Effect, Match, Schema } from "effect"; import { Form, data, useLoaderData, useNavigation } from "react-router"; import { Button } from "../components/ui/button"; import { requireAuth } from "../lib/auth.server"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { nativeProblemFrom } from "../lib/native-problem"; import { semesterLabel } from "../lib/semester-label"; import type { Route } from "./+types/dashboard.epostliste._index"; +type PlacementScopes = Effect.Success>; + export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); const params = new URL(request.url).searchParams; const department = params.get("department") ?? ""; const semester = params.get("semester") ?? ""; const type = params.get("type") ?? "assistants"; - let scopes: typeof PlacementScopes.Type | null = null; + let scopes: PlacementScopes | null = null; try { - scopes = (await client.placements.listScopes()).body; + scopes = await callNative(cookie, request, (client) => client["placements.listScopes"]()); - const query = Schema.decodeUnknownSync(Schema.Struct(MailingListQuery))({ - department: department || undefined, - semester: semester || undefined, + const query = Schema.decodeUnknownSync(MailingListQuery)({ + departmentId: department || undefined, + semesterId: semester || undefined, type, }); - const lists = (await client.organization.listMailingLists({ query })).body; + const lists = await callNative(cookie, request, (client) => + client["organization.listMailingLists"](query), + ); + const emails = [...new Set(lists.flatMap((list) => list.emails))]; return data( diff --git a/apps/dashboard/app/routes/dashboard.intervjuer._index.tsx b/apps/dashboard/app/routes/dashboard.intervjuer._index.tsx index 5770a8508..e061f0990 100644 --- a/apps/dashboard/app/routes/dashboard.intervjuer._index.tsx +++ b/apps/dashboard/app/routes/dashboard.intervjuer._index.tsx @@ -10,7 +10,7 @@ import { recruitmentFailureFromSdk, } from "../foldkit/recruitment/bridge"; import { type SchedulingInput, LoadedSchedulingInput, FailedSchedulingInput } from "../foldkit/scheduling/model"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; import type { Route } from "./+types/dashboard.intervjuer._index"; @@ -21,15 +21,13 @@ const responseHeaders = { export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); let profile; try { - const result = await client.profile.readOwnProfile({ headers: {} }); - - if (result.body === undefined) throw new Error("Profile response did not include a body"); - profile = result.body; + ({ profile } = await callNative(cookie, request, (native) => + native["profile.readOwnProfile"](), + )); } catch { throw await expiredSessionRedirect(request); } @@ -41,8 +39,11 @@ export async function loader({ request }: Route.LoaderArgs) { let scheduling: SchedulingInput; try { - const result = await client.recruitment.readSchedulingBoard(); - scheduling = LoadedSchedulingInput.make({board: S.decodeSync(SchedulingBoard)(result.body, { + const board = await callNative(cookie, request, (native) => + native["recruitment.readSchedulingBoard"](), + ); + + scheduling = LoadedSchedulingInput.make({board: S.decodeSync(SchedulingBoard)(board, { onExcessProperty: "error", })}); } catch (error) { diff --git a/apps/dashboard/app/routes/dashboard.intervjuer.rapport.tsx b/apps/dashboard/app/routes/dashboard.intervjuer.rapport.tsx index a1c1e5dea..d0cebd26c 100644 --- a/apps/dashboard/app/routes/dashboard.intervjuer.rapport.tsx +++ b/apps/dashboard/app/routes/dashboard.intervjuer.rapport.tsx @@ -1,10 +1,9 @@ import { Predicate } from "effect"; import { Schema } from "effect"; import { Form, Link, data, useLoaderData, useNavigation } from "react-router"; -import { InterviewReport, -InterviewReportQuery, -interviewScoreTotal, } from "@vektorprogrammet/http-api" -import { createAuthenticatedClient } from "../lib/api.server"; +import { interviewScoreTotal } from "@vektorprogrammet/domain/recruitment"; +import { InterviewReport, InterviewReportQuery } from "@vektorprogrammet/rpc"; +import { callNative } from "../lib/api.server"; import { requireAuth, expiredSessionRedirect } from "../lib/auth.server"; import { recruitmentFailureFromSdk } from "../foldkit/recruitment/bridge"; import { Button } from "../components/ui/button"; @@ -28,14 +27,10 @@ export async function loader({ request }: Route.LoaderArgs) { } try { - const result = await createAuthenticatedClient(cookie, request).recruitment.readInterviewReport( - { query }, + const report = await callNative(cookie, request, (client) => + client["recruitment.readInterviewReport"](query), ); - const report = Schema.decodeSync(InterviewReport)(result.body, { - onExcessProperty: "error", - }); - return data({ report, failed: false as const, query }, { headers: responseHeaders }); } catch (error) { const failure = recruitmentFailureFromSdk(error); diff --git a/apps/dashboard/app/routes/dashboard.mine-utlegg._index.tsx b/apps/dashboard/app/routes/dashboard.mine-utlegg._index.tsx index 019110123..2e4fa110f 100644 --- a/apps/dashboard/app/routes/dashboard.mine-utlegg._index.tsx +++ b/apps/dashboard/app/routes/dashboard.mine-utlegg._index.tsx @@ -16,21 +16,26 @@ import { type ReceiptUiErrorField, } from "@/lib/receipt-view"; import { - RECEIPT_FILE_MAX_BYTES, - ReceiptFileTooLarge, + ReceiptCursor, ReceiptId, - readBoundedReceiptForm, -} from "@vektorprogrammet/http-api"; + type ReceiptFileUpload, + type SubmitReceiptRequest, +} from "@vektorprogrammet/rpc"; import { IdempotencyKey, StrongETag, type IdempotencyKey as IdempotencyKeyValue, type StrongETag as StrongETagValue, -} from "@vektorprogrammet/http-api"; -import { Schema } from "effect"; +} from "@vektorprogrammet/rpc"; +import { Option, Schema } from "effect"; import { data, useActionData, useLoaderData, useNavigation } from "react-router"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; +import { + RECEIPT_FILE_MAX_BYTES, + ReceiptFileTooLarge, + readBoundedReceiptForm, +} from "../lib/receipt-upload.server"; import type { Route } from "./+types/dashboard.mine-utlegg._index"; const MAX_AMOUNT_ORE = 9_007_199_254_740_991n; @@ -237,31 +242,42 @@ function parseReceiptFile(form: FormData, required: boolean): ParseResult { + return { contentType: file.type, bytes: new Uint8Array(await file.arrayBuffer()) }; +} - return payload; +async function receiptPayload( + fields: ParsedReceiptFields["payload"], + file: File, +): Promise { + return { ...fields, file: await receiptFileUpload(file) }; } +const decodeCursor = Schema.decodeOption(ReceiptCursor); + export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); - const cursor = new URL(request.url).searchParams.get("cursor") ?? undefined; + const cursorText = new URL(request.url).searchParams.get("cursor"); + const cursor = cursorText === null ? Option.some(undefined) : decodeCursor(cursorText); + + // A cursor that no list issued names no page; the backend never sees it. + if (Option.isNone(cursor)) { + return { + nextCursor: undefined, + receipts: [], + error: receiptDecodeError("Kontroller feltene og prøv igjen."), + }; + } try { - const result = await client.receipts.listReceipts({ query: { cursor } }); + const result = await callNative(cookie, request, (client) => + client["receipts.listReceipts"]({ cursor: cursor.value }), + ); return { - receipts: result.body.items.map(mapOwnedReceiptView), - nextCursor: result.body.nextCursor, + receipts: result.items.map(mapOwnedReceiptView), + nextCursor: result.nextCursor, error: undefined, }; } catch (error) { @@ -279,7 +295,6 @@ export async function loader({ request }: Route.LoaderArgs) { export async function action({ request }: Route.ActionArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); const read = await readReceiptForm(request); const { form } = read; @@ -336,16 +351,16 @@ export async function action({ request }: Route.ActionArgs) { } try { - const result = await client.receipts.submitReceipt({ - query: {}, - headers: { "idempotency-key": commandId }, - payload: receiptMultipartPayload(fields.value.payload, file.value), - }); + const submitRequest = await receiptPayload(fields.value.payload, file.value); + + const result = await callNative(cookie, request, (client) => + client["receipts.submitReceipt"]({ idempotencyKey: commandId, request: submitRequest }), + ); const submission: ReceiptSubmissionNotice = { commandId: commandIdText, - receiptId: result.body.receiptId, - etag: result.body.etag, + receiptId: result.receiptId, + etag: result.etag, }; return { success: true as const, intent, submission }; @@ -409,22 +424,30 @@ export async function action({ request }: Route.ActionArgs) { } try { - const result = await client.receipts.reviseReceipt({ - params: { receiptId: identity.value.receiptId }, - headers: { - "idempotency-key": commandId, - "if-match": identity.value.etag, - }, - payload: receiptMultipartPayload(fields.value.payload, replacementFile.value), - }); + const replacement = + replacementFile.value === undefined + ? undefined + : await receiptFileUpload(replacementFile.value); + + const result = await callNative(cookie, request, (client) => + client["receipts.reviseReceipt"]({ + receiptId: identity.value.receiptId, + idempotencyKey: commandId, + ifMatch: identity.value.etag, + request: + replacement === undefined + ? fields.value.payload + : { ...fields.value.payload, file: replacement }, + }), + ); const mutationNotice: ReceiptOwnerMutationNotice = { intent, commandId: commandIdText, - receiptId: result.body.receiptId, - status: result.body.status, - revision: result.body.revision, - etag: result.body.etag, + receiptId: result.receiptId, + status: result.status, + revision: result.revision, + etag: result.etag, }; return { success: true as const, intent, mutationNotice }; @@ -469,22 +492,21 @@ export async function action({ request }: Route.ActionArgs) { } try { - const result = await client.receipts.withdrawReceipt({ - params: { receiptId: identity.value.receiptId }, - headers: { - "idempotency-key": commandId, - "if-match": identity.value.etag, - }, - payload: {}, - }); + const result = await callNative(cookie, request, (client) => + client["receipts.withdrawReceipt"]({ + receiptId: identity.value.receiptId, + idempotencyKey: commandId, + ifMatch: identity.value.etag, + }), + ); const mutationNotice: ReceiptOwnerMutationNotice = { intent, commandId: commandIdText, - receiptId: result.body.receiptId, - status: result.body.status, - revision: result.body.revision, - etag: result.body.etag, + receiptId: result.receiptId, + status: result.status, + revision: result.revision, + etag: result.etag, }; return { success: true as const, intent, mutationNotice }; diff --git a/apps/dashboard/app/routes/dashboard.onboarding.tsx b/apps/dashboard/app/routes/dashboard.onboarding.tsx index b822cc155..0711c3c4e 100644 --- a/apps/dashboard/app/routes/dashboard.onboarding.tsx +++ b/apps/dashboard/app/routes/dashboard.onboarding.tsx @@ -1,10 +1,9 @@ -import { OnboardingCommand, OnboardingScope } from "@vektorprogrammet/http-api" -import { IdempotencyIfMatchHeaders } from "@vektorprogrammet/http-api"; +import { IdempotencyKey, OnboardingCommand, OnboardingScope, StrongETag } from "@vektorprogrammet/rpc"; import { Schema } from "effect"; import { useState } from "react"; import { Form, data, useFetcher, useLoaderData } from "react-router"; import { Button } from "../components/ui/button"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { requireAuth } from "../lib/auth.server"; import { formText } from "../lib/form-text"; import type { Route } from "./+types/dashboard.onboarding"; @@ -14,21 +13,18 @@ const privateData = (value: T, status = 200) => export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); - const departments = (await client.placements.listScopes()).body.departments.filter( - (d) => d.canManage, - ); + const scopes = await callNative(cookie, request, (client) => client["placements.listScopes"]()); + const departments = scopes.departments.filter((d) => d.canManage); const departmentId = new URL(request.url).searchParams.get("departmentId") ?? ""; - const board = departmentId - ? ( - await client.onboarding.readBoard({ - query: Schema.decodeSync(OnboardingScope)({ departmentId }), - }) - ).body - : null; + const board = + departmentId === "" + ? null + : await callNative(cookie, request, (client) => + client["onboarding.readBoard"](Schema.decodeSync(OnboardingScope)({ departmentId })), + ); return privateData({ departments, departmentId, board }); } @@ -38,17 +34,21 @@ export async function action({ request }: Route.ActionArgs) { const form: FormData = await request.formData(); try { - await createAuthenticatedClient(cookie, request).onboarding.command({ - query: Schema.decodeUnknownSync(OnboardingScope)({ departmentId: form.get("departmentId") }), - headers: Schema.decodeUnknownSync(IdempotencyIfMatchHeaders)({ - "if-match": form.get("etag"), - "idempotency-key": form.get("commandId"), - }), - payload: Schema.decodeUnknownSync(OnboardingCommand)({ + const scope = Schema.decodeUnknownSync(OnboardingScope)({ + departmentId: form.get("departmentId"), + }); + + const command = { + departmentId: scope.departmentId, + idempotencyKey: Schema.decodeUnknownSync(IdempotencyKey)(form.get("commandId")), + ifMatch: Schema.decodeUnknownSync(StrongETag)(form.get("etag")), + request: Schema.decodeUnknownSync(OnboardingCommand)({ applicationId: form.get("applicationId"), action: form.get("action"), }), - }); + }; + + await callNative(cookie, request, (client) => client["onboarding.command"](command)); return privateData({ ok: true, diff --git a/apps/dashboard/app/routes/dashboard.opptaksperioder._index.tsx b/apps/dashboard/app/routes/dashboard.opptaksperioder._index.tsx index d586e0a6c..f6b37b226 100644 --- a/apps/dashboard/app/routes/dashboard.opptaksperioder._index.tsx +++ b/apps/dashboard/app/routes/dashboard.opptaksperioder._index.tsx @@ -3,23 +3,20 @@ import { AdmissionPeriodCreateForm } from "@/components/admission-periods/Admiss import { AdmissionPeriodList } from "@/components/admission-periods/AdmissionPeriodList"; import { isAdmissionPeriodUnauthorizedError, mapAdmissionPeriodError, mapAdmissionPeriodView, parseAdmissionPeriodForm, type AdmissionPeriodCreateFailure, type AdmissionPeriodRevisionFailure } from "@/lib/admission-period-view"; import { useActionData, useLoaderData, useNavigation } from "react-router"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; import type { Route } from "./+types/dashboard.opptaksperioder._index"; export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); try { - const result = await client.admissions.listAdmissionPeriods({ headers: {} }); - - if (result.body === undefined) { - throw new Error("Admission-period response did not include a body"); - } + const result = await callNative(cookie, request, (client) => + client["admissions.listAdmissionPeriods"](), + ); return { - periods: result.body.items.map(mapAdmissionPeriodView), + periods: result.items.map(mapAdmissionPeriodView), error: undefined, }; } catch (error) { @@ -36,7 +33,6 @@ export async function loader({ request }: Route.LoaderArgs) { export async function action({ request }: Route.ActionArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); const form = await request.formData(); const parsed = parseAdmissionPeriodForm(form, crypto.randomUUID()); @@ -48,38 +44,40 @@ export async function action({ request }: Route.ActionArgs) { try { if (Predicate.isTagged(command, "CreateAdmissionPeriod")) { - const result = await client.admissions.createAdmissionPeriod({ - headers: { "idempotency-key": command.commandId }, - payload: command.payload, - }); + const created = await callNative(cookie, request, (client) => + client["admissions.createAdmissionPeriod"]({ + idempotencyKey: command.commandId, + request: command.payload, + }), + ); return { success: true as const, notice: { intent: "create" as const, commandId: command.commandId, - admissionPeriodId: result.body.id, - etag: result.body.etag, + admissionPeriodId: created.id, + etag: created.etag, }, }; } - const result = await client.admissions.reviseAdmissionPeriod({ - params: { admissionPeriodId: command.admissionPeriodId }, - headers: { - "idempotency-key": command.commandId, - "if-match": command.etag, - }, - payload: command.payload, - }); + const revised = await callNative(cookie, request, (client) => + client["admissions.reviseAdmissionPeriod"]({ + admissionPeriodId: command.admissionPeriodId, + idempotencyKey: command.commandId, + ifMatch: command.etag, + request: command.payload, + }), + ); return { success: true as const, notice: { intent: "revise" as const, commandId: command.commandId, - admissionPeriodId: result.body.id, - etag: result.body.etag, + admissionPeriodId: revised.id, + etag: revised.etag, }, }; } catch (error) { diff --git a/apps/dashboard/app/routes/dashboard.profile._index.tsx b/apps/dashboard/app/routes/dashboard.profile._index.tsx index 1a75e77c6..266b57466 100644 --- a/apps/dashboard/app/routes/dashboard.profile._index.tsx +++ b/apps/dashboard/app/routes/dashboard.profile._index.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; import { ChevronRight } from "lucide-react"; import { NavLink, href, useLoaderData } from "react-router"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { expiredSessionRedirect, loadSessionIdentity, requireAuth } from "../lib/auth.server"; import { nativeProblemFrom } from "../lib/native-problem"; import { projectProfile } from "../lib/profile-view"; @@ -11,15 +11,13 @@ import type { Route } from "./+types/dashboard.profile._index"; export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); - try { - const result = await client.profile.readOwnProfile({ headers: {} }); - - if (result.body === undefined) throw new Error("Profile response did not include a body"); + const { profile } = await callNative(cookie, request, (client) => + client["profile.readOwnProfile"](), + ); return { - profile: projectProfile(result.body), + profile: projectProfile(profile), identity: null, }; } catch (error) { diff --git a/apps/dashboard/app/routes/dashboard.profile.rediger._index.tsx b/apps/dashboard/app/routes/dashboard.profile.rediger._index.tsx index a8bb7cfaf..3216f3eeb 100644 --- a/apps/dashboard/app/routes/dashboard.profile.rediger._index.tsx +++ b/apps/dashboard/app/routes/dashboard.profile.rediger._index.tsx @@ -7,7 +7,7 @@ import { PROFILE_SEED_ATTRIBUTE, } from "../foldkit/profile/elements"; import { ProfileInputJson } from "../foldkit/profile/model"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; import type { Route } from "./+types/dashboard.profile.rediger._index"; @@ -18,20 +18,14 @@ const responseHeaders = { export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); try { - const result = await client.profile.readOwnProfile({ headers: {} }); - - if (result.body === undefined) throw new Error("Profile response did not include a body"); + const resource = await callNative(cookie, request, (client) => + client["profile.readOwnProfile"](), + ); return data( - { - serializedInput: S.encodeSync(ProfileInputJson)({ - profile: result.body, - etag: result.headers.etag, - }), - }, + { serializedInput: S.encodeSync(ProfileInputJson)(resource) }, { headers: responseHeaders }, ); } catch { diff --git a/apps/dashboard/app/routes/dashboard.sokere._index.tsx b/apps/dashboard/app/routes/dashboard.sokere._index.tsx index e4a6da999..4a446ca7a 100644 --- a/apps/dashboard/app/routes/dashboard.sokere._index.tsx +++ b/apps/dashboard/app/routes/dashboard.sokere._index.tsx @@ -10,7 +10,7 @@ import { recruitmentFailureFromSdk, } from "../foldkit/recruitment/bridge"; import { type RecruitmentInput, LoadedRecruitmentInput, FailedRecruitmentInput } from "../foldkit/recruitment/model"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; import type { Route } from "./+types/dashboard.sokere._index"; @@ -21,15 +21,13 @@ const responseHeaders = { export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); let profile; try { - const result = await client.profile.readOwnProfile({ headers: {} }); - - if (result.body === undefined) throw new Error("Profile response did not include a body"); - profile = result.body; + ({ profile } = await callNative(cookie, request, (native) => + native["profile.readOwnProfile"](), + )); } catch { throw await expiredSessionRedirect(request); } @@ -50,9 +48,12 @@ export async function loader({ request }: Route.LoaderArgs) { let recruitment: RecruitmentInput; try { - const result = await client.recruitment.readAssignmentBoard({ query: { status } }); + const board = await callNative(cookie, request, (native) => + native["recruitment.readAssignmentBoard"]({ status }), + ); + recruitment = LoadedRecruitmentInput.make({status, -board: result.body}); +board}); } catch (error) { const failure = recruitmentFailureFromSdk(error); diff --git a/apps/dashboard/app/routes/dashboard.soknad._index.tsx b/apps/dashboard/app/routes/dashboard.soknad._index.tsx index 472858778..941d1a2d4 100644 --- a/apps/dashboard/app/routes/dashboard.soknad._index.tsx +++ b/apps/dashboard/app/routes/dashboard.soknad._index.tsx @@ -1,7 +1,7 @@ import { Predicate } from "effect"; -import type { ApplicantProgressItem, ApplicantProgressState } from "@vektorprogrammet/http-api"; +import type { ApplicantProgressItem, ApplicantProgressState } from "@vektorprogrammet/rpc"; import { data, Link, useLoaderData } from "react-router"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; import { nativeProblemFrom } from "../lib/native-problem"; import type { Route } from "./+types/dashboard.soknad._index"; @@ -13,13 +13,14 @@ const responseHeaders = { export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); try { - const response = await client.admissions.readApplicantProgress(); + const progress = await callNative(cookie, request, (client) => + client["admissions.readApplicantProgress"](), + ); return data( - { progress: response.body, unavailable: false as const }, + { progress, unavailable: false as const }, { headers: responseHeaders }, ); } catch (cause) { diff --git a/apps/dashboard/app/routes/dashboard.teaminteresse._index.tsx b/apps/dashboard/app/routes/dashboard.teaminteresse._index.tsx index fbc5a4465..db442689e 100644 --- a/apps/dashboard/app/routes/dashboard.teaminteresse._index.tsx +++ b/apps/dashboard/app/routes/dashboard.teaminteresse._index.tsx @@ -1,21 +1,23 @@ import { DataTable } from "@/components/data-table"; import type { ColumnDef } from "@tanstack/react-table"; -import { TeamInterestResponse } from "@vektorprogrammet/http-api"; +import type { TeamInterestResponse } from "@vektorprogrammet/rpc"; import { useLoaderData } from "react-router"; import { requireAuth } from "../lib/auth.server"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import type { Route } from "./+types/dashboard.teaminteresse._index"; -type TeamInterest = (typeof TeamInterestResponse.Type)["hydra:member"][number]; +type TeamInterest = TeamInterestResponse["hydra:member"][number]; type TeamInterestRow = Pick; export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); - const result = await client.organization.listTeamInterest({ query: {} }); - const teamInterest = result.body["hydra:member"].map(({ id, userName, teamName }) => ({ + const result = await callNative(cookie, request, (client) => + client["organization.listTeamInterest"]({}), + ); + + const teamInterest = result["hydra:member"].map(({ id, userName, teamName }) => ({ id, userName, teamName, diff --git a/apps/dashboard/app/routes/dashboard.teamsoknader._index.tsx b/apps/dashboard/app/routes/dashboard.teamsoknader._index.tsx index 4f4fa7b28..46f7b4220 100644 --- a/apps/dashboard/app/routes/dashboard.teamsoknader._index.tsx +++ b/apps/dashboard/app/routes/dashboard.teamsoknader._index.tsx @@ -1,27 +1,26 @@ import { Link, useLoaderData } from "react-router"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { requireAuth } from "../lib/auth.server"; import type { Route } from "./+types/dashboard.teamsoknader._index"; /** Every active team: the directory has no membership read, so the team page enforces access. */ export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); const [departments, teams] = await Promise.all([ - client.organization.listDepartments({ headers: {} }), - client.organization.listTeams({ headers: {} }), + callNative(cookie, request, (client) => client["organization.listDepartments"]()), + callNative(cookie, request, (client) => client["organization.listTeams"]()), ]).catch(() => [undefined, undefined] as const); - if (departments?.body === undefined || teams?.body === undefined) { + if (departments === undefined || teams === undefined) { throw new Response("Teamoversikten er midlertidig utilgjengelig.", { status: 503 }); } const collator = new Intl.Collator("nb"); - const activeTeams = teams.body.filter((team) => team.active); + const activeTeams = teams.filter((team) => team.active); return { - departments: departments.body + departments: departments .filter((department) => department.active) .map((department) => ({ departmentId: department.departmentId, diff --git a/apps/dashboard/app/routes/dashboard.tidligere-assistenter._index.tsx b/apps/dashboard/app/routes/dashboard.tidligere-assistenter._index.tsx index 8c77ee6d1..fe31be835 100644 --- a/apps/dashboard/app/routes/dashboard.tidligere-assistenter._index.tsx +++ b/apps/dashboard/app/routes/dashboard.tidligere-assistenter._index.tsx @@ -1,11 +1,11 @@ -import { ReturningAssistantRegistrationInputSchema } from "@vektorprogrammet/http-api" -import { PersonId } from "@vektorprogrammet/http-api" -import { IdempotencyHeaders } from "@vektorprogrammet/http-api"; +import { PersonId } from "@vektorprogrammet/domain/organization"; +import { ReturningAssistantRegistrationInputSchema } from "@vektorprogrammet/rpc"; +import { IdempotencyKey } from "@vektorprogrammet/rpc/problem"; import { Record, Option, Predicate, Schema } from "effect"; import { data, useFetcher, useLoaderData, useNavigation, useRouteError, useSearchParams } from "react-router"; import { useState, useSyncExternalStore, type FormEvent } from "react"; import { Button } from "../components/ui/button"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { requireAuth } from "../lib/auth.server"; import { nativeProblemFrom } from "../lib/native-problem"; import { formText } from "../lib/form-text"; @@ -16,7 +16,6 @@ const privateData = (value: T, status = 200) => export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); const search = new URL(request.url).searchParams; const periodIds = search.getAll("admissionPeriodId"); @@ -28,18 +27,20 @@ export async function loader({ request }: Route.LoaderArgs) { const requestedPeriodId = periodIds[0]; try { - const result = await client.admissions.readReturningAssistantOptions(); + const options = await callNative(cookie, request, (client) => + client["admissions.readReturningAssistantOptions"](), + ); if ( requestedPeriodId !== undefined && - !result.body.periods.some(({ period }) => period.id === requestedPeriodId) + !options.periods.some(({ period }) => period.id === requestedPeriodId) ) return privateData( { options: null, error: "Opptaksperioden er ikke tilgjengelig." }, 400, ); - return privateData({ options: result.body, error: null }); + return privateData({ options, error: null }); } catch (cause) { const problem = nativeProblemFrom(cause); @@ -63,7 +64,6 @@ const boolField = (form: FormData, name: string) => form.get(name) === "true"; export async function action({ request }: Route.ActionArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); const form = await request.formData(); const commandId = formText(form, "commandId") || crypto.randomUUID(); @@ -86,17 +86,18 @@ export async function action({ request }: Route.ActionArgs) { teamIds: form.getAll("teamIds"), }); - const result = await client.admissions.registerReturningAssistant({ - headers: Schema.decodeSync(IdempotencyHeaders)({ "idempotency-key": commandId }), - payload, - }); + const idempotencyKey = Schema.decodeSync(IdempotencyKey)(commandId); + + const result = await callNative(cookie, request, (client) => + client["admissions.registerReturningAssistant"]({ idempotencyKey, request: payload }), + ); return privateData({ success: true as const, message: "Registreringen er lagret.", commandId, admissionPeriodId: payload.admissionPeriodId, - revision: result.body.observation.revision, + revision: result.observation.revision, }); } catch (cause) { const problem = nativeProblemFrom(cause); diff --git a/apps/dashboard/app/routes/dashboard.utlegg.$receiptId.file.ts b/apps/dashboard/app/routes/dashboard.utlegg.$receiptId.file.ts index c89346c26..0ceffb26f 100644 --- a/apps/dashboard/app/routes/dashboard.utlegg.$receiptId.file.ts +++ b/apps/dashboard/app/routes/dashboard.utlegg.$receiptId.file.ts @@ -1,7 +1,7 @@ -import { Predicate, Schema, flow } from "effect"; -import { ReceiptId } from "@vektorprogrammet/http-api" +import { Schema, flow } from "effect"; +import { ReceiptId, type ReceiptFileContent } from "@vektorprogrammet/rpc"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { requireAuth } from "../lib/auth.server"; import { nativeFailureFrom } from "../lib/native-problem"; import type { Route } from "./+types/dashboard.utlegg.$receiptId.file"; @@ -17,15 +17,6 @@ const fileExtensionByContentType = { "application/pdf": "pdf", } as const; -type ReceiptFileHeaders = { - readonly "cache-control"?: unknown; - readonly "content-disposition"?: unknown; - readonly "content-length"?: unknown; - readonly "content-type"?: unknown; - readonly "x-content-type-options"?: unknown; - readonly vary?: unknown; -}; - type ReceiptFileFailureStatus = 401 | 403 | 404 | 503; function privateFailure(status: ReceiptFileFailureStatus): Response { @@ -76,51 +67,20 @@ const authenticatedFailureStatus = flow(nativeFailureFrom, (error): 401 | 503 => return 503; }); -function receiptFileResponseHeaders( - headers: ReceiptFileHeaders, - bytes: Uint8Array, -): Headers | undefined { - const contentType = headers["content-type"]; - const contentLength = headers["content-length"]; - const contentDisposition = headers["content-disposition"]; - const contentTypeOptions = headers["x-content-type-options"]; - const cacheControl = headers["cache-control"]; - const vary = headers.vary; - - if ( - !Predicate.isString(contentType) || - (contentType !== "image/jpeg" && - contentType !== "image/png" && - contentType !== "application/pdf") || - !Predicate.isString(contentLength) || - !Predicate.isString(contentDisposition) || - !Predicate.isString(contentTypeOptions) || - !Predicate.isString(cacheControl) || - !Predicate.isString(vary) - ) { - return undefined; - } - - const extension = fileExtensionByContentType[contentType]; - - if ( - !/^[1-9]\d*$/u.test(contentLength) || - Number(contentLength) !== bytes.byteLength || - contentDisposition !== `inline; filename="receipt.${extension}"` || - contentTypeOptions !== "nosniff" || - cacheControl !== "private, no-store" || - vary !== "Origin" - ) { - return undefined; - } +/** + * The private headers of one receipt file, derived from its stored media type: the browser gets + * the same answer that the backend's HTTP route gave, at the same dashboard URL. + */ +function receiptFileResponseHeaders(file: ReceiptFileContent): Headers | undefined { + if (file.bytes.byteLength === 0) return undefined; return new Headers({ - "cache-control": cacheControl, - "content-disposition": contentDisposition, - "content-length": contentLength, - "content-type": contentType, - vary, - "x-content-type-options": contentTypeOptions, + "cache-control": "private, no-store", + "content-disposition": `inline; filename="receipt.${fileExtensionByContentType[file.contentType]}"`, + "content-length": String(file.bytes.byteLength), + "content-type": file.contentType, + vary: "Origin", + "x-content-type-options": "nosniff", }); } @@ -142,21 +102,18 @@ export async function loader({ request, params }: Route.LoaderArgs): Promise + client["receipts.readReceiptFileForApproval"]({ receiptId }), + ); - const headers = receiptFileResponseHeaders(result.headers, result.body); + const headers = receiptFileResponseHeaders(result); if (headers === undefined) return privateFailure(503); const responseBytes = - result.body.buffer instanceof ArrayBuffer - ? new Uint8Array(result.body.buffer, result.body.byteOffset, result.body.byteLength) - : Uint8Array.from(result.body); + result.bytes.buffer instanceof ArrayBuffer + ? new Uint8Array(result.bytes.buffer, result.bytes.byteOffset, result.bytes.byteLength) + : Uint8Array.from(result.bytes); return new Response(responseBytes, { headers }); } catch (error) { diff --git a/apps/dashboard/app/routes/dashboard.utlegg._index.tsx b/apps/dashboard/app/routes/dashboard.utlegg._index.tsx index 498700851..f7a6499e0 100644 --- a/apps/dashboard/app/routes/dashboard.utlegg._index.tsx +++ b/apps/dashboard/app/routes/dashboard.utlegg._index.tsx @@ -1,5 +1,5 @@ import { ReceiptPagination } from "@/components/receipts/ReceiptPagination"; -import { Predicate, Schema, Match } from "effect"; +import { Match, Option, Predicate, Schema } from "effect"; import { ApprovalReceiptList } from "@/components/receipts/ApprovalReceiptList"; import { Button } from "@/components/ui/button"; import { @@ -11,16 +11,16 @@ import { type ReceiptStatus, ReceiptUiError, } from "@/lib/receipt-view"; -import { ReceiptId } from "@vektorprogrammet/http-api"; +import { ReceiptCursor, ReceiptId } from "@vektorprogrammet/rpc"; import { IdempotencyKey, StrongETag, type IdempotencyKey as IdempotencyKeyValue, type StrongETag as StrongETagValue, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Link, useActionData, useLoaderData, useNavigation } from "react-router"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; import type { Route } from "./+types/dashboard.utlegg._index"; @@ -96,10 +96,28 @@ function parseApprovalCommand( export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); - const cursor = new URL(request.url).searchParams.get("cursor") ?? undefined; + const cursorText = new URL(request.url).searchParams.get("cursor"); const requestedStatus = new URL(request.url).searchParams.get("status"); + // A cursor that no list issued names no page; the backend never sees it. + const cursor = + cursorText === null + ? Option.some(undefined) + : Schema.decodeOption(ReceiptCursor)(cursorText); + + if (Option.isNone(cursor)) { + const error: ReceiptUiError = ReceiptUiError.ReceiptDecodeError({ + message: "Kontroller feltene og prøv igjen.", + }); + + return { + nextCursor: undefined, + receipts: [], + status: undefined, + error, + }; + } + if (requestedStatus !== null && !isReceiptStatus(requestedStatus)) { const error: ReceiptUiError = ReceiptUiError.ReceiptDecodeError({ message: "Statusfilteret er ugyldig. Velg en status fra listen.", @@ -116,14 +134,14 @@ export async function loader({ request }: Route.LoaderArgs) { const status = isReceiptStatus(requestedStatus) ? requestedStatus : undefined; try { - const result = await client.receipts.listReceiptsForApproval({ - query: { status, cursor }, - }); + const result = await callNative(cookie, request, (client) => + client["receipts.listReceiptsForApproval"]({ status, cursor: cursor.value }), + ); return { - receipts: result.body.items.map(mapApprovalReceiptView), + receipts: result.items.map(mapApprovalReceiptView), status, - nextCursor: result.body.nextCursor, + nextCursor: result.nextCursor, error: undefined, }; } catch (error) { @@ -142,7 +160,6 @@ export async function loader({ request }: Route.LoaderArgs) { export async function action({ request }: Route.ActionArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); const form = await request.formData(); const intentValue = readFormText(form, "_intent"); @@ -163,19 +180,18 @@ export async function action({ request }: Route.ActionArgs) { const command = parsed.value; try { - const requestInput = { - params: { receiptId: command.receiptId }, - headers: { - "idempotency-key": command.commandId, - "if-match": command.etag, - }, - payload: {}, + const payload = { + receiptId: command.receiptId, + idempotencyKey: command.commandId, + ifMatch: command.etag, }; - const result = await Match.value(command).pipe( - Match.when({ intent: "approve" }, () => client.receipts.approveReceipt(requestInput)), - Match.when({ intent: "reopen" }, () => client.receipts.reopenReceipt(requestInput)), - Match.orElse(() => client.receipts.rejectReceipt(requestInput)), + const result = await callNative(cookie, request, (client) => + Match.value(command).pipe( + Match.when({ intent: "approve" }, () => client["receipts.approveReceipt"](payload)), + Match.when({ intent: "reopen" }, () => client["receipts.reopenReceipt"](payload)), + Match.orElse(() => client["receipts.rejectReceipt"](payload)), + ), ); return { @@ -183,10 +199,10 @@ export async function action({ request }: Route.ActionArgs) { actionNotice: { intent: command.intent, commandId: command.commandId, - receiptId: result.body.receiptId, - status: result.body.status, - revision: result.body.revision, - etag: result.body.etag, + receiptId: result.receiptId, + status: result.status, + revision: result.revision, + etag: result.etag, }, }; } catch (error) { diff --git a/apps/dashboard/app/routes/dashboard.utlegg.oppgjor.$receiptId.tsx b/apps/dashboard/app/routes/dashboard.utlegg.oppgjor.$receiptId.tsx index f8eaadd8e..426f7163b 100644 --- a/apps/dashboard/app/routes/dashboard.utlegg.oppgjor.$receiptId.tsx +++ b/apps/dashboard/app/routes/dashboard.utlegg.oppgjor.$receiptId.tsx @@ -1,10 +1,10 @@ import { ReceiptSettlementEvidence } from "@/components/receipts/ReceiptSettlementEvidence"; import { Button } from "@/components/ui/button"; import { isUnauthorizedError, mapReceiptSettlementEvidenceView, mapApprovalReceiptError, ReceiptUiError } from "@/lib/receipt-view"; -import { ReceiptId } from "@vektorprogrammet/http-api"; +import { ReceiptId } from "@vektorprogrammet/rpc"; import { Schema } from "effect"; import { Link, useLoaderData } from "react-router"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; import type { Route } from "./+types/dashboard.utlegg.oppgjor.$receiptId"; @@ -21,15 +21,13 @@ export async function loader({ request, params }: Route.LoaderArgs) { }; } - const client = createAuthenticatedClient(cookie, request); - try { - const result = await client.receipts.readReceiptSettlementForFinance({ - params: { receiptId }, - }); + const result = await callNative(cookie, request, (client) => + client["receipts.readReceiptSettlementForFinance"]({ receiptId }), + ); return { - evidence: mapReceiptSettlementEvidenceView(result.body), + evidence: mapReceiptSettlementEvidenceView(result), error: undefined, }; } catch (error) { diff --git a/apps/dashboard/app/routes/dashboard.utlegg.oppgjor._index.tsx b/apps/dashboard/app/routes/dashboard.utlegg.oppgjor._index.tsx index a5d37caa0..84f6ca106 100644 --- a/apps/dashboard/app/routes/dashboard.utlegg.oppgjor._index.tsx +++ b/apps/dashboard/app/routes/dashboard.utlegg.oppgjor._index.tsx @@ -13,15 +13,16 @@ import { } from "@/lib/receipt-view"; import { IdempotencyKey, + ReceiptCursor, ReceiptId, RecordReceiptSettlementRequest, StrongETag, type IdempotencyKey as IdempotencyKeyValue, type StrongETag as StrongETagValue, -} from "@vektorprogrammet/http-api"; -import { Schema } from "effect"; +} from "@vektorprogrammet/rpc"; +import { Option, Schema } from "effect"; import { Link, useActionData, useLoaderData, useNavigation } from "react-router"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; import type { Route } from "./+types/dashboard.utlegg.oppgjor._index"; @@ -153,15 +154,30 @@ function parseSettlementCommand(form: FormData): SettlementCommandParseResult { export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); - const cursor = new URL(request.url).searchParams.get("cursor") ?? undefined; + const cursorText = new URL(request.url).searchParams.get("cursor"); + + // A cursor that no list issued names no page; the backend never sees it. + const cursor = + cursorText === null + ? Option.some(undefined) + : Schema.decodeOption(ReceiptCursor)(cursorText); + + if (Option.isNone(cursor)) { + const error: ReceiptUiError = ReceiptUiError.ReceiptDecodeError({ + message: "Kontroller feltene og prøv igjen.", + }); + + return { nextCursor: undefined, receipts: [], error }; + } try { - const result = await client.receipts.listReceiptsForSettlement({ query: { cursor } }); + const result = await callNative(cookie, request, (client) => + client["receipts.listReceiptsForSettlement"]({ cursor: cursor.value }), + ); return { - receipts: result.body.items.map(mapSettlementReceiptView), - nextCursor: result.body.nextCursor, + receipts: result.items.map(mapSettlementReceiptView), + nextCursor: result.nextCursor, error: undefined, }; } catch (error) { @@ -179,7 +195,6 @@ export async function loader({ request }: Route.LoaderArgs) { export async function action({ request }: Route.ActionArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); const form = await request.formData(); if (readFormText(form, "_intent") !== "settle") { @@ -199,18 +214,18 @@ export async function action({ request }: Route.ActionArgs) { const command = parsed.value; try { - const result = await client.receipts.settleReceipt({ - params: { receiptId: command.receiptId }, - headers: { - "idempotency-key": command.commandId, - "if-match": command.etag, - }, - payload: command.payload, - }); + const result = await callNative(cookie, request, (client) => + client["receipts.settleReceipt"]({ + receiptId: command.receiptId, + idempotencyKey: command.commandId, + ifMatch: command.etag, + request: command.payload, + }), + ); const actionNotice: ReceiptSettlementNotice = { commandId: command.commandId, - settlement: mapReceiptSettlementEvidenceView(result.body), + settlement: mapReceiptSettlementEvidenceView(result), }; return { success: true as const, actionNotice }; diff --git a/apps/dashboard/app/routes/dashboard.vikarer._index.tsx b/apps/dashboard/app/routes/dashboard.vikarer._index.tsx index df94029a5..5fe34c31e 100644 --- a/apps/dashboard/app/routes/dashboard.vikarer._index.tsx +++ b/apps/dashboard/app/routes/dashboard.vikarer._index.tsx @@ -1,10 +1,10 @@ import { AdmissionOutcomeCommand, AdmissionOutcomeScope, - IdempotencyIfMatchHeaders, PublicApplicationIdSchema, type AdmissionOutcomeResource, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; +import { IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc/problem"; import { Option, Predicate, Schema } from "effect"; import { useState } from "react"; import { @@ -18,7 +18,7 @@ import { useNavigation, } from "react-router"; import { Button } from "../components/ui/button"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { requireAuth } from "../lib/auth.server"; import { nativeProblemFrom } from "../lib/native-problem"; import { semesterLabel } from "../lib/semester-label"; @@ -30,7 +30,6 @@ const privateData = (value: T, status = 200) => export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); const query = new URL(request.url).searchParams; const selection = { @@ -39,7 +38,9 @@ export async function loader({ request }: Route.LoaderArgs) { }; try { - const scopes = (await client.admissionOutcomes.listScopes()).body; + const scopes = await callNative(cookie, request, (client) => + client["admissionOutcomes.listScopes"](), + ); if (!selection.departmentId && !selection.semesterId) return privateData({ ...selection, scopes, board: null, error: null }); @@ -56,11 +57,11 @@ export async function loader({ request }: Route.LoaderArgs) { }); try { - const board = ( - await client.admissionOutcomes.readOutcomes({ - query: Schema.decodeSync(AdmissionOutcomeScope)(selection), - }) - ).body; + const scope = Schema.decodeSync(AdmissionOutcomeScope)(selection); + + const board = await callNative(cookie, request, (client) => + client["admissionOutcomes.readOutcomes"](scope), + ); return privateData({ ...selection, scopes, board, error: null }); } catch (cause) { @@ -87,11 +88,12 @@ export async function loader({ request }: Route.LoaderArgs) { } } -/** The fields of one outcome form, decoded as the SDK's record request. */ +/** The fields of one outcome form, decoded as the payload of `admissionOutcomes.recordOutcome`. */ const RecordOutcomeRequest = Schema.Struct({ - params: Schema.Struct({ applicationId: PublicApplicationIdSchema }), - headers: IdempotencyIfMatchHeaders, - payload: AdmissionOutcomeCommand, + applicationId: PublicApplicationIdSchema, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: AdmissionOutcomeCommand, }); const failureMessage = (code: string | undefined) => { @@ -111,15 +113,15 @@ const failureMessage = (code: string | undefined) => { export async function action({ request }: Route.ActionArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); const form: FormData = await request.formData(); const applicationId = formText(form, "applicationId"); const commandId = formText(form, "commandId"); const command = Schema.decodeUnknownOption(RecordOutcomeRequest)({ - params: { applicationId: form.get("applicationId") }, - headers: { "if-match": form.get("etag"), "idempotency-key": form.get("commandId") }, - payload: { outcome: form.get("outcome") }, + applicationId: form.get("applicationId"), + idempotencyKey: form.get("commandId"), + ifMatch: form.get("etag"), + request: { outcome: form.get("outcome") }, }); if (Option.isNone(command)) @@ -135,7 +137,11 @@ export async function action({ request }: Route.ActionArgs) { ); try { - await client.admissionOutcomes.recordOutcome(command.value); + const recorded = command.value; + + await callNative(cookie, request, (client) => + client["admissionOutcomes.recordOutcome"](recorded), + ); return privateData({ success: true as const, @@ -179,7 +185,7 @@ function revealCommandFeedback(element: HTMLElement | null): void { const selectClass = "h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"; -type Entry = typeof AdmissionOutcomeResource.Type; +type Entry = AdmissionOutcomeResource; type Outcome = (AdmissionOutcomeCommand)["outcome"]; diff --git a/apps/dashboard/app/routes/dashboard_.foldkit.tsx b/apps/dashboard/app/routes/dashboard_.foldkit.tsx index 2120e94b7..05bd70587 100644 --- a/apps/dashboard/app/routes/dashboard_.foldkit.tsx +++ b/apps/dashboard/app/routes/dashboard_.foldkit.tsx @@ -1,10 +1,10 @@ -import { UserProfileResponse } from "@vektorprogrammet/http-api"; +import { UserProfileResponse } from "@vektorprogrammet/rpc"; import { Schema as S } from "effect"; import { createElement } from "react"; import { data, useLoaderData } from "react-router"; import { DASHBOARD_ELEMENT, DASHBOARD_INPUT_ATTRIBUTE } from "../foldkit/dashboard/elements"; import { DashboardInput, DashboardInputJson, isDashboardRole, LandingSummary } from "../foldkit/dashboard/model"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; import { nativeProblemFrom } from "../lib/native-problem"; import { ownerEnabled, responseHeaders } from "../lib/interview-bridge.server"; @@ -16,15 +16,13 @@ export async function loader({ request }: Route.LoaderArgs) { } const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); let profile: typeof UserProfileResponse.Type | null = null; try { - const result = await client.profile.readOwnProfile({ headers: {} }); - - if (result.body === undefined) throw new Error("Profile response did not include a body"); - profile = result.body; + ({ profile } = await callNative(cookie, request, (native) => + native["profile.readOwnProfile"](), + )); } catch (error) { const code = nativeProblemFrom(error)?.code; diff --git a/apps/dashboard/app/routes/konto-aktivering.tsx b/apps/dashboard/app/routes/konto-aktivering.tsx index 85cc8087c..6b81142a8 100644 --- a/apps/dashboard/app/routes/konto-aktivering.tsx +++ b/apps/dashboard/app/routes/konto-aktivering.tsx @@ -1,10 +1,10 @@ -import { OnboardingClaim } from "@vektorprogrammet/http-api" +import { OnboardingClaim } from "@vektorprogrammet/rpc" import { Schema } from "effect"; import { useSyncExternalStore } from "react"; import { data, Link, useFetcher, useLoaderData } from "react-router"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; -import { createAuthenticatedClient } from "../lib/api.server"; +import { callNative } from "../lib/api.server"; import { hasAuthenticatedSession, requireAuth } from "../lib/auth.server"; import { nativeProblemFrom } from "../lib/native-problem"; import type { Route } from "./+types/konto-aktivering"; @@ -35,11 +35,10 @@ export async function action({ request }: Route.ActionArgs) { : { mode, token: form.get("token") }, ); + // A new-account claim presents its token alone, so it forwards no session cookie. const cookie = payload.mode === "ExistingAccount" ? await requireAuth(request) : ""; - const client = createAuthenticatedClient(cookie, request); - if (payload.mode === "NewAccount") await client.onboarding.claim({ payload }); - else await client.onboarding.claim({ payload }); + await callNative(cookie, request, (client) => client["onboarding.claim"](payload)); return privateData({ ok: true, diff --git a/apps/dashboard/e2e/admission-period-management.spec.ts b/apps/dashboard/e2e/admission-period-management.spec.ts index bc400efe0..74699d1ca 100644 --- a/apps/dashboard/e2e/admission-period-management.spec.ts +++ b/apps/dashboard/e2e/admission-period-management.spec.ts @@ -1,21 +1,19 @@ -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.js"; import { readFile, writeFile } from "node:fs/promises"; import { expect, test, type Locator } from "@playwright/test"; import { - AdmissionPeriodManagementItem, - AdmissionPeriodManagementListResponse, + AdmissionPeriodId, AdmissionPeriodMergePatch, - AdmissionsCreateAdmissionPeriodProblem, - AdmissionsListAdmissionPeriodsProblem, - AdmissionsReviseAdmissionPeriodProblem, - AdmissionsSubmitApplicationProblem, CreateAdmissionPeriodRequest, + SubmitApplicationRequest, +} from "@vektorprogrammet/rpc"; +import { + IdempotencyKey, + type NativeProblemCode, NativeProblemRegistry, - OpenAdmissionPeriodListResponse, - PublicApplicationConfirmationSchema, - SessionUnauthorizedProblem, - type SubmitApplicationRequest, -} from "@vektorprogrammet/http-api"; + type StrongETag, +} from "@vektorprogrammet/rpc/problem"; +import { nativeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; import { DateTime, Schema } from "effect"; import { dashboardMount, dashboardPagePath } from "../dashboard-base"; @@ -140,83 +138,33 @@ const assertCalendar = ({ fixedNow, semester }: JourneyReference) => { ).toBe(true); }; -/** - * The closed Problem Details unions that the admission endpoints declare, and the credential - * problems that their person security middleware declares once for all of them. - */ -type AdmissionProblems = - | typeof SessionUnauthorizedProblem - | typeof AdmissionsListAdmissionPeriodsProblem - | typeof AdmissionsCreateAdmissionPeriodProblem - | typeof AdmissionsReviseAdmissionPeriodProblem - | typeof AdmissionsSubmitApplicationProblem; - -/** The union names the codes that the operation may answer; the registry owns each status. */ -async function expectProblem( - response: Response, - problems: Problems, - expectedCode: Problems["Type"]["code"], -): Promise<{ readonly status: number; readonly code: string }> { - const body: unknown = await response.json(); - - expect({ status: response.status, body }).toMatchObject({ - status: NativeProblemRegistry[expectedCode].status, - body: { code: expectedCode }, - }); - expect(response.headers.get("content-type")).toContain("application/problem+json"); - let problem: Problems["Type"]; +/** A declared problem: the registry owns each code's status. */ +function expectProblem(answer: ScriptCallResult, expectedCode: NativeProblemCode) { + const status = NativeProblemRegistry[expectedCode].status; - try { - problem = Schema.decodeUnknownSync(problems, exact)(body); - } catch (error) { - throw new Error(`${JSON.stringify(body)} is not a declared problem: ${String(error)}`); - } + expect(answer).toMatchObject({ ok: false, status, code: expectedCode }); - return { status: problem.status, code: problem.code }; + return { status, code: expectedCode }; } -const nativeApi = (backendOrigin: string, dashboardOrigin: string) => { - const request = ( - method: "GET" | "POST" | "PATCH", - path: string, - options: { - readonly session?: string; - readonly idempotencyKey?: string; - readonly ifMatch?: string; - readonly contentType?: string; - readonly body?: Schema.Json; - } = {}, - ) => { - const headers = new Headers(); - - if (options.session !== undefined) { - headers.set("cookie", options.session); - headers.set("origin", dashboardOrigin); - } - - if (options.idempotencyKey !== undefined) { - headers.set("idempotency-key", options.idempotencyKey); - } +/** The value of a successful call. */ +function expectValue(answer: ScriptCallResult): A { + if (!answer.ok) throw new Error(`The call failed with ${answer.code}`); - if (options.ifMatch !== undefined) headers.set("if-match", options.ifMatch); + return answer.value; +} - if (options.body !== undefined) { - headers.set("content-type", options.contentType ?? "application/json"); - } +const nativeApi = (backendOrigin: string, dashboardOrigin: string) => { + const native = nativeScriptClient(backendOrigin); - const requestBody: Pick = - options.body === undefined ? {} : { body: JSON.stringify(options.body) }; + /** The headers of one call: the person's session, sent from the dashboard origin. */ + const as = (session?: string): Readonly> => + session === undefined ? {} : { cookie: session, origin: dashboardOrigin }; - return fetch(`${backendOrigin}${path}`, { - method, - headers, - ...requestBody, - redirect: "manual", - }); - }; + const key = (value: string) => IdempotencyKey.make(value); return { - request, + dispose: () => native.dispose(), signIn: async ({ email, password }: Persona): Promise => { const response = await fetch(`${backendOrigin}/api/auth/sign-in/email`, { method: "POST", @@ -238,22 +186,39 @@ const nativeApi = (backendOrigin: string, dashboardOrigin: string) => { return session; }, - readManagementPage: async (session: string) => { - const response = await request("GET", "/api/admission-periods", { session }); - expect(response.status).toBe(200); - - return Schema.decodeUnknownSync(AdmissionPeriodManagementListResponse, exact)( - await response.json(), - ); - }, - readOpenPage: async () => { - const response = await request("GET", "/api/open-admission-periods"); - expect(response.status).toBe(200); - - return Schema.decodeUnknownSync(OpenAdmissionPeriodListResponse, exact)( - await response.json(), - ); - }, + listManagementPage: (session?: string) => + native.call(as(session), (client) => client["admissions.listAdmissionPeriods"]()), + readManagementPage: async (session: string) => + expectValue( + await native.call(as(session), (client) => client["admissions.listAdmissionPeriods"]()), + ), + readOpenPage: async () => + expectValue( + await native.call({}, (client) => client["admissions.listOpenAdmissionPeriods"]()), + ), + create: (session: string, idempotencyKey: string, request: CreateAdmissionPeriodRequest) => + native.call(as(session), (client) => + client["admissions.createAdmissionPeriod"]({ idempotencyKey: key(idempotencyKey), request }), + ), + revise: ( + session: string, + admissionPeriodId: string, + idempotencyKey: string, + ifMatch: StrongETag, + request: AdmissionPeriodMergePatch, + ) => + native.call(as(session), (client) => + client["admissions.reviseAdmissionPeriod"]({ + admissionPeriodId: AdmissionPeriodId.make(admissionPeriodId), + idempotencyKey: key(idempotencyKey), + ifMatch, + request, + }), + ), + submit: (idempotencyKey: string, request: SubmitApplicationRequest) => + native.call({}, (client) => + client["admissions.submitApplication"]({ idempotencyKey: key(idempotencyKey), request }), + ), }; }; @@ -331,25 +296,23 @@ test.describe("Native admission-period management", () => { if (admissionPeriodId === null) throw new Error("created admission-period ID was absent"); - const periodPath = `/api/admission-periods/${encodeURIComponent(admissionPeriodId)}`; - - const accessibility = await new AxeBuilder({ page }) - .include('section[aria-labelledby="admission-period-page-title"]') - .analyze(); + const accessibility = await auditSettledPage(page, { + include: ['section[aria-labelledby="admission-period-page-title"]'], + }); - expect(accessibility.violations).toEqual([]); + expect(accessibility).toEqual([]); // The form's own window check rejected the first attempt before any command left the // dashboard; the corrected attempt reuses that attempt's key and sends it only as a header. const createCommands = (await readLedger(ledgerPath)).filter( - ({ method, path }) => method === "POST" && path === "/api/admission-periods", + ({ method, path }) => method === "RPC" && path === "admissions.createAdmissionPeriod", ); expect(createCommands).toHaveLength(1); const [createCommand] = createCommands; if (createCommand === undefined) throw new Error("browser create request was not observed"); - expect(createCommand.status).toBe(201); + expect(createCommand.status).toBe(200); expect(createCommand.idempotencyKey).toBe(rejectedCreateKey); expect( Schema.decodeUnknownSync(CreateAdmissionPeriodRequest, exact)(createCommand.body), @@ -357,7 +320,7 @@ test.describe("Native admission-period management", () => { const createIdempotencyKey = createCommand.idempotencyKey; - if (createIdempotencyKey === null) throw new Error("browser create carried no Idempotency-Key"); + if (createIdempotencyKey === null) throw new Error("browser create carried no idempotency key"); const sessions = { leader: await api.signIn(personas.leader), @@ -384,118 +347,94 @@ test.describe("Native admission-period management", () => { const openBeforeClose = await api.readOpenPage(); expect(openBeforeClose.items.map((period) => period.id)).toContain(admissionPeriodId); - const unauthenticatedError = await expectProblem( - await api.request("GET", "/api/admission-periods"), - SessionUnauthorizedProblem, + const unauthenticatedError = expectProblem( + await api.listManagementPage(), "credential.missing", ); - const inactiveError = await expectProblem( - await api.request("GET", "/api/admission-periods", { session: sessions.inactiveLeader }), - AdmissionsListAdmissionPeriodsProblem, + const inactiveError = expectProblem( + await api.listManagementPage(sessions.inactiveLeader), "authority.denied", ); - const roleDeniedError = await expectProblem( - await api.request("GET", "/api/admission-periods", { session: sessions.member }), - AdmissionsListAdmissionPeriodsProblem, + const roleDeniedError = expectProblem( + await api.listManagementPage(sessions.member), "authority.denied", ); - const originalCreate = createCommand.body; - - const replayResponse = await api.request("POST", "/api/admission-periods", { - session: sessions.leader, - idempotencyKey: createIdempotencyKey, - body: originalCreate, - }); - - expect(replayResponse.status).toBe(201); + const originalCreate = Schema.decodeUnknownSync(CreateAdmissionPeriodRequest, exact)( + createCommand.body, + ); - const replay = Schema.decodeUnknownSync(AdmissionPeriodManagementItem, exact)( - await replayResponse.json(), + const replay = expectValue( + await api.create(sessions.leader, createIdempotencyKey, originalCreate), ); expect(replay.id).toBe(admissionPeriodId); - const replayConflict = await expectProblem( - await api.request("POST", "/api/admission-periods", { - session: sessions.leader, - idempotencyKey: createIdempotencyKey, - body: { semesterId, startAt: OPEN_START, endAt: CONFLICTING_END }, + const replayConflict = expectProblem( + await api.create(sessions.leader, createIdempotencyKey, { + ...originalCreate, + endAt: CONFLICTING_END, }), - AdmissionsCreateAdmissionPeriodProblem, "idempotency.digest-conflict", ); - const duplicate = await expectProblem( - await api.request("POST", "/api/admission-periods", { - session: sessions.leader, - idempotencyKey: "admission-e2e-duplicate", - body: originalCreate, - }), - AdmissionsCreateAdmissionPeriodProblem, + const duplicate = expectProblem( + await api.create(sessions.leader, "admission-e2e-duplicate", originalCreate), "admission-period.already-exists", ); - const invalidWindow = await expectProblem( - await api.request("POST", "/api/admission-periods", { - session: sessions.leader, - idempotencyKey: "admission-e2e-invalid-window", - body: { semesterId, startAt: OPEN_END, endAt: OPEN_START }, + const invalidWindow = expectProblem( + await api.create(sessions.leader, "admission-e2e-invalid-window", { + ...originalCreate, + startAt: OPEN_END, + endAt: OPEN_START, }), - AdmissionsCreateAdmissionPeriodProblem, "admission-period.invalid-window", ); - const crossScope = await expectProblem( - await api.request("POST", "/api/admission-periods", { - session: sessions.leader, - idempotencyKey: "admission-e2e-cross-scope", - body: { + const crossScope = expectProblem( + await api.create( + sessions.leader, + "admission-e2e-cross-scope", + Schema.decodeSync(CreateAdmissionPeriodRequest, exact)({ semesterId, startAt: OPEN_START, endAt: OPEN_END, departmentId: reference.foreignDepartmentId, - }, - }), - AdmissionsCreateAdmissionPeriodProblem, + }), + ), "authority.denied", ); - const malformed = await expectProblem( - await api.request("POST", "/api/admission-periods", { - session: sessions.leader, - idempotencyKey: "admission-e2e-malformed", - body: { semesterId, startAt: OPEN_START, endAt: OPEN_END, browserAuthority: true }, - }), - AdmissionsCreateAdmissionPeriodProblem, - "validation.failed", + // The payload schema drops a member that it does not declare, so a browser-supplied authority + // grants nothing: the create is the leader's own, of a period that already exists. + const withBrowserAuthority = { ...originalCreate, browserAuthority: true }; + + const excessMember = expectProblem( + await api.create(sessions.leader, "admission-e2e-excess-member", withBrowserAuthority), + "admission-period.already-exists", ); const applicationIdempotencyKey = "admission-e2e-application-before-close"; - const applicationResponse = await api.request("POST", "/api/applications", { - idempotencyKey: applicationIdempotencyKey, - body: { - departmentId: reference.departmentId, - firstName: "Admission Proof", - lastName: "Applicant", - phone: "+47 900 00 038", - email: "admission-proof-before-close@example.invalid", - gender: 0, - fieldOfStudyId: reference.fieldOfStudyId, - yearOfStudy: 3, - availability: APPLICANT_AVAILABILITY, - }, - }); - - expect(applicationResponse.status).toBe(201); - - const applicationSubmission = Schema.decodeUnknownSync( - PublicApplicationConfirmationSchema, - exact, - )(await applicationResponse.json()); + const applicationSubmission = expectValue( + await api.submit( + applicationIdempotencyKey, + Schema.decodeSync(SubmitApplicationRequest, exact)({ + departmentId: reference.departmentId, + firstName: "Admission Proof", + lastName: "Applicant", + phone: "+47 900 00 038", + email: "admission-proof-before-close@example.invalid", + gender: 0, + fieldOfStudyId: reference.fieldOfStudyId, + yearOfStudy: 3, + availability: APPLICANT_AVAILABILITY, + }), + ), + ); const initialEtag = leaderPage.items[0]?.etag; @@ -506,44 +445,32 @@ test.describe("Native admission-period management", () => { payload: { startAt: OPEN_START, endAt }, })); - const concurrentResponses = await Promise.all( + const concurrentAnswers = await Promise.all( concurrentRequests.map(({ idempotencyKey, payload }) => - api.request("PATCH", periodPath, { - session: sessions.leader, - idempotencyKey, - ifMatch: initialEtag, - contentType: "application/merge-patch+json", - body: payload, - }), + api.revise(sessions.leader, admissionPeriodId, idempotencyKey, initialEtag, payload), ), ); - const winnerIndexes = concurrentResponses - .map((response, index) => (response.ok ? index : -1)) + const winnerIndexes = concurrentAnswers + .map((answer, index) => (answer.ok ? index : -1)) .filter((index) => index >= 0); expect(winnerIndexes).toHaveLength(1); const winnerIndex = winnerIndexes[0] ?? 0; const loserIndex = winnerIndex === 0 ? 1 : 0; - const winnerResponse = concurrentResponses[winnerIndex]; - const loserResponse = concurrentResponses[loserIndex]; + const winnerAnswer = concurrentAnswers[winnerIndex]; + const loserAnswer = concurrentAnswers[loserIndex]; const winnerRequest = concurrentRequests[winnerIndex]; - if (winnerResponse === undefined || loserResponse === undefined || winnerRequest === undefined) { + if (winnerAnswer === undefined || loserAnswer === undefined || winnerRequest === undefined) { throw new Error("concurrent revisions did not both complete"); } - const winner = Schema.decodeUnknownSync(AdmissionPeriodManagementItem, exact)( - await winnerResponse.json(), - ); + const winner = expectValue(winnerAnswer); expect(winner.revision).toBe(1); - const concurrentLoser = await expectProblem( - loserResponse, - AdmissionsReviseAdmissionPeriodProblem, - "precondition.failed", - ); + const concurrentLoser = expectProblem(loserAnswer, "precondition.failed"); await page.reload(); @@ -576,7 +503,7 @@ test.describe("Native admission-period management", () => { // The backend, not the form, rejected the window outside the semester; the corrected // revision reuses that command's key and revises the version that the browser displayed. const revisionCommands = (await readLedger(ledgerPath)).filter( - ({ method, path }) => method === "PATCH" && path === periodPath, + ({ method, path }) => method === "RPC" && path === "admissions.reviseAdmissionPeriod", ); const outsideSemesterProblem = "admission-period.invalid-window"; @@ -603,27 +530,26 @@ test.describe("Native admission-period management", () => { const closeIdempotencyKey = closeCommand.idempotencyKey; - if (closeIdempotencyKey === null) throw new Error("browser close carried no Idempotency-Key"); + if (closeIdempotencyKey === null) throw new Error("browser close carried no idempotency key"); - const stale = await expectProblem( - await api.request("PATCH", periodPath, { - session: sessions.leader, - idempotencyKey: "admission-e2e-stale-after-close", - ifMatch: initialEtag, - contentType: "application/merge-patch+json", - body: { startAt: OPEN_START, endAt: CLOSED_END }, - }), - AdmissionsReviseAdmissionPeriodProblem, + const stale = expectProblem( + await api.revise( + sessions.leader, + admissionPeriodId, + "admission-e2e-stale-after-close", + initialEtag, + { startAt: OPEN_START, endAt: CLOSED_END }, + ), "precondition.failed", ); const openAfterClose = await api.readOpenPage(); expect(openAfterClose.items).toEqual([]); - const rejectedApplication = await expectProblem( - await api.request("POST", "/api/applications", { - idempotencyKey: "admission-e2e-application-after-close", - body: { + const rejectedApplication = expectProblem( + await api.submit( + "admission-e2e-application-after-close", + Schema.decodeSync(SubmitApplicationRequest, exact)({ departmentId: reference.departmentId, firstName: "Closed Period", lastName: "Applicant", @@ -633,12 +559,13 @@ test.describe("Native admission-period management", () => { fieldOfStudyId: reference.fieldOfStudyId, yearOfStudy: 2, availability: APPLICANT_AVAILABILITY, - }, - }), - AdmissionsSubmitApplicationProblem, + }), + ), "application.no-eligible-period", ); + await api.dispose(); + const invalidBrowserContext = await browser.newContext({ baseURL: dashboardOrigin }); try { @@ -705,7 +632,7 @@ test.describe("Native admission-period management", () => { duplicate, invalidWindow, crossScope, - malformed, + excessMember, stale, rejectedApplication, }, diff --git a/apps/dashboard/e2e/dashboard-list-type-boundary.spec.ts b/apps/dashboard/e2e/dashboard-list-type-boundary.spec.ts index b6ad62b68..40a47e5ca 100644 --- a/apps/dashboard/e2e/dashboard-list-type-boundary.spec.ts +++ b/apps/dashboard/e2e/dashboard-list-type-boundary.spec.ts @@ -1,8 +1,13 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { expect, test } from "@playwright/test"; import { dashboardMount } from "../dashboard-base"; -import { Schema } from "effect"; import { journeyClock } from "../../../tools/e2e/journey-clock.js"; +import { + isNativeRpcRequest, + nativeRpcProblem, + nativeRpcSuccess, + readNativeRpcCall, +} from "../test/native-rpc.js"; const FIXTURE_PORT = 8791; @@ -16,59 +21,52 @@ const SESSION_COOKIE = `better-auth.session_token=${SESSION_TOKEN}`; // The fixture session's instants. No clock compares them, so they derive from one pinned instant. const sessionClock = journeyClock("2031-09-15T12:00:00.000Z"); -const PRIVATE_READ_HEADERS = { - "Cache-Control": "private, no-store", - Vary: "Origin", -} as const; - -const PROFILE_READ_HEADERS = { - ...PRIVATE_READ_HEADERS, - ETag: '"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"', -} as const; +const FIXTURE_ETAG = '"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"'; const apiRequests: Array<{ readonly method: string; readonly path: string; - readonly cookie: string | undefined; + readonly tag: string | undefined; + readonly cookie: string | null; }> = []; let fixtureServer: Server | undefined; -function respondJson( - response: ServerResponse, - status: number, - body: Schema.Json, - headers: Readonly> = {}, -): void { - response.writeHead(status, { "Content-Type": "application/json", ...headers }); - response.end(JSON.stringify(body)); +/** Writes a web response through the fixture's Node response. */ +async function writeResponse(response: ServerResponse, answer: Response): Promise { + response.writeHead(answer.status, Object.fromEntries(answer.headers)); + response.end(Buffer.from(await answer.arrayBuffer())); } -function handleFixtureRequest(request: IncomingMessage, response: ServerResponse): void { - const path = new URL(request.url ?? "/", FIXTURE_URL).pathname; - apiRequests.push({ - method: request.method ?? "GET", - path, - cookie: request.headers.cookie, - }); +/** The fixture backend: the session and the profile RPCs, for the one fixture session. */ +async function answerFixtureRequest(request: IncomingMessage): Promise { + const url = new URL(request.url ?? "/", FIXTURE_URL); + const chunks: Buffer[] = []; - if (request.headers.cookie !== SESSION_COOKIE) { - respondJson(response, 401, { - type: "urn:vektorprogrammet:problem:v0.2:credential.missing", - title: "Credential required", - status: 401, - code: "credential.missing", - detail: "A credential is required for this operation.", - }); + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + + const body = Buffer.concat(chunks); + + if (request.method !== "POST" || !isNativeRpcRequest(url)) { + apiRequests.push({ method: request.method ?? "GET", path: url.pathname, tag: undefined, cookie: null }); - return; + return new Response(null, { status: 404 }); } - if (path === "/api/profile" && request.method === "GET") { - respondJson( - response, - 200, - { + const call = await readNativeRpcCall(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + + const cookie = call.headers.get("cookie"); + apiRequests.push({ method: "POST", path: url.pathname, tag: call.tag, cookie }); + + if (cookie !== SESSION_COOKIE) return nativeRpcProblem(call, "credential.missing"); + + if (call.tag === "profile.readOwnProfile") { + return nativeRpcSuccess(call, { + profile: { personId: "2500", firstName: "Operator", lastName: "0025", @@ -78,39 +76,31 @@ function handleFixtureRequest(request: IncomingMessage, response: ServerResponse nameRevision: 0, contactRevision: 0, }, - PROFILE_READ_HEADERS, - ); - - return; + etag: FIXTURE_ETAG, + }); } - if (path === "/api/session" && request.method === "GET") { - respondJson( - response, - 200, - { - sessionId: "fixture-session-0025", - personId: "2500", - createdAt: sessionClock.now, - updatedAt: sessionClock.now, - expiresAt: sessionClock.fromNow(1), - ipAddress: null, - userAgent: null, - current: true, - }, - PRIVATE_READ_HEADERS, - ); - - return; + if (call.tag === "system.readSession") { + return nativeRpcSuccess(call, { + sessionId: "fixture-session-0025", + personId: "2500", + createdAt: sessionClock.now, + updatedAt: sessionClock.now, + expiresAt: sessionClock.fromNow(1), + ipAddress: null, + userAgent: null, + current: true, + }); } - respondJson(response, 404, { - type: "urn:vektorprogrammet:problem:v0.2:route.not-found", - title: "Route not found", - status: 404, - code: "route.not-found", - detail: "The requested route does not exist.", - }); + return nativeRpcProblem(call, "resource.not-found"); +} + +function handleFixtureRequest(request: IncomingMessage, response: ServerResponse): void { + void answerFixtureRequest(request).then( + (answer) => writeResponse(response, answer), + () => writeResponse(response, new Response(null, { status: 500 })), + ); } // /dashboard/assistenter is the native volunteer affiliation and school placement page, not an @@ -185,9 +175,11 @@ test.describe("dashboard unavailable native projections", () => { await expect(page.getByText(unavailable.body, { exact: true })).toBeVisible(); } - expect(apiRequests.some(({ path }) => path === "/api/profile")).toBe(true); + expect(apiRequests.some(({ tag }) => tag === "profile.readOwnProfile")).toBe(true); expect( - apiRequests.filter(({ path }) => !["/api/profile", "/api/session"].includes(path)), + apiRequests.filter( + ({ tag }) => tag !== "profile.readOwnProfile" && tag !== "system.readSession", + ), ).toEqual([]); expect(apiRequests.every(({ cookie }) => cookie === SESSION_COOKIE)).toBe(true); diff --git a/apps/dashboard/e2e/fixtures/receipt-api.ts b/apps/dashboard/e2e/fixtures/receipt-api.ts deleted file mode 100644 index d191d6e04..000000000 --- a/apps/dashboard/e2e/fixtures/receipt-api.ts +++ /dev/null @@ -1,684 +0,0 @@ -import { Schema, Predicate } from "effect"; - -type BunReceiptServer = { stop(force?: boolean): void }; - -declare const Bun: { - serve(options: { - port: number; - hostname: string; - fetch(request: Request): Response | Promise; - }): BunReceiptServer; -}; - -type ReceiptStatus = "Pending" | "Approved" | "Rejected" | "Withdrawn"; - -type Operation = - | "profile" - | "personal-list" - | "personal-create" - | "personal-update" - | "personal-delete" - | "admin-list" - | "admin-status"; - -type MalformedMode = "receipt-date" | "admin-shape" | "create-response"; - -type ReceiptWire = { - receiptId: string; - visualId: string; - ownerPersonId: string; - departmentId: string; - amountOre: number; - currency: "NOK"; - description: string; - receiptDate: string; - submittedAt: string; - status: ReceiptStatus; - approvedAt: string | null; - revision: number; - etag: string; -}; - -type Fault = { - status?: number; - malformed?: MalformedMode; -}; - -type ReceiptFixtureBody = - | { kind: "empty" } - | { kind: "json"; keys: string[] } - | { kind: "multipart"; fields: string[]; filePresent: boolean }; - -type Evidence = { - method: string; - path: string; - query: Record; - selectedHeaders: { - authorization: "bearer" | "missing"; - contentType: string | null; - idempotencyKey: string | null; - ifMatch: string | null; - }; - receiptFixtureBody: ReceiptFixtureBody; - status: number; -}; - -const operations: Record = { - profile: true, - "personal-list": true, - "personal-create": true, - "personal-update": true, - "personal-delete": true, - "admin-list": true, - "admin-status": true, -}; - -const malformedModes: Record = { - "receipt-date": true, - "admin-shape": true, - "create-response": true, -}; - -const etagFor = (receiptId: string, revision: number): string => { - const bytes = new TextEncoder().encode(`${receiptId}:${revision}`); - let encoded = ""; - - for (let index = 0; index < 32; index += 1) { - encoded += String.fromCharCode(bytes[index % bytes.length] ?? index); - } - - return `"vkr2.${btoa(encoded).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "")}"`; -}; - -const makeReceipt = ( - receiptId: string, - description: string, - amountOre: number, - receiptDate: string, - status: ReceiptStatus = "Pending", -): ReceiptWire => ({ - receiptId, - visualId: receiptId, - ownerPersonId: receiptId.startsWith("admin") ? `owner-${receiptId}` : "7", - departmentId: "department-trondheim", - amountOre, - currency: "NOK", - description, - receiptDate, - submittedAt: "2026-08-09T00:00:00.000Z", - status, - approvedAt: status === "Approved" ? "2026-08-10T00:00:00.000Z" : null, - revision: status === "Pending" ? 0 : 1, - etag: etagFor(receiptId, status === "Pending" ? 0 : 1), -}); - -let personalReceipts: ReceiptWire[] = []; - -let adminReceipts: ReceiptWire[] = []; - -let faults = new Map(); - -let evidence: Evidence[] = []; - -let transitions: string[] = []; - -const replayedResponses = new Map(); - -const isRecord = Schema.is(Schema.Record(Schema.String, Schema.Json)); - -function isOperation(value: string): value is Operation { - return value in operations; -} - -function isMalformedMode(value: string): value is MalformedMode { - return value in malformedModes; -} - -function resetState(): void { - personalReceipts = [makeReceipt("personal-1", "Course travel", 7_500, "2026-08-08")]; - adminReceipts = [ - makeReceipt("admin-10", "Approval receipt", 15_000, "2026-08-05"), - makeReceipt("admin-11", "Rejection receipt", 20_000, "2026-08-06"), - makeReceipt("admin-12", "Withdrawn receipt", 22_500, "2026-08-07", "Withdrawn"), - makeReceipt("admin-13", "Already approved", 30_000, "2026-08-04", "Approved"), - ]; - faults = new Map(); - evidence = []; - transitions = []; - replayedResponses.clear(); -} - -function resourceResponse(body: ReceiptWire, status = 200): Response { - const headers = new Headers({ - etag: body.etag, - "cache-control": "private, no-cache", - vary: "Cookie, Authorization", - }); - - if (status === 201) - headers.set("location", `/api/receipts/${encodeURIComponent(body.receiptId)}`); - - return Response.json(body, { status, headers }); -} - -function noContent(status = 204): Response { - return new Response(null, { status }); -} - -function problem(status: number, code: string, detail: string): Response { - return new Response( - JSON.stringify({ - type: `urn:vektorprogrammet:problem:v0.2:${code}`, - title: detail, - status, - code, - detail, - }), - { status, headers: { "content-type": "application/problem+json" } }, - ); -} - -function queryValues(url: URL) { - return Object.fromEntries(url.searchParams.entries()); -} - -function authKind(request: Request): "bearer" | "missing" { - return request.headers.get("authorization")?.startsWith("Bearer ") ? "bearer" : "missing"; -} - -function record( - request: Request, - url: URL, - receiptFixtureBody: ReceiptFixtureBody, - status: number, -): void { - evidence.push({ - method: request.method, - path: url.pathname, - query: queryValues(url), - selectedHeaders: { - authorization: authKind(request), - contentType: request.headers.get("content-type"), - idempotencyKey: request.headers.get("idempotency-key"), - ifMatch: request.headers.get("if-match"), - }, - receiptFixtureBody, - status, - }); -} - -function unauthorized(request: Request, url: URL): Response | null { - if (authKind(request) === "bearer") return null; - record(request, url, { kind: "empty" }, 401); - - return problem(401, "credential.missing", "A credential is required."); -} - -function malformedBody(operation: Operation, mode: MalformedMode) { - if (operation === "personal-list" && mode === "receipt-date") { - return { items: [{ ...makeReceipt("malformed", "Malformed receipt", 2_000, "not-a-date") }] }; - } - - if (operation === "admin-list" && mode === "admin-shape") { - return { items: [{ receiptId: "malformed-admin" }] }; - } - - return { receiptId: 99 }; -} - -function faultResponse(operation: Operation, request: Request, url: URL): Response | null { - const fault = faults.get(operation); - - if (fault === undefined) return null; - const status = fault.status ?? 200; - - const body = fault.malformed - ? malformedBody(operation, fault.malformed) - : { - type: "urn:vektorprogrammet:problem:v0.2:dependency.unavailable", - title: "Synthetic dependency failure", - status, - code: "dependency.unavailable", - detail: "Synthetic dependency failure", - }; - - record( - request, - url, - { kind: "json", keys: isRecord(body) ? Object.keys(body).sort() : [] }, - status, - ); - - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": status >= 400 ? "application/problem+json" : "application/json" }, - }); -} - -async function parseMultipart(request: Request): Promise<{ - form: FormData | null; - receiptFixtureBody: Extract; -}> { - try { - const form = await request.formData(); - const fields: string[] = []; - let filePresent = false; - - for (const [name, value] of form.entries()) { - fields.push(name); - - if (name === "file" && value instanceof File && value.size > 0) filePresent = true; - } - - return { form, receiptFixtureBody: { kind: "multipart", fields: fields.sort(), filePresent } }; - } catch { - return { - form: null, - receiptFixtureBody: { kind: "multipart", fields: [], filePresent: false }, - }; - } -} - -function requireMutationHeaders(request: Request, url: URL): Response | null { - if (request.headers.get("idempotency-key") === null) { - record(request, url, { kind: "empty" }, 400); - - return problem(400, "idempotency-key.invalid", "Idempotency-Key is required."); - } - - if (request.headers.get("if-match") === null) { - record(request, url, { kind: "empty" }, 428); - - return problem(428, "precondition.required", "If-Match is required."); - } - - return null; -} - -function assertIfMatch(request: Request, url: URL, receipt: ReceiptWire): Response | null { - if (request.headers.get("if-match") === receipt.etag) return null; - record(request, url, { kind: "empty" }, 412); - - return problem(412, "precondition.failed", "The Receipt representation changed."); -} - -function replay(request: Request): ReceiptWire | undefined { - const idempotencyKey = request.headers.get("idempotency-key"); - - return idempotencyKey === null ? undefined : replayedResponses.get(idempotencyKey); -} - -function remember(request: Request, receipt: ReceiptWire): void { - const idempotencyKey = request.headers.get("idempotency-key"); - - if (idempotencyKey !== null) replayedResponses.set(idempotencyKey, structuredClone(receipt)); -} - -async function handleProfile(request: Request, url: URL): Promise { - const authFailure = unauthorized(request, url); - - if (authFailure !== null) return authFailure; - const fault = faultResponse("profile", request, url); - - if (fault !== null) return fault; - record(request, url, { kind: "empty" }, 200); - - return Response.json( - { - personId: "7", - firstName: "Trace", - lastName: "User", - email: "trace@example.test", - phone: "", - role: "ROLE_TEAM_MEMBER", - nameRevision: 0, - contactRevision: 0, - }, - { status: 200, headers: {} }, - ); -} - -async function handlePersonalList(request: Request, url: URL): Promise { - const authFailure = unauthorized(request, url); - - if (authFailure !== null) return authFailure; - const fault = faultResponse("personal-list", request, url); - - if (fault !== null) return fault; - const status = url.searchParams.get("status"); - - const rows = - status === null ? personalReceipts : personalReceipts.filter((item) => item.status === status); - - record(request, url, { kind: "empty" }, 200); - - return Response.json({ items: rows }, { status: 200, headers: {} }); -} - -async function handlePersonalCreate(request: Request, url: URL): Promise { - const authFailure = unauthorized(request, url); - - if (authFailure !== null) return authFailure; - const fault = faultResponse("personal-create", request, url); - - if (fault !== null) return fault; - - if (request.headers.get("idempotency-key") === null) { - record(request, url, { kind: "empty" }, 400); - - return problem(400, "idempotency-key.invalid", "Idempotency-Key is required."); - } - - const replayed = replay(request); - - if (replayed !== undefined) return resourceResponse(replayed, 201); - const parsed = await parseMultipart(request); - const description = parsed.form?.get("description"); - const amountOre = parsed.form?.get("amountOre"); - const receiptDate = parsed.form?.get("receiptDate"); - const numericAmountOre = Predicate.isString(amountOre) ? Number(amountOre) : Number.NaN; - - if ( - !Predicate.isString(description) || - description.length === 0 || - !Number.isSafeInteger(numericAmountOre) || - numericAmountOre <= 0 || - !Predicate.isString(receiptDate) || - !parsed.receiptFixtureBody.filePresent - ) { - record(request, url, parsed.receiptFixtureBody, 422); - - return problem(422, "validation.failed", "The Receipt input is invalid."); - } - - const receipt = makeReceipt("personal-99", description, numericAmountOre, receiptDate); - personalReceipts = personalReceipts.filter((item) => item.receiptId !== receipt.receiptId); - personalReceipts.push(receipt); - remember(request, receipt); - transitions.push(`personal:create:${receipt.receiptId}`); - record(request, url, parsed.receiptFixtureBody, 201); - - return resourceResponse(receipt, 201); -} - -async function handlePersonalUpdate( - request: Request, - url: URL, - receiptId: string, -): Promise { - const authFailure = unauthorized(request, url); - - if (authFailure !== null) return authFailure; - const fault = faultResponse("personal-update", request, url); - - if (fault !== null) return fault; - const headerFailure = requireMutationHeaders(request, url); - - if (headerFailure !== null) return headerFailure; - const replayed = replay(request); - - if (replayed !== undefined) return resourceResponse(replayed); - const receipt = personalReceipts.find((item) => item.receiptId === receiptId); - - if (receipt === undefined) - return problem(404, "receipt.not-found", "The Receipt does not exist."); - const preconditionFailure = assertIfMatch(request, url, receipt); - - if (preconditionFailure !== null) return preconditionFailure; - const parsed = await parseMultipart(request); - - if (parsed.form === null) - return problem(422, "validation.failed", "The Receipt input is invalid."); - const description = parsed.form.get("description"); - const amountOre = parsed.form.get("amountOre"); - const receiptDate = parsed.form.get("receiptDate"); - - if (Predicate.isString(description)) receipt.description = description; - - if (Predicate.isString(amountOre) && Number.isSafeInteger(Number(amountOre))) - receipt.amountOre = Number(amountOre); - - if (Predicate.isString(receiptDate)) receipt.receiptDate = receiptDate; - receipt.revision += 1; - receipt.etag = etagFor(receipt.receiptId, receipt.revision); - remember(request, receipt); - transitions.push(`personal:update:${receiptId}`); - record(request, url, parsed.receiptFixtureBody, 200); - - return resourceResponse(receipt); -} - -async function handlePersonalWithdraw( - request: Request, - url: URL, - receiptId: string, -): Promise { - const authFailure = unauthorized(request, url); - - if (authFailure !== null) return authFailure; - const fault = faultResponse("personal-delete", request, url); - - if (fault !== null) return fault; - const headerFailure = requireMutationHeaders(request, url); - - if (headerFailure !== null) return headerFailure; - const replayed = replay(request); - - if (replayed !== undefined) return resourceResponse(replayed); - const receipt = personalReceipts.find((item) => item.receiptId === receiptId); - - if (receipt === undefined) - return problem(404, "receipt.not-found", "The Receipt does not exist."); - const preconditionFailure = assertIfMatch(request, url, receipt); - - if (preconditionFailure !== null) return preconditionFailure; - - if (receipt.status !== "Pending") - return problem(409, "receipt.invalid-transition", "The Receipt cannot be withdrawn."); - let body: unknown; - - try { - body = await request.json(); - } catch { - return problem(400, "request.malformed", "The request is malformed."); - } - - if (!isRecord(body) || Object.keys(body).length !== 0) - return problem(422, "validation.failed", "The request body must be empty."); - receipt.status = "Withdrawn"; - receipt.revision += 1; - receipt.etag = etagFor(receipt.receiptId, receipt.revision); - remember(request, receipt); - transitions.push(`personal:withdraw:${receiptId}`); - record(request, url, { kind: "json", keys: [] }, 200); - - return resourceResponse(receipt); -} - -async function handleAdminList(request: Request, url: URL): Promise { - const authFailure = unauthorized(request, url); - - if (authFailure !== null) return authFailure; - const fault = faultResponse("admin-list", request, url); - - if (fault !== null) return fault; - const status = url.searchParams.get("status"); - - const rows = - status === null ? adminReceipts : adminReceipts.filter((item) => item.status === status); - - record(request, url, { kind: "empty" }, 200); - - return Response.json({ items: rows }, { status: 200, headers: {} }); -} - -async function handleAdminStatus( - request: Request, - url: URL, - receiptId: string, - status: "Approved" | "Rejected", -): Promise { - const authFailure = unauthorized(request, url); - - if (authFailure !== null) return authFailure; - const fault = faultResponse("admin-status", request, url); - - if (fault !== null) return fault; - const headerFailure = requireMutationHeaders(request, url); - - if (headerFailure !== null) return headerFailure; - const replayed = replay(request); - - if (replayed !== undefined) return resourceResponse(replayed); - const receipt = adminReceipts.find((item) => item.receiptId === receiptId); - - if (receipt === undefined) - return problem(404, "receipt.not-found", "The Receipt does not exist."); - const preconditionFailure = assertIfMatch(request, url, receipt); - - if (preconditionFailure !== null) return preconditionFailure; - - if (receipt.status !== "Pending") - return problem(409, "receipt.invalid-transition", "The Receipt cannot change status."); - receipt.status = status; - receipt.approvedAt = status === "Approved" ? "2026-08-10T00:00:00.000Z" : null; - receipt.revision += 1; - receipt.etag = etagFor(receipt.receiptId, receipt.revision); - remember(request, receipt); - transitions.push(`admin:${receiptId}:${status}`); - record(request, url, { kind: "json", keys: [] }, 200); - - return resourceResponse(receipt); -} - -async function handleApi(request: Request, url: URL): Promise { - if (url.pathname === "/api/profile" && request.method === "GET") - return handleProfile(request, url); - - if (url.pathname === "/api/receipts" && request.method === "GET") - return handlePersonalList(request, url); - - if (url.pathname === "/api/receipts" && request.method === "POST") - return handlePersonalCreate(request, url); - - if (url.pathname === "/api/receipt-approval-queue" && request.method === "GET") - return handleAdminList(request, url); - - const reviseMatch = url.pathname.match(/^\/api\/receipts\/([^/:]+)$/u); - - if (reviseMatch !== null && request.method === "PATCH") { - return handlePersonalUpdate(request, url, decodeURIComponent(reviseMatch[1])); - } - - const actionMatch = url.pathname.match(/^\/api\/receipts\/([^/:]+)::(withdraw|approve|reject)$/u); - - if (actionMatch !== null && request.method === "POST") { - const receiptId = decodeURIComponent(actionMatch[1]); - - if (actionMatch[2] === "withdraw") return handlePersonalWithdraw(request, url, receiptId); - - return handleAdminStatus( - request, - url, - receiptId, - actionMatch[2] === "approve" ? "Approved" : "Rejected", - ); - } - - return problem(404, "route.not-found", "The requested route does not exist."); -} - -async function control(request: Request): Promise { - let body: unknown; - - try { - body = await request.json(); - } catch { - return noContent(400); - } - - if (!isRecord(body) || !Predicate.isString(body.operation) || !isOperation(body.operation)) - return noContent(400); - - if (body.action === "clear") { - faults.delete(body.operation); - - return noContent(); - } - - const fault: Fault = {}; - - if (body.status !== undefined) { - if ( - !Predicate.isNumber(body.status) || - !Number.isInteger(body.status) || - body.status < 400 || - body.status > 599 - ) - return noContent(400); - fault.status = body.status; - } - - if (body.malformed !== undefined) { - if (!Predicate.isString(body.malformed) || !isMalformedMode(body.malformed)) - return noContent(400); - fault.malformed = body.malformed; - } - - if (fault.status === undefined && fault.malformed === undefined) return noContent(400); - faults.set(body.operation, fault); - - return noContent(); -} - -resetState(); - -const args = process.argv.slice(2); - -const portIndex = args.indexOf("--port"); - -const port = Number(portIndex >= 0 ? args[portIndex + 1] : "8787"); - -if (!Number.isInteger(port) || port <= 0) throw new Error("receipt stub requires --port 8787"); - -let shuttingDown = false; - -const server = Bun.serve({ - port, - hostname: "127.0.0.1", - async fetch(request) { - const url = new URL(request.url); - - if (url.pathname === "/__health") - return Response.json({ ok: true }, { status: 200, headers: {} }); - - if (url.pathname === "/__control/reset" && request.method === "POST") { - resetState(); - - return noContent(); - } - - if (url.pathname === "/__control/fault" && request.method === "POST") return control(request); - - if (url.pathname === "/__control/evidence" && request.method === "GET") { - return Response.json( - { evidence, transitions, personalReceipts, adminReceipts }, - { status: 200, headers: {} }, - ); - } - - return handleApi(request, url); - }, -}); - -const shutdown = (): void => { - if (shuttingDown) return; - shuttingDown = true; - server.stop(true); -}; - -process.once("SIGINT", shutdown); - -process.once("SIGTERM", shutdown); - -console.log(`receipt stub ready on http://127.0.0.1:${port}`); diff --git a/apps/dashboard/e2e/golden-reimbursement-browser.d.mts b/apps/dashboard/e2e/golden-reimbursement-browser.d.mts new file mode 100644 index 000000000..bf956b1c4 --- /dev/null +++ b/apps/dashboard/e2e/golden-reimbursement-browser.d.mts @@ -0,0 +1,39 @@ +import type { + Binding, + Persons, + ReimbursementFacts, +} from "../../../tools/e2e/golden-reimbursement-evidence"; + +/** One observation of the browser journey, such as a denial, a session, or a bound. */ +export interface ReimbursementBrowserCheck { + readonly kind: string; +} + +export interface ReimbursementBrowserEvidence { + readonly passed: true; + readonly receiptId: string; + readonly settlementId: string; + readonly checks: ReadonlyArray; +} + +/** The runner hooks that the browser journey calls back into. */ +export interface ReimbursementBrowserHooks { + readonly origins: { readonly backend: string; readonly dashboard: string }; + readonly persons: Persons; + readonly artifacts: string; + readonly checkpoint: (step: string, binding?: Binding) => Promise; + readonly eventually: ( + label: string, + inspect: () => Promise, + timeout?: number, + ) => Promise; + readonly readFacts: () => Promise; + readonly restart: (mode?: string) => Promise; + readonly providerMode: (mode: string) => void; + readonly fault: string | undefined; + readonly browserReady: (browser: { close: () => Promise }) => void; +} + +export declare const runReimbursementBrowser: ( + hooks: ReimbursementBrowserHooks, +) => Promise; diff --git a/apps/dashboard/e2e/golden-reimbursement-browser.mjs b/apps/dashboard/e2e/golden-reimbursement-browser.mjs index b4ca06193..4c8ba0595 100644 --- a/apps/dashboard/e2e/golden-reimbursement-browser.mjs +++ b/apps/dashboard/e2e/golden-reimbursement-browser.mjs @@ -3,10 +3,20 @@ import { randomUUID } from "node:crypto"; import { join } from "node:path"; import { writeFile } from "node:fs/promises"; import { chromium, expect } from "@playwright/test"; -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.ts"; import { Predicate } from "effect"; -import { RECEIPT_FILE_MAX_BYTES, receiptTransferMaxBytes } from "@vektorprogrammet/http-api"; -import { fixture, receiptBytes } from "../../../tools/e2e/golden-reimbursement-evidence.mjs"; +import { nativeRpcPath } from "@vektorprogrammet/rpc"; +import { + RECEIPT_FILE_MAX_BYTES, + receiptTransferMaxBytes, +} from "../app/lib/receipt-upload.server.ts"; +import { + nativeRpcOutcome, + nativeRpcRequestBody, + nativeRpcStatus, + nativeRpcValue, +} from "./native-operations.ts"; +import { fixture, receiptBytes } from "../../../tools/e2e/golden-reimbursement-evidence.ts"; import { sha256 } from "../../../tools/e2e/golden-school-service-evidence.mjs"; export const runReimbursementBrowser = async ({ @@ -42,11 +52,38 @@ export const runReimbursementBrowser = async ({ signal: AbortSignal.timeout(15_000), }); - const json = async (response, status) => { - const body = await response.json(); - assert.equal(response.status, status, `HTTP status ${JSON.stringify(body)}`); + /** + * One RPC as the cookie given, answered with its status under the HTTP contract and its value + * or problem body. + */ + const rpc = async (cookie, tag, payload = {}) => { + const text = await ( + await request(cookie, nativeRpcPath, { + method: "POST", + headers: { "content-type": "application/json" }, + body: nativeRpcRequestBody(tag, payload), + }) + ).text(); + + const outcome = nativeRpcOutcome(text); + + return { + status: nativeRpcStatus(text), + body: Predicate.isTagged(outcome, "Success") + ? outcome.value + : Predicate.isTagged(outcome, "Problem") + ? outcome.problem + : null, + }; + }; + + /** The bytes of a receipt file answer, which carries them as base64 text. */ + const fileBytes = (answer) => Buffer.from(answer.body.bytes, "base64"); + + const json = async (answer, status) => { + assert.equal(answer.status, status, `RPC status ${JSON.stringify(answer.body)}`); - return body; + return answer.body; }; const problem = async (response, status, code) => { @@ -73,7 +110,17 @@ export const runReimbursementBrowser = async ({ assert.equal(cookies.length, 1); const cookie = cookies.map(({ name, value }) => `${name}=${value}`).join("; "); - const session = await json(await request(cookie, "/api/session"), 200); + + const sessionAnswer = await ( + await request(cookie, nativeRpcPath, { + method: "POST", + headers: { "content-type": "application/json" }, + body: nativeRpcRequestBody("system.readSession"), + }) + ).text(); + + assert.equal(nativeRpcStatus(sessionAnswer), 200, `session read ${sessionAnswer}`); + const session = nativeRpcValue(sessionAnswer); assert.equal(session.personId, person.personId); checks.push({ kind: "session", role, personId: session.personId }); @@ -91,16 +138,21 @@ export const runReimbursementBrowser = async ({ await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), `${surface} page overflow`, ); - const audit = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze(); + const audit = await auditSettledPage(page, { tags: ["wcag2a", "wcag2aa"] }); - const serious = audit.violations.filter( + const serious = audit.filter( ({ impact }) => impact === "serious" || impact === "critical", ); assert.deepEqual( serious.map(({ id }) => id), [], - `${surface} accessibility`, + `${surface} accessibility: ${JSON.stringify( + serious.map(({ id, targets }) => ({ + id, + targets: targets.map(({ target, failureSummary }) => ({ target, failureSummary })), + })), + )}`, ); checks.push({ kind: "surface", surface, layout, width, seriousViolations: 0 }); } @@ -108,15 +160,30 @@ export const runReimbursementBrowser = async ({ await page.setViewportSize({ width: 1280, height: 900 }); }; - const list = async (actor, path) => json(await request(actor.cookie, path), 200); + const list = async (actor, tag, query = {}) => json(await rpc(actor.cookie, tag, query), 200); - const command = (actor, receiptId, action, key, etag, body = {}) => - request(actor.cookie, `/api/receipts/${encodeURIComponent(receiptId)}:${action}`, { - method: "POST", - headers: { "content-type": "application/json", "idempotency-key": key, "if-match": etag }, - body: JSON.stringify(body), + const listOwned = (actor) => list(actor, "receipts.listReceipts"); + + const command = (actor, receiptId, action, key, etag, body) => { + const payload = { receiptId, idempotencyKey: key, ifMatch: etag }; + + // Only a settlement carries a request; the other transitions had an empty body. + if (body !== undefined) payload.request = body; + + return rpc(actor.cookie, `receipts.${action}Receipt`, payload); + }; + + const submit = (actor, key, fields, bytes) => + rpc(actor.cookie, "receipts.submitReceipt", { + idempotencyKey: key, + request: { + ...fields, + file: { contentType: "image/png", bytes: Buffer.from(bytes).toString("base64") }, + }, }); + const ownerFile = (actor, receiptId) => rpc(actor.cookie, "receipts.readReceiptFile", { receiptId }); + const allDelivered = () => eventually( "receipt effects delivered", @@ -138,7 +205,7 @@ export const runReimbursementBrowser = async ({ await expect(owner.page.locator("[data-command-id]")).toBeVisible(); const submitKey = await owner.page.locator("[data-command-id]").getAttribute("data-command-id"); - const pending = (await list(owner, "/api/receipts")).items.find( + const pending = (await listOwned(owner)).items.find( ({ description }) => description === fixture.description, ); @@ -152,22 +219,21 @@ export const runReimbursementBrowser = async ({ if (fault === "interrupt-after-submitted") process.kill(process.pid, "SIGTERM"); - const repeatSubmission = async () => { - const payload = new FormData(); - payload.set("description", fixture.description); - payload.set("amountOre", String(fixture.amountOre)); - payload.set("receiptDate", fixture.receiptDate); - payload.set("file", new File([receiptBytes], "receipt.png", { type: "image/png" })); - - return json( - await request(owner.cookie, "/api/receipts", { - method: "POST", - headers: { "idempotency-key": submitKey }, - body: payload, - }), - 201, + // A replay answers the first submission's resource, now with 200 instead of 201. + const repeatSubmission = async () => + json( + await submit( + owner, + submitKey, + { + description: fixture.description, + amountOre: fixture.amountOre, + receiptDate: fixture.receiptDate, + }, + receiptBytes, + ), + 200, ); - }; const repeats = await Promise.all([repeatSubmission(), repeatSubmission()]); @@ -181,18 +247,14 @@ export const runReimbursementBrowser = async ({ for (const actor of [other, wrong]) { assert.equal( - (await list(actor, "/api/receipts")).items.some((row) => row.receiptId === receiptId), + (await listOwned(actor)).items.some((row) => row.receiptId === receiptId), false, ); - await problem( - await request(actor.cookie, `/api/receipts/${receiptId}/file`), - 404, - "resource.not-found", - ); + await problem(await ownerFile(actor, receiptId), 404, "resource.not-found"); } await problem( - await request(wrong.cookie, `/api/receipt-approval-queue/${receiptId}/file`), + await rpc(wrong.cookie, "receipts.readReceiptFileForApproval", { receiptId }), 403, "authority.denied", ); @@ -202,19 +264,14 @@ export const runReimbursementBrowser = async ({ "authority.denied", ); await checkpoint("private-denials"); - const beforeRestart = await request(owner.cookie, `/api/receipts/${receiptId}/file`); + const beforeRestart = await ownerFile(owner, receiptId); assert.equal(beforeRestart.status, 200); - assert.equal(sha256(Buffer.from(await beforeRestart.arrayBuffer())), sha256(receiptBytes)); + assert.equal(sha256(fileBytes(beforeRestart)), sha256(receiptBytes)); await restart(); - const afterRestart = await request(owner.cookie, `/api/receipts/${receiptId}/file`); + const afterRestart = await ownerFile(owner, receiptId); assert.equal(afterRestart.status, 200); - assert.equal(afterRestart.headers.get("cache-control"), "private, no-store"); - assert.equal(sha256(Buffer.from(await afterRestart.arrayBuffer())), sha256(receiptBytes)); - await problem( - await request(other.cookie, `/api/receipts/${receiptId}/file`), - 404, - "resource.not-found", - ); + assert.equal(sha256(fileBytes(afterRestart)), sha256(receiptBytes)); + await problem(await ownerFile(other, receiptId), 404, "resource.not-found"); await checkpoint("restart-custody"); const approver = await login("approver"); @@ -252,7 +309,7 @@ export const runReimbursementBrowser = async ({ ); await checkpoint("approval-failed-delivery"); - const approved = (await list(owner, "/api/receipts")).items.find( + const approved = (await listOwned(owner)).items.find( (row) => row.receiptId === receiptId, ); @@ -316,7 +373,7 @@ export const runReimbursementBrowser = async ({ async () => (await readFacts()).settlements.length === 1, ); - const settled = (await list(owner, "/api/receipts")).items.find( + const settled = (await listOwned(owner)).items.find( (row) => row.receiptId === receiptId, ); @@ -381,35 +438,27 @@ export const runReimbursementBrowser = async ({ await restart("disabled"); const opaqueBytes = Buffer.from([0, 255, 17, 42]); - const submitBounded = async (index) => { - const payload = new FormData(); - payload.set("description", `Bounded traversal ${index}`); - payload.set("amountOre", "100"); - payload.set("receiptDate", fixture.receiptDate); - payload.set( - "file", - new File([index === 0 ? opaqueBytes : receiptBytes], "receipt.png", { type: "image/png" }), - ); - - return json( - await request(fresh.cookie, "/api/receipts", { - method: "POST", - headers: { "idempotency-key": randomUUID() }, - body: payload, - }), - 201, + const submitBounded = async (index) => + json( + await submit( + fresh, + randomUUID(), + { + description: `Bounded traversal ${index}`, + amountOre: 100, + receiptDate: fixture.receiptDate, + }, + index === 0 ? opaqueBytes : receiptBytes, + ), + 200, ); - }; const concurrent = await Promise.all([submitBounded(0), submitBounded(1)]); for (const [index, result] of concurrent.entries()) { - const file = await request(fresh.cookie, `/api/receipts/${result.receiptId}/file`); + const file = await ownerFile(fresh, result.receiptId); assert.equal(file.status, 200); - assert.equal( - sha256(Buffer.from(await file.arrayBuffer())), - sha256(index === 0 ? opaqueBytes : receiptBytes), - ); + assert.equal(sha256(fileBytes(file)), sha256(index === 0 ? opaqueBytes : receiptBytes)); } checks.push({ @@ -422,9 +471,16 @@ export const runReimbursementBrowser = async ({ for (let index = 2; index < 51; index++) await submitBounded(index); const pendingBounds = []; - for (const { actor, path, expected } of [ - { actor: fresh, path: "/api/receipts", expected: 52 }, - { actor: approver, path: "/api/receipt-approval-queue?status=Pending", expected: 51 }, + // Each collection keeps the route that its RPC replaced as its evidence path. + for (const { actor, path, tag, query, expected } of [ + { actor: fresh, path: "/api/receipts", tag: "receipts.listReceipts", query: {}, expected: 52 }, + { + actor: approver, + path: "/api/receipt-approval-queue?status=Pending", + tag: "receipts.listReceiptsForApproval", + query: { status: "Pending" }, + expected: 51, + }, ]) { const ids = []; const sizes = []; @@ -433,13 +489,7 @@ export const runReimbursementBrowser = async ({ do { assert.ok(sizes.length < 3, "collection traversal did not terminate"); - const page = await list( - actor, - path + - (cursor === undefined - ? "" - : `${path.includes("?") ? "&" : "?"}cursor=${encodeURIComponent(cursor)}`), - ); + const page = await list(actor, tag, cursor === undefined ? query : { ...query, cursor }); assert.ok(page.items.length <= 50, "collection page exceeds bound"); sizes.push(page.items.length); diff --git a/apps/dashboard/e2e/golden-team-application-browser.ts b/apps/dashboard/e2e/golden-team-application-browser.ts index e7cc10492..db2e2e340 100644 --- a/apps/dashboard/e2e/golden-team-application-browser.ts +++ b/apps/dashboard/e2e/golden-team-application-browser.ts @@ -12,10 +12,17 @@ import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.js"; import { chromium, expect as baseExpect, type BrowserContext, type Page } from "@playwright/test"; -import { OrganizationLifecycleCommand } from "@vektorprogrammet/http-api"; -import { Schema } from "effect"; +import { nativeRpcPath, OrganizationLifecycleCommand } from "@vektorprogrammet/rpc"; +import { Match, Schema } from "effect"; +import { + nativeRpcOutcome, + nativeRpcRequestBody, + nativeRpcStatus, + nativeRpcTag, + nativeRpcValue, +} from "./native-operations.js"; // Production bundles load their workflow before rendering server facts; five seconds is too tight. const expect = baseExpect.configure({ timeout: 15_000 }); @@ -195,6 +202,14 @@ const ApplicationDetail = Schema.Struct({ const LifecycleResult = Schema.Struct({ subjectId: Schema.String, revision: Schema.Int }); +/** The RPC request message of the dashboard's deletion, with the key that it minted. */ +const DeletePayload = Schema.fromJsonString( + Schema.Struct({ + tag: Schema.Literal("team-applications.deleteTeamApplication"), + payload: Schema.Struct({ applicationId: Schema.String, idempotencyKey: Schema.String }), + }), +); + const Replays = Schema.Array(Schema.Struct({ status: Schema.Int, text: Schema.String })); const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; @@ -305,6 +320,38 @@ export const runTeamApplicationBrowser = async ( }); }; + /** + * Posts one RPC as a browser or a server would, and answers it as the HTTP route that it replaced + * answered: a success is 200 with the value, a declared problem carries the status that the + * problem registry gives its code, and a defect is 500 marked `x-rpc-defect`. + */ + const rpc = async (cookie: string | null, tag: string, payload: Schema.Json = null) => { + const response = await api(cookie, nativeRpcPath, { + method: "POST", + headers: { "content-type": "application/json" }, + body: nativeRpcRequestBody(tag, payload), + }); + + const text = await response.text(); + const outcome = nativeRpcOutcome(text); + + if (outcome === undefined) return new Response(text, { status: response.status }); + + return Match.value(outcome).pipe( + Match.tag("Success", ({ value }) => Response.json(value, { status: 200 })), + Match.tag("Problem", ({ problem, status }) => Response.json(problem, { status })), + Match.tag("Defect", () => new Response(text, { status: 500, headers: { "x-rpc-defect": tag } })), + Match.exhaustive, + ); + }; + + /** The RPC server refused a payload that its schema does not decode, before any handler ran. */ + const undecodable = (response: Response, label: string) => { + assert.equal(response.status, 500, `${label} answered ${response.status}`); + assert.ok(response.headers.has("x-rpc-defect"), `${label} is refused as an undecodable payload`); + checks.push({ kind: "undecodable", detail: label }); + }; + const body = async (response: Response, status: number, decoder: Schema.Decoder) => { const text = await response.text(); @@ -336,11 +383,11 @@ export const runTeamApplicationBrowser = async ( assert.equal(overflowing, "", `${surface} overflows at ${width}px`); - const audit = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze(); + const audit = await auditSettledPage(page, { tags: ["wcag2a", "wcag2aa"] }); - const serious = audit.violations.flatMap(({ id, impact, nodes }) => + const serious = audit.flatMap(({ id, impact, targets }) => impact === "serious" || impact === "critical" - ? [`${id}: ${nodes.map(({ target, html }) => `${target.join(" ")} ${html.slice(0, 160)}`).join(" | ")}`] + ? [`${id}: ${targets.map(({ target }) => target).join(" | ")}`] : [], ); @@ -370,7 +417,17 @@ export const runTeamApplicationBrowser = async ( assert.equal(cookies.length, 1, `${role} session cookie`); const cookie = cookies.map(({ name, value }) => `${name}=${value}`).join("; "); - const session = await body(await api(cookie, "/api/session"), 200, Session); + + const sessionAnswer = await ( + await api(cookie, nativeRpcPath, { + method: "POST", + headers: { "content-type": "application/json" }, + body: nativeRpcRequestBody("system.readSession"), + }) + ).text(); + + assert.equal(nativeRpcStatus(sessionAnswer), 200, `${role} session read: ${sessionAnswer}`); + const session = Schema.decodeUnknownSync(Session)(nativeRpcValue(sessionAnswer)); assert.equal(session.personId, person.personId); checks.push({ kind: "session", detail: role }); @@ -378,12 +435,17 @@ export const runTeamApplicationBrowser = async ( return { role, person, context, page, cookie }; }; + const listApplicationsRpc = (cookie: string | null, teamId: string) => + rpc(cookie, "team-applications.listTeamApplications", { teamId }); + + const readApplicationRpc = (cookie: string | null, applicationId: string) => + rpc(cookie, "team-applications.readTeamApplication", { applicationId }); + + const deleteApplicationRpc = (cookie: string, applicationId: string, idempotencyKey: string) => + rpc(cookie, "team-applications.deleteTeamApplication", { applicationId, idempotencyKey }); + const listApplications = async (actor: Actor, team: FixtureTeam) => - body( - await api(actor.cookie, `/api/teams/${encodeURIComponent(team.teamId)}/applications`), - 200, - ApplicationList, - ); + body(await listApplicationsRpc(actor.cookie, team.teamId), 200, ApplicationList); const awaitRevision = (actor: Actor, team: FixtureTeam, revision: number) => poll(`${team.name} intake revision ${revision}`, async () => { @@ -413,20 +475,13 @@ export const runTeamApplicationBrowser = async ( await expect(actor.page.locator('[data-team-application-notice="intake-saved"]')).toBeVisible(); }; - const reviseIntake = (actor: Actor, team: FixtureTeam, etag: string | null, patch: IntakePatch) => { - const headers = new Headers({ - "content-type": "application/merge-patch+json", - "idempotency-key": randomUUID(), - }); - - if (etag !== null) headers.set("if-match", etag); - - return api(actor.cookie, `/api/teams/${encodeURIComponent(team.teamId)}/application-intake`, { - method: "PATCH", - headers, - body: JSON.stringify(patch), + const reviseIntake = (actor: Actor, team: FixtureTeam, etag: string, patch: IntakePatch) => + rpc(actor.cookie, "team-applications.reviseTeamApplicationIntake", { + teamId: team.teamId, + idempotencyKey: randomUUID(), + ifMatch: etag, + request: patch, }); - }; const expectApplyLinks = async ( page: Page, @@ -530,10 +585,10 @@ export const runTeamApplicationBrowser = async ( }; const submitThroughApi = (team: string, key: string, payload: Partial) => - api(null, `/api/teams/${encodeURIComponent(team)}/applications`, { - method: "POST", - headers: { "content-type": "application/json", "idempotency-key": key }, - body: JSON.stringify(payload), + rpc(null, "team-applications.submitTeamApplication", { + teamId: team, + idempotencyKey: key, + request: payload, }); try { @@ -617,7 +672,7 @@ export const runTeamApplicationBrowser = async ( const direct = await body( await submitThroughApi(fixture.alfa.teamId, first.submission.key, fixture.first), - 201, + 200, Confirmation, ); @@ -652,21 +707,19 @@ export const runTeamApplicationBrowser = async ( "resource.not-found", ]); - for (const invalid of [ - { ...fixture.first, email: "not-an-email" }, - { ...fixture.first, fieldOfStudy: "x".repeat(46) }, - { ...fixture.first, name: "" }, - { ...fixture.first, motivation: " padded" }, - ]) - await problem(await submitThroughApi(fixture.alfa.teamId, randomUUID(), invalid), 422, [ - "validation.failed", - ]); + // Structural validation is the RPC server's: an application that the contract schema does not + // decode is refused before the handler, so nothing is counted or stored. + for (const [label, invalid] of [ + ["invalid e-mail", { ...fixture.first, email: "not-an-email" }], + ["long field of study", { ...fixture.first, fieldOfStudy: "x".repeat(46) }], + ["empty name", { ...fixture.first, name: "" }], + ["padded motivation", { ...fixture.first, motivation: " padded" }], + ] as const) + undecodable(await submitThroughApi(fixture.alfa.teamId, randomUUID(), invalid), label); const { motivation: _omitted, ...missing } = fixture.first; - await problem(await submitThroughApi(fixture.alfa.teamId, randomUUID(), missing), 422, [ - "validation.failed", - ]); + undecodable(await submitThroughApi(fixture.alfa.teamId, randomUUID(), missing), "missing motivation"); await visitor.goto(`${origins.homepage}/team/${encodeURIComponent(fixture.gamma.teamId)}/soknad`); await expect(visitor.getByRole("heading", { name: "Teamet tar ikke imot søknader nå" })).toBeVisible(); await expect(visitor.locator('[data-team-application-intake="closed"]')).toBeVisible(); @@ -728,7 +781,7 @@ export const runTeamApplicationBrowser = async ( ); const memberDetail = await body( - await api(memberAlfa.cookie, `/api/team-applications/${first.submission.applicationId}`), + await readApplicationRpc(memberAlfa.cookie, first.submission.applicationId), 200, ApplicationDetail, ); @@ -758,30 +811,23 @@ export const runTeamApplicationBrowser = async ( for (const role of ["memberBeta", "leaderBeta", "formerAlfa", "suspendedAlfa"] as const) { const actor = role === "leaderBeta" ? leaderBeta : await login(role); - await problem( - await api(actor.cookie, `/api/teams/${fixture.alfa.teamId}/applications`), - 403, - ["authority.denied"], - ); - await problem( - await api(actor.cookie, `/api/team-applications/${first.submission.applicationId}`), - 403, - ["authority.denied"], - ); + await problem(await listApplicationsRpc(actor.cookie, fixture.alfa.teamId), 403, [ + "authority.denied", + ]); + await problem(await readApplicationRpc(actor.cookie, first.submission.applicationId), 403, [ + "authority.denied", + ]); await actor.page.goto(`${origins.dashboard}/dashboard/teamsoknader/${fixture.alfa.teamId}`); await expect(actor.page.locator('[data-team-application-state="denied"]')).toBeVisible(); await expect(actor.page.locator("[data-application-id]")).toHaveCount(0); } - for (const path of [ - `/api/teams/${fixture.alfa.teamId}/applications`, - `/api/team-applications/${first.submission.applicationId}`, - ]) { - const anonymous = await api(null, path); - - assert.ok(anonymous.headers.has("www-authenticate"), "anonymous denial challenges"); + // An RPC answer carries no WWW-Authenticate challenge; the problem code still tells. + for (const anonymous of [ + await listApplicationsRpc(null, fixture.alfa.teamId), + await readApplicationRpc(null, first.submission.applicationId), + ]) await problem(anonymous, 401, ["credential.missing", "credential.invalid"]); - } const anonymousPage = await (await newContext()).newPage(); @@ -792,7 +838,7 @@ export const runTeamApplicationBrowser = async ( .or(anonymousPage.locator('[data-team-application-state="session-expired"]')), ).toBeVisible(); await expect(anonymousPage.locator("[data-application-id]")).toHaveCount(0); - await problem(await api(memberAlfa.cookie, `/api/team-applications/${randomUUID()}`), 404, [ + await problem(await readApplicationRpc(memberAlfa.cookie, randomUUID()), 404, [ "resource.not-found", ]); checks.push({ kind: "staff-denial", detail: "other team, other leader, former, suspended, anonymous" }); @@ -801,18 +847,15 @@ export const runTeamApplicationBrowser = async ( const suspensionKey = randomUUID(); const suspension = await body( - await api(leaderAlfa.cookie, "/api/organization/appointments/commands", { - method: "POST", - headers: { "content-type": "application/json", "idempotency-key": suspensionKey }, - body: JSON.stringify( - Schema.encodeSync(OrganizationLifecycleCommand)( - OrganizationLifecycleCommand.cases.SuspendAppointment.make({ - commandId: suspensionKey, - reason: "Golden journey: membership suspended during an open session", - appointmentId: fixture.persons.sessionAlfa.membershipId, - expectedRevision: 0, - }), - ), + await rpc(leaderAlfa.cookie, "organization.executeLifecycle", { + idempotencyKey: suspensionKey, + request: Schema.encodeSync(Schema.toCodecJson(OrganizationLifecycleCommand))( + OrganizationLifecycleCommand.cases.SuspendAppointment.make({ + commandId: suspensionKey, + reason: "Golden journey: membership suspended during an open session", + appointmentId: fixture.persons.sessionAlfa.membershipId, + expectedRevision: 0, + }), ), }), 200, @@ -824,16 +867,12 @@ export const runTeamApplicationBrowser = async ( suspendedMembershipId = suspension.subjectId; // The same session cookie that read the list a moment ago is now refused. - await problem( - await api(sessionAlfa.cookie, `/api/teams/${fixture.alfa.teamId}/applications`), - 403, - ["authority.denied"], - ); - await problem( - await api(sessionAlfa.cookie, `/api/team-applications/${first.submission.applicationId}`), - 403, - ["authority.denied"], - ); + await problem(await listApplicationsRpc(sessionAlfa.cookie, fixture.alfa.teamId), 403, [ + "authority.denied", + ]); + await problem(await readApplicationRpc(sessionAlfa.cookie, first.submission.applicationId), 403, [ + "authority.denied", + ]); await sessionAlfa.page.reload(); await expect(sessionAlfa.page.locator('[data-team-application-state="denied"]')).toBeVisible(); checks.push({ kind: "session-revocation", detail: "existing session lost access after suspension" }); @@ -841,10 +880,7 @@ export const runTeamApplicationBrowser = async ( for (const actor of [memberAlfa, leaderBeta]) await problem( - await api(actor.cookie, `/api/team-applications/${first.submission.applicationId}`, { - method: "DELETE", - headers: { "idempotency-key": randomUUID() }, - }), + await deleteApplicationRpc(actor.cookie, first.submission.applicationId, randomUUID()), 403, ["authority.denied"], ); @@ -876,11 +912,11 @@ export const runTeamApplicationBrowser = async ( deadline: secondDeadlineAt.toISOString(), }); - const concurrentEtag = concurrent.headers.get("etag"); + // The RPC answer carries the new entity tag in the resource; there is no ETag header. const revised = await body(concurrent, 200, Intake); assert.equal(revised.revision, 4); - assert.equal(concurrentEtag, revised.etag); + assert.notEqual(revised.etag, observed.intake.etag); secondDeadline = revised.deadline; // The page still holds revision 3; its save must fail as stale and reload. @@ -898,9 +934,7 @@ export const runTeamApplicationBrowser = async ( 412, ["precondition.failed"], ); - await problem(await reviseIntake(leaderAlfa, fixture.alfa, null, { acceptApplication: false }), 428, [ - "precondition.required", - ]); + // precondition.required is gone: the RPC payload cannot omit ifMatch. assert.equal((await listApplications(leaderAlfa, fixture.alfa)).intake.revision, 4); checks.push({ kind: "stale-intake", detail: "stale UI save and stale If-Match refused without change" }); await checkpoint("intake-stale"); @@ -986,33 +1020,36 @@ export const runTeamApplicationBrowser = async ( const deleteRequest = leaderAlfa.page.waitForRequest( (request) => - request.method() === "DELETE" && - new URL(request.url()).pathname === `/api/team-applications/${second.submission.applicationId}`, + request.method() === "POST" && + /^\/api\/rpc\/?$/u.test(new URL(request.url()).pathname) && + nativeRpcTag(request.postData() ?? "") === "team-applications.deleteTeamApplication", ); await dialog.getByRole("button", { name: "Slett søknaden", exact: true }).click(); - const deleteKey = (await (await deleteRequest).allHeaders())["idempotency-key"] ?? ""; + const deletePayload = Schema.decodeSync(DeletePayload)( + (await deleteRequest).postData() ?? "", + ); + + assert.equal(deletePayload.payload.applicationId, second.submission.applicationId); + + const deleteKey = deletePayload.payload.idempotencyKey; assert.ok(deleteKey.length > 0, "delete sends an idempotency key"); await expect(leaderAlfa.page.locator('[data-team-application-notice="deleted"]')).toBeVisible(); await expect(leaderAlfa.page.locator("[data-application-id]")).toHaveCount(1); deletions.push({ applicationId: second.submission.applicationId, key: deleteKey }); - const deletePath = `/api/team-applications/${second.submission.applicationId}`; + const deletedId = second.submission.applicationId; - const replayedDelete = await api(leaderAlfa.cookie, deletePath, { - method: "DELETE", - headers: { "idempotency-key": deleteKey }, - }); + const replayedDelete = await deleteApplicationRpc(leaderAlfa.cookie, deletedId, deleteKey); - assert.equal(replayedDelete.status, 204, "same-key delete replays"); - await problem( - await api(leaderAlfa.cookie, deletePath, { method: "DELETE", headers: { "idempotency-key": randomUUID() } }), - 404, - ["resource.not-found"], - ); - await problem(await api(memberAlfa.cookie, deletePath), 404, ["resource.not-found"]); + // The RPC answers the stored no-content receipt as a success with no value. + assert.equal(replayedDelete.status, 200, "same-key delete replays"); + await problem(await deleteApplicationRpc(leaderAlfa.cookie, deletedId, randomUUID()), 404, [ + "resource.not-found", + ]); + await problem(await readApplicationRpc(memberAlfa.cookie, deletedId), 404, ["resource.not-found"]); checks.push({ kind: "deleted", detail: `${second.submission.applicationId} by the current leader` }); await checkpoint("deleted"); diff --git a/apps/dashboard/e2e/native-content-publication.spec.ts b/apps/dashboard/e2e/native-content-publication.spec.ts index 5f49a0cc6..636f3dbe8 100644 --- a/apps/dashboard/e2e/native-content-publication.spec.ts +++ b/apps/dashboard/e2e/native-content-publication.spec.ts @@ -1,9 +1,17 @@ -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.js"; import { writeFile } from "node:fs/promises"; import { expect, test, type Browser, type BrowserContext, type Page } from "@playwright/test"; -import { Option, Schema } from "effect"; -import { NativeProblem } from "@vektorprogrammet/http-api"; +import { Option, Predicate, Schema } from "effect"; +import { nativeRpcPath } from "@vektorprogrammet/rpc"; import { ContentArticleObservationSchema, ContentBridgeActionSchema } from "../app/foldkit/content/bridge"; +import { + isNativeRpcPath, + nativeRpcOutcome, + nativeRpcRequestBody, + nativeRpcStatus, + nativeRpcTag, + nativeRpcValue, +} from "./native-operations.js"; import { addressesAnyRoute, legacyRoutes } from "./request-routes.js"; @@ -46,11 +54,56 @@ type BrowserRequest = { readonly method: string; readonly pathname: string; readonly search: string; + readonly rpcTag?: string; + readonly articleId?: number; readonly idempotencyKey?: string; readonly ifMatch?: string; readonly requestFields?: ReadonlyArray; }; +/** The members of an RPC payload that the request ledger keeps. */ +const RpcPayloadFacts = Schema.fromJsonString( + Schema.Struct({ + payload: Schema.Struct({ + articleId: Schema.optional(Schema.Int), + idempotencyKey: Schema.optional(Schema.String), + ifMatch: Schema.optional(Schema.String), + }), + }), +); + +const rpcPayloadFacts = (body: string | null) => + Option.getOrUndefined(Schema.decodeOption(RpcPayloadFacts)(body ?? ""))?.payload; + +/** The problem that one RPC response answered, or its whole outcome when it answered none. */ +const problemOf = (body: string) => { + const outcome = nativeRpcOutcome(body); + + return Predicate.isTagged(outcome, "Problem") ? outcome.problem : outcome; +}; + +/** The entity tag that one article read answered, or a failure when it answered none. */ +const ArticleETag = Schema.Struct({ etag: Schema.String }); + +/** + * Posts one RPC from the script of `page`, with the browser's cookies and origin, and answers the + * RPC response text. The request is a browser request, so the request ledger records it. + */ +const postRpcFromPage = (page: Page, body: string) => + page.evaluate( + async ({ url, body }: { url: string; body: string }) => { + const response = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body, + }); + + return response.text(); + }, + { url: `${contentApiOrigin}${nativeRpcPath}`, body }, + ); + type BrowserResponse = BrowserRequest & { readonly status: number }; const openContext = async ( @@ -73,12 +126,20 @@ const openContext = async ( // Multipart and bodyless requests do not contribute JSON field evidence. } + // An RPC names its operation and carries its key and entity tag in its payload. + const rpc = + request.method() === "POST" && isNativeRpcPath(url.pathname) + ? { tag: nativeRpcTag(request.postData() ?? ""), facts: rpcPayloadFacts(request.postData()) } + : undefined; + browserRequests.push({ method: request.method(), pathname: url.pathname, search: url.search, - idempotencyKey: headers["idempotency-key"] === undefined ? undefined : headers["idempotency-key"], - ifMatch: headers["if-match"] === undefined ? undefined : headers["if-match"], + rpcTag: rpc?.tag, + articleId: rpc?.facts?.articleId, + idempotencyKey: rpc?.facts?.idempotencyKey ?? headers["idempotency-key"], + ifMatch: rpc?.facts?.ifMatch ?? headers["if-match"], requestFields: requestFields === undefined ? undefined : requestFields, }); }); @@ -204,8 +265,8 @@ test.describe("Native Content publication (spec 0062)", () => { published: true, }; - const accessibility = await new AxeBuilder({ page: administrator.page }).analyze(); - expect(accessibility.violations).toEqual([]); + const accessibility = await auditSettledPage(administrator.page); + expect(accessibility).toEqual([]); // --- Leader: revise + republish one immutable version ----------- const leader = await openContext(browser, browserRequests, browserResponses, pageErrors); @@ -286,55 +347,29 @@ test.describe("Native Content publication (spec 0062)", () => { contexts.push(publishedAuthor.context); await signIn(publishedAuthor.page, persons.authorDepartmentA, "/dashboard/artikler"); - const publishedMemberRevision = Schema.decodeSync(Schema.Struct({ detailStatus: Schema.Int, status: Schema.Int, body: NativeProblem }))(await publishedAuthor.page.evaluate( - async ({ - articleId, - departmentId, - apiOrigin, - }: { - articleId: number; - departmentId: string; - apiOrigin: string; - }) => { - const detailResponse = await fetch(`${apiOrigin}/api/content/articles/${articleId}`, { - credentials: "include", - }); - - const etag = '"vkr2.u07rIftJrsco7ukQ6cx0H6e8Axg_EHPO4atC27n8zhk"'; - - const response = await fetch(`${apiOrigin}/api/content/articles/${articleId}`, { - method: "PATCH", - credentials: "include", - headers: { - "content-type": "application/merge-patch+json", - "Idempotency-Key": "author-published-revise-denial", - "If-Match": etag, - }, - body: JSON.stringify({ - title: "To versjoner", - bodyHtml: "

Uautorisert publisert endring

", - departmentIds: [departmentId], - sticky: false, - }), - }); - - return { - detailStatus: detailResponse.status, - status: response.status, - body: (await response.json()), - }; - }, - { + const publishedDetail = await postRpcFromPage( + publishedAuthor.page, + nativeRpcRequestBody("content.readArticle", { articleId: twoVersionArticleId }), + ); + + const publishedMemberRevision = await postRpcFromPage( + publishedAuthor.page, + nativeRpcRequestBody("content.reviseArticle", { articleId: twoVersionArticleId, - departmentId: departmentAlpha, - apiOrigin: contentApiOrigin, - }, - )); - - expect(publishedMemberRevision.detailStatus).toBe(403); - expect(publishedMemberRevision.status).toBe(403); - expect(publishedMemberRevision.body).toMatchObject({ - status: 403, + idempotencyKey: "author-published-revise-denial", + ifMatch: '"vkr2.u07rIftJrsco7ukQ6cx0H6e8Axg_EHPO4atC27n8zhk"', + request: { + title: "To versjoner", + bodyHtml: "

Uautorisert publisert endring

", + departmentIds: [departmentAlpha], + sticky: false, + }, + }), + ); + + expect(nativeRpcStatus(publishedDetail)).toBe(403); + expect(nativeRpcStatus(publishedMemberRevision)).toBe(403); + expect(problemOf(publishedMemberRevision)).toMatchObject({ code: "authority.denied", type: "urn:vektorprogrammet:problem:v0.2:authority.denied", }); @@ -417,44 +452,32 @@ test.describe("Native Content publication (spec 0062)", () => { const authorDraftId = Number(await authorDraftRow.getAttribute("data-article-id")); expect(Number.isSafeInteger(authorDraftId)).toBe(true); - const directNativePublish = Schema.decodeSync(Schema.Struct({ status: Schema.Int, body: NativeProblem }))(await author.page.evaluate( - async ({ articleId, apiOrigin }: { articleId: number; apiOrigin: string }) => { - const detailResponse = await fetch(`${apiOrigin}/api/content/articles/${articleId}`, { - credentials: "include", - }); - - const etag = detailResponse.headers.get("etag"); - - if (etag === null) throw new Error("content detail response omitted ETag"); - - const response = await fetch(`${apiOrigin}/api/content/articles/${articleId}:publish`, { - method: "POST", - credentials: "include", - headers: { - "content-type": "application/json", - "Idempotency-Key": "author-forced-publish-denial", - "If-Match": etag, - }, - body: "{}", - }); - - return { - status: response.status, - body: (await response.json()), - }; - }, - { articleId: authorDraftId, apiOrigin: contentApiOrigin }, - )); - - expect(directNativePublish.status).toBe(403); - expect(directNativePublish.body).toMatchObject({ - status: 403, + const authorDraft = await postRpcFromPage( + author.page, + nativeRpcRequestBody("content.readArticle", { articleId: authorDraftId }), + ); + + const { etag: authorDraftETag } = Schema.decodeUnknownSync(ArticleETag)( + nativeRpcValue(authorDraft), + ); + + const directNativePublish = await postRpcFromPage( + author.page, + nativeRpcRequestBody("content.publishArticle", { + articleId: authorDraftId, + idempotencyKey: "author-forced-publish-denial", + ifMatch: authorDraftETag, + }), + ); + + expect(nativeRpcStatus(directNativePublish)).toBe(403); + expect(problemOf(directNativePublish)).toMatchObject({ code: "authority.denied", type: "urn:vektorprogrammet:problem:v0.2:authority.denied", }); observations.authorDenial = { publishButtonAbsent: true, nativeStatus: 403 }; - const authorAccessibility = await new AxeBuilder({ page: author.page }).analyze(); - expect(authorAccessibility.violations).toEqual([]); + const authorAccessibility = await auditSettledPage(author.page); + expect(authorAccessibility).toEqual([]); // --- Ended-only and no-authority personas: safely indistinguishable denials --- for (const [name, person] of [ @@ -480,13 +503,16 @@ test.describe("Native Content publication (spec 0062)", () => { await expect(anonPage.getByText("Publisert alfa")).toBeVisible(); await expect(anonPage.getByText("To versjoner")).toHaveCount(0); - const departmentListing = await anonPage.request.get( - `${contentApiOrigin}/api/news?department=${departmentBeta}`, - ); + const departmentListing = await anonPage.request.post(`${contentApiOrigin}${nativeRpcPath}`, { + headers: { "content-type": "application/json" }, + data: nativeRpcRequestBody("content.listNews", { departmentId: departmentBeta }), + }); + + const departmentListingText = await departmentListing.text(); - expect(departmentListing.status()).toBe(200); + expect(nativeRpcStatus(departmentListingText)).toBe(200); - const departmentListingBody = Schema.decodeUnknownSync(Schema.Struct({ articles: Schema.Array(Schema.Struct({ slug: Schema.String })) }))(await departmentListing.json()); + const departmentListingBody = Schema.decodeUnknownSync(Schema.Struct({ articles: Schema.Array(Schema.Struct({ slug: Schema.String })) }))(nativeRpcValue(departmentListingText)); expect(departmentListingBody.articles.map((article) => article.slug)).toEqual([ "festet-fleravdeling", @@ -516,19 +542,19 @@ test.describe("Native Content publication (spec 0062)", () => { teaserVisible: true, }; - const anonAccessibility = await new AxeBuilder({ page: anonPage }) - .include('section[aria-labelledby="news-teaser-heading"]') - .analyze(); + const anonAccessibility = await auditSettledPage(anonPage, { + include: ['section[aria-labelledby="news-teaser-heading"]'], + }); - expect(anonAccessibility.violations).toEqual([]); + expect(anonAccessibility).toEqual([]); // --- Request confinement ----------------------------------------- const bridgeRequests = browserRequests.filter( (request) => request.pathname === "/dashboard/content", ); - const nativeContentRequests = browserRequests.filter((request) => - request.pathname.startsWith("/api/content/articles"), + const nativeContentRequests = browserRequests.filter( + (request) => isNativeRpcPath(request.pathname) && request.rpcTag?.startsWith("content.") === true, ); const publicRequests = browserRequests.filter( @@ -536,23 +562,24 @@ test.describe("Native Content publication (spec 0062)", () => { request.pathname === "/" || request.pathname === "/nyheter" || request.pathname.startsWith("/nyhet/") || - request.pathname.startsWith("/api/news"), + request.rpcTag === "content.listNews" || + request.rpcTag === "content.readNewsArticle", ); expect(publicRequests.some((request) => request.pathname === "/nyheter")).toBe(true); expect(publicRequests.some((request) => request.pathname.startsWith("/nyhet/"))).toBe(true); expect(bridgeRequests.length).toBeGreaterThanOrEqual(3); expect(nativeContentRequests).toEqual([ - expect.objectContaining({ method: "GET", pathname: "/api/content/articles/5" }), + expect.objectContaining({ rpcTag: "content.readArticle", articleId: 5 }), expect.objectContaining({ - method: "PATCH", - pathname: "/api/content/articles/5", + rpcTag: "content.reviseArticle", + articleId: 5, idempotencyKey: "author-published-revise-denial", }), - expect.objectContaining({ method: "GET", pathname: "/api/content/articles/1" }), + expect.objectContaining({ rpcTag: "content.readArticle", articleId: 1 }), expect.objectContaining({ - method: "POST", - pathname: "/api/content/articles/1:publish", + rpcTag: "content.publishArticle", + articleId: 1, idempotencyKey: "author-forced-publish-denial", }), ]); @@ -577,7 +604,7 @@ test.describe("Native Content publication (spec 0062)", () => { nativeContentRequests, observations, pageErrors, - accessibilityViolations: accessibility.violations, + accessibilityViolations: accessibility, }; if (evidencePath !== undefined) { diff --git a/apps/dashboard/e2e/native-identity-browser.spec.ts b/apps/dashboard/e2e/native-identity-browser.spec.ts index 010c19215..dd9691c86 100644 --- a/apps/dashboard/e2e/native-identity-browser.spec.ts +++ b/apps/dashboard/e2e/native-identity-browser.spec.ts @@ -1,11 +1,13 @@ -import { SessionListResponse } from "@vektorprogrammet/http-api"; -import { Schema, Predicate } from "effect"; +import { IdempotencyKey, nativeRpcPath, type NativeRpcClient, type SessionListResponse } from "@vektorprogrammet/rpc"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; +import { Effect, Predicate, Schema } from "effect"; import assert from "node:assert/strict"; import { randomBytes } from "node:crypto"; -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.js"; import { writeFile } from "node:fs/promises"; import { expect, test, type BrowserContext, type Page } from "@playwright/test"; import { readBrowserStorage } from "../browser/interview-response-state.js"; +import { nativeRpcRequestBody } from "./native-operations.js"; import { addressesAnyRoute, legacyRoutes } from "./request-routes.js"; const realRun = process.env.REAL_NATIVE_IDENTITY_E2E === "1"; @@ -104,9 +106,9 @@ type BrowserAuthorityCheck = { }; const blockingViolations = async (page: Page) => { - const result = await new AxeBuilder({ page }).analyze(); + const result = await auditSettledPage(page); - return result.violations.filter( + return result.filter( (violation) => violation.impact === "serious" || violation.impact === "critical", ); }; @@ -277,52 +279,67 @@ const signInContext = async ( expect(response.status()).toBe(200); }; -/** Reads owned sessions; the contract's owner-only projection rejects any extra field. */ -const readSessions = async (context: BrowserContext): Promise => { - const response = await context.request.get(`${apiOrigin}/api/sessions`, { - headers: originHeaders(), - }); +const nativeScript = nativeScriptClient(apiOrigin === "" ? "http://native.invalid" : apiOrigin); - expect(response.status()).toBe(200); +type NativeClient = NativeRpcClient["Service"]; - const body = Schema.decodeUnknownSync(SessionListResponse, { onExcessProperty: "error" })( - await response.json(), - ); +/** The Better Auth cookie that a browser context holds for the backend, as one Cookie header. */ +const cookieHeaderOf = async (context: BrowserContext): Promise => + (await context.cookies(apiOrigin)).map(({ name, value }) => `${name}=${value}`).join("; "); - expect(body.length).toBeGreaterThan(0); - expect(body.filter((session) => session.current)).toHaveLength(1); +/** Calls one RPC with the session that `context` holds, from the dashboard origin. */ +const callAs = async ( + context: BrowserContext, + rpc: (client: NativeClient) => Effect.Effect, +) => nativeScript.call({ cookie: await cookieHeaderOf(context), origin: dashboardOrigin }, rpc); - return body; -}; +/** Reads owned sessions; exactly one of them is the caller's current session. */ +const readSessions = async (context: BrowserContext): Promise => { + const response = await callAs(context, (client) => client["system.listSessions"]()); -/** Session mutations are idempotent commands; each call is a new command with its own key. */ -const commandHeaders = (method: "DELETE" | "GET" | "POST"): Record => - method === "GET" ? {} : { "Idempotency-Key": randomBytes(18).toString("base64url") }; + if (!response.ok) throw new Error(`system.listSessions answered ${response.code}`); -const nativeMutation = ( - context: BrowserContext, - method: "DELETE" | "POST", - path: string, - origin = dashboardOrigin, -) => - context.request.fetch(`${apiOrigin}${path}`, { - method, - headers: { ...originHeaders(origin), ...commandHeaders(method) }, - }); + expect(response.value.length).toBeGreaterThan(0); + expect(response.value.filter((session) => session.current)).toHaveLength(1); + + return response.value; +}; + +/** Session commands are idempotent; each call is a new command with its own key. */ +const commandKey = () => IdempotencyKey.make(randomBytes(18).toString("base64url")); + +/** The session operations, each one command or read of `system`. */ +const sessionOperations = { + readSession: (client: NativeClient) => client["system.readSession"](), + deleteSession: (client: NativeClient) => + client["system.deleteSession"]({ idempotencyKey: commandKey() }), + revokeOtherSessions: (client: NativeClient) => + client["system.revokeOtherSessions"]({ idempotencyKey: commandKey() }), + revokeAllSessions: (client: NativeClient) => + client["system.revokeAllSessions"]({ idempotencyKey: commandKey() }), + deleteOwnedSession: (sessionId: string) => (client: NativeClient) => + client["system.deleteOwnedSession"]({ idempotencyKey: commandKey(), sessionId }), +}; -const replayWithCookie = ( - method: "DELETE" | "GET" | "POST", - path: string, +/** + * Posts one session command as a browser in `context` would, with its cookie and `origin` as HTTP + * headers, so the ingress decides the origin before any RPC runs. + */ +const browserRpcStatus = async (context: BrowserContext, origin: string) => + ( + await context.request.post(`${apiOrigin}${nativeRpcPath}`, { + headers: { ...originHeaders(origin), "content-type": "application/json" }, + data: nativeRpcRequestBody("system.revokeOtherSessions", { idempotencyKey: commandKey() }), + }) + ).status(); + +/** Replays one RPC with a cookie that the browser no longer holds. */ +const replayWithCookie = ( cookie: { readonly name: string; readonly value: string }, -) => - fetch(`${apiOrigin}${path}`, { - method, - headers: { - Cookie: `${cookie.name}=${cookie.value}`, - Origin: dashboardOrigin, - ...commandHeaders(method), - }, - }); + rpc: (client: NativeClient) => Effect.Effect, +) => nativeScript.call({ cookie: `${cookie.name}=${cookie.value}`, origin: dashboardOrigin }, rpc); + +test.afterAll(() => nativeScript.dispose()); test.describe("Native Identity browser evidence (spec 0065 with spec 0056 rules)", () => { test.skip(!realRun, "run through the bounded native Identity PostgreSQL runner"); @@ -348,28 +365,15 @@ test.describe("Native Identity browser evidence (spec 0065 with spec 0056 rules) throw new Error("current session projection omitted its opaque identifier"); } - const rejectedOrigin = await nativeMutation( - primary, - "POST", - "/api/sessions:revoke-others", - "https://untrusted.example.invalid", - ); - - expect(rejectedOrigin.status()).toBe(403); + expect(await browserRpcStatus(primary, "https://untrusted.example.invalid")).toBe(403); expect(await readSessions(primary)).toHaveLength(2); - expect((await nativeMutation(primary, "POST", "/api/sessions:revoke-others")).status()).toBe( - 204, - ); - expect((await nativeMutation(primary, "POST", "/api/sessions:revoke-others")).status()).toBe( - 204, - ); + expect((await callAs(primary, sessionOperations.revokeOtherSessions)).status).toBe(200); + expect((await callAs(primary, sessionOperations.revokeOtherSessions)).status).toBe(200); - const secondaryAfterRevocation = await secondary.request.get(`${apiOrigin}/api/session`, { - headers: originHeaders(), - }); + const secondaryAfterRevocation = await callAs(secondary, sessionOperations.readSession); - expect(secondaryAfterRevocation.status()).toBe(401); + expect(secondaryAfterRevocation.status).toBe(401); await signInContext(third, email, password); const afterThirdSignIn = await readSessions(primary); @@ -384,37 +388,23 @@ test.describe("Native Identity browser evidence (spec 0065 with spec 0056 rules) } expect( - ( - await nativeMutation( - primary, - "DELETE", - `/api/sessions/${encodeURIComponent(thirdSessionId)}`, - ) - ).status(), - ).toBe(204); - - const repeatedDeleted = await nativeMutation( + (await callAs(primary, sessionOperations.deleteOwnedSession(thirdSessionId))).status, + ).toBe(200); + + const repeatedDeleted = await callAs( primary, - "DELETE", - `/api/sessions/${encodeURIComponent(thirdSessionId)}`, + sessionOperations.deleteOwnedSession(thirdSessionId), ); - const missing = await nativeMutation( + const missing = await callAs( primary, - "DELETE", - "/api/sessions/missing-session-0054-1", + sessionOperations.deleteOwnedSession("missing-session-0054-1"), ); - expect(repeatedDeleted.status()).toBe(404); - expect(missing.status()).toBe(404); - expect(await repeatedDeleted.json()).toEqual(await missing.json()); - expect( - ( - await third.request.get(`${apiOrigin}/api/session`, { - headers: originHeaders(), - }) - ).status(), - ).toBe(401); + expect(repeatedDeleted.status).toBe(404); + expect(missing.status).toBe(404); + expect(repeatedDeleted).toEqual(missing); + expect((await callAs(third, sessionOperations.readSession)).status).toBe(401); await delay(betterAuthSignInWindowResetMs); await signInContext(member, memberEmail, memberPassword); @@ -426,21 +416,19 @@ test.describe("Native Identity browser evidence (spec 0065 with spec 0056 rules) throw new Error("member session projection omitted its opaque identifier"); } - const memberCrossPerson = await nativeMutation( + const memberCrossPerson = await callAs( member, - "DELETE", - `/api/sessions/${encodeURIComponent(currentSessionId)}`, + sessionOperations.deleteOwnedSession(currentSessionId), ); - const administratorCrossPerson = await nativeMutation( + const administratorCrossPerson = await callAs( primary, - "DELETE", - `/api/sessions/${encodeURIComponent(memberSessionId)}`, + sessionOperations.deleteOwnedSession(memberSessionId), ); - expect(memberCrossPerson.status()).toBe(404); - expect(administratorCrossPerson.status()).toBe(404); - expect(await memberCrossPerson.json()).toEqual(await administratorCrossPerson.json()); + expect(memberCrossPerson.status).toBe(404); + expect(administratorCrossPerson.status).toBe(404); + expect(memberCrossPerson).toEqual(administratorCrossPerson); const signupResponse = await signup.request.post(`${apiOrigin}/api/auth/sign-up/email`, { headers: originHeaders(), @@ -459,12 +447,12 @@ test.describe("Native Identity browser evidence (spec 0065 with spec 0056 rules) ); assert.ok(primaryCookie); - expect((await nativeMutation(primary, "POST", "/api/sessions:revoke-all")).status()).toBe( - 204, + expect((await callAs(primary, sessionOperations.revokeAllSessions)).status).toBe(200); + expect((await replayWithCookie(primaryCookie, sessionOperations.readSession)).status).toBe( + 401, ); - expect((await replayWithCookie("GET", "/api/session", primaryCookie)).status).toBe(401); expect( - (await replayWithCookie("POST", "/api/sessions:revoke-all", primaryCookie)).status, + (await replayWithCookie(primaryCookie, sessionOperations.revokeAllSessions)).status, ).toBe(401); await signInContext(primary, email, password); @@ -474,18 +462,20 @@ test.describe("Native Identity browser evidence (spec 0065 with spec 0056 rules) ); assert.ok(replacementCookie); - expect((await nativeMutation(primary, "DELETE", "/api/session")).status()).toBe(204); + expect((await callAs(primary, sessionOperations.deleteSession)).status).toBe(200); const immediateReplay = await Promise.all( - Array.from({ length: 4 }, () => replayWithCookie("GET", "/api/session", replacementCookie)), + Array.from({ length: 4 }, () => + replayWithCookie(replacementCookie, sessionOperations.readSession), + ), ); expect(immediateReplay.map((response) => response.status)).toEqual([401, 401, 401, 401]); - expect((await replayWithCookie("DELETE", "/api/session", replacementCookie)).status).toBe( - 401, - ); + expect( + (await replayWithCookie(replacementCookie, sessionOperations.deleteSession)).status, + ).toBe(401); - expect((await nativeMutation(member, "DELETE", "/api/session")).status()).toBe(204); + expect((await callAs(member, sessionOperations.deleteSession)).status).toBe(200); await writeFile( hardeningEvidencePath, `${JSON.stringify( @@ -500,13 +490,13 @@ test.describe("Native Identity browser evidence (spec 0065 with spec 0056 rules) }, originRejection: { status: 403, mutationObserved: false }, revocation: { - revokeOthers: [204, 204], + revokeOthers: [200, 200], otherContextNextRequest: 401, - revokeOne: 204, + revokeOne: 200, repeatedAndMissing: [404, 404], - revokeAll: 204, + revokeAll: 200, revokeAllReplay: 401, - current: 204, + current: 200, immediateReplay: immediateReplay.map((response) => response.status), currentReplay: 401, }, @@ -702,7 +692,7 @@ test.describe("Native Identity browser evidence (spec 0065 with spec 0056 rules) false, ); await expect(page.getByRole("heading", { level: 1, name: "Vektorprogrammet" })).toBeVisible(); - observations.logout = { nativeStatus: 204, redirect: "/login", browserCookieRemoved: true }; + observations.logout = { nativeStatus: 200, redirect: "/login", browserCookieRemoved: true }; browserAuthorityChecks.push( await observeBrowserAuthorityIsolation(context, page, "logout-login"), ); diff --git a/apps/dashboard/e2e/native-mailing-lists-journey.spec.ts b/apps/dashboard/e2e/native-mailing-lists-journey.spec.ts index 89461f2ee..afb02324b 100644 --- a/apps/dashboard/e2e/native-mailing-lists-journey.spec.ts +++ b/apps/dashboard/e2e/native-mailing-lists-journey.spec.ts @@ -1,5 +1,5 @@ -import { Schema } from "effect"; -import { MailingListResponse } from "@vektorprogrammet/http-api"; +import { DepartmentId, SemesterId } from "@vektorprogrammet/domain"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { expect, test, type Page } from "@playwright/test"; const nativeIdentityMode = process.env.REAL_NATIVE_IDENTITY_E2E === "1"; @@ -15,13 +15,23 @@ const memberEmail = "member.0059@example.invalid"; const apiOrigin = process.env.API_URL ?? "http://127.0.0.1:8790"; -const selectedSemester = "semester-0060-selected"; +const selectedSemester = SemesterId.make("semester-0060-selected"); -const beforeAppointments = "semester-0060-before-appointments"; +const beforeAppointments = SemesterId.make("semester-0060-before-appointments"); -const trondheim = "department-0059-trondheim"; +const trondheim = DepartmentId.make("department-0059-trondheim"); -const bergen = "department-0059-bergen"; +const bergen = DepartmentId.make("department-0059-bergen"); + +const native = nativeScriptClient(apiOrigin); + +/** The browser's cookies and the dashboard origin, which a native RPC takes from the page. */ +const pageHeaders = async (page: Page, baseURL: string | undefined) => ({ + cookie: (await page.context().cookies()) + .map(({ name, value }) => `${name}=${value}`) + .join("; "), + origin: new URL(baseURL ?? page.url()).origin, +}); const teamEmails = [ "astrid.admin@example.invalid", @@ -42,8 +52,11 @@ const signIn = async (page: Page, email: string) => { test.describe("Native scoped mailing recipients", () => { test.skip(!nativeIdentityMode, "requires the real native identity topology"); + test.afterAll(() => native.dispose()); + test("selection controls expose copyable recipients and retain filters after reload", async ({ page, + baseURL, }) => { await signIn(page, adminEmail); await page.goto(`/dashboard/epostliste?semester=${selectedSemester}`); @@ -72,14 +85,16 @@ test.describe("Native scoped mailing recipients", () => { await expect(recipients).toHaveCount(0); await expect(page.getByRole("alert")).toHaveCount(0); - const response = await page.request.get( - `${apiOrigin}/api/mailing-lists?department=${trondheim}&semester=${beforeAppointments}&type=team`, + const response = await native.call(await pageHeaders(page, baseURL), (client) => + client["organization.listMailingLists"]({ + departmentId: trondheim, + semesterId: beforeAppointments, + type: "team", + }), ); - expect(response.status()).toBe(200); - expect(Schema.decodeUnknownSync(MailingListResponse)(await response.json())).toEqual([ - { name: `team-${trondheim}`, emails: [] }, - ]); + expect(response.status).toBe(200); + expect(response.ok && response.value).toEqual([{ name: `team-${trondheim}`, emails: [] }]); await page.goto(`/dashboard/epostliste?department=${trondheim}&semester=unknown&type=team`); await expect(page.getByRole("alert")).toBeVisible(); @@ -88,6 +103,7 @@ test.describe("Native scoped mailing recipients", () => { test("public department choices never expand a leader's recipient authority", async ({ page, + baseURL, }) => { await signIn(page, leaderEmail); await page.goto( @@ -101,38 +117,41 @@ test.describe("Native scoped mailing recipients", () => { await expect(page.getByRole("alert")).toBeVisible(); await expect(page.getByRole("textbox", { name: "E-postadresser", exact: true })).toHaveCount(0); - const response = await page.request.get( - `${apiOrigin}/api/mailing-lists?department=${bergen}&semester=${selectedSemester}&type=team`, + const response = await native.call(await pageHeaders(page, baseURL), (client) => + client["organization.listMailingLists"]({ + departmentId: bergen, + semesterId: selectedSemester, + type: "team", + }), ); - expect(response.status()).toBe(403); - expect(await response.json()).toMatchObject({ code: "authority.denied" }); + expect(response.status).toBe(403); + expect(response.ok ? undefined : response.code).toBe("authority.denied"); }); - test("ordinary membership does not permit recipient reads", async ({ page }) => { + test("ordinary membership does not permit recipient reads", async ({ page, baseURL }) => { await signIn(page, memberEmail); await page.goto(`/dashboard/epostliste?semester=${selectedSemester}&type=team`); await expect(page.getByRole("alert")).toBeVisible(); await expect(page.getByRole("textbox", { name: "E-postadresser", exact: true })).toHaveCount(0); - const response = await page.request.get( - `${apiOrigin}/api/mailing-lists?semester=${selectedSemester}&type=team`, + const response = await native.call(await pageHeaders(page, baseURL), (client) => + client["organization.listMailingLists"]({ semesterId: selectedSemester, type: "team" }), ); - expect(response.status()).toBe(403); - expect(await response.json()).toMatchObject({ code: "authority.denied" }); + expect(response.status).toBe(403); + expect(response.ok ? undefined : response.code).toBe("authority.denied"); }); - test("anonymous access and an invalid cohort fail at their boundaries", async ({ page }) => { - const anonymous = await page.request.get(`${apiOrigin}/api/mailing-lists`); - - expect(anonymous.status()).toBe(401); - expect(await anonymous.json()).toMatchObject({ code: "credential.missing" }); - await signIn(page, adminEmail); - - const invalidType = await page.request.get(`${apiOrigin}/api/mailing-lists?type=bogus`); + // An invalid cohort no longer reaches the backend: the RPC payload schema admits only the + // declared list types, so the typed client cannot send one. + test("anonymous access fails at its boundary", async ({ baseURL }) => { + const anonymous = await native.call( + { origin: new URL(baseURL ?? apiOrigin).origin }, + (client) => client["organization.listMailingLists"]({}), + ); - expect(invalidType.status()).toBe(400); - expect(await invalidType.json()).toMatchObject({ code: "request.malformed" }); + expect(anonymous.status).toBe(401); + expect(anonymous.ok ? undefined : anonymous.code).toBe("credential.missing"); }); }); diff --git a/apps/dashboard/e2e/native-onboarding.spec.ts b/apps/dashboard/e2e/native-onboarding.spec.ts index 5f7183ae9..3100c8344 100644 --- a/apps/dashboard/e2e/native-onboarding.spec.ts +++ b/apps/dashboard/e2e/native-onboarding.spec.ts @@ -1,4 +1,4 @@ -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.js"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, test, type Page } from "@playwright/test"; @@ -22,14 +22,7 @@ const signIn = async ( }; const axe = async (page: Page) => { - const result = await new AxeBuilder({ page }).analyze(); - expect( - result.violations.map((v) => ({ - id: v.id, - impact: v.impact, - targets: v.nodes.map((n) => n.target), - })), - ).toEqual([]); + expect(await auditSettledPage(page)).toEqual([]); }; test("0099 applicant claims an invited account then requests affiliation and receives a placement", async ({ diff --git a/apps/dashboard/e2e/native-operations.ts b/apps/dashboard/e2e/native-operations.ts index fe05b168f..6eeef311c 100644 --- a/apps/dashboard/e2e/native-operations.ts +++ b/apps/dashboard/e2e/native-operations.ts @@ -1,61 +1,140 @@ /** - * The native surface that a dashboard-to-backend recorder allows, derived from the HTTP contract. + * The native surface that a dashboard-to-backend recorder allows, derived from the RPC contract. * - * A request is native when it fills an operation's path template segment by segment, so a random - * value in a path parameter never changes the answer. Use it to prove that a journey sends no - * legacy, provider, recovery, or token request, rather than listing such routes by name. + * Every native operation is an RPC of `NativeRpcs`, sent as `POST /api/rpc`; the health probe + * stays `GET /health`. A recorder that keeps the request body reads the RPC tag from it, so a + * request to the RPC endpoint counts as native only when it names an RPC of the contract. Use it to + * prove that a journey sends no legacy, provider, recovery, or token request, rather than listing + * such routes by name. */ -import { ExternalNativeApi } from "@vektorprogrammet/http-api"; -import { OpenApi } from "effect/unstable/httpapi"; +import { isNativeRpcPath, NativeProblem, NativeRpcs } from "@vektorprogrammet/rpc"; +import { Array as Arr, Match, Option, Predicate, Schema } from "effect"; -const httpMethods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const; +/** One RPC request message, as the JSON serialization of the RPC client sends it over HTTP. */ +const RpcRequestMessage = Schema.fromJsonString( + Schema.TaggedStruct("Request", { tag: Schema.String }), +); + +const decodeRpcRequest = Schema.decodeUnknownOption(RpcRequestMessage); -const pathParameter = /\{[^{}]+\}/u; +/** The full request message, as the RPC client sends it with no forwarded headers. */ +const RpcRequest = Schema.TaggedStruct("Request", { + id: Schema.String, + tag: Schema.String, + payload: Schema.Json, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +}); + +const encodeRpcRequest = Schema.encodeSync(Schema.fromJsonString(RpcRequest)); /** - * Whether a path segment fills a template segment. A parameter, as in `{sessionId}` or - * `{receiptId}:approve`, holds the rest of one whole segment; the literal text around it must match. + * The body of one RPC request that a probe posts to the RPC endpoint as a browser would, with its + * cookie and origin as HTTP headers: the ingress then decides the origin before any RPC runs. */ -const fillsSegment = (template: string, segment: string): boolean => { - const parameter = pathParameter.exec(template); +export const nativeRpcRequestBody = (tag: string, payload: Schema.Json = null): string => + encodeRpcRequest(RpcRequest.make({ id: "0", tag, payload, headers: [] })); - if (parameter === null) return template === segment; +/** + * The RPC tag that one request body to the RPC endpoint names, such as `system.readSession`, or + * `undefined` when the body is no RPC request message. + */ +export const nativeRpcTag = (body: string): string | undefined => + Option.getOrUndefined(Option.map(decodeRpcRequest(body), (message) => message.tag)); - const prefix = template.slice(0, parameter.index); - const suffix = template.slice(parameter.index + parameter[0].length); +const RequestId = Schema.Union([Schema.String, Schema.Finite]); - return ( - segment.length > prefix.length + suffix.length && - segment.startsWith(prefix) && - segment.endsWith(suffix) - ); -}; +/** The terminal message of one RPC response, as the JSON serialization writes it over HTTP. */ +const RpcExitMessage = Schema.TaggedStruct("Exit", { + requestId: RequestId, + exit: Schema.Union([ + Schema.TaggedStruct("Success", { value: Schema.Json }), + Schema.TaggedStruct("Failure", { cause: Schema.Array(Schema.Json) }), + ]), +}); -/** Each operation of the native HTTP contract: its method and its path template's segments. */ -const nativeOperations = Object.entries(OpenApi.fromApi(ExternalNativeApi).paths).flatMap( - ([template, pathItem]) => - httpMethods.flatMap((method) => - pathItem[method] === undefined - ? [] - : [{ method: method.toUpperCase(), segments: template.split("/") }], - ), +const decodeRpcResponse = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Array(Schema.Json)), ); +const isRpcExitMessage = Schema.is(RpcExitMessage); + +const FailReason = Schema.TaggedStruct("Fail", { error: NativeProblem }); + +const isFailReason = Schema.is(FailReason); + +/** What one RPC answered: its value, a declared problem, or a defect. */ +export const NativeRpcOutcome = Schema.Union([ + Schema.TaggedStruct("Success", { value: Schema.Json }), + Schema.TaggedStruct("Problem", { status: Schema.Finite, problem: NativeProblem }), + Schema.TaggedStruct("Defect", {}), +]); + +export type NativeRpcOutcome = typeof NativeRpcOutcome.Type; + /** - * Whether a request is an operation of the native HTTP contract. A random value that fills a - * path parameter, such as a session identifier, never changes the answer. + * The outcome that one RPC response body carries, or `undefined` when the body is no RPC response. + * Every RPC answers HTTP 200; a declared problem carries the status that the problem registry gives + * its code, so a recorder can keep comparing statuses. */ -export const isNativeOperation = (method: string, pathname: string): boolean => { - const segments = pathname.split("/"); - - return nativeOperations.some( - (operation) => - operation.method === method && - operation.segments.length === segments.length && - operation.segments.every((template, index) => fillsSegment(template, segments[index] ?? "")), +export const nativeRpcOutcome = (body: string): NativeRpcOutcome | undefined => { + const exit = Option.flatMap(decodeRpcResponse(body), Arr.findFirst(isRpcExitMessage)); + + if (Option.isNone(exit)) return undefined; + + return Match.value(exit.value.exit).pipe( + Match.tag("Success", ({ value }) => NativeRpcOutcome.members[0].make({ value })), + Match.tag("Failure", ({ cause }) => + Option.match(Arr.findFirst(cause, isFailReason), { + onNone: () => NativeRpcOutcome.members[2].make({}), + onSome: ({ error }) => + NativeRpcOutcome.members[1].make({ status: error.status, problem: error }), + }), + ), + Match.exhaustive, ); }; +/** The success value that one RPC response carries, or `undefined` for any other answer. */ +export const nativeRpcValue = (body: string): Schema.Json | undefined => { + const outcome = nativeRpcOutcome(body); + + return Predicate.isTagged(outcome, "Success") ? outcome.value : undefined; +}; + +/** The status that one RPC response answered under the HTTP contract: 200, or its problem's. */ +export const nativeRpcStatus = (body: string): number | undefined => { + const outcome = nativeRpcOutcome(body); + + return outcome === undefined + ? undefined + : Match.value(outcome).pipe( + Match.tag("Success", () => 200), + Match.tag("Problem", ({ status }) => status), + Match.tag("Defect", () => 500), + Match.exhaustive, + ); +}; + +/** Whether a request path addresses the RPC endpoint, with or without its trailing slash. */ +export { isNativeRpcPath }; + +/** + * Whether a request is an operation of the native contract: an RPC of `NativeRpcs` at the RPC + * endpoint, or the health probe. When `body` is given, a request to the RPC endpoint must name an + * RPC of the contract. + */ +export const isNativeOperation = (method: string, pathname: string, body?: string): boolean => { + if (method === "GET" && pathname === "/health") return true; + + if (method !== "POST" || !isNativeRpcPath(pathname)) return false; + + if (body === undefined) return true; + + const tag = nativeRpcTag(body); + + return tag !== undefined && NativeRpcs.requests.has(tag); +}; + /** The identity engine's email-password routes, which native journeys call beside the contract. */ const identityEngineRequests = { "POST /api/auth/sign-in/email": true, @@ -65,23 +144,23 @@ const identityEngineRequests = { /** * Whether a dashboard-to-backend request stays on the native surface: an operation of the - * native HTTP contract or an email-password route of the identity engine. + * native RPC contract or an email-password route of the identity engine. * * @remarks - * The operations come from the OpenAPI document of `ExternalNativeApi`, each a method and a path - * template. A request is one of them when `method`, in upper case, is the operation's and - * `pathname` fills the template segment by segment: a parameter such as `{sessionId}` or - * `{receiptId}:approve` holds the rest of one whole segment, and the literal text around it must - * match, so a random value in a parameter never changes the answer. Beside the contract, only - * `POST /api/auth/sign-in/email`, `POST /api/auth/sign-up/email`, and - * `GET /api/auth/get-session` are native. Every other request, such as a legacy API call or a - * provider, recovery, or token route, leaves the surface. + * Every native operation is an RPC of `NativeRpcs`, which the client posts to the RPC endpoint + * (`POST /api/rpc`, with or without one trailing slash); `GET /health` is the one plain HTTP + * operation. When the recorder passes the request `body`, a request to the RPC endpoint is native + * only when the RPC request message in it names an RPC of `NativeRpcs`, so an unknown tag leaves + * the surface. Beside the contract, only `POST /api/auth/sign-in/email`, + * `POST /api/auth/sign-up/email`, and `GET /api/auth/get-session` are native. Every other request, + * such as a legacy API call or a provider, recovery, or token route, leaves the surface. The path + * is compared whole, so no opaque value in it can match by chance. * * @sideEffects none * * @example * ```ts - * proxy.records.filter(({ method, pathname }) => !isNativeRequest(method, pathname)); + * proxy.records.filter(({ method, pathname, body }) => !isNativeRequest(method, pathname, body)); * ``` * * @avoid A recorder that lists the forbidden routes by name, or tests a path for a substring: a @@ -90,6 +169,6 @@ const identityEngineRequests = { * * @construct request-ledger */ -export const isNativeRequest = (method: string, pathname: string): boolean => - isNativeOperation(method, pathname) || +export const isNativeRequest = (method: string, pathname: string, body?: string): boolean => + isNativeOperation(method, pathname, body) || Object.hasOwn(identityEngineRequests, `${method} ${pathname}`); diff --git a/apps/dashboard/e2e/native-organization-administration.spec.ts b/apps/dashboard/e2e/native-organization-administration.spec.ts index 13194e3ee..c6cbbbf7a 100644 --- a/apps/dashboard/e2e/native-organization-administration.spec.ts +++ b/apps/dashboard/e2e/native-organization-administration.spec.ts @@ -1,17 +1,11 @@ -import { Schema } from "effect"; -import AxeBuilder from "@axe-core/playwright"; +import { Option, Schema } from "effect"; +import { auditSettledPage } from "./settled-axe.js"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; -import { IdempotencyKey } from "@vektorprogrammet/http-api"; -import { createPromiseClient } from "@vektorprogrammet/sdk"; -import { - expect, - test, - type APIResponse, - type APIRequestContext, - type Page, - type Request, -} from "@playwright/test"; +import { DepartmentId } from "@vektorprogrammet/domain"; +import { IdempotencyKey, isNativeRpcPath } from "@vektorprogrammet/rpc"; +import { nativeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; +import { expect, test, type Page, type Request } from "@playwright/test"; const DASHBOARD_ORIGIN = process.env.DASHBOARD_ORIGIN ?? "http://127.0.0.1:5185"; @@ -29,14 +23,39 @@ const requiredEnvironment = (name: string): string => { return value; }; -const responseBody = async (response: APIResponse): Promise => { +const native = nativeScriptClient(API_ORIGIN); + +/** The JSON evidence of one RPC answer: its value, or its problem body. */ +const answerBody =
(result: ScriptCallResult): Schema.Json => { try { - return Schema.decodeUnknownSync(Schema.Json)(await response.json()); + return Schema.decodeUnknownSync(Schema.Json)( + result.ok ? result.value : "problem" in result ? result.problem : result.defect, + ); } catch { return null; } }; +/** An own-profile answer names its person, bare or beside its entity tag. */ +const ProfileAnswer = Schema.Union([ + Schema.Struct({ personId: Schema.String }), + Schema.Struct({ profile: Schema.Struct({ personId: Schema.String }) }), +]); + +/** The native operations that the public Foldkit catalogs read. */ +const publicCatalogTags = new Set([ + "organization.listDepartments", + "organization.listTeams", + "organization.listFieldOfStudies", +]); + +/** The native RPC request that one browser request body carries. */ +const RpcRequestBody = Schema.fromJsonString(Schema.Struct({ tag: Schema.String })); + +/** The tag of the native RPC that one browser request body carries, if any. */ +const rpcTagOf = (body: string | null): string | undefined => + body === null ? undefined : Option.getOrUndefined(Schema.decodeOption(RpcRequestBody)(body))?.tag; + type AuthenticatedPersona = { readonly cookie: string; readonly sessionCookieNames: ReadonlyArray; @@ -45,7 +64,6 @@ type AuthenticatedPersona = { const authenticate = async ( page: Page, - request: APIRequestContext, emailEnvironment: string, passwordEnvironment: string, personIdEnvironment: string, @@ -82,20 +100,24 @@ const authenticate = async ( const cookie = sessionCookies.map(({ name, value }) => `${name}=${value}`).join("; "); - const sessionResponse = await request.get(`${API_ORIGIN}/api/session`, { - headers: { Cookie: cookie }, - }); + const headers = { cookie, origin: DASHBOARD_ORIGIN }; + const sessionResult = await native.call(headers, (client) => client["system.readSession"]()); - expect(sessionResponse.status()).toBe(200); - expect(await responseBody(sessionResponse)).toMatchObject({ current: true }); + expect(sessionResult.status).toBe(200); + expect(answerBody(sessionResult)).toMatchObject({ current: true }); - const profileResponse = await request.get(`${API_ORIGIN}/api/profile`, { - headers: { Cookie: cookie }, - }); + const profileResult = await native.call(headers, (client) => + client["profile.readOwnProfile"](), + ); - expect(profileResponse.status()).toBe(200); + expect(profileResult.status).toBe(200); const expectedPersonId = requiredEnvironment(personIdEnvironment); - expect(await responseBody(profileResponse)).toMatchObject({ personId: expectedPersonId }); + + const profile = Option.getOrUndefined( + Schema.decodeUnknownOption(ProfileAnswer)(profileResult.ok ? profileResult.value : undefined), + ); + + expect(profile === undefined ? undefined : "profile" in profile ? profile.profile.personId : profile.personId).toBe(expectedPersonId); return { cookie, @@ -125,12 +147,15 @@ const observePage = ( ): void => { page.on("request", (request) => { const url = new URL(request.url()); + const tag = rpcTagOf(request.postData()); if ( - request.method() === "GET" && - ["/api/departments", "/api/teams", "/api/field-of-studies"].includes(url.pathname) + request.method() === "POST" && + isNativeRpcPath(url.pathname) && + tag !== undefined && + publicCatalogTags.has(tag) ) { - nativePublicRequests.push(`${request.method()} ${url.pathname}`); + nativePublicRequests.push(`${request.method()} ${url.pathname} ${tag}`); } const legacy = legacyOrganizationRequest(request); @@ -143,9 +168,10 @@ const observePage = ( test.describe("Native Organization administration", () => { test.skip(!REAL_NATIVE_ORGANIZATION_E2E, "run through the disposable native Organization runner"); + test.afterAll(() => native.dispose()); + test("creates native records, proves counterexamples, and renders fresh Foldkit catalogs", async ({ browser, - request, }) => { test.setTimeout(120_000); const evidencePath = requiredEnvironment("ORGANIZATION_E2E_BROWSER_EVIDENCE_PATH"); @@ -168,7 +194,6 @@ test.describe("Native Organization administration", () => { const adminSession = await authenticate( adminPage, - request, "ORGANIZATION_E2E_ADMIN_EMAIL", "ORGANIZATION_E2E_ADMIN_PASSWORD", "ORGANIZATION_E2E_ADMIN_PERSON_ID", @@ -178,18 +203,21 @@ test.describe("Native Organization administration", () => { const memberSession = await authenticate( memberPage, - request, "ORGANIZATION_E2E_MEMBER_EMAIL", "ORGANIZATION_E2E_MEMBER_PASSWORD", "ORGANIZATION_E2E_MEMBER_PERSON_ID", ); - const publicClient = createPromiseClient(API_ORIGIN); + const publicHeaders = { origin: DASHBOARD_ORIGIN }; + const adminHeaders = { cookie: adminSession.cookie, origin: DASHBOARD_ORIGIN }; + const memberHeaders = { cookie: memberSession.cookie, origin: DASHBOARD_ORIGIN }; - const adminClient = createPromiseClient(API_ORIGIN, { - cookie: adminSession.cookie, - origin: DASHBOARD_ORIGIN, - }); + /** The value of an RPC answer that the journey requires to succeed. */ + const required = (result: ScriptCallResult, operation: string): A => { + if (!result.ok) throw new Error(`${operation} failed: ${JSON.stringify(answerBody(result))}`); + + return result.value; + }; const departmentKey = IdempotencyKey.make("organization-department-create-0052"); @@ -211,20 +239,19 @@ test.describe("Native Organization administration", () => { departmentId: null, }; - const departmentResult = await adminClient.organization.createDepartment({ - headers: { "idempotency-key": departmentKey }, - payload: departmentPayload, - }); - - const departmentsAfterCreateResult = await publicClient.organization.listDepartments({ - headers: {}, - }); + const departmentResult = await native.call(adminHeaders, (client) => + client["organization.createDepartment"]({ + idempotencyKey: departmentKey, + request: departmentPayload, + }), + ); - if (departmentsAfterCreateResult.body === undefined) { - throw new Error("listDepartments returned 304 without cache validators"); - } + const createdDepartmentBody = required(departmentResult, "createDepartment"); - const departmentsAfterCreate = departmentsAfterCreateResult.body; + const departmentsAfterCreate = required( + await native.call(publicHeaders, (client) => client["organization.listDepartments"]()), + "listDepartments", + ); const createdDepartment = departmentsAfterCreate.find( (department) => department.name === departmentPayload.name, @@ -248,80 +275,72 @@ test.describe("Native Organization administration", () => { active: true, }; - await adminClient.organization.createTeam({ - headers: { "idempotency-key": teamKey }, - payload: teamPayload, - }); - await adminClient.organization.createFieldOfStudy({ - headers: { "idempotency-key": fieldKey }, - payload: fieldPayload, - }); + const teamResult = await native.call(adminHeaders, (client) => + client["organization.createTeam"]({ idempotencyKey: teamKey, request: teamPayload }), + ); - const unknownReferenceResponse = await request.post(`${API_ORIGIN}/api/teams`, { - headers: { - Cookie: adminSession.cookie, - Origin: DASHBOARD_ORIGIN, - "Content-Type": "application/json", - "Idempotency-Key": "organization-team-unknown-department-0052", - }, - data: { - ...teamPayload, - departmentId: "department-does-not-exist-0052", - }, - }); + required(teamResult, "createTeam"); + + const fieldResult = await native.call(adminHeaders, (client) => + client["organization.createFieldOfStudy"]({ idempotencyKey: fieldKey, request: fieldPayload }), + ); - expect(unknownReferenceResponse.status()).toBe(422); - const unknownReferenceBody = await responseBody(unknownReferenceResponse); + required(fieldResult, "createFieldOfStudy"); + + const unknownReferenceResponse = await native.call(adminHeaders, (client) => + client["organization.createTeam"]({ + idempotencyKey: IdempotencyKey.make("organization-team-unknown-department-0052"), + request: { + ...teamPayload, + departmentId: DepartmentId.make("department-does-not-exist-0052"), + }, + }), + ); + + expect(unknownReferenceResponse.status).toBe(422); + const unknownReferenceBody = answerBody(unknownReferenceResponse); expect(unknownReferenceBody).toMatchObject({ status: 422, code: "organization.invalid-reference", type: "urn:vektorprogrammet:problem:v0.2:organization.invalid-reference", }); - const memberDeniedResponse = await request.post(`${API_ORIGIN}/api/departments`, { - headers: { - Cookie: memberSession.cookie, - Origin: DASHBOARD_ORIGIN, - "Content-Type": "application/json", - "Idempotency-Key": "organization-member-denied-0052", - }, - data: departmentPayload, - }); + const memberDeniedResponse = await native.call(memberHeaders, (client) => + client["organization.createDepartment"]({ + idempotencyKey: IdempotencyKey.make("organization-member-denied-0052"), + request: departmentPayload, + }), + ); - expect(memberDeniedResponse.status()).toBe(403); - const memberDeniedBody = await responseBody(memberDeniedResponse); + expect(memberDeniedResponse.status).toBe(403); + const memberDeniedBody = answerBody(memberDeniedResponse); expect(memberDeniedBody).toMatchObject({ status: 403, code: "authority.denied", type: "urn:vektorprogrammet:problem:v0.2:authority.denied", }); - const exactReplayResponse = await request.post(`${API_ORIGIN}/api/departments`, { - headers: { - Cookie: adminSession.cookie, - Origin: DASHBOARD_ORIGIN, - "Content-Type": "application/json", - "Idempotency-Key": departmentKey, - }, - data: departmentPayload, - }); + const exactReplayResponse = await native.call(adminHeaders, (client) => + client["organization.createDepartment"]({ + idempotencyKey: departmentKey, + request: departmentPayload, + }), + ); - expect(exactReplayResponse.status()).toBe(201); - const exactReplayBody = await responseBody(exactReplayResponse); - expect(exactReplayBody).toEqual(departmentResult.body); - - const changedReplayResponse = await request.post(`${API_ORIGIN}/api/departments`, { - headers: { - Cookie: adminSession.cookie, - Origin: DASHBOARD_ORIGIN, - "Content-Type": "application/json", - "Idempotency-Key": departmentKey, - }, - data: { ...departmentPayload, name: "Et annet navn" }, - }); + expect(exactReplayResponse.status).toBe(200); + const exactReplayBody = answerBody(exactReplayResponse); + expect(exactReplayBody).toEqual(answerBody(departmentResult)); + expect(exactReplayResponse.ok && exactReplayResponse.value).toEqual(createdDepartmentBody); - expect(changedReplayResponse.status()).toBe(409); - const changedReplayBody = await responseBody(changedReplayResponse); + const changedReplayResponse = await native.call(adminHeaders, (client) => + client["organization.createDepartment"]({ + idempotencyKey: departmentKey, + request: { ...departmentPayload, name: "Et annet navn" }, + }), + ); + + expect(changedReplayResponse.status).toBe(409); + const changedReplayBody = answerBody(changedReplayResponse); expect(changedReplayBody).toMatchObject({ status: 409, code: "idempotency.digest-conflict", @@ -329,26 +348,14 @@ test.describe("Native Organization administration", () => { }); const [freshDepartmentsResult, freshTeamsResult, freshFieldsResult] = await Promise.all([ - publicClient.organization.listDepartments({ headers: {} }), - publicClient.organization.listTeams({ headers: {} }), - publicClient.organization.listFieldOfStudies({ headers: {} }), + native.call(publicHeaders, (client) => client["organization.listDepartments"]()), + native.call(publicHeaders, (client) => client["organization.listTeams"]()), + native.call(publicHeaders, (client) => client["organization.listFieldOfStudies"]()), ]); - if (freshDepartmentsResult.body === undefined) { - throw new Error("listDepartments returned 304 without cache validators"); - } - - if (freshTeamsResult.body === undefined) { - throw new Error("listTeams returned 304 without cache validators"); - } - - if (freshFieldsResult.body === undefined) { - throw new Error("listFieldOfStudies returned 304 without cache validators"); - } - - const freshDepartments = freshDepartmentsResult.body; - const freshTeams = freshTeamsResult.body; - const freshFields = freshFieldsResult.body; + const freshDepartments = required(freshDepartmentsResult, "listDepartments"); + const freshTeams = required(freshTeamsResult, "listTeams"); + const freshFields = required(freshFieldsResult, "listFieldOfStudies"); expect(freshDepartments).toContainEqual( expect.objectContaining({ departmentId: createdDepartment.departmentId, @@ -383,10 +390,10 @@ test.describe("Native Organization administration", () => { await teamChoice.selectOption(teamValue); await expect(teamChoice).toHaveValue(teamValue); - const teamAccessibility = await new AxeBuilder({ page: adminPage }).analyze(); + const teamAccessibility = await auditSettledPage(adminPage); - teamAccessibilityViolations = teamAccessibility.violations.length; - expect(teamAccessibility.violations).toEqual([]); + teamAccessibilityViolations = teamAccessibility.length; + expect(teamAccessibility).toEqual([]); const fieldPage = await adminContext.newPage(); observePage(fieldPage, nativePublicRequests, legacyBrowserRequests, pageErrors); @@ -398,12 +405,12 @@ test.describe("Native Organization administration", () => { await expect(fieldTable.getByRole("rowheader", { name: fieldPayload.name })).toBeVisible(); - const fieldAccessibility = await new AxeBuilder({ page: fieldPage }) - .include('section[aria-labelledby="organization-catalog-title"]') - .analyze(); + const fieldAccessibility = await auditSettledPage(fieldPage, { + include: ['section[aria-labelledby="organization-catalog-title"]'], + }); - fieldAccessibilityViolations = fieldAccessibility.violations.length; - expect(fieldAccessibility.violations).toEqual([]); + fieldAccessibilityViolations = fieldAccessibility.length; + expect(fieldAccessibility).toEqual([]); expect(legacyBrowserRequests).toEqual([]); expect(pageErrors).toEqual([]); @@ -416,35 +423,35 @@ test.describe("Native Organization administration", () => { administrator: { nativeLogin: true, sessionCookieNames: adminSession.sessionCookieNames, - apiSessionPath: "/api/session", - personBindingPath: "/api/profile", + apiSessionRpc: "system.readSession", + personBindingRpc: "profile.readOwnProfile", personId: adminSession.sessionPersonId, }, member: { nativeLogin: true, sessionCookieNames: memberSession.sessionCookieNames, - apiSessionPath: "/api/session", - personBindingPath: "/api/profile", + apiSessionRpc: "system.readSession", + personBindingRpc: "profile.readOwnProfile", personId: memberSession.sessionPersonId, }, }, acceptedCreates: { - department: { idempotencyKey: departmentKey, status: 201 }, - team: { idempotencyKey: teamKey, status: 201 }, - fieldOfStudy: { idempotencyKey: fieldKey, status: 201 }, + department: { idempotencyKey: departmentKey, status: departmentResult.status }, + team: { idempotencyKey: teamKey, status: teamResult.status }, + fieldOfStudy: { idempotencyKey: fieldKey, status: fieldResult.status }, }, counterexamples: { unknownDepartment: { - status: unknownReferenceResponse.status(), + status: unknownReferenceResponse.status, response: unknownReferenceBody, }, memberDenied: { - status: memberDeniedResponse.status(), + status: memberDeniedResponse.status, response: memberDeniedBody, }, - exactReplay: { status: exactReplayResponse.status(), response: exactReplayBody }, + exactReplay: { status: exactReplayResponse.status, response: exactReplayBody }, changedReplay: { - status: changedReplayResponse.status(), + status: changedReplayResponse.status, response: changedReplayBody, }, }, diff --git a/apps/dashboard/e2e/native-placement.spec.ts b/apps/dashboard/e2e/native-placement.spec.ts index 58a0688f3..eefbe62c0 100644 --- a/apps/dashboard/e2e/native-placement.spec.ts +++ b/apps/dashboard/e2e/native-placement.spec.ts @@ -1,14 +1,18 @@ -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.js"; import type { AdmissionOutcomeCommand } from "@vektorprogrammet/domain/admissions"; -import type { +import { CoverageCommand, OwnCoverageCommand, PlacementCommand, + PlacementScope, } from "@vektorprogrammet/domain/placements"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, test, type BrowserContext, type Page, type Locator } from "@playwright/test"; -import { Order } from "effect"; +import { type NativeRpcClient, PublicApplicationIdSchema } from "@vektorprogrammet/rpc"; +import { IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; +import { type Effect, Match, Order, Schema } from "effect"; const manifestPath = process.env.PLACEMENT_JOURNEY_MANIFEST; @@ -41,7 +45,7 @@ const selectScope = async (page: Page) => { }; const axe = async (page: Page, state: string) => { - const result = await new AxeBuilder({ page }).analyze(); + const violations = await auditSettledPage(page); const landmarks = await page.locator("form[aria-label]").evaluateAll((forms) => forms.map((form) => ({ @@ -50,14 +54,7 @@ const axe = async (page: Page, state: string) => { })), ); - expect( - result.violations.map(({ id, impact, nodes }) => ({ - id, - impact, - nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })), - })), - `${state}; form landmarks: ${JSON.stringify(landmarks)}`, - ).toEqual([]); + expect(violations, `${state}; form landmarks: ${JSON.stringify(landmarks)}`).toEqual([]); }; const fillPlacement = async (form: Locator, block: string, day = "Monday", workdays = "4") => { @@ -78,57 +75,71 @@ const submittedAndRemoved = async (form: Locator) => { await expect(form).toHaveCount(0); }; -const readBoard = async (page: Page) => { - const response = await page.request.get( - `${manifest.backendOrigin}/api/placements?${new URLSearchParams({ departmentId: manifest.departmentId, semesterId: manifest.semesterId })}`, - { headers: { origin: manifest.dashboardOrigin } }, - ); +let outcomeClient: ReturnType | undefined; - expect(response.status()).toBe(200); +/** The native RPC client of the admission-outcome calls, made once the manifest names the backend. */ +const outcomes = () => { + outcomeClient ??= nativeScriptClient(manifest.backendOrigin); - return response.json(); + return outcomeClient; }; -const readCoverageBoard = async (page: Page) => { - const response = await page.request.get( - `${manifest.backendOrigin}/api/placements/coverage?${new URLSearchParams({ - departmentId: manifest.departmentId, - semesterId: manifest.semesterId, - })}`, - { headers: { origin: manifest.dashboardOrigin } }, - ); +/** The session of one browser context, sent from the dashboard origin as its server sends it. */ +const sessionOf = async (page: Page) => ({ + cookie: (await page.context().cookies(manifest.dashboardOrigin)) + .map(({ name, value }) => `${name}=${value}`) + .join("; "), + origin: manifest.dashboardOrigin, +}); + +/** The department and semester of the journey, as the placement RPCs take them. */ +const placementScope = () => + Schema.decodeSync(PlacementScope)({ + departmentId: manifest.departmentId, + semesterId: manifest.semesterId, + }); - expect(response.status()).toBe(200); +/** + * Answers one placement read as the person of `page`; a refusal fails the journey. The journey + * reads the answer as the untyped JSON body it read over HTTP. + */ +const readAs = async ( + page: Page, + read: (client: NativeRpcClient["Service"]) => Effect.Effect, +): Promise => { + const answer = await outcomes().call(await sessionOf(page), read); - return response.json(); + if (!answer.ok) throw new Error(`The placement read answered ${answer.code}`); + + return answer.value; }; -const readOwnCoverage = async (page: Page) => { - const response = await page.request.get( - `${manifest.backendOrigin}/api/placements/coverage/own?${new URLSearchParams({ - departmentId: manifest.departmentId, - semesterId: manifest.semesterId, - })}`, - { headers: { origin: manifest.dashboardOrigin } }, - ); +const readBoard = (page: Page) => + readAs(page, (client) => client["placements.readBoard"](placementScope())); - expect(response.status()).toBe(200); +const readCoverageBoard = (page: Page) => + readAs(page, (client) => client["placements.readCoverageBoard"](placementScope())); - return response.json(); -}; +const readOwnCoverage = (page: Page) => + readAs(page, (client) => client["placements.readOwnCoverage"](placementScope())); /** Admission management reads one application's outcome entry with its version. */ const readOutcome = async (page: Page, applicationId: string) => { - const response = await page.request.get( - `${manifest.backendOrigin}/api/admission-outcomes/${applicationId}`, - { headers: { origin: manifest.dashboardOrigin } }, + const answer = await outcomes().call(await sessionOf(page), (client) => + client["admissionOutcomes.readOutcome"]({ + applicationId: PublicApplicationIdSchema.make(applicationId), + }), ); - expect(response.status()).toBe(200); + if (!answer.ok) throw new Error(`Reading the outcome answered ${answer.code}`); - return response.json(); + return answer.value; }; +test.afterAll(async () => { + await outcomeClient?.dispose(); +}); + test("0096 placement, 0110 school-service, and 0111 coverage journeys persist with explicit authority", async ({ browser, }) => { @@ -989,20 +1000,80 @@ test("golden school-service continuous functional journey", async ({ browser }) expected: number, expectedCode?: string, ) => { - const response = await actor.request.post(`${manifest.backendOrigin}${path}`, { - headers: { - origin: manifest.dashboardOrigin, - "if-match": etag, - "idempotency-key": crypto.randomUUID(), - }, - data: payload, - }); + // Each path names the placement command RPC that replaced its HTTP route. + const target = new URL(path, manifest.backendOrigin).pathname; + + expect([ + "/api/placements", + "/api/placements/coverage", + "/api/placements/coverage/own", + ]).toContain(target); + + const keys = { + ...placementScope(), + idempotencyKey: IdempotencyKey.make(crypto.randomUUID()), + ifMatch: StrongETag.make(etag), + }; + + const session = await sessionOf(actor); + + const answer = await Match.value(target).pipe( + Match.when("/api/placements", () => + outcomes().call(session, (client) => + client["placements.commandBoard"]({ + ...keys, + request: Schema.decodeUnknownSync(PlacementCommand)(payload), + }), + ), + ), + Match.when("/api/placements/coverage", () => + outcomes().call(session, (client) => + client["placements.commandCoverageBoard"]({ + ...keys, + request: Schema.decodeUnknownSync(CoverageCommand)(payload), + }), + ), + ), + Match.orElse(() => + outcomes().call(session, (client) => + client["placements.commandOwnCoverage"]({ + ...keys, + request: Schema.decodeUnknownSync(OwnCoverageCommand)(payload), + }), + ), + ), + ); - const problem = await response.json(); - expect(response.status(), JSON.stringify(problem)).toBe(expected); + expect(answer.status, JSON.stringify(answer)).toBe(expected); - if (expectedCode !== undefined) expect(problem.code).toBe(expectedCode); - http.push({ check, status: response.status(), boundary: "authenticated-http" }); + if (expectedCode !== undefined) expect(answer.ok ? undefined : answer.code).toBe(expectedCode); + http.push({ check, status: answer.status, boundary: "authenticated-rpc" }); + }; + + /** An admission-outcome record the backend refuses, with its registry status and code. */ + const rejectedOutcome = async ( + actor: Page, + etag: string, + payload: AdmissionOutcomeCommand, + check: string, + expected: number, + expectedCode: string, + ) => { + const answer = await outcomes().call(await sessionOf(actor), (client) => + client["admissionOutcomes.recordOutcome"]({ + applicationId: PublicApplicationIdSchema.make(manifest.applicationId), + idempotencyKey: IdempotencyKey.make(crypto.randomUUID()), + ifMatch: StrongETag.make(etag), + request: payload, + }), + ); + + expect(answer, JSON.stringify(answer)).toMatchObject({ + ok: false, + status: expected, + code: expectedCode, + }); + http.push({ check, status: answer.status, boundary: "authenticated-rpc" }); }; let passed = false; @@ -1348,9 +1419,8 @@ test("golden school-service continuous functional journey", async ({ browser }) ).toHaveText(onCall); await checkpoint("outcome-recorded"); - await rejected( + await rejectedOutcome( page, - `/api/admission-outcomes/${manifest.applicationId}:record`, unrecordedOutcome.etag, { outcome: "Rejected" }, "a stale admission outcome version cannot overwrite", @@ -1378,9 +1448,8 @@ test("golden school-service continuous functional journey", async ({ browser }) 0, ); await expect(member.getByRole("form", { name: /^Opptaksutfall:/ })).toHaveCount(0); - await rejected( + await rejectedOutcome( member, - `/api/admission-outcomes/${manifest.applicationId}:record`, (await readOutcome(page, manifest.applicationId)).etag, { outcome: "Rejected" }, "a department member records no admission outcome", diff --git a/apps/dashboard/e2e/native-profile-self-edit.spec.ts b/apps/dashboard/e2e/native-profile-self-edit.spec.ts index 93d641fbc..1f5d8c7d1 100644 --- a/apps/dashboard/e2e/native-profile-self-edit.spec.ts +++ b/apps/dashboard/e2e/native-profile-self-edit.spec.ts @@ -1,5 +1,7 @@ -import { Schema, Predicate } from "effect"; -import AxeBuilder from "@axe-core/playwright"; +import { IdempotencyKey, type NativeRpcClient, StrongETag } from "@vektorprogrammet/rpc"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; +import { type Effect, Predicate, Schema } from "effect"; +import { auditSettledPage } from "./settled-axe.js"; import { writeFile } from "node:fs/promises"; import { expect, test, type Browser, type BrowserContext, type Page } from "@playwright/test"; import { addressesAnyRoute, legacyRoutes } from "./request-routes.js"; @@ -119,9 +121,9 @@ const profileValues = (page: Page) => ]); const assertAxe = async (page: Page, results: Record, state: string) => { - const accessibility = await new AxeBuilder({ page }).analyze(); + const accessibility = await auditSettledPage(page); - const blockingViolations = accessibility.violations.filter( + const blockingViolations = accessibility.filter( (violation) => violation.impact === "serious" || violation.impact === "critical", ); @@ -129,12 +131,45 @@ const assertAxe = async (page: Page, results: Record, state: str results[state] = blockingViolations.length; }; +const nativeScript = nativeScriptClient(apiOrigin); + +type NativeClient = NativeRpcClient["Service"]; + +/** Calls one RPC as `context`, whose Better Auth cookie is set, or anonymously without one. */ +const callAs = async ( + context: BrowserContext | undefined, + rpc: (client: NativeClient) => Effect.Effect, +) => { + const cookies = context === undefined ? [] : await context.cookies(apiOrigin); + const cookie = cookies.map(({ name, value }) => `${name}=${value}`).join("; "); + + return nativeScript.call( + cookie === "" ? { origin: dashboardOrigin } : { cookie, origin: dashboardOrigin }, + rpc, + ); +}; + +const readOwnProfile = (client: NativeClient) => client["profile.readOwnProfile"](); + +type ProfilePatch = Parameters[0]["request"]; + +const updateOwnProfile = + (idempotencyKey: string, ifMatch: StrongETag, request: ProfilePatch) => (client: NativeClient) => + client["profile.updateOwnProfile"]({ + idempotencyKey: IdempotencyKey.make(idempotencyKey), + ifMatch, + request, + }); + +const anyTag = StrongETag.make('"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"'); + +test.afterAll(() => nativeScript.dispose()); + test.describe("Native Profile self-edit (spec 0064)", () => { test.skip(!realRun, "run through the disposable PostgreSQL Profile runner"); test("proves one authenticated edit, stale conflict, strict HTTP, replay, and confinement", async ({ browser, - request, }) => { test.setTimeout(240_000); const requests: LedgerEntry[] = []; @@ -146,28 +181,15 @@ test.describe("Native Profile self-edit (spec 0064)", () => { let context: BrowserContext | undefined; try { - const unauthenticatedGet = await request.get(`${apiOrigin}/api/profile`); - - const unauthenticatedPatch = await request.patch(`${apiOrigin}/api/profile`, { - headers: { - "content-type": "application/merge-patch+json", - "idempotency-key": "profile-unauthenticated-0064", - "if-match": '"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"', - Origin: dashboardOrigin, - }, - data: {}, - }); + const unauthenticatedGet = await callAs(undefined, readOwnProfile); - expect(unauthenticatedGet.status()).toBe(401); - expect(await unauthenticatedGet.json()).toMatchObject({ - status: 401, - code: "credential.missing", - }); - expect(unauthenticatedPatch.status()).toBe(401); - expect(await unauthenticatedPatch.json()).toMatchObject({ - status: 401, - code: "credential.missing", - }); + const unauthenticatedPatch = await callAs( + undefined, + updateOwnProfile("profile-unauthenticated-0064", anyTag, {}), + ); + + expect(unauthenticatedGet).toMatchObject({ status: 401, code: "credential.missing" }); + expect(unauthenticatedPatch).toMatchObject({ status: 401, code: "credential.missing" }); observations.unauthenticated = { get: 401, patch: 401, problemDetails: true }; const opened = await openContext(browser, requests, responses); @@ -259,33 +281,27 @@ test.describe("Native Profile self-edit (spec 0064)", () => { await assertAxe(page, accessibility, "success"); observations.browserCommit = { values: after, freshRead: true, statusSemantics: true }; - const afterRead = await context.request.get(`${apiOrigin}/api/profile`); - expect(afterRead.status()).toBe(200); - const afterEtag = afterRead.headers().etag; + const afterRead = await callAs(context, readOwnProfile); + + if (!afterRead.ok) throw new Error(`profile.readOwnProfile answered ${afterRead.code}`); + + const afterEtag = afterRead.value.etag; expect(afterEtag).toMatch(/^"vkr2\./u); - const malformed = await context.request.patch(`${apiOrigin}/api/profile`, { - headers: { - "content-type": "application/merge-patch+json", - "idempotency-key": "profile-malformed-0064", - "if-match": afterEtag, - Origin: dashboardOrigin, - }, - data: { - firstName: after.firstName, - lastName: after.lastName, - email: after.email, - phone: after.phone, - role: "ROLE_TEAM_MEMBER", - }, - }); + // The RPC payload schema admits only profile fields, so a patch that the contract cannot + // apply is one that changes nothing or deletes a field. + const malformed = await callAs( + context, + updateOwnProfile("profile-malformed-0064", afterEtag, {}), + ); - expect(malformed.status()).toBe(422); - expect(await malformed.json()).toMatchObject({ - status: 422, - code: "validation.failed", - type: "urn:vektorprogrammet:problem:v0.2:validation.failed", - }); + const deletion = await callAs( + context, + updateOwnProfile("profile-deletion-0064-", afterEtag, { phone: null }), + ); + + expect(malformed).toMatchObject({ status: 422, code: "validation.no-change" }); + expect(deletion).toMatchObject({ status: 422, code: "validation.field-not-deletable" }); observations.malformed = { status: 422, problemDetails: true, mutation: false }; const controlledPatch = { @@ -295,21 +311,17 @@ test.describe("Native Profile self-edit (spec 0064)", () => { phone: "+47 9000 0003", }; - const controlled = await context.request.patch(`${apiOrigin}/api/profile`, { - headers: { - "content-type": "application/merge-patch+json", - "idempotency-key": "profile-controlled-0064", - "if-match": afterEtag, - Origin: dashboardOrigin, - }, - data: controlledPatch, - }); + const controlled = await callAs( + context, + updateOwnProfile("profile-controlled-0064", afterEtag, controlledPatch), + ); + + if (!controlled.ok) throw new Error(`profile.updateOwnProfile answered ${controlled.code}`); - expect(controlled.status()).toBe(200); - const controlledBody = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json))((await controlled.json())); + const controlledBody = controlled.value.profile; expect(controlledBody.nameRevision).toBe(2); expect(controlledBody.contactRevision).toBe(2); - const controlledEtag = controlled.headers().etag; + const controlledEtag = controlled.value.etag; expect(controlledEtag).toMatch(/^"vkr2\./u); observations.controlledConcurrentWrite = { status: 200, revisions: [2, 2] }; @@ -329,8 +341,7 @@ test.describe("Native Profile self-edit (spec 0064)", () => { controlledPatch.email, controlledPatch.phone, ]); - const postConflictValues = { ...controlledBody }; - delete postConflictValues.role; + const { role: _role, ...postConflictValues } = controlledBody; observations.postConflictReload = { values: postConflictValues, revisions: [2, 2], @@ -343,36 +354,35 @@ test.describe("Native Profile self-edit (spec 0064)", () => { phone: "+47 9000 0004", }; - const httpHeaders = { - "content-type": "application/merge-patch+json", - "idempotency-key": "profile-http-conflict-0064", - "if-match": controlledEtag, - Origin: dashboardOrigin, - }; - - const httpWinner = await context.request.patch(`${apiOrigin}/api/profile`, { - headers: httpHeaders, - data: httpPatch, - }); + const httpWinner = await callAs( + context, + updateOwnProfile("profile-http-conflict-0064", controlledEtag, httpPatch), + ); - expect(httpWinner.status()).toBe(200); + expect(httpWinner.status).toBe(200); - const httpConflictChanged = await context.request.patch(`${apiOrigin}/api/profile`, { - headers: httpHeaders, - data: { ...httpPatch, firstName: "Ada HTTP Different" }, - }); + const httpConflictChanged = await callAs( + context, + updateOwnProfile("profile-http-conflict-0064", controlledEtag, { + ...httpPatch, + firstName: "Ada HTTP Different", + }), + ); - expect(httpConflictChanged.status()).toBe(409); - expect(await httpConflictChanged.json()).toMatchObject({ + expect(httpConflictChanged).toMatchObject({ status: 409, code: "idempotency.digest-conflict", - type: "urn:vektorprogrammet:problem:v0.2:idempotency.digest-conflict", + problem: { type: "urn:vektorprogrammet:problem:v0.2:idempotency.digest-conflict" }, }); observations.sameIdConflict = { status: 409, problemDetails: true, dataUnchanged: true }; - const authenticatedGet = await context.request.get(`${apiOrigin}/api/profile`); - expect(authenticatedGet.status()).toBe(200); - const authenticatedBody = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json))((await authenticatedGet.json())); + const authenticatedGet = await callAs(context, readOwnProfile); + + if (!authenticatedGet.ok) { + throw new Error(`profile.readOwnProfile answered ${authenticatedGet.code}`); + } + + const authenticatedBody = authenticatedGet.value.profile; expect(Object.keys(authenticatedBody).sort()).toEqual([ "contactRevision", "email", @@ -389,7 +399,7 @@ test.describe("Native Profile self-edit (spec 0064)", () => { get: 200, patch: 200, responseFields: Object.keys(authenticatedBody).sort(), - sdkRoutes: ["/api/profile"], + rpcTags: ["profile.readOwnProfile", "profile.updateOwnProfile"], }; await inputs[0].focus(); diff --git a/apps/dashboard/e2e/native-recruitment-assignment.spec.ts b/apps/dashboard/e2e/native-recruitment-assignment.spec.ts index 3b4e04844..8080b7407 100644 --- a/apps/dashboard/e2e/native-recruitment-assignment.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-assignment.spec.ts @@ -1,5 +1,5 @@ import { Predicate } from "effect"; -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.js"; import { expect, test, type Page } from "@playwright/test"; const DASHBOARD_ORIGIN = process.env.DASHBOARD_ORIGIN ?? "http://127.0.0.1:5174"; @@ -88,10 +88,10 @@ test.describe("Native recruitment applicant assignment", () => { "readAssignmentBoard", ]); - const accessibility = await new AxeBuilder({ page }) - .include('section[aria-labelledby="fr-page-title"]') - .analyze(); + const accessibility = await auditSettledPage(page, { + include: ['section[aria-labelledby="fr-page-title"]'], + }); - expect(accessibility.violations).toEqual([]); + expect(accessibility).toEqual([]); }); }); diff --git a/apps/dashboard/e2e/native-recruitment-first-placement.spec.ts b/apps/dashboard/e2e/native-recruitment-first-placement.spec.ts index 914b72ed0..cd14c4f1b 100644 --- a/apps/dashboard/e2e/native-recruitment-first-placement.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-first-placement.spec.ts @@ -1,8 +1,14 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, test, type Page, type Locator, type BrowserContext } from "@playwright/test"; -import AxeBuilder from "@axe-core/playwright"; -import { Predicate } from "effect"; +import { auditSettledPage } from "./settled-axe.js"; +import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; +import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; +import { PlacementScope } from "@vektorprogrammet/domain/placements"; +import { RecruitmentInterviewId } from "@vektorprogrammet/domain/recruitment"; +import { IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; +import { Predicate, Schema } from "effect"; const manifestPath = process.env.PLACEMENT_JOURNEY_MANIFEST; @@ -238,33 +244,47 @@ test("continuous recruitment to first placement", async ({ browser }) => { // Claim URLs are bearer capabilities. Do not give the other applicant this capability. // Their own session cannot read the staff assessment or issue an invitation. - const privateRead = await other.request.get( - m.backendOrigin + "/api/recruitment/interviews/" + interviewId, + const native = nativeScriptClient(m.backendOrigin); + + const sessionOf = async (page: Page) => ({ + cookie: (await page.context().cookies(m.backendOrigin)) + .map(({ name, value }) => `${name}=${value}`) + .join("; "), + origin: m.dashboardOrigin, + }); + + const privateRead = await native.call(await sessionOf(other), (client) => + client["recruitment.readInterviewConduct"]({ + interviewId: RecruitmentInterviewId.make(interviewId), + }), ); - expect([403, 404]).toContain(privateRead.status()); - expect(await privateRead.text()).not.toMatch(/Jeg vil hjelpe|explanatoryPower|ada@example/); + expect([403, 404]).toContain(privateRead.status); + expect(JSON.stringify(privateRead)).not.toMatch(/Jeg vil hjelpe|explanatoryPower|ada@example/); - const leaderBoard = await staff.request.get( - m.backendOrigin + "/api/onboarding?departmentId=" + m.departmentId, + const leaderBoard = await native.call(await sessionOf(staff), (client) => + client["onboarding.readBoard"]({ departmentId: DepartmentId.make(m.departmentId) }), ); - expect(leaderBoard.status()).toBe(200); + expect(leaderBoard.status).toBe(200); - const denied = await other.request.post( - m.backendOrigin + "/api/onboarding?departmentId=" + m.departmentId, - { - headers: { - origin: m.dashboardOrigin, - "if-match": (await leaderBoard.json()).etag, - "idempotency-key": "other-applicant-denied", + if (!leaderBoard.ok) throw new Error(`leader onboarding board: ${leaderBoard.code}`); + + const denied = await native.call(await sessionOf(other), (client) => + client["onboarding.command"]({ + departmentId: DepartmentId.make(m.departmentId), + idempotencyKey: IdempotencyKey.make("other-applicant-denied"), + ifMatch: leaderBoard.value.etag, + request: { + applicationId: PublicApplicationIdSchema.make(mainApplication.application_id), + action: "Issue", }, - data: { applicationId: mainApplication.application_id, action: "Issue" }, - }, + }), ); - expect(denied.status()).toBe(403); - expect((await denied.json()).code).toBe("authority.denied"); + await native.dispose(); + expect(denied.status).toBe(403); + expect(denied.ok ? "success" : denied.code).toBe("authority.denied"); await checkpoint("other-applicant-denied"); await applicant.goto(mainClaim); @@ -308,22 +328,33 @@ test("continuous recruitment to first placement", async ({ browser }) => { await signIn(wrong, m.persons.wrongDepartment, "/dashboard"); - const wrongResult = await wrong.request.post( - m.backendOrigin + - "/api/placements?" + - new URLSearchParams({ departmentId: m.departmentId, semesterId: m.semesterId }), - { - headers: { - origin: m.dashboardOrigin, - "if-match": fields.etag, - "idempotency-key": crypto.randomUUID(), - }, - data: { action: "Affiliation", personId: fields.personId, transition: "Establish" }, - }, + // The out-of-scope board command goes to the placement RPC that replaced its HTTP route. + const placementClient = nativeScriptClient(m.backendOrigin); + + const wrongCookie = (await wrong.context().cookies(m.dashboardOrigin)) + .map(({ name, value }) => `${name}=${value}`) + .join("; "); + + const wrongResult = await placementClient.call( + { cookie: wrongCookie, origin: m.dashboardOrigin }, + (client) => + client["placements.commandBoard"]({ + ...Schema.decodeSync(PlacementScope)({ + departmentId: m.departmentId, + semesterId: m.semesterId, + }), + idempotencyKey: IdempotencyKey.make(crypto.randomUUID()), + ifMatch: StrongETag.make(fields.etag ?? ""), + request: { + action: "Affiliation", + personId: PersonId.make(fields.personId ?? ""), + transition: "Establish", + }, + }), ); - expect(wrongResult.status()).toBe(403); - expect((await wrongResult.json()).code).toBe("authority.denied"); + await placementClient.dispose(); + expect(wrongResult).toMatchObject({ ok: false, status: 403, code: "authority.denied" }); await checkpoint("wrong-scope-denied"); await card(staff, "Ada Rekrutt").getByRole("button", { name: "Godkjenn tilknytning" }).click(); await saved(card(staff, "Ada Rekrutt").locator("form")); @@ -358,7 +389,7 @@ test("continuous recruitment to first placement", async ({ browser }) => { await other.goto(m.dashboardOrigin + placements); await expect(other.locator("[data-own-placement-id]")).toHaveCount(0); expect(await other.locator("body").innerText()).not.toContain("Ada Rekrutt"); - expect((await new AxeBuilder({ page: volunteer }).analyze()).violations).toEqual([]); + expect(await auditSettledPage(volunteer)).toEqual([]); await volunteer.screenshot({ path: join(m.artifacts, "recruitment-first-placement.png"), fullPage: true, diff --git a/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts b/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts index b52c4ebdc..c65c77cd6 100644 --- a/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts @@ -1,7 +1,9 @@ -import { CancelInterviewResponse, ConductObservation } from "@vektorprogrammet/http-api"; +import { CancelInterviewResponse } from "@vektorprogrammet/rpc"; import { RecruitmentBridgeFailure } from "../app/foldkit/recruitment/bridge"; +import { ApplicantProgressResponseSchema } from "@vektorprogrammet/rpc"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { Schema, Predicate } from "effect"; -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.js"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { expect, test, type BrowserContext, type Page, type Request } from "@playwright/test"; @@ -252,11 +254,11 @@ test.describe("Native recruitment interview conduct (spec 0063)", () => { "Jeg liker å bygge gode løsninger sammen med andre.", ); - const detailAxe = await new AxeBuilder({ page }) - .include('section[aria-labelledby="fs-page-title"]') - .analyze(); + const detailAxe = await auditSettledPage(page, { + include: ['section[aria-labelledby="fs-page-title"]'], + }); - accessibilityViolations += detailAxe.violations.length; + accessibilityViolations += detailAxe.length; // A real reload starts from the native session and reads the persisted terminal detail again. await page.reload(); @@ -282,11 +284,11 @@ test.describe("Native recruitment interview conduct (spec 0063)", () => { await page.evaluate("document.documentElement.scrollWidth <= window.innerWidth"), ).toBe(true); - const mobileAxe = await new AxeBuilder({ page }) - .include('section[aria-labelledby="fs-page-title"]') - .analyze(); + const mobileAxe = await auditSettledPage(page, { + include: ['section[aria-labelledby="fs-page-title"]'], + }); - accessibilityViolations += mobileAxe.violations.length; + accessibilityViolations += mobileAxe.length; await page.screenshot({ path: join(screenshotDirectory, "interview-completion-mobile.png"), fullPage: true, @@ -302,16 +304,26 @@ test.describe("Native recruitment interview conduct (spec 0063)", () => { const applicantPage = await applicantContext.newPage(); await signIn(applicantPage, applicantEmail, applicantPassword); - const applicantProgress = await applicantContext.request.get( - `${apiOrigin}/api/applicant-progress`, - { headers: { origin: dashboardOrigin } }, + // The applicant reads the progress RPC with the session that the dashboard sign-in set. + const applicantCookie = (await applicantContext.cookies(dashboardOrigin)) + .map(({ name, value }) => `${name}=${value}`) + .join("; "); + + const progressClient = nativeScriptClient(apiOrigin); + + const applicantProgress = await progressClient.call( + { cookie: applicantCookie, origin: dashboardOrigin }, + (client) => client["admissions.readApplicantProgress"](), ); + await progressClient.dispose(); await applicantPage.goto("/dashboard/soknad"); - expect(applicantProgress.status()).toBe(200); + expect(applicantProgress.status).toBe(200); + + if (!applicantProgress.ok) throw new Error(`applicant progress: ${applicantProgress.code}`); - const applicantProgressBody = Schema.decodeUnknownSync(Schema.Json)( - await applicantProgress.json(), + const applicantProgressBody = Schema.decodeSync(Schema.Json)( + Schema.encodeSync(ApplicantProgressResponseSchema)(applicantProgress.value), ); assertNoApplicantPrivateFields(applicantProgressBody); @@ -331,8 +343,8 @@ test.describe("Native recruitment interview conduct (spec 0063)", () => { await expect( applicantPage.getByRole("heading", { name: "Intervjuet er fullført", exact: true }), ).toBeVisible(); - const applicantAxe = await new AxeBuilder({ page: applicantPage }).analyze(); - accessibilityViolations += applicantAxe.violations.length; + const applicantAxe = await auditSettledPage(applicantPage); + accessibilityViolations += applicantAxe.length; applicantProgressObserved = true; // The independent revision-1 submit loses to the committed finalization. @@ -356,11 +368,11 @@ test.describe("Native recruitment interview conduct (spec 0063)", () => { await expect(stalePage.locator(".fs-conduct")).toHaveCount(1); await expect(stalePage.locator("#interviewer-recommendation")).toHaveValue("Ja"); - const pageAxe = await new AxeBuilder({ page }) - .include('section[aria-labelledby="fs-page-title"]') - .analyze(); + const pageAxe = await auditSettledPage(page, { + include: ['section[aria-labelledby="fs-page-title"]'], + }); - accessibilityViolations += pageAxe.violations.length; + accessibilityViolations += pageAxe.length; } finally { await staleContext?.close(); await applicantContext?.close(); @@ -421,17 +433,27 @@ test.describe("Native recruitment interview conduct (spec 0063)", () => { ).toBeVisible(); await expect(cardFor(independentPage, applicantB)).toHaveCount(0); - const retainedConductResponse = await independentContext.request.get( - `${apiOrigin}/api/recruitment/interviews/${cancellation.interviewId}`, - { headers: { origin: dashboardOrigin } }, - ); + // The leader reads the conduct RPC with the session that the independent context holds. + const independentCookie = (await independentContext.cookies(dashboardOrigin)) + .map(({ name, value }) => `${name}=${value}`) + .join("; "); - expect(retainedConductResponse.status()).toBe(200); + const conductClient = nativeScriptClient(apiOrigin); - const retainedConduct = Schema.decodeUnknownSync(ConductObservation)( - await retainedConductResponse.json(), + const retainedConductResponse = await conductClient.call( + { cookie: independentCookie, origin: dashboardOrigin }, + (client) => + client["recruitment.readInterviewConduct"]({ interviewId: cancellation.interviewId }), ); + await conductClient.dispose(); + expect(retainedConductResponse.status).toBe(200); + + if (!retainedConductResponse.ok) + throw new Error(`retained conduct: ${retainedConductResponse.code}`); + + const retainedConduct = retainedConductResponse.value.detail; + expect(retainedConduct.interviewId).toBe(cancellation.interviewId); expect(retainedConduct.cancellationState).toBe("Cancelled"); expect(retainedConduct.cancelledAt).toBe(cancellation.cancelledAt); @@ -444,11 +466,11 @@ test.describe("Native recruitment interview conduct (spec 0063)", () => { ); await expect(independentPage.locator("body")).not.toContainText("90000064"); - const independentAxe = await new AxeBuilder({ page: independentPage }) - .include('section[aria-labelledby="fs-page-title"]') - .analyze(); + const independentAxe = await auditSettledPage(independentPage, { + include: ['section[aria-labelledby="fs-page-title"]'], + }); - accessibilityViolations += independentAxe.violations.length; + accessibilityViolations += independentAxe.length; } finally { await independentContext.close(); } diff --git a/apps/dashboard/e2e/native-recruitment-interview-scheduling.spec.ts b/apps/dashboard/e2e/native-recruitment-interview-scheduling.spec.ts index b8ddfccb2..043be0858 100644 --- a/apps/dashboard/e2e/native-recruitment-interview-scheduling.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-interview-scheduling.spec.ts @@ -1,5 +1,5 @@ import { Predicate } from "effect"; -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.js"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { expect, test, type Page, type Request } from "@playwright/test"; @@ -201,11 +201,11 @@ test.describe("Native recruitment interview scheduling", () => { expect(legacyBrowserRequests).toEqual([]); expect(await page.locator("body").innerText()).not.toContain("responseCapability"); - const accessibility = await new AxeBuilder({ page }) - .include('section[aria-labelledby="fs-page-title"]') - .analyze(); + const accessibility = await auditSettledPage(page, { + include: ['section[aria-labelledby="fs-page-title"]'], + }); - expect(accessibility.violations).toEqual([]); + expect(accessibility).toEqual([]); } finally { await firstContext.close(); firstContextClosed = true; diff --git a/apps/dashboard/e2e/native-recruitment-session-journey.spec.ts b/apps/dashboard/e2e/native-recruitment-session-journey.spec.ts index d2af0df17..fe01a1261 100644 --- a/apps/dashboard/e2e/native-recruitment-session-journey.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-session-journey.spec.ts @@ -1,8 +1,10 @@ import { Predicate } from "effect"; -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.js"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { expect, test, type Request } from "@playwright/test"; +import { nativeRpcPath } from "@vektorprogrammet/rpc"; +import { nativeRpcRequestBody, nativeRpcStatus, nativeRpcValue } from "./native-operations.js"; const dashboardOrigin = process.env.DASHBOARD_ORIGIN ?? "http://127.0.0.1:5194"; @@ -138,12 +140,22 @@ test.describe("Native recruitment assignment journey (spec 0049.3)", () => { expect(sessionCookies).toHaveLength(1); expect(sessionCookies[0]?.name).toBe("better-auth.session_token"); expect(sessionCookies[0]?.value ?? "").not.toBe(""); - const sessionResponse = await page.context().request.get(`${apiOrigin}/api/session`); - expect(sessionResponse.status()).toBe(200); - expect(await sessionResponse.json()).toMatchObject({ current: true }); - const profileResponse = await page.context().request.get(`${apiOrigin}/api/profile`); - expect(profileResponse.status()).toBe(200); - expect(await profileResponse.json()).toMatchObject({ personId: expectedLeaderPersonId }); + + // One RPC as the browser context sends it, with its cookie and the dashboard origin. + const readNative = async (tag: string) => + ( + await page.context().request.post(`${apiOrigin}${nativeRpcPath}`, { + headers: { Origin: dashboardOrigin, "content-type": "application/json" }, + data: nativeRpcRequestBody(tag), + }) + ).text(); + + const session = await readNative("system.readSession"); + expect(nativeRpcStatus(session)).toBe(200); + expect(nativeRpcValue(session)).toMatchObject({ current: true }); + const profile = await readNative("profile.readOwnProfile"); + expect(nativeRpcStatus(profile)).toBe(200); + expect(nativeRpcValue(profile)).toMatchObject({ profile: { personId: expectedLeaderPersonId } }); await page.goto("/dashboard/sokere"); await expect(page).toHaveURL(/\/dashboard\/sokere$/); @@ -215,11 +227,11 @@ test.describe("Native recruitment assignment journey (spec 0049.3)", () => { await expect(assignedRow).toContainText(interviewerName); await expect(assignedRow).not.toContainText("Ikke tildelt"); - const accessibility = await new AxeBuilder({ page }) - .include('section[aria-labelledby="fr-page-title"]') - .analyze(); + const accessibility = await auditSettledPage(page, { + include: ['section[aria-labelledby="fr-page-title"]'], + }); - expect(accessibility.violations).toEqual([]); + expect(accessibility).toEqual([]); expect(bridgeRequests.map(({ operation }) => operation)).toEqual([ "readAssignmentBoard", "createApplicationInterview", @@ -258,7 +270,7 @@ test.describe("Native recruitment assignment journey (spec 0049.3)", () => { interviewers: interviewerOptions.slice(1), schemas: schemaOptions.slice(1), }, - accessibilityViolations: accessibility.violations.length, + accessibilityViolations: accessibility.length, legacyBrowserRequests, externalBrowserRequests, pageErrors, diff --git a/apps/dashboard/e2e/native-rpc-ledger.ts b/apps/dashboard/e2e/native-rpc-ledger.ts new file mode 100644 index 000000000..16e018530 --- /dev/null +++ b/apps/dashboard/e2e/native-rpc-ledger.ts @@ -0,0 +1,293 @@ +/** + * The facts that a recording proxy between the dashboard and the backend keeps of one native RPC, + * spelled as the HTTP route that the RPC replaced. + * + * A journey ledger compares its requests with the command receipts of the backend, whose normalized + * targets are the old HTTP routes, so a recorder classifies each RPC by that route: its method and + * path, the idempotency key and entity tag that its payload carries, and the answer that ends it. + * Every RPC travels as `POST /api/rpc`; its outcome keeps the status that the problem registry + * gives a declared problem, and 200 for a success. + */ +import { Array as Arr, Match, Option, Predicate, Schema } from "effect"; + +type Route = readonly [method: "GET" | "POST", path: string]; + +const encodePathIdentity = (identity: Schema.Json | undefined): string => + encodeURIComponent(Predicate.isString(identity) ? identity : "").replace( + /[!'()*]/gu, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); + +const interviewRoute = (suffix: string) => (payload: Payload) => + `/api/recruitment/interviews/${encodePathIdentity(payload.interviewId)}${suffix}`; + +type Payload = Readonly>; + +/** The HTTP route of each RPC that the recruitment, onboarding, and session journeys send. */ +const replacedRoutes: ReadonlyMap Route> = new Map< + string, + (payload: Payload) => Route +>([ + ["system.readSession", () => ["GET", "/api/session"]], + ["profile.readOwnProfile", () => ["GET", "/api/profile"]], + ["recruitment.readInvitationResponse", () => ["GET", "/api/recruitment/invitation-response"]], + [ + "recruitment.confirmInvitation", + () => ["POST", "/api/recruitment/invitation-response:confirm"], + ], + ["recruitment.rejectInvitation", () => ["POST", "/api/recruitment/invitation-response:reject"]], + [ + "recruitment.requestNewInvitationTime", + () => ["POST", "/api/recruitment/invitation-response:request-new-time"], + ], + [ + "recruitment.readAssignmentBoard", + () => ["GET", "/api/recruitment/application-assignments"], + ], + ["recruitment.readSchedulingBoard", () => ["GET", "/api/recruitment/interviews"]], + ["recruitment.readInterviewReport", () => ["GET", "/api/recruitment/interview-report"]], + [ + "recruitment.createApplicationInterview", + (payload) => [ + "POST", + `/api/recruitment/applications/${encodePathIdentity(payload.applicationId)}/interviews`, + ], + ], + ["recruitment.scheduleInterview", (payload) => ["POST", interviewRoute(":schedule")(payload)]], + ["recruitment.readInterviewConduct", (payload) => ["GET", interviewRoute("")(payload)]], + ["recruitment.finalizeInterview", (payload) => ["POST", interviewRoute(":finalize")(payload)]], + [ + "recruitment.correctInterviewAssessment", + (payload) => ["POST", interviewRoute(":correct")(payload)], + ], + ["recruitment.cancelInterview", (payload) => ["POST", interviewRoute(":cancel")(payload)]], + ["recruitment.readQuestionnaires", () => ["GET", "/api/recruitment/questionnaires"]], + ["recruitment.readInterviewStaffing", () => ["GET", "/api/recruitment/interview-staffing"]], + [ + "recruitment.maintainRecruitment", + () => ["POST", "/api/recruitment/maintenance/commands"], + ], + ["onboarding.readBoard", () => ["GET", "/api/onboarding"]], + ["onboarding.command", () => ["POST", "/api/onboarding"]], + ["onboarding.claim", () => ["POST", "/api/onboarding/claim"]], +]); + +const RpcRequestMessage = Schema.Struct({ + tag: Schema.String, + payload: Schema.Json, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +}); + +const PayloadRecord = Schema.Record(Schema.String, Schema.Json); + +/** One RPC request, as the HTTP route that it replaced. */ +export interface ReplacedRequest { + readonly tag: string; + readonly method: "GET" | "POST"; + readonly path: string; + readonly idempotencyKey: string | null; + readonly ifMatch: string | null; + /** The command body that the HTTP route took, without the RPC's key, tag, and capability. */ + readonly requestJson: Schema.Json | undefined; + /** Whether the payload carried an invitation capability, as the old dedicated header did. */ + readonly capabilityPresent: boolean; + /** The invitation capability of the payload, for a recorder that resolves its holder. */ + readonly capability: string | null; + /** + * The headers that the RPC message carries: a server forwards the person's cookie there, where + * the HTTP request of a browser carries it as a header. + */ + readonly messageHeaders: Readonly>; + /** The payload members, for a recorder that spells a query the route took. */ + readonly payload: Payload; +} + +/** + * The replaced route of the RPC request that a proxied body carries to the RPC endpoint, or + * `undefined` for a body that is no RPC request message. + */ +export const replacedRequest = (requestJson: Schema.Json | undefined): ReplacedRequest | undefined => + Option.getOrUndefined( + Option.map(Schema.decodeUnknownOption(RpcRequestMessage)(requestJson), ({ tag, payload, headers }) => { + const members = Option.getOrElse( + Schema.decodeUnknownOption(PayloadRecord)(payload), + (): Payload => ({}), + ); + + const [method, path] = replacedRoutes.get(tag)?.(members) ?? ["POST", "/api/rpc"]; + + return { + tag, + method, + path, + idempotencyKey: Predicate.isString(members.idempotencyKey) ? members.idempotencyKey : null, + ifMatch: Predicate.isString(members.ifMatch) ? members.ifMatch : null, + requestJson: "request" in members ? members.request : undefined, + capabilityPresent: "capability" in members, + capability: Predicate.isString(members.capability) ? members.capability : null, + payload: members, + messageHeaders: Object.fromEntries( + headers.map(([name, value]) => [name.toLowerCase(), value] as const), + ), + }; + }), + ); + +const RpcAnswer = Schema.Tuple([ + Schema.Struct({ + exit: Schema.Union([ + Schema.TaggedStruct("Success", { value: Schema.Json }), + Schema.TaggedStruct("Failure", { cause: Schema.Array(Schema.Json) }), + ]), + }), +]); + +/** The reason of a failure that carries a declared problem; a defect carries none. */ +const FailReason = Schema.Struct({ error: Schema.Json }); + +const isFailReason = Schema.is(FailReason); + +const ProblemStatus = Schema.Struct({ status: Schema.Int }); + +const TaggedResource = Schema.Union([ + Schema.Struct({ result: Schema.Json, etag: Schema.String }), + Schema.Struct({ interview: Schema.Json, etag: Schema.String }), + Schema.Struct({ detail: Schema.Json, etag: Schema.String }), + Schema.Struct({ observation: Schema.Json, etag: Schema.String }), + Schema.Struct({ profile: Schema.Json, etag: Schema.String }), + Schema.Struct({ etag: Schema.String }), +]); + +/** The answer that ends one RPC, as the HTTP response of the route that it replaced. */ +export interface ReplacedAnswer { + readonly status: number; + /** The representation that the HTTP body carried: the resource beside a tag is unwrapped. */ + readonly responseJson: Schema.Json; + /** The entity tag that the HTTP `ETag` header carried beside the resource, if any. */ + readonly responseEtag: string | null; +} + +const unwrapTagged = (value: Schema.Json): ReplacedAnswer => + // Exact members: a resource that carries its tag among its own fields stays whole. + Option.match(Schema.decodeUnknownOption(TaggedResource)(value, { onExcessProperty: "error" }), { + onNone: () => ({ status: 200, responseJson: value, responseEtag: null }), + onSome: (tagged) => ({ + status: 200, + responseJson: + "result" in tagged + ? tagged.result + : "interview" in tagged + ? tagged.interview + : "detail" in tagged + ? tagged.detail + : "observation" in tagged + ? tagged.observation + : "profile" in tagged + ? tagged.profile + : null, + responseEtag: tagged.etag, + }), + }); + +/** + * The answer of the RPC response that a proxied body carries: a success answers its value at 200, + * a failure answers its problem at the registry status of the problem's code, and a defect, such as + * a payload that the server refused before the handler, answers 500 with the cause as its body. + * `undefined` for a body that is no RPC response. + */ +export const replacedAnswer = (responseJson: Schema.Json | undefined): ReplacedAnswer | undefined => + Option.match(Schema.decodeUnknownOption(RpcAnswer)(responseJson), { + onNone: () => undefined, + onSome: ([{ exit }]) => + Match.value(exit).pipe( + Match.tag("Success", ({ value }) => unwrapTagged(value)), + Match.tag("Failure", ({ cause }) => + Option.match(Arr.findFirst(cause, isFailReason), { + onNone: () => ({ status: 500, responseJson: cause, responseEtag: null }), + onSome: ({ error }) => ({ + status: Option.match(Schema.decodeUnknownOption(ProblemStatus)(error), { + onNone: () => 500, + onSome: ({ status }) => status, + }), + responseJson: error, + responseEtag: null, + }), + }), + ), + Match.exhaustive, + ), + }); + +const JsonText = Schema.fromJsonString(Schema.Json); + +const parseJsonText = (text: string): Schema.Json | undefined => + Option.getOrUndefined(Schema.decodeOption(JsonText)(text)); + +/** + * The HTTP response that the route an RPC replaced would have answered, from the RPC's own + * response: the replaced status, the representation as the body, and the entity tag as the `ETag` + * header. A probe written against the HTTP contract keeps reading `status`, `headers`, and the + * body. A response that carries no RPC answer, such as a transport rejection, passes unchanged. + */ +export const replacedHttpResponse = async (response: Response): Promise => { + const text = await response.text(); + const answer = replacedAnswer(parseJsonText(text)); + + if (answer === undefined) return new Response(text, { status: response.status }); + + return new Response(Schema.encodeSync(JsonText)(answer.responseJson), { + status: answer.status, + headers: + answer.responseEtag === null + ? { "content-type": "application/json" } + : { "content-type": "application/json", etag: answer.responseEtag }, + }); +}; + +/** The HTTP headers a browser sends with an RPC: the dashboard origin, and its cookie if any. */ +export interface BrowserHeaders { + readonly origin: string; + readonly cookie?: string | null | undefined; +} + +const browserHeaders = (input: BrowserHeaders): Headers => { + const headers = new Headers({ origin: input.origin, "content-type": "application/json" }); + + if (Predicate.isString(input.cookie) && input.cookie.length > 0) { + headers.set("cookie", input.cookie); + } + + return headers; +}; + +/** One RPC request message with a hand-built payload, as the RPC client serializes it. */ +const RpcRequest = Schema.TaggedStruct("Request", { + id: Schema.String, + tag: Schema.String, + payload: Schema.Json, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +}); + +const RpcRequestBody = Schema.fromJsonString(RpcRequest); + +/** + * Posts one RPC to the backend at `origin` as a browser would, with `headers` such as the cookie + * and the dashboard origin, and answers the HTTP response of the route that it replaced. The + * payload is hand built, so a value that its schema refuses reaches the server, which answers a + * defect (500). + */ +export const replacedFetch = async (input: { + readonly origin: string; + readonly tag: string; + readonly payload: Schema.Json; + readonly headers: BrowserHeaders; +}): Promise => + replacedHttpResponse( + await fetch(new URL("/api/rpc", input.origin), { + method: "POST", + headers: browserHeaders(input.headers), + body: Schema.encodeSync(RpcRequestBody)( + RpcRequest.make({ id: "0", tag: input.tag, payload: input.payload, headers: [] }), + ), + }), + ); diff --git a/apps/dashboard/e2e/native-schools-directory.spec.ts b/apps/dashboard/e2e/native-schools-directory.spec.ts index d5dced3c4..ff658a890 100644 --- a/apps/dashboard/e2e/native-schools-directory.spec.ts +++ b/apps/dashboard/e2e/native-schools-directory.spec.ts @@ -1,8 +1,15 @@ -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.js"; import { writeFile } from "node:fs/promises"; import { expect, test, type Browser, type BrowserContext, type Page } from "@playwright/test"; -import { Schema } from "effect"; -import { NativeProblem } from "@vektorprogrammet/http-api"; +import { Option, Schema } from "effect"; +import { DepartmentId } from "@vektorprogrammet/domain"; +import { NativeProblem, nativeRpcPath } from "@vektorprogrammet/rpc"; +import { + isNativeRpcPath, + nativeRpcRequestBody, + nativeRpcStatus, + nativeRpcValue, +} from "./native-operations.js"; import { addressesAnyRoute, legacyRoutes } from "./request-routes.js"; const realNativeIdentity = process.env.REAL_NATIVE_IDENTITY_E2E === "1"; @@ -10,11 +17,43 @@ const realNativeIdentity = process.env.REAL_NATIVE_IDENTITY_E2E === "1"; const evidencePath = process.env.SCHOOLS_E2E_BROWSER_EVIDENCE_PATH; const departments = { - alpha: "schools-e2e-0061-department-alpha", - beta: "schools-e2e-0061-department-beta", - empty: "schools-e2e-0061-department-empty", + alpha: DepartmentId.make("schools-e2e-0061-department-alpha"), + beta: DepartmentId.make("schools-e2e-0061-department-beta"), + empty: DepartmentId.make("schools-e2e-0061-department-empty"), } as const; +/** The RPC that the browser read the school directory with. */ +const listSchoolsTag = "directory.listSchools"; + +/** The native RPC request that one browser request body carries. */ +const RpcRequestBody = Schema.fromJsonString(Schema.Struct({ tag: Schema.String })); + +/** The tag of the native RPC that one browser request body carries, if any. */ +const rpcTagOf = (body: string | null): string | undefined => + body === null ? undefined : Option.getOrUndefined(Schema.decodeOption(RpcRequestBody)(body))?.tag; + +/** An RPC answer that ends its request with one declared problem. */ +const RpcProblemAnswer = Schema.fromJsonString( + Schema.Tuple([ + Schema.Struct({ + exit: Schema.Struct({ cause: Schema.Tuple([Schema.Struct({ error: NativeProblem })]) }), + }), + ]), +); + +/** The problem that one RPC answer failed with, decoded with the contract schema. */ +const rpcProblemOf = (answer: string) => { + const [ + { + exit: { + cause: [{ error }], + }, + }, + ] = Schema.decodeSync(RpcProblemAnswer)(answer); + + return error; +}; + const persons = { administrator: { email: "administrator.schools.0061@example.invalid", @@ -42,6 +81,7 @@ type BrowserRequest = { readonly method: string; readonly pathname: string; readonly search: string; + readonly rpcTag?: string | undefined; }; type BrowserResponse = BrowserRequest & { readonly status: number }; @@ -55,7 +95,12 @@ const openContext = async ( const context = await browser.newContext(); context.on("request", (request) => { const url = new URL(request.url()); - browserRequests.push({ method: request.method(), pathname: url.pathname, search: url.search }); + browserRequests.push({ + method: request.method(), + pathname: url.pathname, + search: url.search, + rpcTag: rpcTagOf(request.postData()), + }); }); context.on("response", (response) => { const url = new URL(response.url()); @@ -63,6 +108,7 @@ const openContext = async ( method: response.request().method(), pathname: url.pathname, search: url.search, + rpcTag: rpcTagOf(response.request().postData()), status: response.status(), }); }); @@ -188,17 +234,29 @@ test.describe("Native Schools directory (spec 0061)", () => { administrator.page.getByRole("rowheader", { name: "Historisk Internasjonal" }), ).toBeVisible(); - const emptyDepartment = await administrator.page.evaluate(async (departmentId) => { - const response = await fetch( - `/api/schools?department=${encodeURIComponent(departmentId)}`, - { + // The browser posts the RPC itself, same-origin, so its own session cookie authenticates it. + const emptyDepartmentText = await administrator.page.evaluate( + async ({ path, body }) => { + const response = await fetch(path, { + method: "POST", credentials: "same-origin", - headers: { accept: "application/json" }, - }, - ); + headers: { "content-type": "application/json" }, + body, + }); + + return response.text(); + }, + { + path: nativeRpcPath, + body: nativeRpcRequestBody(listSchoolsTag, { departmentId: departments.empty }), + }, + ); - return { status: response.status, body: await response.json() }; - }, departments.empty); + // An answer that is no RPC exit records null, which the expectation below rejects. + const emptyDepartment = { + status: nativeRpcStatus(emptyDepartmentText) ?? null, + body: nativeRpcValue(emptyDepartmentText) ?? null, + }; expect(emptyDepartment).toEqual({ status: 200, @@ -211,8 +269,8 @@ test.describe("Native Schools directory (spec 0061)", () => { emptyDepartment, }; - const accessibility = await new AxeBuilder({ page: administrator.page }).analyze(); - expect(accessibility.violations).toEqual([]); + const accessibility = await auditSettledPage(administrator.page); + expect(accessibility).toEqual([]); const twoDepartment = await openContext( browser, @@ -261,13 +319,15 @@ test.describe("Native Schools directory (spec 0061)", () => { contexts.push(denied.context); const rejection = denied.page.waitForResponse( - (response) => new URL(response.url()).pathname === "/api/schools", + (response) => + isNativeRpcPath(new URL(response.url()).pathname) && + rpcTagOf(response.request().postData()) === listSchoolsTag, ); await signIn(denied.page, person, "/dashboard/skoler"); const response = await rejection; - expect(response.status()).toBe(403); - const failure = Schema.decodeUnknownSync(NativeProblem)(await response.json()); + const failure = rpcProblemOf(await response.text()); + expect(failure.status).toBe(403); expect(failure.code).toBe("authority.denied"); await expect(denied.page).toHaveURL(/\/dashboard\/skoler$/); await assertDirectoryShell(denied.page); @@ -277,14 +337,17 @@ test.describe("Native Schools directory (spec 0061)", () => { denied.page.getByRole("alert").getByText(person.email, { exact: true }), ).toHaveCount(0); observations[name] = { - status: response.status(), + status: failure.status, tag: failure.code, renderedAt: "/dashboard/skoler", }; } const directoryRequests = browserRequests.filter( - (request) => request.method === "GET" && request.pathname === "/api/schools", + (request) => + request.method === "POST" && + isNativeRpcPath(request.pathname) && + request.rpcTag === listSchoolsTag, ); expect( @@ -300,14 +363,15 @@ test.describe("Native Schools directory (spec 0061)", () => { passed: true, browser: "Chromium", realSessionCookie: true, - browserPath: "/api/schools", + browserPath: nativeRpcPath, + rpcTag: listSchoolsTag, directoryRequests, directoryResponses: browserResponses.filter( - (response) => response.pathname === "/api/schools", + (response) => isNativeRpcPath(response.pathname) && response.rpcTag === listSchoolsTag, ), observations, pageErrors, - accessibilityViolations: accessibility.violations, + accessibilityViolations: accessibility, }; if (evidencePath !== undefined) { diff --git a/apps/dashboard/e2e/native-substitutes.spec.ts b/apps/dashboard/e2e/native-substitutes.spec.ts index 9e511b2f1..a8c2a2851 100644 --- a/apps/dashboard/e2e/native-substitutes.spec.ts +++ b/apps/dashboard/e2e/native-substitutes.spec.ts @@ -1,7 +1,10 @@ -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.js"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, test, type Page } from "@playwright/test"; +import { PublicApplicationIdSchema } from "@vektorprogrammet/rpc"; +import { IdempotencyKey } from "@vektorprogrammet/rpc/problem"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; const manifestPath = process.env.SUBSTITUTE_JOURNEY_MANIFEST; @@ -26,15 +29,7 @@ const signIn = async (page: Page, person: { email: string; password: string }) = }; const axe = async (page: Page, state: string) => { - const result = await new AxeBuilder({ page }).analyze(); - expect( - result.violations.map(({ id, impact, nodes }) => ({ - id, - impact, - nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })), - })), - state, - ).toEqual([]); + expect(await auditSettledPage(page), state).toEqual([]); }; const outcomeForm = (page: Page, name: string) => @@ -43,17 +38,43 @@ const outcomeForm = (page: Page, name: string) => const onCallItems = (page: Page) => page.getByRole("list", { name: "Vikarer på vakt", exact: true }).getByRole("listitem"); -const readOutcome = async (page: Page) => { - const response = await page.request.get( - `${manifest.backendOrigin}/api/admission-outcomes/${manifest.applicationId}`, - { headers: { Origin: manifest.dashboardOrigin } }, +let native: ReturnType | undefined; + +/** The backend's native RPC client, made on first use once the manifest names the backend. */ +const nativeClient = () => { + native ??= nativeScriptClient(manifest.backendOrigin); + + return native; +}; + +/** The session of one browser context, sent from the dashboard origin as its server sends it. */ +const sessionOf = async (page: Page) => ({ + cookie: (await page.context().cookies(manifest.dashboardOrigin)) + .map(({ name, value }) => `${name}=${value}`) + .join("; "), + origin: manifest.dashboardOrigin, +}); + +const applicationId = () => PublicApplicationIdSchema.make(manifest.applicationId); + +/** Reads the application's outcome as the person signed in to `page`. */ +const callReadOutcome = async (page: Page) => + nativeClient().call(await sessionOf(page), (client) => + client["admissionOutcomes.readOutcome"]({ applicationId: applicationId() }), ); - expect(response.status()).toBe(200); +const readOutcome = async (page: Page) => { + const answer = await callReadOutcome(page); - return response.json(); + if (!answer.ok) throw new Error(`Reading the outcome answered ${answer.code}`); + + return answer.value; }; +test.afterAll(async () => { + await native?.dispose(); +}); + test("admission management records substitutes; members see who is on call", async ({ browser, }) => { @@ -246,26 +267,22 @@ test("admission management records substitutes; members see who is on call", asy await expect(readOnly.getByText("Anne API")).toHaveCount(0); const current = await readOutcome(page); - const memberRead = await readOnly.request.get( - `${manifest.backendOrigin}/api/admission-outcomes/${manifest.applicationId}`, - { headers: { Origin: manifest.dashboardOrigin } }, - ); - - expect(memberRead.status()).toBe(403); + expect(await callReadOutcome(readOnly)).toMatchObject({ + ok: false, + status: 403, + code: "authority.denied", + }); - const memberRecord = await readOnly.request.post( - `${manifest.backendOrigin}/api/admission-outcomes/${manifest.applicationId}:record`, - { - headers: { - Origin: manifest.dashboardOrigin, - "Idempotency-Key": "browser-member-denied-substitutes", - "If-Match": current.etag, - }, - data: { outcome: "Rejected" }, - }, + const memberRecord = await nativeClient().call(await sessionOf(readOnly), (client) => + client["admissionOutcomes.recordOutcome"]({ + applicationId: applicationId(), + idempotencyKey: IdempotencyKey.make("browser-member-denied-substitutes"), + ifMatch: current.etag, + request: { outcome: "Rejected" }, + }), ); - expect(memberRecord.status()).toBe(403); + expect(memberRecord).toMatchObject({ ok: false, status: 403, code: "authority.denied" }); await axe(readOnly, "member on-call list"); await readOnly.setViewportSize({ width: 390, height: 844 }); await expect(onCallItems(readOnly)).toHaveText([manifest.onCall]); diff --git a/apps/dashboard/e2e/native-team-interest-journey.spec.ts b/apps/dashboard/e2e/native-team-interest-journey.spec.ts index 930ed3953..8c12b5216 100644 --- a/apps/dashboard/e2e/native-team-interest-journey.spec.ts +++ b/apps/dashboard/e2e/native-team-interest-journey.spec.ts @@ -1,3 +1,4 @@ +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { expect, test, type Page } from "@playwright/test"; const nativeIdentityMode = process.env.REAL_NATIVE_IDENTITY_E2E === "1"; @@ -69,17 +70,25 @@ test.describe("Native team-interest journey (spec 0059)", () => { await expect(page.getByRole("cell", { name: "Sondre Soker", exact: true })).toHaveCount(0); }); - test("native endpoint orders rows by registration id and gates anonymous callers", async ({ - request, - }) => { + test("native RPC gates anonymous callers", async ({ baseURL }) => { test.skip(!nativeIdentityMode, "requires the real native identity topology"); - const anonymous = await request.get(`${apiOrigin}/api/team-interest-registrations`); - expect(anonymous.status()).toBe(401); - expect(await anonymous.json()).toMatchObject({ - status: 401, - code: "credential.missing", - type: "urn:vektorprogrammet:problem:v0.2:credential.missing", - }); + const native = nativeScriptClient(apiOrigin); + + try { + const anonymous = await native.call( + { origin: new URL(baseURL ?? apiOrigin).origin }, + (client) => client["organization.listTeamInterest"]({}), + ); + + expect(anonymous.status).toBe(401); + expect(!anonymous.ok && "problem" in anonymous ? anonymous.problem : undefined).toMatchObject({ + status: 401, + code: "credential.missing", + type: "urn:vektorprogrammet:problem:v0.2:credential.missing", + }); + } finally { + await native.dispose(); + } }); }); diff --git a/apps/dashboard/e2e/real-interview-response.spec.ts b/apps/dashboard/e2e/real-interview-response.spec.ts index c9d057968..9464d5dc8 100644 --- a/apps/dashboard/e2e/real-interview-response.spec.ts +++ b/apps/dashboard/e2e/real-interview-response.spec.ts @@ -1,6 +1,6 @@ import { InvitationResponseResourceSchema, type InvitationResponseObservation } from "../app/foldkit/interview/bridge"; import { Schema, Predicate } from "effect"; -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.js"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { @@ -835,11 +835,11 @@ test.describe("Native recruitment invitation response", () => { null, ); - const pendingAccessibility = await new AxeBuilder({ page }) - .include("main.foldkit-interview") - .analyze(); + const pendingAccessibility = await auditSettledPage(page, { + include: ["main.foldkit-interview"], + }); - if (pendingAccessibility.violations.length !== 0) { + if (pendingAccessibility.length !== 0) { throw new Error("Applicant pending validation state has accessibility violations"); } @@ -911,11 +911,11 @@ test.describe("Native recruitment invitation response", () => { await expect(page.getByText(responseCase.stateLabel, { exact: true })).toBeVisible(); await assertApplicantPrivacy(context, page, capability, capabilities); - const accessibility = await new AxeBuilder({ page }) - .include("main.foldkit-interview") - .analyze(); + const accessibility = await auditSettledPage(page, { + include: ["main.foldkit-interview"], + }); - if (accessibility.violations.length !== 0) { + if (accessibility.length !== 0) { throw new Error("Applicant response state has accessibility violations"); } @@ -1011,11 +1011,11 @@ test.describe("Native recruitment invitation response", () => { } } - const accessibility = await new AxeBuilder({ page }) - .include('section[aria-labelledby="fs-page-title"]') - .analyze(); + const accessibility = await auditSettledPage(page, { + include: ['section[aria-labelledby="fs-page-title"]'], + }); - if (accessibility.violations.length !== 0) { + if (accessibility.length !== 0) { throw new Error("Staff scheduling board has accessibility violations"); } diff --git a/apps/dashboard/e2e/receipt-approval.spec.ts b/apps/dashboard/e2e/receipt-approval.spec.ts index 5a540714b..f4ba5e2c8 100644 --- a/apps/dashboard/e2e/receipt-approval.spec.ts +++ b/apps/dashboard/e2e/receipt-approval.spec.ts @@ -18,11 +18,20 @@ import { } from "@playwright/test"; import { ReceiptApprovalQueueResponse, + ReceiptFileContent, ReceiptListResponse, ReceiptResource, -} from "@vektorprogrammet/http-api"; -import { Schema } from "effect"; +} from "@vektorprogrammet/rpc"; +import { Predicate, Schema } from "effect"; import { z } from "zod"; +import { nativeRpcPath } from "@vektorprogrammet/rpc"; +import { + nativeRpcOutcome, + nativeRpcRequestBody, + nativeRpcStatus, + nativeRpcValue, +} from "./native-operations.js"; +import { type NativeRpcAnswer, postNativeRpc, rpcBytes } from "./receipt-rpc.js"; import { addressesAnyRoute, addressesRoute, legacyRoutes } from "./request-routes.js"; const execFileAsync = promisify(execFile); @@ -84,24 +93,21 @@ const PDF_RECEIPT_FILE = { name: "receipt.pdf", } as const; -/** Receipt bodies decode through the HTTP contract and reject undeclared members. */ +/** + * Receipt bodies decode through the RPC contract's JSON codec, the one that RPC serialization + * derives, and reject undeclared members. File bytes travel as base64 on that codec. + */ const exactDecoding = { onExcessProperty: "error" } as const; -const decodeReceiptResource = Schema.decodeUnknownSync(ReceiptResource); +const decodeReceiptResource = Schema.decodeUnknownSync(Schema.toCodecJson(ReceiptResource)); -const decodeOwnedReceiptPage = Schema.decodeUnknownSync(ReceiptListResponse); +const decodeOwnedReceiptPage = Schema.decodeUnknownSync(Schema.toCodecJson(ReceiptListResponse)); -const decodeApprovalQueuePage = Schema.decodeUnknownSync(ReceiptApprovalQueueResponse); +const decodeApprovalQueuePage = Schema.decodeUnknownSync( + Schema.toCodecJson(ReceiptApprovalQueueResponse), +); -const receiptProblemSchema = z - .object({ - type: z.string(), - title: z.string(), - status: z.number().int(), - code: z.string(), - detail: z.string(), - }) - .passthrough(); +const decodeReceiptFile = Schema.decodeUnknownSync(Schema.toCodecJson(ReceiptFileContent)); const fileIdentitySchema = z.array( z @@ -234,44 +240,117 @@ function sessionHeaders(cookie: string) { return { Cookie: cookie, Origin: DASHBOARD_ORIGIN }; } -const actionPath = (receiptId: string, intent: ResolutionIntent): string => - `${BACKEND_ORIGIN}/api/receipts/${encodeURIComponent(receiptId)}:${intent}`; - -const approvalFilePath = (receiptId: string): string => - `${BACKEND_ORIGIN}/api/receipt-approval-queue/${encodeURIComponent(receiptId)}/file`; - const dashboardApprovalFilePath = (receiptId: string): string => `${DASHBOARD_ORIGIN}/dashboard/utlegg/${encodeURIComponent(receiptId)}/file`; -const ownerFilePath = (receiptId: string): string => - `${BACKEND_ORIGIN}/api/receipts/${encodeURIComponent(receiptId)}/file`; - +/** The E2E barrier probe, a real HTTP header of the RPC request, which the handler reads. */ const concurrencyProbeHeader = "x-receipt-e2e-concurrency-probe"; -const concurrencySynchronizedHeader = "x-receipt-e2e-concurrency-synchronized"; +/** The headers of one RPC as a session, or as no one. */ +const rpcHeaders = (cookie?: string): Record => + cookie === undefined ? { Origin: DASHBOARD_ORIGIN } : sessionHeaders(cookie); -const actionHeaders = (cookie: string, idempotencyKey: string, ifMatch: string) => ({ - ...sessionHeaders(cookie), - "content-type": "application/json", - "Idempotency-Key": idempotencyKey, - "If-Match": ifMatch, -}); +const backendRpc = ( + request: APIRequestContext, + tag: string, + payload: Schema.Json, + headers: Record, +) => postNativeRpc(request, { origin: BACKEND_ORIGIN, tag, payload, headers }); + +/** Approves or rejects one receipt, as the session given, with an optional barrier probe. */ +const receiptAction = ( + request: APIRequestContext, + receiptId: string, + intent: ResolutionIntent, + input: { + readonly cookie: string; + readonly idempotencyKey: string; + readonly ifMatch: string; + readonly probe?: string; + }, +) => + backendRpc( + request, + intent === "approve" ? "receipts.approveReceipt" : "receipts.rejectReceipt", + { receiptId, idempotencyKey: input.idempotencyKey, ifMatch: input.ifMatch }, + input.probe === undefined + ? rpcHeaders(input.cookie) + : { ...rpcHeaders(input.cookie), [concurrencyProbeHeader]: input.probe }, + ); + +/** Reads one receipt file as its approver. */ +const approvalFile = ( + request: APIRequestContext, + receiptId: string, + cookie?: string, + probe?: string, +) => + backendRpc( + request, + "receipts.readReceiptFileForApproval", + { receiptId }, + probe === undefined + ? rpcHeaders(cookie) + : { ...rpcHeaders(cookie), [concurrencyProbeHeader]: probe }, + ); + +/** Reads one receipt file as its owner. */ +const ownerFile = (request: APIRequestContext, receiptId: string, cookie?: string) => + backendRpc(request, "receipts.readReceiptFile", { receiptId }, rpcHeaders(cookie)); + +/** The approval queue, as the session given. */ +const approvalQueue = (request: APIRequestContext, cookie?: string, status?: ReceiptStatus) => + backendRpc( + request, + "receipts.listReceiptsForApproval", + status === undefined ? {} : { status }, + rpcHeaders(cookie), + ); async function expectProblemCode( - response: APIResponse, + answer: NativeRpcAnswer, expectedStatus: number, expectedCode: string, ): Promise { - expect(response.status()).toBe(expectedStatus); - expect(response.headers()["content-type"]).toContain("application/problem+json"); - const problem = receiptProblemSchema.parse(await response.json()); - expect(problem).toMatchObject({ + expect(answer.status).toBe(expectedStatus); + expect(answer.problem).toMatchObject({ status: expectedStatus, code: expectedCode, type: `urn:vektorprogrammet:problem:v0.2:${expectedCode}`, }); - return problem.code; + return answer.problem?.code ?? "missing"; +} + +/** + * The verified bytes of one receipt file read over RPC: its stored media type and exact bytes, + * with no storage identity in the answer. The artifact names the disposition that the dashboard + * route answers for that media type. + */ +function expectApprovedRpcFile( + answer: NativeRpcAnswer, + file: ReceiptFileFixture, + fileIdentities: ReadonlyArray, +) { + expect(answer.status).toBe(200); + const content = decodeReceiptFile(answer.value, exactDecoding); + expect(content.contentType).toBe(file.contentType); + const bytes = Buffer.from(content.bytes); + expect(Buffer.compare(bytes, file.bytes)).toBe(0); + const exposed = JSON.stringify({ ...content, bytes: undefined }); + + for (const identity of fileIdentities) { + expect(exposed).not.toContain(identity.fileRef); + expect(exposed).not.toContain(identity.objectKey); + expect(exposed).not.toContain(identity.sha256); + } + + return { + byteLength: bytes.byteLength, + contentDisposition: `inline; filename="receipt.${file.extension}"`, + contentType: content.contentType, + sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + }; } function expectApprovedReceiptFileHeaders( @@ -330,9 +409,8 @@ async function expectDashboardFileFailure(response: APIResponse, status: number) expect((await response.body()).byteLength).toBe(0); } -function expectNoReceiptFileHeaders(response: APIResponse): void { - const headers = response.headers(); - expect(headers["content-disposition"]).toBeUndefined(); +function expectNoReceiptFileHeaders(answer: NativeRpcAnswer): void { + expect(answer.value).toBeUndefined(); } const fileIdentitySql = ` @@ -414,13 +492,10 @@ async function observeDurablePostgresFailure( let failure: { readonly status: number; readonly tag: string } | undefined; try { - const response = await request.get(`${BACKEND_ORIGIN}/api/receipt-approval-queue`, { - headers: sessionHeaders(cookie), - timeout: 5_000, - }); + const response = await approvalQueue(request, cookie); failure = { - status: response.status(), + status: response.status, tag: await expectProblemCode(response, 503, "receipts.unavailable"), }; } finally { @@ -446,36 +521,37 @@ async function submitReceipt( ): Promise { const submissionIdempotencyKey = randomUUID(); - const response = await request.post(`${BACKEND_ORIGIN}/api/receipts`, { - headers: { - ...sessionHeaders(cookie), - "Idempotency-Key": submissionIdempotencyKey, - }, - multipart: { - description, - amountOre: String(amountOre), - receiptDate: RECEIPT_DATE, - file: { - name: file.name, - mimeType: file.contentType, - buffer: file.bytes, + const response = await backendRpc( + request, + "receipts.submitReceipt", + { + idempotencyKey: submissionIdempotencyKey, + request: { + description, + amountOre, + receiptDate: RECEIPT_DATE, + file: { contentType: file.contentType, bytes: rpcBytes(file.bytes) }, }, }, - }); + sessionHeaders(cookie), + ); - expect(response.status()).toBe(201); - const resource = decodeReceiptResource(await response.json(), exactDecoding); + expect(response.status).toBe(200); + const resource = decodeReceiptResource(response.value, exactDecoding); expect(resource).toMatchObject({ status: "Pending", revision: 0, }); - const ownedResponse = await request.get(`${BACKEND_ORIGIN}/api/receipts`, { - headers: sessionHeaders(cookie), - }); + const ownedResponse = await backendRpc( + request, + "receipts.listReceipts", + {}, + sessionHeaders(cookie), + ); - expect(ownedResponse.status()).toBe(200); - const owned = decodeOwnedReceiptPage(await ownedResponse.json(), exactDecoding); + expect(ownedResponse.status).toBe(200); + const owned = decodeOwnedReceiptPage(ownedResponse.value, exactDecoding); const projection = owned.items.find((item) => item.receiptId === resource.receiptId); if (projection === undefined) { @@ -490,15 +566,11 @@ async function listForApproval( cookie: string, status?: ReceiptStatus, ): Promise { - const query = status === undefined ? "" : `?status=${encodeURIComponent(status)}`; + const response = await approvalQueue(request, cookie, status); - const response = await request.get(`${BACKEND_ORIGIN}/api/receipt-approval-queue${query}`, { - headers: sessionHeaders(cookie), - }); + expect(response.status).toBe(200); - expect(response.status()).toBe(200); - - return decodeApprovalQueuePage(await response.json(), exactDecoding); + return decodeApprovalQueuePage(response.value, exactDecoding); } async function authenticate( @@ -538,34 +610,41 @@ async function authenticate( if (sessionCookie === undefined) throw new Error("Better Auth session cookie is missing"); const cookie = `${sessionCookie.name}=${sessionCookie.value}`; - const sessionResponse = await request.get(`${BACKEND_ORIGIN}/api/session`, { - headers: sessionHeaders(cookie), - }); + // One RPC as a browser sends it, with the session cookie and the dashboard origin. + const readNative = async (tag: string) => + ( + await request.post(`${BACKEND_ORIGIN}${nativeRpcPath}`, { + headers: { ...sessionHeaders(cookie), "content-type": "application/json" }, + data: nativeRpcRequestBody(tag), + }) + ).text(); + + const sessionAnswer = await readNative("system.readSession"); - expect(sessionResponse.status()).toBe(200); + expect(nativeRpcStatus(sessionAnswer)).toBe(200); const session = z .object({ sessionId: z.string(), personId: z.string(), current: z.literal(true) }) .passthrough() - .parse(await sessionResponse.json()); + .parse(nativeRpcValue(sessionAnswer)); expect(session.personId).toBe(persona.personId); - const profileResponse = await request.get(`${BACKEND_ORIGIN}/api/profile`, { - headers: sessionHeaders(cookie), - }); + const profileAnswer = await readNative("profile.readOwnProfile"); - expect(profileResponse.status()).toBe(expectedProfileStatus); + expect(nativeRpcStatus(profileAnswer)).toBe(expectedProfileStatus); if (expectedProfileStatus === 200) { const profile = z - .object({ personId: z.string() }) + .object({ profile: z.object({ personId: z.string() }).passthrough() }) .passthrough() - .parse(await profileResponse.json()); + .parse(nativeRpcValue(profileAnswer)); - expect(profile.personId).toBe(persona.personId); + expect(profile.profile.personId).toBe(persona.personId); } else { - await expectProblemCode(profileResponse, 403, "authority.denied"); + const outcome = nativeRpcOutcome(profileAnswer); + + expect(Predicate.isTagged(outcome, "Problem") && outcome.problem.code).toBe("authority.denied"); } return { @@ -685,9 +764,7 @@ test.describe("Native scoped Receipt approval journey", () => { }); }); - const unauthenticatedResponse = await request.get( - `${BACKEND_ORIGIN}/api/receipt-approval-queue`, - ); + const unauthenticatedResponse = await approvalQueue(request); const unauthenticatedTag = await expectProblemCode( unauthenticatedResponse, @@ -697,9 +774,10 @@ test.describe("Native scoped Receipt approval journey", () => { await expectUnauthenticatedBrowser(browser); - const expiredApiResponse = await request.get(`${BACKEND_ORIGIN}/api/receipt-approval-queue`, { - headers: sessionHeaders("better-auth.session_token=invalid-local-receipt-approval-session"), - }); + const expiredApiResponse = await approvalQueue( + request, + "better-auth.session_token=invalid-local-receipt-approval-session", + ); const expiredApiTag = await expectProblemCode(expiredApiResponse, 401, "credential.invalid"); @@ -713,15 +791,11 @@ test.describe("Native scoped Receipt approval journey", () => { noneScope: await authenticate(page, request, environment.noneScope), }; - const inactiveResponse = await request.get(`${BACKEND_ORIGIN}/api/receipt-approval-queue`, { - headers: sessionHeaders(sessions.inactive.cookie), - }); + const inactiveResponse = await approvalQueue(request, sessions.inactive.cookie); const inactiveTag = await expectProblemCode(inactiveResponse, 403, "authority.denied"); - const noneScopeResponse = await request.get(`${BACKEND_ORIGIN}/api/receipt-approval-queue`, { - headers: sessionHeaders(sessions.noneScope.cookie), - }); + const noneScopeResponse = await approvalQueue(request, sessions.noneScope.cookie); const noneScopeTag = await expectProblemCode(noneScopeResponse, 403, "authority.denied"); @@ -774,8 +848,9 @@ test.describe("Native scoped Receipt approval journey", () => { const fileReadMutationCountsBefore = await readReceiptMutationCounts(); - const unauthenticatedApprovalFileResponse = await request.get( - approvalFilePath(approvedReceipt.projection.receiptId), + const unauthenticatedApprovalFileResponse = await approvalFile( + request, + approvedReceipt.projection.receiptId, ); const unauthenticatedApprovalFileTag = await expectProblemCode( @@ -786,11 +861,10 @@ test.describe("Native scoped Receipt approval journey", () => { expectNoReceiptFileHeaders(unauthenticatedApprovalFileResponse); - const invalidApprovalFileResponse = await request.get( - approvalFilePath(approvedReceipt.projection.receiptId), - { - headers: sessionHeaders("better-auth.session_token=invalid-local-receipt-approval-session"), - }, + const invalidApprovalFileResponse = await approvalFile( + request, + approvedReceipt.projection.receiptId, + "better-auth.session_token=invalid-local-receipt-approval-session", ); const invalidApprovalFileTag = await expectProblemCode( @@ -801,61 +875,42 @@ test.describe("Native scoped Receipt approval journey", () => { expectNoReceiptFileHeaders(invalidApprovalFileResponse); - const activePngApprovalFileResponse = await request.get( - approvalFilePath(approvedReceipt.projection.receiptId), - { headers: sessionHeaders(sessions.departmentA.cookie) }, + const activePngApprovalFileResponse = await approvalFile( + request, + approvedReceipt.projection.receiptId, + sessions.departmentA.cookie, ); - const activePngApprovalFile = await expectApprovedReceiptFile( + const activePngApprovalFile = expectApprovedRpcFile( activePngApprovalFileResponse, approvedReceipt.file, fileIdentitiesBefore, ); - expect(activePngApprovalFileResponse.url()).toBe( - approvalFilePath(approvedReceipt.projection.receiptId), - ); - - const activePdfApprovalFileResponse = await request.get( - approvalFilePath(rejectReceipt.projection.receiptId), - { headers: sessionHeaders(sessions.departmentB.cookie) }, + const activePdfApprovalFileResponse = await approvalFile( + request, + rejectReceipt.projection.receiptId, + sessions.departmentB.cookie, ); - const activePdfApprovalFile = await expectApprovedReceiptFile( + const activePdfApprovalFile = expectApprovedRpcFile( activePdfApprovalFileResponse, rejectReceipt.file, fileIdentitiesBefore, ); - expect(activePdfApprovalFileResponse.url()).toBe( - approvalFilePath(rejectReceipt.projection.receiptId), - ); - - const ownerFileResponse = await request.get( - ownerFilePath(approvedReceipt.projection.receiptId), - { - headers: sessionHeaders(sessions.ownerA.cookie), - }, + const ownerFileResponse = await ownerFile( + request, + approvedReceipt.projection.receiptId, + sessions.ownerA.cookie, ); - expect(ownerFileResponse.status()).toBe(200); - - const ownerFileMetadata = [ - ownerFileResponse.url(), - ...Object.values(ownerFileResponse.headers()), - ].join("\n"); - - for (const identity of fileIdentitiesBefore) { - expect(ownerFileMetadata).not.toContain(identity.fileRef); - expect(ownerFileMetadata).not.toContain(identity.objectKey); - expect(ownerFileMetadata).not.toContain(identity.sha256); - } - - expect(Buffer.compare(await ownerFileResponse.body(), approvedReceipt.file.bytes)).toBe(0); + expectApprovedRpcFile(ownerFileResponse, approvedReceipt.file, fileIdentitiesBefore); - const ownerApprovalFileResponse = await request.get( - approvalFilePath(approvedReceipt.projection.receiptId), - { headers: sessionHeaders(sessions.ownerA.cookie) }, + const ownerApprovalFileResponse = await approvalFile( + request, + approvedReceipt.projection.receiptId, + sessions.ownerA.cookie, ); const ownerApprovalFileTag = await expectProblemCode( @@ -866,11 +921,10 @@ test.describe("Native scoped Receipt approval journey", () => { expectNoReceiptFileHeaders(ownerApprovalFileResponse); - const foreignOwnerFileResponse = await request.get( - ownerFilePath(approvedReceipt.projection.receiptId), - { - headers: sessionHeaders(sessions.ownerB.cookie), - }, + const foreignOwnerFileResponse = await ownerFile( + request, + approvedReceipt.projection.receiptId, + sessions.ownerB.cookie, ); const foreignOwnerFileTag = await expectProblemCode( @@ -881,11 +935,10 @@ test.describe("Native scoped Receipt approval journey", () => { expectNoReceiptFileHeaders(foreignOwnerFileResponse); - const approverOwnerFileResponse = await request.get( - ownerFilePath(approvedReceipt.projection.receiptId), - { - headers: sessionHeaders(sessions.departmentA.cookie), - }, + const approverOwnerFileResponse = await ownerFile( + request, + approvedReceipt.projection.receiptId, + sessions.departmentA.cookie, ); const approverOwnerFileTag = await expectProblemCode( @@ -896,9 +949,10 @@ test.describe("Native scoped Receipt approval journey", () => { expectNoReceiptFileHeaders(approverOwnerFileResponse); - const foreignApprovalFileResponse = await request.get( - approvalFilePath(rejectReceipt.projection.receiptId), - { headers: sessionHeaders(sessions.departmentA.cookie) }, + const foreignApprovalFileResponse = await approvalFile( + request, + rejectReceipt.projection.receiptId, + sessions.departmentA.cookie, ); const foreignApprovalFileTag = await expectProblemCode( @@ -909,9 +963,10 @@ test.describe("Native scoped Receipt approval journey", () => { expectNoReceiptFileHeaders(foreignApprovalFileResponse); - const inactiveApprovalFileResponse = await request.get( - approvalFilePath(approvedReceipt.projection.receiptId), - { headers: sessionHeaders(sessions.inactive.cookie) }, + const inactiveApprovalFileResponse = await approvalFile( + request, + approvedReceipt.projection.receiptId, + sessions.inactive.cookie, ); const inactiveApprovalFileTag = await expectProblemCode( @@ -922,9 +977,10 @@ test.describe("Native scoped Receipt approval journey", () => { expectNoReceiptFileHeaders(inactiveApprovalFileResponse); - const noScopeApprovalFileResponse = await request.get( - approvalFilePath(approvedReceipt.projection.receiptId), - { headers: sessionHeaders(sessions.noneScope.cookie) }, + const noScopeApprovalFileResponse = await approvalFile( + request, + approvedReceipt.projection.receiptId, + sessions.noneScope.cookie, ); const noScopeApprovalFileTag = await expectProblemCode( @@ -935,9 +991,10 @@ test.describe("Native scoped Receipt approval journey", () => { expectNoReceiptFileHeaders(noScopeApprovalFileResponse); - const absentApprovalFileResponse = await request.get( - approvalFilePath(`receipt-absent-file-${randomUUID()}`), - { headers: sessionHeaders(sessions.global.cookie) }, + const absentApprovalFileResponse = await approvalFile( + request, + `receipt-absent-file-${randomUUID()}`, + sessions.global.cookie, ); const absentApprovalFileTag = await expectProblemCode( @@ -1025,12 +1082,13 @@ test.describe("Native scoped Receipt approval journey", () => { await rename(committedFilePath, unavailableFilePath); try { - const missingObjectApprovalFileResponse = await request.get( - approvalFilePath(approvedReceipt.projection.receiptId), - { headers: sessionHeaders(sessions.departmentA.cookie) }, + const missingObjectApprovalFileResponse = await approvalFile( + request, + approvedReceipt.projection.receiptId, + sessions.departmentA.cookie, ); - missingObjectApprovalFileStatus = missingObjectApprovalFileResponse.status(); + missingObjectApprovalFileStatus = missingObjectApprovalFileResponse.status; missingObjectApprovalFileTag = await expectProblemCode( missingObjectApprovalFileResponse, 503, @@ -1060,15 +1118,14 @@ test.describe("Native scoped Receipt approval journey", () => { const fileReadMutationCountsAfter = await readReceiptMutationCounts(); expect(fileReadMutationCountsAfter).toEqual(fileReadMutationCountsBefore); - const inactiveCommandResponse = await request.post( - actionPath(approvedReceipt.projection.receiptId, "approve"), + const inactiveCommandResponse = await receiptAction( + request, + approvedReceipt.projection.receiptId, + "approve", { - headers: actionHeaders( - sessions.inactive.cookie, - randomUUID(), - approvedReceipt.projection.etag, - ), - data: {}, + cookie: sessions.inactive.cookie, + idempotencyKey: randomUUID(), + ifMatch: approvedReceipt.projection.etag, }, ); @@ -1078,68 +1135,8 @@ test.describe("Native scoped Receipt approval journey", () => { "authority.denied", ); - const malformedJsonResponse = await request.post( - actionPath(approvedReceipt.projection.receiptId, "approve"), - { - headers: actionHeaders( - sessions.departmentA.cookie, - randomUUID(), - approvedReceipt.projection.etag, - ), - data: Buffer.from("{", "utf8"), - }, - ); - - const malformedJsonTag = await expectProblemCode( - malformedJsonResponse, - 400, - "request.malformed", - ); - - const excessJsonResponse = await request.post( - actionPath(approvedReceipt.projection.receiptId, "approve"), - { - headers: actionHeaders( - sessions.departmentA.cookie, - randomUUID(), - approvedReceipt.projection.etag, - ), - data: { unexpected: true }, - }, - ); - - const excessJsonTag = await expectProblemCode(excessJsonResponse, 422, "validation.failed"); - - const queryRejectedResponse = await request.post( - `${actionPath(approvedReceipt.projection.receiptId, "approve")}?unexpected=1`, - { - headers: actionHeaders( - sessions.departmentA.cookie, - randomUUID(), - approvedReceipt.projection.etag, - ), - data: {}, - }, - ); - - const queryRejectedTag = await expectProblemCode( - queryRejectedResponse, - 400, - "request.malformed", - ); - - const invalidFilterResponse = await request.get( - `${BACKEND_ORIGIN}/api/receipt-approval-queue?status=Pending&unexpected=1`, - { - headers: sessionHeaders(sessions.departmentA.cookie), - }, - ); - - const invalidFilterTag = await expectProblemCode( - invalidFilterResponse, - 400, - "request.malformed", - ); + // A malformed body, an excess member, a query, or an unknown filter cannot reach a receipt RPC: + // the payload schema has no place for them, so those HTTP probes are gone. const departmentAId = approvedReceipt.projection.departmentId; const departmentBId = rejectReceipt.projection.departmentId; @@ -1310,15 +1307,14 @@ test.describe("Native scoped Receipt approval journey", () => { await expect(receiptRowFor(page, approvedReceipt.projection.receiptId)).toHaveCount(1); await expect(receiptRowFor(page, rejectReceipt.projection.receiptId)).toHaveCount(1); - const foreignScopeResponse = await request.post( - actionPath(rejectReceipt.projection.receiptId, "approve"), + const foreignScopeResponse = await receiptAction( + request, + rejectReceipt.projection.receiptId, + "approve", { - headers: actionHeaders( - sessions.departmentA.cookie, - randomUUID(), - rejectReceipt.projection.etag, - ), - data: {}, + cookie: sessions.departmentA.cookie, + idempotencyKey: randomUUID(), + ifMatch: rejectReceipt.projection.etag, }, ); @@ -1326,20 +1322,18 @@ test.describe("Native scoped Receipt approval journey", () => { const absentReceiptId = `receipt-absent-${randomUUID()}`; - const absentScopeResponse = await request.post(actionPath(absentReceiptId, "approve"), { - headers: actionHeaders( - sessions.departmentA.cookie, - randomUUID(), - approvedReceipt.projection.etag, - ), - data: {}, + const absentScopeResponse = await receiptAction(request, absentReceiptId, "approve", { + cookie: sessions.departmentA.cookie, + idempotencyKey: randomUUID(), + ifMatch: approvedReceipt.projection.etag, }); const absentScopeTag = await expectProblemCode(absentScopeResponse, 404, "receipt.not-found"); - const globalAbsentResponse = await request.post(actionPath(absentReceiptId, "approve"), { - headers: actionHeaders(sessions.global.cookie, randomUUID(), approvedReceipt.projection.etag), - data: {}, + const globalAbsentResponse = await receiptAction(request, absentReceiptId, "approve", { + cookie: sessions.global.cookie, + idempotencyKey: randomUUID(), + ifMatch: approvedReceipt.projection.etag, }); const globalAbsentTag = await expectProblemCode(globalAbsentResponse, 404, "receipt.not-found"); @@ -1406,21 +1400,19 @@ test.describe("Native scoped Receipt approval journey", () => { await expect(approvedRow.locator('[data-revision="1"]')).toBeVisible(); await expectNoApproveOrRejectControls(approvedRow); - const approvalReplayResponse = await request.post( - actionPath(approvedReceipt.projection.receiptId, "approve"), + const approvalReplayResponse = await receiptAction( + request, + approvedReceipt.projection.receiptId, + "approve", { - headers: actionHeaders( - sessions.global.cookie, - approvalMutation.idempotencyKey, - approvalMutation.ifMatch, - ), - data: {}, + cookie: sessions.global.cookie, + idempotencyKey: approvalMutation.idempotencyKey, + ifMatch: approvalMutation.ifMatch, }, ); - expect(approvalReplayResponse.status()).toBe(200); - const approvalReplay = decodeReceiptResource(await approvalReplayResponse.json(), exactDecoding); - expect(approvalReplayResponse.headers()["etag"]).toBe(approvalReplay.etag); + expect(approvalReplayResponse.status).toBe(200); + const approvalReplay = decodeReceiptResource(approvalReplayResponse.value, exactDecoding); expect(approvalReplay).toMatchObject({ receiptId: approvedReceipt.projection.receiptId, status: "Approved", @@ -1428,15 +1420,14 @@ test.describe("Native scoped Receipt approval journey", () => { }); await listForApproval(request, sessions.global.cookie); - const conflictingReplayResponse = await request.post( - actionPath(approvedReceipt.projection.receiptId, "approve"), + const conflictingReplayResponse = await receiptAction( + request, + approvedReceipt.projection.receiptId, + "approve", { - headers: actionHeaders( - sessions.global.cookie, - approvalMutation.idempotencyKey, - approvalReplay.etag, - ), - data: {}, + cookie: sessions.global.cookie, + idempotencyKey: approvalMutation.idempotencyKey, + ifMatch: approvalReplay.etag, }, ); @@ -1446,11 +1437,14 @@ test.describe("Native scoped Receipt approval journey", () => { "idempotency.digest-conflict", ); - const staleTerminalResponse = await request.post( - actionPath(approvedReceipt.projection.receiptId, "reject"), + const staleTerminalResponse = await receiptAction( + request, + approvedReceipt.projection.receiptId, + "reject", { - headers: actionHeaders(sessions.global.cookie, randomUUID(), approvalMutation.ifMatch), - data: {}, + cookie: sessions.global.cookie, + idempotencyKey: randomUUID(), + ifMatch: approvalMutation.ifMatch, }, ); @@ -1460,11 +1454,14 @@ test.describe("Native scoped Receipt approval journey", () => { "precondition.failed", ); - const terminalApprovedResponse = await request.post( - actionPath(approvedReceipt.projection.receiptId, "reject"), + const terminalApprovedResponse = await receiptAction( + request, + approvedReceipt.projection.receiptId, + "reject", { - headers: actionHeaders(sessions.global.cookie, randomUUID(), approvalReplay.etag), - data: {}, + cookie: sessions.global.cookie, + idempotencyKey: randomUUID(), + ifMatch: approvalReplay.etag, }, ); @@ -1485,21 +1482,19 @@ test.describe("Native scoped Receipt approval journey", () => { await expect(rejectRow.locator('[data-revision="1"]')).toHaveText("Versjon 1"); await expectNoApproveOrRejectControls(rejectRow); - const rejectReplayResponse = await request.post( - actionPath(rejectReceipt.projection.receiptId, "reject"), + const rejectReplayResponse = await receiptAction( + request, + rejectReceipt.projection.receiptId, + "reject", { - headers: actionHeaders( - sessions.global.cookie, - rejectMutation.idempotencyKey, - rejectMutation.ifMatch, - ), - data: {}, + cookie: sessions.global.cookie, + idempotencyKey: rejectMutation.idempotencyKey, + ifMatch: rejectMutation.ifMatch, }, ); - expect(rejectReplayResponse.status()).toBe(200); - const rejectReplay = decodeReceiptResource(await rejectReplayResponse.json(), exactDecoding); - expect(rejectReplayResponse.headers()["etag"]).toBe(rejectReplay.etag); + expect(rejectReplayResponse.status).toBe(200); + const rejectReplay = decodeReceiptResource(rejectReplayResponse.value, exactDecoding); expect(rejectReplay).toMatchObject({ receiptId: rejectReceipt.projection.receiptId, status: "Rejected", @@ -1507,12 +1502,11 @@ test.describe("Native scoped Receipt approval journey", () => { }); await listForApproval(request, sessions.global.cookie); - const terminalRejectResponse = await request.post( - actionPath(rejectReceipt.projection.receiptId, "approve"), - { - headers: actionHeaders(sessions.global.cookie, randomUUID(), rejectReplay.etag), - data: {}, - }, + const terminalRejectResponse = await receiptAction( + request, + rejectReceipt.projection.receiptId, + "approve", + { cookie: sessions.global.cookie, idempotencyKey: randomUUID(), ifMatch: rejectReplay.etag }, ); const terminalRejectTag = await expectProblemCode( @@ -1523,23 +1517,25 @@ test.describe("Native scoped Receipt approval journey", () => { const terminalFileReadMutationCountsBefore = await readReceiptMutationCounts(); - const terminalApprovedApprovalFileResponse = await request.get( - approvalFilePath(approvedReceipt.projection.receiptId), - { headers: sessionHeaders(sessions.global.cookie) }, + const terminalApprovedApprovalFileResponse = await approvalFile( + request, + approvedReceipt.projection.receiptId, + sessions.global.cookie, ); - const terminalApprovedApprovalFile = await expectApprovedReceiptFile( + const terminalApprovedApprovalFile = expectApprovedRpcFile( terminalApprovedApprovalFileResponse, approvedReceipt.file, fileIdentitiesBefore, ); - const terminalRejectApprovalFileResponse = await request.get( - approvalFilePath(rejectReceipt.projection.receiptId), - { headers: sessionHeaders(sessions.global.cookie) }, + const terminalRejectApprovalFileResponse = await approvalFile( + request, + rejectReceipt.projection.receiptId, + sessions.global.cookie, ); - const terminalRejectApprovalFile = await expectApprovedReceiptFile( + const terminalRejectApprovalFile = expectApprovedRpcFile( terminalRejectApprovalFileResponse, rejectReceipt.file, fileIdentitiesBefore, @@ -1595,26 +1591,24 @@ test.describe("Native scoped Receipt approval journey", () => { const externalResolutionIdempotencyKey = randomUUID(); - const externalResolutionResponse = await request.post( - actionPath(staleReceipt.projection.receiptId, "reject"), + const externalResolutionResponse = await receiptAction( + request, + staleReceipt.projection.receiptId, + "reject", { - headers: actionHeaders( - sessions.global.cookie, - externalResolutionIdempotencyKey, - staleReceipt.projection.etag, - ), - data: {}, + cookie: sessions.global.cookie, + idempotencyKey: externalResolutionIdempotencyKey, + ifMatch: staleReceipt.projection.etag, }, ); - expect(externalResolutionResponse.status()).toBe(200); + expect(externalResolutionResponse.status).toBe(200); const externalResolution = decodeReceiptResource( - await externalResolutionResponse.json(), + externalResolutionResponse.value, exactDecoding, ); - expect(externalResolutionResponse.headers()["etag"]).toBe(externalResolution.etag); expect(externalResolution).toMatchObject({ status: "Rejected", revision: 1, @@ -1642,48 +1636,41 @@ test.describe("Native scoped Receipt approval journey", () => { const [concurrentApprovalFileResponse, concurrentApproveResponse, concurrentRejectResponse] = await Promise.all([ - request.get(approvalFilePath(concurrentReceipt.projection.receiptId), { - headers: { - ...sessionHeaders(sessions.global.cookie), - [concurrencyProbeHeader]: "file-read", - }, - }), - request.post(actionPath(concurrentReceipt.projection.receiptId, "approve"), { - headers: { - ...actionHeaders( - sessions.global.cookie, - concurrentApproveIdempotencyKey, - concurrentReceipt.projection.etag, - ), - [concurrencyProbeHeader]: "approve", - }, - data: {}, + approvalFile( + request, + concurrentReceipt.projection.receiptId, + sessions.global.cookie, + "file-read", + ), + receiptAction(request, concurrentReceipt.projection.receiptId, "approve", { + cookie: sessions.global.cookie, + idempotencyKey: concurrentApproveIdempotencyKey, + ifMatch: concurrentReceipt.projection.etag, + probe: "approve", }), - request.post(actionPath(concurrentReceipt.projection.receiptId, "reject"), { - headers: { - ...actionHeaders( - sessions.global.cookie, - concurrentRejectIdempotencyKey, - concurrentReceipt.projection.etag, - ), - [concurrencyProbeHeader]: "reject", - }, - data: {}, + receiptAction(request, concurrentReceipt.projection.receiptId, "reject", { + cookie: sessions.global.cookie, + idempotencyKey: concurrentRejectIdempotencyKey, + ifMatch: concurrentReceipt.projection.etag, + probe: "reject", }), ]); + // An RPC answer carries no synchronization header. The barrier holds each probed lane until + // all three arrive and times a missing lane out as receipts.unavailable, so three answers + // other than a timeout or a defect show that the lanes met inside their transactions. for (const [lane, response] of [ ["file-read", concurrentApprovalFileResponse], ["approve", concurrentApproveResponse], ["reject", concurrentRejectResponse], ] as const) { expect( - response.headers()[concurrencySynchronizedHeader], - `${lane} response status ${response.status()} did not prove barrier synchronization`, - ).toBe("1"); + [500, 503].includes(response.status), + `${lane} response status ${response.status} did not prove barrier synchronization`, + ).toBe(false); } - const concurrentApprovalFile = await expectApprovedReceiptFile( + const concurrentApprovalFile = expectApprovedRpcFile( concurrentApprovalFileResponse, concurrentReceipt.file, fileIdentitiesBefore, @@ -1710,29 +1697,22 @@ test.describe("Native scoped Receipt approval journey", () => { }, ]; - expect(concurrentAttempts.filter((attempt) => attempt.response.status() === 200)).toHaveLength( - 1, - ); - expect(concurrentAttempts.filter((attempt) => attempt.response.status() === 412)).toHaveLength( - 1, - ); + expect(concurrentAttempts.filter((attempt) => attempt.response.status === 200)).toHaveLength(1); + expect(concurrentAttempts.filter((attempt) => attempt.response.status === 412)).toHaveLength(1); - const concurrentWinner = concurrentAttempts.find( - (attempt) => attempt.response.status() === 200, - ); + const concurrentWinner = concurrentAttempts.find((attempt) => attempt.response.status === 200); - const concurrentLoser = concurrentAttempts.find((attempt) => attempt.response.status() === 412); + const concurrentLoser = concurrentAttempts.find((attempt) => attempt.response.status === 412); if (concurrentWinner === undefined || concurrentLoser === undefined) { throw new Error("Concurrent Receipt resolution did not produce exactly one winner and loser"); } const concurrentObservation = decodeReceiptResource( - await concurrentWinner.response.json(), + concurrentWinner.response.value, exactDecoding, ); - expect(concurrentWinner.response.headers()["etag"]).toBe(concurrentObservation.etag); expect(concurrentObservation).toMatchObject({ receiptId: concurrentReceipt.projection.receiptId, status: concurrentWinner.intent === "approve" ? "Approved" : "Rejected", @@ -1747,26 +1727,21 @@ test.describe("Native scoped Receipt approval journey", () => { await listForApproval(request, sessions.global.cookie); - const concurrentReplayResponse = await request.post( - actionPath(concurrentReceipt.projection.receiptId, concurrentWinner.intent), + const concurrentReplayResponse = await receiptAction( + request, + concurrentReceipt.projection.receiptId, + concurrentWinner.intent, { - headers: actionHeaders( - sessions.global.cookie, - concurrentWinner.idempotencyKey, - concurrentReceipt.projection.etag, - ), - data: {}, + cookie: sessions.global.cookie, + idempotencyKey: concurrentWinner.idempotencyKey, + ifMatch: concurrentReceipt.projection.etag, }, ); - expect(concurrentReplayResponse.status()).toBe(200); + expect(concurrentReplayResponse.status).toBe(200); - const concurrentReplay = decodeReceiptResource( - await concurrentReplayResponse.json(), - exactDecoding, - ); + const concurrentReplay = decodeReceiptResource(concurrentReplayResponse.value, exactDecoding); - expect(concurrentReplayResponse.headers()["etag"]).toBe(concurrentReplay.etag); expect(concurrentReplay).toEqual(concurrentObservation); await listForApproval(request, sessions.global.cookie); @@ -1992,10 +1967,10 @@ test.describe("Native scoped Receipt approval journey", () => { }, statusMatrix: { approvalList: { - missingSession: unauthenticatedResponse.status(), - invalidSession: expiredApiResponse.status(), - inactiveActor: inactiveResponse.status(), - noScopeActor: noneScopeResponse.status(), + missingSession: unauthenticatedResponse.status, + invalidSession: expiredApiResponse.status, + inactiveActor: inactiveResponse.status, + noScopeActor: noneScopeResponse.status, departmentA: 200, departmentB: 200, global: 200, @@ -2003,43 +1978,39 @@ test.describe("Native scoped Receipt approval journey", () => { recoveredAfterPostgresFailure: 200, }, command: { - inactiveActor: inactiveCommandResponse.status(), - malformedJson: malformedJsonResponse.status(), - excessJson: excessJsonResponse.status(), - queryParameters: queryRejectedResponse.status(), - foreignDepartment: foreignScopeResponse.status(), - absentDepartmentScope: absentScopeResponse.status(), - absentGlobalScope: globalAbsentResponse.status(), + inactiveActor: inactiveCommandResponse.status, + foreignDepartment: foreignScopeResponse.status, + absentDepartmentScope: absentScopeResponse.status, + absentGlobalScope: globalAbsentResponse.status, acceptedApproval: 200, acceptedReject: 200, - identicalApprovalReplay: approvalReplayResponse.status(), - identicalRejectReplay: rejectReplayResponse.status(), - changedReplay: conflictingReplayResponse.status(), - staleRevision: staleTerminalResponse.status(), - terminalApproved: terminalApprovedResponse.status(), - terminalReject: terminalRejectResponse.status(), - concurrent: [ - concurrentApproveResponse.status(), - concurrentRejectResponse.status(), - ].sort((left, right) => left - right), + identicalApprovalReplay: approvalReplayResponse.status, + identicalRejectReplay: rejectReplayResponse.status, + changedReplay: conflictingReplayResponse.status, + staleRevision: staleTerminalResponse.status, + terminalApproved: terminalApprovedResponse.status, + terminalReject: terminalRejectResponse.status, + concurrent: [concurrentApproveResponse.status, concurrentRejectResponse.status].sort( + (left, right) => left - right, + ), }, approvalFile: { - missingSession: unauthenticatedApprovalFileResponse.status(), - invalidSession: invalidApprovalFileResponse.status(), - activePng: activePngApprovalFileResponse.status(), - activePdf: activePdfApprovalFileResponse.status(), - ownerEndpoint: ownerFileResponse.status(), - ownerApproval: ownerApprovalFileResponse.status(), - foreignOwner: foreignOwnerFileResponse.status(), - approverOwner: approverOwnerFileResponse.status(), - foreignScope: foreignApprovalFileResponse.status(), - inactive: inactiveApprovalFileResponse.status(), - noScope: noScopeApprovalFileResponse.status(), - absent: absentApprovalFileResponse.status(), + missingSession: unauthenticatedApprovalFileResponse.status, + invalidSession: invalidApprovalFileResponse.status, + activePng: activePngApprovalFileResponse.status, + activePdf: activePdfApprovalFileResponse.status, + ownerEndpoint: ownerFileResponse.status, + ownerApproval: ownerApprovalFileResponse.status, + foreignOwner: foreignOwnerFileResponse.status, + approverOwner: approverOwnerFileResponse.status, + foreignScope: foreignApprovalFileResponse.status, + inactive: inactiveApprovalFileResponse.status, + noScope: noScopeApprovalFileResponse.status, + absent: absentApprovalFileResponse.status, missingObject: missingObjectApprovalFileStatus, - terminalApproved: terminalApprovedApprovalFileResponse.status(), - terminalReject: terminalRejectApprovalFileResponse.status(), - concurrent: concurrentApprovalFileResponse.status(), + terminalApproved: terminalApprovedApprovalFileResponse.status, + terminalReject: terminalRejectApprovalFileResponse.status, + concurrent: concurrentApprovalFileResponse.status, dashboard: { missingSession: dashboardMissingSessionFileResponse.status(), invalidSession: dashboardInvalidSessionFileResponse.status(), @@ -2093,10 +2064,6 @@ test.describe("Native scoped Receipt approval journey", () => { absentScope: absentScopeTag, globalAbsent: globalAbsentTag, browserScope: "ReceiptScopeDenied", - malformedJson: malformedJsonTag, - excessJson: excessJsonTag, - queryRejected: queryRejectedTag, - invalidFilter: invalidFilterTag, conflictingReplay: conflictingReplayTag, staleTerminal: staleTerminalTag, terminalApproved: terminalApprovedTag, diff --git a/apps/dashboard/e2e/receipt-rpc.ts b/apps/dashboard/e2e/receipt-rpc.ts new file mode 100644 index 000000000..e51969bef --- /dev/null +++ b/apps/dashboard/e2e/receipt-rpc.ts @@ -0,0 +1,56 @@ +/** + * Receipt RPCs as a browser or a journey driver posts them: one RPC request message to the RPC + * endpoint, with the credential, the origin, and any probe as real HTTP headers. The answer keeps + * the status that its problem's registry entry gives it, so a journey compares statuses as it did + * under the HTTP contract. + */ +import type { APIRequestContext } from "@playwright/test"; +import { internalNativeRpcPath, nativeRpcPath, type NativeProblem } from "@vektorprogrammet/rpc"; +import { Predicate, type Schema } from "effect"; +import { nativeRpcOutcome, nativeRpcRequestBody } from "./native-operations.js"; + +/** What one RPC answered: its registry status, its value on success, and its problem otherwise. */ +export interface NativeRpcAnswer { + readonly status: number; + readonly value: Schema.Json | undefined; + readonly problem: NativeProblem | undefined; +} + +/** The answer that one RPC response body carries; a body that is no RPC response is a 500. */ +export const nativeRpcAnswer = (body: string): NativeRpcAnswer => { + const outcome = nativeRpcOutcome(body); + + if (Predicate.isTagged(outcome, "Success")) { + return { status: 200, value: outcome.value, problem: undefined }; + } + + if (Predicate.isTagged(outcome, "Problem")) { + return { status: outcome.status, value: undefined, problem: outcome.problem }; + } + + return { status: 500, value: undefined, problem: undefined }; +}; + +/** The base64 text that carries bytes in an RPC payload or success. */ +export const rpcBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64"); + +/** Posts one RPC to the external endpoint, or to the internal one when `internal` is set. */ +export async function postNativeRpc( + request: APIRequestContext, + input: { + readonly origin: string; + readonly tag: string; + readonly payload?: Schema.Json; + readonly headers: Readonly>; + readonly internal?: boolean; + }, +): Promise { + const path = input.internal === true ? internalNativeRpcPath : nativeRpcPath; + + const response = await request.post(`${input.origin}${path}`, { + headers: { ...input.headers, "content-type": "application/json" }, + data: nativeRpcRequestBody(input.tag, input.payload ?? null), + }); + + return nativeRpcAnswer(await response.text()); +} diff --git a/apps/dashboard/e2e/receipts.spec.ts b/apps/dashboard/e2e/receipts.spec.ts index ffd132d3d..7d35cbb0c 100644 --- a/apps/dashboard/e2e/receipts.spec.ts +++ b/apps/dashboard/e2e/receipts.spec.ts @@ -3,23 +3,17 @@ import { spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { chmod, readdir, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { expect, test, type APIRequestContext, type Browser, type Page } from "@playwright/test"; +import { RECEIPT_FILE_MAX_BYTES } from "@vektorprogrammet/domain/receipt"; import { - expect, - test, - type APIRequestContext, - type APIResponse, - type Browser, - type Page, -} from "@playwright/test"; -import { - NativeProblem, - RECEIPT_FILE_MAX_BYTES, - ReadReceiptEvidenceEndpoint, ReceiptLifecycleEvidenceResponse, ReceiptListResponse, ReceiptResource, - UserProfileResponse, -} from "@vektorprogrammet/http-api"; + nativeRpcPath, + OwnProfileResource, +} from "@vektorprogrammet/rpc"; +import { nativeRpcRequestBody, nativeRpcStatus, nativeRpcValue } from "./native-operations.js"; +import { type NativeRpcAnswer, postNativeRpc, rpcBytes } from "./receipt-rpc.js"; import { dashboardBaseUrl, dashboardMount } from "../dashboard-base"; type JourneyOutcome = Data.TaggedEnum<{ @@ -75,15 +69,13 @@ const RECEIPT_BYTES = Buffer.from( // Responses decode with the API contract, so a contract change fails here instead of a copy. const exactDecoding = { onExcessProperty: "error" } as const; -const decodeProblem = Schema.decodeUnknownSync(NativeProblem); - const decodeReceiptResource = Schema.decodeUnknownSync(ReceiptResource); const decodeReceiptPage = Schema.decodeUnknownSync(ReceiptListResponse); const decodeLifecycleEvidence = Schema.decodeUnknownSync(ReceiptLifecycleEvidenceResponse); -const decodeProfile = Schema.decodeUnknownSync(UserProfileResponse); +const decodeProfile = Schema.decodeUnknownSync(OwnProfileResource); /** Settings of the operator drain, which the runner owns. */ const decodeOperatorEnvironment = Schema.decodeUnknownSync( @@ -198,13 +190,16 @@ async function authenticate( if (sessionCookie === undefined) throw new Error("Better Auth session cookie is missing"); const cookie = `${sessionCookie.name}=${sessionCookie.value}`; - const profileResponse = await request.get(`${BACKEND_ORIGIN}/api/profile`, { - headers: sessionHeaders(cookie), - }); + const profileAnswer = await ( + await request.post(`${BACKEND_ORIGIN}${nativeRpcPath}`, { + headers: { ...sessionHeaders(cookie), "content-type": "application/json" }, + data: nativeRpcRequestBody("profile.readOwnProfile"), + }) + ).text(); - expect(profileResponse.status()).toBe(200); - expect(decodeProfile(await profileResponse.json(), exactDecoding)).toMatchObject({ - personId: persona.personId, + expect(nativeRpcStatus(profileAnswer)).toBe(200); + expect(decodeProfile(nativeRpcValue(profileAnswer), exactDecoding)).toMatchObject({ + profile: { personId: persona.personId }, }); return cookie; @@ -270,16 +265,15 @@ async function captureLifecycleEvidence( readonly committed: ReadonlyArray; }; }> { - const evidencePath = ReadReceiptEvidenceEndpoint.path.replace( - ":receiptId", - encodeURIComponent(receiptId), - ); - - const response = await request.get(`${INTERNAL_BACKEND_ORIGIN}${evidencePath}`, { + const answer = await postNativeRpc(request, { + origin: INTERNAL_BACKEND_ORIGIN, + tag: "receipts.readReceiptEvidence", + payload: { receiptId }, headers: sessionHeaders(sessionCookie), + internal: true, }); - expect(response.status()).toBe(200); + expect(answer.status).toBe(200); const stagingRoot = process.env.RECEIPT_E2E_STAGING_ROOT; const committedRoot = process.env.RECEIPT_E2E_COMMITTED_ROOT; @@ -287,7 +281,7 @@ async function captureLifecycleEvidence( throw new Error("Receipt lifecycle evidence roots are missing"); } - const evidence = decodeLifecycleEvidence(await response.json(), exactDecoding); + const evidence = decodeLifecycleEvidence(answer.value, exactDecoding); return { ...evidence, @@ -299,22 +293,28 @@ async function captureLifecycleEvidence( } async function expectProblemCode( - response: APIResponse, + answer: NativeRpcAnswer, expectedStatus: number, expectedCode: string, ): Promise { - expect(response.status()).toBe(expectedStatus); - expect(response.headers()["content-type"]).toContain("application/problem+json"); - const problem = decodeProblem(await response.json(), exactDecoding); - expect(problem).toMatchObject({ + expect(answer.status).toBe(expectedStatus); + expect(answer.problem).toMatchObject({ status: expectedStatus, code: expectedCode, type: `urn:vektorprogrammet:problem:v0.2:${expectedCode}`, }); - return problem.code; + return answer.problem?.code ?? "missing"; } +/** One receipt RPC to the backend, as the headers given. */ +const receiptRpc = ( + request: APIRequestContext, + tag: string, + payload: Schema.Json, + headers: Readonly>, +) => postNativeRpc(request, { origin: BACKEND_ORIGIN, tag, payload, headers }); + function receiptRowFor(page: Page, receiptId: string) { return page.locator(`tr[data-receipt-id=${JSON.stringify(receiptId)}]`); } @@ -327,7 +327,14 @@ test.describe("Native Receipt owner journey", () => { page, request, }) => { - const unauthenticatedResponse = await request.get(`${BACKEND_ORIGIN}/api/receipts`); + const unauthenticatedResponse = await receiptRpc( + request, + "receipts.listReceipts", + {}, + { + Origin: DASHBOARD_ORIGIN, + }, + ); const unauthenticatedTag = await expectProblemCode( unauthenticatedResponse, @@ -481,10 +488,7 @@ test.describe("Native Receipt owner journey", () => { const submissionSuccess = submissionForm.getByRole("status"); await expect(submissionSuccess).toBeVisible(); - await expect(submissionSuccess).toHaveAttribute( - "data-command-id", - submissionIdempotencyKey, - ); + await expect(submissionSuccess).toHaveAttribute("data-command-id", submissionIdempotencyKey); let receiptRow = page.locator("[data-receipt-id]").filter({ hasText: DESCRIPTION }); await expect(receiptRow).toHaveCount(1); const receiptId = (await receiptRow.getByTestId("receipt-id").textContent())?.trim(); @@ -507,42 +511,40 @@ test.describe("Native Receipt owner journey", () => { await expect(receiptRow).toContainText(RECEIPT_DATE); await expect(receiptRow.locator('[data-status="Pending"]')).toBeVisible(); - const submitReplayResponse = await request.post(`${BACKEND_ORIGIN}/api/receipts`, { - headers: { - ...authorization, - "Idempotency-Key": submissionIdempotencyKey, - }, - multipart: { - description: DESCRIPTION, - amountOre: String(AMOUNT_ORE), - receiptDate: RECEIPT_DATE, - file: { - name: "receipt.png", - mimeType: "image/png", - buffer: RECEIPT_BYTES, + const submitReplayResponse = await receiptRpc( + request, + "receipts.submitReceipt", + { + idempotencyKey: submissionIdempotencyKey, + request: { + description: DESCRIPTION, + amountOre: AMOUNT_ORE, + receiptDate: RECEIPT_DATE, + file: { contentType: "image/png", bytes: rpcBytes(RECEIPT_BYTES) }, }, }, - }); + authorization, + ); - expect(submitReplayResponse.status()).toBe(201); - const submitReplay = decodeReceiptResource(await submitReplayResponse.json(), exactDecoding); - expect(submitReplayResponse.headers()["etag"]).toBe(submitReplay.etag); + // A replay answers the first submission's resource; RPC answers carry no 201 or ETag header. + expect(submitReplayResponse.status).toBe(200); + const submitReplay = decodeReceiptResource(submitReplayResponse.value, exactDecoding); expect(submitReplay).toMatchObject({ receiptId, status: "Pending", revision: 0, }); - const ownedAtRevisionZeroResponse = await request.get(`${BACKEND_ORIGIN}/api/receipts`, { - headers: authorization, - }); + const ownedAtRevisionZeroResponse = await receiptRpc( + request, + "receipts.listReceipts", + {}, + authorization, + ); - expect(ownedAtRevisionZeroResponse.status()).toBe(200); + expect(ownedAtRevisionZeroResponse.status).toBe(200); - const ownedAtRevisionZero = decodeReceiptPage( - await ownedAtRevisionZeroResponse.json(), - exactDecoding, - ); + const ownedAtRevisionZero = decodeReceiptPage(ownedAtRevisionZeroResponse.value, exactDecoding); expect(ownedAtRevisionZero.items).toHaveLength(1); const revisionZero = ownedAtRevisionZero.items[0]; @@ -590,10 +592,7 @@ test.describe("Native Receipt owner journey", () => { const revisionNotice = page.locator('[role="status"][data-action-intent="revise"]'); await expect(revisionNotice).toBeVisible(); - await expect(revisionNotice).toHaveAttribute( - "data-command-id", - stableRevisionIdempotencyKey, - ); + await expect(revisionNotice).toHaveAttribute("data-command-id", stableRevisionIdempotencyKey); await expect(revisionNotice).toHaveAttribute("data-revision", "1"); const revisionOneEtag = await revisionNotice.getAttribute("data-etag"); @@ -656,35 +655,26 @@ test.describe("Native Receipt owner journey", () => { if (revisionTwoEtag === null) throw new Error("Receipt revision two ETag is missing"); - const replacementRetryResponse = await request.patch( - `${BACKEND_ORIGIN}/api/receipts/${encodeURIComponent(receiptId)}`, + const replacementRetryResponse = await receiptRpc( + request, + "receipts.reviseReceipt", { - headers: { - ...authorization, - "Idempotency-Key": replacementIdempotencyKey, - "If-Match": revisionOneEtag, - }, - multipart: { + receiptId, + idempotencyKey: replacementIdempotencyKey, + ifMatch: revisionOneEtag, + request: { description: REPLACED_DESCRIPTION, - amountOre: String(REVISED_AMOUNT_ORE), + amountOre: REVISED_AMOUNT_ORE, receiptDate: REVISED_RECEIPT_DATE, - file: { - name: "replacement.png", - mimeType: "image/png", - buffer: RECEIPT_BYTES, - }, + file: { contentType: "image/png", bytes: rpcBytes(RECEIPT_BYTES) }, }, }, + authorization, ); - expect(replacementRetryResponse.status()).toBe(200); - - const replacementRetry = decodeReceiptResource( - await replacementRetryResponse.json(), - exactDecoding, - ); + expect(replacementRetryResponse.status).toBe(200); - expect(replacementRetryResponse.headers()["etag"]).toBe(replacementRetry.etag); + const replacementRetry = decodeReceiptResource(replacementRetryResponse.value, exactDecoding); expect(replacementRetry).toMatchObject({ receiptId, revision: 2, @@ -718,26 +708,26 @@ test.describe("Native Receipt owner journey", () => { await writeFile(lifecycleEvidencePath, JSON.stringify({ beforeFailure, afterRetry }), "utf8"); - const stableRevisionReplayResponse = await request.patch( - `${BACKEND_ORIGIN}/api/receipts/${encodeURIComponent(receiptId)}`, + const stableRevisionReplayResponse = await receiptRpc( + request, + "receipts.reviseReceipt", { - headers: { - ...authorization, - "Idempotency-Key": stableRevisionIdempotencyKey, - "If-Match": revisionZero.etag, - }, - multipart: { + receiptId, + idempotencyKey: stableRevisionIdempotencyKey, + ifMatch: revisionZero.etag, + request: { description: REVISED_DESCRIPTION, - amountOre: String(REVISED_AMOUNT_ORE), + amountOre: REVISED_AMOUNT_ORE, receiptDate: REVISED_RECEIPT_DATE, }, }, + authorization, ); - expect(stableRevisionReplayResponse.status()).toBe(200); + expect(stableRevisionReplayResponse.status).toBe(200); const stableRevisionReplay = decodeReceiptResource( - await stableRevisionReplayResponse.json(), + stableRevisionReplayResponse.value, exactDecoding, ); @@ -763,30 +753,29 @@ test.describe("Native Receipt owner journey", () => { const concurrentIdempotencyKey = randomUUID(); - const concurrentRevisionResponse = await request.patch( - `${BACKEND_ORIGIN}/api/receipts/${encodeURIComponent(receiptId)}`, + const concurrentRevisionResponse = await receiptRpc( + request, + "receipts.reviseReceipt", { - headers: { - ...authorization, - "Idempotency-Key": concurrentIdempotencyKey, - "If-Match": revisionTwoEtag, - }, - multipart: { + receiptId, + idempotencyKey: concurrentIdempotencyKey, + ifMatch: revisionTwoEtag, + request: { description: CONCURRENT_DESCRIPTION, - amountOre: String(REVISED_AMOUNT_ORE), + amountOre: REVISED_AMOUNT_ORE, receiptDate: REVISED_RECEIPT_DATE, }, }, + authorization, ); - expect(concurrentRevisionResponse.status()).toBe(200); + expect(concurrentRevisionResponse.status).toBe(200); const concurrentRevision = decodeReceiptResource( - await concurrentRevisionResponse.json(), + concurrentRevisionResponse.value, exactDecoding, ); - expect(concurrentRevisionResponse.headers()["etag"]).toBe(concurrentRevision.etag); expect(concurrentRevision).toMatchObject({ receiptId, status: "Pending", @@ -814,22 +803,16 @@ test.describe("Native Receipt owner journey", () => { await expect(receiptRow.locator('[data-revision="3"]')).toHaveText("Versjon 3"); const foreignContext = await browser.newContext({ baseURL: DASHBOARD_BASE_URL }); - let foreignOwnerResponse: APIResponse; + let foreignOwnerResponse: NativeRpcAnswer; try { const foreignPage = await foreignContext.newPage(); const foreignSession = await authenticate(foreignPage, request, receiptPersona("FOREIGN")); - foreignOwnerResponse = await request.post( - `${BACKEND_ORIGIN}/api/receipts/${encodeURIComponent(receiptId)}:withdraw`, - { - headers: { - ...sessionHeaders(foreignSession), - "content-type": "application/json", - "Idempotency-Key": randomUUID(), - "If-Match": concurrentRevision.etag, - }, - data: {}, - }, + foreignOwnerResponse = await receiptRpc( + request, + "receipts.withdrawReceipt", + { receiptId, idempotencyKey: randomUUID(), ifMatch: concurrentRevision.etag }, + sessionHeaders(foreignSession), ); } finally { await foreignContext.close(); @@ -853,10 +836,7 @@ test.describe("Native Receipt owner journey", () => { await expect(withdrawalNotice).toBeVisible(); await expect(withdrawalNotice).toHaveAttribute("data-status", "Withdrawn"); await expect(withdrawalNotice).toHaveAttribute("data-revision", "4"); - await expect(withdrawalNotice).toHaveAttribute( - "data-command-id", - withdrawalIdempotencyKey, - ); + await expect(withdrawalNotice).toHaveAttribute("data-command-id", withdrawalIdempotencyKey); const withdrawalEtag = await withdrawalNotice.getAttribute("data-etag"); if (withdrawalEtag === null) throw new Error("Withdrawal ETag is missing"); @@ -876,27 +856,16 @@ test.describe("Native Receipt owner journey", () => { await expect(receiptRow.locator('[data-revision="4"]')).toBeVisible(); await expect(receiptRow.getByRole("button")).toHaveCount(0); - const withdrawalReplayResponse = await request.post( - `${BACKEND_ORIGIN}/api/receipts/${encodeURIComponent(receiptId)}:withdraw`, - { - headers: { - ...authorization, - "content-type": "application/json", - "Idempotency-Key": withdrawalIdempotencyKey, - "If-Match": concurrentRevision.etag, - }, - data: {}, - }, + const withdrawalReplayResponse = await receiptRpc( + request, + "receipts.withdrawReceipt", + { receiptId, idempotencyKey: withdrawalIdempotencyKey, ifMatch: concurrentRevision.etag }, + authorization, ); - expect(withdrawalReplayResponse.status()).toBe(200); - - const withdrawalReplay = decodeReceiptResource( - await withdrawalReplayResponse.json(), - exactDecoding, - ); + expect(withdrawalReplayResponse.status).toBe(200); - expect(withdrawalReplayResponse.headers()["etag"]).toBe(withdrawalReplay.etag); + const withdrawalReplay = decodeReceiptResource(withdrawalReplayResponse.value, exactDecoding); expect(withdrawalReplay).toMatchObject({ receiptId, status: "Withdrawn", @@ -904,17 +873,11 @@ test.describe("Native Receipt owner journey", () => { }); expect(withdrawalReplay.etag).toBe(withdrawalEtag); - const terminalResponse = await request.post( - `${BACKEND_ORIGIN}/api/receipts/${encodeURIComponent(receiptId)}:withdraw`, - { - headers: { - ...authorization, - "content-type": "application/json", - "Idempotency-Key": randomUUID(), - "If-Match": withdrawalReplay.etag, - }, - data: {}, - }, + const terminalResponse = await receiptRpc( + request, + "receipts.withdrawReceipt", + { receiptId, idempotencyKey: randomUUID(), ifMatch: withdrawalReplay.etag }, + authorization, ); const terminalTag = await expectProblemCode( @@ -923,12 +886,15 @@ test.describe("Native Receipt owner journey", () => { "receipt.invalid-transition", ); - const finalOwnedResponse = await request.get(`${BACKEND_ORIGIN}/api/receipts`, { - headers: authorization, - }); + const finalOwnedResponse = await receiptRpc( + request, + "receipts.listReceipts", + {}, + authorization, + ); - expect(finalOwnedResponse.status()).toBe(200); - const finalOwned = decodeReceiptPage(await finalOwnedResponse.json(), exactDecoding); + expect(finalOwnedResponse.status).toBe(200); + const finalOwned = decodeReceiptPage(finalOwnedResponse.value, exactDecoding); expect(finalOwned.items).toHaveLength(1); expect(finalOwned.items[0]).toMatchObject({ receiptId, @@ -951,7 +917,7 @@ test.describe("Native Receipt owner journey", () => { fileStore: "private-filesystem", }, unauthenticated: { - status: unauthenticatedResponse.status(), + status: unauthenticatedResponse.status, code: unauthenticatedTag, }, rejected: [ @@ -966,12 +932,12 @@ test.describe("Native Receipt owner journey", () => { }, { code: foreignOwnerTag, - status: foreignOwnerResponse.status(), + status: foreignOwnerResponse.status, revision: 3, }, { code: terminalTag, - status: terminalResponse.status(), + status: terminalResponse.status, revision: withdrawalReplay.revision, }, ], diff --git a/apps/dashboard/e2e/run-real-admission-period-management.mjs b/apps/dashboard/e2e/run-real-admission-period-management.mjs index 35887f4fa..2cd8671d6 100644 --- a/apps/dashboard/e2e/run-real-admission-period-management.mjs +++ b/apps/dashboard/e2e/run-real-admission-period-management.mjs @@ -1,11 +1,8 @@ -import { Predicate } from "effect"; +import { Match, Predicate } from "effect"; import { postgresProgram, reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; import { deriveHttpIdentity, encodePathIdentity } from "@vektorprogrammet/backend/http-semantics"; -import { - AdmissionsApi, - CreateAdmissionPeriodEndpoint, - ReviseAdmissionPeriodEndpoint, -} from "@vektorprogrammet/http-api"; +import { nativeRpcPath } from "@vektorprogrammet/rpc"; +import { isNativeRpcPath, nativeRpcOutcome } from "./native-operations.ts"; import { randomBytes } from "node:crypto"; import { spawn } from "node:child_process"; import { appendFileSync } from "node:fs"; @@ -365,13 +362,62 @@ const parseJsonBody = (bytes) => { } }; -const admissionPeriodPath = /^\/api\/admission-periods(?:\/|$)/u; +/** The admission-period commands whose requests the ledger keeps. */ +const admissionPeriodCommands = new Set([ + "admissions.createAdmissionPeriod", + "admissions.reviseAdmissionPeriod", +]); + +/** + * One RPC request of the JSON wire, as the ledger names it: the RPC tag as its path, and the + * idempotency key, precondition, and request of its payload. + */ +const rpcRequestEntry = (bytes) => { + const message = parseJsonBody(bytes); + + if (!Predicate.hasProperty(message, "tag") || !Predicate.isString(message.tag)) { + return { path: nativeRpcPath, idempotencyKey: null, ifMatch: null, body: null }; + } + + const payload = Predicate.hasProperty(message, "payload") ? message.payload : null; + + const member = (name) => + Predicate.hasProperty(payload, name) && Predicate.isString(payload[name]) ? payload[name] : null; + + return { + path: message.tag, + idempotencyKey: member("idempotencyKey"), + ifMatch: member("ifMatch"), + body: + admissionPeriodCommands.has(message.tag) && Predicate.hasProperty(payload, "request") + ? payload.request + : null, + }; +}; + +/** + * The outcome of one RPC answer: 200 for a success, the registry status and code of a declared + * problem, and 500 for a defect or a body that is no RPC answer. + */ +const rpcAnswerOutcome = (bytes) => { + const outcome = nativeRpcOutcome(bytes.toString("utf8")); + + return outcome === undefined + ? { status: 500, problemCode: "defect" } + : Match.value(outcome).pipe( + Match.tag("Success", () => ({ status: 200, problemCode: null })), + Match.tag("Problem", ({ status, problem }) => ({ status, problemCode: problem.code })), + Match.tag("Defect", () => ({ status: 500, problemCode: "defect" })), + Match.exhaustive, + ); +}; /** * The dashboard server calls the backend from its loaders and actions, so the browser's * commands are observable only between the two. Each forwarded request is appended to a - * JSON Lines ledger before its response is released, and the spec reads that ledger. - * Only admission-period bodies are kept: sign-in bodies carry passwords. + * JSON Lines ledger before its response is released, and the spec reads that ledger. An RPC + * request is recorded by its tag, and only admission-period requests keep their request: + * sign-in bodies carry passwords. */ async function startRecordingProxy(ledgerPath) { const records = []; @@ -388,15 +434,13 @@ async function startRecordingProxy(ledgerPath) { for await (const chunk of request) chunks.push(Buffer.from(chunk)); const requestBytes = Buffer.concat(chunks); - const idempotencyKey = request.headers["idempotency-key"]; - const ifMatch = request.headers["if-match"]; + const isRpc = method === "POST" && isNativeRpcPath(url.pathname); const entry = { - method, - path: url.pathname, - idempotencyKey: Predicate.isString(idempotencyKey) ? idempotencyKey : null, - ifMatch: Predicate.isString(ifMatch) ? ifMatch : null, - body: admissionPeriodPath.test(url.pathname) ? parseJsonBody(requestBytes) : null, + method: isRpc ? "RPC" : method, + ...(isRpc + ? rpcRequestEntry(requestBytes) + : { path: url.pathname, idempotencyKey: null, ifMatch: null, body: null }), status: 502, problemCode: null, }; @@ -429,7 +473,11 @@ async function startRecordingProxy(ledgerPath) { const responseBytes = Buffer.from(await upstream.arrayBuffer()); entry.status = upstream.status; - if (upstream.headers.get("content-type")?.startsWith("application/problem+json") === true) { + if (isRpc && upstream.status === 200) { + Object.assign(entry, rpcAnswerOutcome(responseBytes)); + } else if ( + upstream.headers.get("content-type")?.startsWith("application/problem+json") === true + ) { const problem = parseJsonBody(responseBytes); entry.problemCode = @@ -563,10 +611,10 @@ async function readPostgresEvidence(environment) { * target, and Idempotency-Key, never the raw key. The leader issued every accepted * command: the browser's create and close, and the concurrent API revision that won. */ -const leaderCommandId = (endpoint, normalizedTarget, idempotencyKey) => +const leaderCommandId = (qualifiedOperationId, normalizedTarget, idempotencyKey) => deriveHttpIdentity({ credentialSubject: `Person:${personas.leader.personId}`, - qualifiedOperationId: `${AdmissionsApi.identifier}.${endpoint.identifier}`, + qualifiedOperationId, normalizedTarget, idempotencyKey, }).commandId; @@ -575,25 +623,23 @@ function assertDurableEvidence(postgres, lifecycle) { const audits = Array.isArray(postgres.audits) ? postgres.audits : []; const outbox = Array.isArray(postgres.outbox) ? postgres.outbox : []; - const periodTarget = ReviseAdmissionPeriodEndpoint.path.replace( - ":admissionPeriodId", - encodePathIdentity(lifecycle.period.id), - ); + // The RPCs keep the HTTP route of each command as its normalized target. + const periodTarget = `/api/admission-periods/${encodePathIdentity(lifecycle.period.id)}`; const createCommandId = leaderCommandId( - CreateAdmissionPeriodEndpoint, - CreateAdmissionPeriodEndpoint.path, + "admissions.createAdmissionPeriod", + "/api/admission-periods", lifecycle.period.createIdempotencyKey, ); const concurrentWinnerCommandId = leaderCommandId( - ReviseAdmissionPeriodEndpoint, + "admissions.reviseAdmissionPeriod", periodTarget, lifecycle.period.concurrentWinnerIdempotencyKey, ); const closeCommandId = leaderCommandId( - ReviseAdmissionPeriodEndpoint, + "admissions.reviseAdmissionPeriod", periodTarget, lifecycle.period.closeIdempotencyKey, ); diff --git a/apps/dashboard/e2e/run-real-interview-response.mjs b/apps/dashboard/e2e/run-real-interview-response.mjs index 4f922fe04..186c2ac38 100644 --- a/apps/dashboard/e2e/run-real-interview-response.mjs +++ b/apps/dashboard/e2e/run-real-interview-response.mjs @@ -1,8 +1,5 @@ -import { - RecruitmentReadInvitationResponseProblem, - RecruitmentRequestNewInvitationTimeProblem, -} from "@vektorprogrammet/http-api"; -import { Predicate, Result, Schema } from "effect"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; +import { Predicate } from "effect"; import { postgresProgram, reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; import { createHash, randomBytes } from "node:crypto"; import { spawn } from "node:child_process"; @@ -13,12 +10,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { journeyClock } from "../../../tools/e2e/journey-clock.ts"; +import { replacedAnswer, replacedRequest } from "./native-rpc-ledger.ts"; +import { isNativeRpcPath } from "./native-operations.ts"; const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); const dashboardRoot = fileURLToPath(new URL("../", import.meta.url)); -const sdkRoot = fileURLToPath(new URL("../../../packages/sdk/", import.meta.url)); const databaseRoot = fileURLToPath(new URL("../../../packages/database/", import.meta.url)); @@ -70,7 +68,6 @@ const recruitmentTeamId = "team-native-invitation-response-0051"; const interviewSchemaId = "interview-schema-native-invitation-response-0051"; -const invitationCapabilityHeader = "x-recruitment-invitation-capability"; const responseCases = [ { @@ -624,27 +621,39 @@ async function startRecordingProxy(targetOrigin, actorsByCapability) { for await (const chunk of request) chunks.push(Buffer.from(chunk)); const requestBytes = Buffer.concat(chunks); - const requestJson = parseJsonBody(requestBytes); - const capabilityHeader = request.headers[invitationCapabilityHeader]; + const rawRequestJson = parseJsonBody(requestBytes); + const rpc = isNativeRpcPath(path) ? replacedRequest(rawRequestJson) : undefined; - const capabilityValue = Array.isArray(capabilityHeader) - ? capabilityHeader[0] - : capabilityHeader; + // A native RPC is recorded as the HTTP route it replaced; its body is the command it carries. + const requestJson = rpc === undefined ? rawRequestJson : rpc.requestJson; + + // The capability travels in the payload's `capability` member, where the dedicated header was. + const capabilityValue = rpc?.capability ?? undefined; + + // The request text outside that one member, which must carry no capability. + const requestTextOutsideCapability = + capabilityValue === undefined + ? requestBytes.toString("utf8") + : requestBytes.toString("utf8").replace(JSON.stringify(capabilityValue), '""'); const invitationActor = Predicate.isString(capabilityValue) ? (actorsByCapability.get(capabilityValue) ?? null) : null; + // No header carries a capability: it travels in the RPC payload alone. const nonCapabilityHeaders = Object.entries(request.headers) - .filter(([name]) => name !== invitationCapabilityHeader) .map(([, value]) => (Array.isArray(value) ? value.join(",") : (value ?? ""))) .join("\n"); const record = { - method, - path, - sessionCookieAuth: hasSessionCookie(request.headers.cookie), - authorizationHeaderPresent: request.headers.authorization !== undefined, + method: rpc?.method ?? method, + path: rpc?.path ?? path, + rpcTag: rpc?.tag ?? null, + sessionCookieAuth: + hasSessionCookie(request.headers.cookie) || hasSessionCookie(rpc?.messageHeaders.cookie), + authorizationHeaderPresent: + request.headers.authorization !== undefined || + rpc?.messageHeaders.authorization !== undefined, invitationActor, requestHasInvitationCapability: Predicate.isString(capabilityValue), requestInvitationCapabilityValid: @@ -655,13 +664,11 @@ async function startRecordingProxy(targetOrigin, actorsByCapability) { hasObjectKey(requestJson, "capability"), requestRawCapabilityOutsideDedicatedHeader: containsRawCapability(request.url ?? "") || - containsRawCapability(requestBytes.toString("utf8")) || + containsRawCapability(requestTextOutsideCapability) || containsRawCapability(nonCapabilityHeaders), requestJson, - idempotencyKey: Predicate.isString(request.headers["idempotency-key"]) - ? request.headers["idempotency-key"] - : null, - ifMatch: Predicate.isString(request.headers["if-match"]) ? request.headers["if-match"] : null, + idempotencyKey: rpc?.idempotencyKey ?? null, + ifMatch: rpc?.ifMatch ?? null, responseHasResponseCapabilityField: false, responseRawCapability: false, responseJson: null, @@ -702,7 +709,7 @@ async function startRecordingProxy(targetOrigin, actorsByCapability) { const responseHeaders = [...upstream.headers.entries()] .flatMap(([name, value]) => - name !== invitationCapabilityHeader ? [`${name}:${value}`] : [], + [`${name}:${value}`], ) .join("\n"); @@ -711,6 +718,16 @@ async function startRecordingProxy(targetOrigin, actorsByCapability) { record.responseEtag = upstream.headers.get("etag"); record.responseContentType = upstream.headers.get("content-type"); record.responseVary = upstream.headers.get("vary"); + + // An RPC answers 200 with its exit; the record keeps the status and body of that answer. + const answer = rpc === undefined ? undefined : replacedAnswer(responseJson); + + if (answer !== undefined) { + record.status = answer.status; + record.responseJson = answer.responseJson; + record.responseEtag = answer.responseEtag; + } + record.responseHasResponseCapabilityField = [ "responseCapability", "invitationCapability", @@ -777,128 +794,89 @@ async function startRecordingProxy(targetOrigin, actorsByCapability) { }; } -const nativeMutationHeaders = (capability, etag) => ({ - "content-type": "application/json", - "if-match": etag, - [invitationCapabilityHeader]: capability, -}); +/** The script client of the backend, which sends each invitation RPC with its capability alone. */ +const invitationClient = () => nativeScriptClient(backendOrigin); /** - * Decodes a failure through the endpoint's declared problem union: every required member with - * its registry value, the optional `instance`, a validation code's `validation` extension, and - * no undeclared member. + * Checks a failed invitation RPC against its expected code and the registry status of that code, + * and that its answer carries no raw capability. */ -async function expectNativeProblem(path, init, expectedStatus, expectedCode, declaredProblems) { - const response = await fetch(new URL(path, backendOrigin), init); - const responseText = await response.text(); - assertNoRawCapability(responseText, `Native ${expectedCode} response`); - const problem = JSON.parse(responseText); - - const decoded = Schema.decodeUnknownResult(declaredProblems, { onExcessProperty: "error" })( - problem, - ); +function expectNativeProblem(result, expectedStatus, expectedCode, label) { + assertNoRawCapability(JSON.stringify(result), `Native ${label} response`); - if ( - response.status !== expectedStatus || - !response.headers.get("content-type")?.startsWith("application/problem+json") || - Result.isFailure(decoded) || - decoded.success.code !== expectedCode || - decoded.success.status !== expectedStatus - ) { + if (result.ok || result.status !== expectedStatus || result.code !== expectedCode) { throw new Error( - `Native ${expectedCode} boundary did not return its declared Problem Details: ${JSON.stringify({ - status: response.status, - contentType: response.headers.get("content-type"), - code: problem?.code, - type: problem?.type, - keys: - problem === null || !Predicate.isObjectOrArray(problem) - ? [] - : Object.keys(problem).sort(), - decodeFailure: Result.isFailure(decoded) ? String(decoded.failure) : undefined, + `Native ${label} boundary did not answer ${expectedCode}: ${JSON.stringify({ + status: result.status, + code: result.ok ? undefined : result.code, })}`, ); } return { - status: response.status, - code: decoded.success.code, - type: decoded.success.type, - responseKeys: Object.keys(problem).sort(), + status: result.status, + code: result.code, + type: result.code === "defect" ? undefined : result.problem.type, rawCapabilityObserved: false, }; } async function exerciseNativeBoundaryFailures() { const capability = rawCapabilitiesByCase["requested-new-time"]; + const client = invitationClient(); - const read = await fetch(new URL("/api/recruitment/invitation-response", backendOrigin), { - headers: { [invitationCapabilityHeader]: capability }, - }); + try { + const read = await client.call({}, (native) => + native["recruitment.readInvitationResponse"]({ capability }), + ); - const readText = await read.text(); - assertNoRawCapability(readText, "Native pending invitation boundary read"); - const etag = read.headers.get("etag"); + assertNoRawCapability(JSON.stringify(read), "Native pending invitation boundary read"); - if (read.status !== 200 || etag === null) { - throw new Error("Native boundary rehearsal could not read the pending invitation"); - } + if (!read.ok) { + throw new Error("Native boundary rehearsal could not read the pending invitation"); + } - const basePath = "/api/recruitment/invitation-response:request-new-time"; - const malformedCapability = "A".repeat(43); - const overlongMessage = "x ".repeat(1_000_000); + const etag = read.value.etag; + const malformedCapability = "A".repeat(43); + const overlongMessage = "x ".repeat(1_000_000); - return { - unknownCapability: await expectNativeProblem( - "/api/recruitment/invitation-response", - { headers: { [invitationCapabilityHeader]: malformedCapability } }, - 404, - "resource.not-found", - RecruitmentReadInvitationResponseProblem, - ), - duplicateJsonMember: await expectNativeProblem( - basePath, - { - method: "POST", - headers: nativeMutationHeaders(capability, etag), - body: '{"message":"first","message":"second"}', - }, - 400, - "request.malformed", - RecruitmentRequestNewInvitationTimeProblem, - ), - capabilityTokenMessage: await expectNativeProblem( - basePath, - { - method: "POST", - headers: nativeMutationHeaders(capability, etag), - body: JSON.stringify({ message: malformedCapability }), - }, - 422, - "validation.failed", - RecruitmentRequestNewInvitationTimeProblem, - ), - overlongBody: await expectNativeProblem( - basePath, - { - method: "POST", - headers: nativeMutationHeaders(capability, etag), - body: JSON.stringify({ message: overlongMessage }), - }, - 413, - "request.too-large", - RecruitmentRequestNewInvitationTimeProblem, - ), - }; -} + const requestNewTime = (message) => + client.call({}, (native) => + native["recruitment.requestNewInvitationTime"]({ + capability, + ifMatch: etag, + request: { message }, + }), + ); -const semanticResponseHeaders = (headers) => - Object.fromEntries( - ["cache-control", "content-type", "etag", "location", "retry-after", "vary"].map((name) => [ - name, - headers.get(name), - ]), - ); + // A malformed payload fails in the RPC server before the handler, as a defect: the HTTP + // contract's request.malformed, validation.failed, and request.too-large have no RPC answer. + return { + unknownCapability: expectNativeProblem( + await client.call({}, (native) => + native["recruitment.readInvitationResponse"]({ capability: malformedCapability }), + ), + 404, + "resource.not-found", + "unknown capability", + ), + capabilityTokenMessage: expectNativeProblem( + await requestNewTime(malformedCapability), + 500, + "defect", + "capability-token message", + ), + overlongBody: expectNativeProblem( + await requestNewTime(overlongMessage), + 500, + "defect", + "overlong message", + ), + }; + } finally { + await client.dispose(); + } +} async function exerciseNonReplayableRepeat(records) { const original = records.find( @@ -906,42 +884,38 @@ async function exerciseNonReplayableRepeat(records) { record.invitationActor === "accepted" && record.method === "POST" && record.path === responseCases[0].commandPath && - record.status === 204, + record.status === 200, ); if (original === undefined || !Predicate.isString(original.ifMatch)) { throw new Error("Non-replay rehearsal could not locate the accepted invitation command"); } - const response = await fetch(new URL(original.path, backendOrigin), { - method: "POST", - headers: { - "content-type": "application/json", - "if-match": original.ifMatch, - [invitationCapabilityHeader]: rawCapabilitiesByCase.accepted, - }, - body: JSON.stringify(original.requestJson ?? {}), - }); + const client = invitationClient(); - const body = Buffer.from(await response.arrayBuffer()); - assertNoRawCapability(body.toString("utf8"), "Non-replayable invitation repeat"); - const problem = parseJsonBody(body); + try { + const repeated = await client.call({}, (native) => + native["recruitment.confirmInvitation"]({ + capability: rawCapabilitiesByCase.accepted, + ifMatch: original.ifMatch, + }), + ); - if ( - response.status !== 409 || - problem?.code !== "invitation.already-responded" || - !response.headers.get("content-type")?.startsWith("application/problem+json") - ) { - throw new Error("A repeated invitation command replayed instead of returning typed conflict"); - } + assertNoRawCapability(JSON.stringify(repeated), "Non-replayable invitation repeat"); - return { - status: response.status, - code: problem.code, - replayedResponse: false, - recovery: "required-fresh-read", - headers: semanticResponseHeaders(response.headers), - }; + if (repeated.ok || repeated.status !== 409 || repeated.code !== "invitation.already-responded") { + throw new Error("A repeated invitation command replayed instead of returning typed conflict"); + } + + return { + status: repeated.status, + code: repeated.code, + replayedResponse: false, + recovery: "required-fresh-read", + }; + } finally { + await client.dispose(); + } } function summarizeNativeContracts(records) { @@ -952,7 +926,7 @@ function summarizeNativeContracts(records) { ); const successfulMutations = invitationRecords.filter( - ({ method, status }) => method === "POST" && status === 204, + ({ method, status }) => method === "POST" && status === 200, ); const terminalConflicts = invitationRecords.filter( @@ -1477,7 +1451,7 @@ function assertNativeTransport(records) { { method: "POST", path: responseCases[0].commandPath, - status: 204, + status: 200, invitationActor: "accepted", sessionCookieAuth: false, authorizationHeaderPresent: false, @@ -1509,7 +1483,7 @@ function assertNativeTransport(records) { { method: "POST", path: responseCases[1].commandPath, - status: 204, + status: 200, invitationActor: "rejected", sessionCookieAuth: false, authorizationHeaderPresent: false, @@ -1565,7 +1539,7 @@ function assertNativeTransport(records) { { method: "POST", path: responseCases[2].commandPath, - status: 204, + status: 200, invitationActor: "requested-new-time", sessionCookieAuth: false, authorizationHeaderPresent: false, @@ -1693,13 +1667,13 @@ function assertNativeTransport(records) { ); } - if (record.status === 204) { + // A response answers only the invitation's new entity tag, as the 204 carried its ETag. + if (record.status === 200) { if ( record.responseJson !== null || - record.responseVary !== "Origin" || !/^"vkr2\.[A-Za-z0-9_-]{43}"$/u.test(record.responseEtag ?? "") ) { - throw new Error("Invitation mutation did not return the generated 204 response contract"); + throw new Error("Invitation mutation did not answer only the invitation's entity tag"); } continue; @@ -1707,7 +1681,7 @@ function assertNativeTransport(records) { if ( record.status !== 409 || - !record.responseContentType?.startsWith("application/problem+json") || + !record.responseContentType?.startsWith("application/json") || record.responseJson?.status !== 409 || record.responseJson?.code !== "invitation.already-responded" || record.responseJson?.type !== @@ -2011,11 +1985,6 @@ async function main() { ADMISSION_FIXED_NOW: fixedClock, }; - await runCommand("bun", ["run", "build"], { - cwd: sdkRoot, - env: dashboardEnvironment, - label: "Native invitation-response SDK build", - }); // The journey serves a production build, whose bundles are never re-optimized and reloaded // under a navigating browser as a dev server's are. diff --git a/apps/dashboard/e2e/run-real-native-content-publication.mjs b/apps/dashboard/e2e/run-real-native-content-publication.mjs index 4fac147c4..bd729a3be 100644 --- a/apps/dashboard/e2e/run-real-native-content-publication.mjs +++ b/apps/dashboard/e2e/run-real-native-content-publication.mjs @@ -1,4 +1,4 @@ -import { Predicate } from "effect"; +import { Match, Option, Predicate, Schema } from "effect"; import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -8,6 +8,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; import { localBackendEnvironment } from "../../../tools/e2e/local-backend-environment.ts"; +import { isNativeRpcPath } from "./native-operations.ts"; import { addressesAnyRoute, legacyRoutes } from "./request-routes.ts"; const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); @@ -16,8 +17,6 @@ const dashboardRoot = fileURLToPath(new URL("../", import.meta.url)); const backendRoot = fileURLToPath(new URL("../../backend/", import.meta.url)); -const sdkRoot = fileURLToPath(new URL("../../../packages/sdk/", import.meta.url)); - const homepageRoot = fileURLToPath(new URL("../../homepage/", import.meta.url)); const [postgresPort, dashboardPort, backendPort, upstreamPort, homepagePort] = @@ -174,18 +173,140 @@ const copyResponseHeaders = (source, target) => { if (cookies.length > 0) target.setHeader("Set-Cookie", cookies); }; +const contentUnavailableProblem = { + type: "urn:vektorprogrammet:problem:v0.2:content.unavailable", + title: "Content unavailable", + status: 503, + code: "content.unavailable", + detail: "Content data is unavailable.", +}; + +/** The one RPC request that a request body carries: its id, tag, and payload. */ +const RpcRequestBody = Schema.fromJsonString( + Schema.Struct({ + id: Schema.Union([Schema.String, Schema.Int]), + tag: Schema.String, + payload: Schema.Unknown, + }), +); + +const parseRpcRequest = (bytes) => + bytes === undefined + ? null + : Option.getOrNull(Schema.decodeUnknownOption(RpcRequestBody)(bytes.toString("utf8"))); + +/** The answer that ends one RPC request: a success value, or one failure's error. */ +const RpcAnswer = Schema.Tuple([ + Schema.Struct({ + exit: Schema.Union([ + Schema.TaggedStruct("Success", { value: Schema.Unknown }), + Schema.TaggedStruct("Failure", { + cause: Schema.Tuple([Schema.Struct({ error: Schema.Unknown })]), + }), + ]), + }), +]); + +const ProblemStatus = Schema.Struct({ status: Schema.Int }); + +/** The registry status of one RPC answer: 200 for a success, its problem's status for a failure. */ +const rpcAnswerStatus = (bytes) => { + let json; + + try { + json = JSON.parse(bytes.toString("utf8")); + } catch { + return null; + } + + return Option.match(Schema.decodeUnknownOption(RpcAnswer)(json), { + onNone: () => null, + onSome: ([{ exit }]) => + Match.value(exit).pipe( + Match.tag("Success", () => 200), + Match.tag("Failure", ({ cause: [{ error }] }) => + Option.match(Schema.decodeUnknownOption(ProblemStatus)(error), { + onNone: () => 500, + onSome: ({ status }) => status, + }), + ), + Match.exhaustive, + ), + }); +}; + +const ExitMessage = Schema.TaggedStruct("Exit", { + requestId: Schema.Union([Schema.String, Schema.Int]), + exit: Schema.Unknown, +}); + +const FailureExit = Schema.TaggedStruct("Failure", { cause: Schema.Array(Schema.Unknown) }); + +const FailReason = Schema.TaggedStruct("Fail", { error: Schema.Unknown }); + +/** Answers one RPC request with the content.unavailable problem, as the backend would. */ +const sendContentUnavailableRpc = (response, requestId) => { + response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); + response.end( + JSON.stringify([ + ExitMessage.make({ + requestId, + exit: FailureExit.make({ cause: [FailReason.make({ error: contentUnavailableProblem })] }), + }), + ]), + ); +}; + +const PayloadMembers = Schema.Record(Schema.String, Schema.Unknown); + +const payloadMember = (payload, name) => + Option.match(Schema.decodeUnknownOption(PayloadMembers)(payload), { + onNone: () => undefined, + onSome: (members) => members[name], + }); + +/** + * The HTTP route that each content RPC replaced. The ledger names each RPC by that route, as the + * command receipts of the backend name it by their normalized target. + */ +const replacedContentRoute = (tag, payload) => { + const articleId = String(payloadMember(payload, "articleId")); + + switch (tag) { + case "content.readContentWorkspace": + return ["GET", "/api/content/articles"]; + case "content.createArticle": + return ["POST", "/api/content/articles"]; + case "content.readArticle": + return ["GET", `/api/content/articles/${articleId}`]; + case "content.reviseArticle": + return ["PATCH", `/api/content/articles/${articleId}`]; + case "content.publishArticle": + return ["POST", `/api/content/articles/${articleId}:publish`]; + case "content.unpublishArticle": + return ["POST", `/api/content/articles/${articleId}:unpublish`]; + case "content.listNews": + return ["GET", "/api/news"]; + case "content.readNewsArticle": + return ["GET", `/api/news/${String(payloadMember(payload, "slug"))}`]; + default: + return null; + } +}; + const startRecordingUpstream = async (ledger) => { let forcedContentFailure = false; const server = createServer(async (request, response) => { const startedAt = Date.now(); - const pathname = new URL(request.url ?? "/", upstreamOrigin).pathname; + const url = new URL(request.url ?? "/", upstreamOrigin); const entry = { sequence: ledger.length + 1, method: request.method ?? "GET", - pathname, - search: new URL(request.url ?? "/", upstreamOrigin).search, + pathname: url.pathname, + search: url.search, + rpcTag: null, forced: false, forwardedTo: backendOrigin, status: 0, @@ -195,49 +316,35 @@ const startRecordingUpstream = async (ledger) => { ledger.push(entry); try { - if ( - !forcedContentFailure && - request.method === "GET" && - pathname === "/api/content/articles" - ) { - forcedContentFailure = true; - entry.forced = true; - entry.status = 503; - response.writeHead(503, { - "content-type": "application/problem+json; charset=utf-8", - "cache-control": "no-store", - }); - response.end( - JSON.stringify({ - type: "urn:vektorprogrammet:problem:v0.2:content.unavailable", - title: "Content unavailable", - status: 503, - code: "content.unavailable", - detail: "Content data is unavailable.", - }), - ); + const body = await requestBody(request); + const rpcRequest = isNativeRpcPath(url.pathname) ? parseRpcRequest(body) : null; - return; - } + if (rpcRequest !== null) { + entry.rpcTag = rpcRequest.tag; + const route = replacedContentRoute(rpcRequest.tag, rpcRequest.payload); - const body = await requestBody(request); - const idempotencyKey = request.headers["idempotency-key"]; + if (route !== null) [entry.method, entry.pathname] = route; + const idempotencyKey = payloadMember(rpcRequest.payload, "idempotencyKey"); + + if (Predicate.isString(idempotencyKey)) entry.idempotencyKey = idempotencyKey; + const ifMatch = payloadMember(rpcRequest.payload, "ifMatch"); - if (Predicate.isString(idempotencyKey)) entry.idempotencyKey = idempotencyKey; - const ifMatch = request.headers["if-match"]; + if (Predicate.isString(ifMatch)) entry.ifMatch = ifMatch; + const command = payloadMember(rpcRequest.payload, "request"); - if (Predicate.isString(ifMatch)) entry.ifMatch = ifMatch; + entry.requestFields = Option.match(Schema.decodeUnknownOption(PayloadMembers)(command), { + onNone: () => [], + onSome: (members) => Object.keys(members).sort(), + }); + } - if (body !== undefined) { - try { - const payload = JSON.parse(body.toString("utf8")); + if (!forcedContentFailure && entry.rpcTag === "content.readContentWorkspace") { + forcedContentFailure = true; + entry.forced = true; + entry.status = 503; + sendContentUnavailableRpc(response, rpcRequest.id); - if (Predicate.isObjectOrArray(payload) && payload !== null && !Array.isArray(payload)) { - entry.requestFields = Object.keys(payload).sort(); - } - } catch { - // Non-JSON request bodies do not contribute field evidence. - } + return; } const headers = new Headers(); @@ -260,10 +367,14 @@ const startRecordingUpstream = async (ledger) => { redirect: "manual", }); - entry.status = upstream.status; + const responseBytes = Buffer.from(await upstream.arrayBuffer()); + + // An RPC answers 200 with its exit; the ledger records the registry status of the exit. + entry.status = + entry.rpcTag === null ? upstream.status : (rpcAnswerStatus(responseBytes) ?? upstream.status); response.statusCode = upstream.status; copyResponseHeaders(upstream.headers, response); - response.end(Buffer.from(await upstream.arrayBuffer())); + response.end(responseBytes); } catch (cause) { entry.status = 502; response.writeHead(502, { "content-type": "application/json; charset=utf-8" }); @@ -420,11 +531,6 @@ try { NODE_ENV: "production", }; - run("bun", ["run", "build"], { - cwd: sdkRoot, - env: dashboardEnvironment, - label: "Content SDK build", - }); run("bun", ["run", "build"], { cwd: dashboardRoot, env: dashboardEnvironment, @@ -489,14 +595,19 @@ try { entry.pathname.startsWith("/api/content/articles"), ); + // Every staff request is one content RPC, named by the route it replaced; a browser's CORS + // preflight of the RPC endpoint names none. for (const entry of staffRequests) { const exact = - (entry.method === "OPTIONS" && entry.pathname.startsWith("/api/content/articles")) || - (entry.method === "GET" && entry.pathname === "/api/content/articles") || - (entry.method === "POST" && entry.pathname === "/api/content/articles") || - (entry.method === "GET" && /^\/api\/content\/articles\/\d+$/u.test(entry.pathname)) || - (entry.method === "PATCH" && /^\/api\/content\/articles\/\d+$/u.test(entry.pathname)) || - (entry.method === "POST" && + (entry.rpcTag === "content.readContentWorkspace" && + entry.method === "GET" && + entry.pathname === "/api/content/articles") || + (entry.rpcTag === "content.createArticle" && entry.pathname === "/api/content/articles") || + (entry.rpcTag === "content.readArticle" && + /^\/api\/content\/articles\/\d+$/u.test(entry.pathname)) || + (entry.rpcTag === "content.reviseArticle" && + /^\/api\/content\/articles\/\d+$/u.test(entry.pathname)) || + ((entry.rpcTag === "content.publishArticle" || entry.rpcTag === "content.unpublishArticle") && /^\/api\/content\/articles\/\d+:(?:publish|unpublish)$/u.test(entry.pathname)); assert.equal( @@ -506,7 +617,24 @@ try { ); } - const staffMutations = staffRequests.filter((entry) => ["PATCH", "POST"].includes(entry.method)); + assert.deepEqual( + ledger.filter( + (entry) => + isNativeRpcPath(entry.pathname) && entry.method !== "OPTIONS" && entry.rpcTag === null, + ), + [], + "every request to the RPC endpoint must carry one RPC request", + ); + + const staffMutations = staffRequests.filter((entry) => + [ + "content.createArticle", + "content.reviseArticle", + "content.publishArticle", + "content.unpublishArticle", + ].includes(entry.rpcTag), + ); + assert.ok(staffMutations.length >= 3, "staff arc must mutate through the native API"); for (const mutation of staffMutations) { @@ -515,8 +643,7 @@ try { "missing idempotency key: " + mutation.method + " " + mutation.pathname, ); - const createsArticle = - mutation.method === "POST" && mutation.pathname === "/api/content/articles"; + const createsArticle = mutation.rpcTag === "content.createArticle"; if (!createsArticle) { assert.match(mutation.ifMatch ?? "", /^"vkr2\./u); diff --git a/apps/dashboard/e2e/run-real-native-identity-browser.mjs b/apps/dashboard/e2e/run-real-native-identity-browser.mjs index 9705ce3af..a77b15ae9 100644 --- a/apps/dashboard/e2e/run-real-native-identity-browser.mjs +++ b/apps/dashboard/e2e/run-real-native-identity-browser.mjs @@ -1,4 +1,4 @@ -import { NativeProblem, SessionResponse } from "@vektorprogrammet/http-api"; +import { SessionResponse } from "@vektorprogrammet/rpc"; import { Order, Predicate, Schema } from "effect"; import assert from "node:assert/strict"; import { mkdtemp, readFile, rm } from "node:fs/promises"; @@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; import { postgresProgram, reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; import { localBackendEnvironment } from "../../../tools/e2e/local-backend-environment.ts"; -import { isNativeRequest } from "./native-operations.ts"; +import { isNativeRequest, nativeRpcOutcome, nativeRpcStatus, nativeRpcTag } from "./native-operations.ts"; /** The owner-only session metadata fields that the API contract defines. */ const sessionFields = Object.keys(SessionResponse.fields).sort(); @@ -300,7 +300,7 @@ const startRecordingBoundary = async (targetOrigin) => { request: findAuthorityData(requestBytes.toString("utf8")), response: [], }, - legacyOrProvider: !isNativeRequest(method, target.pathname), + legacyOrProvider: !isNativeRequest(method, target.pathname, requestBytes.toString("utf8")), status: 0, durationMs: 0, responseByteLength: 0, @@ -335,30 +335,36 @@ const startRecordingBoundary = async (targetOrigin) => { record.authorityDataMatches.response = findAuthorityData(bodyText); response.statusCode = upstream.status; - if (target.pathname === "/api/session") { - if (upstream.status === 200) { + record.rpcTag = nativeRpcTag(requestBytes.toString("utf8")); + + if (record.rpcTag !== undefined) { + const outcome = nativeRpcOutcome(bodyText); + record.rpcStatus = nativeRpcStatus(bodyText); + + if (record.rpcTag === "system.readSession" && Predicate.isTagged(outcome, "Success")) { const projection = Schema.decodeUnknownSync(SessionResponse, { onExcessProperty: "error", - })(JSON.parse(bodyText)); + })(outcome.value); assert.equal(projection.current, true); assert.ok(Date.parse(projection.expiresAt) > Date.now()); - assert.equal(bodyText, JSON.stringify(JSON.parse(bodyText))); record.sessionProjection = { - keys: Object.keys(projection), + keys: Object.keys(outcome.value), sessionId: projection.sessionId, expiresAt: projection.expiresAt, current: projection.current, bodyByteLength: bytes.byteLength, exactJsonBytes: true, }; - } else if (upstream.status === 401) { - const problem = Schema.decodeUnknownSync(NativeProblem, { onExcessProperty: "error" })( - JSON.parse(bodyText), - ); + } else if ( + record.rpcTag === "system.readSession" && + Predicate.isTagged(outcome, "Problem") && + outcome.status === 401 + ) { + // The outcome reader decoded the problem with the closed `NativeProblem` schema. + const problem = outcome.problem; assert.ok(credentialProblemCodes.includes(problem.code), `unexpected ${problem.code}`); - assert.equal(bodyText, JSON.stringify(JSON.parse(bodyText))); record.unauthenticatedProjection = { keys: ["code"], code: problem.code, @@ -489,11 +495,6 @@ const main = async () => { ); await waitForHttp(`${backendOrigin}/health`, backend, "Identity backend"); boundary = await startRecordingBoundary(backendOrigin); - await run("bun", ["run", "--cwd", "packages/sdk", "build"], { - cwd: repositoryRoot, - env: baseEnvironment, - label: "Identity SDK production build", - }); await run("bun", ["run", "--cwd", "apps/dashboard", "build"], { cwd: repositoryRoot, env: { @@ -554,7 +555,7 @@ const main = async () => { assert.deepEqual(hardeningEvidence.sessionProjection.fields, sessionFields); assert.deepEqual(hardeningEvidence.sessionProjection.credentialFieldsObserved, []); assert.equal(hardeningEvidence.sessionProjection.oneCurrent, 1); - assert.deepEqual(hardeningEvidence.revocation.revokeOthers, [204, 204]); + assert.deepEqual(hardeningEvidence.revocation.revokeOthers, [200, 200]); assert.deepEqual(hardeningEvidence.revocation.repeatedAndMissing, [404, 404]); assert.deepEqual(hardeningEvidence.revocation.immediateReplay, [401, 401, 401, 401]); assert.deepEqual(hardeningEvidence.ownership, { @@ -603,7 +604,9 @@ const main = async () => { assert.deepEqual(browserEvidence.requestLedger.forbidden, []); assert.deepEqual(browserEvidence.requestLedger.unexpectedDestinations, []); assert.ok( - boundary.records.some((entry) => entry.path === "/api/profile" && entry.status === 403), + boundary.records.some( + (entry) => entry.rpcTag === "profile.readOwnProfile" && entry.rpcStatus === 403, + ), "no-scope member profile denial must reach the native backend", ); assert.ok( @@ -628,11 +631,11 @@ const main = async () => { assert.deepEqual(wrongStatuses, [401, 401, 401, 429, 429, 429, 429, 429, 429, 429]); const nativeSignOut = boundary.records.filter( - (entry) => entry.path === "/api/session" && entry.method === "DELETE", + (entry) => entry.rpcTag === "system.deleteSession", ); - const explicitSignOut = nativeSignOut.filter(({ status }) => status === 204); - const revokedReplaySignOut = nativeSignOut.filter(({ status }) => status === 401); + const explicitSignOut = nativeSignOut.filter(({ rpcStatus }) => rpcStatus === 200); + const revokedReplaySignOut = nativeSignOut.filter(({ rpcStatus }) => rpcStatus === 401); assert.ok( explicitSignOut.length >= 4, "hardening and dashboard journeys must persist every first sign-out", @@ -643,18 +646,18 @@ const main = async () => { ); const sessionRequests = boundary.records.filter( - (entry) => entry.path === "/api/session" && entry.method === "GET", + (entry) => entry.rpcTag === "system.readSession", ); - assert.ok(sessionRequests.some((entry) => entry.status === 200)); - assert.ok(sessionRequests.some((entry) => entry.status === 401)); + assert.ok(sessionRequests.some((entry) => entry.rpcStatus === 200)); + assert.ok(sessionRequests.some((entry) => entry.rpcStatus === 401)); const successfulSessionProjections = sessionRequests - .filter((entry) => entry.status === 200) + .filter((entry) => entry.rpcStatus === 200) .map((entry) => entry.sessionProjection); const unauthenticatedSessionProjections = sessionRequests - .filter((entry) => entry.status === 401) + .filter((entry) => entry.rpcStatus === 401) .map((entry) => entry.unauthenticatedProjection); assert.ok( @@ -773,7 +776,7 @@ const main = async () => { }, reload: browserEvidence.observations.reload, logout: { - nativeStatuses: explicitSignOut.map(({ status }) => status), + nativeStatuses: explicitSignOut.map(({ rpcStatus }) => rpcStatus), browser: browserEvidence.observations.logout, }, revokedCookieReplay: { diff --git a/apps/dashboard/e2e/run-real-native-onboarding.mjs b/apps/dashboard/e2e/run-real-native-onboarding.mjs index 683fef465..577cb8e44 100644 --- a/apps/dashboard/e2e/run-real-native-onboarding.mjs +++ b/apps/dashboard/e2e/run-real-native-onboarding.mjs @@ -167,7 +167,6 @@ for (const signal of ["SIGTERM", "SIGINT"]) }); try { - await run("bun", ["run", "build"], join(root, "packages/sdk")); await run("bun", ["run", "build"], dashboardRoot); dashboard = spawn(process.env.PLAYWRIGHT_NODE_EXECUTABLE ?? "bun", ["server.mjs"], { cwd: dashboardRoot, diff --git a/apps/dashboard/e2e/run-real-native-organization-administration.mjs b/apps/dashboard/e2e/run-real-native-organization-administration.mjs index 95118947b..974375105 100644 --- a/apps/dashboard/e2e/run-real-native-organization-administration.mjs +++ b/apps/dashboard/e2e/run-real-native-organization-administration.mjs @@ -1,4 +1,4 @@ -import { Order, Predicate } from "effect"; +import { Match, Option, Order, Predicate, Schema } from "effect"; import { postgresProgram, reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; import { randomBytes } from "node:crypto"; import { spawn } from "node:child_process"; @@ -10,12 +10,12 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { localBackendEnvironment } from "../../../tools/e2e/local-backend-environment.ts"; import { deriveHttpIdentity } from "@vektorprogrammet/backend/http-semantics"; +import { isNativeRpcPath } from "./native-operations.ts"; const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); const dashboardRoot = fileURLToPath(new URL("../", import.meta.url)); -const sdkRoot = fileURLToPath(new URL("../../../packages/sdk/", import.meta.url)); const databaseRoot = fileURLToPath(new URL("../../../packages/database/", import.meta.url)); @@ -368,6 +368,79 @@ const parseJsonBody = (bytes) => { } }; +/** + * The HTTP route that each native RPC of this journey replaced. The ledger classifies an RPC by + * the route it replaced, so its facts stay comparable with the commands' receipts, whose + * normalized targets are those routes. + */ +const replacedRoutes = new Map([ + ["organization.listDepartments", ["GET", "/api/departments"]], + ["organization.listTeams", ["GET", "/api/teams"]], + ["organization.listFieldOfStudies", ["GET", "/api/field-of-studies"]], + ["organization.createDepartment", ["POST", "/api/departments"]], + ["organization.createTeam", ["POST", "/api/teams"]], + ["organization.createFieldOfStudy", ["POST", "/api/field-of-studies"]], + ["profile.readOwnProfile", ["GET", "/api/profile"]], + ["system.readSession", ["GET", "/api/session"]], +]); + +/** The one RPC request that a request body carries: its tag, payload, and message headers. */ +const RpcRequestMessage = Schema.Struct({ + tag: Schema.String, + payload: Schema.Unknown, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +}); + +const parseRpcRequest = (json) => + Option.getOrUndefined(Schema.decodeUnknownOption(RpcRequestMessage)(json)); + +/** The answer that ends one RPC request: a success value, or one failure's error. */ +const RpcAnswer = Schema.Tuple([ + Schema.Struct({ + exit: Schema.Union([ + Schema.TaggedStruct("Success", { value: Schema.Unknown }), + Schema.TaggedStruct("Failure", { + cause: Schema.Tuple([Schema.Struct({ error: Schema.Unknown })]), + }), + ]), + }), +]); + +const ProblemStatus = Schema.Struct({ status: Schema.Int }); + +/** + * Unwraps the answer of one RPC: a success answers its value at 200, and a failure answers its + * problem at the registry status of the problem's code. + */ +const unwrapRpcAnswer = (json) => + Option.match(Schema.decodeUnknownOption(RpcAnswer)(json), { + onNone: () => undefined, + onSome: ([{ exit }]) => + Match.value(exit).pipe( + Match.tag("Success", ({ value }) => ({ status: 200, json: value })), + Match.tag("Failure", ({ cause: [{ error }] }) => ({ + status: Option.match(Schema.decodeUnknownOption(ProblemStatus)(error), { + onNone: () => 500, + onSome: ({ status }) => status, + }), + json: error, + })), + Match.exhaustive, + ), + }); + +/** An own-profile answer names its person, bare or beside its entity tag. */ +const ProfileAnswer = Schema.Union([ + Schema.Struct({ personId: Schema.String }), + Schema.Struct({ profile: Schema.Struct({ personId: Schema.String }) }), +]); + +const profilePersonId = (value) => + Option.match(Schema.decodeUnknownOption(ProfileAnswer)(value), { + onNone: () => undefined, + onSome: (profile) => ("profile" in profile ? profile.profile.personId : profile.personId), + }); + const sessionCookieNames = new Set([ "better-auth.session_token", "__Secure-better-auth.session_token", @@ -400,14 +473,25 @@ async function startRecordingProxy(targetOrigin) { for await (const chunk of request) chunks.push(Buffer.from(chunk)); const requestBytes = Buffer.concat(chunks); - const cookieKey = sessionCookieKey(request.headers.cookie); + const requestJson = parseJsonBody(requestBytes); + + const rpc = + method === "POST" && isNativeRpcPath(url.pathname) ? parseRpcRequest(requestJson) : undefined; + + // A caller that forwards a person's credential sends it as a header of the RPC message, which + // the backend lays over the HTTP header of the same name. + const callerHeader = (name) => + rpc?.headers.findLast(([header]) => header.toLowerCase() === name)?.[1] ?? + request.headers[name]; + + const cookieKey = sessionCookieKey(callerHeader("cookie")); const record = { method, path: url.pathname, query: url.search, sessionCookieAuth: cookieKey !== undefined, - authorizationHeaderPresent: request.headers.authorization !== undefined, + authorizationHeaderPresent: callerHeader("authorization") !== undefined, idempotencyKey: Predicate.isString(request.headers["idempotency-key"]) ? request.headers["idempotency-key"] : null, @@ -418,14 +502,31 @@ async function startRecordingProxy(targetOrigin) { sessionPersonId: cookieKey === undefined ? null : (sessionPersonsByCookie.get(cookieKey) ?? null), canonicalAuthorityFixture: null, - request: parseJsonBody(requestBytes), + request: requestJson, responseJson: null, responseContentType: null, responseEtag: null, responseLocation: null, + transportPath: url.pathname, + rpcTag: null, status: 0, }; + // A native RPC is recorded as the route it replaced, with its key and request from the payload. + if (rpc !== undefined) { + const replaced = replacedRoutes.get(rpc.tag); + const payload = Predicate.isObjectOrArray(rpc.payload) ? rpc.payload : {}; + + record.transportPath = url.pathname; + record.rpcTag = rpc.tag; + record.method = replaced?.[0] ?? method; + record.path = replaced?.[1] ?? url.pathname; + record.idempotencyKey = Predicate.isString(payload.idempotencyKey) + ? payload.idempotencyKey + : null; + record.request = "request" in payload ? payload.request : rpc.payload; + } + records.push(record); try { @@ -461,17 +562,21 @@ async function startRecordingProxy(targetOrigin) { record.responseEtag = upstream.headers.get("etag"); record.responseLocation = upstream.headers.get("location"); - if ( - cookieKey !== undefined && - upstream.status === 200 && - url.pathname === "/api/profile" && - responseJson !== null && - Predicate.isObjectOrArray(responseJson) && - "personId" in responseJson && - Predicate.isString(responseJson.personId) - ) { - sessionPersonsByCookie.set(cookieKey, responseJson.personId); - record.sessionPersonId = responseJson.personId; + const answer = record.rpcTag === null ? undefined : unwrapRpcAnswer(responseJson); + + if (answer !== undefined) { + record.status = answer.status; + record.responseJson = answer.json; + } + + const profilePerson = + record.path === "/api/profile" && record.status === 200 + ? profilePersonId(record.responseJson) + : undefined; + + if (cookieKey !== undefined && profilePerson !== undefined) { + sessionPersonsByCookie.set(cookieKey, profilePerson); + record.sessionPersonId = profilePerson; } if (record.sessionPersonId !== null) { @@ -662,12 +767,6 @@ const hasExactKeys = (value, expectedKeys) => !Array.isArray(value) && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...expectedKeys].sort(Order.String)); -const assertStrongEtag = (etag, label) => { - if (!Predicate.isString(etag) || !/^"vkr2\.[A-Za-z0-9_-]{43}"$/u.test(etag)) { - throw new Error(`${label} did not return a canonical strong ETag`); - } -}; - const assertProblemResponse = (record, expected) => { const expectedKeys = ["code", "detail", "status", "title", "type"]; @@ -689,7 +788,9 @@ const assertProblemResponse = (record, expected) => { ("instance" in (record.responseJson ?? {}) && record.responseJson.instance !== null && !Predicate.isString(record.responseJson.instance)) || - !record.responseContentType?.startsWith("application/problem+json") + !record.responseContentType?.startsWith( + record.rpcTag === null ? "application/problem+json" : "application/json", + ) ) { throw new Error( `${record.method} ${record.path} did not return the generated ${expected.code} Problem Details shape`, @@ -940,12 +1041,6 @@ async function main() { BACKEND_PG_URL: postgresUrl, }; - await runCommand("bun", ["run", "build"], { - cwd: sdkRoot, - env: journeyEnvironment, - label: "Native Organization SDK build", - }); - // The interactive preview keeps the dev server; the journey serves the production build, // whose bundles cannot be re-optimized and reloaded under a signing-in browser. if (process.env.ORGANIZATION_LIFECYCLE_PREVIEW === "1") { @@ -1027,15 +1122,15 @@ async function main() { administrator: { nativeLogin: true, sessionCookieNames: ["better-auth.session_token"], - apiSessionPath: "/api/session", - personBindingPath: "/api/profile", + apiSessionRpc: "system.readSession", + personBindingRpc: "profile.readOwnProfile", personId: adminPersonId, }, member: { nativeLogin: true, sessionCookieNames: ["better-auth.session_token"], - apiSessionPath: "/api/session", - personBindingPath: "/api/profile", + apiSessionRpc: "system.readSession", + personBindingRpc: "profile.readOwnProfile", personId: memberPersonId, }, }, @@ -1143,23 +1238,10 @@ async function main() { record.idempotencyKey !== null || record.ifMatch !== null || !record.responseContentType?.startsWith("application/json") || - !hasExactKeys(record.responseJson, [ - "contactRevision", - "email", - "firstName", - "lastName", - "nameRevision", - "personId", - "phone", - "role", - ]) + profilePersonId(record.responseJson) === undefined ) { - throw new Error( - "Native Organization profile resolution did not use the generated v0.2 shape", - ); + throw new Error("Native Organization profile resolution did not name its person"); } - - assertStrongEtag(record.responseEtag, "Native Organization profile resolution"); } const retiredOrganizationRequests = proxy.records.filter( @@ -1198,10 +1280,9 @@ async function main() { !Array.isArray(record.responseJson) || record.responseJson.some((item) => !hasExactKeys(item, responseKeys)) ) { - throw new Error(`GET ${record.path} did not use the generated v0.2 read contract`); + throw new Error(`GET ${record.path} did not use the native read contract`); } - assertStrongEtag(record.responseEtag, `GET ${record.path}`); continue; } @@ -1217,20 +1298,16 @@ async function main() { record.idempotencyKey.length === 0 || record.ifMatch !== null ) { - throw new Error(`POST ${record.path} did not use the generated v0.2 mutation contract`); + throw new Error(`POST ${record.path} did not use the native mutation contract`); } - if (record.status === 201) { - if ( - !hasExactKeys(record.responseJson, responseKeys) || - !record.responseContentType?.startsWith("application/json") || - !Predicate.isString(record.responseLocation) || - record.responseLocation.length === 0 - ) { - throw new Error(`POST ${record.path} did not return the generated create response`); - } - - assertStrongEtag(record.responseEtag, `POST ${record.path}`); + // An RPC create answers the created resource; Location and ETag were HTTP transport facts. + if ( + record.status === 200 && + (!hasExactKeys(record.responseJson, responseKeys) || + !record.responseContentType?.startsWith("application/json")) + ) { + throw new Error(`POST ${record.path} did not return the native create response`); } } @@ -1259,7 +1336,7 @@ async function main() { const expectedStatusFacts = [ [ "/api/departments", - 201, + 200, true, false, adminPersonId, @@ -1268,7 +1345,7 @@ async function main() { ], [ "/api/teams", - 201, + 200, true, false, adminPersonId, @@ -1277,7 +1354,7 @@ async function main() { ], [ "/api/field-of-studies", - 201, + 200, true, false, adminPersonId, @@ -1304,7 +1381,7 @@ async function main() { ], [ "/api/departments", - 201, + 200, true, false, adminPersonId, @@ -1381,11 +1458,11 @@ async function main() { const departmentReplays = organizationRequests.filter( ({ path, status, idempotencyKey }) => - path === "/api/departments" && status === 201 && idempotencyKey === commandIds.department, + path === "/api/departments" && status === 200 && idempotencyKey === commandIds.department, ); if (departmentReplays.length !== 2) { - throw new Error("Native Organization exact replay did not preserve the create status"); + throw new Error("Native Organization exact replay did not preserve the create answer"); } assertEqual( @@ -1393,11 +1470,6 @@ async function main() { departmentReplays[0].responseJson, "Native Organization exact replay response", ); - assertEqual( - departmentReplays[1].responseEtag, - departmentReplays[0].responseEtag, - "Native Organization exact replay ETag", - ); evidence = { topology: { @@ -1412,6 +1484,7 @@ async function main() { ({ method, path, + rpcTag, status, sessionCookieAuth, authorizationHeaderPresent, @@ -1424,6 +1497,7 @@ async function main() { }) => ({ method, path, + rpcTag, status, sessionCookieAuth, authorizationHeaderPresent, diff --git a/apps/dashboard/e2e/run-real-native-placement.mjs b/apps/dashboard/e2e/run-real-native-placement.mjs index c02ff7a14..806fa5482 100644 --- a/apps/dashboard/e2e/run-real-native-placement.mjs +++ b/apps/dashboard/e2e/run-real-native-placement.mjs @@ -272,7 +272,6 @@ try { reservation.listen(Number(environment.PORT), "127.0.0.1", resolve); }); await new Promise((resolve) => reservation.close(resolve)); - await run("bun", ["--no-env-file", "run", "build"], join(root, "packages/sdk")); await run("bun", ["--no-env-file", "run", "build"], dashboardRoot); const sourceTree = spawnSync("git", ["rev-parse", "HEAD^{tree}"], { diff --git a/apps/dashboard/e2e/run-real-native-profile-self-edit.mjs b/apps/dashboard/e2e/run-real-native-profile-self-edit.mjs index ed3dc24c5..f61b3d3cf 100644 --- a/apps/dashboard/e2e/run-real-native-profile-self-edit.mjs +++ b/apps/dashboard/e2e/run-real-native-profile-self-edit.mjs @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; import { postgresProgram, startDisposablePostgres } from "@monoweb/postgres"; import { localBackendEnvironment } from "../../../tools/e2e/local-backend-environment.ts"; +import { nativeRpcStatus, nativeRpcTag } from "./native-operations.ts"; import { addressesAnyRoute, legacyRoutes } from "./request-routes.ts"; const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); @@ -158,7 +159,18 @@ const startProxy = async (targetOrigin) => { for await (const chunk of request) chunks.push(Buffer.from(chunk)); const body = Buffer.concat(chunks); - const record = { method, path, status: 0, durationMs: 0, direction: "proxy-to-native" }; + const rpcTag = nativeRpcTag(body.toString("utf8")); + + const record = { + method, + path, + rpcTag, + status: 0, + rpcStatus: undefined, + durationMs: 0, + direction: "proxy-to-native", + }; + records.push(record); try { @@ -173,7 +185,7 @@ const startProxy = async (targetOrigin) => { headers.set(name, Array.isArray(value) ? value.join(", ") : value); } - if (method === "PATCH" && path === "/api/profile") await sleep(200); + if (rpcTag === "profile.updateOwnProfile") await sleep(200); const upstream = await fetch(new URL(request.url ?? "/", targetOrigin), { method, @@ -184,6 +196,7 @@ const startProxy = async (targetOrigin) => { const bytes = Buffer.from(await upstream.arrayBuffer()); record.status = upstream.status; + record.rpcStatus = rpcTag === undefined ? undefined : nativeRpcStatus(bytes.toString("utf8")); record.durationMs = Date.now() - started; response.statusCode = upstream.status; @@ -369,43 +382,29 @@ const main = async () => { assert.equal(postgresEvidence.replay.byteEqualResult, true); assert.equal(postgresEvidence.changedPayloadConflict.tag, "ProfileCommandConflict"); - const ledger = proxy.records.map(({ method, path, status, durationMs, direction }) => ({ - method, - path, - status, - durationMs, - direction, - })); - - assert.equal( - ledger.some( - (entry) => entry.path === "/api/profile" && entry.method === "GET" && entry.status === 200, - ), - true, - ); - assert.equal( - ledger.some( - (entry) => - entry.path === "/api/profile" && entry.method === "PATCH" && entry.status === 200, - ), - true, - ); - assert.equal( - ledger.some((entry) => entry.path === "/api/profile" && entry.status === 401), - true, - ); - assert.equal( - ledger.some((entry) => entry.path === "/api/profile" && entry.status === 422), - true, - ); - assert.equal( - ledger.some((entry) => entry.path === "/api/profile" && entry.status === 409), - true, - ); - assert.equal( - ledger.some((entry) => entry.path === "/api/profile" && entry.status === 412), - true, + const ledger = proxy.records.map( + ({ method, path, rpcTag, status, rpcStatus, durationMs, direction }) => ({ + method, + path, + rpcTag, + status, + rpcStatus, + durationMs, + direction, + }), ); + + const profileTags = ["profile.readOwnProfile", "profile.updateOwnProfile"]; + + const answered = (tags, status) => + ledger.some((entry) => tags.includes(entry.rpcTag) && entry.rpcStatus === status); + + assert.equal(answered(["profile.readOwnProfile"], 200), true); + assert.equal(answered(["profile.updateOwnProfile"], 200), true); + assert.equal(answered(profileTags, 401), true); + assert.equal(answered(profileTags, 422), true); + assert.equal(answered(profileTags, 409), true); + assert.equal(answered(profileTags, 412), true); assert.equal( ledger.some((entry) => addressesAnyRoute(entry.path, legacyRoutes)), false, diff --git a/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs b/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs index 8407b7356..ee9f766fa 100644 --- a/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs +++ b/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs @@ -1,4 +1,4 @@ -import { Predicate } from "effect"; +import { Match, Predicate } from "effect"; import assert from "node:assert/strict"; import { randomBytes, randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; @@ -9,14 +9,21 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.ts"; import { startDisposablePostgres } from "@monoweb/postgres"; import { chromium } from "@playwright/test"; -import { createPromiseClient } from "@vektorprogrammet/sdk"; +import { nativeRpcPath } from "@vektorprogrammet/rpc"; import { dashboardMount } from "../dashboard-base.ts"; import { journeyClock } from "../../../tools/e2e/journey-clock.ts"; -import { isNativeRequest } from "./native-operations.ts"; +import { + isNativeOperation, + isNativeRequest, + nativeRpcOutcome, + nativeRpcRequestBody, + nativeRpcStatus, + nativeRpcValue, +} from "./native-operations.ts"; const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); @@ -286,6 +293,67 @@ const parseJson = (bytes) => { } }; +const encodedId = (payload) => + Predicate.isString(payload.receiptId) ? encodeURIComponent(payload.receiptId) : ""; + +/** + * The HTTP route that each native RPC of this journey replaced, from its tag and payload. The + * ledger records an RPC by that route, so its facts stay comparable with the commands' receipts. + */ +const replacedRoute = (tag, payload) => + Match.value(tag).pipe( + Match.when("system.readSession", () => ["GET", "/api/session"]), + Match.when("profile.readOwnProfile", () => ["GET", "/api/profile"]), + Match.when("receipts.submitReceipt", () => ["POST", "/api/receipts"]), + Match.when("receipts.listReceipts", () => ["GET", "/api/receipts"]), + Match.when("receipts.approveReceipt", () => ["POST", `/api/receipts/${encodedId(payload)}:approve`]), + Match.when("receipts.settleReceipt", () => ["POST", `/api/receipts/${encodedId(payload)}:settle`]), + Match.when("receipts.listReceiptsForSettlement", () => ["GET", "/api/receipt-settlement-queue"]), + Match.when("receipts.readReceiptSettlementForFinance", () => [ + "GET", + `/api/receipt-settlement-queue/${encodedId(payload)}`, + ]), + Match.orElse(() => ["POST", `/api/rpc#${tag}`]), + ); + +/** The one RPC request message that a request body to the RPC endpoint carries. */ +const parseRpcRequest = (json) => + json !== null && + Predicate.isObjectOrArray(json) && + !Array.isArray(json) && + Predicate.isTagged(json, "Request") && + Predicate.isString(json.tag) + ? { + tag: json.tag, + payload: Predicate.isObjectOrArray(json.payload) ? json.payload : {}, + headers: new Map(Array.isArray(json.headers) ? json.headers : []), + } + : undefined; + +/** One RPC answer as a journey reads it: its registry status and its value or problem body. */ +const rpcAnswer = (text) => { + const outcome = nativeRpcOutcome(text); + + const body = Predicate.isTagged(outcome, "Success") + ? outcome.value + : Predicate.isTagged(outcome, "Problem") + ? outcome.problem + : null; + + return { status: nativeRpcStatus(text) ?? 500, body, json: async () => body }; +}; + +/** Posts one RPC as a driver does: the cookie and the dashboard origin are HTTP headers. */ +async function postRpc(apiOrigin, cookie, tag, payload) { + const response = await fetch(`${apiOrigin}${nativeRpcPath}`, { + method: "POST", + headers: nativeHeaders(cookie, { "content-type": "application/json" }), + body: nativeRpcRequestBody(tag, payload), + }); + + return rpcAnswer(await response.text()); +} + async function startRecordingProxy(targetOrigin) { const records = []; @@ -312,26 +380,47 @@ async function startRecordingProxy(targetOrigin) { }); const upstreamBody = Buffer.from(await upstream.arrayBuffer()); + + // A native RPC is recorded as the route it replaced, with its key, precondition, request, + // and answer from the RPC messages. The dashboard server forwards the cookie in the message. + // The RPC client posts to the endpoint with one trailing slash, which `isNativeOperation` + // accepts as the backend does. + const rpc = + request.method === "POST" && isNativeOperation(request.method, url.pathname) + ? parseRpcRequest(parseJson(requestBody)) + : undefined; + + const answer = rpc === undefined ? undefined : rpcAnswer(upstreamBody.toString("utf8")); + + const [method, pathname] = + rpc === undefined ? [request.method ?? "GET", url.pathname] : replacedRoute(rpc.tag, rpc.payload); + records.push({ - method: request.method ?? "GET", - pathname: url.pathname, + method, + pathname, query: url.search, targetOrigin: url.origin, + transportMethod: request.method ?? "GET", + transportPath: url.pathname, + rpcTag: rpc?.tag ?? null, requestHeaders: { - authorization: request.headers.authorization ?? null, - cookiePresent: Predicate.isString(request.headers.cookie), - idempotencyKey: request.headers["idempotency-key"] ?? null, - ifMatch: request.headers["if-match"] ?? null, + authorization: request.headers.authorization ?? rpc?.headers.get("authorization") ?? null, + cookiePresent: + Predicate.isString(request.headers.cookie) || Predicate.isString(rpc?.headers.get("cookie")), + idempotencyKey: Predicate.isString(rpc?.payload.idempotencyKey) + ? rpc.payload.idempotencyKey + : null, + ifMatch: Predicate.isString(rpc?.payload.ifMatch) ? rpc.payload.ifMatch : null, contentType: request.headers["content-type"] ?? null, }, - requestJson: parseJson(requestBody), - responseStatus: upstream.status, + requestJson: rpc === undefined ? parseJson(requestBody) : (rpc.payload.request ?? null), + responseStatus: answer?.status ?? upstream.status, responseHeaders: { etag: upstream.headers.get("etag"), contentType: upstream.headers.get("content-type"), cacheControl: upstream.headers.get("cache-control"), }, - responseJson: parseJson(upstreamBody), + responseJson: answer === undefined ? parseJson(upstreamBody) : answer.body, }); response.statusCode = upstream.status; @@ -455,12 +544,8 @@ async function startDeliverySink() { } function resultBody(result, label) { - assert.ok( - result && (result === null || Predicate.isObjectOrArray(result)), - `${label} did not return an SDK response`, - ); - assert.ok("body" in result, `${label} SDK response has no body`); - assert.ok(result.body !== undefined, `${label} SDK response returned no body`); + assert.equal(result.status, 200, `${label} answered ${JSON.stringify(result.body)}`); + assert.ok(result.body !== null && result.body !== undefined, `${label} returned no value`); return result.body; } @@ -476,31 +561,21 @@ function nativeHeaders(cookie, headers = {}) { } async function requestSettlement(apiOrigin, cookie, receiptId, etag, idempotencyKey, payload) { - return fetch(`${apiOrigin}/api/receipts/${encodeURIComponent(receiptId)}:settle`, { - method: "POST", - headers: nativeHeaders(cookie, { - "content-type": "application/json", - "idempotency-key": idempotencyKey, - "if-match": etag, - }), - body: JSON.stringify(payload), + return postRpc(apiOrigin, cookie, "receipts.settleReceipt", { + receiptId, + idempotencyKey, + ifMatch: etag, + request: payload, }); } async function requestFinanceEvidence(apiOrigin, cookie, receiptId) { - return fetch(`${apiOrigin}/api/receipt-settlement-queue/${encodeURIComponent(receiptId)}`, { - headers: nativeHeaders(cookie), - }); + return postRpc(apiOrigin, cookie, "receipts.readReceiptSettlementForFinance", { receiptId }); } async function expectProblem(response, expectedStatus, expectedCode, label) { const body = await response.json(); assert.equal(response.status, expectedStatus, `${label} status: ${JSON.stringify(body)}`); - assert.match( - response.headers.get("content-type") ?? "", - /application\/problem\+json/u, - `${label} must return a native problem document`, - ); assert.equal(body?.status, expectedStatus, `${label} problem status`); assert.equal(body?.code, expectedCode, `${label} problem code`); assert.equal( @@ -636,12 +711,16 @@ async function login(browser, persona) { assert.ok(sessionCookie, `${persona.personId} session cookie is missing`); const cookie = `${sessionCookie.name}=${sessionCookie.value}`; - const sessionResponse = await fetch(`${backendOrigin}/api/session`, { - headers: nativeHeaders(cookie), - }); + const sessionAnswer = await ( + await fetch(`${backendOrigin}/api/rpc`, { + method: "POST", + headers: nativeHeaders(cookie, { "content-type": "application/json" }), + body: nativeRpcRequestBody("system.readSession"), + }) + ).text(); - assert.equal(sessionResponse.status, 200, `${persona.personId} native session read`); - const session = await sessionResponse.json(); + assert.equal(nativeRpcStatus(sessionAnswer), 200, `${persona.personId} native session read`); + const session = nativeRpcValue(sessionAnswer); assert.equal( session?.personId, persona.personId, @@ -960,33 +1039,21 @@ async function main() { for (const session of sessions) session.context.on("request", recordBrowserRequest); - const ownerSdk = createPromiseClient(proxy.origin, { - cookie: owner.cookie, - origin: dashboardOrigin, - }); - - const approverSdk = createPromiseClient(proxy.origin, { - cookie: approver.cookie, - origin: dashboardOrigin, - }); - - const settlerSdk = createPromiseClient(proxy.origin, { - cookie: settler.cookie, - origin: dashboardOrigin, - }); - const listOwned = async () => - resultBody(await ownerSdk.receipts.listReceipts({ query: {} }), "list owned receipts"); + resultBody( + await postRpc(proxy.origin, owner.cookie, "receipts.listReceipts", {}), + "list owned receipts", + ); const listSettlementQueue = async () => resultBody( - await settlerSdk.receipts.listReceiptsForSettlement({ query: {} }), + await postRpc(proxy.origin, settler.cookie, "receipts.listReceiptsForSettlement", {}), "list settlement queue", ); const readFinanceSettlement = async (receiptId) => resultBody( - await settlerSdk.receipts.readReceiptSettlementForFinance({ params: { receiptId } }), + await requestFinanceEvidence(proxy.origin, settler.cookie, receiptId), "read settlement evidence for finance", ); @@ -1018,12 +1085,12 @@ async function main() { const approvalKey = randomUUID(); const approvedResult = resultBody( - await approverSdk.receipts.approveReceipt({ - params: { receiptId: submittedReceipt.receiptId }, - headers: { "idempotency-key": approvalKey, "if-match": submittedReceipt.etag }, - payload: {}, + await postRpc(proxy.origin, approver.cookie, "receipts.approveReceipt", { + receiptId: submittedReceipt.receiptId, + idempotencyKey: approvalKey, + ifMatch: submittedReceipt.etag, }), - "approve receipt through generated SDK", + "approve receipt through the native RPC", ); assert.equal(approvedResult.status, "Approved", "Approval changes the claim decision only"); @@ -1103,7 +1170,6 @@ async function main() { "Settlement grant must be separate from approval grant", ); - const queuePath = `${proxy.origin}/api/receipt-settlement-queue`; const denialCountsBefore = await readWriteCounts(pool); const settlementAttemptPayload = (reference, expectedRevision = approvedResult.revision) => ({ @@ -1131,7 +1197,13 @@ async function main() { ["expired settlement grantee", expiredSettler.cookie, 200, 404, "receipt.not-found"], ["wrong department settlement grantee", foreignSettler.cookie, 200, 404, "receipt.not-found"], ]) { - const queueResponse = await fetch(queuePath, { headers: nativeHeaders(cookie) }); + const queueResponse = await postRpc( + proxy.origin, + cookie, + "receipts.listReceiptsForSettlement", + {}, + ); + const queueRecord = proxy.records.at(-1); assert.equal( queueRecord?.requestHeaders.cookiePresent, @@ -1329,12 +1401,12 @@ async function main() { assert.match(confirmationText, new RegExp(submittedReceipt.visualId, "u")); assert.match(confirmationText, /browser-settlement-reference-0114/u); - const axe = await new AxeBuilder({ page: settler.page }) - .include("[data-receipt-settlement-dialog]") - .analyze(); + const axe = await auditSettledPage(settler.page, { + include: ["[data-receipt-settlement-dialog]"], + }); assert.deepEqual( - axe.violations.map(({ id }) => id), + axe.map(({ id }) => id), [], "Settlement confirmation has no blocking accessibility violations", ); @@ -1425,7 +1497,17 @@ async function main() { "private, no-store", "Settlement response is private", ); - assert.ok(canonicalRecord.responseHeaders.etag, "Settlement response carries a fresh ETag"); + + // The settlement RPC answers its evidence alone (its ETag header is dropped); the settled + // receipt's fresh tag is the one that the owner's list answers. + const settledEtag = receiptById( + (await listOwned()).items, + submittedReceipt.receiptId, + "owner after settlement", + ).etag; + + assert.ok(Predicate.isString(settledEtag), "The settled receipt carries a tag"); + assert.notEqual(settledEtag, approvedResult.etag, "Settlement issues a fresh receipt tag"); const canonicalEvidence = await eventually( () => readReceiptEvidence(pool, submittedReceipt.receiptId), @@ -1673,7 +1755,7 @@ async function main() { proxy.origin, settler.cookie, submittedReceipt.receiptId, - canonicalRecord.responseHeaders.etag, + settledEtag, randomUUID(), settlementAttemptPayload( "second-settlement-reference-0114", @@ -1773,21 +1855,21 @@ async function main() { deliverySink.failNext(); const deliverySettlement = resultBody( - await settlerSdk.receipts.settleReceipt({ - params: { receiptId: deliveryCandidate.receiptId }, - headers: { "idempotency-key": deliveryKey, "if-match": deliveryCandidate.etag }, - payload: settlementAttemptPayload( - "delivery-retry-reference-0114", - deliveryCandidate.revision, - ), - }), - "settle delivery-retry receipt through generated SDK", + await requestSettlement( + proxy.origin, + settler.cookie, + deliveryCandidate.receiptId, + deliveryCandidate.etag, + deliveryKey, + settlementAttemptPayload("delivery-retry-reference-0114", deliveryCandidate.revision), + ), + "settle delivery-retry receipt through the native RPC", ); assert.equal( deliverySettlement.receiptId, deliveryCandidate.receiptId, - "SDK settlement response identifies the receipt", + "RPC settlement response identifies the receipt", ); const failedDeliveryEvidence = await eventually( @@ -1903,7 +1985,12 @@ async function main() { proxyTargets: [...new Set(proxy.records.map(({ targetOrigin }) => targetOrigin))], deliveryTargets: deliverySink.deliveries.map(({ loopback }) => loopback), providerCalls: proxy.records.filter( - ({ method, pathname }) => !isNativeRequest(method, pathname), + ({ transportMethod, transportPath, rpcTag }) => + !isNativeRequest( + transportMethod, + transportPath, + rpcTag === null ? undefined : nativeRpcRequestBody(rpcTag), + ), ), }; @@ -1951,8 +2038,8 @@ async function main() { evidence = { topology: { dashboard: "production-react-router-server", - backend: "native-effect-http-api", - sdk: "generated-@vektorprogrammet/sdk", + backend: "native-effect-rpc", + client: "native-rpc-@vektorprogrammet/rpc", browser: "real-headless-chromium", database: "disposable-postgresql-local", delivery: "acknowledged-loopback-http", @@ -2007,7 +2094,7 @@ async function main() { route: settlementRoute, mobileWidth: 390, keyboard: "open-and-confirm", - accessibilityViolations: axe.violations.length, + accessibilityViolations: axe.length, ownerReloaded: true, financeReloaded: true, }, diff --git a/apps/dashboard/e2e/run-real-native-recruitment-interview-conduct.mjs b/apps/dashboard/e2e/run-real-native-recruitment-interview-conduct.mjs index c18e614dc..8eca06458 100644 --- a/apps/dashboard/e2e/run-real-native-recruitment-interview-conduct.mjs +++ b/apps/dashboard/e2e/run-real-native-recruitment-interview-conduct.mjs @@ -1,4 +1,6 @@ import { Predicate } from "effect"; +import { replacedAnswer, replacedRequest } from "./native-rpc-ledger.ts"; +import { isNativeRpcPath } from "./native-operations.ts"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { spawn } from "node:child_process"; import { createServer } from "node:http"; @@ -250,6 +252,15 @@ const startProxy = async (targetOrigin) => { responseHasCapability: false, }; + const rpc = isNativeRpcPath(path) ? replacedRequest(parseJson(requestBytes)) : undefined; + + // A native RPC is recorded as the HTTP route it replaced. + if (rpc !== undefined) { + record.rpcTag = rpc.tag; + record.method = rpc.method; + record.path = rpc.path; + } + records.push(record); try { @@ -275,6 +286,16 @@ const startProxy = async (targetOrigin) => { if (upstream.status >= 400) record.responseBody = responseBytes.toString(); record.status = upstream.status; + + // An RPC answers 200; its problem keeps the registry status and body of its code. + const answer = rpc === undefined ? undefined : replacedAnswer(parseJson(responseBytes)); + + if (answer !== undefined) { + record.status = answer.status; + record.responseBody = + answer.status >= 400 ? JSON.stringify(answer.responseJson) : undefined; + } + response.statusCode = upstream.status; record.responseHasCapability = hasObjectKey(parseJson(responseBytes), "responseCapability"); @@ -379,11 +400,6 @@ const main = async () => { ); await waitForHttp(`${backendOrigin}/health`, backend, "native backend"); proxy = await startProxy(backendOrigin); - await run("bun", ["run", "--cwd", "packages/sdk", "build"], { - cwd: repositoryRoot, - env: backendEnvironment, - label: "native SDK build", - }); const dashboardEnvironment = { ...baseEnvironment, diff --git a/apps/dashboard/e2e/run-real-native-recruitment-interview-scheduling.mjs b/apps/dashboard/e2e/run-real-native-recruitment-interview-scheduling.mjs index cd9a25ad8..c92fef782 100644 --- a/apps/dashboard/e2e/run-real-native-recruitment-interview-scheduling.mjs +++ b/apps/dashboard/e2e/run-real-native-recruitment-interview-scheduling.mjs @@ -9,12 +9,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { journeyClock } from "../../../tools/e2e/journey-clock.ts"; +import { replacedAnswer, replacedRequest } from "./native-rpc-ledger.ts"; +import { isNativeRpcPath } from "./native-operations.ts"; const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); const dashboardRoot = fileURLToPath(new URL("../", import.meta.url)); -const sdkRoot = fileURLToPath(new URL("../../../packages/sdk/", import.meta.url)); const databaseRoot = fileURLToPath(new URL("../../../packages/database/", import.meta.url)); @@ -475,6 +476,20 @@ async function startRecordingProxy(targetOrigin) { status: 0, }; + const rpc = isNativeRpcPath(path) ? replacedRequest(requestJson) : undefined; + + // A native RPC is recorded as the HTTP route it replaced, with the facts of its message. + if (rpc !== undefined) { + record.rpcTag = rpc.tag; + record.method = rpc.method; + record.path = rpc.path; + record.idempotencyKey = rpc.idempotencyKey; + record.ifMatch = rpc.ifMatch; + record.requestJson = rpc.requestJson; + record.sessionCookieAuth ||= hasSessionCookie(rpc.messageHeaders.cookie); + record.authorizationHeaderPresent ||= rpc.messageHeaders.authorization !== undefined; + } + records.push(record); try { @@ -507,6 +522,14 @@ async function startRecordingProxy(targetOrigin) { record.status = upstream.status; record.responseJson = responseJson ?? null; record.responseEtag = upstream.headers.get("etag"); + const answer = rpc === undefined ? undefined : replacedAnswer(responseJson); + + if (answer !== undefined) { + record.status = answer.status; + record.responseJson = answer.responseJson; + record.responseEtag = answer.responseEtag; + } + record.responseHasResponseCapability = hasObjectKey(responseJson, "responseCapability"); response.statusCode = upstream.status; @@ -960,11 +983,6 @@ async function main() { SCHEDULING_RECORDING_EVIDENCE_PATH: recordingEvidencePath, }; - await runCommand("bun", ["run", "build"], { - cwd: sdkRoot, - env: journeyEnvironment, - label: "Native scheduling SDK build", - }); // The journey serves the production build, whose bundles are never re-optimized and reloaded // under a navigating browser as a dev server's are. diff --git a/apps/dashboard/e2e/run-real-native-schools-directory.mjs b/apps/dashboard/e2e/run-real-native-schools-directory.mjs index 7c874e47c..429ccda36 100644 --- a/apps/dashboard/e2e/run-real-native-schools-directory.mjs +++ b/apps/dashboard/e2e/run-real-native-schools-directory.mjs @@ -1,4 +1,4 @@ -import { Order, Predicate } from "effect"; +import { Match, Option, Order, Predicate, Schema } from "effect"; import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { mkdtemp, readFile, rm } from "node:fs/promises"; @@ -8,6 +8,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { startDisposablePostgres } from "@monoweb/postgres"; +import { isNativeRpcPath } from "./native-operations.ts"; import { addressesAnyRoute, legacyRoutes } from "./request-routes.ts"; const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); @@ -18,7 +19,6 @@ const backendRoot = fileURLToPath(new URL("../../backend/", import.meta.url)); const databaseRoot = fileURLToPath(new URL("../../../packages/database/", import.meta.url)); -const sdkRoot = fileURLToPath(new URL("../../../packages/sdk/", import.meta.url)); const postgresPort = 45160; @@ -191,6 +191,80 @@ const schoolsUnavailableProblem = { detail: "The school directory is temporarily unavailable.", }; +/** The native RPC that the browser reads the school directory with. */ +const listSchoolsTag = "directory.listSchools"; + +/** The one RPC request that a request body carries: its id, tag, and payload. */ +const RpcRequestBody = Schema.fromJsonString( + Schema.Struct({ + id: Schema.Union([Schema.String, Schema.Int]), + tag: Schema.String, + payload: Schema.Unknown, + }), +); + +const parseRpcRequest = (bytes) => + bytes === undefined + ? null + : Option.getOrNull(Schema.decodeUnknownOption(RpcRequestBody)(bytes.toString("utf8"))); + +/** The answer that ends one RPC request: a success value, or one failure's error. */ +const RpcAnswer = Schema.Tuple([ + Schema.Struct({ + exit: Schema.Union([ + Schema.TaggedStruct("Success", { value: Schema.Unknown }), + Schema.TaggedStruct("Failure", { + cause: Schema.Tuple([Schema.Struct({ error: Schema.Unknown })]), + }), + ]), + }), +]); + +const ProblemStatus = Schema.Struct({ status: Schema.Int }); + +/** + * Unwraps the answer of one RPC: a success answers its value at 200, and a failure answers its + * problem at the registry status of the problem's code. + */ +const unwrapRpcAnswer = (json) => + Option.match(Schema.decodeUnknownOption(RpcAnswer)(json), { + onNone: () => null, + onSome: ([{ exit }]) => + Match.value(exit).pipe( + Match.tag("Success", ({ value }) => ({ status: 200, json: value })), + Match.tag("Failure", ({ cause: [{ error }] }) => ({ + status: Option.match(Schema.decodeUnknownOption(ProblemStatus)(error), { + onNone: () => 500, + onSome: ({ status }) => status, + }), + json: error, + })), + Match.exhaustive, + ), + }); + +const ExitMessage = Schema.TaggedStruct("Exit", { + requestId: Schema.Union([Schema.String, Schema.Int]), + exit: Schema.Unknown, +}); + +const FailureExit = Schema.TaggedStruct("Failure", { cause: Schema.Array(Schema.Unknown) }); + +const FailReason = Schema.TaggedStruct("Fail", { error: Schema.Unknown }); + +/** Answers one RPC request with the schools.unavailable problem, as the backend would. */ +const sendSchoolsUnavailableRpc = (response, requestId) => { + response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); + response.end( + JSON.stringify([ + ExitMessage.make({ + requestId, + exit: FailureExit.make({ cause: [FailReason.make({ error: schoolsUnavailableProblem })] }), + }), + ]), + ); +}; + const sendSchoolsUnavailable = (response) => { response.writeHead(503, { "content-type": "application/problem+json", @@ -236,8 +310,10 @@ const assertProblemResponse = (entry, expected) => { } assert.ok( - entry.responseContentType?.startsWith("application/problem+json"), - `${entry.method} ${entry.pathname} must return application/problem+json`, + entry.responseContentType?.startsWith( + entry.rpcTag === null ? "application/problem+json" : "application/json", + ), + `${entry.method} ${entry.pathname} must return its problem in its declared media type`, ); }; @@ -266,6 +342,8 @@ const startRecordingUpstream = async (ledger) => { pathname, search: new URL(request.url ?? "/", upstreamOrigin).search, forced: false, + rpcTag: null, + rpcPayload: null, forwardedTo: backendOrigin, sessionCookieAuth: Predicate.isString(request.headers.cookie) && @@ -284,18 +362,22 @@ const startRecordingUpstream = async (ledger) => { ledger.push(entry); try { - if (!forcedSchoolsFailure && request.method === "GET" && pathname === "/api/schools") { + const body = await requestBody(request); + const rpcRequest = isNativeRpcPath(pathname) ? parseRpcRequest(body) : null; + entry.rpcTag = rpcRequest?.tag ?? null; + entry.rpcPayload = rpcRequest?.payload ?? null; + + if (!forcedSchoolsFailure && request.method === "POST" && entry.rpcTag === listSchoolsTag) { forcedSchoolsFailure = true; entry.forced = true; entry.status = 503; - entry.responseContentType = "application/problem+json"; + entry.responseContentType = "application/json"; entry.responseJson = schoolsUnavailableProblem; - sendSchoolsUnavailable(response); + sendSchoolsUnavailableRpc(response, rpcRequest.id); return; } - const body = await requestBody(request); const headers = new Headers(); for (const [name, value] of Object.entries(request.headers)) { @@ -320,6 +402,15 @@ const startRecordingUpstream = async (ledger) => { entry.status = upstream.status; entry.responseContentType = upstream.headers.get("content-type"); entry.responseJson = parseJsonBody(responseBytes); + + // An RPC answers 200 with its exit; the ledger records the exit's value or problem. + const answer = entry.rpcTag === null ? null : unwrapRpcAnswer(entry.responseJson); + + if (answer !== null) { + entry.status = answer.status; + entry.responseJson = answer.json; + } + response.statusCode = upstream.status; copyResponseHeaders(upstream.headers, response); response.end(responseBytes); @@ -438,11 +529,6 @@ try { NODE_ENV: "production", }; - run("bun", ["run", "build"], { - cwd: sdkRoot, - env: dashboardEnvironment, - label: "Schools SDK build", - }); run("bun", ["run", "build"], { cwd: dashboardRoot, env: dashboardEnvironment, @@ -480,7 +566,7 @@ try { const browserEvidence = JSON.parse(await readFile(browserEvidencePath, "utf8")); assert.equal(browserEvidence.passed, true); - const schoolsRequests = ledger.filter((entry) => entry.pathname === "/api/schools"); + const schoolsRequests = ledger.filter((entry) => entry.rpcTag === listSchoolsTag); const forcedFailures = schoolsRequests.filter((entry) => entry.forced); const forwardedSuccesses = schoolsRequests.filter( @@ -494,7 +580,7 @@ try { assert.ok(authorityDenials.length >= 2, "typed authority denials must reach the backend"); for (const entry of schoolsRequests) { - assert.equal(entry.method, "GET", "the native Schools operation is read-only"); + assert.equal(entry.method, "POST", "the native Schools operation is one RPC"); assert.equal(entry.sessionCookieAuth, true, "the native Schools operation requires a session"); assert.equal( entry.authorizationHeaderPresent, @@ -507,10 +593,14 @@ try { "the native Schools read must not use Idempotency-Key", ); assert.equal(entry.ifMatch, null, "the native Schools read must not use If-Match"); - const queryKeys = [...new URLSearchParams(entry.search).keys()]; + + const payloadKeys = Predicate.isObjectOrArray(entry.rpcPayload) + ? Object.keys(entry.rpcPayload) + : []; + assert.ok( - queryKeys.length <= 1 && queryKeys.every((key) => key === "department"), - "the native Schools read used an unsupported query parameter", + payloadKeys.length <= 1 && payloadKeys.every((key) => key === "departmentId"), + "the native Schools read used an unsupported payload member", ); } @@ -592,8 +682,9 @@ try { seed: seedEvidence, browser: browserEvidence, requestLedger: { - browserPath: "/api/schools", - backendPath: "/api/schools", + browserPath: "/api/rpc", + backendPath: "/api/rpc", + rpcTag: listSchoolsTag, schoolsRequests, forcedFailures: forcedFailures.length, forwardedSuccesses: forwardedSuccesses.length, diff --git a/apps/dashboard/e2e/run-real-native-social-events.mjs b/apps/dashboard/e2e/run-real-native-social-events.mjs index 37dbd12ac..8bfc4a088 100644 --- a/apps/dashboard/e2e/run-real-native-social-events.mjs +++ b/apps/dashboard/e2e/run-real-native-social-events.mjs @@ -10,7 +10,10 @@ import { fileURLToPath } from "node:url"; import { startDisposablePostgres } from "@monoweb/postgres"; import { chromium } from "@playwright/test"; import pg from "pg"; -import { Order } from "effect"; +import { Option, Order, Schema } from "effect"; +import { nativeRpcPath } from "@vektorprogrammet/rpc"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; +import { isNativeRpcPath, nativeRpcOutcome, nativeRpcRequestBody } from "./native-operations.ts"; const { Client } = pg; @@ -22,7 +25,6 @@ const backendRoot = fileURLToPath(new URL("../../backend/", import.meta.url)); const databaseRoot = fileURLToPath(new URL("../../../packages/database/", import.meta.url)); -const sdkRoot = fileURLToPath(new URL("../../../packages/sdk/", import.meta.url)); const runnerPath = fileURLToPath(import.meta.url); @@ -221,6 +223,20 @@ const requestBody = async (request) => { return chunks.length === 0 ? undefined : Buffer.concat(chunks); }; +/** The one RPC request that a request body carries: its tag, payload, and message headers. */ +const RpcRequestBody = Schema.fromJsonString( + Schema.Struct({ + tag: Schema.String, + payload: Schema.Unknown, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), + }), +); + +const parseRpcRequest = (bytes) => + bytes === undefined + ? null + : Option.getOrNull(Schema.decodeUnknownOption(RpcRequestBody)(bytes.toString("utf8"))); + const startRecordingProxy = async (ledger) => { const server = createServer(async (request, response) => { const url = new URL(request.url ?? "/", apiOrigin); @@ -237,22 +253,28 @@ const startRecordingProxy = async (ledger) => { } } - let requestJson = null; + // Every native operation is one RPC at the RPC endpoint; the ledger names it by its tag. + const rpcRequest = + request.method === "POST" && isNativeRpcPath(url.pathname) ? parseRpcRequest(body) : null; - if (body !== undefined && headers.get("content-type")?.includes("json")) { - requestJson = JSON.parse(body.toString("utf8")); - } + // The backend reads an RPC's headers with the message headers merged over the HTTP headers, + // where a server caller such as the dashboard forwards the person's cookie. + const rpcHeaders = new Headers(headers); + + for (const [name, value] of rpcRequest?.headers ?? []) rpcHeaders.set(name, value); const entry = { sequence: ledger.length + 1, method: request.method ?? "GET", path: url.pathname, query: url.search, - idempotencyKey: headers.get("idempotency-key"), - requestJson, + rpcTag: rpcRequest?.tag ?? null, + rpcPayload: rpcRequest?.payload ?? null, + sessionCookie: (rpcHeaders.get("cookie") ?? "").includes("better-auth.session_token="), + authorizationHeader: rpcHeaders.has("authorization"), status: 0, responseHeaders: {}, - responseJson: null, + outcome: null, }; ledger.push(entry); @@ -269,9 +291,8 @@ const startRecordingProxy = async (ledger) => { entry.status = upstream.status; entry.responseHeaders = Object.fromEntries(upstream.headers.entries()); - if (bytes.length > 0 && upstream.headers.get("content-type")?.includes("json")) { - entry.responseJson = JSON.parse(bytes.toString("utf8")); - } + // An RPC answers HTTP 200 with its exit: a value, a problem, or a defect. + if (entry.rpcTag !== null) entry.outcome = nativeRpcOutcome(bytes.toString("utf8")) ?? null; response.statusCode = upstream.status; @@ -389,24 +410,35 @@ const signIn = async (browser, persona) => { }; }; -const api = async (cookie, method, path, { body, key } = {}) => { - const headers = new Headers({ cookie, origin: dashboardOrigin }); +const native = nativeScriptClient(apiOrigin); + +/** + * Calls one social-event RPC as the person whose session cookie is given, from the dashboard + * origin, through the recording proxy. The answer keeps the registry status of each problem. + */ +const rpc = (cookie, call) => native.call({ cookie, origin: dashboardOrigin }, call); + +/** + * Posts one RPC request message as raw JSON, so a payload that the typed client would refuse to + * encode still reaches the RPC server, and answers the outcome of its exit. + */ +const rawRpc = async (cookie, tag, payload) => { + const response = await fetch(`${apiOrigin}${nativeRpcPath}`, { + method: "POST", + headers: { cookie, origin: dashboardOrigin, "content-type": "application/json" }, + body: nativeRpcRequestBody(tag, payload), + redirect: "manual", + }); - if (body !== undefined) headers.set("content-type", "application/json"); + assert.equal(response.status, 200, `${tag} answers its exit over HTTP 200`); - if (key !== undefined) headers.set("idempotency-key", key); - const request = { method, headers, redirect: "manual", body: JSON.stringify(body) }; + return nativeRpcOutcome(await response.text()); +}; - const response = await fetch(`${apiOrigin}${path}`, request); - const bytes = Buffer.from(await response.arrayBuffer()); +const readScope = (cookie) => rpc(cookie, (client) => client["social-events.readScope"]()); - return { - status: response.status, - headers: Object.fromEntries(response.headers.entries()), - body: bytes.length === 0 ? null : JSON.parse(bytes.toString("utf8")), - bytes, - }; -}; +const createWith = (cookie, idempotencyKey, request) => + rpc(cookie, (client) => client["social-events.create"]({ idempotencyKey, request })); const createBody = (overrides = {}) => ({ departmentId: ids.departmentA, @@ -420,21 +452,18 @@ const createBody = (overrides = {}) => ({ ...overrides, }); -const createEvent = (cookie, key, overrides) => - api(cookie, "POST", "/api/social-events", { key, body: createBody(overrides) }); +const createEvent = (cookie, key, overrides) => createWith(cookie, key, createBody(overrides)); const listEvents = (cookie, departmentId = ids.departmentA, semesterId = ids.semesterA) => - api( - cookie, - "GET", - `/api/social-events?departmentId=${encodeURIComponent(departmentId)}&semesterId=${encodeURIComponent(semesterId)}`, - ); - -const assertProblem = (response, status, code) => { - assert.equal(response.status, status); - assert.equal(response.body?.status, status); - assert.equal(response.body?.code, code); - assert.match(response.headers["content-type"] ?? "", /^application\/problem\+json/u); + rpc(cookie, (client) => client["social-events.list"]({ departmentId, semesterId })); + +/** Asserts that one RPC answered the declared problem `code` at its registry `status`. */ +const assertProblem = (answer, status, code) => { + assert.equal(answer.ok, false, `expected ${code}`); + assert.equal(answer.status, status); + assert.equal(answer.code, code); + assert.equal(answer.problem?.status, status); + assert.equal(answer.problem?.code, code); }; const isoAround = (instant, milliseconds) => @@ -502,17 +531,17 @@ const exerciseJourney = async ({ browser, ledger }) => { const pageErrors = []; member.page.on("pageerror", (error) => pageErrors.push(error.message)); - const memberScope = await api(member.cookie, "GET", "/api/social-events/scope"); + const memberScope = await readScope(member.cookie); assert.equal(memberScope.status, 200); assert.deepEqual( - memberScope.body.departments.map(({ departmentId }) => departmentId), + memberScope.value.departments.map(({ departmentId }) => departmentId), [ids.departmentA], ); assert.deepEqual( - memberScope.body.semesters.map(({ semesterId }) => semesterId), + memberScope.value.semesters.map(({ semesterId }) => semesterId), [ids.semesterA, ids.semesterB], ); - const referenceInstant = memberScope.body.observedAt; + const referenceInstant = memberScope.value.observedAt; await member.page.goto("/dashboard/arrangementer"); await member.page.getByRole("heading", { name: "Arrangementer" }).waitFor(); @@ -552,23 +581,29 @@ const exerciseJourney = async ({ browser, ledger }) => { assert.match(await firstRow.innerText(), /Kun teammedlemmer/u); const firstUiPost = ledger.find( - ({ method, path, requestJson }) => - method === "POST" && path === "/api/social-events" && requestJson?.title === "Sosial kveld", + ({ rpcTag, rpcPayload }) => + rpcTag === "social-events.create" && rpcPayload?.request?.title === "Sosial kveld", ); assert.ok(firstUiPost, "dashboard create reached the native API"); - assert.equal(firstUiPost.status, 201); - assert.equal(firstUiPost.responseHeaders["cache-control"], "no-store"); - assert.equal(firstUiPost.responseHeaders.vary, "Origin"); - assert.match(firstUiPost.responseHeaders.etag ?? "", /^"vkr2\.[A-Za-z0-9_-]{43}"$/u); - assert.match(firstUiPost.responseHeaders.location ?? "", /^\/api\/social-events\//u); + assert.equal(firstUiPost.status, 200); + assert.equal(firstUiPost.outcome?._tag, "Success", "dashboard create committed"); + assert.equal(firstUiPost.sessionCookie, true, "dashboard create carries the session cookie"); + assert.equal(firstUiPost.authorizationHeader, false, "dashboard create carries no bearer"); + assert.match(firstUiPost.responseHeaders["content-type"] ?? "", /^application\/json/u); + assert.match(firstUiPost.rpcPayload.idempotencyKey ?? "", /^\S+$/u); + assert.equal(firstUiPost.outcome.value.title, "Sosial kveld"); + assert.equal(firstUiPost.outcome.value.revision, 0); const firstPostIndex = ledger.indexOf(firstUiPost); assert.ok( ledger .slice(firstPostIndex + 1) .some( - ({ method, path, status }) => - method === "GET" && path === "/api/social-events" && status === 200, + ({ rpcTag, rpcPayload, outcome }) => + rpcTag === "social-events.list" && + rpcPayload?.departmentId === ids.departmentA && + rpcPayload?.semesterId === ids.semesterA && + outcome?._tag === "Success", ), "dashboard fetched a fresh scoped list after create", ); @@ -607,8 +642,8 @@ const exerciseJourney = async ({ browser, ledger }) => { endAt: isoAround(sameStart, 60 * 60 * 1000), }); - assert.equal(equalA.status, 201); - assert.equal(equalB.status, 201); + assert.equal(equalA.status, 200); + assert.equal(equalB.status, 200); const boundaryDefinitions = [ ["Før nå", -1], @@ -634,15 +669,15 @@ const exerciseJourney = async ({ browser, ledger }) => { }, ); - assert.equal(created.status, 201, `create ${title}`); + assert.equal(created.status, 200, `create ${title}`); } const listed = await listEvents(member.cookie); assert.equal(listed.status, 200); - const equalRows = listed.body.events.filter(({ startAt }) => startAt === sameStart); + const equalRows = listed.value.events.filter(({ startAt }) => startAt === sameStart); assert.deepEqual( equalRows.map(({ eventId }) => eventId), - [equalA.body.eventId, equalB.body.eventId].sort(Order.String), + [equalA.value.eventId, equalB.value.eventId].sort(Order.String), ); const { timeLabel } = await import("../app/foldkit/social-events/view.ts"); @@ -661,15 +696,14 @@ const exerciseJourney = async ({ browser, ledger }) => { null, ); - const replay = await api(member.cookie, "POST", "/api/social-events", { - key: firstUiPost.idempotencyKey, - body: firstUiPost.requestJson, - }); + const replay = await createWith( + member.cookie, + firstUiPost.rpcPayload.idempotencyKey, + firstUiPost.rpcPayload.request, + ); - assert.equal(replay.status, 201); - assert.deepEqual(replay.body, firstUiPost.responseJson); - assert.equal(replay.headers.etag, firstUiPost.responseHeaders.etag); - assert.equal(replay.headers.location, firstUiPost.responseHeaders.location); + assert.equal(replay.status, 200); + assert.deepEqual(replay.value, firstUiPost.outcome.value); const afterReplay = await counts(); const locker = await connect(); @@ -678,31 +712,24 @@ const exerciseJourney = async ({ browser, ledger }) => { const raceBody = createBody({ title: "Samtidig arrangement", link: null }); const raceKey = "social-events-race-0110"; - const raceOne = api(member.cookie, "POST", "/api/social-events", { - key: raceKey, - body: raceBody, - }); + const raceOne = createWith(member.cookie, raceKey, raceBody); await waitForDatabaseWait("INSERT INTO public.social_events"); - const raceTwo = api(member.cookie, "POST", "/api/social-events", { - key: raceKey, - body: raceBody, - }); + const raceTwo = createWith(member.cookie, raceKey, raceBody); const quickRace = await withTimeout(raceTwo, 10_000, "matching command in-flight response"); assertProblem(quickRace, 409, "idempotency.in-flight"); - assert.equal(quickRace.headers["retry-after"], "1"); await locker.query("COMMIT"); await locker.end(); const acceptedRace = await raceOne; - assert.equal(acceptedRace.status, 201); + assert.equal(acceptedRace.status, 200); const digestCounts = await counts(); - const changedReplay = await api(member.cookie, "POST", "/api/social-events", { - key: raceKey, - body: { ...raceBody, title: "Endret samtidig arrangement" }, + const changedReplay = await createWith(member.cookie, raceKey, { + ...raceBody, + title: "Endret samtidig arrangement", }); assertProblem(changedReplay, 409, "idempotency.digest-conflict"); @@ -710,14 +737,14 @@ const exerciseJourney = async ({ browser, ledger }) => { const tombKey = "social-events-tombstone-0110"; const tombCreated = await createEvent(member.cookie, tombKey, { title: "Utløpt svar" }); - assert.equal(tombCreated.status, 201); + assert.equal(tombCreated.status, 200); await query( `UPDATE native_http_idempotency_receipts SET committed_at = committed_at - interval '25 hours', full_expires_at = full_expires_at - interval '25 hours' WHERE operation_id = 'social-events.create' AND convert_from(body_bytes, 'UTF8')::jsonb ->> 'eventId' = $1`, - [tombCreated.body.eventId], + [tombCreated.value.eventId], ); const tombCounts = await counts(); const tombReplay = await createEvent(member.cookie, tombKey, { title: "Utløpt svar" }); @@ -726,13 +753,19 @@ const exerciseJourney = async ({ browser, ledger }) => { const beforeValidation = await counts(); - const invalidTime = await createEvent(member.cookie, "social-events-invalid-time-0110", { - title: "Ugyldig tid", - startAt: "2030-03-01T20:00:00.000Z", - endAt: "2030-03-01T19:00:00.000Z", + // The payload schema orders the times, so the RPC server refuses this payload as a defect + // before the handler runs (validation.failed came from HTTP body decoding). The typed client + // would refuse to encode it, so it travels as raw JSON. + const invalidTime = await rawRpc(member.cookie, "social-events.create", { + idempotencyKey: "social-events-invalid-time-0110", + request: createBody({ + title: "Ugyldig tid", + startAt: "2030-03-01T20:00:00.000Z", + endAt: "2030-03-01T19:00:00.000Z", + }), }); - assertProblem(invalidTime, 422, "validation.failed"); + assert.equal(invalidTime?._tag, "Defect", "the RPC server refuses an end before the start"); assert.deepEqual(await counts(), beforeValidation); const beforeAuthority = await counts(); @@ -763,10 +796,10 @@ const exerciseJourney = async ({ browser, ledger }) => { assert.deepEqual(await counts(), beforeAuthority); - const adminScope = await api(administrator.cookie, "GET", "/api/social-events/scope"); + const adminScope = await readScope(administrator.cookie); assert.equal(adminScope.status, 200); assert.deepEqual( - adminScope.body.departments.map(({ departmentId }) => departmentId), + adminScope.value.departments.map(({ departmentId }) => departmentId), [ids.departmentB, ids.departmentA], ); assert.equal((await listEvents(administrator.cookie, ids.departmentA)).status, 200); @@ -777,7 +810,7 @@ const exerciseJourney = async ({ browser, ledger }) => { title: "Administrator Bergen", }); - assert.equal(adminCreated.status, 201); + assert.equal(adminCreated.status, 200); const beforeUnknown = await counts(); const unknownDepartment = "department-social-events-unknown-0110"; @@ -827,9 +860,9 @@ const exerciseJourney = async ({ browser, ledger }) => { await snapshotLocker.end(); const [heldList, laterEvent] = await Promise.all([heldListPromise, laterEventPromise]); assert.equal(heldList.status, 200); - assert.equal(laterEvent.status, 201); + assert.equal(laterEvent.status, 200); assert.equal( - heldList.body.events.some(({ eventId }) => eventId === laterEvent.body.eventId), + heldList.value.events.some(({ eventId }) => eventId === laterEvent.value.eventId), false, ); assertProblem(await listEvents(member.cookie), 403, "authority.denied"); @@ -848,19 +881,13 @@ const exerciseJourney = async ({ browser, ledger }) => { const revocableKey = "social-events-revocable-replay-0110"; const revocableBody = createBody({ title: "Opprettet før tilbakekall" }); - const revocable = await api(member.cookie, "POST", "/api/social-events", { - key: revocableKey, - body: revocableBody, - }); + const revocable = await createWith(member.cookie, revocableKey, revocableBody); - assert.equal(revocable.status, 201); + assert.equal(revocable.status, 200); await setMemberActive(false); const beforeDeniedReplay = await counts(); assertProblem( - await api(member.cookie, "POST", "/api/social-events", { - key: revocableKey, - body: revocableBody, - }), + await createWith(member.cookie, revocableKey, revocableBody), 403, "authority.denied", ); @@ -948,14 +975,19 @@ const exerciseJourney = async ({ browser, ledger }) => { return { scope: { memberDepartments: [ids.departmentA], semesters: [ids.semesterA, ids.semesterB] }, - exactCreateHeaders: firstUiPost.responseHeaders, + dashboardCreate: { + rpcTag: firstUiPost.rpcTag, + status: firstUiPost.status, + sessionCookie: firstUiPost.sessionCookie, + authorizationHeader: firstUiPost.authorizationHeader, + }, postThenGet: true, - replay: { status: replay.status, eventId: replay.body.eventId }, + replay: { status: replay.status, eventId: replay.value.eventId }, race: { accepted: acceptedRace.status, inFlight: quickRace.status }, negativeCases: { digestConflict: changedReplay.status, responseExpired: tombReplay.status, - invalidTime: invalidTime.status, + invalidTime: invalidTime._tag, crossDepartment: 403, inactive: 403, unaffiliated: 403, @@ -1059,11 +1091,6 @@ try { NODE_ENV: "production", }; - run("bun", ["run", "build"], { - cwd: sdkRoot, - env: dashboardEnvironment, - label: "0110 generated SDK build", - }); run("bun", ["run", "build"], { cwd: dashboardRoot, env: dashboardEnvironment, @@ -1093,6 +1120,7 @@ try { } finally { try { if (browser !== undefined) await browser.close(); + await native.dispose(); await stop(dashboard); await closeServer(proxy); await stop(backend); @@ -1121,15 +1149,14 @@ const manifest = { command: "bun --cwd apps/dashboard e2e/run-real-native-social-events.mjs", topology: { database: postgres.version, - backend: "native Effect HTTP API", - sdk: "generated @vektorprogrammet/sdk", + backend: "native Effect RPC", dashboard: "production React Router server", browser: "real headless Chromium", }, gates: { seededAuthoritiesAndScopes: true, dashboardCreateReload: true, - exactHttpContract: true, + exactRpcContract: true, orderingAndHalfOpenLabels: true, idempotencyAndConcurrency: true, authorityAndUnknownScopeCounterexamples: true, @@ -1140,7 +1167,6 @@ const manifest = { observedRuntime: journey, commands: [ "bun run identity:seed", - "bun --cwd packages/sdk run build", "bun --cwd apps/dashboard run build", "Chromium social-event journey", ], diff --git a/apps/dashboard/e2e/run-real-native-substitutes.mjs b/apps/dashboard/e2e/run-real-native-substitutes.mjs index fd43bfb6a..6da5fd548 100644 --- a/apps/dashboard/e2e/run-real-native-substitutes.mjs +++ b/apps/dashboard/e2e/run-real-native-substitutes.mjs @@ -135,7 +135,6 @@ for (const signal of ["SIGTERM", "SIGINT"]) }); try { - await run("bun", ["run", "build"], join(root, "packages/sdk")); await run("bun", ["run", "build"], dashboardRoot); dashboard = spawn( process.env.PLAYWRIGHT_NODE_EXECUTABLE ?? "node", diff --git a/apps/dashboard/e2e/run-real-receipt-approval.mjs b/apps/dashboard/e2e/run-real-receipt-approval.mjs index 36847a0f6..215d3a288 100644 --- a/apps/dashboard/e2e/run-real-receipt-approval.mjs +++ b/apps/dashboard/e2e/run-real-receipt-approval.mjs @@ -12,6 +12,7 @@ import { isDeepStrictEqual } from "node:util"; import { startReceiptDeliverySink } from "../../../tools/e2e/receipt-delivery-sink.ts"; import { sanitizePlaywrightArtifact } from "./runtime-evidence-receipt.mjs"; import { addressesAnyRoute, addressesRoute, legacyRoutes } from "./request-routes.ts"; +import { isNativeOperation, nativeRpcStatus, nativeRpcValue } from "./native-operations.ts"; const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); @@ -340,6 +341,54 @@ const setCookieKey = (setCookie) => { : undefined; }; +const encodedId = (payload) => + Predicate.isString(payload.receiptId) ? encodeURIComponent(payload.receiptId) : ""; + +/** + * The HTTP route that each native RPC of this journey replaced, from its tag and payload. The + * ledger classifies an RPC by that route, so its facts stay comparable with the commands' + * receipts, whose normalized targets are those routes. + */ +const replacedRoute = (tag, payload) => + Match.value(tag).pipe( + Match.when("system.readSession", () => ["GET", "/api/session"]), + Match.when("profile.readOwnProfile", () => ["GET", "/api/profile"]), + Match.when("receipts.submitReceipt", () => ["POST", "/api/receipts"]), + Match.when("receipts.listReceipts", () => ["GET", "/api/receipts"]), + Match.when("receipts.readReceiptFile", () => [ + "GET", + `/api/receipts/${encodedId(payload)}/file`, + ]), + Match.when("receipts.listReceiptsForApproval", () => ["GET", "/api/receipt-approval-queue"]), + Match.when("receipts.readReceiptFileForApproval", () => [ + "GET", + `/api/receipt-approval-queue/${encodedId(payload)}/file`, + ]), + Match.when("receipts.approveReceipt", () => ["POST", `/api/receipts/${encodedId(payload)}:approve`]), + Match.when("receipts.rejectReceipt", () => ["POST", `/api/receipts/${encodedId(payload)}:reject`]), + Match.when("receipts.reopenReceipt", () => ["POST", `/api/receipts/${encodedId(payload)}:reopen`]), + Match.when("receipts.withdrawReceipt", () => [ + "POST", + `/api/receipts/${encodedId(payload)}:withdraw`, + ]), + Match.orElse(() => ["POST", `/api/rpc#${tag}`]), + ); + +/** The one RPC request message that a request body to the RPC endpoint carries. */ +const parseRpcRequest = (json) => + json !== null && + json !== undefined && + Predicate.isObjectOrArray(json) && + !Array.isArray(json) && + Predicate.isTagged(json, "Request") && + Predicate.isString(json.tag) + ? { + tag: json.tag, + payload: Predicate.isObjectOrArray(json.payload) ? json.payload : {}, + headers: new Map(Array.isArray(json.headers) ? json.headers : []), + } + : undefined; + async function startRecordingProxy(targetOrigin) { const records = []; const sessionPersonsByCookie = new Map(); @@ -352,32 +401,53 @@ async function startRecordingProxy(targetOrigin) { for await (const chunk of request) chunks.push(Buffer.from(chunk)); const requestBytes = Buffer.concat(chunks); - const cookieKey = sessionCookieKey(request.headers.cookie); + + // A native RPC is recorded as the route it replaced. A browser or driver sends its cookie as + // an HTTP header; the dashboard server forwards it in the RPC message. The RPC client posts to + // the endpoint with one trailing slash, which `isNativeOperation` accepts as the backend does. + const rpc = + method === "POST" && isNativeOperation(method, url.pathname) + ? parseRpcRequest(parseJsonBody(requestBytes)) + : undefined; + + const route = rpc === undefined ? [method, url.pathname] : replacedRoute(rpc.tag, rpc.payload); + const cookieKey = sessionCookieKey(request.headers.cookie ?? rpc?.headers.get("cookie")); + const isRead = route[0] === "GET"; const record = { - method, - pathname: url.pathname, + method: route[0], + pathname: route[1], query: url.search, status: 0, - body: sanitizeRequestBody( - requestBytes, - Predicate.isString(request.headers["content-type"]) ? request.headers["content-type"] : "", - ), + rpcTag: rpc?.tag ?? null, + body: + rpc === undefined + ? sanitizeRequestBody( + requestBytes, + Predicate.isString(request.headers["content-type"]) + ? request.headers["content-type"] + : "", + ) + : isRead + ? null + : sanitizeRequestBody( + Buffer.from(JSON.stringify(rpc.payload.request ?? {})), + "application/json", + ), sessionCookieAuth: cookieKey !== undefined, - authorizationHeaderPresent: request.headers.authorization !== undefined, + authorizationHeaderPresent: + request.headers.authorization !== undefined || rpc?.headers.has("authorization") === true, sessionPersonId: cookieKey === undefined ? null : (sessionPersonsByCookie.get(cookieKey) ?? null), canonicalAuthorityFixture: null, - idempotencyKey: - Predicate.isString(request.headers["idempotency-key"]) - ? request.headers["idempotency-key"] - : null, - ifMatch: Predicate.isString(request.headers["if-match"]) ? request.headers["if-match"] : null, + idempotencyKey: Predicate.isString(rpc?.payload.idempotencyKey) + ? rpc.payload.idempotencyKey + : null, + ifMatch: Predicate.isString(rpc?.payload.ifMatch) ? rpc.payload.ifMatch : null, concurrencyProbe: Predicate.isString(request.headers["x-receipt-e2e-concurrency-probe"]) ? request.headers["x-receipt-e2e-concurrency-probe"] : null, - concurrencySynchronized: null, }; records.push(record); @@ -408,16 +478,19 @@ async function startRecordingProxy(targetOrigin) { }); const responseBytes = Buffer.from(await upstream.arrayBuffer()); - const responseJson = parseJsonBody(responseBytes); - record.status = upstream.status; - record.concurrencySynchronized = upstream.headers.get( - "x-receipt-e2e-concurrency-synchronized", - ); + const answerText = responseBytes.toString("utf8"); + + // An RPC answers HTTP 200; its status under the HTTP contract is its problem's registry status. + const answerStatus = rpc === undefined ? undefined : nativeRpcStatus(answerText); + record.status = answerStatus ?? upstream.status; + + const responseJson = + rpc === undefined ? parseJsonBody(responseBytes) : (nativeRpcValue(answerText) ?? null); if ( cookieKey !== undefined && - upstream.status === 200 && - url.pathname === "/api/session" && + record.status === 200 && + record.pathname === "/api/session" && responseJson !== null && Predicate.isObjectOrArray(responseJson) && "personId" in responseJson && @@ -1142,9 +1215,6 @@ function assertJourneyEvidence(journeyEvidence, seedEvidence) { }, command: { inactiveActor: 403, - malformedJson: 400, - excessJson: 422, - queryParameters: 400, foreignDepartment: 403, absentDepartmentScope: 404, absentGlobalScope: 404, @@ -1288,17 +1358,18 @@ function assertRequestLedger(records, journeyEvidence) { ({ method, pathname }) => method === "POST" && pathname === "/api/receipts", ); + // A submission answers 200 with its resource, and carries its file as bytes in the payload. if ( submissions.length !== 4 || submissions.some( ({ status, body, idempotencyKey, ifMatch }) => - status !== 201 || - body?.kind !== "multipart/form-data" || + status !== 200 || + body?.kind !== "json" || !Predicate.isString(idempotencyKey) || ifMatch !== null, ) ) { - throw new Error("Receipt submission sequence is not the exact four native multipart writes"); + throw new Error("Receipt submission sequence is not the exact four native writes"); } const ownerReads = receiptOperations.filter( @@ -1348,10 +1419,9 @@ function assertRequestLedger(records, journeyEvidence) { .filter(({ concurrencyProbe }) => concurrencyProbe !== null) .sort(({ concurrencyProbe: left }, { concurrencyProbe: right }) => left.localeCompare(right)); - if ( - concurrencyRecords.length !== 3 || - concurrencyRecords.some(({ concurrencySynchronized }) => concurrencySynchronized !== "1") - ) { + // The barrier times out a lane that the others never meet, as receipts.unavailable; three + // answers of 200, 200, and 412 show that all three lanes met inside their transactions. + if (concurrencyRecords.length !== 3) { throw new Error("Receipt transaction concurrency barrier was not observed on all three lanes"); } @@ -1375,10 +1445,7 @@ function assertRequestLedger(records, journeyEvidence) { const commandStatuses = commands.map(({ status }) => status).sort((left, right) => left - right); assertEqual( commandStatuses, - [ - 200, 200, 200, 200, 200, 200, 200, 400, 400, 403, 403, 403, 404, 404, 409, 409, 409, 412, 412, - 412, 422, - ], + [200, 200, 200, 200, 200, 200, 200, 403, 403, 403, 404, 404, 409, 409, 409, 412, 412, 412], "Exact scoped approval/reject operation sequence", ); @@ -1387,12 +1454,10 @@ function assertRequestLedger(records, journeyEvidence) { throw new Error("Semantic Receipt command omitted Idempotency-Key or If-Match"); } - if (command.body?.kind === "malformed-json") continue; - if ( command.body?.kind !== "json" || !Array.isArray(command.body.keys) || - (command.status !== 422 && command.body.keys.length !== 0) + command.body.keys.length !== 0 ) { throw new Error("Semantic Receipt command body was not the canonical empty JSON object"); } diff --git a/apps/dashboard/e2e/run-real-receipt-owner.mjs b/apps/dashboard/e2e/run-real-receipt-owner.mjs index a7c302e65..bf38e4ab7 100644 --- a/apps/dashboard/e2e/run-real-receipt-owner.mjs +++ b/apps/dashboard/e2e/run-real-receipt-owner.mjs @@ -1,5 +1,5 @@ import { postgresProgram, reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; -import { ReadReceiptEvidenceEndpoint } from "@vektorprogrammet/http-api"; +import { internalNativeRpcPath } from "@vektorprogrammet/rpc"; import { Predicate } from "effect"; import { randomBytes } from "node:crypto"; import { spawn } from "node:child_process"; @@ -18,7 +18,6 @@ const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); const dashboardRoot = fileURLToPath(new URL("../", import.meta.url)); -const sdkRoot = fileURLToPath(new URL("../../../packages/sdk/", import.meta.url)); const databaseRoot = fileURLToPath(new URL("../../../packages/database/", import.meta.url)); @@ -692,16 +691,12 @@ async function main() { env: internalApiEnvironment, }); await waitForHttp(`${backendOrigin}/health`, apiProcess, "Unified native backend"); - // Internal ingress mounts only the internal API; its evidence route answers once it is up. + // Internal ingress mounts only the internal RPC endpoint; it answers once it is up. await waitForHttp( - `${internalBackendOrigin}${ReadReceiptEvidenceEndpoint.path.replace(":receiptId", "readiness")}`, + `${internalBackendOrigin}${internalNativeRpcPath}`, internalApiProcess, "Internal native backend", ); - await runCommand("bun", ["run", "build"], { - cwd: sdkRoot, - env: dashboardEnvironment, - }); await runCommand("bun", ["run", "build"], { cwd: dashboardRoot, env: dashboardEnvironment, @@ -714,13 +709,10 @@ async function main() { }); await waitForHttp(dashboardLoginUrl, dashboardProcess, "Dashboard"); + // Node comes from the devenv toolchain, whose major the root manifest's engines field pins. await runCommand( - "nix", + "node", [ - "shell", - "nixpkgs#nodejs_24", - "--command", - "node", "./node_modules/@playwright/test/cli.js", "test", "e2e/receipts.spec.ts", diff --git a/apps/dashboard/e2e/settled-axe.ts b/apps/dashboard/e2e/settled-axe.ts new file mode 100644 index 000000000..23f314554 --- /dev/null +++ b/apps/dashboard/e2e/settled-axe.ts @@ -0,0 +1,108 @@ +/** + * The accessibility audit of the browser journeys: axe on a page whose animations have settled. + * + * Axe reads computed colours. A CSS transition that still runs reads as its midpoint: the shared + * button carries `transition-all` and `disabled:opacity-50`, so right after a submission it is + * enabled and half transparent for 150 ms, which axe reports as `color-contrast`. The dashboard, + * the homepage, and the tools that drive them audit through `auditSettledPage`, and the Oxlint rule + * `anti-slop/no-unsettled-axe` rejects `new AxeBuilder(...)` anywhere else. + * + * The module lives beside the dashboard journeys because the dashboard owns `@axe-core/playwright` + * and Playwright: a Node or Bun runner in another workspace imports it by path, and the axe package + * resolves from here. It uses only erasable TypeScript, so Node runs it without a build. + */ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; + +/** What axe audits: its rule tags, and the selectors of the regions it includes or excludes. */ +export interface SettledAxeOptions { + /** Run only the rules with one of these tags, such as `wcag2aa`; all rules when absent. */ + readonly tags?: ReadonlyArray; + /** Audit only these regions; the whole page when absent. */ + readonly include?: ReadonlyArray; + /** Leave these regions out of the audit. */ + readonly exclude?: ReadonlyArray; +} + +/** One element that violates a rule. */ +export interface SettledAxeTarget { + /** The element's selector; a selector into a frame or shadow root joins its steps with a space. */ + readonly target: string; + /** Axe's account of what the element fails. */ + readonly failureSummary: string; +} + +/** One violated axe rule and the elements that violate it. */ +export interface SettledAxeViolation { + /** The rule, such as `color-contrast`. */ + readonly id: string; + /** `minor`, `moderate`, `serious`, or `critical`; `null` when axe names none. */ + readonly impact: string | null; + /** The elements that violate the rule. */ + readonly targets: ReadonlyArray; +} + +/** + * Resolves in the page once every animation with a finite end has finished or been cancelled. It is + * source text, so the tools that import this module type-check it without the DOM library. + */ +const settleAnimations = `Promise.all( + document + .getAnimations() + .filter((animation) => Number.isFinite(animation.effect?.getComputedTiming().endTime)) + .map((animation) => animation.finished.catch(() => animation)), +)`; + +/** + * Audits a page with axe once every finite animation on it has finished. + * + * @remarks + * It waits in the page for every running animation and transition whose end is finite + * (`document.getAnimations()`, each one's `finished`), then runs axe with the caller's tags and + * regions, and returns each violation with its rule, impact, and the targets that fail with axe's + * failure summary. It returns no markup, so no value that a field holds reaches a log or the + * evidence. An animation that repeats forever, such as a spinner, never finishes, so the wait skips + * it; an animation that is cancelled while it waits counts as settled. The caller keeps its own + * assertion: an exact empty list, or no violation of serious or critical impact. + * + * @sideEffects Evaluates a script in the page, and injects and runs axe in each of its frames. + * + * @example + * ```ts + * const violations = await auditSettledPage(page, { tags: ["wcag2a", "wcag2aa"] }); + * expect(violations).toEqual([]); + * ``` + * + * @avoid Calling `new AxeBuilder({ page }).analyze()` right after an interaction: a transition + * that still runs reads as its midpoint colours, and the audit fails at random (the onboarding + * and golden reimbursement runs of 2026-09-28). `anti-slop/no-unsettled-axe` rejects it. + * + * @construct browser-audit + */ +export const auditSettledPage = async ( + /** The page to audit, where the journey's last interaction has already been awaited. */ + page: Page, + /** The rule tags and regions of the audit; all rules on the whole page when absent. */ + options: SettledAxeOptions = {}, +): Promise> => { + await page.evaluate(settleAnimations); + + const builder = new AxeBuilder({ page }); + + if (options.tags !== undefined) builder.withTags([...options.tags]); + + for (const selector of options.include ?? []) builder.include(selector); + + for (const selector of options.exclude ?? []) builder.exclude(selector); + + const { violations } = await builder.analyze(); + + return violations.map(({ id, impact, nodes }) => ({ + id, + impact: impact ?? null, + targets: nodes.map((node) => ({ + target: node.target.map(String).join(" "), + failureSummary: node.failureSummary ?? "", + })), + })); +}; diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 1f01c5aa8..f9594507f 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -34,14 +34,14 @@ "e2e:real-settlement": "bun --conditions=@vektorprogrammet/source e2e/run-real-native-receipt-settlement.mjs", "e2e:real-owner": "bun e2e/run-real-receipt-owner.mjs", "e2e:real-admission-periods": "bun e2e/run-real-admission-period-management.mjs", - "e2e:real-recruitment": "bun ../../tools/e2e/run-real-native-recruitment-assignment.mjs", + "e2e:real-recruitment": "bun ../../tools/e2e/run-real-native-recruitment-assignment.ts", "e2e:real-scheduling": "bun e2e/run-real-native-recruitment-interview-scheduling.mjs", "e2e:real-conduct": "bun e2e/run-real-native-recruitment-interview-conduct.mjs", "e2e:real-organization": "bun e2e/run-real-native-organization-administration.mjs", "e2e:real-substitutes": "bun --no-env-file ../../tools/acceptance/substitute-outcome-check.ts --browser", "e2e:real-schools": "bun e2e/run-real-native-schools-directory.mjs", "e2e:real-interview-response": "bun e2e/run-real-interview-response.mjs", - "e2e:real-profile": "node e2e/run-real-native-profile-self-edit.mjs", + "e2e:real-profile": "bun --no-env-file e2e/run-real-native-profile-self-edit.mjs", "e2e:real-identity": "bun --no-env-file e2e/run-real-native-identity-browser.mjs", "e2e:real-oauth": "node ./node_modules/@playwright/test/cli.js test e2e/native-oauth-consent.spec.ts --project=chromium --workers=1 --retries=0", "e2e:sign-in-pages": "API_URL=http://127.0.0.1:9 node ./node_modules/@playwright/test/cli.js test e2e/auth.spec.ts e2e/password-reset.spec.ts --project=chromium --workers=1 --retries=0 --reporter=line", @@ -69,8 +69,7 @@ "@react-router/serve": "^8.3.0", "@tailwindcss/vite": "^4.0.17", "@tanstack/react-table": "^8.21.2", - "@vektorprogrammet/http-api": "workspace:*", - "@vektorprogrammet/sdk": "workspace:*", + "@vektorprogrammet/rpc": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -78,8 +77,8 @@ "foldkit": "catalog:effect-v4", "isbot": "^5.1.25", "lucide-react": "^0.486.0", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "catalog:react", + "react-dom": "catalog:react", "react-router": "^8.3.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.17", @@ -95,14 +94,14 @@ "@playwright/test": "^1.51.1", "@react-router/dev": "^8.3.0", "@types/node": "^22.13.14", - "@types/react": "^19.2.10", - "@types/react-dom": "^19.2.3", + "@types/react": "catalog:react", + "@types/react-dom": "catalog:react", "@vektorprogrammet/backend": "workspace:*", "@vektorprogrammet/domain": "workspace:*", "happy-dom": "^20.12.0", "pg": "8", "typescript": "7.0.2", - "vite": "^8.0.7", + "vite": "catalog:react", "vitest": "catalog:effect-v4" }, "engines": { diff --git a/apps/dashboard/test/native-http.ts b/apps/dashboard/test/native-http.ts index 94b47ce47..e74e31aa3 100644 --- a/apps/dashboard/test/native-http.ts +++ b/apps/dashboard/test/native-http.ts @@ -1,4 +1,4 @@ -import { makeNativeProblem, type NativeProblemCode } from "@vektorprogrammet/http-api"; +import { makeNativeProblem, type NativeProblemCode } from "@vektorprogrammet/rpc"; import { RouterContextProvider } from "react-router"; export const sessionCookie = "better-auth.session_token=session-value"; diff --git a/apps/dashboard/test/native-rpc.ts b/apps/dashboard/test/native-rpc.ts new file mode 100644 index 000000000..5253ef45a --- /dev/null +++ b/apps/dashboard/test/native-rpc.ts @@ -0,0 +1,116 @@ +/** + * A fake native backend for dashboard server tests, at the wire: it reads the RPC request message + * that `callNative` posts, and answers with the RPC response message that the backend's RPC server + * writes. A test stubs `fetch` with a function that reads each call and answers it. + */ +import { + makeNativeValidationError, + type NativeProblemCode, + Problem, + problemBody, + type ValidationProblemCode, + SessionResponse, +} from "@vektorprogrammet/rpc"; +import { PersonId } from "@vektorprogrammet/domain/organization"; +import { Exit, Schema } from "effect"; + +/** The RPC client numbers its requests; the answer echoes the number. */ +const RequestId = Schema.Union([Schema.String, Schema.Finite]); + +const RpcRequestBody = Schema.fromJsonString( + Schema.TaggedStruct("Request", { + id: RequestId, + tag: Schema.String, + payload: Schema.Json, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), + }), +); + +const RpcExitMessage = Schema.TaggedStruct("Exit", { + requestId: RequestId, + exit: Schema.Json, +}); + +/** An exit whose success, failure, and defect are already JSON, as the RPC server encodes them. */ +const encodeJsonExit = Schema.encodeSync( + Schema.toCodecJson(Schema.Exit(Schema.Json, Schema.Json, Schema.Json)), +); + +/** One RPC call that the dashboard server sent. */ +export interface NativeRpcCall { + readonly url: string; + readonly id: typeof RequestId.Type; + readonly tag: string; + readonly payload: Schema.Json; + /** The HTTP headers, with the headers of the RPC message, such as the forwarded cookie, over them. */ + readonly headers: Headers; +} + +/** Reads the RPC call that `input` posts to the RPC endpoint. */ +export async function readNativeRpcCall( + input: Parameters[0], + init?: Parameters[1], +): Promise { + const request = new Request(input, init); + const body = Schema.decodeSync(RpcRequestBody)(await request.text()); + const headers = new Headers(request.headers); + + for (const [name, value] of body.headers) headers.set(name, value); + + return { url: request.url, id: body.id, tag: body.tag, payload: body.payload, headers }; +} + +/** Whether a request addresses the RPC endpoint, rather than Better Auth or another route. */ +export const isNativeRpcRequest = (input: Parameters[0]): boolean => + /^\/api\/rpc\/?$/u.test( + new URL(input instanceof Request ? input.url : input instanceof URL ? input.href : input) + .pathname, + ); + +/** Answers `call` with a success whose value the test encoded with the RPC's success schema. */ +export const nativeRpcSuccess = (call: NativeRpcCall, encoded: Schema.Json): Response => + Response.json([ + RpcExitMessage.make({ requestId: call.id, exit: encodeJsonExit(Exit.succeed(encoded)) }), + ]); + +const isValidationCode = (code: NativeProblemCode): code is ValidationProblemCode => + code === "validation.failed" || + code === "validation.no-change" || + code === "validation.field-not-deletable"; + +/** + * Answers `call` with the declared problem `code`; a validation problem carries one diagnostic at + * the root pointer, as `requestInvalid` answers. + */ +export const nativeRpcProblem = (call: NativeRpcCall, code: NativeProblemCode): Response => { + const problem = isValidationCode(code) + ? Problem.validation(code, [makeNativeValidationError("", "invalid")]) + : Problem.fromWire({ code }, {}); + + return Response.json([ + RpcExitMessage.make({ + requestId: call.id, + exit: encodeJsonExit(Exit.fail({ ...problemBody(problem) })), + }), + ]); +}; + +/** The caller's current session, encoded as `system.readSession` answers it. */ +export const nativeSession = Schema.encodeSync(Schema.toCodecJson(SessionResponse))( + SessionResponse.make({ + sessionId: "session-1", + personId: PersonId.make("person-1"), + createdAt: "2030-01-01T00:00:00Z", + updatedAt: "2030-01-01T00:00:00Z", + expiresAt: "2030-01-02T00:00:00Z", + ipAddress: null, + userAgent: null, + current: true, + }), +); + +/** Answers the session check that `requireAuth` sends with the caller's current session. */ +export const nativeSessionAnswer = async ( + input: Parameters[0], + init?: Parameters[1], +): Promise => nativeRpcSuccess(await readNativeRpcCall(input, init), nativeSession); diff --git a/apps/dashboard/test/request-routes.test.ts b/apps/dashboard/test/request-routes.test.ts index a0c61d584..91bd0a9f6 100644 --- a/apps/dashboard/test/request-routes.test.ts +++ b/apps/dashboard/test/request-routes.test.ts @@ -1,21 +1,97 @@ +import { Schema } from "effect"; import { describe, expect, it } from "vitest"; -import { isNativeOperation, isNativeRequest } from "../e2e/native-operations.ts"; +import { PersonId } from "@vektorprogrammet/domain/organization"; +import { Problem, ReadSession, SessionResponse } from "@vektorprogrammet/rpc"; +import { Exit, Predicate } from "effect"; +import { Rpc } from "effect/unstable/rpc"; +import { + isNativeOperation, + isNativeRequest, + nativeRpcOutcome, + nativeRpcStatus, + nativeRpcTag, +} from "../e2e/native-operations.ts"; import { addressesRoute } from "../e2e/request-routes.ts"; -/** Better Auth session identifier from hosted run 36224459581; its "jwT" is random. */ -const hostedRunSessionId = "wm97nHEWTDO8UArCXrVpni919bgIjwTB"; +/** The RPC request message that the client posts for one RPC. */ +const RpcRequest = Schema.TaggedStruct("Request", { + id: Schema.Finite, + tag: Schema.String, + payload: Schema.Json, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +}); + +const Ack = Schema.TaggedStruct("Ack", { requestId: Schema.Finite }); + +const RpcExitMessage = Schema.TaggedStruct("Exit", { requestId: Schema.Finite, exit: Schema.Json }); + +const encodeReadSessionExit = Schema.encodeSync(Schema.toCodecJson(Rpc.exitSchema(ReadSession))); + +/** The response body that the RPC server writes for one `system.readSession` exit. */ +const readSessionAnswer = (exit: Rpc.Exit) => + Schema.encodeSync(Schema.fromJsonString(Schema.Array(RpcExitMessage)))([ + RpcExitMessage.make({ requestId: 0, exit: encodeReadSessionExit(exit) }), + ]); + +const session = SessionResponse.make({ + sessionId: "session-1", + personId: PersonId.make("person-1"), + createdAt: "2030-01-01T00:00:00Z", + updatedAt: "2030-01-01T00:00:00Z", + expiresAt: "2030-01-02T00:00:00Z", + ipAddress: null, + userAgent: null, + current: true, +}); + +const rpcBody = (tag: string, payload: Schema.Json = {}) => + Schema.encodeSync(Schema.fromJsonString(RpcRequest))( + RpcRequest.make({ id: 0, tag, payload, headers: [] }), + ); describe("journey request route classification", () => { - it("keeps a native request native whatever random value fills its path parameter", () => { - expect(isNativeRequest("DELETE", `/api/sessions/${hostedRunSessionId}`)).toBe(true); - expect(isNativeRequest("DELETE", "/api/sessions/reset-token-jwt-verification")).toBe(true); + it("keeps an RPC of the contract native, whatever its payload holds", () => { + const sessionId = "wm97nHEWTDO8UArCXrVpni919bgIjwTB"; + const body = rpcBody("system.deleteOwnedSession", { idempotencyKey: "k", sessionId }); + + expect(isNativeRequest("POST", "/api/rpc", body)).toBe(true); + expect(isNativeRequest("POST", "/api/rpc/", body)).toBe(true); + expect(isNativeRequest("POST", "/api/rpc")).toBe(true); + expect(isNativeRequest("GET", "/health")).toBe(true); + }); + + it("matches the method, the whole endpoint path, and a tag of the contract", () => { + expect(isNativeOperation("POST", "/api/rpc", rpcBody("profile.readOwnProfile"))).toBe(true); + expect(isNativeOperation("GET", "/api/rpc", rpcBody("profile.readOwnProfile"))).toBe(false); + expect(isNativeOperation("POST", "/api/rpcs", rpcBody("profile.readOwnProfile"))).toBe(false); + expect(isNativeOperation("POST", "/api/rpc/extra", rpcBody("profile.readOwnProfile"))).toBe( + false, + ); + expect(isNativeOperation("POST", "/api/rpc", rpcBody("legacy.login"))).toBe(false); + expect(isNativeOperation("POST", "/api/rpc", "not json")).toBe(false); + expect(isNativeOperation("POST", "/api/session")).toBe(false); + }); + + it("reads the RPC tag from the request body", () => { + expect(nativeRpcTag(rpcBody("system.readSession"))).toBe("system.readSession"); + const ack = Schema.encodeSync(Schema.fromJsonString(Ack))(Ack.make({ requestId: 0 })); + + expect(nativeRpcTag(ack)).toBeUndefined(); }); - it("matches the method, the literal text beside a parameter, and one whole segment", () => { - expect(isNativeOperation("POST", "/api/receipts/receipt_1:approve")).toBe(true); - expect(isNativeOperation("GET", `/api/sessions/${hostedRunSessionId}`)).toBe(false); - expect(isNativeOperation("POST", "/api/receipts/receipt_1:approved")).toBe(false); - expect(isNativeOperation("DELETE", `/api/sessions/${hostedRunSessionId}/extra`)).toBe(false); + it("reads the outcome and the status of an RPC response", () => { + const success = readSessionAnswer(Exit.succeed(session)); + const denied = readSessionAnswer(Exit.fail(Problem.fromWire({ code: "credential.invalid" }, {}))); + + const read = nativeRpcOutcome(success); + const rejected = nativeRpcOutcome(denied); + + expect(Predicate.isTagged(read, "Success") && read.value).toMatchObject({ sessionId: "session-1" }); + expect(nativeRpcStatus(success)).toBe(200); + expect(Predicate.isTagged(rejected, "Problem") && rejected.problem.code).toBe("credential.invalid"); + expect(nativeRpcStatus(denied)).toBe(401); + expect(nativeRpcStatus(readSessionAnswer(Exit.die("boom")))).toBe(500); + expect(nativeRpcStatus("[]")).toBeUndefined(); }); it("keeps the email sign-in and leaves the native surface for every other identity route", () => { diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts index 95370288b..095d28cf0 100644 --- a/apps/dashboard/vite.config.ts +++ b/apps/dashboard/vite.config.ts @@ -41,10 +41,6 @@ export default defineConfig(({ command }) => { "@/ui": "/app/components/ui", }; - const rehearsalSdk = process.env.ORGANIZATION_IMPORT_REHEARSAL_SDK_EFFECT_PATH; - - if (rehearsalSdk !== undefined) alias["@vektorprogrammet/sdk/effect"] = rehearsalSdk; - const server: ServerOptions = { host: "127.0.0.1", port: Number(process.env.LOCAL_DASHBOARD_PORT ?? 5173), @@ -62,7 +58,7 @@ export default defineConfig(({ command }) => { ), }, plugins: [reactRouter(), foldkit(), tailwindcss(), defaultProfileImage()], - // Workspace packages such as the SDK resolve to source; the plugins append Vite's default conditions. + // No workspace export map uses this condition since the SDK was removed; the plugins append Vite's default conditions. resolve: { alias, conditions: ["@vektorprogrammet/source"] }, ssr: { resolve: { conditions: ["@vektorprogrammet/source"] } }, server, diff --git a/apps/dashboard/vitest.config.ts b/apps/dashboard/vitest.config.ts index c6cceaa78..3bcb41b30 100644 --- a/apps/dashboard/vitest.config.ts +++ b/apps/dashboard/vitest.config.ts @@ -5,7 +5,7 @@ import { sharedVitestConfig } from "../../vitest.shared.js"; export default mergeConfig( sharedVitestConfig, defineConfig({ - // Workspace packages such as the SDK resolve to source; Vitest appends its default conditions. + // No workspace export map uses this condition since the SDK was removed; Vitest appends its default conditions. resolve: { alias: { "@/components": fileURLToPath(new URL("./app/components", import.meta.url)), diff --git a/apps/docs/package.json b/apps/docs/package.json index 572100593..a2022bf5e 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -5,13 +5,15 @@ "type": "module", "scripts": { "pages": "bun --no-env-file scripts/sync-pages.ts", - "dev": "bun run --cwd ../../packages/http-api generate && (bun --no-env-file scripts/sync-pages.ts --watch & vocs dev)", - "build": "bun run --cwd ../../packages/http-api generate && bun --no-env-file scripts/sync-pages.ts && vocs build", + "dev": "bun --no-env-file scripts/sync-pages.ts --watch & vocs dev", + "build": "bun --no-env-file scripts/sync-pages.ts && vocs build", "preview": "vocs preview", "check-types": "tsc --noEmit" }, "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", "cn": "0.4.0", + "effect": "catalog:effect-v4", "mermaid": "11.17.2", "motion": "12.35.0", "react": "19.3.0", diff --git a/apps/docs/remark-repository-links.ts b/apps/docs/remark-repository-links.ts index 0b3fa95a7..92ef1f23e 100644 --- a/apps/docs/remark-repository-links.ts +++ b/apps/docs/remark-repository-links.ts @@ -2,7 +2,9 @@ // A link to a published document becomes its site route; a link to any other // repository file or directory becomes its GitHub view. A link to a missing path // stays unchanged, so the Vocs dead-link check fails the build. +// oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0011: remark runs this plugin synchronously, and Effect FileSystem has no synchronous backend import { existsSync, statSync } from "node:fs"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0011: remark runs this plugin synchronously, and Effect Path is a service of the same platform layer import { posix, relative, resolve, sep } from "node:path"; import type { Root } from "mdast"; import { visit } from "unist-util-visit"; @@ -14,9 +16,12 @@ const external = /^(?:[a-z][a-z\d+.-]*:|#|\/)/i; export function remarkRepositoryLinks() { return (tree: Root, file: VFile) => { // The search index compiles sources without a path; its links need no rewriting. - if (!file.path) return; + // The getter is typed `string` but returns `undefined` for a file without a path. + const filePath: string | undefined = file.path; - const page = relative(pagesDirectory, file.path).split(sep).join("/"); + if (filePath === undefined || filePath === "") return; + + const page = relative(pagesDirectory, filePath).split(sep).join("/"); if (page.startsWith("..")) return; diff --git a/apps/docs/scripts/sync-pages.ts b/apps/docs/scripts/sync-pages.ts index 16ea83f32..f6d226605 100644 --- a/apps/docs/scripts/sync-pages.ts +++ b/apps/docs/scripts/sync-pages.ts @@ -4,9 +4,10 @@ // - a TypeDoc `{@includeCode path}` line becomes a code block with that file; // - an `.mdx` source gets imports for the components it uses from `mdxComponents`. // `--watch` re-mirrors a source whenever it changes, for `vocs dev`. -import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { watch } from "node:fs"; -import { dirname, extname, posix, relative, resolve } from "node:path"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import { Array, Console, Data, Effect, FileSystem, Path, Schema, Stream } from "effect"; +import process from "node:process"; import { completeDirectories, mdxComponents, @@ -16,31 +17,48 @@ import { sources, } from "../site.ts"; -for (const directory of completeDirectories) { - const unpublished = (await readdir(resolve(repositoryRoot, directory))) - .filter((name) => /\.mdx?$/.test(name)) - .map((name) => `${directory}/${name}`) - .filter((source) => !sources.includes(source)); +/** Markdown files in a complete directory that no section of `site.ts` publishes. */ +class UnpublishedSources extends Data.TaggedError("UnpublishedSources")<{ + readonly message: string; +}> {} - if (unpublished.length > 0) - throw new Error(`Add ${unpublished.join(", ")} to a section in apps/docs/site.ts.`); -} +const encodeSpecifier = Schema.encodeSync(Schema.fromJsonString(Schema.String)); -async function mirror(source: string) { - const target = resolve(pagesDirectory, pageFile(source)); - let content = await readFile(resolve(repositoryRoot, source), "utf8"); +const checkCompleteDirectories = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + for (const directory of completeDirectories) { + const unpublished = (yield* fs.readDirectory(path.resolve(repositoryRoot, directory))) + .filter((name) => /\.mdx?$/.test(name)) + .map((name) => `${directory}/${name}`) + .filter((source) => !sources.includes(source)); + + if (unpublished.length > 0) + return yield* new UnpublishedSources({ + message: `Add ${unpublished.join(", ")} to a section in apps/docs/site.ts.`, + }); + } +}); + +const mirror = Effect.fnUntraced(function* (source: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = path.resolve(pagesDirectory, pageFile(source)); + let content = yield* fs.readFileString(path.resolve(repositoryRoot, source), "utf8"); for (const [line, include = ""] of content.matchAll(/^\{@includeCode\s+(\S+)\}$/gm)) { - const code = ( - await readFile(resolve(repositoryRoot, dirname(source), include), "utf8") - ).trimEnd(); + const code = (yield* fs.readFileString( + path.resolve(repositoryRoot, path.dirname(source), include), + "utf8", + )).trimEnd(); // Fence the file with more backticks than any fence inside it. const fence = "`".repeat( Math.max(3, ...[...code.matchAll(/`{3,}/g)].map(([run]) => run.length + 1)), ); - content = content.replace(line, `${fence}${extname(include).slice(1)}\n${code}\n${fence}`); + content = content.replace(line, `${fence}${path.extname(include).slice(1)}\n${code}\n${fence}`); } if (source.endsWith(".mdx")) { @@ -48,33 +66,56 @@ async function mirror(source: string) { .filter(([name]) => content.includes(`<${name}`)) .map( ([name, file]) => - `import { ${name} } from ${JSON.stringify(relative(dirname(target), file))};\n`, + `import { ${name} } from ${encodeSpecifier(path.relative(path.dirname(target), file))};\n`, ); if (imports.length > 0) content = `${imports.join("")}\n${content}`; } - await mkdir(dirname(target), { recursive: true }); + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + + yield* fs.writeFileString(target, content); +}); + +// Watch directories, not files: editors often replace a file on save. +const watchSources = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directories = Array.dedupe(sources.map((source) => path.dirname(source))); + + yield* Stream.mergeAll( + directories.map((directory) => + fs.watch(path.resolve(repositoryRoot, directory)).pipe( + Stream.map((event) => path.join(directory, path.basename(event.path))), + Stream.filter((source) => sources.includes(source)), + ), + ), + { concurrency: "unbounded" }, + ).pipe( + Stream.runForEach((source) => + mirror(source).pipe(Effect.catchCause((cause) => Console.error(cause))), + ), + ); +}); + +const program = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; - await writeFile(target, content); -} + yield* checkCompleteDirectories; -// Committed Vocs files in the pages directory start with an underscore. -for (const entry of await readdir(pagesDirectory)) - if (!entry.startsWith("_")) await rm(resolve(pagesDirectory, entry), { recursive: true }); + // Committed Vocs files in the pages directory start with an underscore. + for (const entry of yield* fs.readDirectory(pagesDirectory)) + if (!entry.startsWith("_")) + yield* fs.remove(path.resolve(pagesDirectory, entry), { recursive: true }); -await Promise.all(sources.map(mirror)); + yield* Effect.forEach(sources, mirror, { concurrency: "unbounded", discard: true }); -console.log( - `Mirrored ${sources.length} repository documents into ${relative(repositoryRoot, pagesDirectory)}.`, -); + yield* Console.log( + `Mirrored ${sources.length} repository documents into ${path.relative(repositoryRoot, pagesDirectory)}.`, + ); -if (process.argv.includes("--watch")) { - // Watch directories, not files: editors often replace a file on save. - for (const directory of new Set(sources.map((source) => posix.dirname(source)))) - watch(resolve(repositoryRoot, directory), (_event, name) => { - const source = posix.join(directory, name ?? ""); + if (process.argv.includes("--watch")) yield* watchSources; +}); - if (sources.includes(source)) mirror(source).catch(console.error); - }); -} +BunRuntime.runMain(program.pipe(Effect.provide(BunServices.layer))); diff --git a/apps/docs/site.ts b/apps/docs/site.ts index 5f89c28d3..d699e27d2 100644 --- a/apps/docs/site.ts +++ b/apps/docs/site.ts @@ -1,6 +1,8 @@ // The documentation site renders repository Markdown in place. This manifest names the // published sources; `scripts/sync-pages.ts` mirrors them into the ignored `src/pages`. +// oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0011: Vocs evaluates this manifest synchronously, and Effect FileSystem has no synchronous backend import { readdirSync, readFileSync } from "node:fs"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0011: Vocs evaluates this manifest synchronously, and Effect Path is a service of the same platform layer import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -72,9 +74,10 @@ export function route(source: string) { /** The source's first-level heading. */ export function title(source: string) { - const heading = /^# (.+)$/m.exec(readFileSync(resolve(repositoryRoot, source), "utf8")); + const heading = /^# (.+)$/m.exec(readFileSync(resolve(repositoryRoot, source), "utf8"))?.[1]; - if (!heading?.[1]) throw new Error(`${source} has no first-level heading.`); + if (heading === undefined || heading === "") + throw new Error(`${source} has no first-level heading.`); - return heading[1].trim(); + return heading.trim(); } diff --git a/apps/docs/vocs.config.ts b/apps/docs/vocs.config.ts index 63b966575..7e352049f 100644 --- a/apps/docs/vocs.config.ts +++ b/apps/docs/vocs.config.ts @@ -1,7 +1,6 @@ -import { resolve } from "node:path"; -import { defineConfig, OpenApi } from "vocs/config"; +import { defineConfig } from "vocs/config"; import { remarkRepositoryLinks } from "./remark-repository-links.ts"; -import { repositoryRoot, repositoryUrl, route, sections, title } from "./site.ts"; +import { repositoryUrl, route, sections, title } from "./site.ts"; export default defineConfig({ title: "Vektorprogrammet", @@ -19,14 +18,7 @@ export default defineConfig({ text: "Edit this page on GitHub", }, socials: [{ icon: "github", link: repositoryUrl }], - topNav: [ - { text: "Documentation", link: "/" }, - { text: "HTTP API", link: "/api" }, - ], - // The build derives this ignored spec from the HTTP contract first. - openapi: [ - OpenApi.from({ spec: resolve(repositoryRoot, "packages/http-api/openapi.json"), path: "/api" }), - ], + topNav: [{ text: "Documentation", link: "/" }], sidebar: sections.map((section) => ({ text: section.text, items: [ diff --git a/apps/homepage/e2e/public-applicant-admission.spec.ts b/apps/homepage/e2e/public-applicant-admission.spec.ts index 846338749..1e800360b 100644 --- a/apps/homepage/e2e/public-applicant-admission.spec.ts +++ b/apps/homepage/e2e/public-applicant-admission.spec.ts @@ -1,22 +1,19 @@ -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "../../dashboard/e2e/settled-axe.js"; import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { - AdmissionPeriodManagementItem, - AdmissionsSubmitApplicationProblem, - makeNativeValidationError, - type NativeValidationError, - PublicApplicationConfirmationSchema, - ReadApplicationCatalogEndpoint, + AdmissionPeriodId, + nativeRpcPath, + ReadApplicationConfirmation, SubmitApplicationRequest, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; +import { IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { nativeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; import { Schema, Struct } from "effect"; -import { type HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"; import { expect, test, type APIRequestContext, - type APIResponse, type Locator, type Page, } from "@playwright/test"; @@ -98,27 +95,11 @@ const LANGUAGE_LABELS: Readonly = - Response extends HttpApiSchema.WithHeaders ? Body : never; - -/** The 200 body that the application options endpoint declares; its 304 has none. */ -const catalogSchema = (() => { - for (const response of ReadApplicationCatalogEndpoint.success) { - if (HttpApiSchema.isWithHeaders(response) && !HttpApiSchema.isNoContent(response.schema.ast)) { - // SAFETY: the endpoint's runtime set holds exactly the responses its type declares. - return response.schema as ResponseBody< - HttpApiEndpoint.Success - >; - } - } - - throw new Error("The application options endpoint declares no body"); -})(); - const decodeStrict = >(schema: S) => Schema.decodeUnknownSync(schema, { onExcessProperty: "error" }); +const SubmitApplicationConfirmationId = ReadApplicationConfirmation.payloadSchema.fields.applicationId; + function requiredEnvironment(name: string): string { const value = process.env[name]; @@ -161,62 +142,90 @@ function applicationInput(overrides: Partial = {}): Applicatio /** A submitted body; the excess-property law adds a member the contract does not declare. */ type SubmittedApplication = ApplicationInput & { readonly applicantId?: string }; -/** Submits one application under its own Idempotency-Key unless a replay names one. */ -function submit( - request: APIRequestContext, - data: SubmittedApplication, - idempotencyKey: string = randomUUID(), -): Promise { - return request.post(`${BACKEND_ORIGIN}/api/applications`, { - headers: { "idempotency-key": idempotencyKey }, - data, - }); +/** The backend's native RPC client, one public call at a time. */ +const native = nativeScriptClient(BACKEND_ORIGIN); + +/** + * Submits one application under its own idempotency key unless a replay names one. The input + * decodes through the contract first, so only a value that the contract admits is sent typed. + */ +function submit(data: ApplicationInput, idempotencyKey: string = randomUUID()) { + const request = decodeStrict(SubmitApplicationRequest)(data); + + return native.call({}, (client) => + client["admissions.submitApplication"]({ + idempotencyKey: IdempotencyKey.make(idempotencyKey), + request, + }), + ); } -/** Decodes one rejection through the submit endpoint's closed Problem Details union. */ -async function rejection(response: APIResponse, status: number) { - expect(response.status()).toBe(status); - expect(response.headers()["content-type"]).toBe("application/problem+json"); +/** A problem answer: the registry status and the code of the declared problem. */ +function expectProblem(result: ScriptCallResult, status: number, code: string) { + expect(result).toMatchObject({ ok: false, status, code }); - return decodeStrict(AdmissionsSubmitApplicationProblem)(await response.json()); + return { status, code }; } -async function expectProblem( - response: APIResponse, - status: number, - code: string, -): Promise<{ readonly status: number; readonly code: string }> { - const problem = await rejection(response, status); - expect(problem.code).toBe(code); +/** The value of a successful call. */ +function expectValue(result: ScriptCallResult): A { + if (!result.ok) throw new Error(`The call failed with ${result.code}`); - return { status, code: problem.code }; + return result.value; } -/** A validation failure names exactly the rejected members, by pointer and never by value. */ -async function expectValidation( - response: APIResponse, - errors: ReadonlyArray, -): Promise<{ readonly status: number; readonly code: string; readonly pointers: string[] }> { - const problem = await rejection(response, 422); +/** One RPC request on the JSON wire. */ +const WireRequest = Schema.TaggedStruct("Request", { + id: Schema.String, + tag: Schema.String, + payload: Schema.Unknown, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +}); + +/** The one answer of a request that the server rejected with a defect before any handler ran. */ +const RejectedBeforeHandler = Schema.Tuple([ + Schema.TaggedStruct("Exit", { + exit: Schema.TaggedStruct("Failure", { + cause: Schema.Tuple([Schema.TaggedStruct("Die", { defect: Schema.Unknown })]), + }), + }), +]); - expect(problem).toMatchObject({ - code: "validation.failed", - validation: { errors, truncated: false }, +/** + * Sends one submission whose payload the contract does not admit, as untyped RPC wire JSON. The + * RPC server rejects it while decoding, before the handler, so no command or receipt runs. + */ +async function submitUntyped( + request: APIRequestContext, + data: SubmittedApplication, +): Promise<{ readonly rejectedBeforeHandler: true }> { + const response = await request.post(`${BACKEND_ORIGIN}${nativeRpcPath}`, { + headers: { "content-type": "application/json" }, + data: WireRequest.make({ + id: "1", + tag: "admissions.submitApplication", + payload: { idempotencyKey: randomUUID(), request: data }, + headers: [], + }), }); - return { status: 422, code: problem.code, pointers: errors.map((error) => error.pointer) }; + expect(response.status()).toBe(200); + // Decoding fails unless the answer is exactly one defect exit. + Schema.decodeUnknownSync(RejectedBeforeHandler)(await response.json()); + + return { rejectedBeforeHandler: true }; } /** Asserts that the application section has no serious or critical axe violation. */ async function expectNoSeriousViolations(page: Page): Promise { - const result = await new AxeBuilder({ page }).include('section[id="sok"]').analyze(); + const result = await auditSettledPage(page, { include: ['section[id="sok"]'] }); // Rule IDs and selectors name each violation without the values that fields hold. - const serious = result.violations + const serious = result .filter((violation) => violation.impact === "serious" || violation.impact === "critical") .map((violation) => ({ id: violation.id, - targets: violation.nodes.map((node) => String(node.target)), + targets: violation.targets.map(({ target }) => target), })); expect(serious).toEqual([]); @@ -262,26 +271,26 @@ async function fillApplicationForm(page: Page, input: ApplicationInput): Promise } } -async function catalog(request: APIRequestContext) { - const response = await request.get(`${BACKEND_ORIGIN}/api/application-options`); - expect(response.status()).toBe(200); - - const decoded = decodeStrict(catalogSchema)(await response.json()); - - if (decoded === undefined) throw new Error("The application options response has no body"); - - return decoded; +async function catalog() { + return expectValue( + await native.call({}, (client) => client["admissions.listApplicationOptions"]()), + ); } -async function confirmation(request: APIRequestContext, applicationId: string) { - const response = await request.get(`${BACKEND_ORIGIN}/api/applications/${applicationId}`); - expect(response.status()).toBe(200); - - return decodeStrict(PublicApplicationConfirmationSchema)(await response.json()); +async function confirmation(applicationId: string) { + return expectValue( + await native.call({}, (client) => + client["admissions.readApplicationConfirmation"]({ + applicationId: decodeStrict(SubmitApplicationConfirmationId)(applicationId), + }), + ), + ); } test.use({ baseURL: HOMEPAGE_ORIGIN }); +test.afterAll(() => native.dispose()); + test.describe("Public applicant admission", () => { test.skip(!REAL_PUBLIC_APPLICATION_E2E, "run through the disposable PostgreSQL homepage runner"); @@ -342,7 +351,7 @@ test.describe("Public applicant admission", () => { submittedFormFields = [...body.keys()].sort(); }); - const initialCatalog = await catalog(request); + const initialCatalog = await catalog(); expect(initialCatalog.departments).toEqual([ { departmentId: DEPARTMENT_ID, @@ -398,18 +407,16 @@ test.describe("Public applicant admission", () => { expect(confirmationPage).not.toContain(canary); } - // The homepage submitted the form's command ID as the Idempotency-Key, so the same - // key and payload replay the committed confirmation. - const replayResponse = await submit(request, acceptedInput, submittedCommandId); - expect(replayResponse.status()).toBe(201); - const replay = decodeStrict(PublicApplicationConfirmationSchema)(await replayResponse.json()); + // The homepage submitted the form's command ID as the idempotency key, so the same key and + // payload replay the committed confirmation. + const replay = expectValue(await submit(acceptedInput, submittedCommandId)); expect(replay.applicationId).toBe(applicationId); - const initialConfirmation = await confirmation(request, applicationId); + const initialConfirmation = await confirmation(applicationId); expect(initialConfirmation.applicationId).toBe(applicationId); - const replayConflict = await expectProblem( - await submit(request, applicationInput({ phone: "+47 911 11 111" }), submittedCommandId), + const replayConflict = expectProblem( + await submit(applicationInput({ phone: "+47 911 11 111" }), submittedCommandId), 409, "idempotency.digest-conflict", ); @@ -458,147 +465,123 @@ test.describe("Public applicant admission", () => { ], ] as const; + // A structurally invalid member fails the RPC payload schema in the server, before the + // handler; the pointer of the member no longer reaches the client. const validation = []; for (const [data, pointer] of malformedInputs) { - validation.push( - await expectValidation(await submit(request, data), [ - makeNativeValidationError(pointer, "invalid"), - ]), - ); + validation.push({ pointer, ...(await submitUntyped(request, data)) }); } - const excess = await expectValidation( - await submit(request, { - ...applicationInput(), - applicantId: "browser-must-not-select-identity", - }), - [makeNativeValidationError("/applicantId", "unknown")], - ); + // The payload schema drops a member it does not declare, so the browser cannot select an + // applicant identity: the submission is the canary applicant's second, a duplicate. + const withApplicantId = { + ...decodeStrict(SubmitApplicationRequest)(applicationInput()), + applicantId: "browser-must-not-select-identity", + }; - const malformedJson = await expectProblem( - await request.fetch(`${BACKEND_ORIGIN}/api/applications`, { - method: "POST", - headers: { "content-type": "application/json", "idempotency-key": randomUUID() }, - // Playwright serializes a string that does not parse as JSON; bytes go out unchanged. - data: Buffer.from("{"), + const excessAnswer = await native.call({}, (client) => + client["admissions.submitApplication"]({ + idempotencyKey: IdempotencyKey.make(randomUUID()), + request: withApplicantId, }), - 400, - "request.malformed", ); - const wrongContentType = await expectProblem( - await request.fetch(`${BACKEND_ORIGIN}/api/applications`, { - method: "POST", - headers: { "content-type": "text/plain", "idempotency-key": randomUUID() }, - data: JSON.stringify(applicationInput()), - }), - 415, - "media-type.unsupported", - ); + const excess = expectProblem(excessAnswer, 409, "application.duplicate"); - const bodyLimit = await expectProblem( - await submit(request, applicationInput({ firstName: "x".repeat(131_072) })), - 413, - "request.too-large", + const bodyLimit = await submitUntyped( + request, + applicationInput({ firstName: "x".repeat(131_072) }), ); // The frozen unions have no department problem, so an unknown department is invalid input. - const unknownDepartment = await expectValidation( - await submit(request, applicationInput({ departmentId: "department-unknown" })), - [makeNativeValidationError("/departmentId", "invalid")], + const unknownDepartmentAnswer = await submit( + applicationInput({ departmentId: "department-unknown" }), ); - const unknownField = await expectProblem( - await submit(request, applicationInput({ fieldOfStudyId: "field-unknown" })), + expect(unknownDepartmentAnswer).toMatchObject({ + ok: false, + status: 422, + code: "validation.failed", + problem: { + validation: { errors: [{ pointer: "/departmentId", code: "invalid" }], truncated: false }, + }, + }); + + const unknownDepartment = { status: 422, code: "validation.failed", pointers: ["/departmentId"] }; + + const unknownField = expectProblem( + await submit(applicationInput({ fieldOfStudyId: "field-unknown" })), 422, "application.invalid-field-of-study", ); - const inactiveField = await expectProblem( - await submit(request, applicationInput({ fieldOfStudyId: INACTIVE_FIELD_OF_STUDY_ID })), + const inactiveField = expectProblem( + await submit(applicationInput({ fieldOfStudyId: INACTIVE_FIELD_OF_STUDY_ID })), 422, "application.invalid-field-of-study", ); - const crossDepartmentField = await expectProblem( - await submit(request, applicationInput({ fieldOfStudyId: FOREIGN_FIELD_OF_STUDY_ID })), + const crossDepartmentField = expectProblem( + await submit(applicationInput({ fieldOfStudyId: FOREIGN_FIELD_OF_STUDY_ID })), 422, "application.invalid-field-of-study", ); - const concurrentResponses = await Promise.all([ - submit(request, applicationInput({ email: "concurrent-applicant-0039@example.invalid" })), - submit(request, applicationInput({ email: "CONCURRENT-APPLICANT-0039@EXAMPLE.INVALID" })), + const concurrentAnswers = await Promise.all([ + submit(applicationInput({ email: "concurrent-applicant-0039@example.invalid" })), + submit(applicationInput({ email: "CONCURRENT-APPLICANT-0039@EXAMPLE.INVALID" })), ]); - const concurrentAccepted = concurrentResponses.filter((response) => response.status() === 201); - const concurrentRejected = concurrentResponses.filter((response) => response.status() !== 201); + const concurrentAccepted = concurrentAnswers.filter((answer) => answer.ok); + const concurrentRejected = concurrentAnswers.filter((answer) => !answer.ok); expect(concurrentAccepted).toHaveLength(1); expect(concurrentRejected).toHaveLength(1); - const concurrentObservation = decodeStrict(PublicApplicationConfirmationSchema)( - await concurrentAccepted[0]!.json(), - ); + const concurrentObservation = expectValue(concurrentAccepted[0]!); - const concurrentDuplicate = await expectProblem( - concurrentRejected[0]!, - 409, - "application.duplicate", - ); + const concurrentDuplicate = expectProblem(concurrentRejected[0]!, 409, "application.duplicate"); - const closeResponse = await request.patch( - `${BACKEND_ORIGIN}/api/admission-periods/${admissionPeriodId}`, - { - headers: { - cookie: leaderCookie, - origin: staffOrigin, - "content-type": "application/merge-patch+json", - "if-match": admissionPeriodETag, - "idempotency-key": randomUUID(), - }, - data: JSON.stringify({ endAt: closedEnd }), - }, + const closedPeriod = expectValue( + await native.call({ cookie: leaderCookie, origin: staffOrigin }, (client) => + client["admissions.reviseAdmissionPeriod"]({ + admissionPeriodId: AdmissionPeriodId.make(admissionPeriodId), + idempotencyKey: IdempotencyKey.make(randomUUID()), + ifMatch: StrongETag.make(admissionPeriodETag), + request: { endAt: closedEnd }, + }), + ), ); - expect(closeResponse.status()).toBe(200); - const closedPeriod = decodeStrict(AdmissionPeriodManagementItem)(await closeResponse.json()); expect(closedPeriod).toMatchObject({ id: admissionPeriodId, endAt: closedEnd, revision: admissionPeriodRevision + 1, }); - const confirmationAfterClose = await confirmation(request, applicationId); + const confirmationAfterClose = await confirmation(applicationId); expect(confirmationAfterClose.applicationId).toBe(applicationId); - const closedApplication = await expectProblem( - await submit(request, applicationInput({ email: "after-close-0039@example.invalid" })), + const closedApplication = expectProblem( + await submit(applicationInput({ email: "after-close-0039@example.invalid" })), 409, "application.no-eligible-period", ); - expect((await catalog(request)).departments).toEqual([]); + expect((await catalog()).departments).toEqual([]); await page.reload(); await expect(page.getByRole("heading", { name: "Ingen opptak er åpne nå" })).toBeVisible(); - let rateLimited: - | { readonly status: number; readonly code: string; readonly retryAfter: string } - | undefined; + // The RPC problem carries no Retry-After delay; the code alone states the spent limit. + let rateLimited: { readonly status: number; readonly code: string } | undefined; for (let index = 0; index < rateLimitAttempts && !rateLimited; index += 1) { - const response = await submit( - request, - applicationInput({ email: `rate-limit-${index}@example.invalid` }), - ); + const answer = await submit(applicationInput({ email: `rate-limit-${index}@example.invalid` })); - if (response.status() === 429) { - const problem = await expectProblem(response, 429, "rate-limit.exceeded"); - const retryAfter = response.headers()["retry-after"] ?? ""; - expect(retryAfter).toMatch(/^[1-9][0-9]*$/u); - rateLimited = { ...problem, retryAfter }; + if (!answer.ok && answer.status === 429) { + rateLimited = expectProblem(answer, 429, "rate-limit.exceeded"); } else { - await expectProblem(response, 409, "application.no-eligible-period"); + expectProblem(answer, 409, "application.no-eligible-period"); } } @@ -646,8 +629,6 @@ test.describe("Public applicant admission", () => { replayConflict, validation, excess, - malformedJson, - wrongContentType, bodyLimit, unknownDepartment, unknownField, diff --git a/apps/homepage/e2e/run-native-contact.mjs b/apps/homepage/e2e/run-native-contact.mjs index 311bd2f45..91aa9c9be 100644 --- a/apps/homepage/e2e/run-native-contact.mjs +++ b/apps/homepage/e2e/run-native-contact.mjs @@ -1,4 +1,4 @@ -// Spec 0043.1: isolated real Worker, native HTTP, PostgreSQL and acknowledged loopback delivery. +// Spec 0043.1: isolated real Worker, native RPC, PostgreSQL and acknowledged loopback delivery. // Reuses repository real-journey runners' PostgreSQL lifecycle and installed Cloudflare runtime. import assert from "node:assert/strict"; import { spawn, execFileSync } from "node:child_process"; @@ -11,10 +11,22 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; import { chromium, expect } from "@playwright/test"; -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "../../dashboard/e2e/settled-axe.ts"; +import { Option, Predicate, Schema } from "effect"; const root = fileURLToPath(new URL("../../../", import.meta.url)); +/** The tag of one RPC request body, as the JSON serialization of the RPC client writes it. */ +const rpcTagOf = (body) => + Option.getOrUndefined( + Option.map( + Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.TaggedStruct("Request", { tag: Schema.String })), + )(body), + (message) => message.tag, + ), + ); + const homepage = join(root, "apps/homepage"); const require = createRequire(import.meta.url); @@ -274,7 +286,16 @@ try { if (url.origin !== backendOrigin) throw new Error("Local Worker outbound origin rejected"); - if (redirectContact && request.method === "POST") { + const body = ["GET", "HEAD"].includes(request.method) + ? undefined + : await request.arrayBuffer(); + + // Every native read is an RPC POST too, so only the contact command is redirected. + if ( + redirectContact && + body !== undefined && + rpcTagOf(Buffer.from(body).toString("utf8")) === "contact.submitContactMessage" + ) { return new Response(null, { status: 307, headers: { location: redirectOrigin } }); } @@ -282,7 +303,7 @@ try { headers: request.headers, redirect: "manual" }; -if (!(["GET", "HEAD"].includes(request.method))) Object.assign(requestOptions1, { body: await request.arrayBuffer() }); +if (body !== undefined) Object.assign(requestOptions1, { body }); const response = await fetch(url, requestOptions1); workerOutbound.push({ @@ -377,20 +398,49 @@ const response = await mf.dispatchFetch(`http://p000.vektor.phibkro.org${req.url await listen(ingress, ingressPort); const origin = `http://127.0.0.1:${ingressPort}`; + /** One RPC request message, as the JSON serialization of the RPC client writes it. */ + const RpcRequest = Schema.TaggedStruct("Request", { + id: Schema.String, + tag: Schema.String, + payload: Schema.Json, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), + }); + const headers = (ip, token = tokens.backend) => ({ "content-type": "application/json", "x-vektor-contact-ip": ip, "x-vektor-contact-backend": token, }); - const post = (payload = message, ip = "192.0.2.1", token = tokens.backend) => - fetch(`${backendOrigin}/api/contact-messages`, { + /** + * Sends one `contact.submitContactMessage` RPC as the homepage server does, in the JSON wire + * format of the RPC client, and answers the status of its outcome under the HTTP contract: 200 + * for a success, the problem's own status for a declared problem, and 500 for a defect, which is + * how the RPC server answers a payload that fails the contract schema. + */ + const post = async (payload = message, ip = "192.0.2.1", token = tokens.backend) => { + const response = await fetch(`${backendOrigin}/api/rpc`, { method: "POST", headers: headers(ip, token), - body: JSON.stringify(payload), + body: JSON.stringify( + RpcRequest.make({ id: "0", tag: "contact.submitContactMessage", payload, headers: [] }), + ), redirect: "error", }); + if (response.status !== 200) return { status: response.status }; + + const exit = (await response.json()).find(Predicate.isTagged("Exit"))?.exit; + + if (Predicate.isTagged(exit, "Success")) return { status: 200 }; + + const failure = exit?.cause.find(Predicate.isTagged("Fail")); + + return failure === undefined + ? { status: 500, code: "defect" } + : { status: failure.error.status, code: failure.error.code }; + }; + const count = async () => Number( (await pool.query("SELECT coalesce(sum(attempts),0) AS n FROM public.contact_rate_windows")) @@ -412,9 +462,11 @@ const response = await mf.dispatchFetch(`http://p000.vektor.phibkro.org${req.url checkpoint( "wrong/missing/wrong-hop credential and noncanonical identity reject before quota/delivery", ); - assert.equal((await post({ ...message, email: "invalid" })).status, 422); - assert.equal((await post({ ...message, to: "attacker@example.org" })).status, 422); - assert.equal((await post({ ...message, message: "x".repeat(70_000) })).status, 413); + // The RPC server decodes the payload before any handler, so a message that fails the contract + // schema is a defect of that request, and consumes no quota. An extra member such as `to` is + // stripped by the decoding rather than rejected; the recipient stays the department's. + assert.equal((await post({ ...message, email: "invalid" })).status, 500); + assert.equal((await post({ ...message, message: "x".repeat(70_000) })).status, 500); assert.equal(await count(), 0); for (const departmentId of ["unknown", "contact-inactive", "contact-invalid-email"]) { @@ -446,10 +498,10 @@ const response = await mf.dispatchFetch(`http://p000.vektor.phibkro.org${req.url records.length = 0; checkpoint("fixed expiry does not slide and expired window resets"); const concurrent = await Promise.all(Array.from({ length: 12 }, () => post())); - assert.equal(concurrent.filter((r) => r.status === 201).length, 5); + assert.equal(concurrent.filter((r) => r.status === 200).length, 5); assert.equal(concurrent.filter((r) => r.status === 429).length, 7); assert.equal(records.length, 5); - assert.equal((await post(message, "192.0.2.2")).status, 201); + assert.equal((await post(message, "192.0.2.2")).status, 200); await clear(); checkpoint("atomic concurrency admits5/rejects7; separate visitor has quota"); mode = "reject"; @@ -512,12 +564,11 @@ const response = await mf.dispatchFetch(`http://p000.vektor.phibkro.org${req.url }; const axe = async (label) => { - const result = await new AxeBuilder({ page }) - .include('nav[aria-label="Velg avdeling"]') - .include("main") - .analyze(); + const result = await auditSettledPage(page, { + include: ['nav[aria-label="Velg avdeling"]', "main"], + }); - assert.deepEqual(result.violations, [], `axe ${label}`); + assert.deepEqual(result, [], `axe ${label}`); }; await axe("initial"); diff --git a/apps/homepage/e2e/run-real-public-applicant-admission.mjs b/apps/homepage/e2e/run-real-public-applicant-admission.mjs index 00470e42b..10d9ce2d4 100644 --- a/apps/homepage/e2e/run-real-public-applicant-admission.mjs +++ b/apps/homepage/e2e/run-real-public-applicant-admission.mjs @@ -1,8 +1,7 @@ import { postgresProgram, reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; -import { - AdmissionPeriodManagementItem, - AdmissionsSubmitApplicationProblem, -} from "@vektorprogrammet/http-api"; +import { CreateAdmissionPeriodRequest, SubmitApplicationRequest } from "@vektorprogrammet/rpc"; +import { IdempotencyKey } from "@vektorprogrammet/rpc/problem"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { Predicate, Schema } from "effect"; import { randomBytes, randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; @@ -395,34 +394,40 @@ async function signInLeader() { } async function createOpenPeriod(leaderCookie) { - const response = await fetch(`${backendOrigin}/api/admission-periods`, { - method: "POST", - headers: { - cookie: leaderCookie, - origin: staffOrigin, - "content-type": "application/json", - "idempotency-key": randomUUID(), - }, - body: JSON.stringify({ semesterId, startAt: openStart, endAt: openEnd, departmentId }), - }); + const native = nativeScriptClient(backendOrigin); - if (response.status !== 201) { - throw new Error(`Admission period creation answered ${response.status}`); - } + try { + const answer = await native.call({ cookie: leaderCookie, origin: staffOrigin }, (client) => + client["admissions.createAdmissionPeriod"]({ + idempotencyKey: IdempotencyKey.make(randomUUID()), + request: decodeStrict(CreateAdmissionPeriodRequest)({ + semesterId, + startAt: openStart, + endAt: openEnd, + departmentId, + }), + }), + ); - const period = decodeStrict(AdmissionPeriodManagementItem)(await response.json()); + if (!answer.ok) { + throw new Error(`Admission period creation answered ${answer.status} ${answer.code}`); + } - if ( - period.departmentId !== departmentId || - period.semesterId !== semesterId || - period.startAt !== openStart || - period.endAt !== openEnd || - response.headers.get("etag") !== period.etag - ) { - throw new Error("Admission period creation returned another period"); - } + const period = answer.value; + + if ( + period.departmentId !== departmentId || + period.semesterId !== semesterId || + period.startAt !== openStart || + period.endAt !== openEnd + ) { + throw new Error("Admission period creation returned another period"); + } - return period; + return period; + } finally { + await native.dispose(); + } } async function runOutboxDelivery(environment) { @@ -586,7 +591,7 @@ function assertDurableEvidence(postgres, lifecycle, delivery, persistenceFailure lifecycle.rejections.duplicate.code !== "application.duplicate" || lifecycle.rejections.replayConflict.code !== "idempotency.digest-conflict" || lifecycle.rejections.rateLimited?.code !== "rate-limit.exceeded" || - lifecycle.rejections.bodyLimit.code !== "request.too-large" || + lifecycle.rejections.bodyLimit.rejectedBeforeHandler !== true || persistenceFailure.code !== "idempotency.unavailable" || postgres.audits.some( (audit) => @@ -642,39 +647,45 @@ function assertDurableEvidence(postgres, lifecycle, delivery, persistenceFailure * browser application's availability, so only the outage can reject it. */ async function exercisePostgresFailure(postgres, availability) { - const response = await postgres.outage(() => - fetch(`${backendOrigin}/api/applications`, { - method: "POST", - headers: { "content-type": "application/json", "idempotency-key": randomUUID() }, - body: JSON.stringify({ - departmentId, - firstName: "Persistence Failure", - lastName: "Canary", - phone: "+47 933 33 333", - email: "persistence-failure-0039@example.invalid", - gender: 1, - fieldOfStudyId, - yearOfStudy: 4, - availability, - }), - }), - ); + const native = nativeScriptClient(backendOrigin); - if ( - response.status !== 503 || - response.headers.get("content-type") !== "application/problem+json" || - response.headers.get("retry-after") !== "5" - ) { - throw new Error(`PostgreSQL failure answered ${response.status} instead of a 503 problem`); - } + try { + const request = decodeStrict(SubmitApplicationRequest)({ + departmentId, + firstName: "Persistence Failure", + lastName: "Canary", + phone: "+47 933 33 333", + email: "persistence-failure-0039@example.invalid", + gender: 1, + fieldOfStudyId, + yearOfStudy: 4, + availability, + }); - const problem = decodeStrict(AdmissionsSubmitApplicationProblem)(await response.json()); + const answer = await postgres.outage(() => + native.call({}, (client) => + client["admissions.submitApplication"]({ + idempotencyKey: IdempotencyKey.make(randomUUID()), + request, + }), + ), + ); - if (problem.code !== "idempotency.unavailable") { - throw new Error(`PostgreSQL failure answered ${problem.code}`); - } + // The RPC problem carries the registry status of its code; no Retry-After header reaches it. + if (answer.ok || answer.status !== 503) { + throw new Error( + `PostgreSQL failure answered ${answer.ok ? "a success" : answer.code} instead of a 503 problem`, + ); + } - return { status: response.status, code: problem.code }; + if (answer.code !== "idempotency.unavailable") { + throw new Error(`PostgreSQL failure answered ${answer.code}`); + } + + return { status: answer.status, code: answer.code }; + } finally { + await native.dispose(); + } } function startBackend(environment) { diff --git a/apps/homepage/package.json b/apps/homepage/package.json index 6afd0e3e3..de9e0f248 100644 --- a/apps/homepage/package.json +++ b/apps/homepage/package.json @@ -40,8 +40,7 @@ "@react-router/fs-routes": "^8.3.0", "@react-router/node": "^8.3.0", "@tailwindcss/vite": "^4.0.17", - "@vektorprogrammet/http-api": "workspace:*", - "@vektorprogrammet/sdk": "workspace:*", + "@vektorprogrammet/rpc": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -49,15 +48,15 @@ "isbot": "^5.1.25", "lucide-react": "^0.486.0", "motion": "^12.6.2", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "catalog:react", + "react-dom": "catalog:react", "react-router": "^8.3.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.17", "tailwindcss-animate": "^1.0.7", "typescript": "7.0.2", "vaul": "^1.1.2", - "vite": "^8.0.7" + "vite": "catalog:react" }, "devDependencies": { "@axe-core/playwright": "^4.10.1", @@ -66,8 +65,8 @@ "@playwright/test": "^1.51.1", "@react-router/dev": "^8.3.0", "@types/node": "^22.13.14", - "@types/react": "^19.2.10", - "@types/react-dom": "^19.2.3", + "@types/react": "catalog:react", + "@types/react-dom": "catalog:react", "vitest": "catalog:effect-v4" }, "browserslist": { diff --git a/apps/homepage/src/lib/api-types.ts b/apps/homepage/src/lib/api-types.ts index 8850b0a6b..fd895d5fb 100644 --- a/apps/homepage/src/lib/api-types.ts +++ b/apps/homepage/src/lib/api-types.ts @@ -1,52 +1,36 @@ -import { - ListDepartmentsEndpoint, - ListNewsEndpoint, - ListTeamApplicationIntakesEndpoint, - ListTeamsEndpoint, - ReadApplicationCatalogEndpoint, - ReadNewsArticleEndpoint, - ReadTeamApplicationIntakeEndpoint, - SubmitContactMessageEndpoint, - SubmitTeamApplicationEndpoint, -} from "@vektorprogrammet/http-api"; -import type { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"; - -type EndpointResponseBody = - Response extends HttpApiSchema.WithHeaders ? Body["Type"] : never; - -type EndpointBody = Exclude< - EndpointResponseBody>, - void ->; - -export type HomepageDepartment = EndpointBody[number]; - -export type PublishedNewsListing = EndpointBody; +import type { + DepartmentJson, + PublicApplicationCatalogSchema, + TeamJson, +} from "@vektorprogrammet/rpc"; +import type { + NewsArticleQuery, + PublicTeamApplicationIntake, + PublishedNewsArticle as PublishedNewsArticleType, + PublishedNewsListing as PublishedNewsListingType, + TeamApplicationConfirmation, + TeamApplicationInput, + TeamApplicationIntakeListItem, +} from "@vektorprogrammet/rpc"; + +export type HomepageDepartment = DepartmentJson; + +export type PublishedNewsListing = PublishedNewsListingType; export type PublishedNewsSummary = PublishedNewsListing["articles"][number]; -export type PublishedNewsArticle = EndpointBody; +export type PublishedNewsArticle = PublishedNewsArticleType; -export type PublicApplicationCatalog = EndpointBody; +export type PublicApplicationCatalog = typeof PublicApplicationCatalogSchema.Type; -export type ContactMessagePayload = HttpApiEndpoint.Payload< - typeof SubmitContactMessageEndpoint ->["Type"]; +export type NewsArticleSlug = NewsArticleQuery["slug"]; -export type ContactMessageHeaders = HttpApiEndpoint.Headers< - typeof SubmitContactMessageEndpoint ->["Type"]; +export type HomepageTeam = TeamJson; -export type NewsArticleSlug = HttpApiEndpoint.Params["Type"]["slug"]; +export type HomepageTeamIntake = TeamApplicationIntakeListItem; -export type HomepageTeam = EndpointBody[number]; +export type HomepageTeamApplicationIntake = PublicTeamApplicationIntake; -export type HomepageTeamIntake = EndpointBody[number]; +export type TeamApplicationPayload = TeamApplicationInput; -export type HomepageTeamApplicationIntake = EndpointBody; - -export type TeamApplicationPayload = HttpApiEndpoint.Payload< - typeof SubmitTeamApplicationEndpoint ->["Type"]; - -export type SubmittedTeamApplication = EndpointBody; +export type SubmittedTeamApplication = TeamApplicationConfirmation; diff --git a/apps/homepage/src/lib/api.server.ts b/apps/homepage/src/lib/api.server.ts index 16dba5e9a..e28fd9469 100644 --- a/apps/homepage/src/lib/api.server.ts +++ b/apps/homepage/src/lib/api.server.ts @@ -1,7 +1,53 @@ -import { createPromiseClient } from "@vektorprogrammet/sdk"; +/** + * The homepage server's calls to the native backend. The homepage calls public RPCs with no + * person's credential; the contact form adds its deployment secret per call through `headers`. A + * failure rejects with the RPC's `Problem`, and a redirect rejects without being followed. + */ +import { + NativeRpcClient, + nativeRpcClientLayer, + withForwardedHeaders, +} from "@vektorprogrammet/rpc/client"; +import { type Effect, ManagedRuntime } from "effect"; -export function createHomepageApiClient(explicitOrigin?: string) { - const runtimeApiUrl = typeof process === "undefined" ? undefined : process.env.API_URL; +const configuredApiUrl = (explicitOrigin?: string): string => { + const apiUrl = + explicitOrigin ?? (typeof process === "undefined" ? undefined : process.env.API_URL); - return createPromiseClient(explicitOrigin ?? runtimeApiUrl); + if (apiUrl === undefined || apiUrl.trim() === "") throw new Error("API URL is not configured"); + + return apiUrl; +}; + +// The composition root of the homepage server's backend client, one per backend origin. +const runtimes = new Map>(); + +const nativeRuntime = (origin: string) => { + const existing = runtimes.get(origin); + + if (existing !== undefined) return existing; + + const runtime = ManagedRuntime.make(nativeRpcClientLayer(origin)); + + runtimes.set(origin, runtime); + + return runtime; +}; + +type NativeClient = NativeRpcClient["Service"]; + +/** + * Calls one native RPC from the homepage server. `explicitOrigin` overrides `API_URL`, as a + * Worker binding does; `headers` are added to this call only. + */ +export function callHomepageNative( + call: (client: NativeClient) => Effect.Effect, + options: { + readonly explicitOrigin?: string; + readonly headers?: Readonly>; + } = {}, +): Promise { + return nativeRuntime(configuredApiUrl(options.explicitOrigin)).runPromise( + NativeRpcClient.use(call).pipe(withForwardedHeaders(options.headers ?? {})), + ); } diff --git a/apps/homepage/src/lib/contact-context.server.ts b/apps/homepage/src/lib/contact-context.server.ts index a0041ab5e..e0f59ee07 100644 --- a/apps/homepage/src/lib/contact-context.server.ts +++ b/apps/homepage/src/lib/contact-context.server.ts @@ -1,7 +1,12 @@ -import { ContactVisitorIp as ContactVisitorIpSchema } from "@vektorprogrammet/http-api"; +import { + CONTACT_BACKEND_HEADER, + CONTACT_IP_HEADER, + ContactVisitorIp as ContactVisitorIpSchema, +} from "@vektorprogrammet/rpc"; import { Schema } from "effect"; import { createContext } from "react-router"; -import type { ContactMessageHeaders } from "./api-types"; + +export { CONTACT_BACKEND_HEADER, CONTACT_IP_HEADER }; const canonicalIpv4 = (raw: string): string | undefined => { const octets = raw.split("."); @@ -51,11 +56,7 @@ const canonicalContactIp = (raw: string): string => { export const CONTACT_INGRESS_HEADER = "x-vektor-contact-ingress"; -export const CONTACT_BACKEND_HEADER = "x-vektor-contact-backend"; - -export const CONTACT_IP_HEADER = "x-vektor-contact-ip"; - -type ContactVisitorIp = ContactMessageHeaders[typeof CONTACT_IP_HEADER]; +type ContactVisitorIp = ContactVisitorIpSchema; export type ContactIngress = { readonly backendOrigin: string; diff --git a/apps/homepage/src/lib/contact-message.server.ts b/apps/homepage/src/lib/contact-message.server.ts index 9020b5fb9..ffcfa367f 100644 --- a/apps/homepage/src/lib/contact-message.server.ts +++ b/apps/homepage/src/lib/contact-message.server.ts @@ -1,8 +1,7 @@ -import { ContactMessage, isProblem } from "@vektorprogrammet/http-api"; -import { createEffectClient } from "@vektorprogrammet/sdk/effect"; -import { Match, Predicate, Effect, Schema } from "effect"; -import type { ContactMessagePayload, HomepageDepartment } from "./api-types"; -import { createHomepageApiClient } from "./api.server"; +import { ContactMessage, isProblem } from "@vektorprogrammet/rpc"; +import { Match, Predicate, Schema } from "effect"; +import type { HomepageDepartment } from "./api-types"; +import { callHomepageNative } from "./api.server"; import { CONTACT_BACKEND_HEADER, CONTACT_IP_HEADER, @@ -15,18 +14,14 @@ import { contactDepartmentSlug, } from "./contact-message"; - async function activeDepartments(backendOrigin?: string): Promise { try { - const result = await createHomepageApiClient(backendOrigin).organization.listDepartments({ - headers: {}, - }); - - if (result.body === undefined) { - throw new Error("The conditional department response has no body."); - } + const listed = await callHomepageNative( + (client) => client["organization.listDepartments"](), + { explicitOrigin: backendOrigin }, + ); - const departments = result.body.filter((department) => department.active); + const departments = listed.filter((department) => department.active); const slugs = new Set(); for (const department of departments) { @@ -102,10 +97,10 @@ export async function submitContactMessage( message: formValue(formData, "message"), }; - let payload: ContactMessagePayload; + let message: ContactMessage; try { - payload = Schema.decodeSync(ContactMessage)( + message = Schema.decodeSync(ContactMessage)( { ...values, departmentId: page.selectedDepartment.departmentId }, { onExcessProperty: "error" }, ); @@ -113,38 +108,27 @@ export async function submitContactMessage( return { ok: false, message: "Fyll ut alle feltene med gyldig informasjon." }; } - const client = createEffectClient(ingress.backendOrigin, { - headers: { [CONTACT_BACKEND_HEADER]: ingress.backendToken }, - fetch: async (input, init) => { - const url = new URL(input instanceof Request ? input.url : String(input)); - - if (url.origin !== ingress.backendOrigin) - throw new Error("Unsupported contact backend origin"); - // Workers supports manual redirects; never forward the scoped credential to a redirect target. - const response = await fetch(input, { ...init, redirect: "manual" }); - - if ((response.status >= 300 && response.status < 400) || response.type === "opaqueredirect") { - await response.body?.cancel(); - throw new Error("Contact backend redirect rejected"); - } + try { + // The deployment secret and the visitor address travel with this one call only. + await callHomepageNative((client) => client["contact.submitContactMessage"](message), { + explicitOrigin: ingress.backendOrigin, + headers: { + [CONTACT_BACKEND_HEADER]: ingress.backendToken, + [CONTACT_IP_HEADER]: ingress.visitorIp, + }, + }); - return response; - }, - }); - - return Effect.runPromise( - client.contact - .submitContactMessage({ payload, headers: { [CONTACT_IP_HEADER]: ingress.visitorIp } }) - .pipe( - Effect.match({ - onSuccess: (): ContactActionData => ({ ok: true }), - onFailure: (error): ContactActionData => ({ - ok: false, - message: isProblem(error) - ? Match.value(error.code).pipe(Match.when("rate-limit.exceeded", () => "Du har sendt for mange meldinger. Prøv igjen senere." as const), Match.when("validation.failed", () => "Fyll ut alle feltene med gyldig informasjon." as const), Match.orElse(() => "Meldingen kunne ikke sendes. Prøv igjen senere." as const)) - : "Meldingen kunne ikke sendes. Prøv igjen senere.", - }), - }), - ), - ); + return { ok: true }; + } catch (error) { + return { + ok: false, + message: isProblem(error) + ? Match.value(error.code).pipe( + Match.when("rate-limit.exceeded", () => "Du har sendt for mange meldinger. Prøv igjen senere." as const), + Match.when("validation.failed", () => "Fyll ut alle feltene med gyldig informasjon." as const), + Match.orElse(() => "Meldingen kunne ikke sendes. Prøv igjen senere." as const), + ) + : "Meldingen kunne ikke sendes. Prøv igjen senere.", + }; + } } diff --git a/apps/homepage/src/lib/news.server.ts b/apps/homepage/src/lib/news.server.ts index 80a6e0cb2..6ffbc469f 100644 --- a/apps/homepage/src/lib/news.server.ts +++ b/apps/homepage/src/lib/news.server.ts @@ -1,6 +1,6 @@ -import { ArticleSlug, isProblem } from "@vektorprogrammet/http-api"; -import type { PublishedNewsArticle, PublishedNewsListing } from "./api-types"; -import { createHomepageApiClient } from "./api.server"; +import { ArticleSlug, isProblem } from "@vektorprogrammet/rpc"; +import type { PublishedNewsListing } from "./api-types"; +import { callHomepageNative } from "./api.server"; import { applyDepartmentFilter, NEWS_TEASER_COUNT, @@ -12,7 +12,7 @@ import { /** * Server-only news loaders (spec 0062 §Homepage public surface contract). * - * Every render performs its own fresh read through createHomepageApiClient(); + * Every render performs its own fresh read through callHomepageNative(); * no module-level cache, no build-time snapshot, no loader-shared mutable * state. Typed Response throws: 404 for unknown/withdrawn slugs (including a * ?versjon miss), 503 for network/decode/persistence failures. @@ -24,35 +24,17 @@ const upstreamFailure = (): Response => const notFound = (): Response => new Response("Nyheten finnes ikke.", { status: 404 }); const readListing = async (): Promise => { - const client = createHomepageApiClient(); - try { - const result = await client.content.listNews({ - query: {}, - headers: {}, - }); - - if (result.body === undefined) throw new Error("The conditional news response has no body."); - - return result.body; + return await callHomepageNative((client) => client["content.listNews"]({})); } catch { throw upstreamFailure(); } }; export const loadNewsListing = async (departmentSlugOrId?: string): Promise => { - const client = createHomepageApiClient(); - - const departments = await client.organization - .listDepartments({ headers: {} }) - .then((result) => { - if (result.body === undefined) { - throw new Error("The conditional department response has no body."); - } - - return result.body; - }) - .catch((): readonly never[] => []); + const departments = await callHomepageNative((client) => + client["organization.listDepartments"](), + ).catch((): readonly never[] => []); const { departmentId, degraded } = resolveDepartmentFilter(departments, departmentSlugOrId); // One fresh listing read per render; the filter is applied client-side on @@ -79,7 +61,6 @@ export const loadNewsArticle = async ( slug: string, versionParam?: string, ): Promise => { - const client = createHomepageApiClient(); const version = versionParam === undefined ? undefined : Number(versionParam); if (version !== undefined && (!Number.isSafeInteger(version) || version <= 0)) { @@ -87,24 +68,17 @@ export const loadNewsArticle = async ( } try { - const [articleResult, listingResult] = await Promise.all([ - client.content.readNewsArticle({ - params: { slug: ArticleSlug.make(slug) }, - query: version === undefined ? {} : { version }, - headers: {}, - }), - client.content.listNews({ query: {}, headers: {} }), - ]); + const articleSlug = ArticleSlug.make(slug); + const query = version === undefined ? { slug: articleSlug } : { slug: articleSlug, version }; - if (articleResult.body === undefined || listingResult.body === undefined) { - throw new Error("The conditional news response has no body."); - } - - const article: PublishedNewsArticle = articleResult.body; + const [article, listing] = await Promise.all([ + callHomepageNative((client) => client["content.readNewsArticle"](query)), + callHomepageNative((client) => client["content.listNews"]({})), + ]); return { article, - otherNews: listingResult.body.articles + otherNews: listing.articles .filter((summary) => summary.slug !== article.slug) .slice(0, NEWS_TEASER_COUNT), }; diff --git a/apps/homepage/src/lib/public-application-choices.ts b/apps/homepage/src/lib/public-application-choices.ts index 79b1aece5..6b96a533a 100644 --- a/apps/homepage/src/lib/public-application-choices.ts +++ b/apps/homepage/src/lib/public-application-choices.ts @@ -4,7 +4,7 @@ * not import it; the parser decodes every answer through the contract schema, so a list that * drifts from the contract fails there. */ -import type { SubmitApplicationRequest } from "@vektorprogrammet/http-api"; +import type { SubmitApplicationRequest } from "@vektorprogrammet/rpc"; import { Struct } from "effect"; type AssistantAvailability = SubmitApplicationRequest["availability"]; diff --git a/apps/homepage/src/lib/public-application.ts b/apps/homepage/src/lib/public-application.ts index df048628a..be6a0c507 100644 --- a/apps/homepage/src/lib/public-application.ts +++ b/apps/homepage/src/lib/public-application.ts @@ -1,6 +1,5 @@ -import { HttpClientError } from "effect/unstable/http"; import type { PublicApplicationCatalog } from "./api-types"; -import { isProblem, problemBody, validationProblemSchema, type ValidationProblem, IdempotencyKey, SubmitApplicationRequest } from "@vektorprogrammet/http-api"; +import { isProblem, problemBody, validationProblemSchema, type ValidationProblem, IdempotencyKey, SubmitApplicationRequest } from "@vektorprogrammet/rpc"; import { Data, Match, Option, Predicate, Schema } from "effect"; import { languageOptions, @@ -342,12 +341,18 @@ export function mapPublicApplicationError(cause: unknown): PublicApplicationErro return result; } - if (HttpClientError.isHttpClientError(cause)) { - return Match.value(cause.reason).pipe( - Match.tag("TransportError", () => PublicApplicationErrorView.Network({ + // The RPC client reports an HTTP failure below the protocol with the kind of that failure. + if ( + Predicate.isTagged(cause, "RpcClientError") && + Predicate.hasProperty(cause, "reason") && + Predicate.isTagged(cause.reason, "HttpError") && + Predicate.hasProperty(cause.reason, "kind") + ) { + return Match.value(cause.reason.kind).pipe( + Match.when("TransportError", () => PublicApplicationErrorView.Network({ message: "Søknadstjenesten svarer ikke. Kontroller forbindelsen og prøv igjen.", })), - Match.tag("InvalidUrlError", () => PublicApplicationErrorView.Configuration({ + Match.when("InvalidUrlError", () => PublicApplicationErrorView.Configuration({ message: "Søknadstjenesten er ikke tilgjengelig på denne siden.", })), Match.orElse(unexpectedApplicationError), diff --git a/apps/homepage/src/lib/public-team-application.ts b/apps/homepage/src/lib/public-team-application.ts index 187f24953..d545ffe08 100644 --- a/apps/homepage/src/lib/public-team-application.ts +++ b/apps/homepage/src/lib/public-team-application.ts @@ -1,4 +1,3 @@ -import { HttpClientError } from "effect/unstable/http"; import { IdempotencyKey, isProblem, @@ -7,7 +6,7 @@ import { TeamApplicationInput, TeamApplicationsReadIntakeProblem, TeamApplicationsSubmitProblem, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Data, Match, Option, Predicate, Schema, Struct } from "effect"; import type { HomepageTeamApplicationIntake, @@ -342,14 +341,6 @@ const unavailableMessage = "Søknadstjenesten er midlertidig utilgjengelig. Prø /** One outcome for each code of the contract's TeamApplicationsSubmitProblem union. */ const submitProblemOutcomes: Readonly> = { - "request.malformed": retry( - "Søknaden kunne ikke leses. Kontroller feltene og send søknaden på nytt.", - ), - "header.malformed": retry("Søknaden kunne ikke sendes i riktig format. Prøv igjen."), - "idempotency-key.invalid": SubmitProblemOutcome.Retry({ - message: "Innsendingen kunne ikke identifiseres. Send søknaden på nytt.", - newKey: true, - }), "resource.not-found": SubmitProblemOutcome.TeamNotFound(), "idempotency.in-flight": retry("Søknaden behandles allerede. Vent litt før du prøver igjen."), "idempotency.digest-conflict": SubmitProblemOutcome.Retry({ @@ -361,11 +352,6 @@ const submitProblemOutcomes: Readonly>> = - Object.fromEntries(teamApplicationFieldNames.map((field) => [`/${field}`, field])); - const decodeSubmitProblem = Schema.decodeUnknownOption(TeamApplicationsSubmitProblem); -function validationFieldErrors(problem: TeamApplicationProblem): TeamApplicationFieldErrors { - const fieldErrors: Partial> = {}; - - if (problem.code !== "validation.failed") return fieldErrors; - - for (const { pointer } of problem.validation.errors) { - const field = fieldByPointer[pointer]; - - if (field !== undefined) fieldErrors[field] = "Kontroller dette feltet."; - } - - return fieldErrors; -} - -/** A failure outside the contract's problems: the service was not reached, or answered otherwise. */ +/** + * A failure outside the contract's problems: the service was not reached, or answered otherwise. + * The RPC client reports an HTTP failure below the protocol with the kind of that failure. + */ function transportFailure(cause: unknown): PublicTeamApplicationErrorView { const unexpected = PublicTeamApplicationErrorView.Unexpected({ message: "Søknaden kunne ikke sendes. Prøv igjen senere.", @@ -402,17 +373,24 @@ function transportFailure(cause: unknown): PublicTeamApplicationErrorView { fieldErrors: {}, }); - if (!HttpClientError.isHttpClientError(cause)) return unexpected; + if ( + !Predicate.isTagged(cause, "RpcClientError") || + !Predicate.hasProperty(cause, "reason") || + !Predicate.isTagged(cause.reason, "HttpError") || + !Predicate.hasProperty(cause.reason, "kind") + ) { + return unexpected; + } - return Match.value(cause.reason).pipe( - Match.tag("TransportError", () => + return Match.value(cause.reason.kind).pipe( + Match.when("TransportError", () => PublicTeamApplicationErrorView.Network({ message: "Søknadstjenesten svarer ikke akkurat nå. Prøv igjen om litt.", status: 503, fieldErrors: {}, }), ), - Match.tag("InvalidUrlError", () => + Match.when("InvalidUrlError", () => PublicTeamApplicationErrorView.Configuration({ message: "Søknadstjenesten er ikke tilgjengelig på denne siden.", status: 500, @@ -432,7 +410,7 @@ export function failedPublicTeamApplication( submission: PublicTeamApplicationSubmission, cause: unknown, ): PublicTeamApplicationActionData { - // The SDK fails with a Problem; its body is the canonical wire problem. + // The RPC client fails with a Problem; its body is the canonical wire problem. const problem = Option.getOrUndefined( decodeSubmitProblem(isProblem(cause) ? problemBody(cause) : cause), ); @@ -466,7 +444,9 @@ export function failedPublicTeamApplication( PublicTeamApplicationErrorView[code]({ message, status: problem.status, - fieldErrors: validationFieldErrors(problem), + // The form decodes every field with the contract schema before it submits, and the + // RPC answers no field pointers. + fieldErrors: {}, }), newKey, ), diff --git a/apps/homepage/src/lib/team-directory.server.ts b/apps/homepage/src/lib/team-directory.server.ts index fb9f2b47d..8892d1681 100644 --- a/apps/homepage/src/lib/team-directory.server.ts +++ b/apps/homepage/src/lib/team-directory.server.ts @@ -1,29 +1,22 @@ -import { createHomepageApiClient } from "./api.server"; +import { callHomepageNative } from "./api.server"; import { projectTeamDirectory, type TeamDirectory } from "./team-directory"; /** * Server-only team page read. Every render reads departments, teams, and intake * state afresh and concurrently. Departments and teams are required: a failed - * read, a 304 response without a body, or an unroutable department is a 503. + * read or an unroutable department is a 503. * Intake state is optional: a failed intake read renders every team without an * application link. */ export async function loadTeamDirectory(): Promise { - const client = createHomepageApiClient(); - const [departments, teams, intakes] = await Promise.all([ - client.organization.listDepartments({ headers: {} }).then( - (result) => result.body, - () => undefined, - ), - client.organization.listTeams({ headers: {} }).then( - (result) => result.body, + callHomepageNative((client) => client["organization.listDepartments"]()).catch( () => undefined, ), - client["team-applications"].listTeamApplicationIntakes().then( - (result) => result.body, - () => null, - ), + callHomepageNative((client) => client["organization.listTeams"]()).catch(() => undefined), + callHomepageNative((client) => + client["team-applications.listTeamApplicationIntakes"](), + ).catch(() => null), ]); const directory = diff --git a/apps/homepage/src/routes/_home.assistenter.tsx b/apps/homepage/src/routes/_home.assistenter.tsx index ba73c69b8..8a6970db1 100644 --- a/apps/homepage/src/routes/_home.assistenter.tsx +++ b/apps/homepage/src/routes/_home.assistenter.tsx @@ -10,7 +10,7 @@ import { getAssistantFaqs } from "~/api/faq"; import { Divider } from "~/components/divider"; import { PublicApplicationForm } from "~/components/public-application-form"; import { Button } from "~/components/ui/button"; -import { createHomepageApiClient } from "~/lib/api.server"; +import { callHomepageNative } from "~/lib/api.server"; import { mapPublicApplicationError, parsePublicApplicationForm, @@ -20,21 +20,12 @@ import { import type { Route } from "./+types/_home.assistenter"; export async function loader(): Promise { - const client = createHomepageApiClient(); - try { - const result = await client.admissions.listApplicationOptions({ - headers: {}, - }); - - if (result.body === undefined) { - throw new Error("The conditional application options response has no body."); - } + const catalog = await callHomepageNative((client) => + client["admissions.listApplicationOptions"](), + ); - return { - ok: true, - catalog: result.body, - }; + return { ok: true, catalog }; } catch (error) { return { ok: false, @@ -64,18 +55,14 @@ export async function action({ request }: Route.ActionArgs): Promise + client["admissions.submitApplication"]({ idempotencyKey: commandId, request: payload }), + ); - return { - success: true, - confirmation: result.body, - }; + return { success: true, confirmation }; } catch (error) { return { success: false, diff --git a/apps/homepage/src/routes/_home.team_.$teamId.soknad.tsx b/apps/homepage/src/routes/_home.team_.$teamId.soknad.tsx index 9a0853651..985384ece 100644 --- a/apps/homepage/src/routes/_home.team_.$teamId.soknad.tsx +++ b/apps/homepage/src/routes/_home.team_.$teamId.soknad.tsx @@ -1,7 +1,7 @@ import { Option } from "effect"; import { data, useActionData, useLoaderData, useNavigation } from "react-router"; import { PublicTeamApplicationForm } from "~/components/public-team-application-form"; -import { createHomepageApiClient } from "~/lib/api.server"; +import { callHomepageNative } from "~/lib/api.server"; import { decodeTeamApplicationTeamId, failedPublicTeamApplication, @@ -55,11 +55,11 @@ export async function loader({ params }: Route.LoaderArgs) { if (Option.isNone(teamId)) return pageResponse({ state: "not-found" }); try { - const result = await createHomepageApiClient()["team-applications"].readTeamApplicationIntake({ - params: { teamId: teamId.value }, - }); + const intake = await callHomepageNative((client) => + client["team-applications.readTeamApplicationIntake"]({ teamId: teamId.value }), + ); - return pageResponse(publicTeamApplicationPage(result.body)); + return pageResponse(publicTeamApplicationPage(intake)); } catch (cause) { return pageResponse(publicTeamApplicationPageFailure(cause)); } @@ -82,28 +82,26 @@ export async function action({ request, params }: Route.ActionArgs) { if (!parsed.ok) return outcomeResponse({ outcome: "rejected", failure: parsed.failure }); - const applications = createHomepageApiClient()["team-applications"]; - - const outcome = await applications - .submitTeamApplication({ - params: { teamId: teamId.value }, - headers: { "idempotency-key": parsed.value.commandId }, - payload: parsed.value.payload, - }) - .then( - (result) => receivedPublicTeamApplication(result.body), - (cause) => failedPublicTeamApplication(parsed.value, cause), - ); + const outcome = await callHomepageNative((client) => + client["team-applications.submitTeamApplication"]({ + teamId: teamId.value, + idempotencyKey: parsed.value.commandId, + request: parsed.value.payload, + }), + ).then( + (confirmation) => receivedPublicTeamApplication(confirmation), + (cause) => failedPublicTeamApplication(parsed.value, cause), + ); if (outcome.outcome !== "received") return outcomeResponse(outcome); // The application is stored. A failed name read only leaves the name out of the confirmation. - const teamName = await applications - .readTeamApplicationIntake({ params: { teamId: teamId.value } }) - .then( - (intake) => intake.body.teamName, - () => undefined, - ); + const teamName = await callHomepageNative((client) => + client["team-applications.readTeamApplicationIntake"]({ teamId: teamId.value }), + ).then( + (intake) => intake.teamName, + () => undefined, + ); return outcomeResponse({ ...outcome, teamName }); } diff --git a/apps/homepage/test/api.server.test.ts b/apps/homepage/test/api.server.test.ts index 539f2c3c2..165e0cb3c 100644 --- a/apps/homepage/test/api.server.test.ts +++ b/apps/homepage/test/api.server.test.ts @@ -1,31 +1,36 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createHomepageApiClient } from "../src/lib/api.server"; +import { callHomepageNative } from "../src/lib/api.server"; +import { nativeRpcProblem, stubNativeBackend } from "./native-rpc"; + +const backend = stubNativeBackend(); afterEach(() => { vi.unstubAllEnvs(); - vi.unstubAllGlobals(); }); describe("homepage server API origin", () => { - it("sends each request to the current runtime API_URL", async () => { - const urls: string[] = []; - - const fetch: typeof globalThis.fetch = async (input) => { - urls.push(input instanceof Request ? input.url : String(input)); + it("sends each call to the current runtime API_URL, with its own headers only", async () => { + backend.answer((call) => nativeRpcProblem(call, "credential.missing")); - return Response.json({ status: "ok" }, { - headers: { "cache-control": "no-store", vary: "Origin" }, - }); - }; + const readSession = (headers?: Readonly>) => + callHomepageNative((client) => client["system.readSession"](), { headers }).catch( + () => undefined, + ); - vi.stubGlobal("fetch", fetch); vi.stubEnv("API_URL", "https://api.example.invalid"); - await createHomepageApiClient().system.health(); + await readSession({ "x-vektor-contact-backend": "one-call-only" }); vi.stubEnv("API_URL", "https://changed.example.invalid"); - await createHomepageApiClient().system.health(); - expect(urls).toEqual([ - "https://api.example.invalid/health", - "https://changed.example.invalid/health", + await readSession(); + + expect( + backend.calls.map(({ url, tag, headers }) => [ + url, + tag, + headers.get("x-vektor-contact-backend"), + ]), + ).toEqual([ + ["https://api.example.invalid/api/rpc/", "system.readSession", "one-call-only"], + ["https://changed.example.invalid/api/rpc/", "system.readSession", null], ]); }); }); diff --git a/apps/homepage/test/contact-message.test.ts b/apps/homepage/test/contact-message.test.ts index eddd33c40..706022c2d 100644 --- a/apps/homepage/test/contact-message.test.ts +++ b/apps/homepage/test/contact-message.test.ts @@ -1,26 +1,54 @@ import { RouterContextProvider } from "react-router"; import { contactIngressContext } from "../src/lib/contact-context.server"; -import { DepartmentJsonSchema, type DepartmentJson } from "@vektorprogrammet/http-api" -import { Schema } from "effect"; +import { ContactVisitorIp } from "@vektorprogrammet/rpc"; +import type { Schema } from "effect"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type NativeBackend, + nativeRpcProblem, + nativeRpcSuccess, + stubNativeBackend, +} from "./native-rpc"; + +vi.stubEnv("API_URL", "http://api.test"); + +const backend = stubNativeBackend(); + +/** A department as `organization.listDepartments` encodes it. */ +const makeDepartment = (overrides: { readonly [field: string]: Schema.Json } = {}) => ({ + departmentId: "department-17", + name: "Vektorprogrammet Ås", + shortName: "Ås", + email: "aas@example.com", + address: "Universitetsveien 1", + city: "Ås", + latitude: "59.66", + longitude: "10.77", + slackChannel: null, + logoPath: null, + active: true, + revision: 0, + ...overrides, +}); -const contactApi = { departments: Array() }; - -const withDepartmentResponse = (deliver: typeof globalThis.fetch): typeof globalThis.fetch => async (input, init) => { - const url = new URL(input instanceof Request ? input.url : String(input)); +const contactApi = { departments: Array>() }; - if (url.pathname === "/api/departments") return Response.json(contactApi.departments, { headers: { - "cache-control": "public, max-age=60, s-maxage=300, must-revalidate", - vary: "Origin", - etag: '"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"', - } }); +/** Answers the department list, and passes every other call to `contact`. */ +const withDepartments = + (contact: NativeBackend): NativeBackend => + (call) => + call.tag === "organization.listDepartments" + ? nativeRpcSuccess(call, contactApi.departments) + : contact(call); - return deliver(input, init); +const unexpectedContact: NativeBackend = (call) => { + throw new Error(`Unexpected contact request ${call.tag}`); }; beforeEach(() => { vi.stubEnv("API_URL", "http://api.test"); - vi.stubGlobal("fetch", withDepartmentResponse(async () => { throw new Error("Unexpected contact request"); })); + backend.calls.length = 0; + backend.answer(withDepartments(unexpectedContact)); }); import { contactDepartmentSlug, type ContactFormValues } from "../src/lib/contact-message"; @@ -36,26 +64,6 @@ const submitContactMessage = (request: Request, slug?: string) => visitorIp: ContactVisitorIp.make("127.0.0.1"), }); -import { ContactVisitorIp } from "@vektorprogrammet/http-api" -import { makeNativeProblem } from "@vektorprogrammet/http-api"; - -const makeDepartment = (overrides: Partial = {}): DepartmentJson => - Schema.decodeSync(DepartmentJsonSchema)({ - departmentId: "department-17", - name: "Vektorprogrammet Ås", - shortName: "Ås", - email: "aas@example.com", - address: "Universitetsveien 1", - city: "Ås", - latitude: "59.66", - longitude: "10.77", - slackChannel: null, - logoPath: null, - active: true, - revision: 0, - ...overrides, - }); - const department = makeDepartment(); contactApi.departments = [department]; @@ -67,22 +75,18 @@ const formRequest = (values: ContactFormValues): Request => }); afterEach(() => { - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); contactApi.departments = [department]; }); describe("homepage contact-message boundary", () => { it("denies contact writes without a bound ingress context before fetching", async () => { - const fetch = vi.fn(); - vi.stubGlobal("fetch", fetch); const context = new RouterContextProvider(); const request = new Request("http://127.0.0.1:8787/kontakt", { method: "POST" }); const result = await submitWithIngress(request, undefined, context.get(contactIngressContext)); expect(result.ok).toBe(false); - expect(fetch).not.toHaveBeenCalled(); + expect(backend.calls).toEqual([]); }); it("maps the live department name to the stable route slug", () => { @@ -90,18 +94,7 @@ describe("homepage contact-message boundary", () => { }); it("submits the route-selected department without returning the draft", async () => { - vi.stubEnv("API_URL", "http://api.test"); - - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(null, { - status: 201, - headers: { "cache-control": "no-store", vary: "Origin" }, - }), - ); - - vi.stubGlobal("fetch", withDepartmentResponse(fetchMock)); + backend.answer(withDepartments((call) => nativeRpcSuccess(call, null))); const values = { name: "Ola Nordmann", @@ -114,13 +107,16 @@ describe("homepage contact-message boundary", () => { expect(result).toEqual({ ok: true }); expect(JSON.stringify(result)).not.toContain(values.email); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0]; - expect(new Request(url).url).toBe("http://api.test/api/contact-messages"); - expect(await new Response(init?.body).json()).toEqual({ - ...values, - departmentId: department.departmentId, - }); + + const contact = backend.calls.filter(({ tag }) => tag === "contact.submitContactMessage"); + + expect(contact).toHaveLength(1); + expect(contact[0]?.url).toBe("http://api.test/api/rpc/"); + expect(contact[0]?.payload).toEqual({ ...values, departmentId: department.departmentId }); + expect(contact[0]?.headers.get("x-vektor-contact-backend")).toBe( + "backend-test-00000000000000000000000", + ); + expect(contact[0]?.headers.get("x-vektor-contact-ip")).toBe("127.0.0.1"); }); it("rejects an unknown or inactive department route", async () => { @@ -156,9 +152,6 @@ describe("homepage contact-message boundary", () => { }); it("keeps invalid private input out of the action response", async () => { - vi.stubEnv("API_URL", "http://api.test"); - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", withDepartmentResponse(fetchMock)); const privateCanary = "private-contact-canary"; const result = await submitContactMessage( @@ -173,11 +166,10 @@ describe("homepage contact-message boundary", () => { expect(result).toMatchObject({ ok: false }); expect(JSON.stringify(result)).not.toContain(privateCanary); + expect(backend.calls.map(({ tag }) => tag)).not.toContain("contact.submitContactMessage"); }); - it("classifies the typed native validation and rate-limit responses", async () => { - vi.stubEnv("API_URL", "http://api.test"); - + it("classifies the typed native validation and rate-limit problems", async () => { const values = { name: "Ola Nordmann", email: "ola@example.com", @@ -185,51 +177,25 @@ describe("homepage contact-message boundary", () => { message: "Når starter neste opptak?", } as const; - const validationFetch = vi - .fn() - .mockResolvedValue( - new Response( - JSON.stringify({ - ...makeNativeProblem("validation.failed", 422), - validation: { errors: [], truncated: false }, - }), - { - status: 422, - headers: { - "content-type": "application/problem+json", - "cache-control": "no-store", - vary: "Origin", - }, - }, - ), - ); - - vi.stubGlobal("fetch", withDepartmentResponse(validationFetch)); + backend.answer(withDepartments((call) => nativeRpcProblem(call, "validation.failed"))); await expect(submitContactMessage(formRequest(values), "aas")).resolves.toEqual({ ok: false, message: "Fyll ut alle feltene med gyldig informasjon.", }); - vi.stubGlobal( - "fetch", - withDepartmentResponse(vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify(makeNativeProblem("rate-limit.exceeded", 429)), { - status: 429, - headers: { - "content-type": "application/problem+json", - "cache-control": "no-store", - vary: "Origin", - "retry-after": "3600", - }, - }), - )), - ); + backend.answer(withDepartments((call) => nativeRpcProblem(call, "rate-limit.exceeded"))); + await expect(submitContactMessage(formRequest(values), "aas")).resolves.toEqual({ ok: false, message: "Du har sendt for mange meldinger. Prøv igjen senere.", }); + + backend.answer(withDepartments((call) => nativeRpcProblem(call, "contact.unavailable"))); + + await expect(submitContactMessage(formRequest(values), "aas")).resolves.toEqual({ + ok: false, + message: "Meldingen kunne ikke sendes. Prøv igjen senere.", + }); }); }); diff --git a/apps/homepage/test/native-rpc.ts b/apps/homepage/test/native-rpc.ts new file mode 100644 index 000000000..42199e64f --- /dev/null +++ b/apps/homepage/test/native-rpc.ts @@ -0,0 +1,124 @@ +/** + * A fake native backend for homepage server tests, at the wire: it reads the RPC request message + * that `callHomepageNative` posts, and answers with the RPC response message that the backend's RPC + * server writes. + * + * The RPC client's fetch transport reads `globalThis.fetch` once, on its first request, and keeps + * it. A test file therefore stubs `fetch` once with `stubNativeBackend`, and each test sets the + * answer through the function that it returns. + */ +import { + makeNativeValidationError, + type NativeProblemCode, + Problem, + problemBody, + type ValidationProblemCode, +} from "@vektorprogrammet/rpc"; +import { Exit, Schema } from "effect"; +import { vi } from "vitest"; + +/** The RPC client numbers its requests; the answer echoes the number. */ +const RequestId = Schema.Union([Schema.String, Schema.Finite]); + +const RpcRequestBody = Schema.fromJsonString( + Schema.TaggedStruct("Request", { + id: RequestId, + tag: Schema.String, + payload: Schema.Json, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), + }), +); + +const RpcExitMessage = Schema.TaggedStruct("Exit", { + requestId: RequestId, + exit: Schema.Json, +}); + +/** An exit whose success, failure, and defect are already JSON, as the RPC server encodes them. */ +const encodeJsonExit = Schema.encodeSync( + Schema.toCodecJson(Schema.Exit(Schema.Json, Schema.Json, Schema.Json)), +); + +/** One RPC call that the homepage server sent. */ +export interface NativeRpcCall { + readonly url: string; + readonly id: typeof RequestId.Type; + readonly tag: string; + readonly payload: Schema.Json; + /** The HTTP headers, with the headers of the RPC message, such as the contact secret, over them. */ + readonly headers: Headers; +} + +/** Reads the RPC call that `input` posts to the RPC endpoint. */ +export async function readNativeRpcCall( + input: Parameters[0], + init?: Parameters[1], +): Promise { + const request = new Request(input, init); + const body = Schema.decodeSync(RpcRequestBody)(await request.text()); + const headers = new Headers(request.headers); + + for (const [name, value] of body.headers) headers.set(name, value); + + return { url: request.url, id: body.id, tag: body.tag, payload: body.payload, headers }; +} + +/** Answers `call` with a success whose value the test encoded with the RPC's success schema. */ +export const nativeRpcSuccess = (call: NativeRpcCall, encoded: Schema.Json): Response => + Response.json([ + RpcExitMessage.make({ requestId: call.id, exit: encodeJsonExit(Exit.succeed(encoded)) }), + ]); + +const isValidationCode = (code: NativeProblemCode): code is ValidationProblemCode => + code === "validation.failed" || + code === "validation.no-change" || + code === "validation.field-not-deletable"; + +/** + * Answers `call` with the declared problem `code`; a validation problem carries one diagnostic at + * the root pointer, as `requestInvalid` answers. + */ +export const nativeRpcProblem = (call: NativeRpcCall, code: NativeProblemCode): Response => { + const problem = isValidationCode(code) + ? Problem.validation(code, [makeNativeValidationError("", "invalid")]) + : Problem.fromWire({ code }, {}); + + return Response.json([ + RpcExitMessage.make({ + requestId: call.id, + exit: encodeJsonExit(Exit.fail({ ...problemBody(problem) })), + }), + ]); +}; + +/** Answers one RPC call; a test replaces it per case. */ +export type NativeBackend = (call: NativeRpcCall) => Response | Promise; + +/** + * Stubs `fetch` with one stable transport that reads each RPC call and passes it to the current + * backend. Returns the function that sets the backend, and the calls that the transport read. + */ +export const stubNativeBackend = () => { + const calls: Array = []; + + let backend: NativeBackend = (call) => { + throw new Error(`Unexpected native RPC ${call.tag}`); + }; + + vi.stubGlobal( + "fetch", + vi.fn(async (input, init) => { + const call = await readNativeRpcCall(input, init); + calls.push(call); + + return backend(call); + }), + ); + + return { + calls, + answer: (next: NativeBackend) => { + backend = next; + }, + }; +}; diff --git a/apps/homepage/test/news.test.ts b/apps/homepage/test/news.test.ts index e2bca6cb1..e29894175 100644 --- a/apps/homepage/test/news.test.ts +++ b/apps/homepage/test/news.test.ts @@ -1,4 +1,4 @@ -import { ArticleVersionNumber } from "@vektorprogrammet/http-api"; +import { ArticleVersionNumber } from "@vektorprogrammet/rpc"; type NewsApiFixture = { listingCalls: number; @@ -9,47 +9,52 @@ type NewsApiFixture = { }; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ArticleSlug, DepartmentJsonSchema, makeNativeProblem } from "@vektorprogrammet/http-api"; +import { ArticleSlug, DepartmentJsonSchema, PublishedNewsArticleSchema, PublishedNewsListingSchema } from "@vektorprogrammet/rpc"; import { Schema } from "effect"; import type { PublishedNewsSummary, HomepageDepartment, PublishedNewsArticle, PublishedNewsListing } from "../src/lib/api-types"; +import { nativeRpcProblem, nativeRpcSuccess, stubNativeBackend } from "./native-rpc"; + +vi.stubEnv("API_URL", "http://api.test"); const apiState: NewsApiFixture = { listingCalls: 0, listResult: { articles: [] }, departments: [], readArticle: undefined, listError: undefined }; -const publicHeaders = { - "cache-control": "public, max-age=60, s-maxage=300, must-revalidate", - vary: "Origin", - etag: '"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"', -}; +const encodeListing = Schema.encodeSync(Schema.toCodecJson(PublishedNewsListingSchema)); + +const encodeArticle = Schema.encodeSync(Schema.toCodecJson(PublishedNewsArticleSchema)); + +const encodeDepartments = Schema.encodeSync(Schema.toCodecJson(Schema.Array(DepartmentJsonSchema))); + +/** The version selector of a `content.readNewsArticle` call. */ +const NewsArticleCall = Schema.Struct({ slug: Schema.String, version: Schema.optional(Schema.Finite) }); + +const backend = stubNativeBackend(); beforeEach(() => { vi.stubEnv("API_URL", "http://api.test"); - const fetch: typeof globalThis.fetch = async (input) => { - const url = new URL(input instanceof Request ? input.url : String(input)); - - if (url.pathname === "/api/departments") return Response.json(apiState.departments, { headers: publicHeaders }); + backend.answer((call) => { + if (call.tag === "organization.listDepartments") return nativeRpcSuccess(call, encodeDepartments(apiState.departments)); - if (url.pathname === "/api/news") { + if (call.tag === "content.listNews") { if (apiState.listError !== undefined) throw new TypeError("Network unavailable"); apiState.listingCalls += 1; - return Response.json(apiState.listResult, { headers: publicHeaders }); + return nativeRpcSuccess(call, encodeListing(apiState.listResult)); } + if (call.tag !== "content.readNewsArticle") throw new Error(`Unexpected news call ${call.tag}`); + + const { version } = Schema.decodeUnknownSync(NewsArticleCall)(call.payload); const article = apiState.readArticle; - if (article === undefined || "notFound" in article || url.searchParams.get("version") === "99") { - return Response.json(makeNativeProblem("content.article-not-found", 404), { - status: 404, headers: { "cache-control": "no-store", vary: "Origin" }, - }); + if (article === undefined || "notFound" in article || version === 99) { + return nativeRpcProblem(call, "content.article-not-found"); } if ("networkError" in article) throw new TypeError("Network unavailable"); - return Response.json(url.searchParams.get("version") === "1" ? { ...article, bodyHtml: "

eldre, uforanderlige bytes

" } : article, { headers: publicHeaders }); - }; - - vi.stubGlobal("fetch", fetch); + return nativeRpcSuccess(call, encodeArticle(version === 1 ? { ...article, bodyHtml: "

eldre, uforanderlige bytes

" } : article)); + }); }); @@ -75,7 +80,6 @@ const summary = (overrides: NewsSummaryOverrides = {}): PublishedNewsSummary => afterEach(() => { apiState.listingCalls = 0; vi.unstubAllEnvs(); - vi.unstubAllGlobals(); apiState.listError = undefined; apiState.readArticle = undefined; }); diff --git a/apps/homepage/test/public-application.test.ts b/apps/homepage/test/public-application.test.ts index 3de0e1a84..de2fd4042 100644 --- a/apps/homepage/test/public-application.test.ts +++ b/apps/homepage/test/public-application.test.ts @@ -1,13 +1,15 @@ import { createServer } from "node:http"; -import { createPromiseClient } from "@vektorprogrammet/sdk"; -import { makeNativeProblem } from "@vektorprogrammet/http-api"; +import { SubmitApplication } from "@vektorprogrammet/rpc"; +import { Problem } from "@vektorprogrammet/rpc/problem"; +import { Rpc } from "effect/unstable/rpc"; import { describe, expect, it } from "vitest"; +import { callHomepageNative } from "../src/lib/api.server"; import { mapPublicApplicationError, parsePublicApplicationForm, } from "../src/lib/public-application"; import { languageOptions, positionOptions } from "../src/lib/public-application-choices"; -import { Predicate } from "effect"; +import { Exit, Predicate, Schema } from "effect"; const privateCanaries = [ @@ -96,7 +98,7 @@ describe("public application form boundary", () => { } }); - it("rejects excess and duplicate form members before the SDK call", () => { + it("rejects excess and duplicate form members before the RPC call", () => { const excess = completeForm(); excess.set("applicantId", "browser-owned-identity"); const duplicate = completeForm(); @@ -275,14 +277,30 @@ expect(observedFailure4).toEqual({ message: "Søknaden kunne ikke sendes. Prøv }); -it("preserves a duplicate-application denial through the real HTTP and generated SDK boundary", async () => { +/** One RPC answer on the JSON wire: the exit of one request. */ +const WireExit = Schema.TaggedStruct("Exit", { requestId: Schema.Unknown, exit: Schema.Unknown }); + +it("preserves a duplicate-application denial through the real RPC client boundary", async () => { const parsed = parsePublicApplicationForm(completeForm()); if (!parsed.ok) throw new Error("Invalid application fixture"); - const server = createServer((_request, response) => { - response.writeHead(409, { "Content-Type": "application/problem+json", "Cache-Control": "no-store", Vary: "Origin" }); - response.end(JSON.stringify(makeNativeProblem("application.duplicate", 409))); + // The backend answers the RPC with the declared problem, encoded as its exit schema encodes it. + const failureExit = Schema.encodeSync(Schema.toCodecJson(Rpc.exitSchema(SubmitApplication)))( + Exit.fail(Problem.make("application.duplicate")), + ); + + const server = createServer((request, response) => { + const chunks: Array = []; + + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + const message: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + const requestId = Predicate.hasProperty(message, "id") ? message.id : null; + + response.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }); + response.end(JSON.stringify([WireExit.make({ requestId, exit: failureExit })])); + }); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -291,11 +309,12 @@ it("preserves a duplicate-application denial through the real HTTP and generated const address = server.address(); if (address === null || Predicate.isString(address)) throw new Error("Expected a loopback TCP listener"); - const client = createPromiseClient(`http://127.0.0.1:${address.port}`); + const { commandId, payload } = parsed.value; - const failure = await client.admissions.submitApplication({ - headers: { "idempotency-key": parsed.value.commandId }, payload: parsed.value.payload, - }).then(() => { throw new Error("Unexpected application success"); }, mapPublicApplicationError); + const failure = await callHomepageNative( + (client) => client["admissions.submitApplication"]({ idempotencyKey: commandId, request: payload }), + { explicitOrigin: `http://127.0.0.1:${address.port}` }, + ).then(() => { throw new Error("Unexpected application success"); }, mapPublicApplicationError); expect(failure._tag).toBe("application.duplicate"); expect(failure.resetCommandId).toBeUndefined(); @@ -303,4 +322,3 @@ it("preserves a duplicate-application denial through the real HTTP and generated await new Promise((resolve, reject) => server.close((cause) => cause ? reject(cause) : resolve())); } }); - diff --git a/apps/homepage/test/public-team-application.test.ts b/apps/homepage/test/public-team-application.test.ts index c8962ea85..44777c664 100644 --- a/apps/homepage/test/public-team-application.test.ts +++ b/apps/homepage/test/public-team-application.test.ts @@ -1,13 +1,13 @@ import { IdempotencyKey, makeNativeProblem, - type NativeProblemCode, PublicTeamApplicationIntake, TeamApplicationsSubmitProblem, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Schema } from "effect"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createHomepageApiClient } from "../src/lib/api.server"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { callHomepageNative } from "../src/lib/api.server"; +import { type NativeBackend, nativeRpcProblem, stubNativeBackend } from "./native-rpc"; import { failedPublicTeamApplication, parsePublicTeamApplicationForm, @@ -134,58 +134,44 @@ it("gives every submit problem of the contract its own outcome", () => { for (const member of TeamApplicationsSubmitProblem.members) { const code = member.fields.code.literal; - const problem = - code === "validation.failed" - ? { ...makeNativeProblem(code), validation: { errors: [], truncated: false } } - : makeNativeProblem(code); - - const result = failedPublicTeamApplication(completeSubmission(), problem); + const result = failedPublicTeamApplication(completeSubmission(), makeNativeProblem(code)); // A code without an outcome falls through to the unexpected failure. if (result.outcome === "rejected") expect(result.failure.error._tag).toBe(code); } }); -describe("team application submission failures through the SDK", () => { - const problemHeaders = { - "content-type": "application/problem+json", - "cache-control": "no-store", - vary: "Origin", - }; - - // Effect memoizes the first global fetch it reads, so one stub answers through this binding. - let respond: () => Response = () => { - throw new Error("No backend response was arranged"); - }; - - function problemResponse( - code: NativeProblemCode, - status: number, - headers: Readonly> = {}, - ): Response { - return Response.json(makeNativeProblem(code, status), { - status, - headers: { ...problemHeaders, ...headers }, - }); - } +describe("team application submission failures over RPC", () => { + // The RPC client keeps the first fetch it reads, so one stub answers every test of this file. + let native: ReturnType; + + beforeAll(() => { + vi.stubEnv("API_URL", "http://api.test"); + native = stubNativeBackend(); + }); - async function submitAgainst(next: () => Response): Promise { + afterAll(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + async function submitAgainst(backend: NativeBackend): Promise { const submission = completeSubmission(); - respond = next; - - return createHomepageApiClient() - ["team-applications"].submitTeamApplication({ - params: { teamId: PublicTeamApplicationIntake.fields.teamId.make("team-it") }, - headers: { "idempotency-key": submission.commandId }, - payload: submission.payload, - }) - .then( - () => { - throw new Error("Unexpected team application success"); - }, - (cause) => failedPublicTeamApplication(submission, cause), - ); + native.answer(backend); + + return callHomepageNative((client) => + client["team-applications.submitTeamApplication"]({ + teamId: PublicTeamApplicationIntake.fields.teamId.make("team-it"), + idempotencyKey: submission.commandId, + request: submission.payload, + }), + ).then( + () => { + throw new Error("Unexpected team application success"); + }, + (cause) => failedPublicTeamApplication(submission, cause), + ); } function rejectedKey(result: PublicTeamApplicationActionData): string { @@ -195,89 +181,62 @@ describe("team application submission failures through the SDK", () => { return result.failure.commandId; } - beforeEach(() => { - const fetch: typeof globalThis.fetch = async () => respond(); + it("sends the key and the decoded fields as the submission payload", async () => { + await submitAgainst((call) => nativeRpcProblem(call, "internal.error")); - vi.stubEnv("API_URL", "http://api.test"); - vi.stubGlobal("fetch", fetch); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); + expect(native.calls.at(-1)).toMatchObject({ + tag: "team-applications.submitTeamApplication", + payload: { + teamId: "team-it", + idempotencyKey: submittedCommandId, + request: { name: "Ada Applicant", motivation: "Jeg vil bidra." }, + }, + }); }); it("ends the form for a closed intake and for an unknown team", async () => { await expect( - submitAgainst(() => problemResponse("team-application.intake-closed", 409)), + submitAgainst((call) => nativeRpcProblem(call, "team-application.intake-closed")), ).resolves.toMatchObject({ outcome: "closed" }); - await expect(submitAgainst(() => problemResponse("resource.not-found", 404))).resolves.toEqual({ - outcome: "not-found", - }); + await expect( + submitAgainst((call) => nativeRpcProblem(call, "resource.not-found")), + ).resolves.toEqual({ outcome: "not-found" }); }); - it("maps validation pointers to their fields and keeps the draft and key", async () => { - const result = await submitAgainst(() => - Response.json( - { - ...makeNativeProblem("validation.failed", 422), - validation: { - errors: [ - { pointer: "/fieldOfStudy", code: "invalid", message: "The value is invalid." }, - { pointer: "/email", code: "missing", message: "A required value is missing." }, - ], - truncated: false, - }, - }, - { status: 422, headers: problemHeaders }, - ), + it("issues a new key after a conflict that binds the key to another request", async () => { + const key = rejectedKey( + await submitAgainst((call) => nativeRpcProblem(call, "idempotency.digest-conflict")), ); - if (result.outcome !== "rejected") - throw new Error(`Expected a rejection, got ${result.outcome}`); - - expect(Object.keys(result.failure.error.fieldErrors).sort()).toEqual(["email", "fieldOfStudy"]); - expect(result.failure.commandId).toBe(submittedCommandId); - expect(result.failure.values).toMatchObject({ name: "Ada Applicant" }); - }); - - it("issues a new key after each conflict that binds the key to another request", async () => { - const conflicts = [ - () => problemResponse("idempotency.digest-conflict", 409), - () => problemResponse("idempotency-key.invalid", 400), - ]; - - for (const respond of conflicts) { - const key = rejectedKey(await submitAgainst(respond)); - - expect(key).not.toBe(submittedCommandId); - expect(Schema.is(IdempotencyKey)(key)).toBe(true); - } + expect(key).not.toBe(submittedCommandId); + expect(Schema.is(IdempotencyKey)(key)).toBe(true); }); it("confirms a replay whose stored response expired, without a reference or a new form", async () => { await expect( - submitAgainst(() => problemResponse("idempotency.response-expired", 409)), + submitAgainst((call) => nativeRpcProblem(call, "idempotency.response-expired")), ).resolves.toEqual({ outcome: "received", receipt: null }); }); it("retries the same key while the request is in flight or the service is unreachable", async () => { - const retries = [ - () => problemResponse("idempotency.in-flight", 409, { "retry-after": "1" }), - () => { - throw new TypeError("Network unavailable"); - }, - ]; + const inFlight = await submitAgainst((call) => nativeRpcProblem(call, "idempotency.in-flight")); - for (const respond of retries) { - expect(rejectedKey(await submitAgainst(respond))).toBe(submittedCommandId); - } + expect(rejectedKey(inFlight)).toBe(submittedCommandId); + + const unreachable = await submitAgainst(() => { + throw new TypeError("Network unavailable"); + }); + + expect(rejectedKey(unreachable)).toBe(submittedCommandId); + + if (unreachable.outcome !== "rejected") throw new Error("Expected a rejection"); + + expect(unreachable.failure.error._tag).toBe("Network"); + expect(unreachable.failure.error.status).toBe(503); }); it("answers a rate-limited submission with 429 and keeps the draft and the key", async () => { - const result = await submitAgainst(() => - problemResponse("rate-limit.exceeded", 429, { "retry-after": "60" }), - ); + const result = await submitAgainst((call) => nativeRpcProblem(call, "rate-limit.exceeded")); if (result.outcome !== "rejected") throw new Error(`Expected a rejection, got ${result.outcome}`); diff --git a/apps/homepage/test/team-directory.test.ts b/apps/homepage/test/team-directory.test.ts index 47b0b70d0..bf4d5d22c 100644 --- a/apps/homepage/test/team-directory.test.ts +++ b/apps/homepage/test/team-directory.test.ts @@ -2,7 +2,7 @@ import { DepartmentJsonSchema, TeamApplicationIntakeListItem, TeamJsonSchema, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Schema } from "effect"; import { describe, expect, it } from "vitest"; import { contactDepartmentSlug } from "../src/lib/contact-message"; diff --git a/apps/homepage/vite.config.ts b/apps/homepage/vite.config.ts index f314888d5..98057a18e 100644 --- a/apps/homepage/vite.config.ts +++ b/apps/homepage/vite.config.ts @@ -86,7 +86,7 @@ export default defineConfig(({ command, isPreview }) => { preview: { allowedHosts: ["p000.vektor.phibkro.org"], }, - // Workspace packages such as the SDK resolve to source; the plugins append Vite's default conditions. + // No workspace export map uses this condition since the SDK was removed; the plugins append Vite's default conditions. resolve: { alias: { "~": "/src", diff --git a/apps/homepage/vitest.config.ts b/apps/homepage/vitest.config.ts index 0ec1b1dd3..443a87531 100644 --- a/apps/homepage/vitest.config.ts +++ b/apps/homepage/vitest.config.ts @@ -13,7 +13,7 @@ export default mergeConfig( "sha256:0000000000000000000000000000000000000000000000000000000000000000", ), }, - // Workspace packages such as the SDK resolve to source; Vitest appends its default conditions. + // No workspace export map uses this condition since the SDK was removed; Vitest appends its default conditions. resolve: { conditions: ["@vektorprogrammet/source"] }, ssr: { resolve: { conditions: ["@vektorprogrammet/source"] } }, test: { diff --git a/bun.lock b/bun.lock index 4ff55f2da..499d17537 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ "@effect/platform-bun": "catalog:effect-v4", "@vektorprogrammet/database": "workspace:*", "@vektorprogrammet/domain": "workspace:*", - "@vektorprogrammet/http-api": "workspace:*", + "@vektorprogrammet/rpc": "workspace:*", "effect": "catalog:effect-v4", }, "devDependencies": { @@ -63,8 +63,7 @@ "@react-router/serve": "^8.3.0", "@tailwindcss/vite": "^4.0.17", "@tanstack/react-table": "^8.21.2", - "@vektorprogrammet/http-api": "workspace:*", - "@vektorprogrammet/sdk": "workspace:*", + "@vektorprogrammet/rpc": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -72,8 +71,8 @@ "foldkit": "catalog:effect-v4", "isbot": "^5.1.25", "lucide-react": "^0.486.0", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "catalog:react", + "react-dom": "catalog:react", "react-router": "^8.3.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.17", @@ -89,14 +88,14 @@ "@playwright/test": "^1.51.1", "@react-router/dev": "^8.3.0", "@types/node": "^22.13.14", - "@types/react": "^19.2.10", - "@types/react-dom": "^19.2.3", + "@types/react": "catalog:react", + "@types/react-dom": "catalog:react", "@vektorprogrammet/backend": "workspace:*", "@vektorprogrammet/domain": "workspace:*", "happy-dom": "^20.12.0", "pg": "8", "typescript": "7.0.2", - "vite": "^8.0.7", + "vite": "catalog:react", "vitest": "catalog:effect-v4", }, }, @@ -104,7 +103,9 @@ "name": "@monoweb/docs", "version": "0.1.0", "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", "cn": "0.4.0", + "effect": "catalog:effect-v4", "mermaid": "11.17.2", "motion": "12.35.0", "react": "19.3.0", @@ -145,8 +146,7 @@ "@react-router/fs-routes": "^8.3.0", "@react-router/node": "^8.3.0", "@tailwindcss/vite": "^4.0.17", - "@vektorprogrammet/http-api": "workspace:*", - "@vektorprogrammet/sdk": "workspace:*", + "@vektorprogrammet/rpc": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -154,15 +154,15 @@ "isbot": "^5.1.25", "lucide-react": "^0.486.0", "motion": "^12.6.2", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "catalog:react", + "react-dom": "catalog:react", "react-router": "^8.3.0", "tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.17", "tailwindcss-animate": "^1.0.7", "typescript": "7.0.2", "vaul": "^1.1.2", - "vite": "^8.0.7", + "vite": "catalog:react", }, "devDependencies": { "@axe-core/playwright": "^4.10.1", @@ -171,8 +171,8 @@ "@playwright/test": "^1.51.1", "@react-router/dev": "^8.3.0", "@types/node": "^22.13.14", - "@types/react": "^19.2.10", - "@types/react-dom": "^19.2.3", + "@types/react": "catalog:react", + "@types/react-dom": "catalog:react", "vitest": "catalog:effect-v4", }, }, @@ -223,9 +223,9 @@ "vitest": "catalog:effect-v4", }, }, - "packages/http-api": { - "name": "@vektorprogrammet/http-api", - "version": "0.2.0", + "packages/rpc": { + "name": "@vektorprogrammet/rpc", + "version": "0.3.0", "dependencies": { "@vektorprogrammet/domain": "workspace:*", "effect": "catalog:effect-v4", @@ -238,24 +238,19 @@ "vitest": "catalog:effect-v4", }, }, - "packages/sdk": { - "name": "@vektorprogrammet/sdk", - "version": "0.2.0", + "tools/acceptance": { + "name": "@monoweb/acceptance", + "version": "0.1.0", "dependencies": { - "@vektorprogrammet/http-api": "workspace:*", + "@effect/platform-bun": "catalog:effect-v4", "effect": "catalog:effect-v4", }, - "devDependencies": { - "@effect/vitest": "catalog:effect-v4", - "@types/node": "^22", - "typescript": "7.0.2", - "vitest": "catalog:effect-v4", - }, }, "tools/conventions": { "name": "@monoweb/conventions", "version": "0.1.0", "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", "effect": "catalog:effect-v4", "rolldown": "1.2.6", }, @@ -273,7 +268,7 @@ "@vektorprogrammet/backend": "workspace:*", "@vektorprogrammet/database": "workspace:*", "@vektorprogrammet/domain": "workspace:*", - "@vektorprogrammet/http-api": "workspace:*", + "@vektorprogrammet/rpc": "workspace:*", "effect": "catalog:effect-v4", "mysql2": "^3.15.3", "pg": "8", @@ -296,6 +291,7 @@ "name": "@monoweb/placements-docs", "version": "0.1.0", "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", "effect": "catalog:effect-v4", }, "devDependencies": { @@ -308,9 +304,11 @@ "name": "@monoweb/postgres", "version": "0.1.0", "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", "effect": "catalog:effect-v4", }, "devDependencies": { + "@effect/vitest": "catalog:effect-v4", "@types/node": "^22", "typescript": "7.0.2", "vitest": "catalog:effect-v4", @@ -319,10 +317,18 @@ "tools/scripts": { "name": "@monoweb/scripts", "version": "0.1.0", + "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", + "effect": "catalog:effect-v4", + }, }, "tools/source-safety": { "name": "@monoweb/source-safety", "version": "0.1.0", + "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", + "effect": "catalog:effect-v4", + }, "devDependencies": { "@types/node": "^22", "typescript": "7.0.2", @@ -338,13 +344,13 @@ "@vektorprogrammet/backend": "workspace:*", "@vektorprogrammet/database": "workspace:*", "@vektorprogrammet/domain": "workspace:*", - "@vektorprogrammet/http-api": "workspace:*", - "@vektorprogrammet/sdk": "workspace:*", + "@vektorprogrammet/rpc": "workspace:*", "better-auth": "1.7.1", "effect": "catalog:effect-v4", "pg": "8", }, "devDependencies": { + "@effect/vitest": "catalog:effect-v4", "@types/node": "^22", "@types/pg": "^8.23.1", "typescript": "7.0.2", @@ -352,9 +358,6 @@ }, }, }, - "patchedDependencies": { - "effect@4.0.0-rc.116": "patches/effect@4.0.0-rc.116.patch", - }, "catalogs": { "effect-v4": { "@effect/platform-browser": "4.0.0-rc.116", @@ -369,6 +372,13 @@ "foldkit": "0.163.0", "vitest": "5.0.0", }, + "react": { + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "react": "19.2.8", + "react-dom": "19.2.8", + "vite": "8.2.2", + }, }, "packages": { "@antfu/install-pkg": ["@antfu/install-pkg@2.1.0", "", { "dependencies": { "package-manager-detector": "^1.8.0", "tinyexec": "^1.3.1" } }, "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw=="], @@ -755,6 +765,8 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.1", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA=="], + "@monoweb/acceptance": ["@monoweb/acceptance@workspace:tools/acceptance"], + "@monoweb/conventions": ["@monoweb/conventions@workspace:tools/conventions"], "@monoweb/dashboard": ["@monoweb/dashboard@workspace:apps/dashboard"], @@ -1451,9 +1463,7 @@ "@vektorprogrammet/domain": ["@vektorprogrammet/domain@workspace:packages/domain"], - "@vektorprogrammet/http-api": ["@vektorprogrammet/http-api@workspace:packages/http-api"], - - "@vektorprogrammet/sdk": ["@vektorprogrammet/sdk@workspace:packages/sdk"], + "@vektorprogrammet/rpc": ["@vektorprogrammet/rpc@workspace:packages/rpc"], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="], @@ -2461,7 +2471,7 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], @@ -2477,7 +2487,7 @@ "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], - "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], @@ -2973,8 +2983,6 @@ "@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@monoweb/dashboard/@types/node": ["@types/node@22.19.14", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-a39m4Z/qy3oYWP8Fc5RO674p/ENAB88JbwnmNwu6+hlfDTbqwE649936RqKNAXAOUwfggSVg6y2KwQcYBYaTsA=="], - "@monoweb/docs/@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], "@monoweb/docs/@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], @@ -2985,16 +2993,8 @@ "@monoweb/docs/vite": ["vite@8.3.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA=="], - "@monoweb/e2e/@types/node": ["@types/node@22.19.14", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-a39m4Z/qy3oYWP8Fc5RO674p/ENAB88JbwnmNwu6+hlfDTbqwE649936RqKNAXAOUwfggSVg6y2KwQcYBYaTsA=="], - - "@monoweb/homepage/@types/node": ["@types/node@22.19.14", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-a39m4Z/qy3oYWP8Fc5RO674p/ENAB88JbwnmNwu6+hlfDTbqwE649936RqKNAXAOUwfggSVg6y2KwQcYBYaTsA=="], - - "@monoweb/placements-docs/@types/node": ["@types/node@22.19.14", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-a39m4Z/qy3oYWP8Fc5RO674p/ENAB88JbwnmNwu6+hlfDTbqwE649936RqKNAXAOUwfggSVg6y2KwQcYBYaTsA=="], - "@monoweb/placements-docs/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "@monoweb/verification/@types/node": ["@types/node@22.19.14", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-a39m4Z/qy3oYWP8Fc5RO674p/ENAB88JbwnmNwu6+hlfDTbqwE649936RqKNAXAOUwfggSVg6y2KwQcYBYaTsA=="], - "@parcel/watcher/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -3023,8 +3023,6 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@rollup/pluginutils/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], - "@scalar/api-client/@scalar/snippetz": ["@scalar/snippetz@0.10.2", "", { "dependencies": { "@scalar/helpers": "0.15.0", "@scalar/types": "0.22.1", "js-base64": "^3.9.2" } }, "sha512-4lGQPu4/UtT5WcLjqJFVSqoZ3ba/pNN9lqe/zCbuPzscLDewrcz5yNBylbW71uGLACV6+NU24Yotv01z7wtJww=="], "@scalar/api-client/@scalar/workspace-store": ["@scalar/workspace-store@0.67.0", "", { "dependencies": { "@scalar/asyncapi-upgrader": "0.1.13", "@scalar/helpers": "0.15.0", "@scalar/json-magic": "0.15.2", "@scalar/openapi-upgrader": "0.4.0", "@scalar/schemas": "0.12.1", "@scalar/snippetz": "0.10.2", "@scalar/typebox": "0.1.3", "@scalar/types": "0.22.1", "@scalar/validation": "0.6.5", "js-base64": "^3.9.2", "type-fest": "^5.8.0", "vue": "^3.5.40", "yaml": "^2.9.0" } }, "sha512-dQl05foz0ZkRiJZ/BcZSKWDz9ZTlJQWclKRK7jG4kjE1x4W1xmspLdLIVuMdJkGLccpCAJq2/wK2Sh4k1lX9kQ=="], @@ -3105,8 +3103,6 @@ "@types/ws/@types/node": ["@types/node@22.19.14", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-a39m4Z/qy3oYWP8Fc5RO674p/ENAB88JbwnmNwu6+hlfDTbqwE649936RqKNAXAOUwfggSVg6y2KwQcYBYaTsA=="], - "@vektorprogrammet/sdk/@types/node": ["@types/node@22.19.14", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-a39m4Z/qy3oYWP8Fc5RO674p/ENAB88JbwnmNwu6+hlfDTbqwE649936RqKNAXAOUwfggSVg6y2KwQcYBYaTsA=="], - "@vitejs/plugin-rsc/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "@vitest/expect/@vitest/spy": ["@vitest/spy@4.1.0", "", {}, "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw=="], @@ -3119,6 +3115,8 @@ "@vue/compiler-sfc/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "@vue/compiler-sfc/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + "accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "babel-dead-code-elimination/@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], @@ -3235,19 +3233,19 @@ "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "tinyglobby/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "tsx/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "unplugin/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], - "unplugin-icons/@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], "unplugin-icons/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], - "vitest/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "vite/rolldown": ["rolldown@1.2.11", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.11", "@rolldown/binding-android-arm64": "1.2.11", "@rolldown/binding-darwin-arm64": "1.2.11", "@rolldown/binding-darwin-x64": "1.2.11", "@rolldown/binding-freebsd-x64": "1.2.11", "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", "@rolldown/binding-linux-arm64-gnu": "1.2.11", "@rolldown/binding-linux-arm64-musl": "1.2.11", "@rolldown/binding-linux-ppc64-gnu": "1.2.11", "@rolldown/binding-linux-s390x-gnu": "1.2.11", "@rolldown/binding-linux-x64-gnu": "1.2.11", "@rolldown/binding-linux-x64-musl": "1.2.11", "@rolldown/binding-openharmony-arm64": "1.2.11", "@rolldown/binding-win32-arm64-msvc": "1.2.11", "@rolldown/binding-win32-x64-msvc": "1.2.11" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw=="], - "vitest/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "vitest/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "vocs/@vitejs/plugin-rsc": ["@vitejs/plugin-rsc@0.5.35", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1", "es-module-lexer": "^2.3.2", "estree-walker": "^3.0.3", "magic-string": "^1.2.3", "obug": "^3.0.0", "srvx": "^0.12.7", "strip-literal": "^4.0.0", "turbo-stream": "^3.2.1", "vitefu": "^1.1.3" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-server-dom-webpack": "*", "vite": "*" }, "optionalPeers": ["react-server-dom-webpack"] }, "sha512-OZtJ57o5luSeNb2lqi3xeHHF41W8jCgm4nGb/1qmKjLb5gk/szIHS0Wxfo82WWQkKIqBBu6M697ohKWXrP0qqg=="], @@ -3307,10 +3305,6 @@ "@monoweb/docs/react-dom/scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], - "@monoweb/docs/vite/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], - - "@monoweb/docs/vite/postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], - "@monoweb/docs/vite/rolldown": ["rolldown@1.2.11", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.11", "@rolldown/binding-android-arm64": "1.2.11", "@rolldown/binding-darwin-arm64": "1.2.11", "@rolldown/binding-darwin-x64": "1.2.11", "@rolldown/binding-freebsd-x64": "1.2.11", "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", "@rolldown/binding-linux-arm64-gnu": "1.2.11", "@rolldown/binding-linux-arm64-musl": "1.2.11", "@rolldown/binding-linux-ppc64-gnu": "1.2.11", "@rolldown/binding-linux-s390x-gnu": "1.2.11", "@rolldown/binding-linux-x64-gnu": "1.2.11", "@rolldown/binding-linux-x64-musl": "1.2.11", "@rolldown/binding-openharmony-arm64": "1.2.11", "@rolldown/binding-win32-arm64-msvc": "1.2.11", "@rolldown/binding-win32-x64-msvc": "1.2.11" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw=="], "@scalar/api-client/@scalar/workspace-store/@scalar/json-magic": ["@scalar/json-magic@0.15.2", "", { "dependencies": { "@scalar/helpers": "0.15.0", "pathe": "^2.0.3", "undici": "7.29.0", "yaml": "^2.9.0" } }, "sha512-9mpHRQqy1m+6Rn8uFCzP+TUZRqZvfZhtPYYkv7zokZ2xRH40fd9TgkgOslq66cZ3tGqPTiaYU1zvwZsDrnVtEA=="], @@ -3425,6 +3419,8 @@ "better-auth/vitest/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "better-auth/vitest/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "better-auth/vitest/tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "better-auth/vitest/vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="], @@ -3525,7 +3521,37 @@ "unplugin-icons/@antfu/install-pkg/tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], - "unplugin-icons/unplugin/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.151.0", "", {}, "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA=="], + + "vite/rolldown/@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="], + + "vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="], + + "vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ=="], + + "vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w=="], + + "vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow=="], + + "vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.11", "", { "os": "linux", "cpu": "arm" }, "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw=="], + + "vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg=="], + + "vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw=="], + + "vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ=="], + + "vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A=="], + + "vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg=="], + + "vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.11", "", { "os": "none", "cpu": "arm64" }, "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA=="], + + "vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw=="], + + "vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA=="], "vocs/@vitejs/plugin-rsc/magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="], @@ -3545,10 +3571,6 @@ "waku/react-dom/scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], - "waku/vite/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], - - "waku/vite/postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], - "waku/vite/rolldown": ["rolldown@1.2.11", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.11", "@rolldown/binding-android-arm64": "1.2.11", "@rolldown/binding-darwin-arm64": "1.2.11", "@rolldown/binding-darwin-x64": "1.2.11", "@rolldown/binding-freebsd-x64": "1.2.11", "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", "@rolldown/binding-linux-arm64-gnu": "1.2.11", "@rolldown/binding-linux-arm64-musl": "1.2.11", "@rolldown/binding-linux-ppc64-gnu": "1.2.11", "@rolldown/binding-linux-s390x-gnu": "1.2.11", "@rolldown/binding-linux-x64-gnu": "1.2.11", "@rolldown/binding-linux-x64-musl": "1.2.11", "@rolldown/binding-openharmony-arm64": "1.2.11", "@rolldown/binding-win32-arm64-msvc": "1.2.11", "@rolldown/binding-win32-x64-msvc": "1.2.11" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw=="], "wrangler/miniflare/sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" } }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], @@ -3663,6 +3685,8 @@ "better-auth/vitest/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "better-auth/vitest/vite/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + "better-auth/vitest/vite/rolldown": ["rolldown@1.2.3", "", { "dependencies": { "@oxc-project/types": "=0.143.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.3", "@rolldown/binding-darwin-arm64": "1.2.3", "@rolldown/binding-darwin-x64": "1.2.3", "@rolldown/binding-freebsd-x64": "1.2.3", "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", "@rolldown/binding-linux-arm64-gnu": "1.2.3", "@rolldown/binding-linux-arm64-musl": "1.2.3", "@rolldown/binding-linux-ppc64-gnu": "1.2.3", "@rolldown/binding-linux-s390x-gnu": "1.2.3", "@rolldown/binding-linux-x64-gnu": "1.2.3", "@rolldown/binding-linux-x64-musl": "1.2.3", "@rolldown/binding-openharmony-arm64": "1.2.3", "@rolldown/binding-win32-arm64-msvc": "1.2.3", "@rolldown/binding-win32-x64-msvc": "1.2.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A=="], "body-parser/qs/side-channel/side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], diff --git a/docs/architecture.md b/docs/architecture.md index 9606eb857..767056f0a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Intended architecture -**Status:** Target architecture for the native replacement. Revised 2026-09-24. +**Status:** Target architecture for the native replacement. Revised 2026-09-28. See [system.md](system.md) for business meaning and [STATE.md](../STATE.md) for current implementation status. @@ -8,14 +8,14 @@ current implementation status. ## Product shape The target remains one modular backend, two browser applications, one PostgreSQL -database, and one generated client contract. +database, and one RPC contract that the backend serves and every client calls. ```text apps/homepage ---+ - +--> packages/sdk --> packages/http-api -apps/dashboard -+ | - +--> portable domain contracts -apps/backend --> domain services ----------+ + +--> RpcClient --> packages/rpc +apps/dashboard -+ | + +--> portable domain contracts +apps/backend --> domain services -----+ | +--> PostgreSQL adapters --> packages/database --> PostgreSQL @@ -44,9 +44,8 @@ system retires. | --------------------- | --------------------------------------------------------------------------------------------------------- | | `packages/domain` | Business values, state transitions, failures, capability requirements, and service contracts | | `packages/database` | Shared PostgreSQL schema and runtime; persistence adapters and service Layers for each context | -| `packages/http-api` | Public and internal HTTP groups, middleware declarations, schemas, and generated OpenAPI | -| `packages/sdk` | Generated consumer operations and boundary decoding | -| `apps/backend` | Native process composition, HTTP serving, delivery workers, and runtime configuration | +| `packages/rpc` | External and internal RPC groups, credential middlewares, AccessSpecs, problems, and the clients | +| `apps/backend` | Native process composition, RPC handlers, the HTTP ingress, delivery workers, and runtime configuration | | `apps/homepage` | Anonymous and public journeys | | `apps/dashboard` | Authenticated applicant, volunteer, coordinator, leader, and administrator journeys | | `tools/verification` | Cross-application PostgreSQL proofs, migration rehearsals, and their fixtures | @@ -62,11 +61,10 @@ This diagram defines architectural ownership, not the exact installed dependency Package manifests define that inventory. ```text -frontends -> packages/sdk, portable contracts -packages/http-api -> packages/domain -apps/backend -> service contracts and concrete runtime Layers +frontends -> packages/rpc, portable contracts +packages/rpc -> packages/domain +apps/backend -> packages/rpc, service contracts, and concrete runtime Layers packages/database -> packages/domain -packages/sdk -> generated HTTP contract packages/domain -> Effect and portable domain dependencies ``` @@ -75,24 +73,25 @@ Required rules: - `packages/domain` must not import database, HTTP, application, browser, provider, or migration-tool code. - Product packages must not import from `tools`. -- Frontends communicate through the generated SDK. They do not import backend or +- Frontends call the backend through the RPC groups of `packages/rpc`. They do not import backend or database implementation. - A service exists for a dependency, authority, replaceable policy, or owned lifecycle. A total local calculation stays a direct function. - Concrete runtimes and vendors belong in Layer implementations and composition roots. - The production database entry provides PostgreSQL only; PGlite and its extensions enter through `packages/database/src/test-support/platform.ts`, not through the backend or database barrel. -- Core Effect code, in `packages/domain`, `packages/database`, `packages/http-api`, and `apps/backend`, +- Effect code, in `packages`, `apps/backend`, `apps/docs`, and `tools`, reaches the clock, randomness, timers, the network, the environment, Node built-ins, and the console through Effect services, and uses Effect programs, tagged errors, and Schema instead of Promises, native errors, and `JSON`. - Oxlint rejects the platform forms there (the `effectNative` rules of the Effect language service); the other apps, packages, and tools are not Effect programs. + Oxlint reports every Effect language-service rule there as an error (operator decision, 2026-09-28); a journey driver or tool is an Effect program whose composition root provides the Bun platform. + `apps/homepage` and `apps/dashboard` run only the recommended and correctness rules. - Do not add a microservice until an observed operational need requires an independent deployment boundary. - Oxlint rejects the selected browser-to-database, product-to-proof, and cross-package source imports, including relative paths. A context's service owns complete commands, locks included. Its database Layer takes the context lock and holds it across the fresh read, transport precondition, domain decision, and writes in the caller transaction. -HTTP handlers resolve credentials, decode requests, enforce protocol preconditions, and store response receipts. +RPC handlers resolve credentials, enforce preconditions, and store command receipts; the RPC server decodes payloads. They call the service within the command transaction instead of sequencing locks and mutations. Business facts, audit, history, outbox work, and response receipts commit together. @@ -104,22 +103,21 @@ Package-local tests stay with their domain. Export maps expose supported entry p The same business contract crosses several interfaces. A route, schema, or table is not the business contract by itself. Each interface owns one kind of trust. -| Caller to receiver | Required contract | Hidden implementation | -| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| Human browser to native server | The generated external HTTP contract defines requests, responses, errors, cache rules, revisions, and idempotency. Protected operations resolve the actor and current scope; public flows use explicit capabilities. Better Auth owns its separate credential routes. | Browser state, page structure, PHP routes, and database rows. | -| Service to native server | A machine operation needs an explicit service identity, capability, resource scope, and credential policy. Denial and revocation must hold without a browser session. Internal ingress and network rules add isolation but do not grant business authority. | Provider tokens, transport plumbing, and the receiving handler. | -| Native server to domain service | A command names the actor, intended transition, expected revision, and observable failure. Queries read owned facts under the same authority. | HTTP envelopes and persistence models. | -| Domain service to PostgreSQL | The repository preserves constraints, isolation, compare-and-set revisions, immutable evidence, audit, and outbox work in one transaction. Rejected or competing commands leave no partial business state. | SQL layout, table names, and index choices. | -| Native server to provider | An asynchronous outbox envelope identifies one committed effect. Delivery can retry without repeating the business decision. Private-file reads prove custody and scope. | Mail, storage, SMS, and deployment vendors. | -| Migration source to native target | A selected read-only source snapshot, explicit mappings, row dispositions, immutable provenance, replay rules, and a fenced final delta establish native facts. Import never fabricates human decisions or sends historical notifications. | Legacy schema and transformation machinery. | - -The external HTTP root and generated SDK describe application operations, most -of them human-facing. A machine operation is not authorized by its location. -The internal HTTP root is separate from public OpenAPI. Its receipt-evidence route -currently accepts a scoped Person cookie; an internal path is not automatically -a service-principal API. OAuth introspection has its own isolated route. A -machine-facing operation must prove the service credential and grant through the -actual HTTP path, not only through a handler test. +| Caller to receiver | Required contract | Hidden implementation | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| Human browser to native server | The external RPC contract defines payloads, successes, problems, revisions, and idempotency. Protected operations resolve the actor and current scope; public flows use explicit capabilities. Better Auth owns its separate credential routes. | Browser state, page structure, PHP routes, and database rows. | +| Service to native server | A machine operation needs an explicit service identity, capability, resource scope, and credential policy. Denial and revocation must hold without a browser session. Internal ingress and network rules add isolation but do not grant business authority. | Provider tokens, transport plumbing, and the receiving handler. | +| Native server to domain service | A command names the actor, intended transition, expected revision, and observable failure. Queries read owned facts under the same authority. | RPC envelopes and persistence models. | +| Domain service to PostgreSQL | The repository preserves constraints, isolation, compare-and-set revisions, immutable evidence, audit, and outbox work in one transaction. Rejected or competing commands leave no partial business state. | SQL layout, table names, and index choices. | +| Native server to provider | An asynchronous outbox envelope identifies one committed effect. Delivery can retry without repeating the business decision. Private-file reads prove custody and scope. | Mail, storage, SMS, and deployment vendors. | +| Migration source to native target | A selected read-only source snapshot, explicit mappings, row dispositions, immutable provenance, replay rules, and a fenced final delta establish native facts. Import never fabricates human decisions or sends historical notifications. | Legacy schema and transformation machinery. | + +The external RPC endpoint serves application operations, most of them human-facing. +A machine operation is not authorized by its location. The internal RPC endpoint is a +separate ingress. Its receipt-evidence operation currently accepts a scoped Person +cookie; an internal endpoint is not automatically a service-principal API. OAuth +introspection has its own isolated route. A machine-facing operation must prove the +service credential and grant through the actual ingress, not only through a handler test. ## Domain services @@ -174,12 +172,11 @@ Adoption criteria and verification practices live in [AGENTS.md](../AGENTS.md#bo Use Effect Schema at every external or durable boundary: -- HTTP request and response; +- RPC payload, success, and problem; - environment and configuration; - database read and write; - provider payload; - private-file metadata; -- generated SDK response; - runtime message or receipt. Decode once at entry. Keep the encoded and decoded forms explicit when they differ. @@ -197,8 +194,9 @@ organization appointment, volunteer affiliation, placement, and authority are separate records. Authorization is relationship-based and time-aware. The domain owns capability -requirements. The database adapter reads current facts. The HTTP middleware calls -the interpreter before the handler executes. +requirements. The database adapter reads current facts. The RPC credential middleware +refuses a request without its credential, and the handler evaluates the RPC's AccessSpec +with the interpreter inside the transaction that reads or commits. ```text session -> principal -> scoped facts -> capability interpreter -> allow or deny @@ -303,15 +301,36 @@ and an attempt writes its outcome only while it holds the lease. Migration 0078 Private files require no-follow traversal, ownership checks, restricted permissions, and explicit lifecycle handling. A path string is not authority. -## HTTP and generated client - -`packages/http-api/src/api.ts` composes the public and internal native APIs. The -OpenAPI document and the SDK operation index are generated from that -contract by the package `generate` tasks, which Turbo runs before type checks and builds. - -A generated artifact must name its source and generator. Generation must be -repeatable. Git ignores generated artifacts; regenerate them instead of committing them. -Do not hand-edit a generated client or maintain a separate route list in documentation. +## RPC contract and ingress + +`packages/rpc/src/api.ts` composes the external (`NativeRpcs`, at `/api/rpc`) and internal +(`InternalNativeRpcs`, at `/internal/rpc`) RPC groups of `effect/unstable/rpc`. Each RPC's tag is +its operation ID, which command receipts store; its payload, success, and declared problems are +Schemas; its credential middleware and AccessSpec travel with it. The backend implements each +context's group with `toLayer`, and every client derives from the same group: `RpcClient` in Effect +code, `callNative` in the dashboard server, `callHomepageNative` in the homepage server, and +`nativeScriptClient` from `@vektorprogrammet/rpc/script` in journeys and probes. No client, route +list, or operation index is generated or kept by hand. + +The HTTP ingress in `apps/backend/src/router.ts` stays HTTP: trusted origins, CORS, and the OAuth +gatekeeping wrap the RPC endpoint, Better Auth serves `/api/auth/*`, and `GET /health` answers +probes. The ingress reads an RPC body within `nativeRpcMaxBodyBytes`, refuses duplicate JSON +members, keeps only the caller headers of an RPC message (a message header would otherwise +override the HTTP header of the same name), and marks every answer +`Cache-Control: private, no-store`. A problem is an RFC 9457 body in the RPC failure channel; +`ProblemBoundary` reports a defect and answers `internal.error`. + +RPC does not carry these facts of the former HTTP contract: + +| Lost | What a client does instead | +| ---------------------------------------------- | ---------------------------------------------------------------------------- | +| `If-None-Match`, 304, public `Cache-Control` | Re-reads the whole resource; no shared cache stores an RPC answer | +| `ETag` and `Location` headers, 201, 204 | Reads an entity tag that a later RPC takes as `ifMatch` from a success field | +| Per-operation body bounds | One ingress bound; each command still checks the sizes its domain bounds | +| Field `validation.failed` from schema decoding | Decodes with the contract schema before calling; the server answers a defect | +| `WWW-Authenticate` challenges | Reads `credential.missing` or `credential.invalid` | +| Multipart files | Sends bytes as a `Uint8Array` field, base64 on the JSON wire | +| OpenAPI | Third-party OAuth clients speak the Effect RPC wire protocol | ## Frontend state @@ -322,7 +341,7 @@ offline, mutation, and error states. Rules: - Components render model state and send events. -- Loaders and actions call the SDK. +- Loaders and actions call the RPC client through `callNative`. - Server authorization is never replaced by a client role check. - A preview role override changes presentation only. - Polling, cancellation, stale-response rejection, and cleanup belong to the Model @@ -373,12 +392,12 @@ For one permanent journey change: 2. Define the actor, authority, owned facts, transaction, effects, recovery, and observable outcome. 3. Change every required layer as one clean cutover. -4. Generate OpenAPI and SDK artifacts from the HTTP contract. -5. Exercise the real UI, HTTP, and PostgreSQL path. +4. Change the RPC contract, its handlers, and its callers together. +5. Exercise the real UI, RPC, and PostgreSQL path. 6. Exercise denial and any relevant replay, retry, stale write, or rollback path. 7. Remove disposable runtime assets and the completed contract. 8. Update [system.md](system.md), [operational-responsibility-map.md](operational-responsibility-map.md), and [STATE.md](../STATE.md) only when their facts changed. -A route, table, generated method, unit test, or static report is not proof of a +An RPC, table, unit test, or static report is not proof of a complete journey. diff --git a/docs/constructs.md b/docs/constructs.md index b12e25657..1f401443c 100644 --- a/docs/constructs.md +++ b/docs/constructs.md @@ -13,53 +13,48 @@ The JSDoc of a construct carries `@construct `, a summary sentence, `@ Tag a construct only when at least 2 modules outside its own module and the tests of its app or package import it. `just constructs` fails when these pages differ from the tags, when a construct lacks one of these tags or annotations, and when fewer modules import it. It lists untagged functions that 3 or more modules outside their app or package import. -- [http-transport](constructs/http-transport.md): Reads native HTTP requests and writes their representations: bounded JSON, preconditions, idempotency keys, entity tags, and cache headers. +- [http-transport](constructs/http-transport.md): Derives the transport facts that commands keep across the cutover from HTTP: command identities, request digests, preconditions, and entity tags. - [`encodePathIdentity`](constructs/http-transport.md#encodepathidentity): Encodes one decoded identity as an uppercase RFC 3986 path segment. - [`normalizeTarget`](constructs/http-transport.md#normalizetarget): Fills a route template with its encoded identities; a missing identity is a malformed request. - [`deriveHttpIdentity`](constructs/http-transport.md#derivehttpidentity): Derives the private storage digest and domain command ID from the identity tuple. -- [http-problem](constructs/http-problem.md): Answers a native HTTP request with a declared problem: failure mapping, credential classification, authorization, and decoding. - - [`authorizeAdmissionPerson`](constructs/http-problem.md#authorizeadmissionperson): Evaluates one admission person AccessSpec. - - [`returningAuthorization`](constructs/http-problem.md#returningauthorization): Resolves the current person and authorizes one returning-assistant operation on that person's own profile. - - [`admissionActorForAuthority`](constructs/http-problem.md#admissionactorforauthority): The admission actor of one department scope. - - [`admissionProblems`](constructs/http-problem.md#admissionproblems): The one answer for every admission failure. - - [`authorizeContentOperation`](constructs/http-problem.md#authorizecontentoperation): Evaluates a content endpoint's AccessSpec for one person with the content grant scope. - - [`contentProblems`](constructs/http-problem.md#contentproblems): The one answer for every content domain failure. - - [`contentActorProblems`](constructs/http-problem.md#contentactorproblems): A staff person rejected after ingress is answered from the credential the request presented. - - [`problemWebResponse`](constructs/http-problem.md#problemwebresponse): Renders one problem outside HttpApi encoding, with the encoder's body and headers. - - [`jsonText`](constructs/http-problem.md#jsontext): The JSON text of a representation, byte for byte what `JSON.stringify` writes. - - [`webHandler`](constructs/http-problem.md#webhandler): Runs one Effect-native Web transport operation. - - [`semanticProblem`](constructs/http-problem.md#semanticproblem): Runs a throwing semantic parser. - - [`problemMapper`](constructs/http-problem.md#problemmapper): Builds the one failure-to-problem mapper of a domain. - - [`requireNoQuery`](constructs/http-problem.md#requirenoquery): An operation that accepts no query answers any query as malformed. - - [`readJsonBody`](constructs/http-problem.md#readjsonbody): Reads a bounded JSON body of the one media type `mediaType` accepts. - - [`idempotencyKeyOf`](constructs/http-problem.md#idempotencykeyof): Decodes the one Idempotency-Key a replayable mutation requires. - - [`requiredIfMatchOf`](constructs/http-problem.md#requiredifmatchof): Decodes the one strong If-Match an item mutation requires. - - [`httpIdentity`](constructs/http-problem.md#httpidentity): Derives a command's idempotency identity; a tuple outside the frozen grammar is a request problem. - - [`requireCurrentETag`](constructs/http-problem.md#requirecurrentetag): Fails a mutation whose If-Match no longer names the current representation. - - [`conditionalJson`](constructs/http-problem.md#conditionaljson): Answers a conditional JSON read after authority and concealment: the representation, a bodyless 304, or precondition.failed. - - [`personPresentation`](constructs/http-problem.md#personpresentation): The person credential a request presented, for a rejection answered after ingress. - - [`isSerializationConflict`](constructs/http-problem.md#isserializationconflict): Whether a failure, or one of its causes, is a lost serialization or deadlock race: a transaction.conflict the client may retry. - - [`requestInvalid`](constructs/http-problem.md#requestinvalid): The request as a whole fails validation; no single member is singled out. - - [`decodeRequest`](constructs/http-problem.md#decoderequest): Decodes one JSON request value strictly; any mismatch fails the whole request's validation. - - [`strictOutput`](constructs/http-problem.md#strictoutput): Decodes one response value strictly. - - [`commandReceiptProblems`](constructs/http-problem.md#commandreceiptproblems): HTTP command receipts: the transport's own persistence failures. - - [`commandOutcomeResponse`](constructs/http-problem.md#commandoutcomeresponse): Answers a command receipt outcome: committed and replayed results, or an idempotency problem. - - [`authorizeAnonymous`](constructs/http-problem.md#authorizeanonymous): An anonymous AccessSpec grants every caller and conceals nothing, so a denial means the spec and its scope resolution disagree: a defect. - - [`authorizePerson`](constructs/http-problem.md#authorizeperson): A rejected person credential is answered from the ingress evidence, never by string choice. - - [`unreachable`](constructs/http-problem.md#unreachable): Marks problems a shared mapper can produce but this operation cannot, such as a serialization conflict inside a read-only snapshot. - - [`ProblemBoundaryLive`](constructs/http-problem.md#problemboundarylive): The only Cause consumer. - - [`receiptProblems`](constructs/http-problem.md#receiptproblems): The one answer for every receipt failure other than a rejected credential, including an unavailable store, Identity, or E2E barrier. - - [`receiptCredentialProblems`](constructs/http-problem.md#receiptcredentialproblems): A credential rejected inside a receipt handler is answered from the request's own evidence. - - [`authorizeInvitationOperation`](constructs/http-problem.md#authorizeinvitationoperation): Authorizes the holder of an invitation's response capability. - - [`interviewAuthorizationInTransaction`](constructs/http-problem.md#interviewauthorizationintransaction): Resolves the current person and authorizes one interview inside the caller's transaction; a rejected credential is answered from the request's evidence. - - [`readRecruitmentBody`](constructs/http-problem.md#readrecruitmentbody): Every recruitment request body is one bounded `application/json` document. - - [`recruitmentProblems`](constructs/http-problem.md#recruitmentproblems): The one answer for every recruitment failure. - - [`raceProblems`](constructs/http-problem.md#raceproblems): A failure that lost a serialization or deadlock race answers transaction.conflict, whatever failure carried it. - - [`problemUnion`](constructs/http-problem.md#problemunion): Creates a closed endpoint-specific Problem Details union. - - [`Problem`](constructs/http-problem.md#problem): One RFC 9457 failure in an Effect error channel. - - [`isProblem`](constructs/http-problem.md#isproblem): Narrows a caught value to a `Problem`, also one that another copy of this module created. - - [`problemBody`](constructs/http-problem.md#problembody): The frozen RFC 9457 body: the registry entry, then code, instance, and validation. - - [`makeNativeProblem`](constructs/http-problem.md#makenativeproblem): Builds one safe fixed public problem value. +- [authority-evidence](constructs/authority-evidence.md): Checks a resolved authority and returns the evidence that a command which needs that authority takes; nothing else constructs it. + - [`requireDepartmentReach`](constructs/authority-evidence.md#requiredepartmentreach): Checks that a resolved authority reaches one department with a capability, and returns the evidence that a department-scoped command requires. + - [`requireOrganizationAdministrator`](constructs/authority-evidence.md#requireorganizationadministrator): Checks that a resolved authority holds active global administration, and returns the evidence that the Organization administration commands require. + - [`requireTeamInterestScope`](constructs/authority-evidence.md#requireteaminterestscope): Checks what team interest a resolved authority may read, narrowed to one requested department, and returns the scope that the listing requires. +- [rpc-transport](constructs/rpc-transport.md): Addresses the native RPC endpoints over HTTP, as the ingress serves them and every recorder matches them. + - [`isNativeRpcPath`](constructs/rpc-transport.md#isnativerpcpath): Whether a request path addresses the native RPC endpoint. +- [rpc-problem](constructs/rpc-problem.md): Answers a native RPC with a declared problem: failure mapping, credential classification, authorization, command outcomes, and the defect boundary. + - [`authorizeAdmissionPerson`](constructs/rpc-problem.md#authorizeadmissionperson): Evaluates one admission person AccessSpec. + - [`returningAuthorization`](constructs/rpc-problem.md#returningauthorization): Resolves the current person and authorizes one returning-assistant operation on that person's own profile. + - [`admissionActorForAuthority`](constructs/rpc-problem.md#admissionactorforauthority): The admission actor of one department scope. + - [`admissionProblems`](constructs/rpc-problem.md#admissionproblems): The one answer for every admission failure. + - [`authorizeContentOperation`](constructs/rpc-problem.md#authorizecontentoperation): Evaluates a content RPC's AccessSpec for one person with the content grant scope. + - [`contentProblems`](constructs/rpc-problem.md#contentproblems): The one answer for every content domain failure. + - [`contentActorProblems`](constructs/rpc-problem.md#contentactorproblems): A staff person rejected after the credential middleware is answered from the credential the request presented. + - [`receiptProblems`](constructs/rpc-problem.md#receiptproblems): The one answer for every receipt failure other than a rejected credential, including an unavailable store, Identity, or E2E barrier. + - [`receiptCredentialProblems`](constructs/rpc-problem.md#receiptcredentialproblems): A credential rejected inside a receipt handler is answered from the request's own evidence. + - [`authorizeInvitationOperation`](constructs/rpc-problem.md#authorizeinvitationoperation): Authorizes the holder of an invitation's response capability. + - [`interviewAuthorizationInTransaction`](constructs/rpc-problem.md#interviewauthorizationintransaction): Resolves the current person and authorizes one interview inside the caller's transaction; a rejected credential is answered from the request's evidence. + - [`recruitmentProblems`](constructs/rpc-problem.md#recruitmentproblems): The one answer for every recruitment failure. + - [`raceProblems`](constructs/rpc-problem.md#raceproblems): A failure that lost a serialization or deadlock race answers transaction.conflict, whatever failure carried it. + - [`jsonText`](constructs/rpc-problem.md#jsontext): The JSON text of a representation, byte for byte what `JSON.stringify` writes. + - [`problemMapper`](constructs/rpc-problem.md#problemmapper): Builds the one failure-to-problem mapper of a domain. + - [`commandIdentity`](constructs/rpc-problem.md#commandidentity): Derives a command's idempotency identity: the receipt digest and the domain command ID. + - [`requireCurrentETag`](constructs/rpc-problem.md#requirecurrentetag): Fails a mutation whose If-Match no longer names the current representation. + - [`isSerializationConflict`](constructs/rpc-problem.md#isserializationconflict): Whether a failure, or one of its causes, is a lost serialization or deadlock race: a transaction.conflict the client may retry. + - [`requestInvalid`](constructs/rpc-problem.md#requestinvalid): The request as a whole fails validation; no single member is singled out. + - [`strictOutput`](constructs/rpc-problem.md#strictoutput): Decodes one response value strictly. + - [`commandReceiptProblems`](constructs/rpc-problem.md#commandreceiptproblems): Command receipts: the transport's own persistence failures. + - [`personPresentation`](constructs/rpc-problem.md#personpresentation): The credential evidence of an RPC request: which credential headers it presented. + - [`commandOutcome`](constructs/rpc-problem.md#commandoutcome): The value of a command receipt outcome: the committed or replayed success, or an idempotency problem. + - [`authorizeAnonymous`](constructs/rpc-problem.md#authorizeanonymous): An anonymous AccessSpec grants every caller and conceals nothing, so a denial means the spec and its scope resolution disagree: a defect. + - [`authorizePerson`](constructs/rpc-problem.md#authorizeperson): A rejected person credential is answered from the ingress evidence, never by string choice. + - [`unreachable`](constructs/rpc-problem.md#unreachable): Marks problems a shared mapper can produce but this operation cannot, such as a serialization conflict inside a read-only snapshot. + - [`problemUnion`](constructs/rpc-problem.md#problemunion): Creates the closed Problem Details union of one RPC. + - [`Problem`](constructs/rpc-problem.md#problem): One RFC 9457 failure in an Effect error channel. + - [`isProblem`](constructs/rpc-problem.md#isproblem): Narrows a caught value to a `Problem`, also one that another copy of this module created. + - [`problemBody`](constructs/rpc-problem.md#problembody): The frozen RFC 9457 body: the registry entry, then code, instance, and validation. + - [`makeNativeProblem`](constructs/rpc-problem.md#makenativeproblem): Builds one safe fixed public problem value. - [sql-lock](constructs/sql-lock.md): Transaction-scoped PostgreSQL advisory locks under registered keys. - [`AdvisoryLockKey`](constructs/sql-lock.md#advisorylockkey): The registered advisory-lock keys, one constructor per namespace. - [`lockAdvisory`](constructs/sql-lock.md#lockadvisory): Waits for the advisory lock on `key` until the current transaction ends. @@ -96,6 +91,8 @@ Tag a construct only when at least 2 modules outside its own module and the test - [`reserveLoopbackPorts`](constructs/test-harness.md#reserveloopbackports): Reserves `count` distinct loopback ports for the servers that a journey starts: its backend, dashboard, receivers, and clusters. - [`startDisposablePostgres`](constructs/test-harness.md#startdisposablepostgres): Starts a fresh cluster of the selected major on a private port and socket directory with trust authentication. - [`withDisposablePostgres`](constructs/test-harness.md#withdisposablepostgres): Runs `use` against the database `database` of a fresh cluster, which `startDisposablePostgres` starts, and removes the cluster when `use` settles, also when it fails. +- [browser-audit](constructs/browser-audit.md): Audits the pages that browser journeys render once they have settled: accessibility with axe. + - [`auditSettledPage`](constructs/browser-audit.md#auditsettledpage): Audits a page with axe once every finite animation on it has finished. - [request-ledger](constructs/request-ledger.md): Classifies the requests that journey recorders observe by whole path segments: native contract operations and legacy routes. - - [`isNativeRequest`](constructs/request-ledger.md#isnativerequest): Whether a dashboard-to-backend request stays on the native surface: an operation of the native HTTP contract or an email-password route of the identity engine. + - [`isNativeRequest`](constructs/request-ledger.md#isnativerequest): Whether a dashboard-to-backend request stays on the native surface: an operation of the native RPC contract or an email-password route of the identity engine. - [`addressesAnyRoute`](constructs/request-ledger.md#addressesanyroute): Whether a request path addresses any of the routes, each matched by whole path segments. diff --git a/docs/constructs/authority-evidence.md b/docs/constructs/authority-evidence.md new file mode 100644 index 000000000..1c9d3f65f --- /dev/null +++ b/docs/constructs/authority-evidence.md @@ -0,0 +1,134 @@ +# authority-evidence + +[//]: # "constructs: generated from the @construct tags and their JSDoc by just constructs write; do not edit" + +Checks a resolved authority and returns the evidence that a command which needs that authority takes; nothing else constructs it. The [index](../constructs.md) lists every category. + +## `requireDepartmentReach` + +Checks that a resolved authority reaches one department with a capability, and returns the evidence that a department-scoped command requires. + +```ts +requireDepartmentReach( + capability: C, + departmentId: DepartmentId +): (authority: OrganizationPersonAuthority) => Result.Result, DepartmentReachDenied> +requireDepartmentReach( + authority: OrganizationPersonAuthority, + capability: C, + departmentId: DepartmentId +): Result.Result, DepartmentReachDenied> +``` + +- Inputs: + - `authority: OrganizationPersonAuthority` + - `capability: C` + - `departmentId: DepartmentId` +- Output: `Result.Result, DepartmentReachDenied>` +- Errors: `DepartmentReachDenied` +- Requirements: none +- Side effects: none +- Source: [packages/domain/src/authz/reach.ts:234](../../packages/domain/src/authz/reach.ts#L234) + +**How it works** + +It is the only constructor of `DepartmentReach` and decides exactly as `reaches` +with a department target: a board leadership of the independent department, a national board +leadership, a delegation over the department or the organization, or an active global +administrator where the capability admits one. A team leadership never reaches a department. + +**Use** + +```ts +const coordinator = yield* Effect.fromResult( + requireDepartmentReach(authority, "placements.coordinate", departmentId), +); +yield* placements.execute({ mutation, coordinator, now, commandId }); +``` + +**Avoid** + +Checking `reaches` at the call site and passing a department and a person to the +command: the command then trusts that some caller checked, for that department. Require the +evidence, and take the department from it. + +## `requireOrganizationAdministrator` + +Checks that a resolved authority holds active global administration, and returns the evidence that the Organization administration commands require. + +```ts +requireOrganizationAdministrator( + authority: OrganizationPersonAuthority +): Result.Result +``` + +- Inputs: `authority: OrganizationPersonAuthority` +- Output: `Result.Result` +- Errors: `OrganizationRoleDenied` +- Requirements: none +- Side effects: none +- Source: [packages/domain/src/organization/authority.ts:285](../../packages/domain/src/organization/authority.ts#L285) + +**How it works** + +It is the only constructor of `OrganizationAdministratorEvidence`. An active global +administrator yields evidence whose actor names the same person; an inactive or absent grant +yields `OrganizationRoleDenied` for that person. It reads only `globalAdministrator` and +`personId`: memberships, board seats, and delegations confer no organization administration. + +**Use** + +```ts +const administrator = yield* Effect.fromResult(requireOrganizationAdministrator(authority)); +yield* organization.createDepartment(command, administrator); +``` + +**Avoid** + +Passing an `OrganizationActor` to a create command, or checking its tag at the call site: +any module can build an actor, so the command could not trust it. Resolve the authority and +require the evidence here. + +## `requireTeamInterestScope` + +Checks what team interest a resolved authority may read, narrowed to one requested department, and returns the scope that the listing requires. + +```ts +requireTeamInterestScope( + input: { readonly requested: DepartmentId | undefined; readonly departments: ReadonlyArray } +): (authority: OrganizationPersonAuthority) => Result.Result +requireTeamInterestScope( + authority: OrganizationPersonAuthority, + input: { readonly requested: DepartmentId | undefined; readonly departments: ReadonlyArray } +): Result.Result +``` + +- Inputs: + - `authority: OrganizationPersonAuthority` + - `input: { readonly requested: DepartmentId | undefined; readonly departments: ReadonlyArray }` +- Output: `Result.Result` +- Errors: `TeamInterestScopeDenied` +- Requirements: none +- Side effects: none +- Source: [packages/domain/src/organization/authority.ts:380](../../packages/domain/src/organization/authority.ts#L380) + +**How it works** + +It is the only constructor of `TeamInterestReadScope`. A reach over the organization +reads every department in `departments`, also while there is none; a department reach reads +that department; a team leader's reach reads the team where no department reach covers it. A +person with no reach, or with none in the requested department or its teams, is denied. + +**Use** + +```ts +const scope = yield* Effect.fromResult( + requireTeamInterestScope(authority, { requested, departments }), +); +yield* organization.listTeamInterestRegistrations(scope, semesterId); +``` + +**Avoid** + +Computing the departments and teams in a handler and passing them to the listing as a +filter: the listing then reads whatever scope a caller names. Require the scope here. diff --git a/docs/constructs/browser-audit.md b/docs/constructs/browser-audit.md new file mode 100644 index 000000000..c081ad5fe --- /dev/null +++ b/docs/constructs/browser-audit.md @@ -0,0 +1,48 @@ +# browser-audit + +[//]: # "constructs: generated from the @construct tags and their JSDoc by just constructs write; do not edit" + +Audits the pages that browser journeys render once they have settled: accessibility with axe. The [index](../constructs.md) lists every category. + +## `auditSettledPage` + +Audits a page with axe once every finite animation on it has finished. + +```ts +auditSettledPage( + page: Page, + options: SettledAxeOptions = {} +): Promise> +``` + +- Inputs: + - `page: Page` + - `options: SettledAxeOptions = {}` +- Output: `Promise>` +- Errors: none +- Requirements: none +- Side effects: Evaluates a script in the page, and injects and runs axe in each of its frames. +- Source: [apps/dashboard/e2e/settled-axe.ts:82](../../apps/dashboard/e2e/settled-axe.ts#L82) + +**How it works** + +It waits in the page for every running animation and transition whose end is finite +(`document.getAnimations()`, each one's `finished`), then runs axe with the caller's tags and +regions, and returns each violation with its rule, impact, and the targets that fail with axe's +failure summary. It returns no markup, so no value that a field holds reaches a log or the +evidence. An animation that repeats forever, such as a spinner, never finishes, so the wait skips +it; an animation that is cancelled while it waits counts as settled. The caller keeps its own +assertion: an exact empty list, or no violation of serious or critical impact. + +**Use** + +```ts +const violations = await auditSettledPage(page, { tags: ["wcag2a", "wcag2aa"] }); +expect(violations).toEqual([]); +``` + +**Avoid** + +Calling `new AxeBuilder({ page }).analyze()` right after an interaction: a transition +that still runs reads as its midpoint colours, and the audit fails at random (the onboarding +and golden reimbursement runs of 2026-09-28). `anti-slop/no-unsettled-axe` rejects it. diff --git a/docs/constructs/delivery.md b/docs/constructs/delivery.md index 246d5fe0a..dcb28767f 100644 --- a/docs/constructs/delivery.md +++ b/docs/constructs/delivery.md @@ -12,19 +12,23 @@ Shared acknowledged JSON transport; deliberately no retry on ambiguous acceptanc deliverJson( body: Schema.Json, config: HttpDeliveryConfig, - headers: Readonly> = {} + headers?: Readonly> ): Effect.Effect +deliverJson( + config: HttpDeliveryConfig, + headers?: Readonly> +): (body: Schema.Json) => Effect.Effect ``` - Inputs: - `body: Schema.Json` - `config: HttpDeliveryConfig` - - `headers: Readonly> = {}` + - `headers?: Readonly>` - Output: `Effect.Effect` - Errors: `HttpDeliveryFailure | Cause.TimeoutError` - Requirements: `HttpClient.HttpClient` - Side effects: One HTTP POST to `config.endpoint`; the timeout waits on the Effect clock. -- Source: [apps/backend/src/delivery/http.ts:56](../../apps/backend/src/delivery/http.ts#L56) +- Source: [apps/backend/src/delivery/http.ts:57](../../apps/backend/src/delivery/http.ts#L57) **How it works** diff --git a/docs/constructs/http-transport.md b/docs/constructs/http-transport.md index 190b30ec7..fbc064641 100644 --- a/docs/constructs/http-transport.md +++ b/docs/constructs/http-transport.md @@ -2,7 +2,7 @@ [//]: # "constructs: generated from the @construct tags and their JSDoc by just constructs write; do not edit" -Reads native HTTP requests and writes their representations: bounded JSON, preconditions, idempotency keys, entity tags, and cache headers. The [index](../constructs.md) lists every category. +Derives the transport facts that commands keep across the cutover from HTTP: command identities, request digests, preconditions, and entity tags. The [index](../constructs.md) lists every category. ## `encodePathIdentity` @@ -18,7 +18,7 @@ encodePathIdentity(identity: string): string - Throws: The `Problem` request.malformed when `identity` is not a string of Unicode scalar values, such as one with a lone surrogate. - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-semantics.ts:381](../../apps/backend/src/http-semantics.ts#L381) +- Source: [apps/backend/src/http-semantics.ts:393](../../apps/backend/src/http-semantics.ts#L393) **How it works** @@ -44,6 +44,7 @@ identity with this. Fills a route template with its encoded identities; a missing identity is a malformed request. ```ts +normalizeTarget(identities: Readonly>): (routeTemplate: string) => string normalizeTarget(routeTemplate: string, identities: Readonly>): string ``` @@ -55,7 +56,7 @@ normalizeTarget(routeTemplate: string, identities: Readonly = A & { readonly cursorTimestamp: string } - Errors: none - Requirements: none - Side effects: none -- Source: [packages/database/src/receipt/cursor.ts:38](../../packages/database/src/receipt/cursor.ts#L38) +- Source: [packages/database/src/receipt/cursor.ts:39](../../packages/database/src/receipt/cursor.ts#L39) **How it works** @@ -42,6 +42,7 @@ Declare the row as `CursorPositioned` and select its text with `receiptCurs Selects the ordering column as microsecond UTC text so cursor positions compare exactly. ```ts +receiptCursorTimestamp(column: Statement.Fragment): (sql: DatabaseOperations) => Statement.Fragment receiptCursorTimestamp(sql: DatabaseOperations, column: Statement.Fragment): Statement.Fragment ``` @@ -52,7 +53,7 @@ receiptCursorTimestamp(sql: DatabaseOperations, column: Statement.Fragment): Sta - Errors: none - Requirements: none - Side effects: none: it builds a fragment, and the statement that embeds it reads the rows. -- Source: [packages/database/src/receipt/cursor.ts:62](../../packages/database/src/receipt/cursor.ts#L62) +- Source: [packages/database/src/receipt/cursor.ts:63](../../packages/database/src/receipt/cursor.ts#L63) **How it works** @@ -88,7 +89,7 @@ receiptCursorPage
!isNativeRequest(method, pathname)); +proxy.records.filter(({ method, pathname, body }) => !isNativeRequest(method, pathname, body)); ``` **Avoid** diff --git a/docs/constructs/http-problem.md b/docs/constructs/rpc-problem.md similarity index 54% rename from docs/constructs/http-problem.md rename to docs/constructs/rpc-problem.md index 099d1eee3..a71022f5f 100644 --- a/docs/constructs/http-problem.md +++ b/docs/constructs/rpc-problem.md @@ -1,8 +1,8 @@ -# http-problem +# rpc-problem [//]: # "constructs: generated from the @construct tags and their JSDoc by just constructs write; do not edit" -Answers a native HTTP request with a declared problem: failure mapping, credential classification, authorization, and decoding. The [index](../constructs.md) lists every category. +Answers a native RPC with a declared problem: failure mapping, credential classification, authorization, command outcomes, and the defect boundary. The [index](../constructs.md) lists every category. ## `authorizeAdmissionPerson` @@ -10,36 +10,39 @@ Evaluates one admission person AccessSpec. ```ts authorizeAdmissionPerson( - request: Request, + input: NativePersonAuthorization +): (headers: Headers.Headers) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">> +authorizeAdmissionPerson( + headers: Headers.Headers, input: NativePersonAuthorization ): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">> ``` - Inputs: - - `request: Request` + - `headers: Headers.Headers` - `input: NativePersonAuthorization` - Output: `Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">>` - Errors: `Problem<"authority.denied"> | Problem<"credential.invalid"> | Problem<"credential.missing">` - Requirements: none - Side effects: none -- Source: [apps/backend/src/admission/http-access.ts:65](../../apps/backend/src/admission/http-access.ts#L65) +- Source: [apps/backend/src/admission/access.ts:68](../../apps/backend/src/admission/access.ts#L68) **How it works** -It runs `authorizePerson` with the credential that the request presented, then +It runs `authorizePerson` with the credential headers that the request presented, then `unreachable("resource.not-found")`: every admission AccessSpec reveals its denials, so -authorization never answers 404, and a concealment would be a defect. +authorization never answers resource.not-found, and a concealment would be a defect. **Use** ```ts -yield* authorizeAdmissionPerson(request, { spec, credential, personId, resolution, grantScopes, now }); +yield* authorizeAdmissionPerson(headers, { spec, credential, personId, resolution, grantScopes, now }); ``` **Avoid** Calling `authorizePerson` in an admission handler and declaring resource.not-found on the -endpoint: admission specs reveal their denials, so that problem never occurs. Call this. +RPC: admission specs reveal their denials, so that problem never occurs. Call this. ## `returningAuthorization` @@ -47,33 +50,37 @@ Resolves the current person and authorizes one returning-assistant operation on ```ts returningAuthorization( - request: Request, - input: AdmissionApiHttpOptions, - endpoint: | typeof ReadReturningAssistantOptionsEndpoint | typeof RegisterReturningAssistantEndpoint + options: NativeRpcOptions, + rpc: typeof ReadReturningAssistantOptions | typeof RegisterReturningAssistant +): (headers: Headers.Headers) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">, Database | Organization | IdentitySnapshot | OAuthCredentialAuthority> +returningAuthorization( + headers: Headers.Headers, + options: NativeRpcOptions, + rpc: typeof ReadReturningAssistantOptions | typeof RegisterReturningAssistant ): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">, Database | Organization | IdentitySnapshot | OAuthCredentialAuthority> ``` - Inputs: - - `request: Request` - - `input: AdmissionApiHttpOptions` - - `endpoint: | typeof ReadReturningAssistantOptionsEndpoint | typeof RegisterReturningAssistantEndpoint` + - `headers: Headers.Headers` + - `options: NativeRpcOptions` + - `rpc: typeof ReadReturningAssistantOptions | typeof RegisterReturningAssistant` - Output: `Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">, Database | Organization | IdentitySnapshot | OAuthCredentialAuthority>` - Errors: `| IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError | Problem<"authority.denied"> | Problem<"credential.invalid"> | Problem<"credential.missing">` - Requirements: `Database | Organization | IdentitySnapshot | OAuthCredentialAuthority` - Side effects: Reads the person's session or token and organization authority in the caller's transaction. -- Source: [apps/backend/src/admission/http-access.ts:96](../../apps/backend/src/admission/http-access.ts#L96) +- Source: [apps/backend/src/admission/access.ts:118](../../apps/backend/src/admission/access.ts#L118) **How it works** It resolves the request's person credential and organization authority in the caller's -transaction at one instant, then evaluates the endpoint's AccessSpec for that person over their -own `person-profile` resource, with the resource as the grant scope. It answers the resolved +transaction at one instant, then evaluates the RPC's AccessSpec for that person over their own +`person-profile` resource, with the resource as the grant scope. It answers the resolved authority, whose instant the command uses for its own reads. **Use** ```ts -const authorization = yield* returningAuthorization(request, input, RegisterReturningAssistantEndpoint); +const authorization = yield* returningAuthorization(headers, options, RegisterReturningAssistant); ``` **Avoid** @@ -90,6 +97,9 @@ admissionActorForAuthority( authority: OrganizationPersonAuthority, departmentScope?: string ): Effect.Effect +admissionActorForAuthority( + departmentScope?: string +): (authority: OrganizationPersonAuthority) => Effect.Effect ``` - Inputs: @@ -99,7 +109,7 @@ admissionActorForAuthority( - Errors: `AdmissionRoleDenied | AdmissionScopeDenied | InactiveActor` - Requirements: none - Side effects: none -- Source: [apps/backend/src/admission/http-context.ts:76](../../apps/backend/src/admission/http-context.ts#L76) +- Source: [apps/backend/src/admission/context.ts:46](../../apps/backend/src/admission/context.ts#L46) **How it works** @@ -111,7 +121,7 @@ defect. **Use** ```ts -const actor = yield* admissionActorForAuthority(authorization.authority, payload.departmentId); +const actor = yield* admissionActorForAuthority(authorization.authority, request.departmentId); ``` **Avoid** @@ -125,74 +135,77 @@ The one answer for every admission failure. ```ts admissionProblems( - request: Request, + unavailable: Unavailable +): (headers: Headers.Headers) => ProblemMapper> +admissionProblems( + headers: Headers.Headers, unavailable: Unavailable ): ProblemMapper> ``` - Inputs: - - `request: Request` + - `headers: Headers.Headers` - `unavailable: Unavailable` - Output: `ProblemMapper>` - Errors: none - Requirements: none - Side effects: none -- Source: [apps/backend/src/admission/http-problem.ts:125](../../apps/backend/src/admission/http-problem.ts#L125) +- Source: [apps/backend/src/admission/problem.ts:127](../../apps/backend/src/admission/problem.ts#L127) **How it works** -A failed store or dependency answers the endpoint's own unavailable problem, `unavailable`: a -read answers admissions.unavailable, a command dependency.unavailable, and a returning-assistant -operation returning.unavailable. A rejected credential answers from the credential that the -request presented. A command that does not decode answers validation.failed at the root, and an -unknown department or semester answers validation.failed at its member. +A failed store or dependency answers the RPC's own unavailable problem, `unavailable`: a read +answers admissions.unavailable, a command dependency.unavailable, and a returning-assistant +operation returning.unavailable. A rejected credential answers from the credential headers that +the request presented. A command that the domain does not decode answers validation.failed at +the root, and an unknown department or semester answers validation.failed at its member. **Use** ```ts -command.pipe(admissionProblems(request, "dependency.unavailable")); +command.pipe(admissionProblems(headers, "dependency.unavailable")); ``` **Avoid** -Mapping an admission failure in a handler: the answers of the admission endpoints drift -apart. Pipe the handler's effect through this, with the endpoint's unavailable problem. +Mapping an admission failure in a handler: the answers of the admission RPCs drift +apart. Pipe the handler's effect through this, with the RPC's unavailable problem. ## `authorizeContentOperation` -Evaluates a content endpoint's AccessSpec for one person with the content grant scope. +Evaluates a content RPC's AccessSpec for one person with the content grant scope. ```ts authorizeContentOperation( - input: { readonly endpoint: ContentEndpoint; readonly personId: PersonId; readonly authorizationInstant: string; readonly credential?: Extract; readonly request?: Request; readonly resolution: CanonicalScopeResolution; readonly presentation: CredentialPresentation } + input: { readonly rpc: Pick; readonly personId: PersonId; readonly authorizationInstant: string; readonly credential?: Extract; readonly request?: Request; readonly resolution: CanonicalScopeResolution; readonly presentation: CredentialPresentation } ): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">> ``` -- Inputs: `input: { readonly endpoint: ContentEndpoint; readonly personId: PersonId; readonly authorizationInstant: string; readonly credential?: Extract; readonly request?: Request; readonly resolution: CanonicalScopeResolution; readonly presentation: CredentialPresentation }` +- Inputs: `input: { readonly rpc: Pick; readonly personId: PersonId; readonly authorizationInstant: string; readonly credential?: Extract; readonly request?: Request; readonly resolution: CanonicalScopeResolution; readonly presentation: CredentialPresentation }` - Output: `Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">>` - Errors: `Problem<"authority.denied"> | Problem<"credential.invalid"> | Problem<"credential.missing">` - Requirements: none - Side effects: none -- Source: [apps/backend/src/content/http-access.ts:43](../../apps/backend/src/content/http-access.ts#L43) +- Source: [apps/backend/src/content/access.ts:51](../../apps/backend/src/content/access.ts#L51) **How it works** -It runs `authorizePerson` with the endpoint's AccessSpec, the content domain as the grant scope, -and `resolution` at `authorizationInstant`. A command passes the credential that its -transaction resolved; a read passes the request, from which the credential is derived. Content +It runs `authorizePerson` with the RPC's AccessSpec, the content domain as the grant scope, and +`resolution` at `authorizationInstant`. A command passes the credential that its transaction +resolved; a read passes the credential request, from which the credential is derived. Content access reveals every denial, so `unreachable("resource.not-found")` removes the concealment answer. **Use** ```ts -yield* authorizeContentOperation({ endpoint: ReviseArticleEndpoint, credential: actor.credential, personId: actor.personId, authorizationInstant, resolution, presentation }); +yield* authorizeContentOperation({ rpc: ReviseArticle, credential: actor.credential, personId: actor.personId, authorizationInstant, resolution, presentation }); ``` **Avoid** Granting a content operation from a role check in the handler: the AccessSpec of the -contract then stops being the authority for the endpoint. Evaluate it with this. +contract then stops being the authority for the RPC. Evaluate it with this. ## `contentProblems` @@ -207,7 +220,7 @@ const contentProblems: ProblemMapper - Errors: none - Requirements: none - Side effects: none -- Source: [apps/backend/src/content/http-problem.ts:61](../../apps/backend/src/content/http-problem.ts#L61) +- Source: [apps/backend/src/content/problem.ts:61](../../apps/backend/src/content/problem.ts#L61) **How it works** @@ -224,12 +237,12 @@ read.pipe(contentProblems, contentActorProblems(presentation)); **Avoid** -Mapping a content failure in a handler: the content endpoints then answer one failure +Mapping a content failure in a handler: the content RPCs then answer one failure differently. Pipe the handler's effect through this. ## `contentActorProblems` -A staff person rejected after ingress is answered from the credential the request presented. +A staff person rejected after the credential middleware is answered from the credential the request presented. ```ts contentActorProblems( @@ -242,7 +255,7 @@ contentActorProblems( - Errors: none - Requirements: none - Side effects: none -- Source: [apps/backend/src/content/http-problem.ts:101](../../apps/backend/src/content/http-problem.ts#L101) +- Source: [apps/backend/src/content/problem.ts:102](../../apps/backend/src/content/problem.ts#L102) **How it works** @@ -261,368 +274,331 @@ read.pipe(contentProblems, contentActorProblems(presentation)); Answering a rejected staff person with a fixed credential code: a request that presented a rejected session would be told that it presented none. Pipe the actor resolution through this. -## `problemWebResponse` +## `receiptProblems` -Renders one problem outside HttpApi encoding, with the encoder's body and headers. +The one answer for every receipt failure other than a rejected credential, including an unavailable store, Identity, or E2E barrier. ```ts -problemWebResponse(problem: Problem): Response +const receiptProblems: ProblemMapper ``` -- Inputs: `problem: Problem` -- Output: `Response` +- Inputs: none +- Output: `ProblemMapper` - Errors: none - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:77](../../apps/backend/src/http-api/problem.ts#L77) +- Source: [apps/backend/src/receipt/problem.ts:80](../../apps/backend/src/receipt/problem.ts#L80) **How it works** -The body is the JSON text of `problemBody(problem)`, the status is `problem.status`, and the -headers are those of `problemHeaders(problem)` with `content-type: application/problem+json`: -the answer that HttpApiBuilder encodes for a declared problem. It answers where no endpoint -encodes: an origin denial, a method outside the allowed set, an unknown path, and the internal -error of `ProblemBoundaryLive`. +An inactive actor, a denied owner, scope, or authority, and an ambiguous or failed authority +requirement answer authority.denied. The receipt, settlement, file, and idempotency failures +answer their receipt problems, and a command that does not decode answers validation.failed at +the root. The store, the file store, Identity, SQL, and a timeout of the E2E barrier answer +receipts.unavailable. **Use** ```ts -const response = problemWebResponse(Problem.make("method.not-allowed")); +command.pipe(receiptProblems, commandReceiptProblems, receiptCredentialProblems(presentation)); ``` **Avoid** -Building a problem response by hand: its body or headers drift from the frozen encoding -that clients decode. Render the `Problem` with this. +Mapping a receipt failure in a handler: the receipt RPCs then answer one failure +differently. Pipe the handler's effect through this. -## `jsonText` +## `receiptCredentialProblems` -The JSON text of a representation, byte for byte what `JSON.stringify` writes. +A credential rejected inside a receipt handler is answered from the request's own evidence. ```ts -jsonText(value: A): Effect.Effect +receiptCredentialProblems( + presentation: CredentialPresentation +): ProblemMapper> ``` -- Inputs: `value: A` -- Output: `Effect.Effect` +- Inputs: `presentation: CredentialPresentation` +- Output: `ProblemMapper>` - Errors: none - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:105](../../apps/backend/src/http-api/problem.ts#L105) +- Source: [apps/backend/src/receipt/problem.ts:108](../../apps/backend/src/receipt/problem.ts#L108) **How it works** -It encodes `value` with `Schema.fromJsonString(Schema.Unknown)`. A value that JSON cannot -represent, such as a `bigint`, fails the encoding, and the failure becomes a defect, which -`ProblemBoundaryLive` answers as internal.error. +The rejection answers `Problem.unauthenticated(presentation)`: credential.missing when the +request presented no credential, and credential.invalid when it presented one that was +rejected. **Use** ```ts -return new Response(yield* jsonText(body), { headers: { "content-type": "application/json" } }); +command.pipe(receiptProblems, commandReceiptProblems, receiptCredentialProblems(presentation)); ``` **Avoid** -`JSON.stringify` in a handler: it throws outside the error channel, and -`effecttsgo/prefer-schema-over-json` rejects it. Write the text with `jsonText`. +Answering a rejected credential with a fixed code: a request that presented a rejected +credential would be told that it presented none. Pipe the handler's effect through this. -## `webHandler` +## `authorizeInvitationOperation` -Runs one Effect-native Web transport operation. +Authorizes the holder of an invitation's response capability. ```ts -webHandler( - request: HttpServerRequest.HttpServerRequest, - handle: (request: Request) => Effect.Effect -): Effect.Effect +authorizeInvitationOperation( + input: { readonly spec: AccessSpec; readonly source: RecruitmentInvitationHttpSource; readonly authorizationInstant: string } +): Effect.Effect> ``` -- Inputs: - - `request: HttpServerRequest.HttpServerRequest` - - `handle: (request: Request) => Effect.Effect` -- Output: `Effect.Effect` -- Errors: `E` -- Requirements: `R` -- Side effects: none of its own; it runs `handle`. -- Source: [apps/backend/src/http-api/problem.ts:143](../../apps/backend/src/http-api/problem.ts#L143) +- Inputs: `input: { readonly spec: AccessSpec; readonly source: RecruitmentInvitationHttpSource; readonly authorizationInstant: string }` +- Output: `Effect.Effect>` +- Errors: `Problem<"resource.not-found">` +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/recruitment/access.ts:74](../../apps/backend/src/recruitment/access.ts#L74) **How it works** -It converts the HttpApi request to a Web `Request`, runs `handle`, and converts the `Response` -back; a request that does not convert is a defect. The typed failures of `handle` stay in the -error channel, where HttpApiBuilder encodes each against the endpoint's declared problems, so a -problem that the endpoint does not declare fails its type check. +It evaluates the invitation AccessSpec for the capability holder, with one grant of the +spec's one capability on the invitation resource, over the invitation's recruitment context +at `authorizationInstant`. The invitation AccessSpec conceals every denial as not found, so a +denial answers resource.not-found and no other rejection exists. A spec with another capability +set, or a revealed denial, is a defect. **Use** ```ts -.handleRaw("readTeamApplication", ({ request, params }) => webHandler(request, (webRequest) => readApplication(webRequest, params.applicationId))) +yield* authorizeInvitationOperation({ spec: Option.getOrThrow(reflectAccessSpec(ReadInvitationResponse)), source: snapshot.source, authorizationInstant: now }); ``` **Avoid** -Answering a failure inside `handle` with a hand-built `Response`: HttpApiBuilder then -cannot check it against the endpoint's declared problems. Fail with the `Problem`. +Checking the capability digest against the invitation in the handler: the AccessSpec of +the contract then stops being the authority, and a denial can reveal that the invitation +exists. Authorize the holder with this. -## `semanticProblem` +## `interviewAuthorizationInTransaction` -Runs a throwing semantic parser. +Resolves the current person and authorizes one interview inside the caller's transaction; a rejected credential is answered from the request's evidence. ```ts -semanticProblem( - parse: () => A, - codes: ReadonlyArray -): Effect.Effect> +interviewAuthorizationInTransaction( + input: { readonly headers: Headers.Headers; readonly interviewId: RecruitmentInterviewId; readonly rpc: Pick; readonly allowLeader: boolean; readonly now: (() => string) | undefined } +): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">, Database | IdentitySnapshot | OAuthCredentialAuthority | Organization | Recruitment> ``` -- Inputs: - - `parse: () => A` - - `codes: ReadonlyArray` -- Output: `Effect.Effect>` -- Errors: `Problem` -- Requirements: none -- Side effects: none of its own; it runs `parse`. -- Source: [apps/backend/src/http-api/problem.ts:174](../../apps/backend/src/http-api/problem.ts#L174) +- Inputs: `input: { readonly headers: Headers.Headers; readonly interviewId: RecruitmentInterviewId; readonly rpc: Pick; readonly allowLeader: boolean; readonly now: (() => string) | undefined }` +- Output: `Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">, Database | IdentitySnapshot | OAuthCredentialAuthority | Organization | Recruitment>` +- Errors: `| RecruitmentFailure | IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError | Problem<"authority.denied"> | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">` +- Requirements: `Database | IdentitySnapshot | OAuthCredentialAuthority | Organization | Recruitment` +- Side effects: Reads the person's credential and authority, and takes recruitment's locks in the caller's transaction. +- Source: [apps/backend/src/recruitment/access.ts:265](../../apps/backend/src/recruitment/access.ts#L265) **How it works** -`parse` runs when the effect runs. A thrown `Problem` whose code is in `codes` becomes a failure -with that code; any other throw, a problem of another code included, is a defect. The HTTP -semantic parsers, such as `normalizeTarget`, throw their problems, so an Effect handler calls -them through this. +It resolves the request's person credential and organization authority at one instant, lets +recruitment prepare the interview, which takes applicant custody before interview or receipt +locks, and evaluates the RPC's AccessSpec over the interview's access context, with the +interview as the grant scope. `allowLeader` admits the department's administrator. It answers +the actor, the instant, and the interview source that the command uses. **Use** ```ts -const normalizedTarget = yield* semanticProblem(() => normalizeTarget(routeTemplate, identities), ["request.malformed"]); +const authorization = yield* interviewAuthorizationInTransaction({ headers, interviewId, rpc: ScheduleInterview, allowLeader: true, now }); ``` **Avoid** -`Effect.try` with a hand-written catch around a parser: an unexpected throw then becomes -a failure that the endpoint does not declare. List the codes that the endpoint declares here. +Authorizing outside the command's transaction, or before recruitment prepares the +interview: the authority or the interview can change before the command writes. Call it first +inside the transaction that writes. -## `problemMapper` +## `recruitmentProblems` -Builds the one failure-to-problem mapper of a domain. +The one answer for every recruitment failure. ```ts -problemMapper(): (>(cases: ExactCases) => ProblemMapper) +recruitmentProblems( + { presentation, unavailable }: { readonly presentation: CredentialPresentation; readonly unavailable: Unavailable } +): ProblemMapper> ``` -- Inputs: none -- Output: `(>(cases: ExactCases) => ProblemMapper)` +- Inputs: `{ presentation, unavailable }: { readonly presentation: CredentialPresentation; readonly unavailable: Unavailable }` +- Output: `ProblemMapper>` - Errors: none - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:251](../../apps/backend/src/http-api/problem.ts#L251) +- Source: [apps/backend/src/recruitment/problem.ts:123](../../apps/backend/src/recruitment/problem.ts#L123) **How it works** -`problemMapper()(cases)` takes one case per tag of `Failure` and no other key. The -mapper maps each failure of an effect whose tag has a case to the problem that its case -returns, and it passes every other failure through unchanged, so an unmapped failure still -reaches, and fails, the endpoint's type check. The mapped error channel keeps only the problems -that the effect's own failures map to. A mapper constant declares its type as -`ProblemMapper`, with its cases in a constant that `satisfies` -`ProblemCases`. A mapper that a function builds per request adds `CredentialCases` -and `OutageCases` to its constant cases, for the answers that depend on its arguments. +Reads answer an outage as recruitment.unavailable and mutations as dependency.unavailable, +through `unavailable`. A rejected credential answers from the request's own evidence, a denied +actor answers authority.denied, a replayed command with other content answers +idempotency.digest-conflict, and each other recruitment failure answers its recruitment +problem. What remains is a fault of recruitment itself or of a dependency's contract, and +answers internal.error. **Use** ```ts -const storedReceiptProblems = problemMapper()({ ReceiptDecodeError: () => Problem.make("receipts.unavailable") }); +read.pipe(recruitmentProblems({ presentation: personPresentation(headers), unavailable: "recruitment.unavailable" })); ``` **Avoid** -Mapping a domain's failures with `Effect.mapError` or `Effect.catchTag` in each handler: -the copies drift apart, so one failure gets different problems on different endpoints. Map each -domain once and pipe its mapper. +Mapping a recruitment failure in a handler: the recruitment RPCs then answer one failure +differently. Pipe the handler's effect through this, with the RPC's unavailable problem. -## `requireNoQuery` +## `raceProblems` -An operation that accepts no query answers any query as malformed. +A failure that lost a serialization or deadlock race answers transaction.conflict, whatever failure carried it. ```ts -requireNoQuery(request: Request): Effect.Effect> +raceProblems( + effect: Effect.Effect +): Effect.Effect, R> ``` -- Inputs: `request: Request` -- Output: `Effect.Effect>` -- Errors: `Problem<"request.malformed">` -- Requirements: none +- Inputs: `effect: Effect.Effect` +- Output: `Effect.Effect, R>` +- Errors: `E | Problem<"transaction.conflict">` +- Requirements: `R` - Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:305](../../apps/backend/src/http-api/problem.ts#L305) +- Source: [apps/backend/src/recruitment/problem.ts:162](../../apps/backend/src/recruitment/problem.ts#L162) **How it works** -A request whose URL has a non-empty query fails with request.malformed, so an operation that -declares no query parameter never answers as if it had applied one. - -**Use** - -```ts -yield* requireNoQuery(request); -``` - -**Avoid** - -Ignoring an unexpected query: a client that sends a filter that the operation does not -know gets an unfiltered answer that looks filtered. Call it first in an operation without query -parameters. - -## `readJsonBody` - -Reads a bounded JSON body of the one media type `mediaType` accepts. - -```ts -readJsonBody( - request: Request, - mediaType: RegExp, - maxBytes: number -): Effect.Effect | Problem<"request.malformed"> | Problem<"request.too-large"> | Problem<"internal.error">> -``` - -- Inputs: - - `request: Request` - - `mediaType: RegExp` - - `maxBytes: number` -- Output: `Effect.Effect | Problem<"request.malformed"> | Problem<"request.too-large"> | Problem<"internal.error">>` -- Errors: `| Problem<"media-type.unsupported"> | Problem<"request.malformed"> | Problem<"request.too-large"> | Problem<"internal.error">` -- Requirements: none -- Side effects: Reads and consumes the request body. -- Source: [apps/backend/src/http-api/problem.ts:333](../../apps/backend/src/http-api/problem.ts#L333) - -**How it works** - -A `content-type` that `mediaType` does not match fails with media-type.unsupported before a -byte is read. `readBoundedJson` then reads the body: a malformed or too large Content-Length -fails at once, the bytes are counted as they arrive and fail with request.too-large past -`maxBytes`, and the JSON parses without duplicate member names, else request.malformed. A body -that cannot be read is internal.error. It takes the body reader when it is called, not when -the effect runs. +Each failure that `isSerializationConflict` classifies as a lost race becomes the +transaction.conflict problem, which a client may retry; every other failure passes unchanged. +It runs after the command executor, whose one retry reads the raw causes. **Use** ```ts -const body = yield* readJsonBody(request, /^application\/json(?:\s*;|$)/iu, MAX_SUBMISSION_BYTES); +command.pipe(raceProblems, recruitmentProblems({ presentation, unavailable: "dependency.unavailable" })); ``` **Avoid** -`request.json()`: it reads without a bound, accepts duplicate member names, and throws -outside the error channel. Read the body with this. +Mapping races before the command executor: its retry of a lost race then sees a problem +instead of the raw cause, and does not retry. Pipe this after the executor. -## `idempotencyKeyOf` +## `jsonText` -Decodes the one Idempotency-Key a replayable mutation requires. +The JSON text of a representation, byte for byte what `JSON.stringify` writes. ```ts -idempotencyKeyOf( - request: Request -): Effect.Effect> +jsonText(value: A): Effect.Effect ``` -- Inputs: `request: Request` -- Output: `Effect.Effect>` -- Errors: `Problem<"idempotency-key.invalid">` +- Inputs: `value: A` +- Output: `Effect.Effect` +- Errors: none - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:368](../../apps/backend/src/http-api/problem.ts#L368) +- Source: [apps/backend/src/rpc/problem.ts:64](../../apps/backend/src/rpc/problem.ts#L64) **How it works** -`parseIdempotencyKey` decodes the Idempotency-Key field: exactly one field, with no comma, of -the frozen key grammar. A missing, repeated, or malformed key fails with -idempotency-key.invalid. +It encodes `value` with `Schema.fromJsonString(Schema.Unknown)`. A value that JSON cannot +represent, such as a `bigint`, fails the encoding, and the failure becomes a defect, which +`ProblemBoundaryLive` answers as internal.error. **Use** ```ts -const idempotencyKey = yield* idempotencyKeyOf(request); +return new Response(yield* jsonText(body), { headers: { "content-type": "application/json" } }); ``` **Avoid** -Reading the field with `request.headers.get` and trimming it: a repeated or malformed key -then reaches the command identity. Decode it with this before `httpIdentity`. +`JSON.stringify` in a handler: it throws outside the error channel, and +`effecttsgo/prefer-schema-over-json` rejects it. Write the text with `jsonText`. -## `requiredIfMatchOf` +## `problemMapper` -Decodes the one strong If-Match an item mutation requires. +Builds the one failure-to-problem mapper of a domain. ```ts -requiredIfMatchOf( - request: Request -): Effect.Effect> +problemMapper(): (>(cases: ExactCases) => ProblemMapper) ``` -- Inputs: `request: Request` -- Output: `Effect.Effect>` -- Errors: `Problem<"precondition.invalid" | "precondition.required">` +- Inputs: none +- Output: `(>(cases: ExactCases) => ProblemMapper)` +- Errors: none - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:396](../../apps/backend/src/http-api/problem.ts#L396) +- Source: [apps/backend/src/rpc/problem.ts:203](../../apps/backend/src/rpc/problem.ts#L203) **How it works** -`parseRequiredIfMatch` decodes the If-Match field: no field fails with precondition.required, -and anything but one strong entity tag, such as a weak tag, a list, or `*`, fails with -precondition.invalid. +`problemMapper()(cases)` takes one case per tag of `Failure` and no other key. The +mapper maps each failure of an effect whose tag has a case to the problem that its case +returns, and it passes every other failure through unchanged, so an unmapped failure still +reaches, and fails, the endpoint's type check. The mapped error channel keeps only the problems +that the effect's own failures map to. A mapper constant declares its type as +`ProblemMapper`, with its cases in a constant that `satisfies` +`ProblemCases`. A mapper that a function builds per request adds `CredentialCases` +and `OutageCases` to its constant cases, for the answers that depend on its arguments. **Use** ```ts -const ifMatch = yield* requiredIfMatchOf(request); +const storedReceiptProblems = problemMapper()({ ReceiptDecodeError: () => Problem.make("receipts.unavailable") }); ``` **Avoid** -Treating a missing If-Match as a match: a client that never read the representation -overwrites a concurrent change. Require it, and compare it with `requireCurrentETag`. +Mapping a domain's failures with `Effect.mapError` or `Effect.catchTag` in each handler: +the copies drift apart, so one failure gets different problems on different endpoints. Map each +domain once and pipe its mapper. -## `httpIdentity` +## `commandIdentity` -Derives a command's idempotency identity; a tuple outside the frozen grammar is a request problem. +Derives a command's idempotency identity: the receipt digest and the domain command ID. ```ts -httpIdentity( - identity: NativeIdempotencyIdentity -): Effect.Effect> +commandIdentity(identity: NativeIdempotencyIdentity): Effect.Effect ``` - Inputs: `identity: NativeIdempotencyIdentity` -- Output: `Effect.Effect>` -- Errors: `Problem<"idempotency-key.invalid" | "request.malformed">` +- Output: `Effect.Effect` +- Errors: none - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:425](../../apps/backend/src/http-api/problem.ts#L425) +- Source: [apps/backend/src/rpc/problem.ts:250](../../apps/backend/src/rpc/problem.ts#L250) **How it works** -It runs `deriveHttpIdentity` through `semanticProblem`: a credential subject, operation id, or -target outside its grammar fails with request.malformed, and a key outside its grammar with -idempotency-key.invalid. `commandId` and `identitySha256` key the command receipt of the -mutation. +It runs `deriveHttpIdentity` over the credential subject, the operation id, the normalized +target, and the idempotency key. The RPC payload schema has already decoded the key, and a +handler passes its operation id and the path of the HTTP route it replaced as constants, so a +tuple outside the grammar is a defect. Keeping the old path keeps the receipts and command IDs +of commands that straddle the cutover stable. **Use** ```ts -const identity = yield* httpIdentity({ credentialSubject: `Person:${personId}`, qualifiedOperationId: operationId, normalizedTarget, idempotencyKey }); +const identity = yield* commandIdentity({ credentialSubject: `Person:${personId}`, qualifiedOperationId: "social-events.create", normalizedTarget: "/api/social-events", idempotencyKey }); ``` **Avoid** -Calling `deriveHttpIdentity` directly in an Effect handler: its throw escapes the error -channel and becomes a defect, internal.error, instead of the declared request problem. +Deriving a new target from the RPC tag: a replay across the cutover then misses its +receipt, and the domain command runs twice. Pass the old route path. ## `requireCurrentETag` Fails a mutation whose If-Match no longer names the current representation. ```ts +requireCurrentETag( + ifMatch: StrongETag +): (current: StrongETag) => Effect.Effect> requireCurrentETag( current: StrongETag, ifMatch: StrongETag @@ -636,7 +612,7 @@ requireCurrentETag( - Errors: `Problem<"precondition.failed">` - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:454](../../apps/backend/src/http-api/problem.ts#L454) +- Source: [apps/backend/src/rpc/problem.ts:278](../../apps/backend/src/rpc/problem.ts#L278) **How it works** @@ -656,98 +632,20 @@ service.reviseIntake(command, staff.principal, (current) => requireCurrentETag(i Comparing tags before the transaction, as a preflight read: a concurrent change can commit between the check and the write. Compare inside the transaction that writes. -## `conditionalJson` - -Answers a conditional JSON read after authority and concealment: the representation, a bodyless 304, or precondition.failed. - -```ts -conditionalJson( - input: { readonly request: Request; readonly body: unknown; readonly etag: StrongETag; readonly cacheControl: string; readonly contentType: "application/json" | "application/json; charset=utf-8" } -): Effect.Effect | Problem<"precondition.invalid">> -``` - -- Inputs: `input: { readonly request: Request; readonly body: unknown; readonly etag: StrongETag; readonly cacheControl: string; readonly contentType: "application/json" | "application/json; charset=utf-8" }` -- Output: `Effect.Effect | Problem<"precondition.invalid">>` -- Errors: `Problem<"precondition.failed"> | Problem<"precondition.invalid">` -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:485](../../apps/backend/src/http-api/problem.ts#L485) - -**How it works** - -It parses If-Match and If-None-Match, and a field outside their grammar fails with -precondition.invalid. An If-Match without the current strong tag fails with -precondition.failed. An If-None-Match that names the current tag, or `*`, answers a bodyless 304 -with the tag, the cache policy, and `Vary: Origin`. Otherwise it answers 200 with the JSON text -of `body` and the same headers. - -**Use** - -```ts -return yield* conditionalJson({ request, body, etag, cacheControl: PRIVATE_NO_STORE, contentType: "application/json" }); -``` - -**Avoid** - -Evaluating the conditions before authorization and concealment: a 304 or 412 then tells a -caller that a resource it may not see exists. Answer the read with this, last. - -## `personPresentation` - -The person credential a request presented, for a rejection answered after ingress. - -```ts -personPresentation( - request: Request, - challenge: string = nativeUserChallenges() -): CredentialPresentation -``` - -- Inputs: - - `request: Request` - - `challenge: string = nativeUserChallenges()` -- Output: `CredentialPresentation` -- Errors: none -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:549](../../apps/backend/src/http-api/problem.ts#L549) - -**How it works** - -It classifies the raw request once: an Authorization field or a Better Auth session cookie -counts as presented, whatever its validity. `Problem.unauthenticated` answers this evidence with -credential.missing for an absent credential and credential.invalid for a presented one, with -`challenge` in `WWW-Authenticate`; the default challenge names the native session cookie and -bearer tokens. - -**Use** - -```ts -const presentation = personPresentation(request); -``` - -**Avoid** - -Choosing credential.missing or credential.invalid by string: a request that sent a -rejected token would be told that it sent none. Answer a rejected credential from this evidence -with `Problem.unauthenticated`. - ## `isSerializationConflict` Whether a failure, or one of its causes, is a lost serialization or deadlock race: a transaction.conflict the client may retry. ```ts -isSerializationConflict(cause: unknown, depth: number = 0): boolean +isSerializationConflict(cause: unknown): boolean ``` -- Inputs: - - `cause: unknown` - - `depth: number = 0` +- Inputs: `cause: unknown` - Output: `boolean` - Errors: none - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:580](../../apps/backend/src/http-api/problem.ts#L580) +- Source: [apps/backend/src/rpc/problem.ts:325](../../apps/backend/src/rpc/problem.ts#L325) **How it works** @@ -779,7 +677,7 @@ requestInvalid(): Problem<"validation.failed"> - Errors: none - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:609](../../apps/backend/src/http-api/problem.ts#L609) +- Source: [apps/backend/src/rpc/problem.ts:348](../../apps/backend/src/rpc/problem.ts#L348) **How it works** @@ -798,40 +696,6 @@ Echoing the schema issue, or pointing at a guessed member: the answer then leaks internals or misleads the client. Answer the whole request with this, or build `Problem.validation` with the pointers that the decoder knows. -## `decodeRequest` - -Decodes one JSON request value strictly; any mismatch fails the whole request's validation. - -```ts -decodeRequest>( - schema: S -): ((value: Schema.Json) => Effect.Effect, S["DecodingServices"]>) -``` - -- Inputs: `schema: S` -- Output: `((value: Schema.Json) => Effect.Effect, S["DecodingServices"]>)` -- Errors: none -- Requirements: none -- Side effects: none of its own; it runs the decoding services of `schema`. -- Source: [apps/backend/src/http-api/problem.ts:632](../../apps/backend/src/http-api/problem.ts#L632) - -**How it works** - -It decodes `value` with `schema` and rejects excess properties (`onExcessProperty: "error"`). -Any mismatch fails with `requestInvalid()`: one validation.failed problem for the request. - -**Use** - -```ts -const body = yield* decodeRequest(CreateSocialEventRequest)(json); -``` - -**Avoid** - -Decoding a request with the default options of `Schema.decodeUnknownEffect`: excess -properties then pass silently, and the schema error is a failure that the endpoint does not -declare. Decode request values with this. - ## `strictOutput` Decodes one response value strictly. @@ -847,7 +711,7 @@ strictOutput>( - Errors: none - Requirements: none - Side effects: none of its own; it runs the decoding services of `schema`. -- Source: [apps/backend/src/http-api/problem.ts:663](../../apps/backend/src/http-api/problem.ts#L663) +- Source: [apps/backend/src/rpc/problem.ts:371](../../apps/backend/src/rpc/problem.ts#L371) **How it works** @@ -868,7 +732,7 @@ client. Decode it with the response schema first. ## `commandReceiptProblems` -HTTP command receipts: the transport's own persistence failures. +Command receipts: the transport's own persistence failures. ```ts const commandReceiptProblems: ProblemMapper @@ -879,7 +743,7 @@ const commandReceiptProblems: ProblemMapper CredentialPresentation +``` + +- Inputs: + - `headers: Headers.Headers` + - `challenge?: string` +- Output: `CredentialPresentation` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/rpc/problem.ts:430](../../apps/backend/src/rpc/problem.ts#L430) + +**How it works** + +`authorizePerson` answers a credential rejection from this evidence, never by string choice. + +**Use** + +```ts +yield* authorizePerson(input, personPresentation(headers)); +``` + +**Avoid** + +Deciding between credential.missing and credential.invalid in a handler. Pass this. + +## `commandOutcome` -Answers a command receipt outcome: committed and replayed results, or an idempotency problem. +The value of a command receipt outcome: the committed or replayed success, or an idempotency problem. ```ts -commandOutcomeResponse( - outcome: NativeHttpCommandOutcome -): Effect.Effect | Problem<"idempotency.digest-conflict"> | Problem<"idempotency.response-expired">> +commandOutcome>( + success: S +): ((outcome: NativeHttpCommandOutcome) => Effect.Effect | Problem<"idempotency.digest-conflict"> | Problem<"idempotency.response-expired">>) ``` -- Inputs: `outcome: NativeHttpCommandOutcome` -- Output: `Effect.Effect | Problem<"idempotency.digest-conflict"> | Problem<"idempotency.response-expired">>` -- Errors: `| Problem<"idempotency.in-flight"> | Problem<"idempotency.digest-conflict"> | Problem<"idempotency.response-expired">` +- Inputs: `success: S` +- Output: `((outcome: NativeHttpCommandOutcome) => Effect.Effect | Problem<"idempotency.digest-conflict"> | Problem<"idempotency.response-expired">>)` +- Errors: none - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:727](../../apps/backend/src/http-api/problem.ts#L727) +- Source: [apps/backend/src/rpc/problem.ts:462](../../apps/backend/src/rpc/problem.ts#L462) **How it works** -A committed or replayed outcome answers its stored response capsule with -`Cache-Control: no-store`, so a replay returns the status, bytes, and headers of the first -answer. A command that still runs under the key fails with idempotency.in-flight, a key reused -for another request with idempotency.digest-conflict, and a response that is no longer kept -with idempotency.response-expired. +A committed or replayed outcome answers the success that its receipt stores, decoded with the +RPC's success schema, so a replay returns the value of the first answer. A command that still +runs under the key fails with idempotency.in-flight, a key reused for another request with +idempotency.digest-conflict, and a result that is no longer kept with +idempotency.response-expired. **Use** ```ts -return yield* commandOutcomeResponse(outcome); +return yield* commandOutcome(SocialEventResource)(outcome); ``` **Avoid** -Rebuilding the response of a replay from the current state: a retry then sees another +Rebuilding the result of a replay from the current state: a retry then sees another answer than the first attempt. Answer every outcome through this. ## `authorizeAnonymous` @@ -939,6 +835,10 @@ answer than the first attempt. Answer every outcome through this. An anonymous AccessSpec grants every caller and conceals nothing, so a denial means the spec and its scope resolution disagree: a defect. ```ts +authorizeAnonymous( + resolution: CanonicalScopeResolution, + now: string +): (spec: AccessSpec) => Effect.Effect authorizeAnonymous( spec: AccessSpec, resolution: CanonicalScopeResolution, @@ -954,7 +854,7 @@ authorizeAnonymous( - Errors: none - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:766](../../apps/backend/src/http-api/problem.ts#L766) +- Source: [apps/backend/src/rpc/problem.ts:539](../../apps/backend/src/rpc/problem.ts#L539) **How it works** @@ -978,6 +878,9 @@ handler then drift apart unnoticed. Evaluate it, so that a mismatch is a defect. A rejected person credential is answered from the ingress evidence, never by string choice. ```ts +authorizePerson( + presentation: CredentialPresentation +): (input: NativePersonAuthorization) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">> authorizePerson( input: NativePersonAuthorization, presentation: CredentialPresentation @@ -991,7 +894,7 @@ authorizePerson( - Errors: `| Problem<"authority.denied"> | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">` - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:799](../../apps/backend/src/http-api/problem.ts#L799) +- Source: [apps/backend/src/rpc/problem.ts:585](../../apps/backend/src/rpc/problem.ts#L585) **How it works** @@ -1030,7 +933,7 @@ unreachable( - Errors: none - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:841](../../apps/backend/src/http-api/problem.ts#L841) +- Source: [apps/backend/src/rpc/problem.ts:652](../../apps/backend/src/rpc/problem.ts#L652) **How it works** @@ -1050,309 +953,14 @@ authorizePerson(input, presentation).pipe(unreachable("resource.not-found")); Declaring a problem on an endpoint only because a shared mapper can produce it: the contract then promises an answer that the operation never gives. Mark it unreachable here. -## `ProblemBoundaryLive` - -The only Cause consumer. - -```ts -const ProblemBoundaryLive: Layer.Layer -``` - -- Inputs: none -- Output: `Layer.Layer` -- Errors: none -- Requirements: `HttpRouter.HttpRouter` -- Side effects: Reports each cause that it answers through `ErrorReporter`. -- Source: [apps/backend/src/http-api/problem.ts:884](../../apps/backend/src/http-api/problem.ts#L884) - -**How it works** - -A global router middleware. HttpApiBuilder has already answered every declared typed failure, so -a cause that reaches this boundary is a defect, an interrupt, or a failure that no endpoint -declared. It reports the cause through `ErrorReporter` and answers the frozen internal.error -problem. A cause of interrupts only that a client abort annotates passes through unanswered, -because the client is gone. - -**Use** - -```ts -return Layer.mergeAll(nativeRoutes, notFound, ProblemBoundaryLive); -``` - -**Avoid** - -Catching defects or rendering causes in a handler or another middleware: a cause then -reaches the client, or a fault goes unreported. Leave causes to this boundary. - -## `receiptProblems` - -The one answer for every receipt failure other than a rejected credential, including an unavailable store, Identity, or E2E barrier. - -```ts -const receiptProblems: ProblemMapper -``` - -- Inputs: none -- Output: `ProblemMapper` -- Errors: none -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/receipt/http-problem.ts:80](../../apps/backend/src/receipt/http-problem.ts#L80) - -**How it works** - -An inactive actor, a denied owner, scope, or authority, and an ambiguous or failed authority -requirement answer authority.denied. The receipt, settlement, file, and idempotency failures -answer their receipt problems, and a command that does not decode answers validation.failed at -the root. The store, the file store, Identity, SQL, and a timeout of the E2E barrier answer -receipts.unavailable. - -**Use** - -```ts -command.pipe(receiptProblems, commandReceiptProblems, receiptCredentialProblems(presentation)); -``` - -**Avoid** - -Mapping a receipt failure in a handler: the receipt endpoints then answer one failure -differently. Pipe the handler's effect through this. - -## `receiptCredentialProblems` - -A credential rejected inside a receipt handler is answered from the request's own evidence. - -```ts -receiptCredentialProblems( - presentation: CredentialPresentation -): ProblemMapper> -``` - -- Inputs: `presentation: CredentialPresentation` -- Output: `ProblemMapper>` -- Errors: none -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/receipt/http-problem.ts:110](../../apps/backend/src/receipt/http-problem.ts#L110) - -**How it works** - -The rejection answers `Problem.unauthenticated(presentation)`: credential.missing when the -request presented no credential, and credential.invalid when it presented one that was -rejected. - -**Use** - -```ts -command.pipe(receiptProblems, commandReceiptProblems, receiptCredentialProblems(presentation)); -``` - -**Avoid** - -Answering a rejected credential with a fixed code: a request that presented a rejected -credential would be told that it presented none. Pipe the handler's effect through this. - -## `authorizeInvitationOperation` - -Authorizes the holder of an invitation's response capability. - -```ts -authorizeInvitationOperation( - input: { readonly spec: AccessSpec; readonly source: RecruitmentInvitationHttpSource; readonly authorizationInstant: string } -): Effect.Effect> -``` - -- Inputs: `input: { readonly spec: AccessSpec; readonly source: RecruitmentInvitationHttpSource; readonly authorizationInstant: string }` -- Output: `Effect.Effect>` -- Errors: `Problem<"resource.not-found">` -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/recruitment/http-access.ts:79](../../apps/backend/src/recruitment/http-access.ts#L79) - -**How it works** - -It evaluates the invitation AccessSpec for the capability holder, with one grant of the -spec's one capability on the invitation resource, over the invitation's recruitment context -at `authorizationInstant`. The invitation AccessSpec conceals every denial as not found, so a -denial answers resource.not-found and no other rejection exists. A spec with another capability -set, or a revealed denial, is a defect. - -**Use** - -```ts -yield* authorizeInvitationOperation({ spec: Option.getOrThrow(reflectAccessSpec(ReadInvitationResponseEndpoint)), source: snapshot.source, authorizationInstant: now }); -``` - -**Avoid** - -Checking the capability digest against the invitation in the handler: the AccessSpec of -the contract then stops being the authority, and a denial can reveal that the invitation -exists. Authorize the holder with this. - -## `interviewAuthorizationInTransaction` - -Resolves the current person and authorizes one interview inside the caller's transaction; a rejected credential is answered from the request's evidence. - -```ts -interviewAuthorizationInTransaction( - request: Request, - interviewId: RecruitmentInterviewId, - endpoint: | typeof ScheduleInterviewEndpoint | typeof ReadInterviewConductEndpoint | typeof FinalizeInterviewEndpoint | typeof CancelInterviewEndpoint | typeof CorrectInterviewAssessmentEndpoint, - allowLeader: boolean, - input: RecruitmentApiHttpOptions -): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">, Database | IdentitySnapshot | OAuthCredentialAuthority | Organization | Recruitment> -``` - -- Inputs: - - `request: Request` - - `interviewId: RecruitmentInterviewId` - - `endpoint: | typeof ScheduleInterviewEndpoint | typeof ReadInterviewConductEndpoint | typeof FinalizeInterviewEndpoint | typeof CancelInterviewEndpoint | typeof CorrectInterviewAssessmentEndpoint` - - `allowLeader: boolean` - - `input: RecruitmentApiHttpOptions` -- Output: `Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">, Database | IdentitySnapshot | OAuthCredentialAuthority | Organization | Recruitment>` -- Errors: `| RecruitmentFailure | IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError | Problem<"authority.denied"> | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">` -- Requirements: `Database | IdentitySnapshot | OAuthCredentialAuthority | Organization | Recruitment` -- Side effects: Reads the person's credential and authority, and takes recruitment's locks in the caller's transaction. -- Source: [apps/backend/src/recruitment/http-access.ts:253](../../apps/backend/src/recruitment/http-access.ts#L253) - -**How it works** - -It resolves the request's person credential and organization authority at one instant, lets -recruitment prepare the interview, which takes applicant custody before interview or receipt -locks, and evaluates the endpoint's AccessSpec over the interview's access context, with the -interview as the grant scope. `allowLeader` admits the department's administrator. It answers -the actor, the instant, and the interview source that the command uses. - -**Use** - -```ts -const authorization = yield* interviewAuthorizationInTransaction(request, interviewId, ScheduleInterviewEndpoint, true, input); -``` - -**Avoid** - -Authorizing outside the command's transaction, or before recruitment prepares the -interview: the authority or the interview can change before the command writes. Call it first -inside the transaction that writes. - -## `readRecruitmentBody` - -Every recruitment request body is one bounded `application/json` document. - -```ts -readRecruitmentBody( - request: Request, - maxBodyBytes: number -): Effect.Effect | Problem<"media-type.unsupported"> | Problem<"request.malformed"> | Problem<"request.too-large">> -``` - -- Inputs: - - `request: Request` - - `maxBodyBytes: number` -- Output: `Effect.Effect | Problem<"media-type.unsupported"> | Problem<"request.malformed"> | Problem<"request.too-large">>` -- Errors: `| Problem<"internal.error"> | Problem<"media-type.unsupported"> | Problem<"request.malformed"> | Problem<"request.too-large">` -- Requirements: none -- Side effects: Reads and consumes the request body when the effect runs. -- Source: [apps/backend/src/recruitment/http-decode.ts:32](../../apps/backend/src/recruitment/http-decode.ts#L32) - -**How it works** - -It reads the body with `readJsonBody` for the media type `application/json`, with or without -parameters, up to `maxBodyBytes`. `Effect.suspend` takes the body reader when the effect runs, -not when it is built, so a handler that builds the effect and never runs it leaves the body -unread. - -**Use** - -```ts -const body = yield* readRecruitmentBody(request, input.config.maxBodyBytes); -``` - -**Avoid** - -A recruitment handler that reads its body with its own media type pattern or bound: the -recruitment endpoints then accept different bodies. Read it with this. - -## `recruitmentProblems` - -The one answer for every recruitment failure. - -```ts -recruitmentProblems( - presentation: CredentialPresentation, - unavailable: Unavailable -): ProblemMapper> -``` - -- Inputs: - - `presentation: CredentialPresentation` - - `unavailable: Unavailable` -- Output: `ProblemMapper>` -- Errors: none -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/recruitment/http-problem.ts:126](../../apps/backend/src/recruitment/http-problem.ts#L126) - -**How it works** - -Reads answer an outage as recruitment.unavailable and mutations as dependency.unavailable, -through `unavailable`. A rejected credential answers from the request's own evidence, a denied -actor answers authority.denied, a replayed command with other content answers -idempotency.digest-conflict, and each other recruitment failure answers its recruitment -problem. What remains is a fault of recruitment itself or of a dependency's contract, and -answers internal.error. - -**Use** - -```ts -read.pipe(recruitmentProblems(personPresentation(request), "recruitment.unavailable")); -``` - -**Avoid** - -Mapping a recruitment failure in a handler: the recruitment endpoints then answer one -failure differently. Pipe the handler's effect through this, with the endpoint's unavailable -problem. - -## `raceProblems` - -A failure that lost a serialization or deadlock race answers transaction.conflict, whatever failure carried it. - -```ts -raceProblems( - effect: Effect.Effect -): Effect.Effect, R> -``` - -- Inputs: `effect: Effect.Effect` -- Output: `Effect.Effect, R>` -- Errors: `E | Problem<"transaction.conflict">` -- Requirements: `R` -- Side effects: none -- Source: [apps/backend/src/recruitment/http-problem.ts:162](../../apps/backend/src/recruitment/http-problem.ts#L162) - -**How it works** - -Each failure that `isSerializationConflict` classifies as a lost race becomes the -transaction.conflict problem, which a client may retry; every other failure passes unchanged. -It runs after the command executor, whose one retry reads the raw causes. - -**Use** - -```ts -command.pipe(raceProblems, recruitmentProblems(presentation, "dependency.unavailable")); -``` - -**Avoid** - -Mapping races before the command executor: its retry of a lost race then sees a problem -instead of the raw cause, and does not retry. Pipe this after the executor. - ## `problemUnion` -Creates a closed endpoint-specific Problem Details union. +Creates the closed Problem Details union of one RPC. ```ts +problemUnion]>( + codes: Codes +): (identifier: string) => Schema.Union>> problemUnion]>( identifier: string, codes: Codes @@ -1366,14 +974,14 @@ problemUnion extends - Errors: none - Requirements: none - Side effects: none -- Source: [packages/http-api/src/http-semantics.ts:1263](../../packages/http-api/src/http-semantics.ts#L1263) +- Source: [packages/rpc/src/problem.ts:1136](../../packages/rpc/src/problem.ts#L1136) **How it works** @@ -1459,7 +1067,7 @@ isProblem(u: unknown): u is Problem - Errors: none - Requirements: none - Side effects: none -- Source: [packages/http-api/src/http-semantics.ts:1366](../../packages/http-api/src/http-semantics.ts#L1366) +- Source: [packages/rpc/src/problem.ts:1239](../../packages/rpc/src/problem.ts#L1239) **How it works** @@ -1491,7 +1099,7 @@ problemBody(problem: Problem): ProblemWireRecord - Errors: none - Requirements: none - Side effects: none -- Source: [packages/http-api/src/http-semantics.ts:1395](../../packages/http-api/src/http-semantics.ts#L1395) +- Source: [packages/rpc/src/problem.ts:1268](../../packages/rpc/src/problem.ts#L1268) **How it works** @@ -1519,19 +1127,23 @@ makeNativeProblem( code: Code, expectedStatus?: number, instance?: string -): (typeof NativeProblemRegistry)[Code] & { readonly code: Code; readonly instance?: string } +): RegistryProblemBody +makeNativeProblem( + expectedStatus?: number, + instance?: string +): (code: Code) => RegistryProblemBody ``` - Inputs: - `code: Code` - `expectedStatus?: number` - `instance?: string` -- Output: `(typeof NativeProblemRegistry)[Code] & { readonly code: Code; readonly instance?: string }` +- Output: `RegistryProblemBody` - Errors: none - Throws: An `Error` when `expectedStatus` differs from the status that the registry freezes for `code`. - Requirements: none - Side effects: none -- Source: [packages/http-api/src/http-semantics.ts:1584](../../packages/http-api/src/http-semantics.ts#L1584) +- Source: [packages/rpc/src/problem.ts:1388](../../packages/rpc/src/problem.ts#L1388) **How it works** diff --git a/docs/constructs/rpc-transport.md b/docs/constructs/rpc-transport.md new file mode 100644 index 000000000..d8691a00a --- /dev/null +++ b/docs/constructs/rpc-transport.md @@ -0,0 +1,38 @@ +# rpc-transport + +[//]: # "constructs: generated from the @construct tags and their JSDoc by just constructs write; do not edit" + +Addresses the native RPC endpoints over HTTP, as the ingress serves them and every recorder matches them. The [index](../constructs.md) lists every category. + +## `isNativeRpcPath` + +Whether a request path addresses the native RPC endpoint. + +```ts +isNativeRpcPath(pathname: string): boolean +``` + +- Inputs: `pathname: string` +- Output: `boolean` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [packages/rpc/src/api.ts:58](../../packages/rpc/src/api.ts#L58) + +**How it works** + +The RPC client joins the endpoint URL with an empty request URL, so it posts to the path with one +trailing slash (`/api/rpc/`); a probe that builds its own request posts to `/api/rpc`. The +ingress serves both, and every recorder and filter asks this predicate, so no check matches one +spelling and silently misses the other. + +**Use** + +```ts +if (request.method === "POST" && isNativeRpcPath(new URL(request.url).pathname)) record(body); +``` + +**Avoid** + +`pathname === nativeRpcPath` or `pathname === "/api/rpc"`: every call of the RPC client +misses it. `anti-slop/no-rpc-path-comparison` rejects both. diff --git a/docs/constructs/sql-lifecycle.md b/docs/constructs/sql-lifecycle.md index 17d6fbc57..480c3caed 100644 --- a/docs/constructs/sql-lifecycle.md +++ b/docs/constructs/sql-lifecycle.md @@ -9,6 +9,10 @@ Claim-fenced row lifecycles in PostgreSQL, such as outbox claims and account acc Whether the native account of `personId` exists and is not disabled. ```ts +accountAccessEnabled( + personId: PersonId, + lock: "None" | "ForShare" +): (sql: DatabaseOperations) => Effect.Effect accountAccessEnabled( sql: DatabaseOperations, personId: PersonId, @@ -24,7 +28,7 @@ accountAccessEnabled( - Errors: `SqlError` - Requirements: none - Side effects: Reads `auth."user"`; with `ForShare` it holds a share lock on the row until the transaction ends. -- Source: [packages/database/src/identity-access.ts:34](../../packages/database/src/identity-access.ts#L34) +- Source: [packages/database/src/identity-access.ts:35](../../packages/database/src/identity-access.ts#L35) **How it works** @@ -50,6 +54,11 @@ inside the transaction that writes. SET list for the aggregate's claim UPDATE; `targetAlias` names the updated outbox row. ```ts +outboxClaimAssignments( + targetAlias: string, + claimId: string, + claimedAt: string +): (sql: DatabaseOperations) => Statement.Fragment outboxClaimAssignments( sql: DatabaseOperations, targetAlias: string, @@ -67,7 +76,7 @@ outboxClaimAssignments( - Errors: none - Requirements: none - Side effects: none: it builds a fragment, and the aggregate's UPDATE writes the row. -- Source: [packages/database/src/outbox-lifecycle.ts:140](../../packages/database/src/outbox-lifecycle.ts#L140) +- Source: [packages/database/src/outbox-lifecycle.ts:141](../../packages/database/src/outbox-lifecycle.ts#L141) **How it works** @@ -93,6 +102,11 @@ list in every claim UPDATE. Settles the claimed row as Delivered, with delivery evidence when the table records it. ```ts +markOutboxDelivered( + table: OutboxTable, + claim: OutboxClaim, + evidence?: OutboxDeliveryEvidence +): (sql: DatabaseOperations) => Effect.Effect markOutboxDelivered( sql: DatabaseOperations, table: OutboxTable, @@ -110,7 +124,7 @@ markOutboxDelivered( - Errors: `OutboxClaimLost | SqlError` - Requirements: none - Side effects: Writes the outbox row that the claim still owns. -- Source: [packages/database/src/outbox-lifecycle.ts:172](../../packages/database/src/outbox-lifecycle.ts#L172) +- Source: [packages/database/src/outbox-lifecycle.ts:188](../../packages/database/src/outbox-lifecycle.ts#L188) **How it works** @@ -137,6 +151,11 @@ Updating the status by `effect_id` alone, or reporting a delivery after Settles the claimed row as Failed with its failure tag, so a later claim retries it. ```ts +markOutboxFailed( + table: OutboxTable, + claim: OutboxClaim, + failureTag: string +): (sql: DatabaseOperations) => Effect.Effect markOutboxFailed( sql: DatabaseOperations, table: OutboxTable, @@ -154,7 +173,7 @@ markOutboxFailed( - Errors: `OutboxClaimLost | SqlError` - Requirements: none - Side effects: Writes the outbox row that the claim still owns. -- Source: [packages/database/src/outbox-lifecycle.ts:216](../../packages/database/src/outbox-lifecycle.ts#L216) +- Source: [packages/database/src/outbox-lifecycle.ts:247](../../packages/database/src/outbox-lifecycle.ts#L247) **How it works** @@ -178,6 +197,11 @@ later claim takes it again. Quarantine it with `quarantineOutboxClaim`. Settles the claimed row as Quarantined, a terminal status, with its failure tag. ```ts +quarantineOutboxClaim( + table: OutboxTable, + claim: OutboxClaim, + failureTag: string +): (sql: DatabaseOperations) => Effect.Effect quarantineOutboxClaim( sql: DatabaseOperations, table: OutboxTable, @@ -195,7 +219,7 @@ quarantineOutboxClaim( - Errors: `OutboxClaimLost | SqlError` - Requirements: none - Side effects: Writes the outbox row that the claim still owns. -- Source: [packages/database/src/outbox-lifecycle.ts:245](../../packages/database/src/outbox-lifecycle.ts#L245) +- Source: [packages/database/src/outbox-lifecycle.ts:291](../../packages/database/src/outbox-lifecycle.ts#L291) **How it works** @@ -220,6 +244,11 @@ is then never delivered. Mark it failed with `markOutboxFailed`. Returns an interrupted claim to Pending without a provider outcome; a lost claim needs none. ```ts +releaseOutboxClaim( + table: OutboxTable, + claim: OutboxClaim, + failureTag: string +): (sql: DatabaseOperations) => Effect.Effect releaseOutboxClaim( sql: DatabaseOperations, table: OutboxTable, @@ -237,7 +266,7 @@ releaseOutboxClaim( - Errors: `SqlError` - Requirements: none - Side effects: Writes the outbox row when the claim still owns it. -- Source: [packages/database/src/outbox-lifecycle.ts:282](../../packages/database/src/outbox-lifecycle.ts#L282) +- Source: [packages/database/src/outbox-lifecycle.ts:343](../../packages/database/src/outbox-lifecycle.ts#L343) **How it works** @@ -262,6 +291,11 @@ Settle an answered delivery with `markOutboxDelivered` or `markOutboxFailed`. Recovers every Processing row claimed before `claimedBefore`. ```ts +recoverStaleOutboxClaims( + table: OutboxTable, + claimedBefore: string, + recovery: OutboxStaleRecovery +): (sql: DatabaseOperations) => Effect.Effect recoverStaleOutboxClaims( sql: DatabaseOperations, table: OutboxTable, @@ -279,7 +313,7 @@ recoverStaleOutboxClaims( - Errors: `SqlError` - Requirements: none - Side effects: Writes every stale Processing row of the table. -- Source: [packages/database/src/outbox-lifecycle.ts:317](../../packages/database/src/outbox-lifecycle.ts#L317) +- Source: [packages/database/src/outbox-lifecycle.ts:393](../../packages/database/src/outbox-lifecycle.ts#L393) **How it works** diff --git a/docs/constructs/sql-lock.md b/docs/constructs/sql-lock.md index 14edc560e..9b9bc8135 100644 --- a/docs/constructs/sql-lock.md +++ b/docs/constructs/sql-lock.md @@ -17,7 +17,7 @@ const AdvisoryLockKey: AdvisoryLockKeys - Errors: none - Requirements: none - Side effects: none: it builds key text, and `lockAdvisory` takes the lock. -- Source: [packages/database/src/advisory-lock.ts:125](../../packages/database/src/advisory-lock.ts#L125) +- Source: [packages/database/src/advisory-lock.ts:126](../../packages/database/src/advisory-lock.ts#L126) **How it works** @@ -45,22 +45,26 @@ new namespace; a change to an existing one is a lock migration. Waits for the advisory lock on `key` until the current transaction ends. ```ts +lockAdvisory( + key: AdvisoryLockKey, + mode?: AdvisoryLockMode +): (sql: DatabaseOperations) => Effect.Effect lockAdvisory( sql: DatabaseOperations, key: AdvisoryLockKey, - mode: AdvisoryLockMode = "exclusive" + mode?: AdvisoryLockMode ): Effect.Effect ``` - Inputs: - `sql: DatabaseOperations` - `key: AdvisoryLockKey` - - `mode: AdvisoryLockMode = "exclusive"` + - `mode?: AdvisoryLockMode` - Output: `Effect.Effect` - Errors: `SqlError` - Requirements: none - Side effects: Holds a PostgreSQL advisory lock until the transaction ends, and waits while another transaction holds a conflicting one. -- Source: [packages/database/src/advisory-lock.ts:203](../../packages/database/src/advisory-lock.ts#L203) +- Source: [packages/database/src/advisory-lock.ts:204](../../packages/database/src/advisory-lock.ts#L204) **How it works** @@ -88,6 +92,9 @@ guards nothing, and hand-written `pg_advisory_xact_lock` SQL, which Serializes one person's protected command with person-keyed authority writers. ```ts +lockPersonAuthorization( + personId: PersonId +): (sql: DatabaseOperations) => Effect.Effect lockPersonAuthorization( sql: DatabaseOperations, personId: PersonId @@ -101,7 +108,7 @@ lockPersonAuthorization( - Errors: `OrganizationPersistenceError` - Requirements: none - Side effects: Holds the person's advisory lock until the transaction ends, and waits while another transaction holds it. -- Source: [packages/database/src/organization/authority-postgres.ts:66](../../packages/database/src/organization/authority-postgres.ts#L66) +- Source: [packages/database/src/organization/authority-postgres.ts:67](../../packages/database/src/organization/authority-postgres.ts#L67) **How it works** diff --git a/docs/constructs/test-harness.md b/docs/constructs/test-harness.md index 974b62c9a..b5c96a8be 100644 --- a/docs/constructs/test-harness.md +++ b/docs/constructs/test-harness.md @@ -50,10 +50,10 @@ journeyClock(reference: string): JourneyClock - Inputs: `reference: string` - Output: `JourneyClock` - Errors: none -- Throws: An `Error` when `reference` is not an instant that `Date.parse` reads. +- Throws: An `Error` when `reference` is not an instant that `DateTime.make` reads. - Requirements: none - Side effects: none -- Source: [tools/e2e/journey-clock.ts:50](../../tools/e2e/journey-clock.ts#L50) +- Source: [tools/e2e/journey-clock.ts:51](../../tools/e2e/journey-clock.ts#L51) **How it works** @@ -84,10 +84,10 @@ admissionJourneyClock(): JourneyClock - Inputs: none - Output: `JourneyClock` - Errors: none -- Throws: An `Error` when `ADMISSION_FIXED_NOW` is set to text that `Date.parse` does not read. +- Throws: An `Error` when `ADMISSION_FIXED_NOW` is set to text that `DateTime.make` does not read. - Requirements: none - Side effects: Reads `ADMISSION_FIXED_NOW` from the environment, and the current time when it is unset. -- Source: [tools/e2e/journey-clock.ts:89](../../tools/e2e/journey-clock.ts#L89) +- Source: [tools/e2e/journey-clock.ts:92](../../tools/e2e/journey-clock.ts#L92) **How it works** @@ -155,7 +155,7 @@ postgresProgram(program: PostgresProgram): string - Throws: An `Error` that names the selected major and what `PATH` provides, when the first `postgres` on `PATH` is missing or of another major. - Requirements: none - Side effects: Reads `PATH` and runs `postgres --version` at the first resolution of the process. -- Source: [tools/postgres/index.ts:178](../../tools/postgres/index.ts#L178) +- Source: [tools/postgres/index.ts:258](../../tools/postgres/index.ts#L258) **How it works** @@ -190,7 +190,7 @@ postgresVersion(): string - Throws: An `Error` when the first `postgres` on `PATH` is missing or of another major, as `postgresProgram` throws. - Requirements: none - Side effects: Reads `PATH` and runs `postgres --version` at the first resolution of the process. -- Source: [tools/postgres/index.ts:203](../../tools/postgres/index.ts#L203) +- Source: [tools/postgres/index.ts:283](../../tools/postgres/index.ts#L283) **How it works** @@ -221,7 +221,7 @@ loopbackPortFree(port: number): Promise - Errors: none - Requirements: none - Side effects: Binds `port` on loopback for the moment of the probe. -- Source: [tools/postgres/index.ts:260](../../tools/postgres/index.ts#L260) +- Source: [tools/postgres/index.ts:383](../../tools/postgres/index.ts#L383) **How it works** @@ -255,7 +255,7 @@ reserveLoopbackPorts(count: number): Promise> - Throws: Rejects with an `Error` when no port of the range is free. - Requirements: none - Side effects: Binds each drawn port on loopback for the moment of its probe, and keeps the reserved ports in the process, so that no later reservation returns them. -- Source: [tools/postgres/index.ts:309](../../tools/postgres/index.ts#L309) +- Source: [tools/postgres/index.ts:425](../../tools/postgres/index.ts#L425) **How it works** @@ -290,7 +290,7 @@ startDisposablePostgres(options: DisposablePostgresOptions = {}): Promise( + use: (databaseUrl: Redacted.Redacted) => Promise +): (database: string) => Promise withDisposablePostgres( database: string, use: (databaseUrl: Redacted.Redacted) => Promise @@ -332,12 +335,12 @@ withDisposablePostgres( - Throws: Rejects when the cluster does not start, as `startDisposablePostgres` rejects, and with the failure of `use`. - Requirements: none - Side effects: Starts and removes a disposable cluster, as `startDisposablePostgres` does. -- Source: [tools/postgres/index.ts:755](../../tools/postgres/index.ts#L755) +- Source: [tools/postgres/index.ts:1013](../../tools/postgres/index.ts#L1013) **How it works** `use` receives the connection URL of `database` as a `Redacted` value, so a log of it shows no -URL. The cluster stops in a `finally`, so the promise settles as `use` settled, after the +URL. The cluster stops after `use` settles, so the promise settles as `use` settled, after the cluster is gone. **Use** diff --git a/docs/constructs/worker.md b/docs/constructs/worker.md index 096228915..d74f81574 100644 --- a/docs/constructs/worker.md +++ b/docs/constructs/worker.md @@ -9,6 +9,9 @@ Runs background workers on the Effect clock. The [index](../constructs.md) lists Runs `tick` at once, then again after each success. ```ts +pollForever( + options: PollOptions +): (tick: Effect.Effect) => Effect.Effect pollForever( tick: Effect.Effect, options: PollOptions @@ -22,7 +25,7 @@ pollForever( - Errors: `E` - Requirements: `R` - Side effects: Waits on the Effect clock between ticks; every write is the tick's own. -- Source: [apps/backend/src/worker-support.ts:44](../../apps/backend/src/worker-support.ts#L44) +- Source: [apps/backend/src/worker-support.ts:45](../../apps/backend/src/worker-support.ts#L45) **How it works** diff --git a/docs/effect-exceptions.json b/docs/effect-exceptions.json index 914519ede..2635f12d0 100644 --- a/docs/effect-exceptions.json +++ b/docs/effect-exceptions.json @@ -19,14 +19,16 @@ "`just rehearsal organization-import` serves its native API through this handler." ], "owner": "apps/backend", - "examinedWith": { "effect": "4.0.0-rc.116" }, + "examinedWith": { + "effect": "4.0.0-rc.116" + }, "retirementTrigger": "HttpRouter.asHttpEffect() types its failure, or the callers build their handler with HttpRouter.toHttpEffect. Then remove the suppression." }, { "id": "EX-0002", "rules": ["FX008", "effecttsgo/unsafe-effect-type-assertion"], "scope": { - "files": ["apps/backend/src/http-api/problem.ts"], + "files": ["apps/backend/src/rpc/problem.ts"], "symbols": ["problemMapper"] }, "reason": "Each failure tag maps to the problem type that its case returns, and other failures pass through unchanged, so the type check of an endpoint still sees an unmapped failure.", @@ -37,11 +39,14 @@ "Effect.catchIf with a type guard, as unreachable in the same file does, narrows one known failure, not a record of cases." ], "verification": [ - "`just check-types` checks every endpoint whose problems a mapper produces.", - "The HTTP tests of the contexts decode the answered problems with the contract schemas, for example `apps/backend/src/content/http.test.ts`." + "`just check-types` checks every RPC whose problems a mapper produces.", + "The RPC tests of the contexts decode the answered problems through the contract's error schemas, for example `apps/backend/src/content/rpc.test.ts`." ], - "owner": "apps/backend/src/http-api", - "examinedWith": { "effect": "4.0.0-rc.116", "typescript": "7.0.2" }, + "owner": "apps/backend/src/rpc", + "examinedWith": { + "effect": "4.0.0-rc.116", + "typescript": "7.0.2" + }, "retirementTrigger": "TypeScript infers the mapped failure union from the generic cases record. Then remove the assertion and the suppression." }, { @@ -62,7 +67,10 @@ "No test replaces a path between the check and the open; review verifies the O_NOFOLLOW open." ], "owner": "apps/backend/src/receipt", - "examinedWith": { "effect": "4.0.0-rc.116", "@effect/platform-bun": "4.0.0-rc.116" }, + "examinedWith": { + "effect": "4.0.0-rc.116", + "@effect/platform-bun": "4.0.0-rc.116" + }, "retirementTrigger": "Effect FileSystem.open can refuse a symbolic link, for example with a no-follow flag. Then open through FileSystem and remove the node:fs/promises import." }, { @@ -80,7 +88,9 @@ ], "verification": ["`just e2e interview-response` runs the spec in the hosted journey matrix."], "owner": "apps/dashboard", - "examinedWith": { "@phibkro/oxlint-effect-plugin": "0.1.0" }, + "examinedWith": { + "@phibkro/oxlint-effect-plugin": "0.1.0" + }, "retirementTrigger": "The callback moves to a module that oxlint.config.ts declares platform browser, or effect/no-cross-runtime recognizes Playwright page callbacks. Then remove the suppressions." }, { @@ -100,7 +110,10 @@ "`just e2e identity` signs in, reads, and revokes sessions through the backend." ], "owner": "packages/database", - "examinedWith": { "effect": "4.0.0-rc.116", "@effect/tsgo": "0.36.4" }, + "examinedWith": { + "effect": "4.0.0-rc.116", + "@effect/tsgo": "0.36.4" + }, "retirementTrigger": "No caller runs these operations inside its own transaction. Then resolve Database when the layer is built and remove the expectation." }, { @@ -120,29 +133,11 @@ "`just e2e content-publication` publishes articles through the backend." ], "owner": "packages/domain/src/content", - "examinedWith": { "effect": "4.0.0-rc.116", "@effect/tsgo": "0.36.4" }, - "retirementTrigger": "The capability topology lets ContentManagement depend on Organization. Then provide Organization when the layer is built and remove the expectation." - }, - { - "id": "EX-0007", - "rules": ["FX006", "effecttsgo/leaking-requirements"], - "scope": { - "files": ["packages/http-api/src/common.ts"], - "symbols": ["PersonSecurity", "PersonOrServiceSecurity"] + "examinedWith": { + "effect": "4.0.0-rc.116", + "@effect/tsgo": "0.36.4" }, - "reason": "HttpApiMiddleware security handlers run per request, so they require the request, its parsed search parameters, and its route context.", - "missingCapability": "Effect does not mark HttpServerRequest, ParsedSearchParams, or RouteContext with @effect-leakable-service, so effecttsgo/leaking-requirements reports the security services that require them.", - "nativeAlternatives": [ - "Resolving the request services when the layer is built: they exist only per request.", - "Marking the services @effect-leakable-service: the effect package declares them, and this repository does not edit it." - ], - "verification": [ - "`just check-types` checks the security middleware of every endpoint that declares it.", - "`apps/backend/src/receipt/http.test.ts` sends person and service bearers through both, because the receipt group declares them." - ], - "owner": "packages/http-api", - "examinedWith": { "effect": "4.0.0-rc.116", "@effect/tsgo": "0.36.4" }, - "retirementTrigger": "Effect marks these per-request services leakable, or @effect/tsgo stops reporting the requirements of HttpApiMiddleware services. Then remove the expectations." + "retirementTrigger": "The capability topology lets ContentManagement depend on Organization. Then provide Organization when the layer is built and remove the expectation." }, { "id": "EX-0008", @@ -162,65 +157,284 @@ "No test replaces the path with a symbolic link or widens its mode; review verifies the O_NOFOLLOW open and the checks of the opened file." ], "owner": "packages/database", - "examinedWith": { "effect": "4.0.0-rc.116", "@effect/platform-bun": "4.0.0-rc.116" }, + "examinedWith": { + "effect": "4.0.0-rc.116", + "@effect/platform-bun": "4.0.0-rc.116" + }, "retirementTrigger": "Effect FileSystem.open can refuse a symbolic link, for example with a no-follow flag. Then open through FileSystem and remove the node:fs/promises import." }, { - "id": "EX-0009", - "rules": ["FX011", "effect/no-ambient-console"], + "id": "EX-0010", + "rules": ["FX002", "effecttsgo/missing-pipeable-signature"], + "scope": { + "files": ["apps/backend/src/receipt/import-snapshot.ts"], + "symbols": ["decodeSnapshot"] + }, + "reason": "decodeSnapshot is the Schema decoder of a receipt snapshot with excess properties refused. Its callers pass fixtures and malformed rows whose static type is not JSON, such as rows with `data: unknown`, because the importer must quarantine them; its second parameter is the parse options of the decoder, not the subject of a data-last call.", + "missingCapability": "None in Effect. Schema.decodeUnknownSync returns `(input: unknown, options?: ParseOptions) => A`, and the rule asks for a pipeable overload of that signature, which a decoder of one subject has no use for.", + "nativeAlternatives": [ + "A one-parameter wrapper typed `(input: Schema.Json)`, as the domain decoders use: the receipt import rehearsal and the snapshot test pass rows whose data is `unknown` or `Json | undefined`, so each caller would have to change its fixture types.", + "A one-parameter wrapper typed `(input: unknown)`: `anti-slop/no-unknown-parameters` rejects it.", + "Exporting ReceiptSnapshot and decoding at each call site: every caller would repeat `onExcessProperty: \"error\"`, a fact that one decoder should own.", + "`dual` from `effect/Function`: the second parameter is parse options, so a data-last form reads the options as the subject." + ], + "verification": [ + "`apps/backend/src/receipt/import-snapshot.test.ts` decodes a synthetic snapshot through it.", + "`just rehearsal receipt-import` (`tools/verification/receipt-import-rehearsal.ts`) decodes its manifest through it, and `tools/e2e/run-legacy-receipt-rehearsal.ts` checks that it rejects another snapshot kind." + ], + "owner": "apps/backend/src/receipt", + "examinedWith": { + "effect": "4.0.0-rc.116", + "@effect/tsgo": "0.36.4" + }, + "retirementTrigger": "The callers decode JSON-typed input, or the rule exempts Schema decoders. Then give decodeSnapshot a one-parameter form and remove the suppression." + }, + { + "id": "EX-0011", + "rules": ["FX002", "effecttsgo/node-builtin-import"], "scope": { - "files": ["tools/acceptance/recommendation-check.ts"], + "files": ["apps/docs/site.ts", "apps/docs/remark-repository-links.ts"], + "symbols": ["specs", "title", "remarkRepositoryLinks"] + }, + "reason": "Vocs loads `vocs.config.ts` under Node and calls its sidebar builders and remark plugins synchronously: `site.ts` lists the specifications and reads each page's first heading while the configuration evaluates, and the remark plugin checks whether a linked repository path exists while it transforms a page.", + "missingCapability": "Effect FileSystem has no synchronous backend: the Node and Bun layers implement every operation asynchronously, so Effect.runSync cannot read a directory or a file.", + "nativeAlternatives": [ + "Effect.runSync with the FileSystem service: fails with an asynchronous operation.", + "An asynchronous configuration that runs an Effect program with Effect.runPromise: Vite awaits a Promise default export, but the production branch of the Vocs 2.10.0 resolver spreads the default export without awaiting it, and the remark plugin would run a program outside a composition root in every transform.", + "Precomputing titles and link targets in `scripts/sync-pages.ts`: the remark plugin resolves links to any repository path, and `vocs dev` would read stale titles until the script ran again." + ], + "verification": [ + "`bun run --cwd apps/docs build` renders every published page, builds the sidebar from `site.ts`, and fails on a dead link that the remark plugin leaves unchanged.", + "The Docs workflow runs that build." + ], + "owner": "apps/docs", + "examinedWith": { + "effect": "4.0.0-rc.116" + }, + "retirementTrigger": "Vocs awaits an asynchronous configuration in every branch and remark plugins may run Effect programs through a runtime bridge, or Effect gains a synchronous FileSystem. Then read through FileSystem and remove the suppressions." + }, + { + "id": "EX-0012", + "rules": ["FX002", "effecttsgo/node-builtin-import"], + "scope": { + "files": ["tools/e2e/golden-harness.ts"], + "symbols": ["respond", "startProvider"] + }, + "reason": "The loopback notification provider of the golden harness is an HTTP listener on a reserved loopback port. It records every attempt, counts active requests until each response closes, and closes every connection on release, so that cleanup verification finds the port free.", + "missingCapability": "Effect's HttpServer needs a platform server. The Bun one, BunHttpServer of @effect/platform-bun, brings Bun's global types into the Node-typed e2e program (tools/e2e/tsconfig.json), where they change the global Request and fetch types and fail the type check of packages/database/src/oauth-live.ts. No Node platform package is a dependency of tools/e2e.", + "nativeAlternatives": [ + "BunHttpServer.make with HttpServer.serve: fails `tsc -p tools/e2e/tsconfig.json` through the Bun global types, and its release stops the server gracefully instead of closing idle connections.", + "NodeHttpServer of @effect/platform-node: not a dependency, and the Bun composition-root group admits no Node platform layer." + ], + "verification": [ + "`bun --no-env-file tools/e2e/golden-harness-self-test.ts` asserts the accepted, failed, and unauthorized attempts, and that every port rebinds after release, in six probe runners.", + "`bun --no-env-file tools/e2e/golden-team-application.ts` delivers its notifications through the provider." + ], + "owner": "tools/e2e", + "examinedWith": { + "effect": "4.0.0-rc.116", + "@effect/platform-bun": "4.0.0-rc.116" + }, + "retirementTrigger": "The e2e program type-checks with Bun's global types, or a Node platform server becomes a dependency. Then serve the provider through HttpServer and remove the node:http import." + }, + { + "id": "EX-0013", + "rules": ["FX002", "effecttsgo/node-builtin-import"], + "scope": { + "files": ["tools/scripts/heavy-lock.ts"], + "symbols": ["flockDescriptor", "openLockFile", "replaceLockText"] + }, + "reason": "The machine-wide heavy lock and the hook slots are flock(1) locks on the open file description of a lock file that the holder keeps open until it exits. flock(1) locks the descriptor that it receives as standard input, and the holder truncates and writes that same descriptor to name itself, so waiting processes can show the live holders.", + "missingCapability": "Effect's ChildProcess accepts only \"pipe\", \"inherit\", \"ignore\", \"overlapped\", or a Stream as standard input, never a descriptor of this process, and FileSystem.File does not expose its descriptor in the examined version.", + "nativeAlternatives": [ + "ChildProcess.make with a Stream as stdin: flock(1) would lock a pipe, not the lock file.", + "flock(1) with a file path and the job as its command: the lock would belong to flock's process, and the holder could not take the gate and the lock in two steps or write its line before the job starts.", + "FileSystem.open with an additional descriptor: the File handle has no descriptor to pass, and additional descriptors of ChildProcess are pipes." + ], + "verification": [ + "`bun test tests` in tools/scripts runs `tests/heavy-lock.test.ts`: two heavy jobs serialize, a hook job waits for a heavy job, a heavy job waits for running hook jobs and before later ones, nested jobs do not wait for themselves, and a killed holder releases the lock.", + "Every heavy job of `just measure` takes the lock." + ], + "owner": "tools/scripts", + "examinedWith": { + "effect": "4.0.0-rc.116", + "@effect/platform-bun": "4.0.0-rc.116" + }, + "retirementTrigger": "ChildProcess accepts a descriptor of this process as standard input and FileSystem.File exposes its descriptor, or the lock moves to an Effect service that owns flock(2). Then open the lock files through FileSystem, pass their descriptors through ChildProcess, and remove the node imports." + }, + { + "id": "EX-0014", + "rules": ["FX002", "effecttsgo/process-env"], + "scope": { + "files": ["tools/scripts/vitest-admission.ts"], + "symbols": ["setup"] + }, + "reason": "The Vitest global setup admits a run under the heavy lock and a hook slot, and the workers that Vitest starts afterwards must learn that their main process holds the lock, so that a `just measure` or a nested Vitest run inside a test does not wait for its own holder. Vitest starts its workers with the environment of its main process, so the setup writes VEKTORPROGRAMMET_HEAVY_LOCK into it.", + "missingCapability": "None in Effect: Config reads the environment and ChildProcess sets the environment of a child, but no Effect construct changes the environment of the running process, and Vitest, not this program, starts the workers.", + "nativeAlternatives": [ + "ChildProcess with `env`: Vitest spawns its workers itself.", + "Vitest's `provide` of the global setup: it reaches the tests through `inject`, not the environment of the processes that a test starts." + ], + "verification": [ + "A focused Vitest run, such as `bun run --cwd tools/postgres vitest run`, prints the admission lines and runs `tools/postgres/teardown.test.ts`, whose `just measure` child inside the run does not wait for its holder." + ], + "owner": "tools/scripts", + "examinedWith": { + "effect": "4.0.0-rc.116" + }, + "retirementTrigger": "Vitest passes an environment to its workers from the global setup, or the admission moves into the scripts that start Vitest. Then remove the write." + }, + { + "id": "EX-0015", + "rules": ["FX002", "effecttsgo/node-builtin-import"], + "scope": { + "files": ["tools/scripts/job-process.ts"], + "symbols": ["runJob"] + }, + "reason": "`just measure` and a hook slot run a job in their own process group with the terminal, forward SIGINT, SIGTERM, and SIGHUP to it, record in the ledger whether the job ended with an exit code or a signal, and then end the same way, so the caller sees the job's own status.", + "missingCapability": "A ChildProcess handle fails `exitCode` with an Unknown PlatformError when a signal ends the process, and names the signal only inside the message of its cause. Its scoped release also signals the child's process group, which a job that shares this process's group does not have.", + "nativeAlternatives": [ + "ChildProcess with `detached: false` and the signal parsed from the message of the PlatformError: the ledger and the exit would depend on the wording of an error message.", + "ChildProcess with the default `detached: true`: the job leaves the terminal's process group, so a terminal interrupt reaches it only through forwarding, and the release kills the processes that the ledger counts as surviving." + ], + "verification": [ + "`bun test tests` in tools/scripts runs real measure-job and hook-slot jobs to their exit codes in `tests/heavy-lock.test.ts`.", + "`bun --no-env-file tools/scripts/measure-job.ts --class probe -- true` appends a ledger row with its exit code, and every heavy job runs through it. No test sends a signal to a measured job." + ], + "owner": "tools/scripts", + "examinedWith": { + "effect": "4.0.0-rc.116", + "@effect/platform-bun": "4.0.0-rc.116" + }, + "retirementTrigger": "A ChildProcess handle reports the terminating signal as a value. Then spawn the job through ChildProcess with `detached: false` and remove the node import." + }, + { + "id": "EX-0016", + "rules": ["FX002", "effecttsgo/global-fetch"], + "scope": { + "files": ["tools/acceptance/returning-assistant-journey.ts"], + "symbols": ["runReturningAssistantBrowserJourney"] + }, + "reason": "The returning journey proves that the browser page itself calls the RPC endpoint across origins with its own session cookie, as CORS admits it. Playwright serializes the page.evaluate callback and runs it in the page, so the call must be the page's fetch with credentials: include.", + "missingCapability": "effecttsgo/global-fetch judges code by its module, which runs under Bun. It cannot see that Playwright runs a page.evaluate callback in the browser page, where no Effect runtime or HttpClient service exists.", + "nativeAlternatives": [ + "HttpClient of effect from the probe process: it sends no browser cookie jar and no Origin of the page, so it does not observe the browser's CORS path.", + "Playwright's APIRequestContext: it shares the context's cookies but is not the page's fetch, so it bypasses CORS as well.", + "A string expression for page.evaluate: it hides the same fetch from the check instead of registering it." + ], + "verification": [ + "`bun --no-env-file tools/acceptance/recommendation-check.ts --returning-mode` runs the options probe; the hosted journey matrix runs it as `just e2e recommendation-returning`." + ], + "owner": "tools/acceptance", + "examinedWith": { + "effect": "4.0.0-rc.116" + }, + "retirementTrigger": "The effecttsgo rules recognize Playwright page callbacks, or the callback moves to a module whose platform is the browser. Then remove the suppression." + }, + { + "id": "EX-0017", + "rules": ["FX003"], + "scope": { + "files": ["tools/e2e/golden-school-service-evidence.ts", "tools/e2e/safe-file-io.ts"], "symbols": [ - "recordGate", - "stage", - "ReturningLoginProbeComplete", - "ReturningTargetedComplete", - "ApplicantProgressTargetedComplete", - "CoInterviewerTargetedComplete", - "CorrectionTargetedComplete", - "failureEvidence" + "runForPromiseCaller", + "dashboardBuildInventory", + "inspectGoldenEvidence", + "stageGoldenEvidence", + "withProjectionFileLock" ] }, - "reason": "The recommendation probe reports its fixture check, its gate milestones, its returning stages, its result, and its failure evidence as lines on standard output and standard error, which the operator reads. Each line is bounded, and the failure evidence is redacted.", - "missingCapability": "None in Effect: Console of effect writes the same lines, but only inside an Effect program. The probe is a script of top-level Promise code with no Effect runtime at its entry point.", + "reason": "The evidence reader of the golden school-service journey and the projection file lock are Effect programs, and callers that are no Effect programs yet import them: the CI wrapper `golden-school-service-ci.mjs` awaits the evidence inspection and staging, the browser driver `apps/dashboard/e2e/run-real-native-placement.mjs` awaits the dashboard build inventory, and the conduct driver `apps/dashboard/e2e/run-real-native-recruitment-interview-conduct.mjs` awaits the file lock. Each Promise form runs its Effect form once, the evidence forms with the Bun platform, and rejects with the error that the Effect form failed or died with, so the callers keep their behaviour.", + "missingCapability": "None in Effect: Effect.runPromise is the bridge. The requirement stays until the callers become Effect programs.", "nativeAlternatives": [ - "Console.log and Console.error of effect, each run with Effect.runSync at its site: that runs programs outside an owned entry point, which FX003 and effect/no-premature-execution reject.", - "One Effect program that the entry point of the probe runs: a rewrite of the whole probe, which is the retirement path." + "Rewriting the three callers as Effect programs: they belong to other slices of the rewrite of tools and apps/dashboard/e2e.", + "Keeping async functions in the modules: effecttsgo/async-function rejects them." ], "verification": [ - "The hosted journey matrix runs the probe as `just e2e recommendation-applicant-progress`, `recommendation-co-interviewer`, `recommendation-correction`, and `recommendation-returning`.", - "The plugin accepts a directive only when it names no-ambient-console alone with a nonempty `dev only:` reason, and it reports broad, unreasoned, and unused directives." + "`bun test tools/e2e/golden-school-service-evidence.test.ts` runs the Effect forms of the evidence reader and staging and the CI wrapper's early failure.", + "`just golden school-service` runs the browser driver, which awaits dashboardBuildInventory.", + "`just e2e conduct` runs the conduct driver, which awaits withProjectionFileLock." ], - "owner": "tools/acceptance", - "examinedWith": { "@phibkro/oxlint-effect-plugin": "0.1.0", "effect": "4.0.0-rc.116" }, - "retirementTrigger": "The probe becomes an Effect program that its entry point runs, and it writes through Console of effect. Then remove the directives." + "owner": "tools/e2e", + "examinedWith": { + "effect": "4.0.0-rc.116", + "@effect/platform-bun": "4.0.0-rc.116" + }, + "retirementTrigger": "The CI wrapper, the placement browser driver, and the conduct driver become Effect programs. Then they use readGoldenEvidence, stageEvidence, readDashboardBuild, and projectionFileLock, and the Promise forms go." }, { - "id": "EX-0010", - "rules": ["FX001", "FX012"], + "id": "EX-0018", + "rules": ["FX002", "effecttsgo/node-builtin-import"], + "scope": { + "files": ["tools/e2e/safe-file-io.ts"], + "symbols": [ + "acquireFileLock", + "openDirectoryPathNoFollow", + "writeFilePathNoFollow", + "projectionLockPath" + ] + }, + "reason": "Journey drivers write parity evidence into a shared projection directory. The writer must refuse a symbolic link in any path component and a hard-linked target, so it walks the path one component at a time through /proc/self/fd with O_NOFOLLOW and O_DIRECTORY, checks the link count with fstat, and serializes writers across processes with flock(2) on the descriptor of a lock file.", + "missingCapability": "FileSystem.open takes a flag string without O_NOFOLLOW, O_DIRECTORY, or O_NONBLOCK, opens no path relative to a directory descriptor, and FileSystem.File exposes no descriptor for flock(2) in the examined version. The descriptor walk resolves its paths synchronously, where the Path service answers through an Effect.", + "nativeAlternatives": [ + "FileSystem.open and File.stat: no O_NOFOLLOW, no descriptor-relative open, and no descriptor for flock(2).", + "FileSystem.stat before each write: it follows links, and a check before the open leaves a race that O_NOFOLLOW closes." + ], + "verification": [ + "`just e2e conduct` writes its screenshots and runtime evidence through writeFilePathNoFollow under projectionFileLock." + ], + "owner": "tools/e2e", + "examinedWith": { + "effect": "4.0.0-rc.116" + }, + "retirementTrigger": "FileSystem opens with O_NOFOLLOW relative to a directory handle and exposes a descriptor or an advisory lock. Then write through FileSystem and remove the node:fs and node:path imports." + }, + { + "id": "EX-0019", + "rules": ["FX002", "effecttsgo/process-env"], + "scope": { + "files": ["tools/e2e/journey-clock.ts"], + "symbols": ["admissionJourneyClock"] + }, + "reason": "admissionJourneyClock is a synchronous construct: seeds, Playwright specs, and drivers that are no Effect programs call it at module load or inside Promise code, and it must read ADMISSION_FIXED_NOW at each call, the variable that the backend's admission clock reads, so that fixture instants lie where the backend sees them.", + "missingCapability": "None in Effect: Config reads the variable through an Effect, which a synchronous caller outside an Effect runtime cannot run without a runtime bridge of its own.", + "nativeAlternatives": [ + "Config.option(Config.String(\"ADMISSION_FIXED_NOW\")): an Effect, which the synchronous callers cannot yield.", + "A parameter for the instant: every caller would read the environment itself instead." + ], + "verification": [ + "`just golden school-service` and `just e2e interview-response` derive fixture instants from the clock." + ], + "owner": "tools/e2e", + "examinedWith": { + "effect": "4.0.0-rc.116" + }, + "retirementTrigger": "Every caller of admissionJourneyClock is an Effect program. Then read ADMISSION_FIXED_NOW through Config in an Effect form and remove the synchronous one." + }, + { + "id": "EX-0020", + "rules": ["FX002", "effecttsgo/node-builtin-import"], "scope": { - "files": [ - "packages/sdk/src/effect-client.ts", - "packages/sdk/src/__tests__/generated-native-client.test.ts" - ], - "symbols": ["EffectSdk"] + "files": ["tools/postgres/index.ts"], + "symbols": ["spawnPostmaster", "executable", "versionOf", "supportedPostgresMajors"] }, - "reason": "Command endpoints of the contract, such as placements.commandBoard, take a union of commands as their payload, and the dashboard sends a command that it has not narrowed to one member. patches/effect@4.0.0-rc.116.patch changes the declaration type HttpApiEndpoint.ClientRequest so that a generated client method takes the whole payload type. The client runtime is unchanged.", - "missingCapability": "HttpApiEndpoint.ClientRequest of effect 4.0.0-rc.116, unchanged in 4.0.0-rc.117 and on the main branch of effect-smol on 2026-09-27, distributes a union payload over its members: a method takes { payload: A } | { payload: B }, not { payload: A | B }, so a payload typed as the union fails with TS2345. Its conditional P extends Brand<…> names Payload[\"Type\"] in the false branch, and TypeScript narrows that to the member P inside the true branch of Payload[\"Type\"] extends infer P. A program with one union endpoint reproduces it with TypeScript 7.0.2 and 5.9.3.", + "reason": "The disposable cluster must outlive a crash of its owner and still be removed by its sentinel. The postmaster writes its log to a file descriptor, runs in its own process group without holding the owner alive, and gets each shutdown signal alone, so that it relays a fast or immediate shutdown to its backends. The exported values supportedPostgresMajors, postgresProgram, and postgresVersion are synchronous, so the manifest and the installation on PATH resolve synchronously.", + "missingCapability": "Effect's ChildProcess accepts only \"pipe\", \"inherit\", \"ignore\", \"overlapped\", or a Stream or Sink as standard output and error, never a descriptor of a file, and its kill and scoped release signal the whole process group of a detached child. FileSystem and ChildProcessSpawner have no synchronous operations in the examined version.", "nativeAlternatives": [ - "Narrowing each command with Match and calling the method once per member: every caller of a union endpoint then repeats one identical call per command.", - "Asserting the request type at each call site: the assertion removes the check of the request against the contract.", - "One endpoint per command, or a struct that wraps the union: both change the HTTP contract and the wire format of every command endpoint.", - "Deriving the SDK client type again with a corrected request type: it copies HttpApiClient.Client.Method and its unexported MethodReturn from the Effect source, and the copy drifts with each release." + "ChildProcess with a Sink that appends the server log to a FileSystem file: the log passes through a pipe of the owner, so the server loses its log, and meets a broken pipe, when the owner dies before the sentinel stops it.", + "ChildProcess kill with SIGINT, SIGQUIT, and SIGKILL: the node-shared spawner signals the process group of the detached postmaster, so every backend gets the postmaster's shutdown signal directly instead of the postmaster's orderly relay.", + "FileSystem.access, FileSystem.readFileString, and ChildProcessSpawner.string for the installation and the manifest: each is asynchronous, so the synchronous exported values would have to become Effects or Promises, which changes the construct's contract for every journey that reads them." ], "verification": [ - "`bun run --cwd packages/sdk check-types` checks the type test \"accepts the complete command union through one Promise endpoint\" in packages/sdk/src/__tests__/generated-native-client.test.ts, which fails with TS2344 without the patch.", - "`just check-types` checks the dashboard callers that send an unnarrowed command union: apps/dashboard/app/foldkit/recruitment-maintenance/command.ts, apps/dashboard/app/foldkit/schools/browser-client.ts, and apps/dashboard/app/routes/dashboard.assistenter._index.tsx. Without the patch each fails with TS2345.", - "The patch changes only dist/unstable/httpapi/HttpApiEndpoint.d.ts and its source. The contract tests of the same test file run the unchanged client runtime." + "`bun run --cwd tools/postgres test` runs teardown.test.ts, which ends a real owner by an uncaught failure, SIGKILL of the owner and of its process group, SIGTERM through `just measure`, and a tool timeout, and asserts that no directory, server, listener, or sentinel remains; readiness.test.ts and index.test.ts cover readiness and the major selection.", + "Every journey, proof, and rehearsal that starts PostgreSQL runs through startDisposablePostgres, for example `just golden team-application` and `just proof delivery-recovery`, and the database suite starts clusters through it and through startDisposablePgBouncer." ], - "owner": "packages/sdk", - "examinedWith": { "effect": "4.0.0-rc.116", "typescript": "7.0.2" }, - "retirementTrigger": "An Effect release whose HttpApiEndpoint.ClientRequest takes the whole union as the payload of a union endpoint. Then remove the patch and its patchedDependencies entry, delete this entry and the comments that name it, and keep the type test, which then checks the release." + "owner": "tools/postgres", + "examinedWith": { + "effect": "4.0.0-rc.116", + "@effect/platform-bun": "4.0.0-rc.116" + }, + "retirementTrigger": "ChildProcess accepts a file descriptor as standard output and error and signals a detached child alone, and the synchronous exports become Effects. Then spawn the postmaster through ChildProcess, resolve the manifest and the installation through FileSystem and ChildProcessSpawner, and remove the node imports." } ] } diff --git a/docs/enterprise-models.md b/docs/enterprise-models.md index 54de75c05..c78544dda 100644 --- a/docs/enterprise-models.md +++ b/docs/enterprise-models.md @@ -283,7 +283,7 @@ time. The arrows do not grant authority by themselves. ```mermaid flowchart TB - TC01["TR-C01 Public homepage"] --> TC03["TR-C03 Generated SDK and HTTP contract"] + TC01["TR-C01 Public homepage"] --> TC03["TR-C03 Effect RPC contract and derived clients"] TC02["TR-C02 Authenticated dashboard"] --> TC03 TC03 --> TC04["TR-C04 Effect backend runtime"] TC04 --> TC05["TR-C05 Domain services"] @@ -298,7 +298,7 @@ flowchart TB | -------- | ----------------------------------------------------------------------------------------------------------------- | | `TR-Q01` | Keep one modular backend until an observed operational need requires another deployment boundary. | | `TR-Q02` | Keep business rules and capability requirements in the domain package. | -| `TR-Q03` | Derive HTTP, OpenAPI, and SDK artifacts from one contract. | +| `TR-Q03` | Derive every RPC handler and client from one contract. RPC produces no OpenAPI document. | | `TR-Q04` | Encode every external or durable boundary with Effect Schema. | | `TR-Q05` | Use PostgreSQL constraints, transactions, locks, and compare-and-set revisions for owned invariants. | | `TR-Q06` | Enforce the same relationship-based authority for commands and queries. | @@ -500,7 +500,7 @@ flowchart LR AS03 -->|serving| BPR05["BUS-PRC05 Expense reimbursement"] ``` -`packages/domain`, `packages/database`, `packages/http-api`, and `packages/sdk` are +`packages/domain`, `packages/database`, and `packages/rpc` are implementation modules within these components and interfaces. Their exact dependency graph remains authoritative in [architecture.md](architecture.md). diff --git a/docs/module-developer-documentation.md b/docs/module-developer-documentation.md index 35e9451a9..57e9e30ca 100644 --- a/docs/module-developer-documentation.md +++ b/docs/module-developer-documentation.md @@ -66,7 +66,7 @@ Pure calculations remain direct functions. Documentation does not introduce a Se | Supported imports | Package export maps | Derive the public entry-point inventory | | Signatures and schemas | TypeScript declarations | Generate reference material | | API-specific guarantees | Documentation beside public declarations | Render that documentation without maintaining a second copy | -| HTTP operations | Existing HTTP API definitions | Reuse the OpenAPI document generated from those definitions | +| RPC operations | The RPC groups of `packages/rpc` | Link the contract source; RPC has no OpenAPI document | | Working examples | Executable TypeScript files | Include those exact files, rather than copied code blocks | | Business meaning | [Intended system](system.md) | Link the relevant rule instead of restating it | | Architectural ownership | [Architecture](architecture.md#ownership) | Link the owner and dependency rules | @@ -99,10 +99,10 @@ Their commands state required tools, configuration, cleanup, and evidence limits ## Existing tooling and adoption -The repository already has a documentation entry point, system guides, package export maps, and generated HTTP artifacts. +The repository already has a documentation entry point, system guides, package export maps, and the RPC contract. The [documentation site](../apps/docs/site.ts) renders the repository's Markdown and MDX documents in place with Vocs. It publishes existing documents; it is not a module reference or example-validation system. -The [HTTP generator](../packages/http-api/scripts/generate-openapi.ts) derives OpenAPI from the contract before each type check, so no committed copy can drift. +The [RPC contract](../packages/rpc/src/api.ts) is its own reference: nothing is generated from it, so no copy can drift. It produces no OpenAPI document ([architecture](architecture.md#rpc-contract-and-ingress)). The pilot evaluated [Effect docgen](https://github.com/Effect-TS/docgen) before selecting a maintained alternative. The [guide tool-choice record](../packages/domain/src/placements/README.md#tool-choice) owns the qualification result and compatibility limits. @@ -163,11 +163,11 @@ Review compares important explanations with implementation and behavioral eviden ## Development sequence -| Slice | Deliverable | Acceptance | -| ---------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| A: Placements pilot | Consumer guide, maintainer guide, public-import examples, and reference generation | The pilot acceptance conditions hold | -| B: Required checks | Documentation checks in existing credential-free CI | Broken examples and stale reference material fail the required check | -| C: Capability coverage | Apply the accepted pattern to Recruitment, Identity, Receipts, and the SDK | Each capability passes its own bounded contract | +| Slice | Deliverable | Acceptance | +| ---------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| A: Placements pilot | Consumer guide, maintainer guide, public-import examples, and reference generation | The pilot acceptance conditions hold | +| B: Required checks | Documentation checks in existing credential-free CI | Broken examples and stale reference material fail the required check | +| C: Capability coverage | Apply the accepted pattern to Recruitment, Identity, Receipts, and the RPC contract | Each capability passes its own bounded contract | Each slice receives its own implementation specification. The roadmap does not authorize one repository-wide documentation rewrite. diff --git a/docs/operational-responsibility-map.md b/docs/operational-responsibility-map.md index 7d5a58b24..6380c4799 100644 --- a/docs/operational-responsibility-map.md +++ b/docs/operational-responsibility-map.md @@ -228,7 +228,7 @@ Current-source reconciliation must resolve combined teaching blocks and ambiguou | Seam | Required proof before claiming the contract | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Human client to server | Exercise the generated HTTP and SDK journey, including denied scope, stale write, retry, and recovery. Credential routes remain separate. | +| Human client to server | Exercise the RPC journey, including denied scope, stale write, retry, and recovery. Credential routes remain separate. | | Service to server | Exercise a real service credential, scoped grants, and revocation through mounted ingress without a Person cookie. Network isolation grants no authority. | | Server to PostgreSQL | Verify atomic state, revision, evidence, audit, and outbox writes. Denial, conflict, and failed commit leave no partial state. Verify restore and replay. | | Server to providers | Verify real mail, private storage, acknowledgement, restart, and bounded retry on the exact candidate. Include SMS only for required active journeys. | diff --git a/docs/specs/architecture-consolidation.md b/docs/specs/architecture-consolidation.md index 4e8508b5d..6c52b5c87 100644 --- a/docs/specs/architecture-consolidation.md +++ b/docs/specs/architecture-consolidation.md @@ -113,7 +113,7 @@ Messaging context that would read it is unbuilt. This is a dead outbox. | Live `*_audit` tables | 22 | 21 | `school_survey_audit` dropped by 0073 | | History tables | — | 5 | 2 are the receipt-fused organization tables; 3 are pure evidence | | Files hand-writing audit or history INSERTs | — | 25 | [scout] | -| Shared receipt construct at the HTTP layer | — | 1 | `executeNativeHttpCommandPostgres` (`apps/backend/src/http-api/receipt-transaction.ts:250`), used by 12 of 12 command contexts, 0 bypass | +| Shared receipt construct at the HTTP layer | — | 1 | `executeNativeHttpCommandPostgres` (`apps/backend/src/rpc/receipt-transaction.ts:252`), used by 12 of 12 command contexts, 0 bypass | | Shared receipt construct at the domain layer | — | 0 | all 17 domain sites hand-write read, digest compare, and insert | | Shared audit construct | — | 0 | 25 files hand-write the insert | | `CHECK` clauses | 927 | 928 | text occurrences | @@ -191,7 +191,7 @@ sequences, the PersistedQueue store, the onboarding sweeper (an idempotent | Construct | State | Requirement | Restart | Two replicas | | --- | --- | --- | --- | --- | -| `publicRateLimit` (`apps/backend/src/http-api/public-rate-limit.ts:21`) | in-memory `Map`, key from `publicRateLimitKey` (`:10`), which returns the literal `"public"` for every request | rate limit per visitor address | counters reset | the effective limit doubles | +| `publicRateLimit` (`apps/backend/src/rpc/public-rate-limit.ts:14`) | in-memory `Map`, key from `publicRateLimitKey` (`:12`), which returns the literal `"public"` for every request | rate limit per visitor address | counters reset | the effective limit doubles | | Better Auth rate limiter (`packages/database/src/auth-engine.ts`) | vendor default in-memory `Map` | rate limit per credential or address | counters reset | the effective limit doubles | | `ADMISSION_FIXED_NOW` (`apps/backend/src/admission/config.ts:44`) | one instant per process | none; it is a journey pin | the pin persists until restart | each replica pins its own instant | | `ManagedRuntime` (`main.ts:169`) | the process runtime | real | rebuild on start | no shared state | @@ -564,7 +564,7 @@ TABLE|INDEX|TRIGGER|FUNCTION`, `TRUNCATE`): | Area | Files with SQL statements | Non-test | Lead's count | Note | | --- | --- | --- | --- | --- | -| `apps/backend/src` | 19 | 5 | 9 | the 5 are `onboarding/delivery.ts`, `receipt/delivery.ts`, `receipt/drain-main.ts`, `receipt/http-reads.ts`, `http-api/receipt-transaction.ts`; 14 more are test files | +| `apps/backend/src` | 19 | 5 | 9 | the 5 are `onboarding/delivery.ts`, `receipt/delivery.ts`, `receipt/drain-main.ts`, `receipt/http-reads.ts`, `rpc/receipt-transaction.ts`; 14 more are test files | | `apps/dashboard/e2e`, `apps/homepage/e2e` | 22 | 22 | 10 seeds | 10 are `*-seed.mjs`, 11 are `run-real-*` or `run-native-*` runners, 1 is `receipt-approval.spec.ts` | | `tools` | 45 | 39 | 34 | 11 acceptance, 17 e2e, 14 verification, plus oxlint and source-safety rule files that read SQL as data | | `packages/domain/runtime` | 2 | 2 | — | the tutor D1 proof | diff --git a/docs/specs/authz-evidence.md b/docs/specs/authz-evidence.md new file mode 100644 index 000000000..73af03c53 --- /dev/null +++ b/docs/specs/authz-evidence.md @@ -0,0 +1,186 @@ +# Authorization evidence + +Status: rollout (2026-09-28), on branch `refactor/authz-evidence-organization`. The operator +approved the full refactor after the pilot. Nine evidence types cover Organization administration, +team interest, onboarding, admission outcomes, placement boards, schools, recruitment maintenance, +days served, certificates, team applications, and social-event creation. Receipts and content stay +as they are, for the reasons under [Deliberately left](#deliberately-left). + +Remove this specification when every domain command that needs authority takes evidence that only +its interpreter constructs, and `AGENTS.md#construction-over-trust` names the construction. + +## Goal + +A command that needs authority cannot be called without proof that the authority was checked. +Today the proof is a convention: a check returns `void`, and the call site must remember to run it +before the command. Forgetting it compiles. + +## Evidence (measured on `f433ea9`) + +- 14 exported authorize helpers in `apps/backend/src` and `packages/database/src` return `void` or + a value that any module can construct. They have 123 call sites outside tests. +- Organization administration: `authorizeCreate` in `apps/backend/src/organization/http.ts` + resolves the authority, maps it to `OrganizationActor`, and evaluates the AccessSpec with a + global grant only for an administrator. The service then takes the `OrganizationActor` union. Any + module can build `{ _tag: "OrganizationAdministrator", personId }`, and + `packages/database/src/database.test.ts` does. The adapter therefore decodes the actor again and + runs `authorizeOrganizationActor`, a runtime tag check. +- `OrganizationApiHttpOptions.resolveActor` is wired in `apps/backend/src/router.ts` and never + called. +- Receipts already pass a `ReceiptMutationAuthorization` value into the command, but its + constructors are exported, so it is not evidence yet. + +## Design + +Evidence is a value whose type carries a brand that no exported constructor provides. The +interpreter of the context is the only function that returns it, as a `Result` whose failure is +the existing typed denial. A command takes the narrow evidence type, not the actor union. + +```ts +// packages/domain/src/organization/authority.ts +export interface OrganizationAdministratorEvidence { + readonly [brand]: "OrganizationAdministratorEvidence"; + readonly actor: OrganizationAdministrator; +} + +export const requireOrganizationAdministrator: ( + authority: OrganizationPersonAuthority, +) => Result.Result; + +// packages/domain/src/organization/service.ts +createDepartment(command, administrator: OrganizationAdministratorEvidence) +``` + +The trusted kernel is the interpreter, plus the adapter that reads the authority facts. A caller +that fabricates an `OrganizationPersonAuthority` can still mint evidence; branding the authority +at its reader closes that, and belongs to the rollout. + +What stays: + +- The AccessSpec interpreter in `packages/domain/src/authz`. It decides credentials, concealment, + time and scope, and `docs/model/authority.als` verifies it. Evidence carries its result; it does + not replace it. +- The HTTP contract and the generated SDK. The homepage, OAuth user and service bearers, and the + placements reference depend on them. +- The audit row. `actor_json` keeps the shape `{ _tag: "OrganizationAdministrator", personId }`. + +## Pilot scope + +- Add `OrganizationAdministratorEvidence` and `requireOrganizationAdministrator`. +- `createDepartment`, `createTeam`, and `createFieldOfStudy` take the evidence, in the service + contract, the PostgreSQL adapter, and the HTTP handler. +- Delete `authorizeOrganizationActor`, `decodeOrganizationActor`, `organizationActorFrom`, and the + unused `resolveActor` option with its router wiring. +- The authorization-rules proof observes `requireOrganizationAdministrator`, with the same + observed tags as before. + +## Falsifiers and pilot results + +Measured on the pilot commit, with PostgreSQL 18 and `effect` 4.0.0-rc.116. + +1. A create command rejects an `OrganizationActor`, an `OrganizationAdministrator`, an + `OrganizationMember`, and a literal `{ actor }` at the type level: `expectTypeOf(...).not.toExtend` + negative controls in `packages/domain/src/organization/authority-evidence.test.ts`, which + `check-types` compiles. The compiler also found a forged administrator literal in + `packages/database/runtime/organization-postgres-proof-main.ts`, now minted from an authority. +2. Property test (`it.prop`, seed 28092026, 200 runs): evidence exists exactly when + `globalAdministrator` is `Active`, for the same person, in agreement with + `mapOrganizationAuthorityToOrganizationActor`, while memberships and board seats vary through + `Arbitrary.schema`. `Arbitrary.schema(OrganizationPersonAuthoritySchema)` itself exhausts on the + RFC 3339 instant filter (0 runs, 501 discards), so the instant and delegations come from the + fixtures. +3. Backend organization tests (14) and database organization tests (45) pass on PostgreSQL. The + PGlite test now asserts the stored `actor_json` shape. The authorization-rules proof output is + identical before and after, except process ids and timestamps. +4. Stryker 10.0.0, `coverageAnalysis: off`, over `requireOrganizationAdministrator` and its + neighbours in `authority.ts`: 18 of 18 mutants killed. +5. Hand-applied mutants of `authorizeCreate`: granting no scope fails 2 backend tests. Always + granting the global scope passes every test, and is equivalent: a member is still answered + `authority.denied`, now by the missing evidence instead of the AccessSpec. + +Stryker 10 with Vitest 5 does not see failures of tests nested in `describe`: the same assertion +killed every mutant at top level and none inside a `describe`. Its score overstates survivors in +such suites. The pilot test file is flat for this reason. Check the runner version for this before +Stryker joins the checks. + +Invocation, from `packages/domain`, with Stryker installed outside the catalog and its `vitest` +resolved to the repository's copy: a Vitest config that includes `src/organization/**/*.test.ts`, +and a Stryker config with `testRunner: "vitest"`, `inPlace: true`, `concurrency: 1`, +`coverageAnalysis: "off"`, and `mutate: ["src/organization/authority.ts:"]`. + +## Rollout results + +Each evidence type is an interface with a type-only brand (`declare const …Brand: unique symbol`) +and exactly one constructor, marked with a `// SAFETY:` comment. The command takes the evidence, +not a person id. + +| Evidence | Minted by | Consumed by | +| ------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------- | +| `OrganizationAdministratorEvidence` | `requireOrganizationAdministrator` (domain) | `createDepartment`, `createTeam`, `createFieldOfStudy` | +| `TeamInterestReadScope` | `requireTeamInterestScope` (domain) | `listTeamInterestRegistrations` | +| `DepartmentReach` | `requireDepartmentReach` (domain) | onboarding commands, `recordAdmissionOutcome`, placement board and coverage mutations | +| `SocialEventCreation` | `requireSocialEventCreation` (domain) | `SocialEvents.create` | +| `SchoolCommandAuthorization` | `authorizeSchoolCommand` (database, in the transaction) | `executeCommand` | +| `RecruitmentMaintenanceAuthorization` | `authorizeMaintenance` (database) | `maintainRecruitment` | +| `DaysServedConfirmationAuthorization` | `authorizeDaysServedConfirmation` (database) | `confirmDaysServed` | +| `CertificateIssueAuthorization` | `authorizeCertificateIssue` (database) | `issueCertificate` | +| `TeamApplicationAuthorization` | `authorizeTeamApplicationAction` (database) | `deleteApplication`, `reviseIntake` | + +Deleted: `authorizeOrganizationActor`, `decodeOrganizationActor`, `organizationActorFrom`, +`resolveActor`, `CertificateCommandTarget`, `authorizeCertificateCommand`, `TeamInterestFilter`, +and the second authorization inside `executeSchoolCommand`. Commands whose evidence carries a +department (`DepartmentReach`, `SocialEventCreation`) fail when the locked row belongs to another +department: admission outcomes answer `authority.denied`, and placements and social events die, +because the handler minted the evidence for the row's own department. + +Measured on `c2bb975`, with PostgreSQL 18: + +- Type checks pass for domain, database, backend, dashboard, `tools/e2e`, and `tools/verification`. +- Focused Vitest suites pass: domain (organization, authz, placements, admissions: 165 tests), + database (onboarding, admissions, placements, recruitment, schools, team applications including + PgBouncer, organization, `database.test.ts`), backend (organization, onboarding, admission, + placements, recruitment, schools, directory, team applications), and the onboarding delivery + test of `tools/verification`. +- Property tests in `packages/domain/src/authz/reach-evidence.test.ts`: `requireDepartmentReach` + agrees with `reaches`, with department administration for global-administrator capabilities, + with `canManagePlacements`, and with `admissionOutcomePermission === "Decide"`; the team interest + scope agrees with first-membership semantics; social-event creation agrees with its grant check. + Generated inputs reach both outcomes (127 grants and 173 denials in one measured run). +- The authorization-rules proof output is unchanged apart from process ids and timestamps. +- Browser and golden journeys, each exit 0 on a clean tree with PostgreSQL 18 and Chromium: + + | Journey | Exercises | Revision | Seconds | + | ------------------------------ | --------------------------------------------------------- | --------- | ------- | + | `just e2e organization` | administrator evidence; member denied 403; replay | `c2bb975` | 67 | + | `just e2e schools` | school directory authority matrix | `416850c` | 83 | + | `just e2e social-events` | social-event creation | `416850c` | 77 | + | `just e2e substitutes` | admission outcomes | `416850c` | 76 | + | `just e2e onboarding` | coordinator invitation; wrong department denied | `416850c` | 77 | + | `just e2e recruitment` | recruitment session | `416850c` | 55 | + | `just golden school-service` | placement board, coverage, forbidden step (26 steps) | `416850c` | 118 | + | `just golden team-application` | intake revision, deletion, delivery recovery | `3a55e3a` | 88 | + + The first team-application run at `416850c` hung for 25 minutes before its first page. The + base `f433ea9` passed, and a rerun of `416850c` passed in 91 s. The cause was the readiness probe + of `tools/e2e/golden-harness.ts`, fixed in `3a55e3a`. No journey drives team interest in a + browser (`tools/conventions/src/journeys.ts` excludes its spec); the database and backend tests + cover it. + +## Deliberately left + +- Receipts. `ReceiptMutationAuthorization` is minted in the committing transaction, and the pure + `decideCommand` re-checks access on the locked receipt. Sealing the constructor removes neither + check, so it adds a brand without deleting code. +- Content. The database adapter resolves authority itself, in the transaction. The handler-side + `authorizeContentOperation` is a second gate in front of it, not the authority. +- `authorizeAnonymous` and `authorizePerson` in `apps/backend/src/rpc/problem.ts`. They check + the credential, not authority. `authorizeAnonymous` has no production consumer; a test keeps it + consistent with the AccessSpec. The `authorizePerson` sites that run after a domain call only + shape the response. +- `OrganizationPersonAuthority` is not branded at its reader. A caller that fabricates an authority + can still mint evidence. Branding it touches every reader of the fact port and is its own slice. + +Stryker is not in the root catalog. Stryker 10 with Vitest 5 runs no test nested in `describe` for a +mutant (upstream stryker-js issue #6210; fix proposed in PR #6214). A locally patched runner kills +18 of 18 mutants in `describe` as well. Stryker joins the catalog, `just`, and hosted Checks once a +release contains the fix, so that no check exists that nothing runs. diff --git a/docs/specs/certificates-days-served.md b/docs/specs/certificates-days-served.md index 6a6dae5f1..75bfb58f6 100644 --- a/docs/specs/certificates-days-served.md +++ b/docs/specs/certificates-days-served.md @@ -84,7 +84,7 @@ Branch `feat/certificates-0926` (CertificatesBuild, CertificatesBuild2, then Cer - `placements/certificates.ts`: counted attendance is `Completed` decisions of dated commitments only. Commands take the department row lock, then the person lock and the `ForShare` authority. The scope read denies a reader who holds neither capability in any department. - `organization/authority-postgres.ts` and `organization/lifecycle-postgres.ts`: governed departments, the certificate issuer, and `readBoardRosters` (the visibility of appointment management). - Database falsifier tests, `packages/database/src/placements/certificates.test.ts` (6 tests on `DatabaseTestLive`), each with a negative control that failed the suite. -- HTTP contract, `packages/http-api/src/certificates.ts` (group `certificates`, six endpoints) and `GET /api/organization/board-rosters`. The scope read declares `placements.days-served` with `certificates.issue` as its alternative (`personNativeAccess` takes `alternatives`, an `Any` capability expression). `certificate.empty` (409) and `certificate.unprintable` (422) are in the problem registry. +- HTTP contract, `packages/rpc/src/certificates.ts` (group `certificates`, six endpoints) and `GET /api/organization/board-rosters`. The scope read declares `placements.days-served` with `certificates.issue` as its alternative (`personNativeAccess` takes `alternatives`, an `Any` capability expression). `certificate.empty` (409) and `certificate.unprintable` (422) are in the problem registry. - Backend, `apps/backend/src/placements/certificates-http.ts`: reads in one repeatable-read snapshot; commands through `executeNativeHttpCommandPostgres` with `retry: "serialization-once"`, current authority before any replay, and `If-Match` on the fresh entry or preview. - PDF, `apps/backend/src/placements/certificate-pdf.ts` and `true-type.ts`: embedded subsets of Noto Sans Regular and Bold 2.015 (unhinted, OFL) as `CIDFontType2` with `Identity-H`, widths, and a ToUnicode map; deterministic bytes per issue. `fonts/` holds the unchanged release files, `OFL.txt`, and `provenance.json` (archive URL and SHA-256 of each file). `CertificateFontsLive` loads the fonts once with the handler group. Text that no glyph of both faces covers (CJK, controls) fails with `certificate.unprintable` inside the transaction, so no issue is recorded. Poppler (`pdffonts`, `pdftotext`, `pdftoppm`) and `qpdf --check` accepted a two-page render with Polish, Greek, and Cyrillic names. - Tests, `apps/backend/src/placements/certificate-pdf.test.ts` (3: provenance checksums, names extracted through the ToUnicode maps, refusal by field) and `certificates-http.test.ts` (6, on PostgreSQL): the scope read for either capability and 403 for neither; 200 at the observed tag, 412 on a stale tag with no write, and read-back; 403 for the outsider and the assistant with no issue row; an exact replay with identical PDF bytes and one issue, a new key with a second issue and the same content hash, and 403 on a replay after the seat ended; a SQLSTATE 40001 deferred-trigger fault at commit recovered in the same request (two commit attempts, one issue); and 422 for a name outside the fonts. Negative controls, each reverted, failed the suite: no authority check before replay, no commit retry, and no confirmation precondition. diff --git a/docs/specs/command-grammar.md b/docs/specs/command-grammar.md index 947b14ad1..894dc11e2 100644 --- a/docs/specs/command-grammar.md +++ b/docs/specs/command-grammar.md @@ -65,10 +65,10 @@ Observed on 2026-09-27 at `8d1b7e09` and at `b3193cb1`, which adds the hook conf | E8 | The ledger held 3411 rows under 570 distinct class names from 2026-09-25 to 2026-09-27. `measure-job.ts` accepts any kebab-case class. | `~/.local/state/vektorprogrammet/job-ledger.jsonl`; `tools/scripts/measure-job.ts:35,341-342` | | E9 | 122 of the 123 `hook-pre-push-test` rows ran in the main checkout, and all but one ended within about 1 s. With `HEAD` at `main` and unpushed commits, `--affected` with its default base `main` selected nothing, because the merge base of `main` and `HEAD` is `HEAD`. With `TURBO_SCM_BASE=$PRE_COMMIT_FROM_REF` inside a prek pre-push hook, it selected the pushed changes. | ledger; [`git.rs`](https://github.com/vercel/turborepo/blob/v2.8.13/crates/turborepo-scm/src/git.rs#L272-L275); scratch-repository runs of the locked binary | | E10 | `just model check` took 584 s at the median over 4 runs. No hook and no CI job runs `just model`. | ledger class `model-check`; `devenv.nix`; `.github/workflows/*.yml` | -| E11 | These CI steps verify without `just`: two `bun test` files (`tools/placements-docs/placements-artifact.test.mjs`, `tools/e2e/golden-school-service-evidence.test.mjs`), the golden school-service CI wrapper, the placements reference steps (also in the Docs workflow), and `proof:receipt`, `proof:identity-postgres`, and a focused `auth-live` test, which read a PostgreSQL service container. | `.github/workflows/tests.yml:60-69,106-120,278-281,324-341`; `.github/workflows/docs.yml:44-48` | +| E11 | These CI steps verify without `just`: two `bun test` files (`tools/placements-docs/placements-artifact.test.mjs`, `tools/e2e/golden-school-service-evidence.test.ts`), the golden school-service CI wrapper, the placements reference steps (also in the Docs workflow), and `proof:receipt`, `proof:identity-postgres`, and a focused `auth-live` test, which read a PostgreSQL service container. | `.github/workflows/tests.yml:60-69,106-120,278-281,324-341`; `.github/workflows/docs.yml:44-48` | | E12 | `packages/database` has 11 `proof:*` and 3 `trace:*` scripts. CI runs `proof:receipt` and `proof:identity-postgres`. `just proof` runs `authorization-rules` and `rule-reconciliation`, which start their own cluster. No hook or CI job runs the other 8 proofs or 2 traces, which read database URLs from the environment. | `packages/database/package.json`; `packages/database/runtime/*-main.ts` | | E13 | Seven packages have a `lint` script and `turbo.json` has a `lint` task, but no recipe, hook, or workflow runs `turbo lint`. `just lint` runs one root Oxlint process over the tree. | package manifests; `turbo.json:15-17`; `justfile:91-94` | -| E14 | Five test files start a real PostgreSQL cluster: `apps/backend/src/http-api/receipt-transaction.test.ts` (through `apps/backend/test/postgres.ts`), three in `packages/database/src`, and one in `tools/postgres`. The other suites use PGlite or no database. | `git grep startDisposablePostgres\|withDisposablePostgres` | +| E14 | Five test files start a real PostgreSQL cluster: `apps/backend/src/rpc/receipt-transaction.test.ts` (through `apps/backend/test/postgres.ts`), three in `packages/database/src`, and one in `tools/postgres`. The other suites use PGlite or no database. | `git grep startDisposablePostgres\|withDisposablePostgres` | | E15 | In a worktree without `node_modules`, `bun x turbo --version` fetched and ran Turbo 2.11.4. `bun.lock` resolves Turbo 2.8.13. The recipes `build`, `check`, `check-types`, and `test` call `bun x turbo`, and `format` and `lint-files` call `bun x oxfmt` and `bun x oxlint`. | this worktree before `bun install`; `bun.lock:2764`; `justfile` | | E16 | Turbo 2.8.13, the locked version, selects for `--affected` the changed packages and their transitive dependents. Changed paths are `git diff-tree --merge-base ` plus the staged, unstaged, and untracked paths, always. The base is `TURBO_SCM_BASE`, else the event base on GitHub Actions, else local `main`, then `master`. A missing default base, an unknown commit, or a shallow history selects every package with a warning and exit 0. An unknown ref name in `TURBO_SCM_BASE` exits 1. `--affected` with `--filter` exits 1; from 2.9.4 on, it intersects. Task `inputs` do not change selection. No mode selects the index alone. With the base given as a merge-base commit, `--filter=...[]` selected the same packages as `--affected` in clean and dirty trees. Inside a package directory, Turbo adds that package to every selector. | [run reference](https://github.com/vercel/turborepo/blob/v2.8.13/apps/docs/content/docs/reference/run.mdx#L41-L68); [`git.rs`](https://github.com/vercel/turborepo/blob/v2.8.13/crates/turborepo-scm/src/git.rs#L51-L342); [`filter.rs`](https://github.com/vercel/turborepo/blob/v2.8.13/crates/turborepo-scope/src/filter.rs#L209-L260); [`cli/mod.rs`](https://github.com/vercel/turborepo/blob/v2.8.13/crates/turborepo-lib/src/cli/mod.rs#L867-L877); [`target_selector.rs`](https://github.com/vercel/turborepo/blob/v2.8.13/crates/turborepo-scope/src/target_selector.rs#L180-L203); scratch-repository runs of the locked binary (not committed) | | E17 | A change to root `package.json`, `turbo.json`, or `turbo.jsonc` selects every package. Another path outside the packages selects every package only when `globalDependencies` matches it; otherwise it selects the root package `//`, which runs nothing because `turbo.json` defines no root task. `turbo.json` has no `globalDependencies`, yet eleven workspace `tsconfig.json` files extend the root `tsconfig.json`, and nine Vitest configurations import `vitest.shared.ts`. A `bun.lock` change selects the packages whose dependency closure changed; in a scratch copy of mono-web, a change to `vocs` alone also selected `@monoweb/dashboard`. | [`change_mapper/mod.rs`](https://github.com/vercel/turborepo/blob/v2.8.13/crates/turborepo-repository/src/change_mapper/mod.rs#L127-L260); [`package_graph/mod.rs`](https://github.com/vercel/turborepo/blob/v2.8.13/crates/turborepo-repository/src/package_graph/mod.rs#L459-L562); [`configuration.mdx`](https://github.com/vercel/turborepo/blob/v2.8.13/apps/docs/content/docs/reference/configuration.mdx#L36-L61); `turbo.json`; `git grep` of `extends` and `vitest.shared` | diff --git a/docs/specs/commands-and-concurrency.md b/docs/specs/commands-and-concurrency.md index de2807708..da3d137a4 100644 --- a/docs/specs/commands-and-concurrency.md +++ b/docs/specs/commands-and-concurrency.md @@ -31,7 +31,7 @@ Make these shapes type errors or check failures, not review findings. - Background work runs as `Effect.forkScoped` inside a `Layer`, or in `FiberSet`, `FiberMap`, or `FiberHandle`. `Effect.forkDaemon` and `Effect.forkDetach` are banned outside composition roots. - `Effect.run*` is allowed only in composition roots and the registered runtime bridge (enforced partly today, by `no-premature-execution` and tsgo `run-effect-inside-effect`). - A `Fiber` value is not stored in a variable or field; use the scoped collections. -- Shared mutable state uses `Ref`, `SynchronizedRef`, or the `Tx*` STM types. `let` is banned in core code (`packages/domain`, `packages/database`, `packages/http-api`, `apps/backend`) and in Foldkit update code. A local accumulator becomes a fold. +- Shared mutable state uses `Ref`, `SynchronizedRef`, or the `Tx*` STM types. `let` is banned in core code (`packages/domain`, `packages/database`, `packages/rpc`, `apps/backend`) and in Foldkit update code. A local accumulator becomes a fold. ## Evidence (measured on `9cb12743`) diff --git a/docs/specs/repository-conventions.md b/docs/specs/repository-conventions.md index 36f170651..d60afb36f 100644 --- a/docs/specs/repository-conventions.md +++ b/docs/specs/repository-conventions.md @@ -34,7 +34,7 @@ A person or an agent can find the right place for new code, the shared construct ### Layout -- Packages stay layer-first: `packages/domain`, `packages/database`, `packages/http-api`, `packages/sdk`, and `apps/backend` for composition. +- Packages stay layer-first: `packages/domain`, `packages/database`, `packages/rpc`, and `apps/backend` for composition. - Each layer uses the CML context name as its folder name: `packages/domain/src/`, `packages/database/src/`, `apps/backend/src/`, `apps/dashboard/app/foldkit/`. - Code shared by several contexts belongs to a CML Shared Kernel context, not to loose top-level files. - `packages/placements` folds back into `packages/domain/src/placements` and `packages/database/src/placements` before the context checks turn on. diff --git a/docs/specs/rpc-only.md b/docs/specs/rpc-only.md new file mode 100644 index 000000000..c65040ae2 --- /dev/null +++ b/docs/specs/rpc-only.md @@ -0,0 +1,746 @@ +# RPC only + +Status: in progress (2026-09-28), on branch `refactor/rpc-only`, cut from `6979637` +(`refactor/authz-evidence-organization`). The operator decided to delete the HttpApi contract, the +generated SDK, and OpenAPI, and to serve every native operation as an Effect RPC. + +Remove this specification when every operation below is ported or recorded as dropped, the +journeys pass, and `docs/architecture.md`, `docs/system.md`, and `AGENTS.md` describe RPC. + +## What stays HTTP + +- Better Auth `/api/auth/*` and the OAuth2 routes: HTTP by protocol. +- `GET /health`: infrastructure probes it. `router.ts` serves it without RPC. +- The ingress in `apps/backend/src/router.ts`: trusted origins, CORS, and the OAuth gatekeeping + wrap the RPC endpoint exactly as they wrapped the HttpApi routes. + +## The HTTP contract to port + +The deleted files are at the base commit. Read them with +`git show 6979637:`: the contract in `packages/http-api/src/.ts`, the handlers in +`apps/backend/src//http*.ts`, the endpoint tests in `*.http.test.ts` and `*/http.test.ts`, +the backend harness `apps/backend/src/test/native-http.ts`, and the SDK in `packages/sdk`. +Port the behavior; do not rewrite it from memory. + +## Conventions + +The precedent is social events, ported end to end: the contract +`packages/rpc/src/social-events.ts`, the handlers `apps/backend/src/social-events/rpc.ts`, and the +dashboard bridge `apps/dashboard/app/routes/__foldkit.social-events.ts`. Copy its shape. + +- **Tag.** The RPC tag is the old operation ID (`social-events.create`). Command receipts store it. +- **Payload.** Path, query, and body fields become one payload struct. A replayable command takes + `idempotencyKey: IdempotencyKey`; the old body is its `request` field. An If-Match precondition + becomes `ifMatch: StrongETag`, compared inside the committing transaction as before. +- **ETags.** A success that carried an ETag header that a later RPC takes as `ifMatch` returns it + as a field beside the resource. Derive it exactly as the handler did. +- **Conditional reads.** `If-None-Match` and 304 are dropped. Record each in the checklist. +- **Errors.** Each RPC declares `error: rpcProblems(Problem)`. Drop the codes that only + HTTP parsing produced: `request.malformed`, `header.malformed`, `origin.denied`, + `request.too-large`, `media-type.unsupported`, `idempotency-key.invalid`, + `precondition.required`, `method.not-allowed`. Keep `credential.missing` and + `credential.invalid` when the handler re-resolves the credential in its transaction. + `packages/rpc/src/endpoint-problems.ts` still holds the old unions to start from. +- **Structural validation.** A payload that does not decode fails in the RPC server before the + handler, not as `validation.failed` with pointers. Semantic validation stays `validation.failed`. + Record each UI that showed field errors from schema decoding. +- **Credentials.** The RPC takes the middleware of its old security: `PersonCredential`, + `SessionCredential`, `PersonOrServiceCredential`, `InvitationCapabilityCredential` (the + capability moves into the payload), or `ContactBackendCredential`. The handler still resolves the + credential and the authority inside its transaction, from the `headers` its options carry, + through `credentialRequestOf(headers)`. +- **AccessSpec.** `.pipe(withAccessSpec(...))` on the RPC; the handler evaluates it with + `authorizePerson(..., personPresentation(headers))` and `reflectAccessSpec()`. +- **Command receipts.** `executeNativeHttpCommandPostgres` stays. `execute` ends with + `successCapsule()`, and the handler answers `commandOutcome()`. + `commandIdentity` takes the old HTTP route path as `normalizedTarget`, so receipts and domain + command IDs of commands that straddle the cutover stay stable. +- **Defects.** Every RPC runs inside `ProblemBoundary`: a defect is reported and answered + `internal.error`. Do not catch defects in a handler. +- **Handlers.** Each context exports `RpcHandlers(options: NativeRpcOptions)` from its + `rpc.ts`. `router.ts` already lists every one; do not edit it. +- **Re-exports.** A contract re-exports only the schemas its own RPCs use. Clients import other + domain values from `@vektorprogrammet/domain`. +- **Clients.** The dashboard server calls `callNative(cookie, request, (client) => ...)` from + `apps/dashboard/app/lib/api.server.ts`. Journey drivers and probes use `nativeScriptClient` from + `@vektorprogrammet/rpc/script`, whose results keep the registry status of each problem. Backend + tests use `makeBackendTestRpc` from `apps/backend/src/test/native-rpc.ts`. +- **Files.** Uploads and downloads travel as bytes in the payload (`Schema.Uint8Array`) on the JSON + endpoint, where the dashboard server relays them. If a browser needs a direct URL, or JSON size + is a problem, record it; `RpcSerialization.layerSchemaBinary` exists and would need its own path. + +## Lint policy + +Every Effect language-service rule that `@effect/tsgo` ships is an error, except in +`apps/homepage` and `apps/dashboard` (recommended and correctness only), and +`strict-effect-provide` at the entry points that `oxlint.config.ts` declares +(`tools/conventions/tests/oxlint-groups.test.ts` guards both). Every file a worker creates or +changes lints clean: `bun x oxlint --threads=2 `. The rules ship no autofix through Oxlint. + +- `missing-pipeable-signature`: give an exported function of two or more parameters a pipeable + form with `dual` from `effect/Function`, typed as an overload pair (precedent: + `backendHttpHandler` in `apps/backend/src/router.ts`). With optional trailing parameters, + use the predicate form `dual((args) => , impl)`, or take one named + options interface instead (precedent: `makeBackendTestRpc`, `ExternalNativeRpcRouterLive`). +- `new-schema-class`: `X.make(...)`, not `new X(...)`, for Schema classes and tagged errors. +- `strict-boolean-expressions`: compare explicitly (`!== undefined`, `.length > 0`, + `Option.isSome`), never rely on truthiness. +- `schema-struct-with-tag`, `prefer-typed-schema-decoder`, `unnecessary-arrow-block`, + `deterministic-keys`, `nested-effect-gen-yield`: follow the rule's message; each has one form. +- A suppression needs its entry in `docs/effect-exceptions.json`; prefer the fix. + +## Effect lookup order + +Before writing Effect code, read in this order, and check every API against the installed source: + +1. `node_modules/effect/AGENTS.md` and the `ai-docs` examples it links. +2. `.agents/skills/effect-house/SKILL.md` and its `references/`. +3. The official repository at the installed release, outside this repository: + `/home/claude/reference/effect-4.0.0-rc.116` (`LLMS.md`, and for RPC + `packages/effect/test/rpc/*.test.ts`). It is a reference, not a dependency. +4. The installed source under `node_modules/effect/src` is the authority. + +## Missing capabilities + +Record here every behavior of the HTTP contract that RPC does not provide, with the operation and +what a client loses. + +- `If-None-Match` and 304 on 13 reads: a client re-reads the full resource. +- OpenAPI: third-party OAuth clients (82 operations accept an OAuth user bearer) lose a + machine-readable contract and must speak the Effect RPC wire protocol. + +## Checklist + +Mark an operation `ported` when its RPC, handler, client callers, and tests exist, or `dropped` +with the reason in Missing capabilities. + +### admissionOutcomes (`packages/rpc/src/admission-outcomes.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `admissionOutcomes.listScopes` | GET `/api/admission-outcomes/scopes` | - | cookieHeader, oauthUserBearer | ported | +| `admissionOutcomes.readOutcome` | GET `/api/admission-outcomes/{applicationId}` | ifMatch; if-none-match (dropped) | cookieHeader, oauthUserBearer | ported | +| `admissionOutcomes.readOutcomes` | GET `/api/admission-outcomes` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | ported | +| `admissionOutcomes.recordOutcome` | POST `/api/admission-outcomes/{applicationId}:record` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | ported | + +Notes: + +- The four RPCs keep the old operation IDs as tags and the old routes as `normalizedTarget`. + `recordOutcome` still takes `DepartmentReach<"admissions.outcomes">` from the application's own + department; another department's application answers authority.denied before any write + (`apps/backend/src/admission/outcome.rpc.test.ts`). +- Dropped: `If-None-Match` and 304 on `readOutcome`, and its `ETag` header; the resource keeps + `etag`. `precondition.required`: the payload requires `ifMatch`. +- A command whose outcome does not decode, or that lacks `ifMatch`, fails in the RPC server as a + defect instead of validation.failed or precondition.required. An unknown member of the command + is dropped, not answered validation.failed. `dashboard.vikarer` decodes its form itself, so no + field error is lost. + +### admissions (`packages/rpc/src/admissions.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `admissions.createAdmissionPeriod` | POST `/api/admission-periods` | idempotencyKey | cookieHeader, oauthUserBearer | ported | +| `admissions.listAdmissionPeriods` | GET `/api/admission-periods` | ifMatch; if-none-match (dropped) | cookieHeader, oauthUserBearer | ported | +| `admissions.listApplicationOptions` | GET `/api/application-options` | ifMatch; if-none-match (dropped) | none | ported | +| `admissions.listOpenAdmissionPeriods` | GET `/api/open-admission-periods` | ifMatch; if-none-match (dropped) | none | ported | +| `admissions.readApplicantProgress` | GET `/api/applicant-progress` | - | cookieHeader, oauthUserBearer | ported | +| `admissions.readApplicationConfirmation` | GET `/api/applications/{applicationId}` | - | none | ported | +| `admissions.readReturningAssistantOptions` | GET `/api/returning-assistant/options` | - | cookieHeader, oauthUserBearer | ported | +| `admissions.registerReturningAssistant` | POST `/api/returning-assistant/registrations` | idempotencyKey | cookieHeader, oauthUserBearer | ported | +| `admissions.reviseAdmissionPeriod` | PATCH `/api/admission-periods/{admissionPeriodId}` | idempotencyKey; ifMatch; merge patch | cookieHeader, oauthUserBearer | ported | +| `admissions.submitApplication` | POST `/api/applications` | idempotencyKey | none | ported | + +Notes: + +- The ten RPCs keep the old operation IDs as tags and the old routes as `normalizedTarget`, so + command receipts and command IDs that straddle the cutover stay stable. +- Dropped on `listOpenAdmissionPeriods` and `listApplicationOptions`: the public `Cache-Control` + (a `max-age`/`s-maxage` of at most 30 s, bounded by the next window boundary), the collection + `ETag`, and `If-None-Match`/304. A CDN or browser no longer caches the public catalog. +- Dropped on `listAdmissionPeriods`: the collection `ETag` and `If-None-Match`/304. Each item + keeps its `etag`, which `reviseAdmissionPeriod` takes as `ifMatch`. +- Commands answer 200, not 201, and carry no `Location` or `ETag` header. The period item carries + its `etag`; the application confirmation and the returning registration had no body tag, so a + client that read their `ETag` header loses it. +- A payload that does not decode fails in the RPC server as a defect, before the handler, instead + of validation.failed with a pointer per rejected member, and an unknown member is dropped + instead of rejected. The homepage application form and the dashboard period and returning + forms decode the same schemas before they call, so they lose no field error; a third-party + client does. Semantic validation (an unknown department or semester, a missing department of a + global administrator, the merge-patch no-change and field-not-deletable rules) still answers + validation.failed with its pointer. +- `request.too-large` is gone: `ADMISSION_MAX_BODY_BYTES` (16 KiB) no longer bounds admission + payloads, and neither `router.ts` nor the session boundary bounds the RPC body. The payload + schemas still bound each field, but only after the whole body is read and parsed. +- `rate-limit.exceeded` and 503 problems no longer carry `Retry-After`: the RPC problem encodes + the body only. The public rate limit is consumed after the payload decodes, not before. +- `reviseAdmissionPeriod` answers an identifier that no path can spell with validation.failed at + `/admissionPeriodId` instead of request.malformed. +- Callers outside this slice's files were ported at their call sites only: + `apps/dashboard/e2e/native-placement.spec.ts`, `native-recruitment-interview-conduct.spec.ts`, + `tools/e2e/placement-check.ts`, `tools/acceptance/onboarding-check.ts`, and + `recommendation-check.ts`. + +### certificates (`packages/rpc/src/certificates.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `certificates.confirmDaysServed` | POST `/api/departments/{departmentId}/semesters/{semesterId}/days-served/{personId}` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | ported | +| `certificates.issueCertificate` | POST `/api/departments/{departmentId}/certificates/{personId}/issues` | idempotencyKey; ifMatch; binary application/pdf | cookieHeader, oauthUserBearer | ported | +| `certificates.listCertificates` | GET `/api/departments/{departmentId}/certificates` | query: cursor | cookieHeader, oauthUserBearer | ported | +| `certificates.listDaysServed` | GET `/api/departments/{departmentId}/semesters/{semesterId}/days-served` | query: cursor | cookieHeader, oauthUserBearer | ported | +| `certificates.readCertificate` | GET `/api/departments/{departmentId}/certificates/{personId}` | - | cookieHeader, oauthUserBearer | ported | +| `certificates.readCertificateScopes` | GET `/api/certificate-scopes` | - | cookieHeader, oauthUserBearer | ported | + +Notes (certificates): + +- The six RPCs keep the old operation IDs as tags. Each command's `normalizedTarget` is the old + route template filled with its path members, and its domain command ID is still the receipt + identity digest, so receipts and commands that straddle the cutover stay stable. +- Evidence is unchanged: `confirmDaysServed` takes `DaysServedConfirmationAuthorization` and + `issueCertificate` takes `CertificateIssueAuthorization`, each resolved in the committing + transaction before the receipt lookup; a seat that ended cannot replay a stored issue + (`apps/backend/src/placements/certificates-rpc.test.ts`). The handlers provide + `CertificateFontsLive` themselves, so the fonts still load once, when the handlers are built. +- `issueCertificate` answers the PDF as `CertificatePdf` (`Schema.Uint8Array`) in the success. On + the JSON serialization the bytes travel as base64 inside the RPC response, about 4/3 of the PDF + plus the envelope, buffered whole on both sides; the answer has no `application/pdf` media type, + no `ETag` (it equaled the `ifMatch` the client sent), and no URL a browser can download. A + client that shows the PDF must relay the bytes itself. No dashboard route, journey, or probe + called any certificate operation at the base commit, so no relay route exists yet. The receipt + still stores the PDF bytes with `application/pdf` and the certificate tag, and a replay answers + the same bytes. +- `listDaysServed` and `listCertificates` keep `request.malformed` for a cursor that passes + `AssistantCursor` but names no position (`CertificateInvalidCursor`, the domain's own answer). + An unknown or repeated query member answered `request.malformed` before; the typed payload + cannot carry one. +- A `confirmDaysServed` total that fails `DaysServedTotal` answered validation.failed at `/total`; + the RPC server now fails that payload as a defect, and the 1 KiB body bound is gone. Reads lose + `Cache-Control: private, no-store` and `Vary: Origin`. + +### contact (`packages/rpc/src/contact.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `contact.submitContactMessage` | POST `/api/contact-messages` | contact ip header | contactBackend | ported | + +Notes (contact): + +- The visitor address still travels in the `x-vektor-contact-ip` header, now as a header of the RPC + message beside the deployment secret; the handler decodes it and answers `header.malformed` for a + noncanonical address, before quota. The per-visitor quota (five attempts per fixed hour) is kept. +- A message that fails the `ContactMessage` schema no longer answers `validation.failed`: the RPC + server fails the request as a defect before the handler, and no quota is consumed. The homepage + decodes the form with the same schema first, so its form still shows its own message. +- An extra member, such as an injected `to`, is stripped by the payload decoding instead of rejected; + the recipient stays the department's address. `request.too-large` (64 KiB body limit) is gone. +- `rate-limit.exceeded` no longer carries `Retry-After`; success answers no value instead of 201. + +### content (`packages/rpc/src/content.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `content.createArticle` | POST `/api/content/articles` | idempotencyKey | cookieHeader, oauthUserBearer | ported | +| `content.listNews` | GET `/api/news` | ifMatch; if-none-match (dropped); query: department | none | ported | +| `content.publishArticle` | POST `/api/content/articles/{articleId}:publish` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | ported | +| `content.readArticle` | GET `/api/content/articles/{articleId}` | ifMatch; if-none-match (dropped) | cookieHeader, oauthUserBearer | ported | +| `content.readContentWorkspace` | GET `/api/content/articles` | query: department | cookieHeader, oauthUserBearer | ported | +| `content.readNewsArticle` | GET `/api/news/{slug}` | ifMatch; if-none-match (dropped); query: version | none | ported | +| `content.reviseArticle` | PATCH `/api/content/articles/{articleId}` | idempotencyKey; ifMatch; merge patch | cookieHeader, oauthUserBearer | ported | +| `content.unpublishArticle` | POST `/api/content/articles/{articleId}:unpublish` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | ported | + +Notes (content): + +- The eight RPCs keep the old operation IDs as tags and the old routes as `normalizedTarget` + (`/api/content/articles`, `/api/content/articles/{articleId}`, and its `:publish` and + `:unpublish` forms). Each command's receipt keeps the HTTP capsule (a new draft: 201 with + `location` and `etag`; otherwise 200 with `etag`), and a replay decodes it, so a retry that + straddles the cutover answers its first response. The RPCs answer `ContentArticleResource` + (`{ article, etag }`) or `{ result, etag }`; the `etag` field replaces the `ETag` header, and + `reviseArticle`, `publishArticle`, and `unpublishArticle` take it as `ifMatch`. +- Authority is unchanged: a command resolves the credential, the organization authority, and the + content actor inside its serializable transaction, evaluates its AccessSpec there with the + content grant scope, and compares `ifMatch` in `execute`, after the receipt lookup. The database + adapter still resolves content authority itself before it writes + (`apps/backend/src/content/rpc.test.ts`, on PostgreSQL). The handler's content actor check + (`resolveContentActor`) is kept. It denies the same persons that the adapter's own resolution + denies, but something observable depends on it: a read answers an unavailable Organization + projection as internal.error before any content read, where the adapter would answer + content.unavailable. In a command it reuses the authority that the transaction already resolved, + so it costs no query. +- Public reads stay anonymous and evaluate their AccessSpec with `authorizeAnonymous`. Dropped on + `listNews` and `readNewsArticle`: the public `Cache-Control` (`public, max-age=60, + s-maxage=300, must-revalidate`), the `ETag`, `If-None-Match`/304, and the read-side `If-Match` + (412). A CDN or browser no longer caches news; every homepage render reaches the backend, as its + loaders already read fresh per render. The handlers no longer read the HTTP entity-tag sources of + the news tables. +- Dropped on `readArticle`: `If-None-Match`/304 and the read-side `If-Match`; the resource keeps + `etag`. Staff reads and commands lose `Cache-Control: private, no-store`/`no-store`; commands + answer 200 instead of 201 and carry no `Location` or `ETag` header. +- Dropped codes that only HTTP parsing produced: `request.malformed` (a query member other than + `department` or `version`, a slug that is no `ArticleSlug`), `header.malformed`, `origin.denied`, + `idempotency-key.invalid`, `request.too-large` (the 1 MiB content body bound), + `media-type.unsupported` (`application/merge-patch+json` for revisions), `precondition.required`, + `precondition.invalid`, and `validation.failed`, which only body decoding produced. Also dropped: + `dependency.unavailable`, which no content handler produced. The staff RPCs declare + `credential.missing` and `credential.invalid`, since each handler resolves the person again. +- Structural validation moved to the RPC server: a draft or merge patch whose fields fail their + schemas, repeated department identifiers, a slug that is no `ArticleSlug`, or a version below 1 + now fail as a defect (internal.error) instead of validation.failed or request.malformed, and an + unknown merge-patch member is stripped instead of answered validation.failed. The merge-patch + rules stay: no member answers validation.no-change, a `null` member + validation.field-not-deletable. The dashboard content bridge decodes `ContentBridgeActionSchema` + (the same field schemas) before it calls, so no UI loses a field error. +- Clients: the dashboard bridge `routes/__foldkit.content.ts` calls the RPCs through `callNative` + (the Foldkit browser client still talks to the bridge; publish and unpublish still read the + current tag first). The homepage news loaders call `callHomepageNative`, and + `apps/homepage/src/lib/api-types.ts` takes the news types from the contract. The journey spec + posts its direct denial probes as RPCs from the page and records each RPC's tag and payload + facts; `run-real-native-content-publication.mjs` names each RPC by the route it replaced, forces + the first `content.readContentWorkspace` as a content.unavailable exit, and no longer builds the + deleted `packages/sdk`. The journey was ported for compilation only and not run. +- `apps/homepage/vite-digests.ts` still labels the news routes `native-backend:/api/news`: a + projection-manifest source label, not a call, left as the other slices left theirs. + +### directory (`packages/rpc/src/directory.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `directory.executeSchoolCommand` | POST `/api/schools/commands` | idempotencyKey | cookieHeader, oauthUserBearer | ported | +| `directory.listPeople` | GET `/api/people` | - | cookieHeader, oauthUserBearer | ported | +| `directory.listSchools` | GET `/api/schools` | query: department | cookieHeader, oauthUserBearer | ported | +| `directory.readSchoolManagement` | GET `/api/schools/management` | - | cookieHeader, oauthUserBearer | ported | + +Notes (directory): + +- `directory.listPeople` drops `directory.cursor-malformed`: only a query string produced it, and + the RPC takes no payload. +- `directory.listSchools` takes `{ departmentId? }` (`SchoolDirectoryQuerySchema`). An unknown, + duplicate, or empty department parameter answered `request.malformed` before authentication; the + typed client cannot send one now, and a hand-built payload fails in the RPC server as a defect. +- `directory.readSchoolManagement` and `directory.executeSchoolCommand` lose their private + `Cache-Control` and the command's ETag header; no RPC took that ETag as `ifMatch`. The command's + `commandId` must still equal its idempotency key (`idempotency.digest-conflict`). +- The dashboard Foldkit schools client calls these RPCs from the browser through + `apps/dashboard/app/lib/browser-native.ts`. The RPC client dies on an answer that does not fit the + contract; that helper turns such a defect into the typed `NativeAnswerInvalid` failure, so the + view shows its failure message as it did for a malformed HTTP body. +- `apps/dashboard/e2e/run-real-native-schools-directory.mjs` records each RPC with the registry + status of its exit, and forces its one upstream failure as a `schools.unavailable` exit. + +### internal (`packages/rpc/src/receipts.ts (InternalReceiptsRpcs)`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `receipts.readReceiptEvidence` | GET `/api/receipt-lifecycle-evidence-records/{receiptId}` | - | cookieHeader | ported | + +Notes (internal): + +- `InternalNativeRpcRouterLive` alone serves it, at `internalNativeRpcPath`; `NativeRpcs` has no + such tag. It keeps `SessionCredential`, the internal AccessSpec, and its evaluation in one + repeatable-read snapshot; a bearer answers credential.invalid. The shared client and script + client build only `NativeRpcs`, so `receipts.spec.ts` posts the RPC to the internal path with + `apps/dashboard/e2e/receipt-rpc.ts`, and `apps/backend/src/receipt/rpc.test.ts` serves the + internal router itself. + +### onboarding (`packages/rpc/src/onboarding.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `onboarding.claim` | POST `/api/onboarding/claim` | - | cookieHeader | ported | +| `onboarding.command` | POST `/api/onboarding` | idempotencyKey; ifMatch; query: departmentId | cookieHeader, oauthUserBearer | ported | +| `onboarding.readBoard` | GET `/api/onboarding` | query: departmentId | cookieHeader, oauthUserBearer | ported | + +Notes (onboarding): + +- The three RPCs keep the old operation IDs as tags. `onboarding.command` keeps + `/api/onboarding/{departmentId}` as its normalized target and digests the command's JSON with its + `ifMatch`, as the HTTP handler did; its receipt keeps the HTTP capsule (resource body, `etag` + header). The command still takes `DepartmentReach<"admissions.outcomes">` (the coordinator) as + evidence, resolved in the committing transaction (`apps/backend/src/onboarding/claim.rpc.test.ts`). +- `onboarding.readBoard` and `onboarding.command` take `{ departmentId }` in the payload; the + command also takes `idempotencyKey`, `ifMatch`, and `request` (the command). Both answer + `OnboardingResource`, the board with its `etag`, as before; the command's `ETag` header is gone. +- `onboarding.claim` has no credential middleware, as the HTTP route had no security scheme: the + handler answers credential.missing or credential.invalid itself. A new-account claim with a + cookie or bearer beside its token answers credential.invalid; an existing-account claim takes the + browser session and refuses a delegated bearer. Credential problems carry no `WWW-Authenticate` + challenge over RPC. +- Dropped: `validation.failed` from decoding the claim or the command (a payload that does not + decode now fails in the RPC server as a defect), `request.malformed`, `request.too-large` + (8 KiB body bound), `media-type.unsupported`, `precondition.required`, `precondition.invalid`, + and `idempotency-key.invalid`. The dashboard's onboarding and account-claim forms decode the same + schemas before they call, so no field error is lost. +- `apps/dashboard/app/routes/dashboard.onboarding.tsx` reads `placements.listScopes` for its + department list; that RPC belongs to the placements slice and does not type-check until it lands. + +### organization (`packages/rpc/src/organization.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `organization.createDepartment` | POST `/api/departments` | idempotencyKey | cookieHeader, oauthUserBearer | ported | +| `organization.createFieldOfStudy` | POST `/api/field-of-studies` | idempotencyKey | cookieHeader, oauthUserBearer | ported | +| `organization.createTeam` | POST `/api/teams` | idempotencyKey | cookieHeader, oauthUserBearer | ported | +| `organization.executeDelegation` | POST `/api/organization/delegations/commands` | idempotencyKey | cookieHeader, oauthUserBearer | ported | +| `organization.executeLifecycle` | POST `/api/organization/appointments/commands` | idempotencyKey | cookieHeader, oauthUserBearer | ported | +| `organization.listDepartments` | GET `/api/departments` | ifMatch; if-none-match (dropped) | none | ported | +| `organization.listFieldOfStudies` | GET `/api/field-of-studies` | ifMatch; if-none-match (dropped) | none | ported | +| `organization.listMailingLists` | GET `/api/mailing-lists` | query: department, semester, type | cookieHeader, oauthUserBearer | ported | +| `organization.listTeamInterest` | GET `/api/team-interest-registrations` | query: department, semester | cookieHeader, oauthUserBearer | ported | +| `organization.listTeams` | GET `/api/teams` | ifMatch; if-none-match (dropped) | none | ported | +| `organization.readAppointmentManagement` | GET `/api/organization/appointments` | - | cookieHeader, oauthUserBearer | ported | +| `organization.readBoardRosters` | GET `/api/organization/board-rosters` | - | cookieHeader, oauthUserBearer | ported | +| `organization.readDelegationManagement` | GET `/api/organization/delegations` | - | cookieHeader, oauthUserBearer | ported | + +Notes (organization): + +- `listDepartments`, `listTeams`, `listFieldOfStudies` lose `If-None-Match`/304, their ETags, and + their public `Cache-Control` (`public, max-age=60, s-maxage=300`): an RPC is a POST that no shared + cache stores, so every read reaches the backend. No RPC took those ETags as `ifMatch`. +- The three create commands answer 200 with the resource instead of 201 with `Location` and ETag; + `executeLifecycle` and `executeDelegation` lose their ETag header. No RPC took those ETags as + `ifMatch`. Receipts keep the old route paths as normalized targets, so a replay across the cutover + answers the stored resource. +- Structural validation moved to the RPC server: a command body that is not JSON, of another media + type, or larger than `ORGANIZATION_MAX_BODY_BYTES` answered `validation.failed` or + `request.too-large`; a payload that does not decode now fails before the handler, and the per + operation body bound no longer applies (the config is still decoded, and unused). An excess + property, such as `actorRole`, was rejected with `validation.failed`; the RPC payload schema now + drops it. No dashboard form showed field pointers from these failures. +- `listTeamInterest` takes `{ departmentId?, semesterId? }` and `listMailingLists` takes + `{ departmentId?, semesterId?, type? }`. A malformed identifier or an unknown list type answered + `request.malformed` 400; the typed client cannot send one now. +- `executeLifecycle` and `executeDelegation` keep their rule that the command's `commandId` equals + its idempotency key. +- The Foldkit organization catalogs and the appointment and delegation management call these RPCs + from the browser through `apps/dashboard/app/lib/browser-native.ts`; the catalog's 304 failure is + gone. +- `run-real-native-organization-administration.mjs` and the organization import rehearsal record + each RPC as the route that it replaced, so their receipts and evidence keep those names; they now + also read `system.readSession` and `profile.readOwnProfile` of other slices. +- Callers left in files that other slices own, for their owners or the lead: + `apps/dashboard/app/routes/__foldkit.content.ts` (`listDepartments`), its + `foldkit/content/bridge-route.test.ts` and `apps/homepage/test/{news,contact-message}.test.ts` + (fetch mocks of `/api/departments`), `apps/dashboard/e2e/golden-team-application-browser.ts` + (`executeLifecycle`), and `tools/e2e/legacy-candidate-native-journey.ts` (`listMailingLists`, + on the deleted `ExternalNativeApiRouterLive`). + +### placements (`packages/rpc/src/placements.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `placements.commandBoard` | POST `/api/placements` | idempotencyKey; ifMatch; query: departmentId, semesterId | cookieHeader, oauthUserBearer | ported | +| `placements.commandCoverageBoard` | POST `/api/placements/coverage` | idempotencyKey; ifMatch; query: departmentId, semesterId | cookieHeader, oauthUserBearer | ported | +| `placements.commandOwnAffiliation` | POST `/api/placements/affiliation` | idempotencyKey; ifMatch; query: departmentId | cookieHeader, oauthUserBearer | ported | +| `placements.commandOwnCoverage` | POST `/api/placements/coverage/own` | idempotencyKey; ifMatch; query: departmentId, semesterId | cookieHeader, oauthUserBearer | ported | +| `placements.listScopes` | GET `/api/placements/scopes` | - | cookieHeader, oauthUserBearer | ported | +| `placements.readBoard` | GET `/api/placements` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | ported | +| `placements.readCoverageBoard` | GET `/api/placements/coverage` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | ported | +| `placements.readDraft` | GET `/api/placements/draft` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | ported | +| `placements.readOwnAffiliation` | GET `/api/placements/affiliation` | query: departmentId | cookieHeader, oauthUserBearer | ported | +| `placements.readOwnCoverage` | GET `/api/placements/coverage/own` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | ported | + +Notes (placements): + +- The ten RPCs keep the old operation IDs as tags. Each command's `normalizedTarget` is the target + the HTTP handler filled from its query scope (`/api/placements/{departmentId}/{semesterId}`, + `/api/placements/affiliation/{departmentId}`, `/api/placements/coverage/{departmentId}/{semesterId}`, + and `/api/placements/coverage/own/{departmentId}/{semesterId}`), and the domain command ID is + still the receipt identity digest. +- Evidence is unchanged: `commandBoard` and `commandCoverageBoard` take + `DepartmentReach<"placements.coordinate">` in `PlacementExecution`, and `commandOwnAffiliation` + and `commandOwnCoverage` run as the resolved person, all inside the committing transaction + (`apps/backend/src/placements/rpc.test.ts`). Each snapshot keeps its `etag`, derived from the + whole snapshot as before; the draft keeps `boardEtag`. +- One problem union per kind replaces `PlacementProblem`: `PlacementsReadProblem` and + `PlacementsCommandProblem`. Dropped: `precondition.required` (the payload requires `ifMatch`), + `request.malformed` for an unknown or repeated query member, `request.too-large` (the 8 KiB body + bound), `media-type.unsupported`, `precondition.invalid`, `idempotency-key.invalid`, + `header.malformed`, and `validation.failed`, which only body and query decoding produced: such a + payload now fails in the RPC server as a defect. `dashboard.assistenter` decodes every command + with the same schemas before it calls, so it loses no field error. +- The request digest covers the command encoded by its schema rather than the parsed body, which + is the same JSON for any body the schema accepts without excess members. New receipts store the + snapshot with its media type only; old receipts, whose body also carries `etag`, still replay. +- Reads and commands lose `Cache-Control: private, no-store`, `Vary: Origin`, and the command's + `ETag` header; the snapshot's `etag` field carries the tag. +- Callers ported: `dashboard.assistenter` (through `callNative`), and the journeys + `tools/e2e/placement-check.ts`, `apps/dashboard/e2e/native-placement.spec.ts`, and the placement + calls of `native-recruitment-first-placement.spec.ts` and + `tools/e2e/legacy-candidate-native-journey.ts`. `placement-check.ts` asserted 428 and 422 for a + command without `If-Match` and for invalid bodies; it now asserts that the command schema + rejects those bodies. The legacy candidate journey asserted 400 for a forged `personId` query + member; the payload drops it, and the journey asserts the caller still reads only their own + affiliation. `run-real-native-placement.mjs` no longer builds the deleted `packages/sdk`. +- Left for the onboarding slice: `apps/dashboard/app/routes/dashboard.onboarding.tsx` still calls + `client.placements.listScopes()` through the deleted `createAuthenticatedClient`. + +### profile (`packages/rpc/src/profile.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `profile.readOwnProfile` | GET `/api/profile` | ifMatch; if-none-match (dropped) | cookieHeader, oauthUserBearer | ported | +| `profile.updateOwnProfile` | PATCH `/api/profile` | idempotencyKey; ifMatch; merge patch | cookieHeader, oauthUserBearer | ported | + +Notes (profile): + +- Both RPCs answer `OwnProfileResource`, the profile beside its strong entity tag, which + `profile.updateOwnProfile` takes as `ifMatch`. The read drops `If-None-Match`/304 and also the + read-side `If-Match` (412 on a GET); no client sent either. +- The merge patch is the `request` field (`ProfileMergePatch`): an absent member keeps its value, no + member answers `validation.no-change`, a `null` member `validation.field-not-deletable`, as before. + A patch value that fails its field schema, or an unknown member, no longer answers + `validation.failed` with the whole request: the RPC server fails the payload as a defect, and an + unknown member is stripped. The dashboard decodes the form with the same fields first and never + showed server field pointers. +- The update's command receipt keeps the HTTP capsule byte for byte (profile JSON body, `etag` + header), so a retry that straddles the cutover replays its first answer; `commandOutcome` is not + used because the success schema is not the stored body. + +### receipts (`packages/rpc/src/receipts.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `receipts.approveReceipt` | POST `/api/receipts/{receiptId}:approve` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | ported | +| `receipts.listReceipts` | GET `/api/receipts` | query: cursor, status | cookieHeader, oauthUserBearer | ported | +| `receipts.listReceiptsForApproval` | GET `/api/receipt-approval-queue` | query: cursor, status | cookieHeader, oauthUserBearer, oauthServiceBearer | ported | +| `receipts.listReceiptsForSettlement` | GET `/api/receipt-settlement-queue` | query: cursor | cookieHeader, oauthUserBearer | ported | +| `receipts.readReceiptFile` | GET `/api/receipts/{receiptId}/file` | binary application/octet-stream | cookieHeader, oauthUserBearer | ported | +| `receipts.readReceiptFileForApproval` | GET `/api/receipt-approval-queue/{receiptId}/file` | binary application/octet-stream | cookieHeader, oauthUserBearer | ported | +| `receipts.readReceiptSettlementForFinance` | GET `/api/receipt-settlement-queue/{receiptId}` | - | cookieHeader, oauthUserBearer | ported | +| `receipts.rejectReceipt` | POST `/api/receipts/{receiptId}:reject` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | ported | +| `receipts.reopenReceipt` | POST `/api/receipts/{receiptId}:reopen` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | ported | +| `receipts.reviseReceipt` | PATCH `/api/receipts/{receiptId}` | idempotencyKey; ifMatch; multipart upload | cookieHeader, oauthUserBearer | ported | +| `receipts.settleReceipt` | POST `/api/receipts/{receiptId}:settle` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | ported | +| `receipts.submitReceipt` | POST `/api/receipts` | idempotencyKey; multipart upload; query: departmentId | cookieHeader, oauthUserBearer | ported | +| `receipts.withdrawReceipt` | POST `/api/receipts/{receiptId}:withdraw` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | ported | + +Notes (receipts): + +- The thirteen RPCs keep the old operation IDs as tags and the old routes (`/api/receipts`, + `/api/receipts/{id}`, `/api/receipts/{id}/withdraw|approve|reject|reopen|settle`) as normalized + targets. Each command's receipt stores the HTTP capsule byte for byte (201 with `Location` and + `ETag` for a submission, 200 with `ETag` otherwise), whose body `commandOutcome` decodes, so a + retry that straddles the cutover replays. The empty-body transitions take + `{ receiptId, idempotencyKey, ifMatch }` and no `request`; their digest still covers `{}`. +- Authority is unchanged: every command resolves the credential inside its serializable + transaction and authorizes through `Economy.authorizeReceiptMutation`, the If-Match check runs + there, and the owner file read evaluates the owner grant in its snapshot + (`apps/backend/src/receipt/{commands,reads}.ts`, tested in `rpc.test.ts` on PostgreSQL). +- Files: a submission or revision carries `{ contentType, bytes }` (`ReceiptFileUpload`), and the + two file reads answer `ReceiptFileContent` (stored media type and bytes). The JSON serialization + carries bytes as base64: a request or answer is about 4/3 of the file, and the server and client + each hold the base64 text, the decoded bytes, and the `File` that staging reads, roughly three + copies of a 10 MiB file per call. `RpcSerialization.layerSchemaBinary` (MessagePack) would carry + raw bytes; it needs its own RPC path and client, so it is not adopted here. +- The size and media-type limits are checked in the handler (`validate.ts`) and still answer + validation.failed: an empty file, a file over `maxFileBytes`, or a media type other than JPEG, + PNG, or PDF. `request.too-large` is gone: nothing bounds the RPC body before it is read and + parsed. The browser still posts a multipart form to the dashboard, whose bounded reader + (`apps/dashboard/app/lib/receipt-upload.server.ts`, moved from `packages/http-api`) answers a + larger file with 413 before any RPC. +- The browser still gets the approval file at `/dashboard/utlegg/{id}/file`: the dashboard route + calls `receipts.readReceiptFileForApproval` and answers the bytes with the private headers + (`cache-control: private, no-store`, `vary: Origin`, `nosniff`, `inline` disposition) derived + from the stored media type. The backend's own `Cache-Control`, `Content-Disposition`, and + `Content-Length` on its file answers are gone. +- Dropped: 201, `Location`, and the `ETag` header on commands (each `ReceiptResource` keeps its + `etag`); the `ETag` header of `receipts.settleReceipt`, which named the settled receipt's revision + beside the evidence (no RPC takes it: a settled receipt answers receipt.already-settled; a client + reads the tag from `receipts.listReceipts`); the private `Cache-Control`/`Vary` of the reads. +- Dropped probes: a malformed JSON body, an excess member, a query on a command, and an unknown + list filter cannot reach the handler (the payload schema fails as a defect or strips the + member), so `request.malformed`, `media-type.unsupported`, `idempotency-key.invalid`, + `precondition.required`, and the old `validation.failed` for a non-empty transition body are gone. + `listReceipts` takes a single `status`, as its handler accepted. A cursor text that no list + issued fails decoding as a defect instead of request.malformed; the dashboard routes decode the + cursor first and show their decode error. +- The settlement request (`RecordReceiptSettlementRequest`) now fails decoding as a defect instead + of validation.failed with pointers; the dashboard settlement form decodes it first and shows its + own field errors. The owner form checks its fields before it calls, too. +- The E2E concurrency barrier reads its probe from the RPC `headers`, so a journey sends + `x-receipt-e2e-concurrency-probe` as a real HTTP header of the RPC request. The + `x-receipt-e2e-concurrency-synchronized` response header is gone: an RPC answer carries no + header. A lane that the others never meet still times out as receipts.unavailable, so the + approval journey reads synchronization from the three outcomes (200, 200, 412). A probe that names + another lane or receipt, which answered request.malformed, is now a defect (internal.error). +- Every receipt RPC re-resolves the credential in its handler, so each declares credential.missing + and credential.invalid. +- Callers migrated: the dashboard routes `dashboard.utlegg*` and `dashboard.mine-utlegg*`, their + unit tests, `receipts.spec.ts`, `receipt-approval.spec.ts`, `run-real-receipt-owner.mjs`, + `run-real-receipt-approval.mjs`, `run-real-native-receipt-settlement.mjs`, the golden + reimbursement journey, `tools/verification/receipt-*.ts`, `unattended-delivery-recovery.ts`, and + the receipt reads of `tools/e2e/legacy-candidate-native-journey.ts`. The journey recorders label + each RPC with the route it replaced. `apps/dashboard/e2e/fixtures/receipt-api.ts`, an HTTP stub + of the old contract that nothing ran, is deleted. None of these journeys was run. + +### recruitment (`packages/rpc/src/recruitment.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `recruitment.cancelInterview` | POST `/api/recruitment/interviews/{interviewId}:cancel` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | ported | +| `recruitment.confirmInvitation` | POST `/api/recruitment/invitation-response:confirm` | ifMatch | invitationCapability | ported | +| `recruitment.correctInterviewAssessment` | POST `/api/recruitment/interviews/{interviewId}:correct` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | ported | +| `recruitment.createApplicationInterview` | POST `/api/recruitment/applications/{applicationId}/interviews` | idempotencyKey | cookieHeader, oauthUserBearer | ported | +| `recruitment.finalizeInterview` | POST `/api/recruitment/interviews/{interviewId}:finalize` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | ported | +| `recruitment.maintainRecruitment` | POST `/api/recruitment/maintenance/commands` | idempotencyKey | cookieHeader, oauthUserBearer | ported | +| `recruitment.readAssignmentBoard` | GET `/api/recruitment/application-assignments` | query: status | cookieHeader, oauthUserBearer | ported | +| `recruitment.readInterviewConduct` | GET `/api/recruitment/interviews/{interviewId}` | ifMatch; if-none-match (dropped) | cookieHeader, oauthUserBearer | ported | +| `recruitment.readInterviewReport` | GET `/api/recruitment/interview-report` | query: admissionPeriodId, recommendation, participation, sort, direction | cookieHeader, oauthUserBearer | ported | +| `recruitment.readInterviewStaffing` | GET `/api/recruitment/interview-staffing` | - | cookieHeader, oauthUserBearer | ported | +| `recruitment.readInvitationResponse` | GET `/api/recruitment/invitation-response` | ifMatch; if-none-match (dropped) | invitationCapability | ported | +| `recruitment.readQuestionnaires` | GET `/api/recruitment/questionnaires` | - | cookieHeader, oauthUserBearer | ported | +| `recruitment.readSchedulingBoard` | GET `/api/recruitment/interviews` | - | cookieHeader, oauthUserBearer | ported | +| `recruitment.rejectInvitation` | POST `/api/recruitment/invitation-response:reject` | ifMatch | invitationCapability | ported | +| `recruitment.requestNewInvitationTime` | POST `/api/recruitment/invitation-response:request-new-time` | ifMatch | invitationCapability | ported | +| `recruitment.scheduleInterview` | POST `/api/recruitment/interviews/{interviewId}:schedule` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | ported | + +Notes (recruitment): + +- The sixteen RPCs keep the old operation IDs as tags and the old routes as `normalizedTarget`, so + receipts and domain command IDs that straddle the cutover stay stable. Every command keeps its + AccessSpec, its authority resolved inside the committing transaction, and its command receipt; + `recruitment.maintainRecruitment` still takes `RecruitmentMaintenanceAuthorization` and requires + `commandId` to equal the idempotency key (`idempotency.digest-conflict`). +- Interview command receipts keep the HTTP capsule byte for byte: the response body and the + interview's new entity tag as the `etag` header (assignment: status 201 and `location` too). The + RPC answers `{ result, etag }` (assignment: `{ interview, etag }`), and a replay decodes the stored + capsule, so a retry across the cutover answers the first response. +- ETags that a later RPC takes as `ifMatch` travel as fields: `readInterviewConduct` answers + `{ detail, etag }`, the scheduling board keeps an `etag` per interview, `readInvitationResponse` + answers `{ observation, etag }`, and each invitation response answers `{ etag }` where HTTP + answered 204 with an `ETag` header. `readQuestionnaires`, `readInterviewStaffing`, and + `maintainRecruitment` lose their ETag headers; no RPC took them as `ifMatch`. +- Invitation responses take `InvitationCapabilityCredential`. The capability moved from the + `X-Recruitment-Invitation-Capability` header into the payload (`capability`), where the handler + decodes it: a malformed, empty, or unknown capability answers resource.not-found, and every + AccessSpec denial is concealed as resource.not-found as before. A cookie or bearer beside the + capability answers credential.invalid (no `WWW-Authenticate` challenge over RPC). A payload with + no `capability` member at all fails in the RPC server as a defect. +- Dropped: `If-None-Match`/304 and the read-side `If-Match` (412) on `readInvitationResponse` and + `readInterviewConduct`, and the private `Cache-Control`/`Vary` of every read. +- Dropped as HTTP parsing: `request.malformed`, `header.malformed`, `origin.denied`, + `request.too-large` (the recruitment body bound, and the 1 MiB maintenance bound), + `media-type.unsupported`, `idempotency-key.invalid`, `precondition.required`, + `precondition.invalid`. Structural validation moved to the RPC server: a finalization or + correction whose answers, scores, or recommendation do not decode, an invitation message that + fails its schema (such as one containing a capability-like token), and an interview report query + with an unknown sort or filter now fail as a defect instead of validation.failed or + request.malformed; an unknown report query member is dropped instead of refused. Answers that + decode but do not fit the questionnaire still answer recruitment.conduct-invalid. The dashboard + bridge and Foldkit forms decode the same schemas first, so no UI loses a field error. +- `recruitment.cancelInterview` takes no `request` member: the HTTP body was an exact empty object, + which the digest still uses. `recruitment.confirmInvitation` likewise takes no body. +- An identifier that no route path can spell (a lone surrogate passes the identifier schemas) makes + the normalized target throw; it is now a defect (internal.error) instead of request.malformed. +- The old `index.ts` exports `recruitmentInterviewAccessContext`, `interviewETag`, + `invitationETag`, and `schedulingBoardWithETags` had no caller outside the backend's own tests, + so they stay module exports of `apps/backend/src/recruitment/`. +- Clients: the dashboard recruitment bridge (`routes/__foldkit.recruitment.ts`), the interview + invitation bridge (`lib/interview-bridge.server.ts`, capability calls with no session cookie via + `callNativeAnonymously`), the maintenance Foldkit program (`callBrowserNative`), and the + interview, applicant, onboarding, and account-claim routes call the RPCs. Journey recorders + classify each RPC as the route it replaced (`apps/dashboard/e2e/native-rpc-ledger.ts`), so their + transport assertions keep their route names; statuses that were 201 or 204 are now 200. The + acceptance probes read each RPC as the replaced route's response (`replacedFetch`). +- Journeys were ported for compilation only and not run (the lead runs them at integration). + + +### social-events (`packages/rpc/src/social-events.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `social-events.create` | POST `/api/social-events` | idempotencyKey | cookieHeader, oauthUserBearer | ported | +| `social-events.list` | GET `/api/social-events` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | ported | +| `social-events.readScope` | GET `/api/social-events/scope` | - | cookieHeader, oauthUserBearer | ported | + +Notes (social-events): + +- Dropped on `create`: 201 (it answers 200), the `Location` and `ETag` headers (no RPC takes that + ETag as `ifMatch`), `Cache-Control: no-store` and `Vary: Origin`, and the `Retry-After: 1` of + `idempotency.in-flight`. A replay still answers the first value from its receipt. +- A create whose end precedes its start fails the payload schema, so the RPC server answers it as + a defect before the handler instead of `validation.failed`, and writes nothing. +- `apps/dashboard/e2e/run-real-native-social-events.mjs` calls each RPC through the script client, + posts the invalid-time payload as raw JSON, and records each RPC by tag, payload, credential + facts (the message headers merged over the HTTP headers), and exit. + +### system (`packages/rpc/src/system.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `system.deleteOwnedSession` | DELETE `/api/sessions/{sessionId}` | idempotencyKey | cookieHeader | ported | +| `system.deleteSession` | DELETE `/api/session` | idempotencyKey | cookieHeader | ported | +| `system.health` | GET `/health` | - | none | dropped | +| `system.listSessions` | GET `/api/sessions` | - | cookieHeader | ported | +| `system.readSession` | GET `/api/session` | - | cookieHeader | ported | +| `system.revokeAllSessions` | POST `/api/sessions:revoke-all` | idempotencyKey | cookieHeader | ported | +| `system.revokeOtherSessions` | POST `/api/sessions:revoke-others` | idempotencyKey | cookieHeader | ported | + +Notes (system): + +- `system.health` is dropped as an RPC: `router.ts` serves `GET /health` as plain HTTP, because + infrastructure probes it. +- The session commands answer no value instead of 204. Their receipts keep the HTTP no-content + capsule, so a retry that straddles the cutover replays; `normalizedTarget` stays the old path, + including the handler's own `/api/sessions::revoke-others` and `/api/sessions::revoke-all`. +- The audit context of a revocation (correlation, source IP, user agent) is read from the RPC + `headers`, which merge the headers of the RPC message over the HTTP headers: a caller can set + `cf-connecting-ip`, `user-agent`, and `x-vektorprogrammet-request-correlation` in the message. + The HTTP ingress set the correlation header; the RPC message now overrides it (lead: see hand-back). + +### team-applications (`packages/rpc/src/team-application.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `team-applications.deleteTeamApplication` | DELETE `/api/team-applications/{applicationId}` | idempotencyKey | cookieHeader, oauthUserBearer | ported | +| `team-applications.listTeamApplicationIntakes` | GET `/api/team-application-intakes` | - | none | ported | +| `team-applications.listTeamApplications` | GET `/api/teams/{teamId}/applications` | query: cursor | cookieHeader, oauthUserBearer | ported | +| `team-applications.readTeamApplication` | GET `/api/team-applications/{applicationId}` | - | cookieHeader, oauthUserBearer | ported | +| `team-applications.readTeamApplicationIntake` | GET `/api/teams/{teamId}/application-intake` | - | none | ported | +| `team-applications.reviseTeamApplicationIntake` | PATCH `/api/teams/{teamId}/application-intake` | idempotencyKey; ifMatch; merge patch | cookieHeader, oauthUserBearer | ported | +| `team-applications.submitTeamApplication` | POST `/api/teams/{teamId}/applications` | idempotencyKey | none | ported | + +Notes (team-applications): + +- The seven RPCs keep the old operation IDs as tags and the old routes as `normalizedTarget` + (`/api/teams/{teamId}/applications`, `/api/team-applications/{applicationId}`, + `/api/teams/{teamId}/application-intake`). Each command's receipt keeps the HTTP capsule byte for + byte: 201 with `Location` and `ETag` for a submission, 204 for a deletion, and 200 with the + intake and its `etag` header for a revision, so a retry that straddles the cutover replays, and + the golden journey's receipt evidence is unchanged. A deletion answers no value. +- Evidence is unchanged: deletion and intake revision take `TeamApplicationAuthorization` from + `TeamApplications.authorize` (`authorizeTeamApplicationAction`) inside the committing + transaction, before the receipt lookup; the staff reads resolve the actor in the domain adapter + within one repeatable-read snapshot; the public intake reads and the submission evaluate their + anonymous AccessSpecs with `authorizeAnonymous`. The per-process public rate limit and the + outbox delivery are kept (`apps/backend/src/team-application/rpc.test.ts`). +- The rate limit now counts a submission after its payload decodes: a payload that fails + `TeamApplicationInput` (or has no idempotency key) fails in the RPC server as a defect and + consumes no quota. Before, every request counted before its body was read. +- Dropped as HTTP parsing: `request.malformed` (except the cursor below), `header.malformed`, + `idempotency-key.invalid`, `origin.denied`, `request.too-large` (the 128 KiB submission and 4 KiB + patch bounds), `media-type.unsupported`, `precondition.required`, `precondition.invalid`, and the + submission's `validation.failed` with a pointer per field. The homepage form decodes every + field with the contract schema before it submits and shows its own field messages, so it loses + no field error; a third-party client does. An unknown member is stripped instead of refused. +- Kept: `request.malformed` on `listTeamApplications` for a cursor that passes + `TeamApplicationCursor` but names no position (`TeamApplicationInvalidCursor`), and on revision + `validation.no-change`, `validation.field-not-deletable` (`/acceptApplication`), and + `validation.failed` should the command not decode. +- The staff RPCs take `PersonCredential` and re-resolve the credential in their transaction, so + each declares credential.missing and credential.invalid; an anonymous denial carries no + `WWW-Authenticate` challenge over RPC. The staff reads lose `Cache-Control: private, no-store` + and `Vary: Origin`, the public reads `Cache-Control: no-store`. +- Dropped: 201, `Location`, and the `ETag` header on the submission (the confirmation had no body + tag, and no RPC took it), 204 on deletion, and the revision's `ETag` header (the resource keeps + `etag`). +- Callers migrated: the dashboard Foldkit `team-applications` client (`callBrowserNative`; its test + records the RPC wire), the homepage team form route and `team-directory.server.ts` + (`callHomepageNative`), `apps/homepage/src/lib/api-types.ts`, and the golden team-application + journey (not run). The journey's direct probes post RPCs and read them as the replaced routes' + statuses; undecodable submissions now expect a defect instead of 422, the missing-If-Match probe + is gone, and the suspension goes through `organization.executeLifecycle`. The runner no longer + builds the deleted `packages/sdk`. `apps/homepage/vite-digests.ts` still names the old routes as + its digest source labels. diff --git a/docs/system-walkthrough.mdx b/docs/system-walkthrough.mdx index 8d00c9d63..2185e7cc9 100644 --- a/docs/system-walkthrough.mdx +++ b/docs/system-walkthrough.mdx @@ -21,7 +21,7 @@ Private receipt files have separate storage. { nodes: [ { label: "Homepage / dashboard" }, - { label: "SDK + HTTP contract" }, + { label: "RPC contract" }, { label: "Bun backend", tone: "accent" }, { label: "PostgreSQL + files" }, ], @@ -30,7 +30,7 @@ Private receipt files have separate storage. /> Read the arrows as responsibility boundaries, not four separate network calls. -An SDK is client code, not another running service. Frontend server loaders can call the backend too. +An RPC client is code derived from the contract, not another running service. Frontend server loaders can call the backend too. - **Homepage:** public information and entry points, including recruitment. - **Dashboard:** authenticated work for volunteers, coordinators, organizational leaders, and finance roles. @@ -131,21 +131,21 @@ A spinner cannot prove that a command succeeded. A stale screen cannot preserve Sources: [frontend state](architecture.md#frontend-state), [dashboard entry](../apps/dashboard/app/entry.client.tsx), [school workflow](../apps/dashboard/app/foldkit/schools/main.ts), [custom element lifecycle](../apps/dashboard/app/foldkit/schools/elements.ts). -## 4. The HTTP contract separates the browser from the server +## 4. The RPC contract separates the browser from the server A screen sends an operation, not an arbitrary database edit. The operation has input schemas, response schemas, known failures, and protocol rules. -The canonical application contract lives in `packages/http-api`. -OpenAPI and consumer artifacts derive from that contract. -The SDK creates typed operations from the external API definition and decodes responses. -It is not a second place to define business meaning. +The canonical application contract lives in `packages/rpc`, as Effect RPC groups. +Each RPC names its operation, its payload and success schemas, and its declared problems. +The backend implements the groups, and every client derives its typed calls from the same groups. +Nothing is generated, and no client is a second place to define business meaning. | Boundary | What crosses it | What stays hidden | | ------------------------------ | ------------------------------------------------------------------------ | ---------------------------------------------------- | | Human browser to server | Action, credentials, inputs, revision, and retry identity where required | Database tables and internal implementation | | Machine to server | Service identity, capability, scope, and supported credentials | Provider-specific authentication plumbing | -| HTTP adapter to domain service | Business command or query, actor, domain values, and explicit failures | HTTP parsing and response encoding | +| RPC handler to domain service | Business command or query, actor, domain values, and explicit failures | Wire decoding and response encoding | | Domain service to persistence | Reads, constraints, locks, and transactional writes | Public UI layout and transport details | | Delivery program to provider | A committed effect and its delivery identity | Authority to invent the underlying business decision | @@ -162,10 +162,10 @@ A repeated command must not produce a second business effect. Reuse of the same retry identity for a different request is a conflict. Better Auth owns separate credential routes under `/api/auth/*`. -The application has separate external and internal API roots. The public OpenAPI excludes the internal root. +The application has separate external (`/api/rpc`) and internal (`/internal/rpc`) RPC groups. There is no OpenAPI document; third-party clients speak the Effect RPC wire protocol. An internal URL alone grants no authority. -Sources: [API root](../packages/http-api/src/api.ts), [SDK construction](../packages/sdk/src/effect-client.ts), [interface contracts](architecture.md#interface-contracts), [encoding](architecture.md#boundary-encoding). +Sources: [RPC groups](../packages/rpc/src/api.ts), [RPC client](../packages/rpc/src/client.ts), [RPC contract and ingress](architecture.md#rpc-contract-and-ingress), [interface contracts](architecture.md#interface-contracts), [encoding](architecture.md#boundary-encoding). ## 5. Identity answers who; authority answers what @@ -191,7 +191,7 @@ An internal receipt-evidence operation still uses its specified Person credentia Network isolation adds a restriction. It does not replace business authorization. -Sources: [identity and authority](architecture.md#identity-and-authority), [authentication engine](../packages/database/src/auth-engine.ts), [password policy](../packages/database/src/password-codec.ts), [receipt HTTP operations](../apps/backend/src/receipt/http.ts). +Sources: [identity and authority](architecture.md#identity-and-authority), [authentication engine](../packages/database/src/auth-engine.ts), [password policy](../packages/database/src/password-codec.ts), [receipt RPC handlers](../apps/backend/src/receipt/rpc.ts). ## 6. Business services own the decisions @@ -215,13 +215,13 @@ Portable contracts live in `packages/domain`; their PostgreSQL implementations l Placements exports its contracts as `@vektorprogrammet/domain/placements` and its database Layer as `@vektorprogrammet/database/placements`. Its service owns complete commands and queries; it is not only a schema collection. -The Placements HTTP adapter handles transport concerns and calls the service inside the native command transaction. +The Placements RPC handler handles transport concerns and calls the service inside the native command transaction. The service holds the department lock across its precondition, transition, audit, history, and outbox writes. This boundary permits changes to SQL or internal structure without changing what a caller's operation means. It does not imply that every older module already has the same boundary. -Sources: [domain services](architecture.md#domain-services), [Placements service](../packages/domain/src/placements/service.ts), [Placements HTTP adapter](../apps/backend/src/placements/http.ts), [domain exports](../packages/domain/package.json), [database exports](../packages/database/package.json). +Sources: [domain services](architecture.md#domain-services), [Placements service](../packages/domain/src/placements/service.ts), [Placements RPC handlers](../apps/backend/src/placements/rpc.ts), [domain exports](../packages/domain/package.json), [database exports](../packages/database/package.json). ## 7. Transactions protect facts; workers deliver effects @@ -235,7 +235,7 @@ Locks protect decisions that depend on several current rows. Revision checks reject stale changes. A failed transaction leaves no partial business decision. -The shared native HTTP command path uses a serializable transaction and a transaction advisory lock. +The shared native command path uses a serializable transaction and a transaction advisory lock. It can replay a retained response or execute and retain one new response atomically. This guarantee belongs to that command path, not automatically to every piece of code in the repository. @@ -282,7 +282,7 @@ Payment-account fields use authenticated encryption with receipt-specific contex A claim, an approval, and a settlement are separate facts. Settlement requires separate evidence and authority. A legacy “refunded” flag cannot establish native payment evidence. -Sources: [command transaction](../apps/backend/src/http-api/receipt-transaction.ts), [persistence](architecture.md#persistence-and-concurrency), [delivery](architecture.md#delivery-and-providers), [recruitment worker](../apps/backend/src/recruitment/worker.ts), [reimbursement](system.md#expense-reimbursement). +Sources: [command transaction](../apps/backend/src/rpc/receipt-transaction.ts), [persistence](architecture.md#persistence-and-concurrency), [delivery](architecture.md#delivery-and-providers), [recruitment worker](../apps/backend/src/recruitment/worker.ts), [reimbursement](system.md#expense-reimbursement). ## 8. The runtime connects the implementations @@ -420,7 +420,7 @@ Sources: [planning](system.md#school-demand-and-placement), [coverage](system.md | Which module owns a decision? | [Architecture](architecture.md) | | What is accepted, and what remains? | [Current state](../STATE.md) | | How do I run the system or a rehearsal? | [Developer guide](../README.md) | -| What can an API consumer request? | [HTTP contract source](../packages/http-api/src/api.ts) | +| What can an API consumer request? | [RPC contract source](../packages/rpc/src/api.ts) | | What did the combined runner actually exercise? | [Migration rehearsal source](../tools/e2e/run-legacy-candidate-rehearsal.ts) | This is a reading guide, not another specification or readiness certificate. diff --git a/docs/web-system-functional-testing.md b/docs/web-system-functional-testing.md index 767d93928..9c5e2149a 100644 --- a/docs/web-system-functional-testing.md +++ b/docs/web-system-functional-testing.md @@ -58,7 +58,7 @@ An observer must not derive expected results from the mutation implementation un Reuse these sources before adding another runner or abstraction: -- [Native assignment runner](../tools/e2e/run-real-native-recruitment-assignment.mjs): isolated runtime, canonical migrations, native authentication, and persistence evidence. +- [Native assignment runner](../tools/e2e/run-real-native-recruitment-assignment.ts): isolated runtime, canonical migrations, native authentication, and persistence evidence. - [Interview-conduct browser scenario](../apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts): multiple actors, reload, and stale updates. - [Placement browser runner](../apps/dashboard/e2e/run-real-native-placement.mjs): scenario manifest and parent-owned database lifecycle. - [Playwright report sanitizer](../apps/dashboard/e2e/runtime-evidence-receipt.mjs): sanitized Playwright results and outcomes for runner evidence. @@ -287,7 +287,7 @@ Screenshots do not create pixel-baseline obligations for the golden suite. ### Continuous substitute coverage The existing golden command continues through [substitute coverage](system.md#substitute-coverage). -The [source-owned checkpoints](../tools/e2e/golden-school-service.mjs) keep the original journey and add the absence-to-coverage sequence. +The [source-owned checkpoints](../tools/e2e/golden-school-service.ts) keep the original journey and add the absence-to-coverage sequence. A substitute is an admission outcome. People agree on cover outside the system. The system records the absence and the person who covered it. The fixture adds two applicants without an outcome and one department member. It creates no outcome, absence, or coverage record. @@ -304,7 +304,7 @@ These synthetic local observations do not establish real-provider delivery or cu ### Recruitment to first placement -The separate [recruitment observer](../tools/e2e/golden-recruitment.mjs) owns the ordered checkpoints for this continuous native journey. +The separate [recruitment observer](../tools/e2e/golden-recruitment.ts) owns the ordered checkpoints for this continuous native journey. The [browser scenario](../apps/dashboard/e2e/native-recruitment-first-placement.spec.ts) drives the public application, interview, onboarding, affiliation, and first placement. Fixtures create prerequisites, not the outcomes under test. Independent PostgreSQL reads bind the same application and Person through the final placement. @@ -358,7 +358,7 @@ See [STATE.md](../STATE.md) for the exercised source revision and remaining gate ### Golden CI implementation The [CI workflow](../.github/workflows/tests.yml) runs the existing journey through the [CI wrapper](../tools/e2e/golden-school-service-ci.mjs). -The [evidence inspector](../tools/e2e/golden-school-service-evidence.mjs) owns source, build, receipt, and artifact checks. +The [evidence inspector](../tools/e2e/golden-school-service-evidence.ts) owns source, build, receipt, and artifact checks. The wrapper derives upload paths from the checked files after staging. The workflow does not maintain another file inventory. Credential checks cover decoded JSON fields and raw diagnostics. Unsupported files cannot enter staging. diff --git a/justfile b/justfile index 3ec797ee9..7c9e13124 100644 --- a/justfile +++ b/justfile @@ -41,7 +41,7 @@ changelog *args: land branch: bun --no-env-file tools/scripts/land.ts "$1" -# Check layout, constructs, guides, Effect exceptions, source safety, format, lint, types, and the HTTP contract. Arguments go to Turbo. +# Check layout, constructs, guides, Effect exceptions, source safety, format, lint, and types. Arguments go to Turbo. [group('check')] check *args: layout constructs guides exceptions source-safety (format "--check") lint bun x turbo check-types "$@" @@ -93,7 +93,7 @@ lint *args=".": bun run --cwd apps/homepage typegen just lint-files "$@" -# Type check every package and assert the HTTP contract. Arguments go to Turbo. +# Type check every package. Arguments go to Turbo. [group('check')] check-types *args: bun x turbo check-types "$@" @@ -125,7 +125,7 @@ golden journey: set -euo pipefail case "$1" in school-service | recruitment) exec bun --no-env-file tools/e2e/placement-check.ts "--golden-$1" ;; - reimbursement) exec bun --no-env-file tools/e2e/golden-reimbursement.mjs ;; + reimbursement) exec bun --no-env-file tools/e2e/golden-reimbursement.ts ;; team-application) exec bun --no-env-file tools/e2e/golden-team-application.ts ;; *) echo "Unknown journey '$1'. Use school-service, recruitment, reimbursement, or team-application." >&2; exit 2 ;; esac diff --git a/oxlint.config.ts b/oxlint.config.ts index ad4b7f2e2..37757892e 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -1,4 +1,10 @@ -import { correctness, effectNative, recommended } from "@effect/tsgo/oxlint-presets"; +import { + antipattern, + correctness, + effectNative, + recommended, + style, +} from "@effect/tsgo/oxlint-presets"; import { defineConfig, type OxlintConfig } from "oxlint"; import { DEFAULT_PLUGIN_NAME, @@ -8,23 +14,36 @@ import { type OxlintConfigFragment, } from "@phibkro/oxlint-effect-plugin"; -// The Effect language-service rules of the `@effect/tsgo` presets need type information, and each -// one is an error. The effectNative rules replace platform APIs with Effect services, so they run -// only in the core Effect packages: the other apps, packages, and tools are not Effect programs. -const effectTsgoPresets = [recommended, correctness]; +// Every Effect language-service rule that `@effect/tsgo` ships is an error: its correctness, +// antipattern, style, and effectNative presets. They need type information. The two React +// applications are the exception: apps/homepage and apps/dashboard keep the recommended and +// correctness rules without the effectNative ones, because React components and Playwright specs +// build on the platform APIs those rules replace. +const effectTsgoPresets = [recommended, correctness, antipattern, style, effectNative]; -const coreEffectFiles = [ - "apps/backend/**", - "packages/database/**", - "packages/domain/**", - "packages/http-api/**", -]; +const reactApplicationFiles = ["apps/homepage/**", "apps/dashboard/**"]; const presetRules = (presets: ReadonlyArray, severity: "error" | "off") => Object.fromEntries( presets.flatMap((preset) => Object.keys(preset.rules ?? {})).map((rule) => [rule, severity]), ); +// The Effect rules of the React applications: recommended and correctness, less effectNative. +const reactApplicationRules = () => { + const kept = new Set( + [recommended, correctness] + .flatMap((preset) => Object.keys(preset.rules ?? {})) + .filter((rule) => !Object.hasOwn(effectNative.rules ?? {}, rule)), + ); + + return Object.fromEntries( + Object.keys(presetRules(effectTsgoPresets, "error")).map((rule) => [ + rule, + kept.has(rule) ? "error" : "off", + ]), + ); +}; + // Bun implements these Node modules, and the Bun groups import them beside Bun's own modules. // The rule admits extra modules but no globals, so these files import `process` and `Buffer` too. const bunNodeModules = { @@ -70,7 +89,7 @@ const effectConfig = { strictness: "strict", }, { - files: ["packages/sdk/src/**/*.ts"], + files: ["packages/rpc/src/client.ts"], role: "effect-library", platform: "portable", strictness: "strict", @@ -101,8 +120,8 @@ const effectConfig = { strictness: "strict", }, { - // The SDK selects FetchHttpClient, closes the environment, and runs each operation for Promise callers. - files: ["packages/sdk/src/effect-client.ts", "packages/sdk/src/promise.ts"], + // The script client builds its own runtime and runs each RPC for Promise callers. + files: ["packages/rpc/src/script-client.ts"], role: "composition-root", platform: "portable", strictness: "strict", @@ -149,21 +168,48 @@ const effectConfig = { files: [ "apps/backend/src/main.ts", "apps/backend/src/**/*-main.ts", + "apps/docs/scripts/sync-pages.ts", + "tools/acceptance/onboarding-check.ts", "tools/acceptance/password-recovery-check.ts", + "tools/acceptance/substitute-outcome-check.ts", + "tools/acceptance/recommendation-check.ts", "tools/e2e/golden-harness.ts", "tools/e2e/golden-harness-self-test.ts", + "tools/e2e/golden-recruitment.ts", + "tools/e2e/golden-reimbursement.ts", + "tools/e2e/golden-school-service.ts", + "tools/e2e/golden-school-service-evidence.ts", + "tools/e2e/golden-school-service-evidence.test.ts", + "tools/e2e/run-real-native-recruitment-assignment.ts", "tools/e2e/legacy-candidate-native-journey.ts", "tools/e2e/legacy-organization-rehearsal-runtime.ts", + "tools/e2e/placement-check.ts", "tools/e2e/public-application-outbox-driver.ts", + "tools/e2e/receipt-delivery-sink.ts", "tools/e2e/record-native-recruitment-invitation-response.ts", "tools/e2e/record-native-recruitment-invitation.ts", "tools/e2e/run-legacy-*.ts", + "tools/conventions/src/cli.ts", + "tools/placements-docs/placements.ts", + "tools/scripts/changelog.ts", + "tools/scripts/deploy-preview.ts", + "tools/scripts/dev.ts", + "tools/scripts/hook-slot.ts", + "tools/scripts/land.ts", + "tools/scripts/measure-job.ts", + "tools/scripts/model.ts", + "tools/scripts/require-legacy-data-profile.ts", + "tools/scripts/vitest-admission.ts", + "tools/source-safety/src/check.ts", "tools/verification/completion-receipt-postgres-proof-main.ts", "tools/verification/current-assignment-cohort-cli.ts", + "tools/verification/current-assignment-cohort-main.ts", "tools/verification/current-assignment-cohort-rehearsal.ts", "tools/verification/identity-cohort-rehearsal.ts", "tools/verification/organization-import-rehearsal-main.ts", "tools/verification/receipt-import-rehearsal.ts", + "tools/verification/recommendation-preupgrade-fixture.ts", + "tools/verification/unattended-delivery-recovery.ts", ], role: "composition-root", platform: "bun", @@ -175,6 +221,7 @@ const effectConfig = { files: [ "tools/conventions/tests/*.test.ts", "tools/e2e/safe-file-io.ts", + "tools/placements-docs/placements-artifact.test.ts", "tools/scripts/tests/*.test.ts", "tools/source-safety/tests/source-safety.test.ts", ], @@ -211,15 +258,6 @@ const crossPackageSourceImportPatterns = [ }, ]; -// Same boundary without the SDK, whose export map resolves to built `dist/` unless the source condition is set. -const crossPackageSourceImportPatternsExceptSdk = [ - { - regex: - "^(\\./)?(\\.\\./)+(apps/[^/]+|tools/[^/]+|packages/(database|domain|http-api)|[^./][^/]*)/src(/|$)", - message: crossPackageSourceImportMessage, - }, -]; - const productImportPatterns = [ ...crossPackageSourceImportPatterns, { @@ -237,6 +275,25 @@ const browserImportPatterns = [ const expandedEffectConfig = totalOverrides(expandDomains(effectConfig)); +// Effect.provide belongs at an entry point, which the rule cannot recognise itself: its +// documentation asks to disable it there. The entry points are the files that `effectConfig` +// declares as composition roots or tests, and the backend test harness that serves them. +const entryPointFiles = [ + ...new Set( + effectConfig.groups + .filter((group) => group.role === "composition-root" || group.role === "test") + .flatMap((group) => group.files), + ), + "apps/backend/src/test/**/*.ts", + // The proofs, rehearsals, and examples of the domain and database packages run as programs, and + // the runtimes they share provide each proof its own database or platform. + "packages/domain/runtime/**/*.ts", + "packages/database/runtime/**/*.ts", + "packages/database/examples/**/*.ts", + "packages/database/src/receipt/file-proof.ts", + "packages/database/src/rule-reconciliation-migration-postgres-proof.ts", +]; + export default defineConfig({ ...expandedEffectConfig, extends: effectTsgoPresets, @@ -250,7 +307,6 @@ export default defineConfig({ ], rules: { ...presetRules(effectTsgoPresets, "error"), - ...presetRules([effectNative], "off"), "no-restricted-imports": ["error", { patterns: crossPackageSourceImportPatterns }], "anti-slop-effect/no-manual-effect-error-tag": "error", "anti-slop-effect/no-manual-tag-comparison": "error", @@ -269,8 +325,12 @@ export default defineConfig({ "anti-slop/no-reduce-accumulator-copy": "error", "anti-slop/no-reflect-apply": "error", "anti-slop/no-reflect-get": "error", + // The RPC client posts with a trailing slash and a probe without; paths go through the predicates. + "anti-slop/no-rpc-path-comparison": "error", "anti-slop/no-runtime-typeof": "error", "anti-slop/no-shape-in-symbol-names": "error", + // Axe runs only through auditSettledPage, which waits for running transitions to finish. + "anti-slop/no-unsettled-axe": "error", "anti-slop/no-unknown-parameters": "error", "anti-slop/no-unknown-returns": "error", "anti-slop/no-unknown-type-aliases": "error", @@ -281,7 +341,6 @@ export default defineConfig({ }, overrides: [ ...expandedEffectConfig.overrides, - { files: coreEffectFiles, rules: presetRules([effectNative], "error") }, { files: ["apps/*/src/**", "apps/dashboard/app/**", "packages/*/src/**"], rules: { @@ -289,11 +348,7 @@ export default defineConfig({ }, }, { - files: [ - "apps/homepage/src/**", - "apps/dashboard/app/**", - "packages/{domain,http-api,sdk}/src/**", - ], + files: ["apps/homepage/src/**", "apps/dashboard/app/**", "packages/{domain,rpc}/src/**"], rules: { "no-restricted-imports": [ "error", @@ -311,18 +366,6 @@ export default defineConfig({ "no-restricted-imports": "off", }, }, - { - // Bun resolves the SDK to built `dist/` without `--conditions=@vektorprogrammet/source`; these drivers import its source. - files: [ - "tools/e2e/placement-check.ts", - "tools/verification/organization-import-rehearsal-main.ts", - "tools/verification/receipt-import-rehearsal.ts", - "tools/verification/receipt-reopen-observation.ts", - ], - rules: { - "no-restricted-imports": ["error", { patterns: crossPackageSourceImportPatternsExceptSdk }], - }, - }, { // Advisory locks go through lockAdvisory, whose registered keys own the lock identity. files: ["apps/**", "packages/**"], @@ -349,6 +392,16 @@ export default defineConfig({ ], rules: { "anti-slop/no-zoneless-calendar-interval": "off" }, }, + { + // The settled-audit construct is the one module that loads and constructs axe. + files: ["apps/dashboard/e2e/settled-axe.ts"], + rules: { "anti-slop/no-unsettled-axe": "off" }, + }, + { + // The definition of the RPC endpoint predicates compares the paths it names. + files: ["packages/rpc/src/api.ts"], + rules: { "anti-slop/no-rpc-path-comparison": "off" }, + }, { // Unit leadership decides authority only through the reach interpreter (O8-11). files: ["apps/*/src/**", "apps/dashboard/app/**", "packages/*/src/**"], @@ -425,6 +478,9 @@ export default defineConfig({ globals: { Bun: "readonly", HTMLRewriter: "readonly" }, rules: { "no-undef": "error" }, }, + { files: entryPointFiles, rules: { "effecttsgo/strict-effect-provide": "off" } }, + // Last, so no broader group decides the Effect rules of the React applications. + { files: reactApplicationFiles, rules: reactApplicationRules() }, ], ignorePatterns: [ "tools/oxlint/anti-slop/**", diff --git a/package.json b/package.json index e4509cc27..e3e3dab02 100644 --- a/package.json +++ b/package.json @@ -43,9 +43,13 @@ "fast-check": "4.9.0", "foldkit": "0.163.0", "vitest": "5.0.0" + }, + "react": { + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "react": "19.2.8", + "react-dom": "19.2.8", + "vite": "8.2.2" } - }, - "patchedDependencies": { - "effect@4.0.0-rc.116": "patches/effect@4.0.0-rc.116.patch" } } diff --git a/packages/database/runtime/authorization-rules-postgres-proof-main.ts b/packages/database/runtime/authorization-rules-postgres-proof-main.ts index 0bdc6a8c4..c725c81b7 100644 --- a/packages/database/runtime/authorization-rules-postgres-proof-main.ts +++ b/packages/database/runtime/authorization-rules-postgres-proof-main.ts @@ -47,7 +47,7 @@ import { OrganizationGlobalAdministratorGrantId, PersonId, SemesterId, - authorizeOrganizationActor, + requireOrganizationAdministrator, mapOrganizationAuthorityToDepartmentActor, mapOrganizationAuthorityToOrganizationActor, mapOrganizationAuthorityToProfileRole, @@ -2058,13 +2058,10 @@ const proveZeroRuleEquivalence = (databaseUrl: Redacted.Redacted) => const organizationAcceptedRulesEmptyActor = mapOrganizationAuthorityToOrganizationActor(administratorProjection); - const organizationAcceptedDirectResult = yield* Effect.result( - authorizeOrganizationActor(organizationAcceptedDirectActor), - ); + const organizationAcceptedDirectResult = requireOrganizationAdministrator(administratorFixture); - const organizationAcceptedRulesEmptyResult = yield* Effect.result( - authorizeOrganizationActor(organizationAcceptedRulesEmptyActor), - ); + const organizationAcceptedRulesEmptyResult = + requireOrganizationAdministrator(administratorProjection); const organizationAcceptedDirect = { actor: organizationAcceptedDirectActor, @@ -2086,13 +2083,9 @@ const proveZeroRuleEquivalence = (databaseUrl: Redacted.Redacted) => const organizationRejectedRulesEmptyActor = mapOrganizationAuthorityToOrganizationActor(memberProjection); - const organizationRejectedDirectResult = yield* Effect.result( - authorizeOrganizationActor(organizationRejectedDirectActor), - ); + const organizationRejectedDirectResult = requireOrganizationAdministrator(memberFixture); - const organizationRejectedRulesEmptyResult = yield* Effect.result( - authorizeOrganizationActor(organizationRejectedRulesEmptyActor), - ); + const organizationRejectedRulesEmptyResult = requireOrganizationAdministrator(memberProjection); const organizationRejectedDirect = { actor: organizationRejectedDirectActor, @@ -2166,11 +2159,11 @@ const proveZeroRuleEquivalence = (databaseUrl: Redacted.Redacted) => assert.deepEqual(composition.contributingRuleIds, []); assert.equal(composition.mapped._tag, "Success"); - const result = yield* executeReceiptCommand( - submitCommand(ids.commands.directSubmit, ids.departments.alpha, "direct"), - principal(ids.persons.direct, justBeforeExactEnd), - allocation(generatedReceiptIds.direct, generatedVisualIds.direct), - ); + const result = yield* executeReceiptCommand({ + command: submitCommand(ids.commands.directSubmit, ids.departments.alpha, "direct"), + principal: principal(ids.persons.direct, justBeforeExactEnd), + allocation: allocation(generatedReceiptIds.direct, generatedVisualIds.direct), + }); const durable = yield* readDurableCommandFacts(sql, ids.commands.directSubmit); @@ -2231,18 +2224,18 @@ const proveZeroRuleEquivalence = (databaseUrl: Redacted.Redacted) => assert.equal(rejectedComposition.mapped._tag, "Success"); const rejectedResult = yield* Effect.result( - executeReceiptCommand( - submitCommand( + executeReceiptCommand({ + command: submitCommand( ids.commands.matrixReceiptRejected, ids.departments.alpha, "matrix-receipt-rejected", ), - principal(ids.persons.endedDirect, exactEnd), - allocation( + principal: principal(ids.persons.endedDirect, exactEnd), + allocation: allocation( generatedReceiptIds.matrixReceiptRejected, generatedVisualIds.matrixReceiptRejected, ), - ), + }), ); const rejectedDurable = yield* readDurableCommandFacts(sql, ids.commands.matrixReceiptRejected); @@ -2423,14 +2416,14 @@ const proveHalfOpenAndScopeDenials = (databaseUrl: Redacted.Redacted) => ); const endedRuleCommand = yield* Effect.result( - executeReceiptCommand( - ReceiptCommandRequestSchema.cases.RejectReceipt.make({ + executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.RejectReceipt.make({ commandId: ids.commands.endedRuleApprove, receiptId: ReceiptId.make(ids.receipts.endedRule), expectedRevision: 0, }), - principal(ids.persons.endedRule, exactEnd), - ), + principal: principal(ids.persons.endedRule, exactEnd), + }), ); const endedRuleDurable = yield* readDurableCommandFacts(sql, ids.commands.endedRuleApprove); @@ -2448,11 +2441,15 @@ const proveHalfOpenAndScopeDenials = (databaseUrl: Redacted.Redacted) => ); const endedDirectCommand = yield* Effect.result( - executeReceiptCommand( - submitCommand(ids.commands.endedDirectSubmit, ids.departments.alpha, "ended-direct"), - principal(ids.persons.endedDirect, exactEnd), - allocation(generatedReceiptIds.endedDirect, generatedVisualIds.endedDirect), - ), + executeReceiptCommand({ + command: submitCommand( + ids.commands.endedDirectSubmit, + ids.departments.alpha, + "ended-direct", + ), + principal: principal(ids.persons.endedDirect, exactEnd), + allocation: allocation(generatedReceiptIds.endedDirect, generatedVisualIds.endedDirect), + }), ); const endedDirectDurable = yield* readDurableCommandFacts(sql, ids.commands.endedDirectSubmit); @@ -2472,11 +2469,18 @@ const proveHalfOpenAndScopeDenials = (databaseUrl: Redacted.Redacted) => ); const crossDepartmentCommand = yield* Effect.result( - executeReceiptCommand( - submitCommand(ids.commands.crossDepartmentSubmit, ids.departments.beta, "cross-department"), - principal(ids.persons.crossDepartment, exactEnd), - allocation(generatedReceiptIds.crossDepartment, generatedVisualIds.crossDepartment), - ), + executeReceiptCommand({ + command: submitCommand( + ids.commands.crossDepartmentSubmit, + ids.departments.beta, + "cross-department", + ), + principal: principal(ids.persons.crossDepartment, exactEnd), + allocation: allocation( + generatedReceiptIds.crossDepartment, + generatedVisualIds.crossDepartment, + ), + }), ); const crossDepartmentDurable = yield* readDurableCommandFacts( @@ -2560,776 +2564,780 @@ const proveHalfOpenAndScopeDenials = (databaseUrl: Redacted.Redacted) => }; }).pipe(Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-boundaries`))); -const proveDirectAuthorityPhantomProtocol = (databaseUrl: Redacted.Redacted) => - Effect.scoped( - Effect.gen(function* () { - const economyInsertFirst = yield* Effect.gen(function* () { - const writerPaused = yield* Deferred.make(); - const resumeWriter = yield* Deferred.make(); - const commandAttempted = yield* Deferred.make(); - const writerStarted = yield* Deferred.make(); - const commandStarted = yield* Deferred.make(); - const writerTrace = makeSqlTrace(); - const commandTrace = makeSqlTrace(); - - const before = yield* submissionCompositionFacts( - ids.persons.phantomEconomy, - exactEnd, - ids.departments.alpha, - ).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-economy-before`), - ), - ); - - assert.deepEqual(before.applicableRuleIds, []); - assert.equal(before.directPaymentAuthorities.length, 0); - assert.deepEqual( - before.mapped, - ReceiptActorMappingEvidence.Failure({ failureTag: "ReceiptAuthorityDenied" }), - ); - - const writerFiber = yield* Effect.forkScoped( - Effect.gen(function* () { - const sql = yield* Database; - const started = yield* connectionStamp(sql); - yield* Deferred.succeed(writerStarted, started); - - const observed = observeSql(sql, writerTrace, { - pauseAfter: { - phase: "direct-payment-insert", - ready: writerPaused, - resume: resumeWriter, - }, - }); - - const created = yield* createReceiptPaymentAuthority({ - paymentAuthorityId: ReceiptPaymentAuthorityId.make( - ids.directAuthorities.phantomEconomyPayment, - ), - personId: personId(ids.persons.phantomEconomy), - departmentId: departmentId(ids.departments.alpha), - paymentAccountCiphertext: "ciphertext:proof:phantom-economy", - startAt: activeStart, - endAt: null, - }).pipe(Effect.provideService(Database, observed)); - - const completed = yield* connectionStamp(sql); - - return { started, completed, created }; - }).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-economy-writer`), - ), - ), - ); +const provePhantomEconomyInsertFirst = (databaseUrl: Redacted.Redacted) => + Effect.gen(function* () { + const writerPaused = yield* Deferred.make(); + const resumeWriter = yield* Deferred.make(); + const commandAttempted = yield* Deferred.make(); + const writerStarted = yield* Deferred.make(); + const commandStarted = yield* Deferred.make(); + const writerTrace = makeSqlTrace(); + const commandTrace = makeSqlTrace(); + + const before = yield* submissionCompositionFacts( + ids.persons.phantomEconomy, + exactEnd, + ids.departments.alpha, + ).pipe( + Effect.provide( + makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-economy-before`), + ), + ); - yield* Deferred.await(writerPaused); + assert.deepEqual(before.applicableRuleIds, []); + assert.equal(before.directPaymentAuthorities.length, 0); + assert.deepEqual( + before.mapped, + ReceiptActorMappingEvidence.Failure({ failureTag: "ReceiptAuthorityDenied" }), + ); - const commandFiber = yield* Effect.forkScoped( - Effect.gen(function* () { - const sql = yield* Database; - const started = yield* connectionStamp(sql); - yield* Deferred.succeed(commandStarted, started); + const writerFiber = yield* Effect.forkScoped( + Effect.gen(function* () { + const sql = yield* Database; + const started = yield* connectionStamp(sql); + yield* Deferred.succeed(writerStarted, started); + + const observed = observeSql(sql, writerTrace, { + pauseAfter: { + phase: "direct-payment-insert", + ready: writerPaused, + resume: resumeWriter, + }, + }); - const observed = observeSql(sql, commandTrace, { - signalBefore: { - phase: "person-authorization-lock", - deferred: commandAttempted, - }, - }); - - const value = yield* executeReceiptCommand( - submitCommand( - ids.commands.phantomEconomyInsert, - ids.departments.alpha, - "phantom-economy-insert", - ), - principal(ids.persons.phantomEconomy, exactEnd), - allocation( - generatedReceiptIds.phantomEconomyInsert, - generatedVisualIds.phantomEconomyInsert, - ), - ).pipe(Effect.provideService(Database, observed)); - - const completed = yield* connectionStamp(sql); - - return { started, completed, value }; - }).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-economy-command`), - ), + const created = yield* createReceiptPaymentAuthority({ + paymentAuthorityId: ReceiptPaymentAuthorityId.make( + ids.directAuthorities.phantomEconomyPayment, ), - ); + personId: personId(ids.persons.phantomEconomy), + departmentId: departmentId(ids.departments.alpha), + paymentAccountCiphertext: "ciphertext:proof:phantom-economy", + startAt: activeStart, + endAt: null, + }).pipe(Effect.provideService(Database, observed)); - yield* Deferred.await(commandAttempted); - const writerStamp = yield* Deferred.await(writerStarted); - const commandStamp = yield* Deferred.await(commandStarted); - assert.notEqual(writerStamp.pid, commandStamp.pid); + const completed = yield* connectionStamp(sql); - const writerAtBlock = { - attempted: [...writerTrace.attempted], - completed: [...writerTrace.completed], - }; + return { started, completed, created }; + }).pipe( + Effect.provide( + makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-economy-writer`), + ), + ), + ); - const commandAtBlock = { - attempted: [...commandTrace.attempted], - completed: [...commandTrace.completed], - }; + yield* Deferred.await(writerPaused); + + const commandFiber = yield* Effect.forkScoped( + Effect.gen(function* () { + const sql = yield* Database; + const started = yield* connectionStamp(sql); + yield* Deferred.succeed(commandStarted, started); - const locks = yield* observeBlockingAndLocks({ - databaseUrl, - blockedPid: commandStamp.pid, - blockerPid: writerStamp.pid, - personId: ids.persons.phantomEconomy, - commandId: ids.commands.phantomEconomyInsert, - applicationName: `${proofApplicationPrefix}-phantom-economy-observer`, + const observed = observeSql(sql, commandTrace, { + signalBefore: { + phase: "person-authorization-lock", + deferred: commandAttempted, + }, }); - assert.equal(locks.blocked.waitEventType, "Lock"); - assert.ok(locks.blocked.blockingPids.includes(writerStamp.pid)); - assert.ok( - hasAdvisoryLock( - locks.advisoryLocks, - writerStamp.pid, - "person-authorization", - "ExclusiveLock", - true, + const value = yield* executeReceiptCommand({ + command: submitCommand( + ids.commands.phantomEconomyInsert, + ids.departments.alpha, + "phantom-economy-insert", ), - ); - assert.ok( - hasAdvisoryLock( - locks.advisoryLocks, - commandStamp.pid, - "person-authorization", - "ExclusiveLock", - false, + principal: principal(ids.persons.phantomEconomy, exactEnd), + allocation: allocation( + generatedReceiptIds.phantomEconomyInsert, + generatedVisualIds.phantomEconomyInsert, ), - ); - assert.ok( - hasRelationLock( - locks.relationLocks, - writerStamp.pid, - "public.economy_payment_authorities", - "RowExclusiveLock", - ), - ); - assert.equal(commandAtBlock.attempted.at(-1), "person-authorization-lock"); - yield* Deferred.succeed(resumeWriter, undefined); - const writer = yield* Fiber.join(writerFiber); - const command = yield* Fiber.join(commandFiber); - assert.equal(writer.created.revision, 0); - assertSubsequence(writerTrace.completed, [ - "person-authorization-lock", - "direct-payment-insert", - ]); - assertSubsequence(commandTrace.completed, [ - "person-authorization-lock", - "organization-authority-projection", - "direct-receipt-authority-projection", - "authz-shared-lock", - "authz-tag-assignment-projection", - "authz-rule-projection", - "durable-audit-insert", - ]); - - const afterInsert = yield* submissionCompositionFacts( - ids.persons.phantomEconomy, - exactEnd, - ids.departments.alpha, - ).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-economy-after`), - ), - ); + }).pipe(Effect.provideService(Database, observed)); - assert.deepEqual(afterInsert.applicableRuleIds, []); - assert.deepEqual(afterInsert.composedPaymentAuthorityIds, [ + const completed = yield* connectionStamp(sql); + + return { started, completed, value }; + }).pipe( + Effect.provide( + makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-economy-command`), + ), + ), + ); + + yield* Deferred.await(commandAttempted); + const writerStamp = yield* Deferred.await(writerStarted); + const commandStamp = yield* Deferred.await(commandStarted); + assert.notEqual(writerStamp.pid, commandStamp.pid); + + const writerAtBlock = { + attempted: [...writerTrace.attempted], + completed: [...writerTrace.completed], + }; + + const commandAtBlock = { + attempted: [...commandTrace.attempted], + completed: [...commandTrace.completed], + }; + + const locks = yield* observeBlockingAndLocks({ + databaseUrl, + blockedPid: commandStamp.pid, + blockerPid: writerStamp.pid, + personId: ids.persons.phantomEconomy, + commandId: ids.commands.phantomEconomyInsert, + applicationName: `${proofApplicationPrefix}-phantom-economy-observer`, + }); + + assert.equal(locks.blocked.waitEventType, "Lock"); + assert.ok(locks.blocked.blockingPids.includes(writerStamp.pid)); + assert.ok( + hasAdvisoryLock( + locks.advisoryLocks, + writerStamp.pid, + "person-authorization", + "ExclusiveLock", + true, + ), + ); + assert.ok( + hasAdvisoryLock( + locks.advisoryLocks, + commandStamp.pid, + "person-authorization", + "ExclusiveLock", + false, + ), + ); + assert.ok( + hasRelationLock( + locks.relationLocks, + writerStamp.pid, + "public.economy_payment_authorities", + "RowExclusiveLock", + ), + ); + assert.equal(commandAtBlock.attempted.at(-1), "person-authorization-lock"); + yield* Deferred.succeed(resumeWriter, undefined); + const writer = yield* Fiber.join(writerFiber); + const command = yield* Fiber.join(commandFiber); + assert.equal(writer.created.revision, 0); + assertSubsequence(writerTrace.completed, [ + "person-authorization-lock", + "direct-payment-insert", + ]); + assertSubsequence(commandTrace.completed, [ + "person-authorization-lock", + "organization-authority-projection", + "direct-receipt-authority-projection", + "authz-shared-lock", + "authz-tag-assignment-projection", + "authz-rule-projection", + "durable-audit-insert", + ]); + + const afterInsert = yield* submissionCompositionFacts( + ids.persons.phantomEconomy, + exactEnd, + ids.departments.alpha, + ).pipe( + Effect.provide( + makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-economy-after`), + ), + ); + + assert.deepEqual(afterInsert.applicableRuleIds, []); + assert.deepEqual(afterInsert.composedPaymentAuthorityIds, [ + ids.directAuthorities.phantomEconomyPayment, + ]); + assert.equal(afterInsert.mapped._tag, "Success"); + + const cleanup = yield* Effect.gen(function* () { + const sql = yield* Database; + const durable = yield* readDurableCommandFacts(sql, ids.commands.phantomEconomyInsert); + + const removed = yield* removeReceiptPaymentAuthority({ + paymentAuthorityId: ReceiptPaymentAuthorityId.make( ids.directAuthorities.phantomEconomyPayment, - ]); - assert.equal(afterInsert.mapped._tag, "Success"); + ), + expectedRevision: 0, + }); - const cleanup = yield* Effect.gen(function* () { - const sql = yield* Database; - const durable = yield* readDurableCommandFacts(sql, ids.commands.phantomEconomyInsert); + const [remaining] = yield* sql<{ readonly count: number }>` + SELECT count(*)::integer AS count + FROM public.economy_payment_authorities + WHERE payment_authority_id = ${ids.directAuthorities.phantomEconomyPayment} + `; - const removed = yield* removeReceiptPaymentAuthority({ - paymentAuthorityId: ReceiptPaymentAuthorityId.make( - ids.directAuthorities.phantomEconomyPayment, - ), - expectedRevision: 0, - }); + assert(remaining); + + return { + durable, + removed: { + paymentAuthorityId: removed.paymentAuthorityId, + revision: removed.revision, + }, + remainingRows: remaining.count, + }; + }).pipe( + Effect.provide( + makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-economy-cleanup`), + ), + ); + + assert.deepEqual(cleanup.durable, { + commandReceiptRows: 1, + auditRows: 1, + outboxRows: 3, + outboxCommandRows: 1, + }); + assert.equal(cleanup.remainingRows, 0); - const [remaining] = yield* sql<{ readonly count: number }>` - SELECT count(*)::integer AS count - FROM public.economy_payment_authorities - WHERE payment_authority_id = ${ids.directAuthorities.phantomEconomyPayment} - `; + return { + order: "WriterInsertFirst" as const, + before, + participants: { + writer: writer.started, + writerCompleted: writer.completed, + command: command.started, + commandCompleted: command.completed, + independentBackendPids: writer.started.pid !== command.started.pid, + }, + blocked: locks.blocked, + relationLocks: locks.relationLocks, + advisoryLocks: locks.advisoryLocks, + sqlOrderAtBlock: { writer: writerAtBlock, command: commandAtBlock }, + sqlOrderAfterCommit: { writer: writerTrace, command: commandTrace }, + createdAuthority: { + paymentAuthorityId: writer.created.paymentAuthorityId, + personId: writer.created.personId, + departmentId: writer.created.departmentId, + startAt: writer.created.startAt, + endAt: writer.created.endAt, + revision: writer.created.revision, + }, + visibleComposition: afterInsert, + commandObservation: command.value.observation, + cleanup, + }; + }); - assert(remaining); +const provePhantomOrganizationLifecycle = (databaseUrl: Redacted.Redacted) => + Effect.gen(function* () { + const approvalGrant = yield* createReceiptApprovalGrant({ + approvalGrantId: ReceiptApprovalGrantId.make( + ids.directAuthorities.phantomOrganizationApproval, + ), + personId: personId(ids.persons.phantomOrganization), + scope: AuthzRuleScopeSchema.cases.Global.make({}), + startAt: activeStart, + endAt: null, + }).pipe( + Effect.provide( + makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-approval-setup`), + ), + ); - return { - durable, - removed: { - paymentAuthorityId: removed.paymentAuthorityId, - revision: removed.revision, - }, - remainingRows: remaining.count, - }; - }).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-economy-cleanup`), - ), - ); + const before = yield* approvalCompositionFacts( + ids.persons.phantomOrganization, + exactEnd, + ids.departments.alpha, + ).pipe( + Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-before`)), + ); - assert.deepEqual(cleanup.durable, { - commandReceiptRows: 1, - auditRows: 1, - outboxRows: 3, - outboxCommandRows: 1, - }); - assert.equal(cleanup.remainingRows, 0); + assert.deepEqual(before.applicableRuleIds, []); + assert.equal(before.directApprovalGrants[0]?.active, false); + assert.equal(before.mapped._tag, "Success"); - return { - order: "WriterInsertFirst" as const, - before, - participants: { - writer: writer.started, - writerCompleted: writer.completed, - command: command.started, - commandCompleted: command.completed, - independentBackendPids: writer.started.pid !== command.started.pid, - }, - blocked: locks.blocked, - relationLocks: locks.relationLocks, - advisoryLocks: locks.advisoryLocks, - sqlOrderAtBlock: { writer: writerAtBlock, command: commandAtBlock }, - sqlOrderAfterCommit: { writer: writerTrace, command: commandTrace }, - createdAuthority: { - paymentAuthorityId: writer.created.paymentAuthorityId, - personId: writer.created.personId, - departmentId: writer.created.departmentId, - startAt: writer.created.startAt, - endAt: writer.created.endAt, - revision: writer.created.revision, + if (Predicate.isTagged(before.mapped, "Success")) + assert.equal(before.mapped.actor.active, false); + + const writerPaused = yield* Deferred.make(); + const resumeWriter = yield* Deferred.make(); + const commandAttempted = yield* Deferred.make(); + const writerStarted = yield* Deferred.make(); + const commandStarted = yield* Deferred.make(); + const insertWriterTrace = makeSqlTrace(); + const insertCommandTrace = makeSqlTrace(); + + const insertWriterFiber = yield* Effect.forkScoped( + Effect.gen(function* () { + const sql = yield* Database; + const started = yield* connectionStamp(sql); + yield* Deferred.succeed(writerStarted, started); + + const observed = observeSql(sql, insertWriterTrace, { + pauseAfter: { + phase: "organization-administrator-insert", + ready: writerPaused, + resume: resumeWriter, }, - visibleComposition: afterInsert, - commandObservation: command.value.observation, - cleanup, - }; - }); + }); - const organizationLifecycle = yield* Effect.gen(function* () { - const approvalGrant = yield* createReceiptApprovalGrant({ - approvalGrantId: ReceiptApprovalGrantId.make( - ids.directAuthorities.phantomOrganizationApproval, + const created = yield* createOrganizationGlobalAdministratorGrant({ + grantId: OrganizationGlobalAdministratorGrantId.make( + ids.directAuthorities.phantomOrganizationAdministrator, ), personId: personId(ids.persons.phantomOrganization), - scope: AuthzRuleScopeSchema.cases.Global.make({}), startAt: activeStart, endAt: null, - }).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-approval-setup`), - ), - ); + }).pipe(Effect.provideService(Database, observed)); - const before = yield* approvalCompositionFacts( - ids.persons.phantomOrganization, - exactEnd, - ids.departments.alpha, - ).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-before`), - ), - ); + const completed = yield* connectionStamp(sql); - assert.deepEqual(before.applicableRuleIds, []); - assert.equal(before.directApprovalGrants[0]?.active, false); - assert.equal(before.mapped._tag, "Success"); - - if (Predicate.isTagged(before.mapped, "Success")) - assert.equal(before.mapped.actor.active, false); - - const writerPaused = yield* Deferred.make(); - const resumeWriter = yield* Deferred.make(); - const commandAttempted = yield* Deferred.make(); - const writerStarted = yield* Deferred.make(); - const commandStarted = yield* Deferred.make(); - const insertWriterTrace = makeSqlTrace(); - const insertCommandTrace = makeSqlTrace(); - - const insertWriterFiber = yield* Effect.forkScoped( - Effect.gen(function* () { - const sql = yield* Database; - const started = yield* connectionStamp(sql); - yield* Deferred.succeed(writerStarted, started); - - const observed = observeSql(sql, insertWriterTrace, { - pauseAfter: { - phase: "organization-administrator-insert", - ready: writerPaused, - resume: resumeWriter, - }, - }); - - const created = yield* createOrganizationGlobalAdministratorGrant({ - grantId: OrganizationGlobalAdministratorGrantId.make( - ids.directAuthorities.phantomOrganizationAdministrator, - ), - personId: personId(ids.persons.phantomOrganization), - startAt: activeStart, - endAt: null, - }).pipe(Effect.provideService(Database, observed)); - - const completed = yield* connectionStamp(sql); - - return { started, completed, created }; - }).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-insert-writer`), - ), - ), - ); + return { started, completed, created }; + }).pipe( + Effect.provide( + makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-insert-writer`), + ), + ), + ); - yield* Deferred.await(writerPaused); + yield* Deferred.await(writerPaused); - const insertCommandFiber = yield* Effect.forkScoped( - Effect.gen(function* () { - const sql = yield* Database; - const started = yield* connectionStamp(sql); - yield* Deferred.succeed(commandStarted, started); + const insertCommandFiber = yield* Effect.forkScoped( + Effect.gen(function* () { + const sql = yield* Database; + const started = yield* connectionStamp(sql); + yield* Deferred.succeed(commandStarted, started); - const observed = observeSql(sql, insertCommandTrace, { - signalBefore: { - phase: "person-authorization-lock", - deferred: commandAttempted, - }, - }); + const observed = observeSql(sql, insertCommandTrace, { + signalBefore: { + phase: "person-authorization-lock", + deferred: commandAttempted, + }, + }); - const value = yield* executeReceiptCommand( - ReceiptCommandRequestSchema.cases.RejectReceipt.make({ - commandId: ids.commands.phantomOrganizationInsert, - receiptId: ReceiptId.make(ids.receipts.phantomOrganizationInsert), - expectedRevision: 0, - }), - principal(ids.persons.phantomOrganization, exactEnd), - ).pipe(Effect.provideService(Database, observed)); + const value = yield* executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.RejectReceipt.make({ + commandId: ids.commands.phantomOrganizationInsert, + receiptId: ReceiptId.make(ids.receipts.phantomOrganizationInsert), + expectedRevision: 0, + }), + principal: principal(ids.persons.phantomOrganization, exactEnd), + }).pipe(Effect.provideService(Database, observed)); - const completed = yield* connectionStamp(sql); + const completed = yield* connectionStamp(sql); - return { started, completed, value }; - }).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-insert-command`), - ), - ), - ); + return { started, completed, value }; + }).pipe( + Effect.provide( + makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-insert-command`), + ), + ), + ); - yield* Deferred.await(commandAttempted); - const insertWriterStamp = yield* Deferred.await(writerStarted); - const insertCommandStamp = yield* Deferred.await(commandStarted); - assert.notEqual(insertWriterStamp.pid, insertCommandStamp.pid); + yield* Deferred.await(commandAttempted); + const insertWriterStamp = yield* Deferred.await(writerStarted); + const insertCommandStamp = yield* Deferred.await(commandStarted); + assert.notEqual(insertWriterStamp.pid, insertCommandStamp.pid); - const insertWriterAtBlock = { - attempted: [...insertWriterTrace.attempted], - completed: [...insertWriterTrace.completed], - }; + const insertWriterAtBlock = { + attempted: [...insertWriterTrace.attempted], + completed: [...insertWriterTrace.completed], + }; - const insertCommandAtBlock = { - attempted: [...insertCommandTrace.attempted], - completed: [...insertCommandTrace.completed], - }; + const insertCommandAtBlock = { + attempted: [...insertCommandTrace.attempted], + completed: [...insertCommandTrace.completed], + }; - const insertLocks = yield* observeBlockingAndLocks({ - databaseUrl, - blockedPid: insertCommandStamp.pid, - blockerPid: insertWriterStamp.pid, - personId: ids.persons.phantomOrganization, - commandId: ids.commands.phantomOrganizationInsert, - applicationName: `${proofApplicationPrefix}-phantom-org-insert-observer`, - }); + const insertLocks = yield* observeBlockingAndLocks({ + databaseUrl, + blockedPid: insertCommandStamp.pid, + blockerPid: insertWriterStamp.pid, + personId: ids.persons.phantomOrganization, + commandId: ids.commands.phantomOrganizationInsert, + applicationName: `${proofApplicationPrefix}-phantom-org-insert-observer`, + }); - assert.equal(insertLocks.blocked.waitEventType, "Lock"); - assert.ok(insertLocks.blocked.blockingPids.includes(insertWriterStamp.pid)); - assert.ok( - hasAdvisoryLock( - insertLocks.advisoryLocks, - insertWriterStamp.pid, - "person-authorization", - "ExclusiveLock", - true, - ), - ); - assert.ok( - hasAdvisoryLock( - insertLocks.advisoryLocks, - insertCommandStamp.pid, - "person-authorization", - "ExclusiveLock", - false, - ), - ); - assert.ok( - hasRelationLock( - insertLocks.relationLocks, - insertWriterStamp.pid, - "public.organization_global_administrator_grants", - "RowExclusiveLock", - ), - ); - yield* Deferred.succeed(resumeWriter, undefined); - const insertWriter = yield* Fiber.join(insertWriterFiber); - const insertCommand = yield* Fiber.join(insertCommandFiber); - assertSubsequence(insertWriterTrace.completed, [ - "person-authorization-lock", - "organization-administrator-insert", - ]); - assertSubsequence(insertCommandTrace.completed, [ - "person-authorization-lock", - "organization-authority-projection", - "direct-receipt-authority-projection", - "authz-shared-lock", - "authz-tag-assignment-projection", - "authz-rule-projection", - "durable-audit-insert", - ]); - - const afterInsert = yield* approvalCompositionFacts( - ids.persons.phantomOrganization, - exactEnd, - ids.departments.alpha, - ).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-after-insert`), - ), - ); + assert.equal(insertLocks.blocked.waitEventType, "Lock"); + assert.ok(insertLocks.blocked.blockingPids.includes(insertWriterStamp.pid)); + assert.ok( + hasAdvisoryLock( + insertLocks.advisoryLocks, + insertWriterStamp.pid, + "person-authorization", + "ExclusiveLock", + true, + ), + ); + assert.ok( + hasAdvisoryLock( + insertLocks.advisoryLocks, + insertCommandStamp.pid, + "person-authorization", + "ExclusiveLock", + false, + ), + ); + assert.ok( + hasRelationLock( + insertLocks.relationLocks, + insertWriterStamp.pid, + "public.organization_global_administrator_grants", + "RowExclusiveLock", + ), + ); + yield* Deferred.succeed(resumeWriter, undefined); + const insertWriter = yield* Fiber.join(insertWriterFiber); + const insertCommand = yield* Fiber.join(insertCommandFiber); + assertSubsequence(insertWriterTrace.completed, [ + "person-authorization-lock", + "organization-administrator-insert", + ]); + assertSubsequence(insertCommandTrace.completed, [ + "person-authorization-lock", + "organization-authority-projection", + "direct-receipt-authority-projection", + "authz-shared-lock", + "authz-tag-assignment-projection", + "authz-rule-projection", + "durable-audit-insert", + ]); - assert.equal(afterInsert.directApprovalGrants[0]?.active, true); - assert.equal(afterInsert.mapped._tag, "Success"); - - if (Predicate.isTagged(afterInsert.mapped, "Success")) - assert.equal(afterInsert.mapped.actor.active, true); - - const commandReady = yield* Deferred.make(); - const resumeCommand = yield* Deferred.make(); - const endWriterAttempted = yield* Deferred.make(); - const endCommandStarted = yield* Deferred.make(); - const endWriterStarted = yield* Deferred.make(); - const endCommandTrace = makeSqlTrace(); - const endWriterTrace = makeSqlTrace(); - - const endCommandFiber = yield* Effect.forkScoped( - Effect.gen(function* () { - const sql = yield* Database; - const started = yield* connectionStamp(sql); - yield* Deferred.succeed(endCommandStarted, started); - - const observed = observeSql(sql, endCommandTrace, { - pauseAfter: { - phase: "durable-audit-insert", - ready: commandReady, - resume: resumeCommand, - }, - }); + const afterInsert = yield* approvalCompositionFacts( + ids.persons.phantomOrganization, + exactEnd, + ids.departments.alpha, + ).pipe( + Effect.provide( + makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-after-insert`), + ), + ); - const value = yield* executeReceiptCommand( - ReceiptCommandRequestSchema.cases.RejectReceipt.make({ - commandId: ids.commands.phantomOrganizationRemove, - receiptId: ReceiptId.make(ids.receipts.phantomOrganizationRemove), - expectedRevision: 0, - }), - principal(ids.persons.phantomOrganization, exactEnd), - ).pipe(Effect.provideService(Database, observed)); + assert.equal(afterInsert.directApprovalGrants[0]?.active, true); + assert.equal(afterInsert.mapped._tag, "Success"); - const completed = yield* connectionStamp(sql); + if (Predicate.isTagged(afterInsert.mapped, "Success")) + assert.equal(afterInsert.mapped.actor.active, true); - return { started, completed, value }; - }).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-end-command`), - ), - ), - ); + const commandReady = yield* Deferred.make(); + const resumeCommand = yield* Deferred.make(); + const endWriterAttempted = yield* Deferred.make(); + const endCommandStarted = yield* Deferred.make(); + const endWriterStarted = yield* Deferred.make(); + const endCommandTrace = makeSqlTrace(); + const endWriterTrace = makeSqlTrace(); - yield* Deferred.await(commandReady); + const endCommandFiber = yield* Effect.forkScoped( + Effect.gen(function* () { + const sql = yield* Database; + const started = yield* connectionStamp(sql); + yield* Deferred.succeed(endCommandStarted, started); + + const observed = observeSql(sql, endCommandTrace, { + pauseAfter: { + phase: "durable-audit-insert", + ready: commandReady, + resume: resumeCommand, + }, + }); - const endWriterFiber = yield* Effect.forkScoped( - Effect.gen(function* () { - const sql = yield* Database; - const started = yield* connectionStamp(sql); - yield* Deferred.succeed(endWriterStarted, started); + const value = yield* executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.RejectReceipt.make({ + commandId: ids.commands.phantomOrganizationRemove, + receiptId: ReceiptId.make(ids.receipts.phantomOrganizationRemove), + expectedRevision: 0, + }), + principal: principal(ids.persons.phantomOrganization, exactEnd), + }).pipe(Effect.provideService(Database, observed)); - const observed = observeSql(sql, endWriterTrace, { - signalBefore: { - phase: "person-authorization-lock", - deferred: endWriterAttempted, - }, - }); + const completed = yield* connectionStamp(sql); - const ended = yield* endOrganizationGlobalAdministratorGrant({ - grantId: OrganizationGlobalAdministratorGrantId.make( - ids.directAuthorities.phantomOrganizationAdministrator, - ), - endAt: exactEnd, - expectedRevision: 0, - }).pipe(Effect.provideService(Database, observed)); + return { started, completed, value }; + }).pipe( + Effect.provide( + makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-end-command`), + ), + ), + ); - const completed = yield* connectionStamp(sql); + yield* Deferred.await(commandReady); - return { started, completed, ended }; - }).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-end-writer`), - ), + const endWriterFiber = yield* Effect.forkScoped( + Effect.gen(function* () { + const sql = yield* Database; + const started = yield* connectionStamp(sql); + yield* Deferred.succeed(endWriterStarted, started); + + const observed = observeSql(sql, endWriterTrace, { + signalBefore: { + phase: "person-authorization-lock", + deferred: endWriterAttempted, + }, + }); + + const ended = yield* endOrganizationGlobalAdministratorGrant({ + grantId: OrganizationGlobalAdministratorGrantId.make( + ids.directAuthorities.phantomOrganizationAdministrator, ), - ); + endAt: exactEnd, + expectedRevision: 0, + }).pipe(Effect.provideService(Database, observed)); - yield* Deferred.await(endWriterAttempted); - const endCommandStamp = yield* Deferred.await(endCommandStarted); - const endWriterStamp = yield* Deferred.await(endWriterStarted); - assert.notEqual(endCommandStamp.pid, endWriterStamp.pid); + const completed = yield* connectionStamp(sql); - const endCommandAtBlock = { - attempted: [...endCommandTrace.attempted], - completed: [...endCommandTrace.completed], - }; + return { started, completed, ended }; + }).pipe( + Effect.provide( + makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-end-writer`), + ), + ), + ); - const endWriterAtBlock = { - attempted: [...endWriterTrace.attempted], - completed: [...endWriterTrace.completed], - }; + yield* Deferred.await(endWriterAttempted); + const endCommandStamp = yield* Deferred.await(endCommandStarted); + const endWriterStamp = yield* Deferred.await(endWriterStarted); + assert.notEqual(endCommandStamp.pid, endWriterStamp.pid); - const endLocks = yield* observeBlockingAndLocks({ - databaseUrl, - blockedPid: endWriterStamp.pid, - blockerPid: endCommandStamp.pid, - personId: ids.persons.phantomOrganization, - commandId: ids.commands.phantomOrganizationRemove, - applicationName: `${proofApplicationPrefix}-phantom-org-end-observer`, - }); + const endCommandAtBlock = { + attempted: [...endCommandTrace.attempted], + completed: [...endCommandTrace.completed], + }; - assert.equal(endLocks.blocked.waitEventType, "Lock"); - assert.ok(endLocks.blocked.blockingPids.includes(endCommandStamp.pid)); - assert.ok( - hasAdvisoryLock( - endLocks.advisoryLocks, - endCommandStamp.pid, - "person-authorization", - "ExclusiveLock", - true, - ), - ); - assert.ok( - hasAdvisoryLock( - endLocks.advisoryLocks, - endWriterStamp.pid, - "person-authorization", - "ExclusiveLock", - false, - ), - ); - assert.equal(endWriterAtBlock.attempted.at(-1), "person-authorization-lock"); - yield* Deferred.succeed(resumeCommand, undefined); - const endCommand = yield* Fiber.join(endCommandFiber); - const endWriter = yield* Fiber.join(endWriterFiber); - assert.equal(endWriter.ended.endAt, exactEnd); - assert.equal(endWriter.ended.revision, 1); - assertSubsequence(endWriterTrace.completed, [ - "person-authorization-lock", - "organization-administrator-end", - ]); - - const afterEnd = yield* Effect.gen(function* () { - const sql = yield* Database; + const endWriterAtBlock = { + attempted: [...endWriterTrace.attempted], + completed: [...endWriterTrace.completed], + }; - const beforeExact = yield* resolveOrganizationPersonAuthorityForRead( - personId(ids.persons.phantomOrganization), - justBeforeExactEnd, - ); + const endLocks = yield* observeBlockingAndLocks({ + databaseUrl, + blockedPid: endWriterStamp.pid, + blockerPid: endCommandStamp.pid, + personId: ids.persons.phantomOrganization, + commandId: ids.commands.phantomOrganizationRemove, + applicationName: `${proofApplicationPrefix}-phantom-org-end-observer`, + }); - const atExact = yield* resolveOrganizationPersonAuthorityForRead( - personId(ids.persons.phantomOrganization), - exactEnd, - ); + assert.equal(endLocks.blocked.waitEventType, "Lock"); + assert.ok(endLocks.blocked.blockingPids.includes(endCommandStamp.pid)); + assert.ok( + hasAdvisoryLock( + endLocks.advisoryLocks, + endCommandStamp.pid, + "person-authorization", + "ExclusiveLock", + true, + ), + ); + assert.ok( + hasAdvisoryLock( + endLocks.advisoryLocks, + endWriterStamp.pid, + "person-authorization", + "ExclusiveLock", + false, + ), + ); + assert.equal(endWriterAtBlock.attempted.at(-1), "person-authorization-lock"); + yield* Deferred.succeed(resumeCommand, undefined); + const endCommand = yield* Fiber.join(endCommandFiber); + const endWriter = yield* Fiber.join(endWriterFiber); + assert.equal(endWriter.ended.endAt, exactEnd); + assert.equal(endWriter.ended.revision, 1); + assertSubsequence(endWriterTrace.completed, [ + "person-authorization-lock", + "organization-administrator-end", + ]); - const insertDurable = yield* readDurableCommandFacts( - sql, - ids.commands.phantomOrganizationInsert, - ); + const afterEnd = yield* Effect.gen(function* () { + const sql = yield* Database; - const endDurable = yield* readDurableCommandFacts( - sql, - ids.commands.phantomOrganizationRemove, - ); + const beforeExact = yield* resolveOrganizationPersonAuthorityForRead( + personId(ids.persons.phantomOrganization), + justBeforeExactEnd, + ); - const fresh = yield* Effect.result( - executeReceiptCommand( - ReceiptCommandRequestSchema.cases.RejectReceipt.make({ - commandId: ids.commands.phantomOrganizationFresh, - receiptId: ReceiptId.make(ids.receipts.phantomOrganizationFresh), - expectedRevision: 0, - }), - principal(ids.persons.phantomOrganization, exactEnd), - ), - ); + const atExact = yield* resolveOrganizationPersonAuthorityForRead( + personId(ids.persons.phantomOrganization), + exactEnd, + ); - const freshDurable = yield* readDurableCommandFacts( - sql, - ids.commands.phantomOrganizationFresh, - ); + const insertDurable = yield* readDurableCommandFacts( + sql, + ids.commands.phantomOrganizationInsert, + ); - const removedAdministrator = yield* removeOrganizationGlobalAdministratorGrant({ - grantId: OrganizationGlobalAdministratorGrantId.make( - ids.directAuthorities.phantomOrganizationAdministrator, - ), - expectedRevision: 1, - }); + const endDurable = yield* readDurableCommandFacts( + sql, + ids.commands.phantomOrganizationRemove, + ); - const removedApproval = yield* removeReceiptApprovalGrant({ - approvalGrantId: ReceiptApprovalGrantId.make( - ids.directAuthorities.phantomOrganizationApproval, - ), + const fresh = yield* Effect.result( + executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.RejectReceipt.make({ + commandId: ids.commands.phantomOrganizationFresh, + receiptId: ReceiptId.make(ids.receipts.phantomOrganizationFresh), expectedRevision: 0, - }); + }), + principal: principal(ids.persons.phantomOrganization, exactEnd), + }), + ); + + const freshDurable = yield* readDurableCommandFacts( + sql, + ids.commands.phantomOrganizationFresh, + ); + + const removedAdministrator = yield* removeOrganizationGlobalAdministratorGrant({ + grantId: OrganizationGlobalAdministratorGrantId.make( + ids.directAuthorities.phantomOrganizationAdministrator, + ), + expectedRevision: 1, + }); + + const removedApproval = yield* removeReceiptApprovalGrant({ + approvalGrantId: ReceiptApprovalGrantId.make( + ids.directAuthorities.phantomOrganizationApproval, + ), + expectedRevision: 0, + }); + + const [remaining] = yield* sql<{ + readonly administrators: number; + readonly approvals: number; + }>` + SELECT + ( + SELECT count(*)::integer + FROM public.organization_global_administrator_grants + WHERE grant_id = ${ids.directAuthorities.phantomOrganizationAdministrator} + ) AS administrators, + ( + SELECT count(*)::integer + FROM public.economy_receipt_approval_grants + WHERE approval_grant_id = ${ids.directAuthorities.phantomOrganizationApproval} + ) AS approvals + `; + + assert(remaining); + + return { + beforeExactGlobalAdministrator: beforeExact.globalAdministrator, + exactGlobalAdministrator: atExact.globalAdministrator, + insertDurable, + endDurable, + freshFailureTag: resultFailureTag(fresh), + freshDurable, + removedAdministrator: { + grantId: removedAdministrator.grantId, + endAt: removedAdministrator.endAt, + revision: removedAdministrator.revision, + }, + removedApproval: { + approvalGrantId: removedApproval.approvalGrantId, + revision: removedApproval.revision, + }, + remaining, + }; + }).pipe( + Effect.provide( + makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-after-end`), + ), + ); + + assert.equal(afterEnd.beforeExactGlobalAdministrator, "Active"); + assert.equal(afterEnd.exactGlobalAdministrator, "Inactive"); + assert.deepEqual(afterEnd.insertDurable, { + commandReceiptRows: 1, + auditRows: 1, + outboxRows: 2, + outboxCommandRows: 1, + }); + assert.deepEqual(afterEnd.endDurable, { + commandReceiptRows: 1, + auditRows: 1, + outboxRows: 2, + outboxCommandRows: 1, + }); + assert.equal(afterEnd.freshFailureTag, "InactiveActor"); + assert.deepEqual(afterEnd.freshDurable, { + commandReceiptRows: 0, + auditRows: 0, + outboxRows: 0, + outboxCommandRows: 0, + }); + assert.deepEqual(afterEnd.remaining, { administrators: 0, approvals: 0 }); - const [remaining] = yield* sql<{ - readonly administrators: number; - readonly approvals: number; - }>` - SELECT - ( - SELECT count(*)::integer - FROM public.organization_global_administrator_grants - WHERE grant_id = ${ids.directAuthorities.phantomOrganizationAdministrator} - ) AS administrators, - ( - SELECT count(*)::integer - FROM public.economy_receipt_approval_grants - WHERE approval_grant_id = ${ids.directAuthorities.phantomOrganizationApproval} - ) AS approvals - `; - - assert(remaining); - - return { - beforeExactGlobalAdministrator: beforeExact.globalAdministrator, - exactGlobalAdministrator: atExact.globalAdministrator, - insertDurable, - endDurable, - freshFailureTag: resultFailureTag(fresh), - freshDurable, - removedAdministrator: { - grantId: removedAdministrator.grantId, - endAt: removedAdministrator.endAt, - revision: removedAdministrator.revision, - }, - removedApproval: { - approvalGrantId: removedApproval.approvalGrantId, - revision: removedApproval.revision, - }, - remaining, - }; - }).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-after-end`), - ), - ); + return { + setupApprovalGrant: { + approvalGrantId: approvalGrant.approvalGrantId, + personId: approvalGrant.personId, + scope: approvalGrant.scope, + startAt: approvalGrant.startAt, + endAt: approvalGrant.endAt, + revision: approvalGrant.revision, + }, + before, + insertFirst: { + order: "WriterInsertFirst" as const, + participants: { + writer: insertWriter.started, + writerCompleted: insertWriter.completed, + command: insertCommand.started, + commandCompleted: insertCommand.completed, + independentBackendPids: insertWriter.started.pid !== insertCommand.started.pid, + }, + blocked: insertLocks.blocked, + relationLocks: insertLocks.relationLocks, + advisoryLocks: insertLocks.advisoryLocks, + sqlOrderAtBlock: { + writer: insertWriterAtBlock, + command: insertCommandAtBlock, + }, + sqlOrderAfterCommit: { + writer: insertWriterTrace, + command: insertCommandTrace, + }, + createdAdministrator: { + grantId: insertWriter.created.grantId, + personId: insertWriter.created.personId, + startAt: insertWriter.created.startAt, + endAt: insertWriter.created.endAt, + revision: insertWriter.created.revision, + }, + visibleComposition: afterInsert, + commandObservation: insertCommand.value.observation, + }, + commandFirstEnd: { + order: "CommandFirst" as const, + instant: exactEnd, + participants: { + command: endCommand.started, + commandCompleted: endCommand.completed, + writer: endWriter.started, + writerCompleted: endWriter.completed, + independentBackendPids: endCommand.started.pid !== endWriter.started.pid, + }, + blocked: endLocks.blocked, + relationLocks: endLocks.relationLocks, + advisoryLocks: endLocks.advisoryLocks, + sqlOrderAtBlock: { + command: endCommandAtBlock, + writer: endWriterAtBlock, + }, + sqlOrderAfterCommit: { + command: endCommandTrace, + writer: endWriterTrace, + }, + commandObservation: endCommand.value.observation, + endedAdministrator: { + grantId: endWriter.ended.grantId, + endAt: endWriter.ended.endAt, + revision: endWriter.ended.revision, + }, + }, + afterEndAndRemoval: afterEnd, + }; + }); - assert.equal(afterEnd.beforeExactGlobalAdministrator, "Active"); - assert.equal(afterEnd.exactGlobalAdministrator, "Inactive"); - assert.deepEqual(afterEnd.insertDurable, { - commandReceiptRows: 1, - auditRows: 1, - outboxRows: 2, - outboxCommandRows: 1, - }); - assert.deepEqual(afterEnd.endDurable, { - commandReceiptRows: 1, - auditRows: 1, - outboxRows: 2, - outboxCommandRows: 1, - }); - assert.equal(afterEnd.freshFailureTag, "InactiveActor"); - assert.deepEqual(afterEnd.freshDurable, { - commandReceiptRows: 0, - auditRows: 0, - outboxRows: 0, - outboxCommandRows: 0, - }); - assert.deepEqual(afterEnd.remaining, { administrators: 0, approvals: 0 }); +const proveDirectAuthorityPhantomProtocol = (databaseUrl: Redacted.Redacted) => + Effect.scoped( + Effect.gen(function* () { + const economyInsertFirst = yield* provePhantomEconomyInsertFirst(databaseUrl); - return { - setupApprovalGrant: { - approvalGrantId: approvalGrant.approvalGrantId, - personId: approvalGrant.personId, - scope: approvalGrant.scope, - startAt: approvalGrant.startAt, - endAt: approvalGrant.endAt, - revision: approvalGrant.revision, - }, - before, - insertFirst: { - order: "WriterInsertFirst" as const, - participants: { - writer: insertWriter.started, - writerCompleted: insertWriter.completed, - command: insertCommand.started, - commandCompleted: insertCommand.completed, - independentBackendPids: insertWriter.started.pid !== insertCommand.started.pid, - }, - blocked: insertLocks.blocked, - relationLocks: insertLocks.relationLocks, - advisoryLocks: insertLocks.advisoryLocks, - sqlOrderAtBlock: { - writer: insertWriterAtBlock, - command: insertCommandAtBlock, - }, - sqlOrderAfterCommit: { - writer: insertWriterTrace, - command: insertCommandTrace, - }, - createdAdministrator: { - grantId: insertWriter.created.grantId, - personId: insertWriter.created.personId, - startAt: insertWriter.created.startAt, - endAt: insertWriter.created.endAt, - revision: insertWriter.created.revision, - }, - visibleComposition: afterInsert, - commandObservation: insertCommand.value.observation, - }, - commandFirstEnd: { - order: "CommandFirst" as const, - instant: exactEnd, - participants: { - command: endCommand.started, - commandCompleted: endCommand.completed, - writer: endWriter.started, - writerCompleted: endWriter.completed, - independentBackendPids: endCommand.started.pid !== endWriter.started.pid, - }, - blocked: endLocks.blocked, - relationLocks: endLocks.relationLocks, - advisoryLocks: endLocks.advisoryLocks, - sqlOrderAtBlock: { - command: endCommandAtBlock, - writer: endWriterAtBlock, - }, - sqlOrderAfterCommit: { - command: endCommandTrace, - writer: endWriterTrace, - }, - commandObservation: endCommand.value.observation, - endedAdministrator: { - grantId: endWriter.ended.grantId, - endAt: endWriter.ended.endAt, - revision: endWriter.ended.revision, - }, - }, - afterEndAndRemoval: afterEnd, - }; - }); + const organizationLifecycle = yield* provePhantomOrganizationLifecycle(databaseUrl); return { economyInsertFirst, organizationLifecycle }; }), @@ -3371,14 +3379,14 @@ const proveDirectAuthorityRowLock = (databaseUrl: Redacted.Redacted) => }, }); - const value = yield* executeReceiptCommand( - ReceiptCommandRequestSchema.cases.RejectReceipt.make({ + const value = yield* executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.RejectReceipt.make({ commandId: ids.commands.approveLock, receiptId: ReceiptId.make(ids.receipts.approveLock), expectedRevision: 0, }), - principal(ids.persons.ruleApprove, justBeforeExactEnd), - ).pipe(Effect.provideService(Database, observed)); + principal: principal(ids.persons.ruleApprove, justBeforeExactEnd), + }).pipe(Effect.provideService(Database, observed)); const completed = yield* connectionStamp(sql); @@ -3595,11 +3603,11 @@ const proveCommandFirstRuleRemoval = (databaseUrl: Redacted.Redacted) => }, }); - const value = yield* executeReceiptCommand( - submitCommand(ids.commands.ruleSubmit, ids.departments.alpha, "rule-submit"), - principal(ids.persons.ruleSubmit, exactEnd), - allocation(generatedReceiptIds.ruleSubmit, generatedVisualIds.ruleSubmit), - ).pipe(Effect.provideService(Database, observed)); + const value = yield* executeReceiptCommand({ + command: submitCommand(ids.commands.ruleSubmit, ids.departments.alpha, "rule-submit"), + principal: principal(ids.persons.ruleSubmit, exactEnd), + allocation: allocation(generatedReceiptIds.ruleSubmit, generatedVisualIds.ruleSubmit), + }).pipe(Effect.provideService(Database, observed)); const completed = yield* connectionStamp(sql); @@ -3752,11 +3760,11 @@ const proveCommandFirstRuleRemoval = (databaseUrl: Redacted.Redacted) => assert(stored); const replay = yield* Effect.result( - executeReceiptCommand( - submitCommand(ids.commands.ruleSubmit, ids.departments.alpha, "rule-submit"), - principal(ids.persons.ruleSubmit, exactEnd), - allocation(generatedReceiptIds.ruleSubmit, generatedVisualIds.ruleSubmit), - ), + executeReceiptCommand({ + command: submitCommand(ids.commands.ruleSubmit, ids.departments.alpha, "rule-submit"), + principal: principal(ids.persons.ruleSubmit, exactEnd), + allocation: allocation(generatedReceiptIds.ruleSubmit, generatedVisualIds.ruleSubmit), + }), ); const afterReplay = yield* readDurableCommandFacts(sql, ids.commands.ruleSubmit); @@ -3768,11 +3776,14 @@ const proveCommandFirstRuleRemoval = (databaseUrl: Redacted.Redacted) => ); const fresh = yield* Effect.result( - executeReceiptCommand( - freshCommand, - principal(ids.persons.ruleSubmit, exactEnd), - allocation(generatedReceiptIds.ruleSubmitFresh, generatedVisualIds.ruleSubmitFresh), - ), + executeReceiptCommand({ + command: freshCommand, + principal: principal(ids.persons.ruleSubmit, exactEnd), + allocation: allocation( + generatedReceiptIds.ruleSubmitFresh, + generatedVisualIds.ruleSubmitFresh, + ), + }), ); const freshDurable = yield* readDurableCommandFacts(sql, ids.commands.ruleSubmitFresh); @@ -3838,476 +3849,471 @@ const proveCommandFirstRuleRemoval = (databaseUrl: Redacted.Redacted) => }), ); -const proveRuleExpiryRaces = (databaseUrl: Redacted.Redacted) => - Effect.scoped( - Effect.gen(function* () { - const commandFirst = yield* Effect.gen(function* () { - const commandReady = yield* Deferred.make(); - const resumeCommand = yield* Deferred.make(); - const writerAttempted = yield* Deferred.make(); - const commandStarted = yield* Deferred.make(); - const writerStarted = yield* Deferred.make(); - const commandTrace = makeSqlTrace(); - const writerTrace = makeSqlTrace(); - - const before = yield* submissionCompositionFacts( - ids.persons.expiryCommandFirst, - exactEnd, - ids.departments.alpha, - ).pipe( - Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-cf-before`)), - ); +const proveRuleExpiryCommandFirst = (databaseUrl: Redacted.Redacted) => + Effect.gen(function* () { + const commandReady = yield* Deferred.make(); + const resumeCommand = yield* Deferred.make(); + const writerAttempted = yield* Deferred.make(); + const commandStarted = yield* Deferred.make(); + const writerStarted = yield* Deferred.make(); + const commandTrace = makeSqlTrace(); + const writerTrace = makeSqlTrace(); + + const before = yield* submissionCompositionFacts( + ids.persons.expiryCommandFirst, + exactEnd, + ids.departments.alpha, + ).pipe( + Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-cf-before`)), + ); - assert.deepEqual(before.applicableRuleIds, [ids.rules.expiryCommandFirst]); - assert.deepEqual(before.contributingRuleIds, [ids.rules.expiryCommandFirst]); - assert.equal(before.directPaymentAuthorities.length, 0); - assert.equal(before.mapped._tag, "Success"); - - const commandFiber = yield* Effect.forkScoped( - Effect.gen(function* () { - const sql = yield* Database; - const started = yield* connectionStamp(sql); - yield* Deferred.succeed(commandStarted, started); - - const observed = observeSql(sql, commandTrace, { - pauseAfter: { - phase: "durable-audit-insert", - ready: commandReady, - resume: resumeCommand, - }, - }); - - const value = yield* executeReceiptCommand( - submitCommand( - ids.commands.expiryCommandFirst, - ids.departments.alpha, - "expiry-command-first", - ), - principal(ids.persons.expiryCommandFirst, exactEnd), - allocation( - generatedReceiptIds.expiryCommandFirst, - generatedVisualIds.expiryCommandFirst, - ), - ).pipe(Effect.provideService(Database, observed)); - - const completed = yield* connectionStamp(sql); - - return { started, completed, value }; - }).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-cf-command`), - ), + assert.deepEqual(before.applicableRuleIds, [ids.rules.expiryCommandFirst]); + assert.deepEqual(before.contributingRuleIds, [ids.rules.expiryCommandFirst]); + assert.equal(before.directPaymentAuthorities.length, 0); + assert.equal(before.mapped._tag, "Success"); + + const commandFiber = yield* Effect.forkScoped( + Effect.gen(function* () { + const sql = yield* Database; + const started = yield* connectionStamp(sql); + yield* Deferred.succeed(commandStarted, started); + + const observed = observeSql(sql, commandTrace, { + pauseAfter: { + phase: "durable-audit-insert", + ready: commandReady, + resume: resumeCommand, + }, + }); + + const value = yield* executeReceiptCommand({ + command: submitCommand( + ids.commands.expiryCommandFirst, + ids.departments.alpha, + "expiry-command-first", ), - ); + principal: principal(ids.persons.expiryCommandFirst, exactEnd), + allocation: allocation( + generatedReceiptIds.expiryCommandFirst, + generatedVisualIds.expiryCommandFirst, + ), + }).pipe(Effect.provideService(Database, observed)); - yield* Deferred.await(commandReady); + const completed = yield* connectionStamp(sql); - const writerFiber = yield* Effect.forkScoped( - Effect.gen(function* () { - const sql = yield* Database; - const started = yield* connectionStamp(sql); - yield* Deferred.succeed(writerStarted, started); + return { started, completed, value }; + }).pipe( + Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-cf-command`)), + ), + ); - const observed = observeSql(sql, writerTrace, { - signalBefore: { phase: "authz-exclusive-lock", deferred: writerAttempted }, - }); + yield* Deferred.await(commandReady); - const ended = yield* endAuthzRule({ - ruleId: AuthzRuleId.make(ids.rules.expiryCommandFirst), - endAt: exactEnd, - expectedRevision: 0, - }).pipe(Effect.provideService(Database, observed)); + const writerFiber = yield* Effect.forkScoped( + Effect.gen(function* () { + const sql = yield* Database; + const started = yield* connectionStamp(sql); + yield* Deferred.succeed(writerStarted, started); - const completed = yield* connectionStamp(sql); + const observed = observeSql(sql, writerTrace, { + signalBefore: { phase: "authz-exclusive-lock", deferred: writerAttempted }, + }); - return { started, completed, ended }; - }).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-cf-writer`), - ), - ), - ); + const ended = yield* endAuthzRule({ + ruleId: AuthzRuleId.make(ids.rules.expiryCommandFirst), + endAt: exactEnd, + expectedRevision: 0, + }).pipe(Effect.provideService(Database, observed)); - yield* Deferred.await(writerAttempted); - const commandStamp = yield* Deferred.await(commandStarted); - const writerStamp = yield* Deferred.await(writerStarted); - assert.notEqual(commandStamp.pid, writerStamp.pid); + const completed = yield* connectionStamp(sql); - const commandAtBlock = { - attempted: [...commandTrace.attempted], - completed: [...commandTrace.completed], - }; + return { started, completed, ended }; + }).pipe( + Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-cf-writer`)), + ), + ); - const writerAtBlock = { - attempted: [...writerTrace.attempted], - completed: [...writerTrace.completed], - }; + yield* Deferred.await(writerAttempted); + const commandStamp = yield* Deferred.await(commandStarted); + const writerStamp = yield* Deferred.await(writerStarted); + assert.notEqual(commandStamp.pid, writerStamp.pid); - const locks = yield* observeBlockingAndLocks({ - databaseUrl, - blockedPid: writerStamp.pid, - blockerPid: commandStamp.pid, - personId: ids.persons.expiryCommandFirst, - commandId: ids.commands.expiryCommandFirst, - applicationName: `${proofApplicationPrefix}-expiry-cf-observer`, - }); + const commandAtBlock = { + attempted: [...commandTrace.attempted], + completed: [...commandTrace.completed], + }; + + const writerAtBlock = { + attempted: [...writerTrace.attempted], + completed: [...writerTrace.completed], + }; + + const locks = yield* observeBlockingAndLocks({ + databaseUrl, + blockedPid: writerStamp.pid, + blockerPid: commandStamp.pid, + personId: ids.persons.expiryCommandFirst, + commandId: ids.commands.expiryCommandFirst, + applicationName: `${proofApplicationPrefix}-expiry-cf-observer`, + }); + + assert.equal(locks.blocked.waitEventType, "Lock"); + assert.ok(locks.blocked.blockingPids.includes(commandStamp.pid)); + assert.ok( + hasAdvisoryLock( + locks.advisoryLocks, + commandStamp.pid, + "authorization-rules", + "ShareLock", + true, + ), + ); + assert.ok( + hasAdvisoryLock( + locks.advisoryLocks, + writerStamp.pid, + "authorization-rules", + "ExclusiveLock", + false, + ), + ); + yield* Deferred.succeed(resumeCommand, undefined); + const command = yield* Fiber.join(commandFiber); + const writer = yield* Fiber.join(writerFiber); + assert.equal(writer.ended.endAt, exactEnd); + assert.equal(writer.ended.revision, 1); + assertSubsequence(commandTrace.completed, [ + "person-authorization-lock", + "organization-authority-projection", + "direct-receipt-authority-projection", + "authz-shared-lock", + "authz-tag-assignment-projection", + "authz-rule-projection", + "durable-audit-insert", + ]); + assertSubsequence(writerTrace.completed, ["authz-exclusive-lock", "end-rule"]); + + const after = yield* Effect.gen(function* () { + const sql = yield* Database; + + const beforeExact = yield* loadApplicableAuthorizationRules( + PrincipalSchema.cases.Person.make({ + personId: personId(ids.persons.expiryCommandFirst), + }), + "submitReceipt", + justBeforeExactEnd, + proofReceiptContext(departmentId(ids.departments.alpha)), + ); + + const atExact = yield* loadApplicableAuthorizationRules( + PrincipalSchema.cases.Person.make({ + personId: personId(ids.persons.expiryCommandFirst), + }), + "submitReceipt", + exactEnd, + proofReceiptContext(departmentId(ids.departments.alpha)), + ); - assert.equal(locks.blocked.waitEventType, "Lock"); - assert.ok(locks.blocked.blockingPids.includes(commandStamp.pid)); - assert.ok( - hasAdvisoryLock( - locks.advisoryLocks, - commandStamp.pid, - "authorization-rules", - "ShareLock", - true, + const acceptedDurable = yield* readDurableCommandFacts(sql, ids.commands.expiryCommandFirst); + + const fresh = yield* Effect.result( + executeReceiptCommand({ + command: submitCommand( + ids.commands.expiryCommandFirstFresh, + ids.departments.alpha, + "expiry-command-first-fresh", ), - ); - assert.ok( - hasAdvisoryLock( - locks.advisoryLocks, - writerStamp.pid, - "authorization-rules", - "ExclusiveLock", - false, + principal: principal(ids.persons.expiryCommandFirst, exactEnd), + allocation: allocation( + generatedReceiptIds.expiryCommandFirstFresh, + generatedVisualIds.expiryCommandFirstFresh, ), - ); - yield* Deferred.succeed(resumeCommand, undefined); - const command = yield* Fiber.join(commandFiber); - const writer = yield* Fiber.join(writerFiber); - assert.equal(writer.ended.endAt, exactEnd); - assert.equal(writer.ended.revision, 1); - assertSubsequence(commandTrace.completed, [ - "person-authorization-lock", - "organization-authority-projection", - "direct-receipt-authority-projection", - "authz-shared-lock", - "authz-tag-assignment-projection", - "authz-rule-projection", - "durable-audit-insert", - ]); - assertSubsequence(writerTrace.completed, ["authz-exclusive-lock", "end-rule"]); - - const after = yield* Effect.gen(function* () { - const sql = yield* Database; + }), + ); - const beforeExact = yield* loadApplicableAuthorizationRules( - PrincipalSchema.cases.Person.make({ - personId: personId(ids.persons.expiryCommandFirst), - }), - "submitReceipt", - justBeforeExactEnd, - proofReceiptContext(departmentId(ids.departments.alpha)), - ); + const freshDurable = yield* readDurableCommandFacts( + sql, + ids.commands.expiryCommandFirstFresh, + ); - const atExact = yield* loadApplicableAuthorizationRules( - PrincipalSchema.cases.Person.make({ - personId: personId(ids.persons.expiryCommandFirst), - }), - "submitReceipt", - exactEnd, - proofReceiptContext(departmentId(ids.departments.alpha)), - ); + return { + beforeExactRuleIds: beforeExact.rules.map((rule) => rule.ruleId), + exactRuleIds: atExact.rules.map((rule) => rule.ruleId), + acceptedDurable, + freshFailureTag: resultFailureTag(fresh), + freshDurable, + }; + }).pipe( + Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-cf-after`)), + ); - const acceptedDurable = yield* readDurableCommandFacts( - sql, - ids.commands.expiryCommandFirst, - ); + assert.deepEqual(after.beforeExactRuleIds, [ids.rules.expiryCommandFirst]); + assert.deepEqual(after.exactRuleIds, []); + assert.deepEqual(after.acceptedDurable, { + commandReceiptRows: 1, + auditRows: 1, + outboxRows: 3, + outboxCommandRows: 1, + }); + assert.equal(after.freshFailureTag, "ReceiptAuthorityDenied"); + assert.deepEqual(after.freshDurable, { + commandReceiptRows: 0, + auditRows: 0, + outboxRows: 0, + outboxCommandRows: 0, + }); - const fresh = yield* Effect.result( - executeReceiptCommand( - submitCommand( - ids.commands.expiryCommandFirstFresh, - ids.departments.alpha, - "expiry-command-first-fresh", - ), - principal(ids.persons.expiryCommandFirst, exactEnd), - allocation( - generatedReceiptIds.expiryCommandFirstFresh, - generatedVisualIds.expiryCommandFirstFresh, - ), - ), - ); + return { + order: "CommandFirst" as const, + instant: exactEnd, + compositionBefore: before, + participants: { + command: command.started, + commandCompleted: command.completed, + writer: writer.started, + writerCompleted: writer.completed, + independentBackendPids: command.started.pid !== writer.started.pid, + }, + blocked: locks.blocked, + relationLocks: locks.relationLocks, + advisoryLocks: locks.advisoryLocks, + sqlOrderAtBlock: { command: commandAtBlock, writer: writerAtBlock }, + sqlOrderAfterCommit: { command: commandTrace, writer: writerTrace }, + commandObservation: command.value.observation, + endedRule: { + ruleId: writer.ended.ruleId, + endAt: writer.ended.endAt, + revision: writer.ended.revision, + }, + ...after, + }; + }); - const freshDurable = yield* readDurableCommandFacts( - sql, - ids.commands.expiryCommandFirstFresh, - ); +const proveRuleExpiryWriterFirst = (databaseUrl: Redacted.Redacted) => + Effect.gen(function* () { + const writerPaused = yield* Deferred.make(); + const resumeWriter = yield* Deferred.make(); + const commandAttempted = yield* Deferred.make(); + const writerStarted = yield* Deferred.make(); + const commandStarted = yield* Deferred.make(); + const writerTrace = makeSqlTrace(); + const commandTrace = makeSqlTrace(); + + const before = yield* submissionCompositionFacts( + ids.persons.expiryWriterFirst, + justBeforeExactEnd, + ids.departments.alpha, + ).pipe( + Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-wf-before`)), + ); - return { - beforeExactRuleIds: beforeExact.rules.map((rule) => rule.ruleId), - exactRuleIds: atExact.rules.map((rule) => rule.ruleId), - acceptedDurable, - freshFailureTag: resultFailureTag(fresh), - freshDurable, - }; - }).pipe( - Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-cf-after`)), - ); + assert.deepEqual(before.applicableRuleIds, [ids.rules.expiryWriterFirst]); + assert.deepEqual(before.contributingRuleIds, [ids.rules.expiryWriterFirst]); + assert.equal(before.directPaymentAuthorities.length, 0); + assert.equal(before.mapped._tag, "Success"); - assert.deepEqual(after.beforeExactRuleIds, [ids.rules.expiryCommandFirst]); - assert.deepEqual(after.exactRuleIds, []); - assert.deepEqual(after.acceptedDurable, { - commandReceiptRows: 1, - auditRows: 1, - outboxRows: 3, - outboxCommandRows: 1, - }); - assert.equal(after.freshFailureTag, "ReceiptAuthorityDenied"); - assert.deepEqual(after.freshDurable, { - commandReceiptRows: 0, - auditRows: 0, - outboxRows: 0, - outboxCommandRows: 0, + const writerFiber = yield* Effect.forkScoped( + Effect.gen(function* () { + const sql = yield* Database; + const started = yield* connectionStamp(sql); + yield* Deferred.succeed(writerStarted, started); + + const observed = observeSql(sql, writerTrace, { + pauseAfter: { + phase: "end-rule", + ready: writerPaused, + resume: resumeWriter, + }, }); - return { - order: "CommandFirst" as const, - instant: exactEnd, - compositionBefore: before, - participants: { - command: command.started, - commandCompleted: command.completed, - writer: writer.started, - writerCompleted: writer.completed, - independentBackendPids: command.started.pid !== writer.started.pid, - }, - blocked: locks.blocked, - relationLocks: locks.relationLocks, - advisoryLocks: locks.advisoryLocks, - sqlOrderAtBlock: { command: commandAtBlock, writer: writerAtBlock }, - sqlOrderAfterCommit: { command: commandTrace, writer: writerTrace }, - commandObservation: command.value.observation, - endedRule: { - ruleId: writer.ended.ruleId, - endAt: writer.ended.endAt, - revision: writer.ended.revision, - }, - ...after, - }; - }); + const ended = yield* endAuthzRule({ + ruleId: AuthzRuleId.make(ids.rules.expiryWriterFirst), + endAt: exactEnd, + expectedRevision: 0, + }).pipe(Effect.provideService(Database, observed)); - const writerFirst = yield* Effect.gen(function* () { - const writerPaused = yield* Deferred.make(); - const resumeWriter = yield* Deferred.make(); - const commandAttempted = yield* Deferred.make(); - const writerStarted = yield* Deferred.make(); - const commandStarted = yield* Deferred.make(); - const writerTrace = makeSqlTrace(); - const commandTrace = makeSqlTrace(); - - const before = yield* submissionCompositionFacts( - ids.persons.expiryWriterFirst, - justBeforeExactEnd, - ids.departments.alpha, - ).pipe( - Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-wf-before`)), - ); + const completed = yield* connectionStamp(sql); - assert.deepEqual(before.applicableRuleIds, [ids.rules.expiryWriterFirst]); - assert.deepEqual(before.contributingRuleIds, [ids.rules.expiryWriterFirst]); - assert.equal(before.directPaymentAuthorities.length, 0); - assert.equal(before.mapped._tag, "Success"); - - const writerFiber = yield* Effect.forkScoped( - Effect.gen(function* () { - const sql = yield* Database; - const started = yield* connectionStamp(sql); - yield* Deferred.succeed(writerStarted, started); - - const observed = observeSql(sql, writerTrace, { - pauseAfter: { - phase: "end-rule", - ready: writerPaused, - resume: resumeWriter, - }, - }); + return { started, completed, ended }; + }).pipe( + Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-wf-writer`)), + ), + ); - const ended = yield* endAuthzRule({ - ruleId: AuthzRuleId.make(ids.rules.expiryWriterFirst), - endAt: exactEnd, - expectedRevision: 0, - }).pipe(Effect.provideService(Database, observed)); + yield* Deferred.await(writerPaused); - const completed = yield* connectionStamp(sql); + const commandFiber = yield* Effect.forkScoped( + Effect.gen(function* () { + const sql = yield* Database; + const started = yield* connectionStamp(sql); + yield* Deferred.succeed(commandStarted, started); - return { started, completed, ended }; - }).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-wf-writer`), - ), - ), - ); + const observed = observeSql(sql, commandTrace, { + signalBefore: { phase: "authz-shared-lock", deferred: commandAttempted }, + }); - yield* Deferred.await(writerPaused); - - const commandFiber = yield* Effect.forkScoped( - Effect.gen(function* () { - const sql = yield* Database; - const started = yield* connectionStamp(sql); - yield* Deferred.succeed(commandStarted, started); - - const observed = observeSql(sql, commandTrace, { - signalBefore: { phase: "authz-shared-lock", deferred: commandAttempted }, - }); - - const result = yield* Effect.result( - executeReceiptCommand( - submitCommand( - ids.commands.expiryWriterFirst, - ids.departments.alpha, - "expiry-writer-first", - ), - principal(ids.persons.expiryWriterFirst, exactEnd), - allocation( - generatedReceiptIds.expiryWriterFirst, - generatedVisualIds.expiryWriterFirst, - ), - ).pipe(Effect.provideService(Database, observed)), - ); - - const completed = yield* connectionStamp(sql); - - return { started, completed, result }; - }).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-wf-command`), + const result = yield* Effect.result( + executeReceiptCommand({ + command: submitCommand( + ids.commands.expiryWriterFirst, + ids.departments.alpha, + "expiry-writer-first", ), - ), + principal: principal(ids.persons.expiryWriterFirst, exactEnd), + allocation: allocation( + generatedReceiptIds.expiryWriterFirst, + generatedVisualIds.expiryWriterFirst, + ), + }).pipe(Effect.provideService(Database, observed)), ); - yield* Deferred.await(commandAttempted); - const writerStamp = yield* Deferred.await(writerStarted); - const commandStamp = yield* Deferred.await(commandStarted); - assert.notEqual(writerStamp.pid, commandStamp.pid); + const completed = yield* connectionStamp(sql); - const writerAtBlock = { - attempted: [...writerTrace.attempted], - completed: [...writerTrace.completed], - }; + return { started, completed, result }; + }).pipe( + Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-wf-command`)), + ), + ); - const commandAtBlock = { - attempted: [...commandTrace.attempted], - completed: [...commandTrace.completed], - }; + yield* Deferred.await(commandAttempted); + const writerStamp = yield* Deferred.await(writerStarted); + const commandStamp = yield* Deferred.await(commandStarted); + assert.notEqual(writerStamp.pid, commandStamp.pid); - const locks = yield* observeBlockingAndLocks({ - databaseUrl, - blockedPid: commandStamp.pid, - blockerPid: writerStamp.pid, - personId: ids.persons.expiryWriterFirst, - commandId: ids.commands.expiryWriterFirst, - applicationName: `${proofApplicationPrefix}-expiry-wf-observer`, - }); + const writerAtBlock = { + attempted: [...writerTrace.attempted], + completed: [...writerTrace.completed], + }; - assert.equal(locks.blocked.waitEventType, "Lock"); - assert.ok(locks.blocked.blockingPids.includes(writerStamp.pid)); - assert.ok( - hasAdvisoryLock( - locks.advisoryLocks, - writerStamp.pid, - "authorization-rules", - "ExclusiveLock", - true, - ), - ); - assert.ok( - hasAdvisoryLock( - locks.advisoryLocks, - commandStamp.pid, - "authorization-rules", - "ShareLock", - false, - ), - ); - assertSubsequence(commandAtBlock.completed, [ - "person-authorization-lock", - "organization-authority-projection", - "direct-receipt-authority-projection", - ]); - assert.equal(commandAtBlock.attempted.at(-1), "authz-shared-lock"); - yield* Deferred.succeed(resumeWriter, undefined); - const writer = yield* Fiber.join(writerFiber); - const command = yield* Fiber.join(commandFiber); - assert.equal(writer.ended.endAt, exactEnd); - assert.equal(writer.ended.revision, 1); - assert.equal(resultFailureTag(command.result), "ReceiptAuthorityDenied"); - assertSubsequence(writerTrace.completed, ["authz-exclusive-lock", "end-rule"]); - assertSubsequence(commandTrace.completed, [ - "person-authorization-lock", - "organization-authority-projection", - "direct-receipt-authority-projection", - "authz-shared-lock", - "authz-tag-assignment-projection", - "authz-rule-projection", - ]); - - const after = yield* Effect.gen(function* () { - const sql = yield* Database; + const commandAtBlock = { + attempted: [...commandTrace.attempted], + completed: [...commandTrace.completed], + }; - const beforeExact = yield* loadApplicableAuthorizationRules( - PrincipalSchema.cases.Person.make({ - personId: personId(ids.persons.expiryWriterFirst), - }), - "submitReceipt", - justBeforeExactEnd, - proofReceiptContext(departmentId(ids.departments.alpha)), - ); + const locks = yield* observeBlockingAndLocks({ + databaseUrl, + blockedPid: commandStamp.pid, + blockerPid: writerStamp.pid, + personId: ids.persons.expiryWriterFirst, + commandId: ids.commands.expiryWriterFirst, + applicationName: `${proofApplicationPrefix}-expiry-wf-observer`, + }); - const atExact = yield* loadApplicableAuthorizationRules( - PrincipalSchema.cases.Person.make({ - personId: personId(ids.persons.expiryWriterFirst), - }), - "submitReceipt", - exactEnd, - proofReceiptContext(departmentId(ids.departments.alpha)), - ); + assert.equal(locks.blocked.waitEventType, "Lock"); + assert.ok(locks.blocked.blockingPids.includes(writerStamp.pid)); + assert.ok( + hasAdvisoryLock( + locks.advisoryLocks, + writerStamp.pid, + "authorization-rules", + "ExclusiveLock", + true, + ), + ); + assert.ok( + hasAdvisoryLock( + locks.advisoryLocks, + commandStamp.pid, + "authorization-rules", + "ShareLock", + false, + ), + ); + assertSubsequence(commandAtBlock.completed, [ + "person-authorization-lock", + "organization-authority-projection", + "direct-receipt-authority-projection", + ]); + assert.equal(commandAtBlock.attempted.at(-1), "authz-shared-lock"); + yield* Deferred.succeed(resumeWriter, undefined); + const writer = yield* Fiber.join(writerFiber); + const command = yield* Fiber.join(commandFiber); + assert.equal(writer.ended.endAt, exactEnd); + assert.equal(writer.ended.revision, 1); + assert.equal(resultFailureTag(command.result), "ReceiptAuthorityDenied"); + assertSubsequence(writerTrace.completed, ["authz-exclusive-lock", "end-rule"]); + assertSubsequence(commandTrace.completed, [ + "person-authorization-lock", + "organization-authority-projection", + "direct-receipt-authority-projection", + "authz-shared-lock", + "authz-tag-assignment-projection", + "authz-rule-projection", + ]); - const durable = yield* readDurableCommandFacts(sql, ids.commands.expiryWriterFirst); + const after = yield* Effect.gen(function* () { + const sql = yield* Database; - return { - beforeExactRuleIds: beforeExact.rules.map((rule) => rule.ruleId), - exactRuleIds: atExact.rules.map((rule) => rule.ruleId), - durable, - }; - }).pipe( - Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-wf-after`)), - ); + const beforeExact = yield* loadApplicableAuthorizationRules( + PrincipalSchema.cases.Person.make({ + personId: personId(ids.persons.expiryWriterFirst), + }), + "submitReceipt", + justBeforeExactEnd, + proofReceiptContext(departmentId(ids.departments.alpha)), + ); - assert.deepEqual(after.beforeExactRuleIds, [ids.rules.expiryWriterFirst]); - assert.deepEqual(after.exactRuleIds, []); - assert.deepEqual(after.durable, { - commandReceiptRows: 0, - auditRows: 0, - outboxRows: 0, - outboxCommandRows: 0, - }); + const atExact = yield* loadApplicableAuthorizationRules( + PrincipalSchema.cases.Person.make({ + personId: personId(ids.persons.expiryWriterFirst), + }), + "submitReceipt", + exactEnd, + proofReceiptContext(departmentId(ids.departments.alpha)), + ); - return { - order: "WriterFirst" as const, - instant: exactEnd, - compositionBefore: before, - participants: { - writer: writer.started, - writerCompleted: writer.completed, - command: command.started, - commandCompleted: command.completed, - independentBackendPids: writer.started.pid !== command.started.pid, - }, - blocked: locks.blocked, - relationLocks: locks.relationLocks, - advisoryLocks: locks.advisoryLocks, - sqlOrderAtBlock: { writer: writerAtBlock, command: commandAtBlock }, - sqlOrderAfterCommit: { writer: writerTrace, command: commandTrace }, - endedRule: { - ruleId: writer.ended.ruleId, - endAt: writer.ended.endAt, - revision: writer.ended.revision, - }, - commandFailureTag: resultFailureTag(command.result), - ...after, - }; - }); + const durable = yield* readDurableCommandFacts(sql, ids.commands.expiryWriterFirst); + + return { + beforeExactRuleIds: beforeExact.rules.map((rule) => rule.ruleId), + exactRuleIds: atExact.rules.map((rule) => rule.ruleId), + durable, + }; + }).pipe( + Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-expiry-wf-after`)), + ); + + assert.deepEqual(after.beforeExactRuleIds, [ids.rules.expiryWriterFirst]); + assert.deepEqual(after.exactRuleIds, []); + assert.deepEqual(after.durable, { + commandReceiptRows: 0, + auditRows: 0, + outboxRows: 0, + outboxCommandRows: 0, + }); + + return { + order: "WriterFirst" as const, + instant: exactEnd, + compositionBefore: before, + participants: { + writer: writer.started, + writerCompleted: writer.completed, + command: command.started, + commandCompleted: command.completed, + independentBackendPids: writer.started.pid !== command.started.pid, + }, + blocked: locks.blocked, + relationLocks: locks.relationLocks, + advisoryLocks: locks.advisoryLocks, + sqlOrderAtBlock: { writer: writerAtBlock, command: commandAtBlock }, + sqlOrderAfterCommit: { writer: writerTrace, command: commandTrace }, + endedRule: { + ruleId: writer.ended.ruleId, + endAt: writer.ended.endAt, + revision: writer.ended.revision, + }, + commandFailureTag: resultFailureTag(command.result), + ...after, + }; + }); + +const proveRuleExpiryRaces = (databaseUrl: Redacted.Redacted) => + Effect.scoped( + Effect.gen(function* () { + const commandFirst = yield* proveRuleExpiryCommandFirst(databaseUrl); + + const writerFirst = yield* proveRuleExpiryWriterFirst(databaseUrl); return { commandFirst, writerFirst }; }), @@ -4334,14 +4340,14 @@ const proveTagDetachmentWriterFirst = (databaseUrl: Redacted.Redacted) = const accepted = yield* Effect.gen(function* () { const sql = yield* Database; - const value = yield* executeReceiptCommand( - ReceiptCommandRequestSchema.cases.RejectReceipt.make({ + const value = yield* executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.RejectReceipt.make({ commandId: ids.commands.tagAccepted, receiptId: ReceiptId.make(ids.receipts.tagAccepted), expectedRevision: 0, }), - principal(ids.persons.tagApprove, justBeforeExactEnd), - ); + principal: principal(ids.persons.tagApprove, justBeforeExactEnd), + }); const durable = yield* readDurableCommandFacts(sql, ids.commands.tagAccepted); @@ -4407,14 +4413,14 @@ const proveTagDetachmentWriterFirst = (databaseUrl: Redacted.Redacted) = }); const result = yield* Effect.result( - executeReceiptCommand( - ReceiptCommandRequestSchema.cases.RejectReceipt.make({ + executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.RejectReceipt.make({ commandId: ids.commands.tagWriterFirst, receiptId: ReceiptId.make(ids.receipts.tagWriterFirst), expectedRevision: 0, }), - principal(ids.persons.tagApprove, exactEnd), - ).pipe(Effect.provideService(Database, observed)), + principal: principal(ids.persons.tagApprove, exactEnd), + }).pipe(Effect.provideService(Database, observed)), ); const completed = yield* connectionStamp(sql); @@ -4598,14 +4604,14 @@ const proveTagDetachmentWriterFirst = (databaseUrl: Redacted.Redacted) = proofReceiptContext(departmentId(ids.departments.alpha)), ); - const value = yield* executeReceiptCommand( - ReceiptCommandRequestSchema.cases.RejectReceipt.make({ + const value = yield* executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.RejectReceipt.make({ commandId: ids.commands.tagWriterFirst, receiptId: ReceiptId.make(ids.receipts.tagWriterFirst), expectedRevision: 0, }), - principal(ids.persons.tagApprove, exactEnd), - ); + principal: principal(ids.persons.tagApprove, exactEnd), + }); const durable = yield* readDurableCommandFacts(sql, ids.commands.tagWriterFirst); diff --git a/packages/database/runtime/historical-service-cohort-rehearsal.ts b/packages/database/runtime/historical-service-cohort-rehearsal.ts index cfcc2472d..07a24fad1 100644 --- a/packages/database/runtime/historical-service-cohort-rehearsal.ts +++ b/packages/database/runtime/historical-service-cohort-rehearsal.ts @@ -539,7 +539,7 @@ try { productionEffects: "none", }; } finally { - if (pool) await pool.end().catch(() => undefined); + if (pool !== undefined) await pool.end().catch(() => undefined); await postgres?.stop().catch(() => undefined); await removeWorkspace(workspace.artifacts); diff --git a/packages/database/runtime/invitation-response-postgres-proof-main.ts b/packages/database/runtime/invitation-response-postgres-proof-main.ts index 73b1f57e5..5e869e529 100644 --- a/packages/database/runtime/invitation-response-postgres-proof-main.ts +++ b/packages/database/runtime/invitation-response-postgres-proof-main.ts @@ -847,21 +847,21 @@ const proof = (databaseUrl: Redacted.Redacted) => NotificationGateway.of({ deliverInterviewCompletionReceipt: (request) => Effect.fail( - new RecruitmentNotificationDeliveryError({ + RecruitmentNotificationDeliveryError.make({ effectId: request.effectId, message: "Proof recording delivery failure", }), ), deliverInterviewInvitation: (request) => Effect.fail( - new RecruitmentNotificationDeliveryError({ + RecruitmentNotificationDeliveryError.make({ effectId: request.effectId, message: "Proof recording delivery failure", }), ), deliverInterviewInvitationResponse: (request) => Effect.fail( - new RecruitmentNotificationDeliveryError({ + RecruitmentNotificationDeliveryError.make({ effectId: request.effectId, message: "Proof recording delivery failure", }), diff --git a/packages/database/runtime/oauth-postgres-tracer-main.ts b/packages/database/runtime/oauth-postgres-tracer-main.ts index 1182fa9af..98473de0a 100644 --- a/packages/database/runtime/oauth-postgres-tracer-main.ts +++ b/packages/database/runtime/oauth-postgres-tracer-main.ts @@ -118,7 +118,7 @@ const trace = (target: string) => assert.equal(delegated.clientSecret, undefined); - const requestContext = new IdentityRequestContext({ + const requestContext = IdentityRequestContext.make({ requestCorrelation: "oauth-0082-postgres-proof-request", sourceIp: "127.0.0.1", userAgent: "oauth-0082-postgres-proof", diff --git a/packages/database/runtime/organization-postgres-proof-main.ts b/packages/database/runtime/organization-postgres-proof-main.ts index 04ff36b6b..599249a1e 100644 --- a/packages/database/runtime/organization-postgres-proof-main.ts +++ b/packages/database/runtime/organization-postgres-proof-main.ts @@ -9,7 +9,9 @@ import { CreateDepartmentCommandSchema, Organization, OrganizationCommandId, + OrganizationPersonAuthoritySchema, PersonId, + requireOrganizationAdministrator, type CreateDepartmentCommand, } from "@vektorprogrammet/domain/organization"; import { OrganizationLive } from "@vektorprogrammet/database/organization"; @@ -30,10 +32,14 @@ const headMigrationId = Number(databaseSchemaRevision.slice(0, headSeparator)); const headMigrationName = databaseSchemaRevision.slice(headSeparator + 1); -const administrator = { - _tag: "OrganizationAdministrator" as const, +const administratorAuthority = OrganizationPersonAuthoritySchema.make({ personId: PersonId.make("organization-postgres-proof-administrator"), -}; + evaluatedAt: "2026-09-28T12:00:00.000Z", + globalAdministrator: "Active", + memberships: [], + nationalBoardSeats: [], + delegations: [], +}); const replayCommand: CreateDepartmentCommand = CreateDepartmentCommandSchema.make({ commandId: OrganizationCommandId.make(proofCohort.replayCommandId), @@ -110,6 +116,10 @@ const contender = ( SELECT pg_backend_pid() AS pid `; + const administrator = yield* Effect.fromResult( + requireOrganizationAdministrator(administratorAuthority), + ); + yield* Deferred.succeed(ready, undefined); yield* Deferred.await(start); const outcome = yield* Effect.result(organization.createDepartment(command, administrator)); diff --git a/packages/database/runtime/person-cohort-rehearsal.ts b/packages/database/runtime/person-cohort-rehearsal.ts index 864b3a1ec..ed48db5ef 100644 --- a/packages/database/runtime/person-cohort-rehearsal.ts +++ b/packages/database/runtime/person-cohort-rehearsal.ts @@ -448,7 +448,7 @@ try { productionEffects: "none", }; } finally { - if (pool) await pool.end().catch(() => undefined); + if (pool !== undefined) await pool.end().catch(() => undefined); await postgres?.stop().catch(() => undefined); await removeWorkspace(workspace.artifacts); diff --git a/packages/database/runtime/rehearsal-platform.ts b/packages/database/runtime/rehearsal-platform.ts index 9cd2722c2..2e8e9a76b 100644 --- a/packages/database/runtime/rehearsal-platform.ts +++ b/packages/database/runtime/rehearsal-platform.ts @@ -1,7 +1,8 @@ /** The Bun platform steps of the synthetic cohort rehearsals: files, commands, and configuration. */ import assert from "node:assert/strict"; import * as BunServices from "@effect/platform-bun/BunServices"; -import { Config, Data, Effect, FileSystem, Option, Path, Stream } from "effect"; +import { Config, Data, Effect, FileSystem, Option, Path, Predicate, Stream } from "effect"; +import { dual } from "effect/Function"; import { ChildProcess } from "effect/unstable/process"; export class RehearsalCommandFailed extends Data.TaggedError("RehearsalCommandFailed")<{ @@ -44,14 +45,18 @@ export const rehearsalWorkspace = (prefix: string) => ); /** Writes `text` to `file`, readable and writable by the owner only. */ -export const writePrivateFile = (file: string, text: string) => +export const writePrivateFile: { + (text: string): (file: string) => Promise; + (file: string, text: string): Promise; +} = dual(2, (file: string, text: string) => runOnBun( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* fs.writeFileString(file, text, { mode: 0o600 }); yield* fs.chmod(file, 0o600); }), - ); + ), +); /** Removes the artifact directory and everything in it. */ export const removeWorkspace = (artifacts: string) => @@ -63,34 +68,50 @@ export const removeWorkspace = (artifacts: string) => * The standard output of `command`, which must exit with code 0 within a minute. The command * inherits the environment, with `env` taking precedence. */ -export const commandOutput = ( - command: string, - args: ReadonlyArray, - cwd: string, - env: Readonly> = {}, -) => - runOnBun( - Effect.scoped( - Effect.gen(function* () { - const handle = yield* ChildProcess.make(command, args, { - cwd, - env, - extendEnv: true, - stdin: "ignore", - }); +export const commandOutput: { + ( + args: ReadonlyArray, + cwd: string, + env?: Readonly>, + ): (command: string) => Promise; + ( + command: string, + args: ReadonlyArray, + cwd: string, + env?: Readonly>, + ): Promise; +} = dual( + (args) => Predicate.isString(args[0]), + ( + command: string, + args: ReadonlyArray, + cwd: string, + env: Readonly> = {}, + ) => + runOnBun( + Effect.scoped( + Effect.gen(function* () { + const handle = yield* ChildProcess.make(command, args, { + cwd, + env, + extendEnv: true, + stdin: "ignore", + }); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - Stream.mkString(Stream.decodeText(handle.stdout)), - Stream.mkString(Stream.decodeText(handle.stderr)), - handle.exitCode, - ], - { concurrency: "unbounded" }, - ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); - if (exitCode !== 0) return yield* new RehearsalCommandFailed({ command, exitCode, stderr }); + if (exitCode !== 0) + return yield* new RehearsalCommandFailed({ command, exitCode, stderr }); - return stdout; - }), - ).pipe(Effect.timeout("60 seconds")), - ); + return stdout; + }), + ).pipe(Effect.timeout("60 seconds")), + ), +); diff --git a/packages/database/runtime/schools-postgres-proof-main.ts b/packages/database/runtime/schools-postgres-proof-main.ts index 9971cba92..a814c3a78 100644 --- a/packages/database/runtime/schools-postgres-proof-main.ts +++ b/packages/database/runtime/schools-postgres-proof-main.ts @@ -212,7 +212,7 @@ const program = Effect.scoped( }).pipe( Effect.catchTag("SqlError", (cause) => Effect.fail( - new SchoolsPersistenceError({ + SchoolsPersistenceError.make({ operation: "capture Schools proof reader connection", message: String(cause), }), diff --git a/packages/database/runtime/service-principal-grants-postgres-tracer-main.ts b/packages/database/runtime/service-principal-grants-postgres-tracer-main.ts index 4135919c0..ee57ea71c 100644 --- a/packages/database/runtime/service-principal-grants-postgres-tracer-main.ts +++ b/packages/database/runtime/service-principal-grants-postgres-tracer-main.ts @@ -131,7 +131,7 @@ const trace = (target: string) => )`, ); - const requestContext = new IdentityRequestContext({ + const requestContext = IdentityRequestContext.make({ requestCorrelation: "service-principal-grants-token-request", sourceIp: "127.0.0.1", userAgent: "service-principal-grants-postgres-proof", diff --git a/packages/database/src/admission-period/postgres.ts b/packages/database/src/admission-period/postgres.ts index a9a39ed4c..afc6d432b 100644 --- a/packages/database/src/admission-period/postgres.ts +++ b/packages/database/src/admission-period/postgres.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { canonicalJsonValue } from "@vektorprogrammet/domain/shared-kernel"; import { AdvisoryLockKey, lockAdvisory } from "../advisory-lock.js"; import { Database, type DatabaseOperations } from "../service.js"; @@ -56,7 +57,7 @@ interface PeriodCommandReceiptRow { * recognize a lost serialization race and restart the transaction on a fresh snapshot. */ const periodPersistenceError = (operation: string, cause: unknown) => { - const error = new AdmissionPeriodPersistenceError({ operation, message: String(cause) }); + const error = AdmissionPeriodPersistenceError.make({ operation, message: String(cause) }); error.cause = cause; @@ -197,20 +198,20 @@ const checkActor = ( actor: AdmissionPeriodActor, ): Effect.Effect => Effect.gen(function* () { - if (!actor.active) return yield* new InactiveActor({ personId: actor.personId }); + if (!actor.active) return yield* InactiveActor.make({ personId: actor.personId }); if ( !Predicate.isTagged(actor, "DepartmentAdministrator") && !Predicate.isTagged(actor, "GlobalAdmin") ) { - return yield* new AdmissionRoleDenied({ personId: actor.personId }); + return yield* AdmissionRoleDenied.make({ personId: actor.personId }); } }); const checkNow = (now: string): Effect.Effect => isRfc3339Instant(now) ? Effect.void - : Effect.fail(new AdmissionPeriodDecodeError({ message: "now must be an RFC 3339 instant" })); + : Effect.fail(AdmissionPeriodDecodeError.make({ message: "now must be an RFC 3339 instant" })); const actorCanAccessDepartment = (actor: AdmissionPeriodActor, departmentId: string): boolean => Predicate.isTagged(actor, "GlobalAdmin") || actor.departmentId === departmentId; @@ -251,7 +252,7 @@ const writePeriod = ( rows.length === 1 ? Effect.void : Effect.fail( - new StaleAdmissionPeriodRevision({ + StaleAdmissionPeriodRevision.make({ admissionPeriodId: period.id, expected: previous.revision, actual: previous.revision, @@ -356,7 +357,7 @@ const effectiveCreateDepartment = ( if (Predicate.isTagged(actor, "DepartmentAdministrator")) { if (command.departmentId !== undefined && command.departmentId !== actor.departmentId) { return Effect.fail( - new AdmissionScopeDenied({ + AdmissionScopeDenied.make({ personId: actor.personId, departmentId: command.departmentId, }), @@ -366,7 +367,7 @@ const effectiveCreateDepartment = ( return Effect.succeed(actor.departmentId); } - if (command.departmentId === undefined) return Effect.fail(new DepartmentRequired()); + if (command.departmentId === undefined) return Effect.fail(DepartmentRequired.make()); return Effect.succeed(command.departmentId); }; @@ -375,137 +376,153 @@ export const decodeAdmissionPeriodCommand = flow( Schema.decodeUnknownEffect(AdmissionPeriodCommandSchema, { onExcessProperty: "error", }), - Effect.mapError((cause) => new AdmissionPeriodDecodeError({ message: String(cause) })), + Effect.mapError((cause) => AdmissionPeriodDecodeError.make({ message: String(cause) })), ); -export const executeAdmissionPeriodCommand = ( - input: AdmissionPeriodCommand, - context: AdmissionPeriodCommandContext, -): Effect.Effect => - Effect.gen(function* () { - const command = yield* decodeAdmissionPeriodCommand(input); - yield* checkActor(context.actor); - yield* checkNow(context.now); - const sql = yield* Database; - const commandDigest = admissionPeriodCommandDigest(command); - - return yield* sql - .withTransaction( - Effect.gen(function* () { - yield* lockAdvisory(sql, AdvisoryLockKey.admissionPeriodCommand(command.commandId)).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(periodPersistenceError("lock admission command receipt", cause)), - ), - ); - const stored = yield* findPeriodCommandReceipt(sql, command.commandId); - - if (stored !== undefined) { - if (stored.command_sha256 !== commandDigest) { - return yield* new DuplicateAdmissionPeriodCommandConflict({ - commandId: command.commandId, - }); - } - - const original = yield* observationFromStored(stored.observation_json); - - if (!actorCanAccessDepartment(context.actor, original.period.departmentId)) { - return yield* new AdmissionScopeDenied({ - personId: context.actor.personId, - departmentId: original.period.departmentId, - admissionPeriodId: original.period.id, - }); - } - - return replayPeriodResult(command.commandId, original); - } - - let previous: AdmissionPeriod | undefined; - let semester: AdmissionSemesterValue | undefined; - - if (Predicate.isTagged(command, "CreateAdmissionPeriod")) { - const departmentId = yield* effectiveCreateDepartment(command, context.actor); +export const executeAdmissionPeriodCommand: { + ( + context: AdmissionPeriodCommandContext, + ): ( + input: AdmissionPeriodCommand, + ) => Effect.Effect; + ( + input: AdmissionPeriodCommand, + context: AdmissionPeriodCommandContext, + ): Effect.Effect; +} = dual( + 2, + ( + input: AdmissionPeriodCommand, + context: AdmissionPeriodCommandContext, + ): Effect.Effect => + Effect.gen(function* () { + const command = yield* decodeAdmissionPeriodCommand(input); + yield* checkActor(context.actor); + yield* checkNow(context.now); + const sql = yield* Database; + const commandDigest = admissionPeriodCommandDigest(command); + + return yield* sql + .withTransaction( + Effect.gen(function* () { yield* lockAdvisory( sql, - AdvisoryLockKey.admissionPeriodSemester(departmentId, command.semesterId), + AdvisoryLockKey.admissionPeriodCommand(command.commandId), ).pipe( Effect.catchTag("SqlError", (cause) => - Effect.fail(periodPersistenceError("lock admission department semester", cause)), + Effect.fail(periodPersistenceError("lock admission command receipt", cause)), ), ); - - if (!(yield* departmentExists(sql, departmentId))) { - return yield* new DepartmentNotFound({ departmentId }); - } - - previous = yield* findPeriodByPairForUpdate(sql, departmentId, command.semesterId); - semester = yield* findSemester(sql, command.semesterId); - - if (semester === undefined) { - return yield* new SemesterNotFound({ semesterId: command.semesterId }); + const stored = yield* findPeriodCommandReceipt(sql, command.commandId); + + if (stored !== undefined) { + if (stored.command_sha256 !== commandDigest) { + return yield* DuplicateAdmissionPeriodCommandConflict.make({ + commandId: command.commandId, + }); + } + + const original = yield* observationFromStored(stored.observation_json); + + if (!actorCanAccessDepartment(context.actor, original.period.departmentId)) { + return yield* AdmissionScopeDenied.make({ + personId: context.actor.personId, + departmentId: original.period.departmentId, + admissionPeriodId: original.period.id, + }); + } + + return replayPeriodResult(command.commandId, original); } - } else { - previous = yield* findPeriodForUpdate(sql, command.admissionPeriodId); - if (previous === undefined) { - return yield* new AdmissionPeriodNotFound({ - admissionPeriodId: command.admissionPeriodId, - }); + let previous: AdmissionPeriod | undefined; + let semester: AdmissionSemesterValue | undefined; + + if (Predicate.isTagged(command, "CreateAdmissionPeriod")) { + const departmentId = yield* effectiveCreateDepartment(command, context.actor); + yield* lockAdvisory( + sql, + AdvisoryLockKey.admissionPeriodSemester(departmentId, command.semesterId), + ).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(periodPersistenceError("lock admission department semester", cause)), + ), + ); + + if (!(yield* departmentExists(sql, departmentId))) { + return yield* DepartmentNotFound.make({ departmentId }); + } + + previous = yield* findPeriodByPairForUpdate(sql, departmentId, command.semesterId); + semester = yield* findSemester(sql, command.semesterId); + + if (semester === undefined) { + return yield* SemesterNotFound.make({ semesterId: command.semesterId }); + } + } else { + previous = yield* findPeriodForUpdate(sql, command.admissionPeriodId); + + if (previous === undefined) { + return yield* AdmissionPeriodNotFound.make({ + admissionPeriodId: command.admissionPeriodId, + }); + } + + if (!actorCanAccessDepartment(context.actor, previous.departmentId)) { + return yield* AdmissionScopeDenied.make({ + personId: context.actor.personId, + departmentId: previous.departmentId, + admissionPeriodId: previous.id, + }); + } + + semester = yield* findSemester(sql, previous.semesterId); + + if (semester === undefined) { + return yield* SemesterNotFound.make({ semesterId: previous.semesterId }); + } } - if (!actorCanAccessDepartment(context.actor, previous.departmentId)) { - return yield* new AdmissionScopeDenied({ - personId: context.actor.personId, - departmentId: previous.departmentId, - admissionPeriodId: previous.id, - }); - } + const decision = yield* decideAdmissionPeriod( + previous, + command, + contextForActor(context, semester), + ); - semester = yield* findSemester(sql, previous.semesterId); + yield* writePeriod(sql, decision.period, previous); + yield* writePeriodCommandReceipt( + sql, + command, + commandDigest, + decision.observation, + decision.period, + context.now, + ); + yield* writeOutbox(sql, decision.outbox); + yield* writeAudit( + sql, + command, + decision.period, + context.actor.personId, + decision.auditAction, + context.now, + ); - if (semester === undefined) { - return yield* new SemesterNotFound({ semesterId: previous.semesterId }); - } - } - - const decision = yield* decideAdmissionPeriod( - previous, - command, - contextForActor(context, semester), - ); - - yield* writePeriod(sql, decision.period, previous); - yield* writePeriodCommandReceipt( - sql, - command, - commandDigest, - decision.observation, - decision.period, - context.now, - ); - yield* writeOutbox(sql, decision.outbox); - yield* writeAudit( - sql, - command, - decision.period, - context.actor.personId, - decision.auditAction, - context.now, - ); - - return { - period: decision.period, - observation: decision.observation, - replayed: false, - outboxCount: decision.outbox.length, - }; - }), - ) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(periodPersistenceError("admission period transaction", cause)), - ), - ); - }); + return { + period: decision.period, + observation: decision.observation, + replayed: false, + outboxCount: decision.outbox.length, + }; + }), + ) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(periodPersistenceError("admission period transaction", cause)), + ), + ); + }), +); const projectionRows = ( sql: DatabaseOperations, @@ -580,15 +597,28 @@ export const listOpenAdmissionPeriods = ( return rows.filter((row) => row.eligible); }); -export const admissionPeriodProjectionFor = ( - period: AdmissionPeriod, - semester: AdmissionSemesterValue, - now: string, -): AdmissionPeriodProjection => - Struct.assign(period, { - eligible: - compareRfc3339Instants(semester.startAt, now) <= 0 && - compareRfc3339Instants(now, semester.endAt) < 0 && - compareRfc3339Instants(period.startAt, now) <= 0 && - compareRfc3339Instants(now, period.endAt) < 0, - }); +export const admissionPeriodProjectionFor: { + ( + semester: AdmissionSemesterValue, + now: string, + ): (period: AdmissionPeriod) => AdmissionPeriodProjection; + ( + period: AdmissionPeriod, + semester: AdmissionSemesterValue, + now: string, + ): AdmissionPeriodProjection; +} = dual( + 3, + ( + period: AdmissionPeriod, + semester: AdmissionSemesterValue, + now: string, + ): AdmissionPeriodProjection => + Struct.assign(period, { + eligible: + compareRfc3339Instants(semester.startAt, now) <= 0 && + compareRfc3339Instants(now, semester.endAt) < 0 && + compareRfc3339Instants(period.startAt, now) <= 0 && + compareRfc3339Instants(now, period.endAt) < 0, + }), +); diff --git a/packages/database/src/admissions/outcome.test.ts b/packages/database/src/admissions/outcome.test.ts index db959c5ab..cde5edb4f 100644 --- a/packages/database/src/admissions/outcome.test.ts +++ b/packages/database/src/admissions/outcome.test.ts @@ -1,3 +1,4 @@ +import { administratorDepartmentReach } from "@vektorprogrammet/domain/organization/authority-fixtures"; import { expect, layer } from "@effect/vitest"; import { Effect, Exit, Layer, Predicate } from "effect"; import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; @@ -20,10 +21,12 @@ const applicationId = PublicApplicationIdSchema.make("outcome-application"); const recruiter = PersonId.make("outcome-recruiter"); +const decider = administratorDepartmentReach(recruiter, "admissions.outcomes", scope.departmentId); + const record = (outcome: AdmissionOutcome, now = "2026-06-01T10:00:00.000Z") => Admissions.use((service) => service.recordAdmissionOutcome( - { applicationId, command: { outcome }, actor: recruiter, now }, + { applicationId, command: { outcome }, decider, now }, () => Effect.void, ), ); @@ -84,7 +87,7 @@ layer(suiteSeed.pipe(Layer.provideMerge(suiteLayer)), { { applicationId, command: { outcome: "Substitute" }, - actor: recruiter, + decider, now: "2026-06-01T10:00:00.000Z", }, (current) => Effect.fail({ code: "precondition.failed", current }), @@ -101,6 +104,37 @@ layer(suiteSeed.pipe(Layer.provideMerge(suiteLayer)), { }), ); + it.effect("refuses evidence for another department and writes nothing", () => + Effect.gen(function* () { + const result = yield* observeRolledBack( + Effect.gen(function* () { + const failure = yield* Effect.flip( + Admissions.use((service) => + service.recordAdmissionOutcome( + { + applicationId, + command: { outcome: "Admitted" }, + decider: administratorDepartmentReach( + recruiter, + "admissions.outcomes", + "outcome-other-department", + ), + now: "2026-06-01T10:00:00.000Z", + }, + () => Effect.void, + ), + ), + ); + + return { failure, after: yield* readEntry }; + }), + ); + + expect(result.failure).toMatchObject({ code: "authority.denied", status: 403 }); + expect(result.after).toMatchObject({ outcome: null, revision: 0 }); + }), + ); + it.effect("leaves no outcome when the caller cannot finish its receipt", () => Effect.gen(function* () { const failure = yield* Effect.flip( diff --git a/packages/database/src/admissions/outcome.ts b/packages/database/src/admissions/outcome.ts index acf536ac2..14cbbc49b 100644 --- a/packages/database/src/admissions/outcome.ts +++ b/packages/database/src/admissions/outcome.ts @@ -161,7 +161,7 @@ export const admissionOutcomeOperations = ( }), ), readAdmissionOutcome: (applicationId) => run(readEntry(applicationId)), - recordAdmissionOutcome: ({ applicationId, command, actor, now }, checkPrecondition) => + recordAdmissionOutcome: ({ applicationId, command, decider, now }, checkPrecondition) => Effect.gen(function* () { yield* run( Database.use( @@ -170,6 +170,11 @@ export const admissionOutcomeOperations = ( ), ); const current = yield* run(readEntry(applicationId)); + + // The evidence decides only in its own department, checked on the locked application. + if (current.departmentId !== decider.departmentId) + return yield* new AdmissionOutcomeFailure({ code: "authority.denied", status: 403 }); + yield* checkPrecondition(current); if (current.outcome === command.outcome) return current; @@ -179,7 +184,7 @@ export const admissionOutcomeOperations = ( yield* Database.use( (sql) => sql`INSERT INTO public.admission_application_outcomes (application_id, revision, outcome, decided_by_person_id, decided_at) - VALUES (${applicationId}, ${current.revision + 1}, ${command.outcome}, ${actor}, ${now})`, + VALUES (${applicationId}, ${current.revision + 1}, ${command.outcome}, ${decider.personId}, ${now})`, ); return yield* readEntry(applicationId); diff --git a/packages/database/src/advisory-lock.ts b/packages/database/src/advisory-lock.ts index 792b58754..278936e51 100644 --- a/packages/database/src/advisory-lock.ts +++ b/packages/database/src/advisory-lock.ts @@ -20,7 +20,8 @@ * Constructors documented as bare hash the identifier without a prefix, so they share one key * space. Adding a prefix to one of them is a lock migration, not a rename. */ -import { Brand, Effect } from "effect"; +import { Brand, Effect, Predicate } from "effect"; +import { dual } from "effect/Function"; import type { SqlError } from "effect/unstable/sql/SqlError"; import { AUTHZ_LOCK_PROTOCOL } from "@vektorprogrammet/domain/authz"; import { canonicalJson } from "@vektorprogrammet/domain/shared-kernel"; @@ -200,24 +201,40 @@ export type AdvisoryLockMode = "exclusive" | "shared"; * * @construct sql-lock */ -export const lockAdvisory = ( - sql: DatabaseOperations, - key: AdvisoryLockKey, - mode: AdvisoryLockMode = "exclusive", -): Effect.Effect => - (mode === "shared" - ? sql`SELECT pg_catalog.pg_advisory_xact_lock_shared(pg_catalog.hashtextextended(${key}, 0))` - : sql`SELECT pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(${key}, 0))` - ).pipe(Effect.asVoid); +export const lockAdvisory: { + ( + key: AdvisoryLockKey, + mode?: AdvisoryLockMode, + ): (sql: DatabaseOperations) => Effect.Effect; + ( + sql: DatabaseOperations, + key: AdvisoryLockKey, + mode?: AdvisoryLockMode, + ): Effect.Effect; +} = dual( + (args) => !Predicate.isString(args[0]), + ( + sql: DatabaseOperations, + key: AdvisoryLockKey, + mode: AdvisoryLockMode = "exclusive", + ): Effect.Effect => + (mode === "shared" + ? sql`SELECT pg_catalog.pg_advisory_xact_lock_shared(pg_catalog.hashtextextended(${key}, 0))` + : sql`SELECT pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(${key}, 0))` + ).pipe(Effect.asVoid), +); /** * Takes the exclusive advisory lock on `key` until the current transaction ends when no other * transaction holds it. Answers false instead of waiting. */ -export const tryLockAdvisory = ( - sql: DatabaseOperations, - key: AdvisoryLockKey, -): Effect.Effect => - sql<{ readonly acquired: boolean }>` +export const tryLockAdvisory: { + (key: AdvisoryLockKey): (sql: DatabaseOperations) => Effect.Effect; + (sql: DatabaseOperations, key: AdvisoryLockKey): Effect.Effect; +} = dual( + 2, + (sql: DatabaseOperations, key: AdvisoryLockKey): Effect.Effect => + sql<{ readonly acquired: boolean }>` SELECT pg_catalog.pg_try_advisory_xact_lock(pg_catalog.hashtextextended(${key}, 0)) AS acquired - `.pipe(Effect.map((rows) => rows[0]?.acquired === true)); + `.pipe(Effect.map((rows) => rows[0]?.acquired === true)), +); diff --git a/packages/database/src/application/outbox.ts b/packages/database/src/application/outbox.ts index d50804f85..7767b6ffe 100644 --- a/packages/database/src/application/outbox.ts +++ b/packages/database/src/application/outbox.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { Database } from "../service.js"; import { markOutboxDelivered, @@ -70,7 +71,7 @@ export const PublicApplicationOutboxDeliveryResult = Data.taggedEnum(); const persistenceError = (operation: string): PublicApplicationPersistenceError => - new PublicApplicationPersistenceError({ + PublicApplicationPersistenceError.make({ operation, message: "public application persistence failed", }); @@ -80,24 +81,44 @@ const applicationOutbox: OutboxTable = { terminalPayload: "Scrub", }; -export const claimNextPublicApplicationOutbox = ( - claimId: string, - claimedAt: string, -): Effect.Effect< - ClaimedPublicApplicationOutbox | undefined, - PublicApplicationPersistenceError | OutboxClaimLost, - Database -> => - Effect.gen(function* () { - const sql = yield* Database; - - const quarantine = (effectId: string, failureTag: string) => - quarantineOutboxClaim(sql, applicationOutbox, { effectId, claimId }, failureTag); - - return yield* sql - .withTransaction( - Effect.gen(function* () { - const rows = yield* sql` +export const claimNextPublicApplicationOutbox: { + ( + claimedAt: string, + ): ( + claimId: string, + ) => Effect.Effect< + ClaimedPublicApplicationOutbox | undefined, + PublicApplicationPersistenceError | OutboxClaimLost, + Database + >; + ( + claimId: string, + claimedAt: string, + ): Effect.Effect< + ClaimedPublicApplicationOutbox | undefined, + PublicApplicationPersistenceError | OutboxClaimLost, + Database + >; +} = dual( + 2, + ( + claimId: string, + claimedAt: string, + ): Effect.Effect< + ClaimedPublicApplicationOutbox | undefined, + PublicApplicationPersistenceError | OutboxClaimLost, + Database + > => + Effect.gen(function* () { + const sql = yield* Database; + + const quarantine = (effectId: string, failureTag: string) => + quarantineOutboxClaim(sql, applicationOutbox, { effectId, claimId }, failureTag); + + return yield* sql + .withTransaction( + Effect.gen(function* () { + const rows = yield* sql` WITH candidate AS ( SELECT outbox.effect_id FROM admission_application_outbox AS outbox @@ -129,54 +150,54 @@ export const claimNextPublicApplicationOutbox = ( claimed.payload_json, claimed.origin `; - const row = rows[0]; - - if (row === undefined) return undefined; - - const decoded = yield* Schema.decodeUnknownEffect(PublicApplicationOutboxRequestSchema)( - row.payload_json, - { onExcessProperty: "error" }, - ).pipe( - Effect.match({ - onFailure: () => ({ _tag: "Invalid" as const }), - onSuccess: (request) => ({ _tag: "Valid" as const, request }), - }), - ); - - if (Predicate.isTagged(decoded, "Invalid")) { - yield* quarantine(row.effect_id, "InvalidPublicApplicationEffectPayload"); - - return undefined; - } - - const request = decoded.request; - const requestOrigin = "origin" in request ? request.origin : undefined; - - const effectTypeMatchesOrdinal = - (row.ordinal === 0 && row.effect_type === "SendApplicantActivationOrConfirmation") || - (row.ordinal === 1 && row.effect_type === "CreateAdmissionSubscription") || - (row.ordinal === 2 && row.effect_type === "WriteApplicationAudit"); - - if ( - !effectTypeMatchesOrdinal || - request.effectId !== row.effect_id || - request._tag !== row.effect_type || - request.applicationId !== row.application_id || - request.applicantId !== row.applicant_id || - request.commandId !== row.command_id || - (row.origin === "ReturningAssistant" - ? requestOrigin !== "ReturningAssistant" - : requestOrigin !== undefined) - ) { - yield* quarantine(row.effect_id, "InvalidPublicApplicationEffectEnvelope"); - - return undefined; - } - - let identities: ReadonlyArray; - - if (row.origin === "ReturningAssistant") { - identities = yield* sql` + const row = rows[0]; + + if (row === undefined) return undefined; + + const decoded = yield* Schema.decodeUnknownEffect(PublicApplicationOutboxRequestSchema)( + row.payload_json, + { onExcessProperty: "error" }, + ).pipe( + Effect.match({ + onFailure: () => ({ _tag: "Invalid" as const }), + onSuccess: (request) => ({ _tag: "Valid" as const, request }), + }), + ); + + if (Predicate.isTagged(decoded, "Invalid")) { + yield* quarantine(row.effect_id, "InvalidPublicApplicationEffectPayload"); + + return undefined; + } + + const request = decoded.request; + const requestOrigin = "origin" in request ? request.origin : undefined; + + const effectTypeMatchesOrdinal = + (row.ordinal === 0 && row.effect_type === "SendApplicantActivationOrConfirmation") || + (row.ordinal === 1 && row.effect_type === "CreateAdmissionSubscription") || + (row.ordinal === 2 && row.effect_type === "WriteApplicationAudit"); + + if ( + !effectTypeMatchesOrdinal || + request.effectId !== row.effect_id || + request._tag !== row.effect_type || + request.applicationId !== row.application_id || + request.applicantId !== row.applicant_id || + request.commandId !== row.command_id || + (row.origin === "ReturningAssistant" + ? requestOrigin !== "ReturningAssistant" + : requestOrigin !== undefined) + ) { + yield* quarantine(row.effect_id, "InvalidPublicApplicationEffectEnvelope"); + + return undefined; + } + + let identities: ReadonlyArray; + + if (row.origin === "ReturningAssistant") { + identities = yield* sql` SELECT applicant.email, NULL::text AS application_activation_digest, registration.department_id, @@ -195,8 +216,8 @@ export const claimNextPublicApplicationOutbox = ( AND registration.application_id = ${row.application_id} AND registration.applicant_id = ${row.applicant_id} `; - } else { - identities = yield* sql` + } else { + identities = yield* sql` SELECT applicant.email, application.activation_digest AS application_activation_digest, application.department_id, @@ -215,70 +236,72 @@ export const claimNextPublicApplicationOutbox = ( WHERE applicant.applicant_id = ${row.applicant_id} AND application.application_id = ${row.application_id} `; - } - - const identity = identities[0]; - - if (identity === undefined) { - yield* quarantine(row.effect_id, "InvalidPublicApplicationEffectAuthority"); - - return undefined; - } - - const requestPersonId = "personId" in request ? request.personId : undefined; - - const requestRegistrationId = - "registrationId" in request ? request.registrationId : undefined; - - const transactionMatchesCanonicalState = - identity.receipt_application_id === row.application_id && - identity.audit_application_id === row.application_id && - identity.audit_applicant_id === row.applicant_id && - (row.origin !== "ReturningAssistant" || - (identity.linked_person_id !== null && - identity.linked_registration_id !== null && - requestPersonId === identity.linked_person_id && - requestRegistrationId === identity.linked_registration_id && - requestOrigin === "ReturningAssistant")); - - const requestMatchesCanonicalState = Predicate.isTagged( - request, - "SendApplicantActivationOrConfirmation", - ) - ? request.email === identity.email && - (!("activationToken" in request) || request.activationToken === undefined - ? identity.application_activation_digest === null - : publicApplicationActivationDigest(request.activationToken) === - identity.application_activation_digest) - : Predicate.isTagged(request, "CreateAdmissionSubscription") - ? request.email === identity.email && request.departmentId === identity.department_id - : request.action === - (row.origin === "ReturningAssistant" - ? "ReturningAssistantRegistered" - : "PublicApplicationSubmitted"); - - if (!transactionMatchesCanonicalState || !requestMatchesCanonicalState) { - yield* quarantine(row.effect_id, "InvalidPublicApplicationEffectAuthority"); - - return undefined; - } - - return { - effectId: row.effect_id, - commandId: row.command_id, - ordinal: row.ordinal, - attempts: row.attempts, - claimId, - request, - }; - }), - ) - .pipe( - Effect.catchTag("SqlError", () => - Effect.fail(persistenceError("claim application outbox")), - ), - ); - }); + } + + const identity = identities[0]; + + if (identity === undefined) { + yield* quarantine(row.effect_id, "InvalidPublicApplicationEffectAuthority"); + + return undefined; + } + + const requestPersonId = "personId" in request ? request.personId : undefined; + + const requestRegistrationId = + "registrationId" in request ? request.registrationId : undefined; + + const transactionMatchesCanonicalState = + identity.receipt_application_id === row.application_id && + identity.audit_application_id === row.application_id && + identity.audit_applicant_id === row.applicant_id && + (row.origin !== "ReturningAssistant" || + (identity.linked_person_id !== null && + identity.linked_registration_id !== null && + requestPersonId === identity.linked_person_id && + requestRegistrationId === identity.linked_registration_id && + requestOrigin === "ReturningAssistant")); + + const requestMatchesCanonicalState = Predicate.isTagged( + request, + "SendApplicantActivationOrConfirmation", + ) + ? request.email === identity.email && + (!("activationToken" in request) || request.activationToken === undefined + ? identity.application_activation_digest === null + : publicApplicationActivationDigest(request.activationToken) === + identity.application_activation_digest) + : Predicate.isTagged(request, "CreateAdmissionSubscription") + ? request.email === identity.email && + request.departmentId === identity.department_id + : request.action === + (row.origin === "ReturningAssistant" + ? "ReturningAssistantRegistered" + : "PublicApplicationSubmitted"); + + if (!transactionMatchesCanonicalState || !requestMatchesCanonicalState) { + yield* quarantine(row.effect_id, "InvalidPublicApplicationEffectAuthority"); + + return undefined; + } + + return { + effectId: row.effect_id, + commandId: row.command_id, + ordinal: row.ordinal, + attempts: row.attempts, + claimId, + request, + }; + }), + ) + .pipe( + Effect.catchTag("SqlError", () => + Effect.fail(persistenceError("claim application outbox")), + ), + ); + }), +); export const completePublicApplicationOutbox = ( claim: ClaimedPublicApplicationOutbox, @@ -287,13 +310,26 @@ export const completePublicApplicationOutbox = ( Effect.catchTag("SqlError", () => Effect.fail(persistenceError("complete application outbox"))), ); -export const failPublicApplicationOutbox = ( - claim: ClaimedPublicApplicationOutbox, - failureTag: string, -): Effect.Effect => - Database.use((sql) => markOutboxFailed(sql, applicationOutbox, claim, failureTag)).pipe( - Effect.catchTag("SqlError", () => Effect.fail(persistenceError("fail application outbox"))), - ); +export const failPublicApplicationOutbox: { + ( + failureTag: string, + ): ( + claim: ClaimedPublicApplicationOutbox, + ) => Effect.Effect; + ( + claim: ClaimedPublicApplicationOutbox, + failureTag: string, + ): Effect.Effect; +} = dual( + 2, + ( + claim: ClaimedPublicApplicationOutbox, + failureTag: string, + ): Effect.Effect => + Database.use((sql) => markOutboxFailed(sql, applicationOutbox, claim, failureTag)).pipe( + Effect.catchTag("SqlError", () => Effect.fail(persistenceError("fail application outbox"))), + ), +); export const releasePublicApplicationOutbox = ( claim: ClaimedPublicApplicationOutbox, @@ -318,44 +354,68 @@ export const recoverAllStalePublicApplicationOutbox = ( ), ); -export const deliverNextPublicApplicationOutbox = ( - claimId: string, - claimedAt: string, - interpreter: PublicApplicationEffectInterpreter, -): Effect.Effect< - PublicApplicationOutboxDeliveryResult, - PublicApplicationPersistenceError, - Database -> => - Effect.acquireUseRelease( - claimNextPublicApplicationOutbox(claimId, claimedAt), - ( - claim, - ): Effect.Effect< - PublicApplicationOutboxDeliveryResult, - PublicApplicationPersistenceError | OutboxClaimLost, - Database - > => { - if (claim === undefined) return Effect.succeed(PublicApplicationOutboxDeliveryResult.Idle()); - - return interpreter.deliver(claim.request, claim.ordinal, claim.attempts).pipe( - Effect.matchEffect({ - onFailure: (failure) => - failPublicApplicationOutbox(claim, failure._tag).pipe( - Effect.as( - PublicApplicationOutboxDeliveryResult.Failed({ claim, failureTag: failure._tag }), +export const deliverNextPublicApplicationOutbox: { + ( + claimedAt: string, + interpreter: PublicApplicationEffectInterpreter, + ): ( + claimId: string, + ) => Effect.Effect< + PublicApplicationOutboxDeliveryResult, + PublicApplicationPersistenceError, + Database + >; + ( + claimId: string, + claimedAt: string, + interpreter: PublicApplicationEffectInterpreter, + ): Effect.Effect< + PublicApplicationOutboxDeliveryResult, + PublicApplicationPersistenceError, + Database + >; +} = dual( + 3, + ( + claimId: string, + claimedAt: string, + interpreter: PublicApplicationEffectInterpreter, + ): Effect.Effect< + PublicApplicationOutboxDeliveryResult, + PublicApplicationPersistenceError, + Database + > => + Effect.acquireUseRelease( + claimNextPublicApplicationOutbox(claimId, claimedAt), + ( + claim, + ): Effect.Effect< + PublicApplicationOutboxDeliveryResult, + PublicApplicationPersistenceError | OutboxClaimLost, + Database + > => { + if (claim === undefined) + return Effect.succeed(PublicApplicationOutboxDeliveryResult.Idle()); + + return interpreter.deliver(claim.request, claim.ordinal, claim.attempts).pipe( + Effect.matchEffect({ + onFailure: (failure) => + failPublicApplicationOutbox(claim, failure._tag).pipe( + Effect.as( + PublicApplicationOutboxDeliveryResult.Failed({ claim, failureTag: failure._tag }), + ), ), - ), - onSuccess: (evidence) => - completePublicApplicationOutbox(claim).pipe( - Effect.as(PublicApplicationOutboxDeliveryResult.Delivered({ claim, evidence })), - ), - }), - ); - }, - (claim) => (claim === undefined ? Effect.void : releasePublicApplicationOutbox(claim)), - ).pipe( - Effect.catchTag("OutboxClaimLost", ({ effectId }) => - Effect.succeed(PublicApplicationOutboxDeliveryResult.ClaimLost({ effectId })), + onSuccess: (evidence) => + completePublicApplicationOutbox(claim).pipe( + Effect.as(PublicApplicationOutboxDeliveryResult.Delivered({ claim, evidence })), + ), + }), + ); + }, + (claim) => (claim === undefined ? Effect.void : releasePublicApplicationOutbox(claim)), + ).pipe( + Effect.catchTag("OutboxClaimLost", ({ effectId }) => + Effect.succeed(PublicApplicationOutboxDeliveryResult.ClaimLost({ effectId })), + ), ), - ); +); diff --git a/packages/database/src/application/postgres.test.ts b/packages/database/src/application/postgres.test.ts index 5bfd6857d..39876b8f0 100644 --- a/packages/database/src/application/postgres.test.ts +++ b/packages/database/src/application/postgres.test.ts @@ -11,29 +11,27 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })((it) => "derives catalog validators from persisted revisions and admission boundaries", () => Effect.gen(function* () { - const observed = yield* Effect.gen(function* () { - const sql = yield* Database; - yield* sql`INSERT INTO admission_period_departments (department_id, name, revision) + const sql = yield* Database; + yield* sql`INSERT INTO admission_period_departments (department_id, name, revision) VALUES ('department-1', 'Realfag', 13)`; - yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at, revision) + yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at, revision) VALUES ('semester-1', '2031-01-01T00:00:00.000Z', '2031-07-01T00:00:00.000Z', 11)`; - yield* sql`INSERT INTO admission_periods + yield* sql`INSERT INTO admission_periods (admission_period_id, department_id, semester_id, start_at, end_at, revision, last_command_id) VALUES ('period-1', 'department-1', 'semester-1', '2031-01-01T00:00:00.000Z', '2031-02-01T00:00:00.000Z', 7, 'seed')`; - yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name, active, revision) + yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name, active, revision) VALUES ('field-1', 'department-1', 'Matematikk', TRUE, 17)`; - const before = yield* listPublicApplicationCatalog({ now: "2031-01-15T12:00:00.000Z" }); + const before = yield* listPublicApplicationCatalog({ now: "2031-01-15T12:00:00.000Z" }); - const unchanged = yield* listPublicApplicationCatalog({ - now: "2031-01-15T12:00:00.000Z", - }); + const unchanged = yield* listPublicApplicationCatalog({ + now: "2031-01-15T12:00:00.000Z", + }); - yield* sql`UPDATE admission_period_fields_of_study SET revision = revision + 1 WHERE field_of_study_id = 'field-1'`; - const revised = yield* listPublicApplicationCatalog({ now: "2031-01-15T12:00:00.000Z" }); - const closed = yield* listPublicApplicationCatalog({ now: "2031-02-01T00:00:00.000Z" }); + yield* sql`UPDATE admission_period_fields_of_study SET revision = revision + 1 WHERE field_of_study_id = 'field-1'`; + const revised = yield* listPublicApplicationCatalog({ now: "2031-01-15T12:00:00.000Z" }); + const closed = yield* listPublicApplicationCatalog({ now: "2031-02-01T00:00:00.000Z" }); - return { before, unchanged, revised, closed }; - }); + const observed = { before, unchanged, revised, closed }; expect(observed.before.catalog).toEqual({ departments: [ diff --git a/packages/database/src/application/postgres.ts b/packages/database/src/application/postgres.ts index 42fe481f7..8ca6dd4f0 100644 --- a/packages/database/src/application/postgres.ts +++ b/packages/database/src/application/postgres.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { canonicalJsonValue } from "@vektorprogrammet/domain/shared-kernel"; import { AdvisoryLockKey, lockAdvisory } from "../advisory-lock.js"; import { Database, type DatabaseOperations } from "../service.js"; @@ -131,7 +132,7 @@ const persistenceError = ( operation: string, cause?: SqlError, ): PublicApplicationPersistenceError => { - const error = new PublicApplicationPersistenceError({ + const error = PublicApplicationPersistenceError.make({ operation, message: "public application persistence failed", }); @@ -225,7 +226,7 @@ const findEligiblePeriod = ( > => { if (rows.length > 1) { return Effect.fail( - new AmbiguousAdmissionPeriod({ departmentId: DepartmentId.make(departmentId) }), + AmbiguousAdmissionPeriod.make({ departmentId: DepartmentId.make(departmentId) }), ); } @@ -492,7 +493,7 @@ const executeCommandInTransaction = ( if (stored !== undefined) { if (stored.command_sha256 !== commandDigest) { - return yield* new DuplicatePublicApplicationCommandConflict({ + return yield* DuplicatePublicApplicationCommandConflict.make({ commandId: command.commandId, }); } @@ -510,30 +511,32 @@ const executeCommandInTransaction = ( ); if (!(yield* departmentExists(sql, command.departmentId))) { - return yield* new PublicApplicationDepartmentNotFound({ departmentId: command.departmentId }); + return yield* PublicApplicationDepartmentNotFound.make({ + departmentId: command.departmentId, + }); } const period = yield* findEligiblePeriod(sql, command.departmentId, now); if (period === undefined) { - return yield* new NoEligibleAdmissionPeriod({ departmentId: command.departmentId }); + return yield* NoEligibleAdmissionPeriod.make({ departmentId: command.departmentId }); } const field = yield* findFieldOfStudy(sql, command.fieldOfStudyId); if (field === undefined) { - return yield* new FieldOfStudyNotFound({ fieldOfStudyId: command.fieldOfStudyId }); + return yield* FieldOfStudyNotFound.make({ fieldOfStudyId: command.fieldOfStudyId }); } if (field.departmentId !== command.departmentId) { - return yield* new FieldOfStudyDepartmentMismatch({ + return yield* FieldOfStudyDepartmentMismatch.make({ fieldOfStudyId: command.fieldOfStudyId, departmentId: command.departmentId, }); } if (!field.active) { - return yield* new FieldOfStudyInactive({ fieldOfStudyId: command.fieldOfStudyId }); + return yield* FieldOfStudyInactive.make({ fieldOfStudyId: command.fieldOfStudyId }); } const existingApplicant = yield* findApplicantForUpdate(sql, normalizedEmail); @@ -548,8 +551,8 @@ const executeCommandInTransaction = ( ? yield* Schema.decodeEffect(PublicApplicationActivationTokenSchema)( context.activationToken, ).pipe( - Effect.mapError( - () => new PublicApplicationDecodeError({ message: "invalid activation token" }), + Effect.mapError(() => + PublicApplicationDecodeError.make({ message: "invalid activation token" }), ), ) : undefined; @@ -576,7 +579,7 @@ const executeCommandInTransaction = ( "read duplicate application", ); - if (duplicate !== undefined) return yield* new DuplicatePublicApplication(); + if (duplicate !== undefined) return yield* DuplicatePublicApplication.make(); const applicationId = context.applicationId ?? publicApplicationIdForCommand(command); @@ -586,7 +589,7 @@ const executeCommandInTransaction = ( "read application identity", ); - if (collidingApplication !== undefined) return yield* new DuplicatePublicApplication(); + if (collidingApplication !== undefined) return yield* DuplicatePublicApplication.make(); if (existingApplicant === undefined) yield* writeApplicant(sql, applicant); else yield* updateApplicant(sql, applicant); @@ -628,29 +631,55 @@ const executeCommandInTransaction = ( return { observation, replayed: false, outboxCount: requests.length }; }); -export const executePublicApplicationCommand = ( - input: typeof PublicApplicationSubmitInputSchema.Encoded, - context: PublicApplicationSubmitContext, -): Effect.Effect => - Effect.gen(function* () { - const command = yield* decodeSubmitPublicApplicationCommand(input); - const now = yield* decodePublicApplicationNow(context.now); - const sql = yield* Database; - - return yield* sql - .withTransaction(executeCommandInTransaction(command, context, sql, now)) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("public application transaction", cause)), - ), - ); - }); +export const executePublicApplicationCommand: { + ( + context: PublicApplicationSubmitContext, + ): ( + input: typeof PublicApplicationSubmitInputSchema.Encoded, + ) => Effect.Effect; + ( + input: typeof PublicApplicationSubmitInputSchema.Encoded, + context: PublicApplicationSubmitContext, + ): Effect.Effect; +} = dual( + 2, + ( + input: typeof PublicApplicationSubmitInputSchema.Encoded, + context: PublicApplicationSubmitContext, + ): Effect.Effect => + Effect.gen(function* () { + const command = yield* decodeSubmitPublicApplicationCommand(input); + const now = yield* decodePublicApplicationNow(context.now); + const sql = yield* Database; + + return yield* sql + .withTransaction(executeCommandInTransaction(command, context, sql, now)) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("public application transaction", cause)), + ), + ); + }), +); -export const submitPublicApplication = ( - input: typeof PublicApplicationSubmitInputSchema.Encoded, - context: PublicApplicationSubmitContext, -): Effect.Effect => - executePublicApplicationCommand(input, context); +export const submitPublicApplication: { + ( + context: PublicApplicationSubmitContext, + ): ( + input: typeof PublicApplicationSubmitInputSchema.Encoded, + ) => Effect.Effect; + ( + input: typeof PublicApplicationSubmitInputSchema.Encoded, + context: PublicApplicationSubmitContext, + ): Effect.Effect; +} = dual( + 2, + ( + input: typeof PublicApplicationSubmitInputSchema.Encoded, + context: PublicApplicationSubmitContext, + ): Effect.Effect => + executePublicApplicationCommand(input, context), +); export const listPublicApplicationCatalog = ( context: PublicApplicationCatalogContext, @@ -794,8 +823,8 @@ export const findPublicApplicationConfirmation = ( const normalizedId = yield* Schema.decodeEffect(PublicApplicationIdSchema)( applicationId.trim(), ).pipe( - Effect.mapError( - () => new PublicApplicationDecodeError({ message: "invalid application identifier" }), + Effect.mapError(() => + PublicApplicationDecodeError.make({ message: "invalid application identifier" }), ), ); @@ -812,7 +841,7 @@ export const findPublicApplicationConfirmation = ( ); if (rows[0] === undefined) { - return yield* new PublicApplicationNotFound({ applicationId: normalizedId }); + return yield* PublicApplicationNotFound.make({ applicationId: normalizedId }); } // The row only proves that the application exists; its identity is the decoded one. @@ -832,7 +861,7 @@ export const readApplicantContacts = ( > => Effect.gen(function* () { if (applicationIds.length > ADMISSIONS_APPLICANT_CONTACT_READ_LIMIT) { - return yield* new PublicApplicationQueryLimitExceeded({ + return yield* PublicApplicationQueryLimitExceeded.make({ limit: ADMISSIONS_APPLICANT_CONTACT_READ_LIMIT, }); } @@ -841,8 +870,8 @@ export const readApplicantContacts = ( applicationIds, { onExcessProperty: "error" }, ).pipe( - Effect.mapError( - () => new PublicApplicationDecodeError({ message: "invalid application identifier batch" }), + Effect.mapError(() => + PublicApplicationDecodeError.make({ message: "invalid application identifier batch" }), ), ); @@ -882,16 +911,15 @@ export const readApplicantContacts = ( const contact = yield* Schema.decodeEffect(ApplicantContactProjectionSchema)(row, { onExcessProperty: "error", }).pipe( - Effect.mapError( - () => - new PublicApplicationDecodeError({ - message: "invalid persisted applicant contact projection", - }), + Effect.mapError(() => + PublicApplicationDecodeError.make({ + message: "invalid persisted applicant contact projection", + }), ), ); if (byApplicationId.has(contact.applicationId)) { - return yield* new PublicApplicationDecodeError({ + return yield* PublicApplicationDecodeError.make({ message: "duplicate persisted applicant contact projection", }); } @@ -905,7 +933,7 @@ export const readApplicantContacts = ( const contact = byApplicationId.get(applicationId); if (contact === undefined) { - return yield* new PublicApplicationNotFound({ applicationId }); + return yield* PublicApplicationNotFound.make({ applicationId }); } contacts.push(contact); @@ -918,14 +946,26 @@ export const readApplicantContacts = ( * Derives the current-semester applicant-owned progress projection from canonical source facts. * Person custody is explicit through applicant_account_links; contact equality is never consulted. */ -export const readApplicantProgress = ( - personId: string, - now: string, -): Effect.Effect => - Effect.gen(function* () { - const sql = yield* Database; - - const rows = yield* sql` +export const readApplicantProgress: { + ( + now: string, + ): ( + personId: string, + ) => Effect.Effect; + ( + personId: string, + now: string, + ): Effect.Effect; +} = dual( + 2, + ( + personId: string, + now: string, + ): Effect.Effect => + Effect.gen(function* () { + const sql = yield* Database; + + const rows = yield* sql` SELECT application.application_id AS "applicationId", application.admission_period_id AS "admissionPeriodId", @@ -997,77 +1037,78 @@ export const readApplicantProgress = ( AND ${now}::timestamptz < semester.end_at ORDER BY application.submitted_at DESC, application.application_id ASC `.pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("read applicant progress", cause)), - ), - ); - - const applications: Array = []; + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("read applicant progress", cause)), + ), + ); - for (const row of rows) { - if (row.hasConduct && row.hasCancellation) { - return yield* persistenceError("read inconsistent applicant interview lifecycle"); - } + const applications: Array = []; - let progress: typeof ApplicantProgressStateSchema.Encoded; + for (const row of rows) { + if (row.hasConduct && row.hasCancellation) { + return yield* persistenceError("read inconsistent applicant interview lifecycle"); + } - if (row.hasActivePlacement) { - progress = ApplicantProgressStateSchema.cases.AssignedToSchool.make({}); - } else if (row.hasConduct || row.hasReturningRegistration) { - if (row.affiliationStatus === "Active") { - progress = ApplicantProgressStateSchema.cases.AffiliationActive.make({}); - } else if (row.affiliationStatus === "Pending") { - progress = ApplicantProgressStateSchema.cases.AffiliationPending.make({}); + let progress: typeof ApplicantProgressStateSchema.Encoded; + + if (row.hasActivePlacement) { + progress = ApplicantProgressStateSchema.cases.AssignedToSchool.make({}); + } else if (row.hasConduct || row.hasReturningRegistration) { + if (row.affiliationStatus === "Active") { + progress = ApplicantProgressStateSchema.cases.AffiliationActive.make({}); + } else if (row.affiliationStatus === "Pending") { + progress = ApplicantProgressStateSchema.cases.AffiliationPending.make({}); + } else { + progress = row.hasConduct + ? ApplicantProgressStateSchema.cases.InterviewCompleted.make({}) + : ApplicantProgressStateSchema.cases.ReturningRegistrationCompleted.make({}); + } + } else if (row.hasCancellation || row.responseState === "Rejected") { + progress = ApplicantProgressStateSchema.cases.Cancelled.make({}); + } else if (row.responseState === "RequestedNewTime") { + progress = ApplicantProgressStateSchema.cases.AwaitingNewInterviewTime.make({}); + } else if (row.responseState === "Accepted" || row.responseState === "Pending") { + if (row.scheduledAt === null || row.room === null) { + return yield* persistenceError("read applicant invitation without schedule"); + } + + const schedule = { + scheduledAt: row.scheduledAt, + room: row.room, + campus: row.campus, + mapLink: row.mapLink, + }; + + progress = yield* row.responseState === "Accepted" + ? ApplicantProgressStateSchema.cases.InterviewAccepted.makeEffect({ schedule }).pipe( + Effect.mapError(() => persistenceError("decode applicant progress projection")), + ) + : ApplicantProgressStateSchema.cases.InvitedToInterview.makeEffect({ schedule }).pipe( + Effect.mapError(() => persistenceError("decode applicant progress projection")), + ); + } else if (row.responseState === null) { + progress = ApplicantProgressStateSchema.cases.ApplicationReceived.make({}); } else { - progress = row.hasConduct - ? ApplicantProgressStateSchema.cases.InterviewCompleted.make({}) - : ApplicantProgressStateSchema.cases.ReturningRegistrationCompleted.make({}); - } - } else if (row.hasCancellation || row.responseState === "Rejected") { - progress = ApplicantProgressStateSchema.cases.Cancelled.make({}); - } else if (row.responseState === "RequestedNewTime") { - progress = ApplicantProgressStateSchema.cases.AwaitingNewInterviewTime.make({}); - } else if (row.responseState === "Accepted" || row.responseState === "Pending") { - if (row.scheduledAt === null || row.room === null) { - return yield* persistenceError("read applicant invitation without schedule"); + return yield* persistenceError("read unknown applicant invitation response state"); } - const schedule = { - scheduledAt: row.scheduledAt, - room: row.room, - campus: row.campus, - mapLink: row.mapLink, - }; - - progress = yield* row.responseState === "Accepted" - ? ApplicantProgressStateSchema.cases.InterviewAccepted.makeEffect({ schedule }).pipe( - Effect.mapError(() => persistenceError("decode applicant progress projection")), - ) - : ApplicantProgressStateSchema.cases.InvitedToInterview.makeEffect({ schedule }).pipe( - Effect.mapError(() => persistenceError("decode applicant progress projection")), - ); - } else if (row.responseState === null) { - progress = ApplicantProgressStateSchema.cases.ApplicationReceived.make({}); - } else { - return yield* persistenceError("read unknown applicant invitation response state"); + applications.push({ + applicationId: row.applicationId, + admissionPeriodId: row.admissionPeriodId, + departmentId: row.departmentId, + departmentName: row.departmentName, + semesterId: row.semesterId, + submittedAt: row.submittedAt, + progress, + }); } - applications.push({ - applicationId: row.applicationId, - admissionPeriodId: row.admissionPeriodId, - departmentId: row.departmentId, - departmentName: row.departmentName, - semesterId: row.semesterId, - submittedAt: row.submittedAt, - progress, - }); - } - - return yield* Schema.decodeEffect(ApplicantProgressResponseSchema)( - { personId, observedAt: now, applications }, - { onExcessProperty: "error" }, - ).pipe(Effect.mapError(() => persistenceError("decode applicant progress projection"))); - }); + return yield* Schema.decodeEffect(ApplicantProgressResponseSchema)( + { personId, observedAt: now, applications }, + { onExcessProperty: "error" }, + ).pipe(Effect.mapError(() => persistenceError("decode applicant progress projection"))); + }), +); export const decodePublicApplicationCommand = decodeSubmitPublicApplicationCommand; diff --git a/packages/database/src/application/returning-postgres.ts b/packages/database/src/application/returning-postgres.ts index c9df35937..095b8639d 100644 --- a/packages/database/src/application/returning-postgres.ts +++ b/packages/database/src/application/returning-postgres.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { flow, Predicate, Effect, Schema } from "effect"; import { AdvisoryLockKey, lockAdvisory } from "../advisory-lock.js"; import { Database, type DatabaseOperations } from "../service.js"; @@ -335,11 +336,23 @@ export const readReturningAssistantOptions = (context: RegistrationContext) => ), ); -export const preflightReturningAssistantRegistration = ( +const preflightReturningAssistantRegistrationImpl = ( input: Pick, context: RegistrationContext, ) => Database.use((sql) => validateRegistrationEligibility(sql, input, context)); +export const preflightReturningAssistantRegistration: { + ( + context: RegistrationContext, + ): ( + input: Pick, + ) => ReturnType; + ( + input: Pick, + context: RegistrationContext, + ): ReturnType; +} = dual(2, preflightReturningAssistantRegistrationImpl); + const findReceipt = (sql: DatabaseOperations, commandId: string) => sql`SELECT command_sha256, observation_json FROM public.admission_returning_command_receipts WHERE command_id=${commandId} FOR UPDATE`; @@ -522,14 +535,14 @@ const registerInTransaction = ( ), ); -export const registerReturningAssistant = ( +const registerReturningAssistantImpl = ( input: ReturningAssistantRegistrationInput, context: RegistrationContext, ) => Effect.gen(function* () { const command = yield* decodeInput(input); - if (!context.personId) return yield* new ReturningAssistantIdentityMissing(); + if (context.personId === "") return yield* new ReturningAssistantIdentityMissing(); return yield* Database.use((sql) => sql.withTransaction(registerInTransaction(command, context, sql)), @@ -539,3 +552,15 @@ export const registerReturningAssistant = ( Effect.fail(fail("returning registration transaction", cause)), ), ); + +export const registerReturningAssistant: { + ( + context: RegistrationContext, + ): ( + input: ReturningAssistantRegistrationInput, + ) => ReturnType; + ( + input: ReturningAssistantRegistrationInput, + context: RegistrationContext, + ): ReturnType; +} = dual(2, registerReturningAssistantImpl); diff --git a/packages/database/src/auth-engine.ts b/packages/database/src/auth-engine.ts index dae5f8f95..10866fded 100644 --- a/packages/database/src/auth-engine.ts +++ b/packages/database/src/auth-engine.ts @@ -25,6 +25,7 @@ import { Predicate, type Scope, } from "effect"; +import { dual } from "effect/Function"; /** Runs the Effect program behind one Better Auth callback as the Promise Better Auth awaits. */ export type BetterAuthCallbackRunner = (program: Effect.Effect) => Promise; @@ -110,7 +111,7 @@ const makeCredentialLifecycleHooks = (database: Pool, run: BetterAuthCallbackRun ["reset-password:" + (ctx.body?.token ?? "")], )).rows[0]; - if (!access) { + if (access === undefined) { return yield* Effect.fail( new APIError("BAD_REQUEST", { code: "INVALID_TOKEN", message: "Invalid token" }), ); @@ -122,7 +123,7 @@ const makeCredentialLifecycleHooks = (database: Pool, run: BetterAuthCallbackRun [ctx.body?.email ?? ""], )).rows[0]; - if (!access) { + if (access === undefined) { return yield* Effect.fail( new APIError("UNAUTHORIZED", { code: "INVALID_EMAIL_OR_PASSWORD", @@ -142,7 +143,11 @@ const makeCredentialLifecycleHooks = (database: Pool, run: BetterAuthCallbackRun )).rows[0]; // Sign-out must clear the cookies of a revoked or expired session, never refuse them. - if (!access && ctx.path !== "/request-password-reset" && ctx.path !== "/sign-out") { + if ( + access === undefined && + ctx.path !== "/request-password-reset" && + ctx.path !== "/sign-out" + ) { // better-call's `json` answers synchronously, although its type is a Promise. if (ctx.path === "/get-session") { return yield* Effect.promise(() => Promise.resolve(ctx.json(null))); @@ -193,11 +198,9 @@ const makeCredentialLifecycleHooks = (database: Pool, run: BetterAuthCallbackRun const result = ctx.context.returned; if ( - result && - (result === null || Predicate.isObjectOrArray(result)) && + Predicate.isObjectOrArray(result) && "session" in result && - result.session && - (result.session === null || Predicate.isObjectOrArray(result.session)) && + Predicate.isObjectOrArray(result.session) && "id" in result.session ) { const usable = yield* pgQuery( @@ -222,16 +225,15 @@ const makeCredentialLifecycleHooks = (database: Pool, run: BetterAuthCallbackRun const returned = ctx.context.returned; if ( - !evidence?.hash || - !session || + evidence?.hash === undefined || + evidence.hash === "" || + session === null || returned instanceof APIError || - !returned || - !(returned === null || Predicate.isObjectOrArray(returned)) || + !Predicate.isObjectOrArray(returned) || !("token" in returned) || returned.token !== session.session.token || !("user" in returned) || - !returned.user || - !(returned.user === null || Predicate.isObjectOrArray(returned.user)) || + !Predicate.isObjectOrArray(returned.user) || !("id" in returned.user) || returned.user.id !== session.user.id ) @@ -289,7 +291,7 @@ const makeCredentialLifecycleHooks = (database: Pool, run: BetterAuthCallbackRun ], ); - if (!result.rows[0]?.accepted) { + if (result.rows[0]?.accepted !== true) { return yield* Effect.fail( new APIError("UNAUTHORIZED", { code: "INVALID_EMAIL_OR_PASSWORD", @@ -338,7 +340,7 @@ const makeAccessDatabaseHooks = ( Effect.gen(function* () { const evidence = Option.getOrUndefined(decodeNativeAccessEvidence(context?.context)); - if (evidence && (personId === undefined || personId === evidence.personId)) + if (evidence !== undefined && (personId === undefined || personId === evidence.personId)) return evidence.revision; // Internal adapter provisioning has no endpoint context. HTTP and direct API @@ -350,7 +352,7 @@ const makeAccessDatabaseHooks = ( [personId], )).rows[0]; - if (row) return row.revision; + if (row !== undefined) return row.revision; } return yield* Effect.fail( @@ -415,7 +417,7 @@ const makeAccessDatabaseHooks = ( }; }; -export const makeAuthEngineOptions = ( +const authEngineOptions = ( config: AuthEngineConfig, database: Pool, run: BetterAuthCallbackRunner, @@ -496,17 +498,47 @@ export const makeAuthEngineOptions = ( }; }; -export const makeAuthEngine = ( +type AuthEngineOptions = ReturnType; + +export const makeAuthEngineOptions: { + ( + database: Pool, + run: BetterAuthCallbackRunner, + recovery?: PasswordRecovery["Service"], + ): (config: AuthEngineConfig) => AuthEngineOptions; + ( + config: AuthEngineConfig, + database: Pool, + run: BetterAuthCallbackRunner, + recovery?: PasswordRecovery["Service"], + ): AuthEngineOptions; +} = dual((args) => Predicate.isFunction(args[2]), authEngineOptions); + +const authEngine = ( config: AuthEngineConfig, database: Pool, run: BetterAuthCallbackRunner, recovery?: PasswordRecovery["Service"], -) => betterAuth(makeAuthEngineOptions(config, database, run, recovery)); - -export type AuthEngine = ReturnType; +) => betterAuth(authEngineOptions(config, database, run, recovery)); + +export type AuthEngine = ReturnType; + +export const makeAuthEngine: { + ( + database: Pool, + run: BetterAuthCallbackRunner, + recovery?: PasswordRecovery["Service"], + ): (config: AuthEngineConfig) => AuthEngine; + ( + config: AuthEngineConfig, + database: Pool, + run: BetterAuthCallbackRunner, + recovery?: PasswordRecovery["Service"], + ): AuthEngine; +} = dual((args) => Predicate.isFunction(args[2]), authEngine); export class NativeAuthEngine extends Context.Service()( - "@vektorprogrammet/database/NativeAuthEngine", + "@vektorprogrammet/database/auth-engine/NativeAuthEngine", ) {} export const NativeAuthEngineLive = (config: AuthEngineConfig) => diff --git a/packages/database/src/auth-live.test.ts b/packages/database/src/auth-live.test.ts index d76ed034d..d1ee3e4ae 100644 --- a/packages/database/src/auth-live.test.ts +++ b/packages/database/src/auth-live.test.ts @@ -74,7 +74,7 @@ const otherCohort = { password: "AuthLiveTest!other-password-0054", } as const; -const requestContext = new IdentityRequestContext({ +const requestContext = IdentityRequestContext.make({ requestCorrelation: "auth-live-test", sourceIp: "127.0.0.1", userAgent: "auth-live-test", @@ -310,7 +310,7 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( "revokes owned persisted sessions and commits the audit together", () => Effect.gen(function* () { - const actor = new IdentityActor({ + const actor = IdentityActor.make({ personId: PersonId.make("snapshot-mutation-person"), sessionId: "snapshot-mutation-session", expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), @@ -369,7 +369,7 @@ describe("audited Better Auth response ordering", () => { Effect.gen(function* () { const ordering: string[] = []; - const actor = new IdentityActor({ + const actor = IdentityActor.make({ personId: PersonId.make("audit-ordering-person"), sessionId: "audit-ordering-session", expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), @@ -409,7 +409,7 @@ describe("audited Better Auth response ordering", () => { }); return Effect.fail( - new IdentityEngineError({ + IdentityEngineError.make({ operation: "recordSecurityEvent", message: "injected audit append failure", }), @@ -554,7 +554,7 @@ dsl("AuthLive (spec 0054)", () => { ); const context = (requestCorrelation: string) => - new IdentityRequestContext({ + IdentityRequestContext.make({ requestCorrelation, sourceIp: "127.0.0.1", userAgent: "auth-live-hardening-test", diff --git a/packages/database/src/auth-live.ts b/packages/database/src/auth-live.ts index 3fba4efb9..826cc8a2b 100644 --- a/packages/database/src/auth-live.ts +++ b/packages/database/src/auth-live.ts @@ -6,13 +6,12 @@ import { canonicalJsonValue } from "@vektorprogrammet/domain/shared-kernel"; import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; import { getSessionCookie } from "better-auth/cookies"; import { Context, Effect, Layer, Schema } from "effect"; +import { dual } from "effect/Function"; import type { Pool, PoolClient, QueryResultRow } from "pg"; import type { AuthorizationInstant } from "@vektorprogrammet/domain/authz"; import { ServicePrincipalGrantAuthority } from "@vektorprogrammet/domain/authz"; import { Database } from "./service.js"; import { - decodeIdentityActor, - decodeIdentitySession, Identity, IdentityEngineError, IdentityInvalidCredentials, @@ -21,7 +20,8 @@ import { IdentitySecurityEvent, IdentitySecurityEventDetails, IdentitySessionNotFound, - type IdentityActor, + IdentityActor, + IdentitySession, type IdentityRequestContext, type IdentitySessionId, type IdentitySessionMutationSuccess, @@ -30,6 +30,11 @@ import { } from "@vektorprogrammet/domain/identity"; import { DatabasePgPool, pgQuery, pgTransaction } from "./pg-pool.js"; +/** Decodes an engine-native actor row into the canonical domain shape. */ +const decodeIdentityActor = Schema.decodeUnknownEffect(IdentityActor); + +const decodeIdentitySession = Schema.decodeUnknownEffect(IdentitySession); + import { type AuthEngineConfig } from "./auth-engine.js"; import { exactRedirectAccepted, @@ -69,7 +74,7 @@ export interface AuthEngineService { } export class AuthEngine extends Context.Service()( - "@vektorprogrammet/database/AuthEngine", + "@vektorprogrammet/database/auth-live/AuthEngine", ) {} export interface IdentitySnapshotService { @@ -120,7 +125,7 @@ export interface IdentitySnapshotService { * @effect-expect-leaking Database */ export class IdentitySnapshot extends Context.Service()( - "@vektorprogrammet/database/IdentitySnapshot", + "@vektorprogrammet/database/auth-live/IdentitySnapshot", ) {} interface SessionRow extends QueryResultRow { @@ -183,7 +188,7 @@ const auditEvent = (input: { readonly context: IdentityRequestContext | null; readonly details: IdentitySecurityEventDetails; }): IdentitySecurityEvent => - new IdentitySecurityEvent({ + IdentitySecurityEvent.make({ eventKind: input.eventKind, subjectPersonId: input.subjectPersonId, sessionId: input.sessionId, @@ -238,7 +243,7 @@ const appendAudit = (database: Pool | PoolClient, unsafeEvent: IdentitySecurityE const engineFailure = (operation: string, cause: unknown): IdentityEngineError => Schema.is(IdentityEngineError)(cause) ? cause - : new IdentityEngineError({ + : IdentityEngineError.make({ operation, message: cause instanceof Error ? cause.message : "identity persistence failure", }); @@ -310,7 +315,7 @@ const snapshotMutationAudit = ( subjectPersonId: actor.personId, sessionId: input.sessionId, context: request, - details: new IdentitySecurityEventDetails({ + details: IdentitySecurityEventDetails.make({ outcomeCode: input.outcomeCode, affectedSessionCount: input.affectedSessionCount, }), @@ -323,7 +328,7 @@ export const makeIdentitySnapshotService = (config: AuthEngineConfig): IdentityS Effect.gen(function* () { const token = verifiedBetterAuthSessionToken(cookieHeader, config); - if (token === null) return yield* new IdentitySessionNotFound(); + if (token === null) return yield* IdentitySessionNotFound.make(); const sql = yield* Database; const rows = yield* sql` @@ -336,7 +341,7 @@ export const makeIdentitySnapshotService = (config: AuthEngineConfig): IdentityS const row = rows[0]; - if (row === undefined) return yield* new IdentitySessionNotFound(); + if (row === undefined) return yield* IdentitySessionNotFound.make(); return yield* decodeIdentityActor(row).pipe( Effect.mapError((cause) => engineFailure("decodeSnapshotSession", cause)), @@ -356,7 +361,7 @@ export const makeIdentitySnapshotService = (config: AuthEngineConfig): IdentityS RETURNING "id" AS "sessionId" `; - if (deleted.length !== 1) return yield* new IdentitySessionNotFound(); + if (deleted.length !== 1) return yield* IdentitySessionNotFound.make(); yield* snapshotMutationAudit(actor, request, { eventKind: "sign-out", sessionId: actor.sessionId, @@ -380,7 +385,7 @@ export const makeIdentitySnapshotService = (config: AuthEngineConfig): IdentityS RETURNING "id" AS "sessionId" `; - if (deleted.length !== 1) return yield* new IdentityOwnedSessionNotFound({ sessionId }); + if (deleted.length !== 1) return yield* IdentityOwnedSessionNotFound.make({ sessionId }); yield* snapshotMutationAudit(actor, request, { eventKind: "session-revoked-one", sessionId, @@ -429,7 +434,7 @@ export const makeIdentitySnapshotService = (config: AuthEngineConfig): IdentityS RETURNING "id" AS "sessionId" `; - if (deleted.length === 0) return yield* new IdentitySessionNotFound(); + if (deleted.length === 0) return yield* IdentitySessionNotFound.make(); yield* snapshotMutationAudit(actor, request, { eventKind: "session-revoked-all", sessionId: actor.sessionId, @@ -458,13 +463,13 @@ const identityOperations = ( catch: (cause) => engineFailure("resolveSession", cause), }); - if (session?.user == null) return yield* new IdentitySessionNotFound(); + if (session?.user == null) return yield* IdentitySessionNotFound.make(); const usable = yield* pgQuery(pool, `SELECT 1 FROM auth.usable_human_sessions WHERE id=$1`, [ session.session.id, ]).pipe(Effect.mapError((cause) => engineFailure("resolveSession", cause))); - if (usable.rowCount !== 1) return yield* new IdentitySessionNotFound(); + if (usable.rowCount !== 1) return yield* IdentitySessionNotFound.make(); return yield* decodeIdentityActor({ personId: session.user.id, @@ -496,11 +501,11 @@ const identityOperations = ( }); if (!result.ok) { - if (result.status === 401) return yield* new IdentityInvalidCredentials(); + if (result.status === 401) return yield* IdentityInvalidCredentials.make(); - if (result.status === 429) return yield* new IdentityRateLimited(); + if (result.status === 429) return yield* IdentityRateLimited.make(); - return yield* new IdentityEngineError({ + return yield* IdentityEngineError.make({ operation: "signIn", message: `authentication provider returned status ${result.status}`, }); @@ -509,7 +514,7 @@ const identityOperations = ( const [setCookie] = result.headers.getSetCookie(); if (setCookie === undefined) { - return yield* new IdentityEngineError({ + return yield* IdentityEngineError.make({ operation: "signIn", message: "sign-in response carried no session cookie", }); @@ -542,7 +547,7 @@ const identityOperations = ( const row = result.rows[0]; - if (row === undefined) return yield* new IdentitySessionNotFound(); + if (row === undefined) return yield* IdentitySessionNotFound.make(); return yield* sessionProjection(row, actor.sessionId); }).pipe(Effect.mapError(keepSessionNotFound("readCurrentSession"))); @@ -587,7 +592,7 @@ const identityOperations = ( [actor.sessionId, actor.personId], ); - if (deleted.rowCount !== 1) return yield* new IdentitySessionNotFound(); + if (deleted.rowCount !== 1) return yield* IdentitySessionNotFound.make(); yield* appendAudit( client, auditEvent({ @@ -596,7 +601,7 @@ const identityOperations = ( subjectPersonId: actor.personId, sessionId: actor.sessionId, context: request, - details: new IdentitySecurityEventDetails({ + details: IdentitySecurityEventDetails.make({ outcomeCode: "current-session-ended", affectedSessionCount: 1, }), @@ -627,7 +632,7 @@ const identityOperations = ( ); if (deleted.rowCount !== 1) { - return yield* new IdentityOwnedSessionNotFound({ sessionId }); + return yield* IdentityOwnedSessionNotFound.make({ sessionId }); } yield* appendAudit( @@ -638,7 +643,7 @@ const identityOperations = ( subjectPersonId: actor.personId, sessionId, context: request, - details: new IdentitySecurityEventDetails({ + details: IdentitySecurityEventDetails.make({ outcomeCode: "owned-session-revoked", affectedSessionCount: 1, }), @@ -683,7 +688,7 @@ const identityOperations = ( subjectPersonId: actor.personId, sessionId: actor.sessionId, context: request, - details: new IdentitySecurityEventDetails({ + details: IdentitySecurityEventDetails.make({ outcomeCode: "other-sessions-revoked", affectedSessionCount: deleted.rowCount ?? deleted.rows.length, }), @@ -711,7 +716,7 @@ const identityOperations = ( [actor.personId], ); - if ((deleted.rowCount ?? 0) === 0) return yield* new IdentitySessionNotFound(); + if ((deleted.rowCount ?? 0) === 0) return yield* IdentitySessionNotFound.make(); yield* appendAudit( client, auditEvent({ @@ -720,7 +725,7 @@ const identityOperations = ( subjectPersonId: actor.personId, sessionId: actor.sessionId, context: request, - details: new IdentitySecurityEventDetails({ + details: IdentitySecurityEventDetails.make({ outcomeCode: "all-sessions-revoked", affectedSessionCount: deleted.rowCount ?? deleted.rows.length, }), @@ -745,112 +750,124 @@ const identityOperations = ( }; /** @internal Exposed only for focused ordering tests around the Better Auth boundary. */ -export const auditedAuthHandler = +export const auditedAuthHandler: { + ( + identity: Pick, + ): ( + handle: (request: Request) => Effect.Effect, + ) => AuthEngineService["handler"]; + ( + handle: (request: Request) => Effect.Effect, + identity: Pick, + ): AuthEngineService["handler"]; +} = dual( + 2, ( handle: (request: Request) => Effect.Effect, identity: Pick, ): AuthEngineService["handler"] => - (request, context) => - Effect.gen(function* () { - const pathname = new URL(request.url).pathname; - - const signOutActor = - request.method === "POST" && pathname === "/api/auth/sign-out" - ? yield* identity - .resolveSession(request.headers.get("cookie") ?? undefined) - .pipe(Effect.orElseSucceed(() => null)) - : null; - - const response = yield* handle(request); - - const audit = Effect.gen(function* () { - if (request.method === "POST" && pathname === "/api/auth/sign-in/email") { - if (response.ok) { - const [setCookie] = response.headers.getSetCookie(); - - if (setCookie === undefined) { - return yield* new IdentityEngineError({ - operation: "auditSignIn", - message: "successful sign-in returned no cookie", - }); + (request, context) => + Effect.gen(function* () { + const pathname = new URL(request.url).pathname; + + const signOutActor = + request.method === "POST" && pathname === "/api/auth/sign-out" + ? yield* identity + .resolveSession(request.headers.get("cookie") ?? undefined) + .pipe(Effect.orElseSucceed(() => null)) + : null; + + const response = yield* handle(request); + + const audit = Effect.gen(function* () { + if (request.method === "POST" && pathname === "/api/auth/sign-in/email") { + if (response.ok) { + const [setCookie] = response.headers.getSetCookie(); + + if (setCookie === undefined) { + return yield* IdentityEngineError.make({ + operation: "auditSignIn", + message: "successful sign-in returned no cookie", + }); + } + + const actor = yield* identity.resolveSession(setCookie.split(";")[0]); + yield* identity.recordSecurityEvent( + auditEvent({ + eventKind: "sign-in-success", + actor, + subjectPersonId: actor.personId, + sessionId: actor.sessionId, + context, + details: IdentitySecurityEventDetails.make({ + outcomeCode: "credential-accepted", + affectedSessionCount: 1, + }), + }), + ); + } else { + yield* identity.recordSecurityEvent( + auditEvent({ + eventKind: "sign-in-failure", + actor: null, + subjectPersonId: null, + sessionId: null, + context, + details: IdentitySecurityEventDetails.make({ + outcomeCode: "credential-rejected", + affectedSessionCount: 0, + }), + }), + ); } - - const actor = yield* identity.resolveSession(setCookie.split(";")[0]); + } else if (request.method === "POST" && pathname === "/api/auth/sign-up/email") { yield* identity.recordSecurityEvent( auditEvent({ - eventKind: "sign-in-success", - actor, - subjectPersonId: actor.personId, - sessionId: actor.sessionId, + eventKind: "sign-up-rejected", + actor: null, + subjectPersonId: null, + sessionId: null, context, - details: new IdentitySecurityEventDetails({ - outcomeCode: "credential-accepted", - affectedSessionCount: 1, + details: IdentitySecurityEventDetails.make({ + outcomeCode: "public-sign-up-disabled", + affectedSessionCount: 0, }), }), ); - } else { + } else if (response.ok && signOutActor !== null) { yield* identity.recordSecurityEvent( auditEvent({ - eventKind: "sign-in-failure", - actor: null, - subjectPersonId: null, - sessionId: null, + eventKind: "sign-out", + actor: signOutActor, + subjectPersonId: signOutActor.personId, + sessionId: signOutActor.sessionId, context, - details: new IdentitySecurityEventDetails({ - outcomeCode: "credential-rejected", - affectedSessionCount: 0, + details: IdentitySecurityEventDetails.make({ + outcomeCode: "current-session-ended", + affectedSessionCount: 1, }), }), ); } - } else if (request.method === "POST" && pathname === "/api/auth/sign-up/email") { - yield* identity.recordSecurityEvent( - auditEvent({ - eventKind: "sign-up-rejected", - actor: null, - subjectPersonId: null, - sessionId: null, - context, - details: new IdentitySecurityEventDetails({ - outcomeCode: "public-sign-up-disabled", - affectedSessionCount: 0, - }), - }), - ); - } else if (response.ok && signOutActor !== null) { - yield* identity.recordSecurityEvent( - auditEvent({ - eventKind: "sign-out", - actor: signOutActor, - subjectPersonId: signOutActor.personId, - sessionId: signOutActor.sessionId, - context, - details: new IdentitySecurityEventDetails({ - outcomeCode: "current-session-ended", - affectedSessionCount: 1, - }), - }), - ); - } - }); + }); - return yield* audit.pipe( - Effect.as(response), - Effect.orElseSucceed(() => - Response.json( - { error: { tag: "IdentityEngineError" } }, - { - status: 503, - headers: { - "cache-control": "no-store", - "content-type": "application/json; charset=utf-8", + return yield* audit.pipe( + Effect.as(response), + Effect.orElseSucceed(() => + Response.json( + { error: { tag: "IdentityEngineError" } }, + { + status: 503, + headers: { + "cache-control": "no-store", + "content-type": "application/json; charset=utf-8", + }, }, - }, + ), ), - ), - ); - }); + ); + }), +); /** * One scoped construction exposes the Better Auth engine, its typed Identity @@ -919,7 +936,7 @@ export const AuthLive = ( subjectPersonId: null, sessionId: null, context, - details: new IdentitySecurityEventDetails({ + details: IdentitySecurityEventDetails.make({ outcomeCode: "origin-not-trusted", affectedSessionCount: 0, }), diff --git a/packages/database/src/authority-writers.test.ts b/packages/database/src/authority-writers.test.ts index 533952120..e0b055628 100644 --- a/packages/database/src/authority-writers.test.ts +++ b/packages/database/src/authority-writers.test.ts @@ -69,45 +69,43 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( "creates, optimistically ends, and removes the Organization global-admin grant", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - yield* seedReferences; + yield* seedReferences; - const created = yield* createOrganizationGlobalAdministratorGrant({ - grantId, - personId, - startAt, - endAt: null, - }); - - const stale = yield* Effect.flip( - endOrganizationGlobalAdministratorGrant({ - grantId, - endAt, - expectedRevision: 9, - }), - ); + const created = yield* createOrganizationGlobalAdministratorGrant({ + grantId, + personId, + startAt, + endAt: null, + }); - const ended = yield* endOrganizationGlobalAdministratorGrant({ + const stale = yield* Effect.flip( + endOrganizationGlobalAdministratorGrant({ grantId, endAt, - expectedRevision: 0, - }); + expectedRevision: 9, + }), + ); + + const ended = yield* endOrganizationGlobalAdministratorGrant({ + grantId, + endAt, + expectedRevision: 0, + }); - const removed = yield* removeOrganizationGlobalAdministratorGrant({ - grantId, - expectedRevision: 1, - }); + const removed = yield* removeOrganizationGlobalAdministratorGrant({ + grantId, + expectedRevision: 1, + }); - const database = yield* Database; + const database = yield* Database; - const rows = yield* database<{ readonly count: string }>` + const rows = yield* database<{ readonly count: string }>` SELECT count(*)::text AS count FROM public.organization_global_administrator_grants WHERE grant_id = ${grantId} `; - return { created, stale, ended, removed, count: rows[0]?.count }; - }); + const evidence = { created, stale, ended, removed, count: rows[0]?.count }; expect(evidence.created).toMatchObject({ grantId, personId, endAt: null, revision: 0 }); expect(evidence.stale).toBeInstanceOf(OrganizationAuthorityWriteConflict); @@ -122,61 +120,60 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( "creates, optimistically ends, and removes Economy payment and approval authority", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - yield* seedReferences; + yield* seedReferences; + + const payment = yield* createReceiptPaymentAuthority({ + paymentAuthorityId, + personId, + departmentId, + paymentAccountCiphertext: "ciphertext:authority-writer", + startAt, + endAt: null, + }); - const payment = yield* createReceiptPaymentAuthority({ - paymentAuthorityId, - personId, - departmentId, - paymentAccountCiphertext: "ciphertext:authority-writer", - startAt, - endAt: null, - }); + const approval = yield* createReceiptApprovalGrant({ + approvalGrantId, + personId, + scope: Scope.Department({ departmentId }), + startAt, + endAt: null, + }); - const approval = yield* createReceiptApprovalGrant({ - approvalGrantId, - personId, - scope: Scope.Department({ departmentId }), - startAt, - endAt: null, - }); + const endedPayment = yield* endReceiptPaymentAuthority({ + paymentAuthorityId, + endAt, + expectedRevision: 0, + }); - const endedPayment = yield* endReceiptPaymentAuthority({ - paymentAuthorityId, - endAt, - expectedRevision: 0, - }); + const endedApproval = yield* endReceiptApprovalGrant({ + approvalGrantId, + endAt, + expectedRevision: 0, + }); - const endedApproval = yield* endReceiptApprovalGrant({ - approvalGrantId, - endAt, + const staleRemoval = yield* Effect.flip( + removeReceiptPaymentAuthority({ + paymentAuthorityId, expectedRevision: 0, - }); - - const staleRemoval = yield* Effect.flip( - removeReceiptPaymentAuthority({ - paymentAuthorityId, - expectedRevision: 0, - }), - ); + }), + ); - const removedPayment = yield* removeReceiptPaymentAuthority({ - paymentAuthorityId, - expectedRevision: 1, - }); + const removedPayment = yield* removeReceiptPaymentAuthority({ + paymentAuthorityId, + expectedRevision: 1, + }); - const removedApproval = yield* removeReceiptApprovalGrant({ - approvalGrantId, - expectedRevision: 1, - }); + const removedApproval = yield* removeReceiptApprovalGrant({ + approvalGrantId, + expectedRevision: 1, + }); - const database = yield* Database; + const database = yield* Database; - const rows = yield* database<{ - readonly paymentCount: string; - readonly approvalCount: string; - }>` + const rows = yield* database<{ + readonly paymentCount: string; + readonly approvalCount: string; + }>` SELECT (SELECT count(*)::text FROM public.economy_payment_authorities WHERE payment_authority_id = ${paymentAuthorityId}) AS "paymentCount", @@ -184,17 +181,16 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( WHERE approval_grant_id = ${approvalGrantId}) AS "approvalCount" `; - return { - payment, - approval, - endedPayment, - endedApproval, - staleRemoval, - removedPayment, - removedApproval, - counts: rows[0], - }; - }); + const evidence = { + payment, + approval, + endedPayment, + endedApproval, + staleRemoval, + removedPayment, + removedApproval, + counts: rows[0], + }; expect(evidence.payment).toMatchObject({ paymentAuthorityId, personId, revision: 0 }); expect(evidence.approval).toMatchObject({ approvalGrantId, personId, revision: 0 }); @@ -212,38 +208,34 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( "strictly rejects decoder-invalid create inputs before persistence", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - yield* seedReferences; - - const paddedCiphertext = yield* Effect.flip( - createReceiptPaymentAuthority({ - paymentAuthorityId: ReceiptPaymentAuthorityId.make( - "authority-writer-invalid-payment", - ), - personId, - departmentId, - paymentAccountCiphertext: "\tciphertext:invalid", - startAt, - endAt: null, - }), - ); - - const excessInput = { - grantId: OrganizationGlobalAdministratorGrantId.make( - "authority-writer-invalid-admin", + yield* seedReferences; + + const paddedCiphertext = yield* Effect.flip( + createReceiptPaymentAuthority({ + paymentAuthorityId: ReceiptPaymentAuthorityId.make( + "authority-writer-invalid-payment", ), personId, + departmentId, + paymentAccountCiphertext: "\tciphertext:invalid", startAt, endAt: null, - revision: 0, - }; - - const excessProperty = yield* Effect.flip( - createOrganizationGlobalAdministratorGrant(excessInput), - ); - - return { paddedCiphertext, excessProperty }; - }); + }), + ); + + const excessInput = { + grantId: OrganizationGlobalAdministratorGrantId.make("authority-writer-invalid-admin"), + personId, + startAt, + endAt: null, + revision: 0, + }; + + const excessProperty = yield* Effect.flip( + createOrganizationGlobalAdministratorGrant(excessInput), + ); + + const evidence = { paddedCiphertext, excessProperty }; expect(evidence.paddedCiphertext).toBeInstanceOf(ReceiptDecodeError); expect(evidence.excessProperty).toBeInstanceOf(OrganizationDecodeError); diff --git a/packages/database/src/authz/delegation-postgres.ts b/packages/database/src/authz/delegation-postgres.ts index a9a0981a2..e71962bff 100644 --- a/packages/database/src/authz/delegation-postgres.ts +++ b/packages/database/src/authz/delegation-postgres.ts @@ -35,7 +35,7 @@ import { import { Database, type DatabaseOperations } from "../service.js"; const fail = (code: OrganizationLifecycleFailure["code"]) => - new OrganizationLifecycleFailure({ code }); + OrganizationLifecycleFailure.make({ code }); const decode = (schema: S) => flow( @@ -48,7 +48,7 @@ const isLifecycleFailure = Schema.is(OrganizationLifecycleFailure); const failure = (cause: unknown) => isLifecycleFailure(cause) ? cause - : new OrganizationLifecycleFailure({ code: "Unavailable", cause }); + : OrganizationLifecycleFailure.make({ code: "Unavailable", cause }); const DelegationTeamRow = Schema.Struct({ teamId: TeamId, @@ -208,7 +208,7 @@ export const executeDelegation = Effect.fn("executeDelegation")(function* ( }>`SELECT command_digest AS digest, result_json AS result FROM organization_delegation_history WHERE command_id = ${command.commandId}`)[0]; - if (receipt) { + if (receipt !== undefined) { if (receipt.digest !== digest) return yield* fail("Conflict"); return yield* decode(DelegationResult)(receipt.result); diff --git a/packages/database/src/authz/disposable-backfill.ts b/packages/database/src/authz/disposable-backfill.ts index 0bccffaed..a5d80db46 100644 --- a/packages/database/src/authz/disposable-backfill.ts +++ b/packages/database/src/authz/disposable-backfill.ts @@ -176,20 +176,13 @@ const normalizedEndAt = (endAt: string | null): string | null => const authoredSubjectKey = (subject: DisposableAuthzRuleSubjectAuthoring): string => Predicate.isTagged(subject, "Person") ? `Person:${subject.personId}` : `Tag:${subject.tagName}`; -const canonicalSubjectKey = (subject: AuthzRuleSubject): string => { - return Match.value(subject).pipe( - Match.tag("Person", (subject) => { - return `Person:${subject.personId}`; - }), - Match.tag("Tag", (subject) => { - return `Tag:${subject.tagId}`; - }), - Match.tag("ServicePrincipal", (subject) => { - return `ServicePrincipal:${subject.servicePrincipalId}`; - }), +const canonicalSubjectKey = (subject: AuthzRuleSubject): string => + Match.value(subject).pipe( + Match.tag("Person", (subject) => `Person:${subject.personId}`), + Match.tag("Tag", (subject) => `Tag:${subject.tagId}`), + Match.tag("ServicePrincipal", (subject) => `ServicePrincipal:${subject.servicePrincipalId}`), Match.exhaustive, ); -}; const duplicate = (entity: DisposableAuthzBackfillDuplicate["entity"], identity: string) => new DisposableAuthzBackfillDuplicate({ entity, identity }); diff --git a/packages/database/src/authz/postgres.ts b/packages/database/src/authz/postgres.ts index 8017e3dd6..17295fbdb 100644 --- a/packages/database/src/authz/postgres.ts +++ b/packages/database/src/authz/postgres.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { flow, Predicate, Data, Effect, Schema } from "effect"; import { AdvisoryLockKey, lockAdvisory } from "../advisory-lock.js"; import { Database, type DatabaseOperations } from "../service.js"; @@ -346,42 +347,62 @@ export type ApplicableAuthorizationRules = { * transaction, so its advisory and ordered row locks live through commit or * rollback. */ -export const readApplicableAuthorizationRules = ( - sql: DatabaseOperations, - principalInput: Principal, - capabilityIdInput: AuthzCapabilityId, - authorizationInstantInput: string, - context: CanonicalResourceContext, - lockModeInput: AuthzLockMode, -): Effect.Effect => - Effect.gen(function* () { - const principal = yield* Schema.decodeEffect(PrincipalSchema)(principalInput, { - onExcessProperty: "error", - }).pipe(Effect.mapError((cause) => validationError("Principal", cause))); - - const personId = Predicate.isTagged(principal, "Person") ? principal.personId : null; - - const servicePrincipalId = Predicate.isTagged(principal, "ServicePrincipal") - ? principal.servicePrincipalId - : null; - - const capabilityId = yield* Schema.decodeEffect(AuthzCapabilityIdSchema)(capabilityIdInput, { - onExcessProperty: "error", - }).pipe(Effect.mapError((cause) => validationError("AuthzCapabilityId", cause))); - - const authorizationInstant = yield* Schema.decodeEffect(Rfc3339InstantSchema)( - authorizationInstantInput, - { onExcessProperty: "error" }, - ).pipe(Effect.mapError((cause) => validationError("AuthorizationInstant", cause))); - - const lockMode = yield* Schema.decodeEffect(AuthzLockModeSchema)(lockModeInput, { - onExcessProperty: "error", - }).pipe(Effect.mapError((cause) => validationError("AuthzLockMode", cause))); - - if (lockMode === "ForShare") yield* acquireAuthorizationLock(sql, "Shared"); - const assignmentLock = lockMode === "ForShare" ? sql`FOR SHARE` : sql``; - - const selectedAssignments = yield* sql` +export const readApplicableAuthorizationRules: { + ( + principalInput: Principal, + capabilityIdInput: AuthzCapabilityId, + authorizationInstantInput: string, + context: CanonicalResourceContext, + lockModeInput: AuthzLockMode, + ): ( + sql: DatabaseOperations, + ) => Effect.Effect; + ( + sql: DatabaseOperations, + principalInput: Principal, + capabilityIdInput: AuthzCapabilityId, + authorizationInstantInput: string, + context: CanonicalResourceContext, + lockModeInput: AuthzLockMode, + ): Effect.Effect; +} = dual( + 6, + ( + sql: DatabaseOperations, + principalInput: Principal, + capabilityIdInput: AuthzCapabilityId, + authorizationInstantInput: string, + context: CanonicalResourceContext, + lockModeInput: AuthzLockMode, + ): Effect.Effect => + Effect.gen(function* () { + const principal = yield* Schema.decodeEffect(PrincipalSchema)(principalInput, { + onExcessProperty: "error", + }).pipe(Effect.mapError((cause) => validationError("Principal", cause))); + + const personId = Predicate.isTagged(principal, "Person") ? principal.personId : null; + + const servicePrincipalId = Predicate.isTagged(principal, "ServicePrincipal") + ? principal.servicePrincipalId + : null; + + const capabilityId = yield* Schema.decodeEffect(AuthzCapabilityIdSchema)(capabilityIdInput, { + onExcessProperty: "error", + }).pipe(Effect.mapError((cause) => validationError("AuthzCapabilityId", cause))); + + const authorizationInstant = yield* Schema.decodeEffect(Rfc3339InstantSchema)( + authorizationInstantInput, + { onExcessProperty: "error" }, + ).pipe(Effect.mapError((cause) => validationError("AuthorizationInstant", cause))); + + const lockMode = yield* Schema.decodeEffect(AuthzLockModeSchema)(lockModeInput, { + onExcessProperty: "error", + }).pipe(Effect.mapError((cause) => validationError("AuthzLockMode", cause))); + + if (lockMode === "ForShare") yield* acquireAuthorizationLock(sql, "Shared"); + const assignmentLock = lockMode === "ForShare" ? sql`FOR SHARE` : sql``; + + const selectedAssignments = yield* sql` SELECT assignment_id AS "assignmentId", tag_id AS "tagId", @@ -406,20 +427,20 @@ export const readApplicableAuthorizationRules = ( ORDER BY tag_id ASC, assignment_id ASC ${assignmentLock} `.pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("read applicable authorization tag assignments", cause)), - ), - ); + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("read applicable authorization tag assignments", cause)), + ), + ); - const tagAssignments = yield* Schema.decodeEffect(Schema.Array(AuthzTagAssignmentSchema))( - selectedAssignments, - { onExcessProperty: "error" }, - ).pipe(Effect.mapError((cause) => validationError("AuthzTagAssignment", cause))); + const tagAssignments = yield* Schema.decodeEffect(Schema.Array(AuthzTagAssignmentSchema))( + selectedAssignments, + { onExcessProperty: "error" }, + ).pipe(Effect.mapError((cause) => validationError("AuthzTagAssignment", cause))); - const ruleLock = lockMode === "ForShare" ? sql`FOR SHARE OF rule` : sql``; - const requestedDepartmentId = context.departmentId; + const ruleLock = lockMode === "ForShare" ? sql`FOR SHARE OF rule` : sql``; + const requestedDepartmentId = context.departmentId; - const selectedRules = yield* sql` + const selectedRules = yield* sql` SELECT rule.rule_id AS "ruleId", rule.capability_id AS "capabilityId", @@ -490,66 +511,94 @@ export const readApplicableAuthorizationRules = ( ORDER BY rule.rule_id ASC ${ruleLock} `.pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("read applicable authorization rules", cause)), - ), - ); + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("read applicable authorization rules", cause)), + ), + ); - const ruleRows = yield* Schema.decodeEffect(Schema.Array(AuthzRuleDatabaseRowSchema))( - selectedRules, - { onExcessProperty: "error" }, - ).pipe(Effect.mapError((cause) => validationError("AuthzRule", cause))); + const ruleRows = yield* Schema.decodeEffect(Schema.Array(AuthzRuleDatabaseRowSchema))( + selectedRules, + { onExcessProperty: "error" }, + ).pipe(Effect.mapError((cause) => validationError("AuthzRule", cause))); - const decodedRules: Array = []; + const decodedRules: Array = []; - for (const row of ruleRows) decodedRules.push(yield* decodeRuleDatabaseRow(row)); + for (const row of ruleRows) decodedRules.push(yield* decodeRuleDatabaseRow(row)); - const rules = applicableAuthzRules(decodedRules, { - principal, - authorizationInstant, - context, - tagAssignments, - }); + const rules = applicableAuthzRules(decodedRules, { + principal, + authorizationInstant, + context, + tagAssignments, + }); - const referencedTagIds = new Set( - rules.flatMap((rule) => - Predicate.isTagged(rule.subject, "Tag") ? [rule.subject.tagId] : [], - ), - ); + const referencedTagIds = new Set( + rules.flatMap((rule) => + Predicate.isTagged(rule.subject, "Tag") ? [rule.subject.tagId] : [], + ), + ); - return { - rules, - tagAssignments: tagAssignments.filter((assignment) => referencedTagIds.has(assignment.tagId)), - }; - }); + return { + rules, + tagAssignments: tagAssignments.filter((assignment) => + referencedTagIds.has(assignment.tagId), + ), + }; + }), +); /** * Lock-free ambient snapshot for query paths. This loader always uses `None`; * command paths must call `readApplicableAuthorizationRules` with their own * transaction-bound SQL and the explicit `ForShare` mode. */ -export const loadApplicableAuthorizationRules = ( - principal: Principal, - capabilityId: AuthzCapabilityId, - authorizationInstant: string, - context: CanonicalResourceContext, -): Effect.Effect< - ApplicableAuthorizationRules, - AuthzValidationError | AuthzPersistenceError, - Database -> => - Effect.gen(function* () { - const sql = yield* Database; +export const loadApplicableAuthorizationRules: { + ( + capabilityId: AuthzCapabilityId, + authorizationInstant: string, + context: CanonicalResourceContext, + ): ( + principal: Principal, + ) => Effect.Effect< + ApplicableAuthorizationRules, + AuthzValidationError | AuthzPersistenceError, + Database + >; + ( + principal: Principal, + capabilityId: AuthzCapabilityId, + authorizationInstant: string, + context: CanonicalResourceContext, + ): Effect.Effect< + ApplicableAuthorizationRules, + AuthzValidationError | AuthzPersistenceError, + Database + >; +} = dual( + 4, + ( + principal: Principal, + capabilityId: AuthzCapabilityId, + authorizationInstant: string, + context: CanonicalResourceContext, + ): Effect.Effect< + ApplicableAuthorizationRules, + AuthzValidationError | AuthzPersistenceError, + Database + > => + Effect.gen(function* () { + const sql = yield* Database; - return yield* readApplicableAuthorizationRules( - sql, - principal, - capabilityId, - authorizationInstant, - context, - "None", - ); - }); + return yield* readApplicableAuthorizationRules( + sql, + principal, + capabilityId, + authorizationInstant, + context, + "None", + ); + }), +); export const createAuthzRule = flow( decodeAuthzRule, diff --git a/packages/database/src/cohort-cli.ts b/packages/database/src/cohort-cli.ts index a9d6874b7..46d6010df 100644 --- a/packages/database/src/cohort-cli.ts +++ b/packages/database/src/cohort-cli.ts @@ -1,22 +1,22 @@ // oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0008: Effect FileSystem.open has no O_NOFOLLOW; the cohort reader refuses a symlink at open time import { constants, open } from "node:fs/promises"; -import { Effect, Schema } from "effect"; +import { Effect, Predicate, Schema } from "effect"; +import { dual } from "effect/Function"; -export const parseDisposableCohortDatabaseUrl = ( - value: string | undefined, - pathnamePattern: RegExp, - invalid: () => Error, -): string => { +export const parseDisposableCohortDatabaseUrl: { + (pathnamePattern: RegExp, invalid: () => Error): (value: string | undefined) => string; + (value: string | undefined, pathnamePattern: RegExp, invalid: () => Error): string; +} = dual(3, (value: string | undefined, pathnamePattern: RegExp, invalid: () => Error): string => { try { const url = new URL(value!); if ( !["postgres:", "postgresql:"].includes(url.protocol) || !["127.0.0.1", "[::1]"].includes(url.hostname) || - !url.port || + url.port === "" || !pathnamePattern.test(url.pathname) || - url.search || - url.hash + url.search !== "" || + url.hash !== "" ) throw invalid(); @@ -24,7 +24,7 @@ export const parseDisposableCohortDatabaseUrl = ( } catch (cause) { throw cause instanceof Error && cause.message === "InvalidSnapshot" ? cause : invalid(); } -}; +}); const decodeCohortJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Json)); @@ -33,36 +33,45 @@ const decodeCohortJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Json)) * other permission and at most `maxBytes` long, opened without following a symlink. Every other * file, and text that is not JSON, fails with `invalid()`. */ -export const readPrivateCohortJson = ( - path: string | undefined, - invalid: () => E, - maxBytes = 1_048_576, -): Effect.Effect => { - const attempt = (run: () => Promise) => Effect.tryPromise({ try: run, catch: invalid }); +export const readPrivateCohortJson: { + ( + invalid: () => E, + maxBytes?: number, + ): (path: string | undefined) => Effect.Effect; + (path: string | undefined, invalid: () => E, maxBytes?: number): Effect.Effect; +} = dual( + (args) => !Predicate.isFunction(args[0]), + ( + path: string | undefined, + invalid: () => E, + maxBytes: number = 1_048_576, + ): Effect.Effect => { + const attempt = (run: () => Promise) => Effect.tryPromise({ try: run, catch: invalid }); - if (!path) return Effect.fail(invalid()); + if (path === undefined || path === "") return Effect.fail(invalid()); - return Effect.acquireUseRelease( - attempt(() => open(path, constants.O_RDONLY | constants.O_NOFOLLOW)), - (file) => - Effect.gen(function* () { - const stat = yield* attempt(() => file.stat()); + return Effect.acquireUseRelease( + attempt(() => open(path, constants.O_RDONLY | constants.O_NOFOLLOW)), + (file) => + Effect.gen(function* () { + const stat = yield* attempt(() => file.stat()); - if ( - !stat.isFile() || - stat.size > maxBytes || - (stat.mode & 0o077) !== 0 || - stat.uid !== process.getuid?.() - ) - return yield* Effect.fail(invalid()); + if ( + !stat.isFile() || + stat.size > maxBytes || + (stat.mode & 0o077) !== 0 || + stat.uid !== process.getuid?.() + ) + return yield* Effect.fail(invalid()); - const text = yield* attempt(() => file.readFile("utf8")); + const text = yield* attempt(() => file.readFile("utf8")); - return yield* decodeCohortJson(text).pipe(Effect.mapError(invalid)); - }), - (file) => attempt(() => file.close()), - ); -}; + return yield* decodeCohortJson(text).pipe(Effect.mapError(invalid)); + }), + (file) => attempt(() => file.close()), + ); + }, +); const CohortReportJson = Schema.fromJsonString(Schema.Unknown); diff --git a/packages/database/src/content.test.ts b/packages/database/src/content.test.ts index e7cdda672..bdfd3e284 100644 --- a/packages/database/src/content.test.ts +++ b/packages/database/src/content.test.ts @@ -14,14 +14,13 @@ layer(databaseLayer, { excludeTestServices: true, timeout: "15 seconds" })( "uses the final revision and replays the ordered manifest with the five content tables", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* database.migrate; - - const migrationRows = yield* database<{ - readonly migrationId: number; - readonly name: string; - }>` + const database = yield* Database; + yield* database.migrate; + + const migrationRows = yield* database<{ + readonly migrationId: number; + readonly name: string; + }>` SELECT migration.migration_id AS "migrationId", migration.name AS "name" @@ -29,7 +28,7 @@ layer(databaseLayer, { excludeTestServices: true, timeout: "15 seconds" })( WHERE migration.migration_id = 20 `; - const tableRows = yield* database<{ readonly tableName: string }>` + const tableRows = yield* database<{ readonly tableName: string }>` SELECT table_name AS "tableName" FROM information_schema.tables WHERE table_name IN ( @@ -42,12 +41,11 @@ layer(databaseLayer, { excludeTestServices: true, timeout: "15 seconds" })( ORDER BY table_name `; - return { - revision: database.schemaRevision, - migrationRows, - tableNames: tableRows.map((row) => row.tableName), - }; - }); + const evidence = { + revision: database.schemaRevision, + migrationRows, + tableNames: tableRows.map((row) => row.tableName), + }; expect(evidence.revision).toBe(databaseSchemaRevision); expect(evidence.migrationRows).toEqual([ @@ -67,39 +65,38 @@ layer(databaseLayer, { excludeTestServices: true, timeout: "15 seconds" })( it.effect("enforces version-number uniqueness, slug constraints, and department restrict", () => Effect.gen(function* () { - const outcome = yield* Effect.gen(function* () { - const database = yield* Database; - yield* database` + const database = yield* Database; + yield* database` INSERT INTO organization_departments ( department_id, name, short_name, email, city ) VALUES ('content-test-dep', 'Testavdeling', 'TAV', 'tav@example.invalid', 'Oslo') `; - const insertDraft = (slug: string) => - database` + const insertDraft = (slug: string) => + database` INSERT INTO public.content_articles (title, slug, body_html, sticky, created_by_person_id) VALUES (${`Tittel ${slug}`}, ${slug}, '

x

', FALSE, 'person-1') `; - yield* insertDraft("unikt-lenkenavn"); + yield* insertDraft("unikt-lenkenavn"); - const idRows = yield* database<{ readonly articleId: number }>` + const idRows = yield* database<{ readonly articleId: number }>` SELECT article_id AS "articleId" FROM public.content_articles WHERE slug = 'unikt-lenkenavn' `; - const articleId = Number(idRows[0]!.articleId); + const articleId = Number(idRows[0]!.articleId); - // A duplicate slug across drafts is rejected. - const duplicateSlug = yield* Effect.exit(insertDraft("unikt-lenkenavn")); - const duplicateSlugRejected = Predicate.isTagged(duplicateSlug, "Failure"); + // A duplicate slug across drafts is rejected. + const duplicateSlug = yield* Effect.exit(insertDraft("unikt-lenkenavn")); + const duplicateSlugRejected = Predicate.isTagged(duplicateSlug, "Failure"); - yield* database` + yield* database` INSERT INTO public.content_article_departments (article_id, department_id) VALUES (${articleId}, 'content-test-dep') `; - const insertVersion = (version: number) => - database` + const insertVersion = (version: number) => + database` INSERT INTO public.content_article_versions ( article_id, version_number, title, slug, body_html, sticky, published_at, published_by_person_id @@ -109,24 +106,24 @@ layer(databaseLayer, { excludeTestServices: true, timeout: "15 seconds" })( ) `.pipe(Effect.asVoid); - yield* insertVersion(1); - // The same (article, version) pair is a PK violation. - const duplicateVersion = yield* Effect.exit(insertVersion(1)); - const duplicateVersionRejected = Predicate.isTagged(duplicateVersion, "Failure"); + yield* insertVersion(1); + // The same (article, version) pair is a PK violation. + const duplicateVersion = yield* Effect.exit(insertVersion(1)); + const duplicateVersionRejected = Predicate.isTagged(duplicateVersion, "Failure"); - // Historical versions may share listing-order keys. - const sameListingOrder = yield* Effect.exit(insertVersion(2)); - const sameListingOrderAccepted = Predicate.isTagged(sameListingOrder, "Success"); + // Historical versions may share listing-order keys. + const sameListingOrder = yield* Effect.exit(insertVersion(2)); + const sameListingOrderAccepted = Predicate.isTagged(sameListingOrder, "Success"); - const listingIndexRows = yield* database<{ readonly isUnique: boolean }>` + const listingIndexRows = yield* database<{ readonly isUnique: boolean }>` SELECT index.indisunique AS "isUnique" FROM pg_index AS index WHERE index.indexrelid = 'content_article_versions_current_listing_order'::regclass `; - const listingIndexIsNonUnique = listingIndexRows[0]?.isUnique === false; + const listingIndexIsNonUnique = listingIndexRows[0]?.isUnique === false; - yield* database` + yield* database` INSERT INTO public.content_publication_command_receipts ( command_id, article_id, kind, payload_sha256, result_json, committed_at ) VALUES ( @@ -134,7 +131,7 @@ layer(databaseLayer, { excludeTestServices: true, timeout: "15 seconds" })( repeat('0', 64), '{}'::jsonb, '2030-01-01T00:00:00.000Z' ) `; - yield* database` + yield* database` INSERT INTO public.content_publication_audit ( command_id, article_id, actor_person_id, action, version_number, occurred_at ) VALUES ( @@ -143,53 +140,52 @@ layer(databaseLayer, { excludeTestServices: true, timeout: "15 seconds" })( ) `; - const auditRows = yield* database<{ readonly auditId: number }>` + const auditRows = yield* database<{ readonly auditId: number }>` SELECT audit_id AS "auditId" FROM public.content_publication_audit WHERE command_id = 'content-audit-protection-command' `; - const auditInsertWorked = auditRows.length === 1; + const auditInsertWorked = auditRows.length === 1; - const auditUpdate = yield* Effect.exit( - database` + const auditUpdate = yield* Effect.exit( + database` UPDATE public.content_publication_audit SET action = 'Publish' WHERE command_id = 'content-audit-protection-command' `.pipe(Effect.asVoid), - ); + ); - const auditUpdateRejected = Predicate.isTagged(auditUpdate, "Failure"); + const auditUpdateRejected = Predicate.isTagged(auditUpdate, "Failure"); - const auditDelete = yield* Effect.exit( - database` + const auditDelete = yield* Effect.exit( + database` DELETE FROM public.content_publication_audit WHERE command_id = 'content-audit-protection-command' `.pipe(Effect.asVoid), - ); - - const auditDeleteRejected = Predicate.isTagged(auditDelete, "Failure"); - - // FK RESTRICT blocks deleting a department still referenced. - const restrict = yield* Effect.exit( - database`DELETE FROM organization_departments WHERE department_id = 'content-test-dep'`.pipe( - Effect.asVoid, - ), - ); - - const restrictBlocked = Predicate.isTagged(restrict, "Failure"); - - return { - duplicateSlugRejected, - duplicateVersionRejected, - sameListingOrderAccepted, - listingIndexIsNonUnique, - auditInsertWorked, - auditUpdateRejected, - auditDeleteRejected, - restrictBlocked, - }; - }); + ); + + const auditDeleteRejected = Predicate.isTagged(auditDelete, "Failure"); + + // FK RESTRICT blocks deleting a department still referenced. + const restrict = yield* Effect.exit( + database`DELETE FROM organization_departments WHERE department_id = 'content-test-dep'`.pipe( + Effect.asVoid, + ), + ); + + const restrictBlocked = Predicate.isTagged(restrict, "Failure"); + + const outcome = { + duplicateSlugRejected, + duplicateVersionRejected, + sameListingOrderAccepted, + listingIndexIsNonUnique, + auditInsertWorked, + auditUpdateRejected, + auditDeleteRejected, + restrictBlocked, + }; expect(outcome).toEqual({ duplicateSlugRejected: true, diff --git a/packages/database/src/content/news.ts b/packages/database/src/content/news.ts index 0520f29fb..dcaab08f9 100644 --- a/packages/database/src/content/news.ts +++ b/packages/database/src/content/news.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { Predicate, Effect, Schema } from "effect"; import type { DatabaseOperations } from "../service.js"; import { Database } from "../service.js"; @@ -20,7 +21,7 @@ import { } from "@vektorprogrammet/domain/content"; const integrityError = (operation: string, cause: unknown): ContentIntegrityError => - new ContentIntegrityError({ operation, message: String(cause) }); + ContentIntegrityError.make({ operation, message: String(cause) }); const CurrentVersionRowSchema = Schema.Struct({ articleId: Schema.Int, @@ -108,7 +109,7 @@ export const readNewsListingPostgres = ( .pipe( Effect.mapError((cause) => Predicate.isTagged(cause, "DepartmentNotFound") - ? new ContentDepartmentNotFound({ departmentId }) + ? ContentDepartmentNotFound.make({ departmentId }) : integrityError("validate news department", cause), ), ); @@ -158,7 +159,7 @@ export const readNewsListingPostgres = ( for (const personId of authorPersonIds) { if (!namesByPerson.has(personId)) { - return yield* new ContentIntegrityError({ + return yield* ContentIntegrityError.make({ operation: "resolve news authors", message: `no profile resolved for author ${personId}`, }); @@ -191,35 +192,55 @@ export const readNewsListingPostgres = ( * remains published. Unknown, withdrawn, and unknown-version reads share the * same typed not-found result. */ -export const readPublishedArticlePostgres = ( - slug: string, - versionNumber?: number, -): Effect.Effect< - PublishedNewsArticle, - ContentDecodeError | ContentIntegrityError | ArticleNotFound, - Database | Profile -> => - Effect.gen(function* () { - const database = yield* Database; - const profile = yield* Profile; - - return yield* database - .withTransaction( - Effect.gen(function* () { - yield* database`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`.pipe( - Effect.asVoid, - ); - - const versions = yield* database<{ - readonly articleId: number; - readonly versionNumber: number; - readonly slug: string; - readonly title: string; - readonly sticky: boolean; - readonly bodyHtml: string; - readonly publishedAt: string; - readonly createdByPersonId: string; - }>` +export const readPublishedArticlePostgres: { + ( + versionNumber?: number, + ): ( + slug: string, + ) => Effect.Effect< + PublishedNewsArticle, + ContentDecodeError | ContentIntegrityError | ArticleNotFound, + Database | Profile + >; + ( + slug: string, + versionNumber?: number, + ): Effect.Effect< + PublishedNewsArticle, + ContentDecodeError | ContentIntegrityError | ArticleNotFound, + Database | Profile + >; +} = dual( + (args) => Predicate.isString(args[0]), + ( + slug: string, + versionNumber?: number, + ): Effect.Effect< + PublishedNewsArticle, + ContentDecodeError | ContentIntegrityError | ArticleNotFound, + Database | Profile + > => + Effect.gen(function* () { + const database = yield* Database; + const profile = yield* Profile; + + return yield* database + .withTransaction( + Effect.gen(function* () { + yield* database`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`.pipe( + Effect.asVoid, + ); + + const versions = yield* database<{ + readonly articleId: number; + readonly versionNumber: number; + readonly slug: string; + readonly title: string; + readonly sticky: boolean; + readonly bodyHtml: string; + readonly publishedAt: string; + readonly createdByPersonId: string; + }>` SELECT CAST(version.article_id AS integer) AS "articleId", version.version_number AS "versionNumber", @@ -239,69 +260,72 @@ export const readPublishedArticlePostgres = ( WHERE version.slug = ${slug} ORDER BY version.version_number DESC `.pipe( - Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(PublishedVersionRowSchema))), - Effect.catchTags({ - SchemaError: (cause) => integrityError("decode published article rows", cause), - SqlError: (cause) => integrityError("read published article versions", cause), - }), - ); + Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(PublishedVersionRowSchema))), + Effect.catchTags({ + SchemaError: (cause) => integrityError("decode published article rows", cause), + SqlError: (cause) => integrityError("read published article versions", cause), + }), + ); - const current = versions[0]; + const current = versions[0]; - if (current === undefined) { - return yield* new ContentArticleNotFound({}); - } + if (current === undefined) { + return yield* ContentArticleNotFound.make({}); + } - const selected = - versionNumber === undefined - ? current - : versions.find((version) => version.versionNumber === versionNumber); + const selected = + versionNumber === undefined + ? current + : versions.find((version) => version.versionNumber === versionNumber); - if (selected === undefined) { - return yield* new ContentArticleNotFound({}); - } + if (selected === undefined) { + return yield* ContentArticleNotFound.make({}); + } - const departments = yield* readDepartments(database, [selected.articleId]); + const departments = yield* readDepartments(database, [selected.articleId]); - const profiles = yield* profile - .readProfiles([selected.createdByPersonId]) - .pipe(Effect.mapError((cause) => integrityError("resolve news author", cause))); + const profiles = yield* profile + .readProfiles([selected.createdByPersonId]) + .pipe(Effect.mapError((cause) => integrityError("resolve news author", cause))); - const author = profiles.find((entry) => entry.personId === selected.createdByPersonId); + const author = profiles.find((entry) => entry.personId === selected.createdByPersonId); - if (author === undefined) { - return yield* new ContentIntegrityError({ - operation: "resolve news author", - message: `no profile resolved for author ${selected.createdByPersonId}`, - }); - } + if (author === undefined) { + return yield* ContentIntegrityError.make({ + operation: "resolve news author", + message: `no profile resolved for author ${selected.createdByPersonId}`, + }); + } - const previousVersions = versions - .filter((version) => version.versionNumber < selected.versionNumber) - .sort((left, right) => right.versionNumber - left.versionNumber) - .map((row) => ({ - versionNumber: row.versionNumber, - publishedAt: row.publishedAt, - urlPath: `/nyhet/${slug}?versjon=${row.versionNumber}`, - })); - - return yield* Schema.decodeEffect(PublishedNewsArticleSchema)( - { - slug: selected.slug, - title: selected.title, - sticky: selected.sticky, - publishedAt: selected.publishedAt, - authorDisplayName: `${author.firstName} ${author.lastName}`, - departmentIds: [...(departments.get(selected.articleId) ?? [])], - hasImage: false, - bodyHtml: selected.bodyHtml, - previousVersions, - }, - { onExcessProperty: "error" }, - ).pipe(Effect.mapError((cause) => integrityError("decode published article", cause))); - }), - ) - .pipe( - Effect.catchTag("SqlError", (cause) => integrityError("published article snapshot", cause)), - ); - }); + const previousVersions = versions + .filter((version) => version.versionNumber < selected.versionNumber) + .sort((left, right) => right.versionNumber - left.versionNumber) + .map((row) => ({ + versionNumber: row.versionNumber, + publishedAt: row.publishedAt, + urlPath: `/nyhet/${slug}?versjon=${row.versionNumber}`, + })); + + return yield* Schema.decodeEffect(PublishedNewsArticleSchema)( + { + slug: selected.slug, + title: selected.title, + sticky: selected.sticky, + publishedAt: selected.publishedAt, + authorDisplayName: `${author.firstName} ${author.lastName}`, + departmentIds: [...(departments.get(selected.articleId) ?? [])], + hasImage: false, + bodyHtml: selected.bodyHtml, + previousVersions, + }, + { onExcessProperty: "error" }, + ).pipe(Effect.mapError((cause) => integrityError("decode published article", cause))); + }), + ) + .pipe( + Effect.catchTag("SqlError", (cause) => + integrityError("published article snapshot", cause), + ), + ); + }), +); diff --git a/packages/database/src/content/postgres.test.ts b/packages/database/src/content/postgres.test.ts index 95b4589ae..6a929a6de 100644 --- a/packages/database/src/content/postgres.test.ts +++ b/packages/database/src/content/postgres.test.ts @@ -189,21 +189,19 @@ layer(contentLayer, { excludeTestServices: true, timeout: "15 seconds" })( Effect.gen(function* () { yield* reset; - const failure = yield* Effect.gen(function* () { - yield* publishPostgres({ - command: PublishArticleInputSchema.make({ - commandId: ContentCommandId.make("publish-own-article"), - articleId, - }), - personId: administratorId, - authorizationInstant, - }); - - return yield* Effect.flip( - readArticleDetailPostgres({ articleId, personId, authorizationInstant }), - ); + yield* publishPostgres({ + command: PublishArticleInputSchema.make({ + commandId: ContentCommandId.make("publish-own-article"), + articleId, + }), + personId: administratorId, + authorizationInstant, }); + const failure = yield* Effect.flip( + readArticleDetailPostgres({ articleId, personId, authorizationInstant }), + ); + expect(failure._tag).toBe("DraftNotOwned"); }), ); @@ -212,24 +210,22 @@ layer(contentLayer, { excludeTestServices: true, timeout: "15 seconds" })( Effect.gen(function* () { yield* reset; - const observed = yield* Effect.gen(function* () { - const missing = yield* Effect.flip( - readArticleDetailPostgres({ - articleId: ArticleId.make(999), - personId, - authorizationInstant, - }), - ); + const missing = yield* Effect.flip( + readArticleDetailPostgres({ + articleId: ArticleId.make(999), + personId, + authorizationInstant, + }), + ); - const sql = yield* Database; - yield* sql`UPDATE content_articles SET created_by_person_id = 'another-editor' WHERE article_id = ${articleId}`; + const sql = yield* Database; + yield* sql`UPDATE content_articles SET created_by_person_id = 'another-editor' WHERE article_id = ${articleId}`; - const denied = yield* Effect.flip( - readArticleDetailPostgres({ articleId, personId, authorizationInstant }), - ); + const denied = yield* Effect.flip( + readArticleDetailPostgres({ articleId, personId, authorizationInstant }), + ); - return { missing, denied }; - }); + const observed = { missing, denied }; expect(observed.missing._tag).toBe("ArticleNotFound"); expect(observed.denied._tag).toBe("DraftNotOwned"); @@ -244,22 +240,20 @@ layer(contentLayer, { excludeTestServices: true, timeout: "15 seconds" })( Effect.gen(function* () { yield* reset; - const observed = yield* Effect.gen(function* () { - const created = yield* createDraftPostgres(createInput); - const sql = yield* Database; - yield* sql` + const created = yield* createDraftPostgres(createInput); + const sql = yield* Database; + yield* sql` UPDATE organization_memberships SET end_at = '2029-01-01T00:00:00.000Z' WHERE membership_id = 'workspace-membership' `; - const replayed = yield* createDraftPostgres(createInput); + const replayed = yield* createDraftPostgres(createInput); - const counts = yield* sql<{ readonly articles: number; readonly audits: number }>` + const counts = yield* sql<{ readonly articles: number; readonly audits: number }>` SELECT (SELECT count(*)::integer FROM content_articles WHERE slug = 'replay-article') AS articles, (SELECT count(*)::integer FROM content_publication_audit WHERE command_id = ${createCommand.commandId}) AS audits `; - return { created, replayed, counts }; - }); + const observed = { created, replayed, counts }; expect(observed.replayed).toEqual(observed.created); expect(observed.counts).toEqual([{ articles: 1, audits: 1 }]); @@ -270,20 +264,18 @@ layer(contentLayer, { excludeTestServices: true, timeout: "15 seconds" })( Effect.gen(function* () { yield* reset; - const failure = yield* Effect.gen(function* () { - const created = yield* createDraftPostgres(createInput); + const created = yield* createDraftPostgres(createInput); - return yield* Effect.flip( - publishPostgres({ - command: PublishArticleInputSchema.make({ - commandId: createCommand.commandId, - articleId: created.articleId, - }), - personId: administratorId, - authorizationInstant, + const failure = yield* Effect.flip( + publishPostgres({ + command: PublishArticleInputSchema.make({ + commandId: createCommand.commandId, + articleId: created.articleId, }), - ); - }); + personId: administratorId, + authorizationInstant, + }), + ); expect(failure._tag).toBe("CommandConflict"); }), @@ -293,20 +285,18 @@ layer(contentLayer, { excludeTestServices: true, timeout: "15 seconds" })( Effect.gen(function* () { yield* reset; - const failure = yield* Effect.gen(function* () { - yield* createDraftPostgres(createInput); + yield* createDraftPostgres(createInput); - return yield* Effect.flip( - createDraftPostgres({ - command: CreateArticleDraftInputSchema.make({ - ...createCommand, - title: "Different canonical bytes", - }), - personId, - authorizationInstant, + const failure = yield* Effect.flip( + createDraftPostgres({ + command: CreateArticleDraftInputSchema.make({ + ...createCommand, + title: "Different canonical bytes", }), - ); - }); + personId, + authorizationInstant, + }), + ); expect(failure._tag).toBe("CommandConflict"); }), @@ -316,17 +306,15 @@ layer(contentLayer, { excludeTestServices: true, timeout: "15 seconds" })( Effect.gen(function* () { yield* reset; - const failure = yield* Effect.gen(function* () { - yield* createDraftPostgres(createInput); - const sql = yield* Database; - yield* sql` + yield* createDraftPostgres(createInput); + const sql = yield* Database; + yield* sql` UPDATE content_publication_command_receipts SET result_json = result_json || ${sql.json({ privateLeak: "must fail closed" })} WHERE command_id = ${createCommand.commandId} `; - return yield* Effect.flip(createDraftPostgres(createInput)); - }); + const failure = yield* Effect.flip(createDraftPostgres(createInput)); expect(failure._tag).toBe("ContentPersistenceError"); }), @@ -336,26 +324,28 @@ layer(contentLayer, { excludeTestServices: true, timeout: "15 seconds" })( Effect.gen(function* () { yield* reset; - const failure = yield* Effect.gen(function* () { - const sql = yield* Database; - // Force the conflict after the real slug scan, at the actual unique index. - yield* sql.unsafe(` + const sql = yield* Database; + // Force the conflict after the real slug scan, at the actual unique index. + yield* sql.unsafe(` CREATE FUNCTION test_content_slug_conflict() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.slug := 'eksakt-kladd'; RETURN NEW; END; $$ `); - yield* sql.unsafe(` + yield* sql.unsafe(` CREATE TRIGGER test_content_slug_conflict BEFORE INSERT ON content_articles FOR EACH ROW EXECUTE FUNCTION test_content_slug_conflict() `); - try { - return yield* Effect.flip(createDraftPostgres(createInput)); - } finally { - yield* sql.unsafe("DROP TRIGGER test_content_slug_conflict ON content_articles"); - yield* sql.unsafe("DROP FUNCTION test_content_slug_conflict()"); - } - }); + const failure = yield* Effect.flip(createDraftPostgres(createInput)).pipe( + Effect.ensuring( + sql + .unsafe("DROP TRIGGER test_content_slug_conflict ON content_articles") + .pipe( + Effect.andThen(sql.unsafe("DROP FUNCTION test_content_slug_conflict()")), + Effect.orDie, + ), + ), + ); expect(failure._tag).toBe("SlugConflict"); }), @@ -367,51 +357,49 @@ layer(contentLayer, { excludeTestServices: true, timeout: "15 seconds" })( Effect.gen(function* () { yield* reset; - const observed = yield* Effect.gen(function* () { - for (const versionNumber of [1, 2, 3]) { - yield* publishPostgres({ - command: PublishArticleInputSchema.make({ - commandId: ContentCommandId.make(`publish-version-${versionNumber}`), - articleId, - }), - personId: administratorId, - authorizationInstant, - }); - } - - yield* unpublishPostgres({ - command: UnpublishArticleInputSchema.make({ - commandId: ContentCommandId.make("unpublish-third-version"), + for (const versionNumber of [1, 2, 3]) { + yield* publishPostgres({ + command: PublishArticleInputSchema.make({ + commandId: ContentCommandId.make(`publish-version-${versionNumber}`), articleId, }), personId: administratorId, authorizationInstant, }); + } - const unpublished = yield* readArticleDetailPostgres({ + yield* unpublishPostgres({ + command: UnpublishArticleInputSchema.make({ + commandId: ContentCommandId.make("unpublish-third-version"), articleId, - personId: administratorId, - authorizationInstant, - }); + }), + personId: administratorId, + authorizationInstant, + }); - const republished = yield* publishPostgres({ - command: PublishArticleInputSchema.make({ - commandId: ContentCommandId.make("republish-fourth-version"), - articleId, - }), - personId: administratorId, - authorizationInstant, - }); + const unpublished = yield* readArticleDetailPostgres({ + articleId, + personId: administratorId, + authorizationInstant, + }); + + const republished = yield* publishPostgres({ + command: PublishArticleInputSchema.make({ + commandId: ContentCommandId.make("republish-fourth-version"), + articleId, + }), + personId: administratorId, + authorizationInstant, + }); - const sql = yield* Database; + const sql = yield* Database; - const versions = yield* sql<{ readonly versionNumber: number }>` + const versions = yield* sql<{ readonly versionNumber: number }>` SELECT version_number AS "versionNumber" FROM content_article_versions WHERE article_id = ${articleId} ORDER BY version_number `; - return { unpublished, republished, versions }; - }); + const observed = { unpublished, republished, versions }; expect(observed.unpublished.currentVersionNumber).toBeNull(); expect(observed.republished.versionNumber).toBe(4); @@ -428,24 +416,22 @@ layer(contentLayer, { excludeTestServices: true, timeout: "15 seconds" })( Effect.gen(function* () { yield* reset; - const published = yield* Effect.gen(function* () { - const sql = yield* Database; - yield* sql` + const sql = yield* Database; + yield* sql` UPDATE content_articles SET body_html = '

before

after

' WHERE article_id = ${articleId} `; - yield* publishPostgres({ - command: PublishArticleInputSchema.make({ - commandId: ContentCommandId.make("publish-sanitized-body"), - articleId, - }), - personId: administratorId, - authorizationInstant, - }); - - return yield* readPublishedArticlePostgres("eksakt-kladd"); + yield* publishPostgres({ + command: PublishArticleInputSchema.make({ + commandId: ContentCommandId.make("publish-sanitized-body"), + articleId, + }), + personId: administratorId, + authorizationInstant, }); + const published = yield* readPublishedArticlePostgres("eksakt-kladd"); + expect(published.bodyHtml).toBe("

before

after

"); }), ); @@ -454,31 +440,29 @@ layer(contentLayer, { excludeTestServices: true, timeout: "15 seconds" })( Effect.gen(function* () { yield* reset; - const observed = yield* Effect.gen(function* () { - const sql = yield* Database; - yield* sql` + const sql = yield* Database; + yield* sql` UPDATE content_articles SET body_html = ${'

unsafe

'} WHERE article_id = ${articleId} `; - const failure = yield* Effect.flip( - publishPostgres({ - command: PublishArticleInputSchema.make({ - commandId: ContentCommandId.make("publish-unsafe-body"), - articleId, - }), - personId: administratorId, - authorizationInstant, + const failure = yield* Effect.flip( + publishPostgres({ + command: PublishArticleInputSchema.make({ + commandId: ContentCommandId.make("publish-unsafe-body"), + articleId, }), - ); + personId: administratorId, + authorizationInstant, + }), + ); - const counts = yield* sql<{ readonly versions: number; readonly audits: number }>` + const counts = yield* sql<{ readonly versions: number; readonly audits: number }>` SELECT (SELECT count(*)::integer FROM content_article_versions) AS versions, (SELECT count(*)::integer FROM content_publication_audit) AS audits `; - return { failure, counts }; - }); + const observed = { failure, counts }; expect(observed.failure._tag).toBe("ContentDecodeError"); expect(observed.counts).toEqual([{ versions: 0, audits: 0 }]); @@ -491,28 +475,26 @@ layer(contentLayer, { excludeTestServices: true, timeout: "15 seconds" })( Effect.gen(function* () { yield* reset; - const failure = yield* Effect.gen(function* () { - const sql = yield* Database; - yield* sql` + const sql = yield* Database; + yield* sql` UPDATE organization_memberships SET is_team_leader = TRUE WHERE membership_id = 'workspace-membership' `; - yield* sql` + yield* sql` INSERT INTO organization_memberships (membership_id, person_id, team_id, start_at) VALUES ('outside-membership', ${personId}, 'outside-team', '2028-01-01T00:00:00.000Z') `; - return yield* Effect.flip( - createDraftPostgres({ - command: CreateArticleDraftInputSchema.make({ - ...createCommand, - departmentIds: [outsideDepartmentId], - }), - personId, - authorizationInstant, + const failure = yield* Effect.flip( + createDraftPostgres({ + command: CreateArticleDraftInputSchema.make({ + ...createCommand, + departmentIds: [outsideDepartmentId], }), - ); - }); + personId, + authorizationInstant, + }), + ); expect(failure._tag).toBe("NotInScope"); }), diff --git a/packages/database/src/content/postgres.ts b/packages/database/src/content/postgres.ts index 4f7905fd0..3d2a585e0 100644 --- a/packages/database/src/content/postgres.ts +++ b/packages/database/src/content/postgres.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { flow, Predicate, Effect, Schema } from "effect"; import { AdvisoryLockKey, lockAdvisory } from "../advisory-lock.js"; import { Database, type DatabaseOperations } from "../service.js"; @@ -61,10 +62,10 @@ import { dedupeSlug, slugifyTitle } from "@vektorprogrammet/domain/content"; import { sanitizeArticleBodyHtml } from "@vektorprogrammet/domain/content"; const decodeError = (operation: string, cause: unknown): ContentDecodeError => - new ContentDecodeError({ operation, message: String(cause) }); + ContentDecodeError.make({ operation, message: String(cause) }); const persistenceError = (operation: string, cause: unknown): ContentPersistenceError => - new ContentPersistenceError({ operation, message: String(cause) }); + ContentPersistenceError.make({ operation, message: String(cause) }); type DraftRow = typeof DraftRowSchema.Type; @@ -168,8 +169,8 @@ export const readWorkspacePostgres = (input: { if (Predicate.isTagged(decision, "Deny")) { return yield* decision.reason === "AuthorityInactive" - ? new ContentAuthorityInactive({}) - : new ContentNotInScope({}); + ? ContentAuthorityInactive.make({}) + : ContentNotInScope.make({}); } const filterDepartmentId = decodedQuery.departmentId; @@ -179,7 +180,7 @@ export const readWorkspacePostgres = (input: { Effect.asVoid, Effect.mapError((cause) => Predicate.isTagged(cause, "DepartmentNotFound") - ? new ContentDepartmentNotFound({ departmentId: filterDepartmentId }) + ? ContentDepartmentNotFound.make({ departmentId: filterDepartmentId }) : persistenceError("read content department filter", cause), ), ); @@ -188,7 +189,7 @@ export const readWorkspacePostgres = (input: { !Predicate.isTagged(decision.value, "ContentAdministrator") && !decision.value.departmentIds.includes(filterDepartmentId) ) { - return yield* new ContentNotInScope({}); + return yield* ContentNotInScope.make({}); } } @@ -254,7 +255,7 @@ export const readWorkspacePostgres = (input: { Effect.mapError((cause) => Predicate.isTagged(cause, "ProfileContactNotFound") || Predicate.isTagged(cause, "ProfileNotFound") - ? new ContentIntegrityError({ + ? ContentIntegrityError.make({ operation: "read content workspace authors", message: `missing profile for a workspace author: ${String(cause)}`, }) @@ -268,7 +269,7 @@ export const readWorkspacePostgres = (input: { for (const draft of scoped) { if (!namesByPerson.has(draft.createdByPersonId)) { - return yield* new ContentIntegrityError({ + return yield* ContentIntegrityError.make({ operation: "read content workspace authors", message: `no profile resolved for author ${draft.createdByPersonId}`, }); @@ -351,8 +352,8 @@ export const readArticleDetailInTransactionPostgres = (input: { if (Predicate.isTagged(decision, "Deny")) { return yield* decision.reason === "AuthorityInactive" - ? new ContentAuthorityInactive({}) - : new ContentNotInScope({}); + ? ContentAuthorityInactive.make({}) + : ContentNotInScope.make({}); } const rows = yield* database` @@ -380,7 +381,7 @@ export const readArticleDetailInTransactionPostgres = (input: { catch: (cause) => decodeError("decode content article detail row", cause), }); - if (draft === undefined) return yield* new ContentArticleNotFound({}); + if (draft === undefined) return yield* ContentArticleNotFound.make({}); const departmentIds = (yield* departmentIdsForArticles(database, [draft.articleId])).get(draft.articleId) ?? []; @@ -393,15 +394,15 @@ export const readArticleDetailInTransactionPostgres = (input: { if (!canRevise) { return yield* Predicate.isTagged(decision.value, "ContentEditor") - ? new ContentDraftNotOwned({ articleId: draft.articleId }) - : new ContentNotInScope({}); + ? ContentDraftNotOwned.make({ articleId: draft.articleId }) + : ContentNotInScope.make({}); } const profiles = yield* profile.readProfiles([draft.createdByPersonId]).pipe( Effect.mapError((cause) => Predicate.isTagged(cause, "ProfileContactNotFound") || Predicate.isTagged(cause, "ProfileNotFound") - ? new ContentIntegrityError({ + ? ContentIntegrityError.make({ operation: "read content article detail author", message: `missing profile for article author: ${String(cause)}`, }) @@ -412,7 +413,7 @@ export const readArticleDetailInTransactionPostgres = (input: { const author = profiles[0]; if (author === undefined) { - return yield* new ContentIntegrityError({ + return yield* ContentIntegrityError.make({ operation: "read content article detail author", message: "no profile resolved for article author", }); @@ -610,8 +611,8 @@ const authorityDecisionOrDenial = ( if (Predicate.isTagged(decision, "Deny")) { return decision.reason === "AuthorityInactive" - ? Effect.fail(new ContentAuthorityInactive({})) - : Effect.fail(new ContentNotInScope({})); + ? Effect.fail(ContentAuthorityInactive.make({})) + : Effect.fail(ContentNotInScope.make({})); } return Effect.succeed(decision.value); @@ -673,7 +674,7 @@ const replaceArticleDepartments = ( Effect.asVoid, Effect.mapError((cause) => String(cause).includes("foreign key") - ? new ContentDepartmentNotFound({ departmentId }) + ? ContentDepartmentNotFound.make({ departmentId }) : persistenceError("insert content department link", cause), ), ); @@ -714,7 +715,7 @@ export const createDraftPostgres = (input: { if (stored !== undefined) { if (commandReceiptConflicts(stored, "CreateDraft", payloadDigest)) { - return yield* new ContentCommandConflict({ commandId: command.commandId }); + return yield* ContentCommandConflict.make({ commandId: command.commandId }); } return yield* decodeStoredDraft(stored.resultJson); @@ -740,19 +741,19 @@ export const createDraftPostgres = (input: { actor.departmentIds.includes(departmentId), )) ) { - return yield* new ContentNotInScope({}); + return yield* ContentNotInScope.make({}); } const sticky = command.sticky ?? false; if (sticky && Predicate.isTagged(actor, "ContentEditor")) { - return yield* new ContentNotInScope({}); + return yield* ContentNotInScope.make({}); } const baseSlug = slugifyTitle(command.title); if (!/^[a-z0-9-]+$/.test(baseSlug) || baseSlug.length === 0) { - return yield* new ContentSlugConflict({}); + return yield* ContentSlugConflict.make({}); } const taken = new Set( @@ -824,7 +825,7 @@ export const createDraftPostgres = (input: { constraint === "content_articles_slug_unique" || description.includes("content_articles_slug_unique") || description.includes("content_articles_slug_key") - ? new ContentSlugConflict({}) + ? ContentSlugConflict.make({}) : persistenceError("insert content draft", cause); }), ); @@ -880,7 +881,7 @@ export const publishPostgres = (input: { if (stored !== undefined) { if (commandReceiptConflicts(stored, "Publish", payloadDigest)) { - return yield* new ContentCommandConflict({ commandId: command.commandId }); + return yield* ContentCommandConflict.make({ commandId: command.commandId }); } return yield* decodeStoredPublish(stored.resultJson); @@ -896,7 +897,7 @@ export const publishPostgres = (input: { const draft = yield* readDraftForUpdate(database, command.articleId); if (draft === undefined) { - return yield* new ContentArticleNotFound({}); + return yield* ContentArticleNotFound.make({}); } const departmentIds = yield* departmentIdsForArticles(database, [draft.articleId]); @@ -904,8 +905,8 @@ export const publishPostgres = (input: { if (!canPublishContent(actor, draftDepartments)) { return yield* Predicate.isTagged(actor, "ContentEditor") - ? new ContentNotPublisher({ articleId: draft.articleId }) - : new ContentNotInScope({}); + ? ContentNotPublisher.make({ articleId: draft.articleId }) + : ContentNotInScope.make({}); } const nextVersionRows = yield* database<{ readonly nextVersionNumber: number }>` @@ -1013,7 +1014,7 @@ export const unpublishPostgres = (input: { if (stored !== undefined) { if (commandReceiptConflicts(stored, "Unpublish", payloadDigest)) { - return yield* new ContentCommandConflict({ commandId: command.commandId }); + return yield* ContentCommandConflict.make({ commandId: command.commandId }); } return yield* decodeStoredUnpublish(stored.resultJson); @@ -1029,19 +1030,19 @@ export const unpublishPostgres = (input: { const draft = yield* readDraftForUpdate(database, command.articleId); if (draft === undefined) { - return yield* new ContentArticleNotFound({}); + return yield* ContentArticleNotFound.make({}); } const departmentIds = yield* departmentIdsForArticles(database, [draft.articleId]); if (!canPublishContent(actor, departmentIds.get(draft.articleId) ?? [])) { return yield* Predicate.isTagged(actor, "ContentEditor") - ? new ContentNotPublisher({ articleId: draft.articleId }) - : new ContentNotInScope({}); + ? ContentNotPublisher.make({ articleId: draft.articleId }) + : ContentNotInScope.make({}); } if (draft.currentVersionNumber === null) { - return yield* new ContentCommandConflict({ commandId: command.commandId }); + return yield* ContentCommandConflict.make({ commandId: command.commandId }); } yield* database` @@ -1105,7 +1106,7 @@ export const reviseDraftPostgres = (input: { if (stored !== undefined) { if (commandReceiptConflicts(stored, "ReviseDraft", payloadDigest)) { - return yield* new ContentCommandConflict({ commandId: command.commandId }); + return yield* ContentCommandConflict.make({ commandId: command.commandId }); } return yield* decodeStoredDraft(stored.resultJson); @@ -1126,7 +1127,7 @@ export const reviseDraftPostgres = (input: { const draft = yield* readDraftForUpdate(database, command.articleId); if (draft === undefined) { - return yield* new ContentArticleNotFound({}); + return yield* ContentArticleNotFound.make({}); } const departmentIds = yield* departmentIdsForArticles(database, [draft.articleId]); @@ -1140,8 +1141,8 @@ export const reviseDraftPostgres = (input: { }) ) { return yield* Predicate.isTagged(actor, "ContentEditor") - ? new ContentDraftNotOwned({ articleId: draft.articleId }) - : new ContentNotPublisher({ articleId: draft.articleId }); + ? ContentDraftNotOwned.make({ articleId: draft.articleId }) + : ContentNotPublisher.make({ articleId: draft.articleId }); } if ( @@ -1151,17 +1152,17 @@ export const reviseDraftPostgres = (input: { actor.departmentIds.includes(departmentId), )) ) { - return yield* new ContentNotInScope({}); + return yield* ContentNotInScope.make({}); } if (draft.revision !== command.expectedRevision) { - return yield* new ContentCommandConflict({ commandId: command.commandId }); + return yield* ContentCommandConflict.make({ commandId: command.commandId }); } const sticky = command.sticky ?? draft.sticky; if (sticky !== draft.sticky && !canPublishContent(actor, current)) { - return yield* new ContentNotPublisher({ articleId: draft.articleId }); + return yield* ContentNotPublisher.make({ articleId: draft.articleId }); } const revised = yield* database` @@ -1249,7 +1250,7 @@ export const readContentArticleHttpSourcePostgres = ( const row = rows[0]; - if (row === undefined) return yield* new ContentArticleNotFound({}); + if (row === undefined) return yield* ContentArticleNotFound.make({}); return yield* Schema.decodeUnknownEffect(ContentArticleHttpSourceSchema)(row, { onExcessProperty: "error", @@ -1393,17 +1394,37 @@ const PublishedNewsArticleHttpSourceSchema = Schema.Struct({ export type PublishedNewsArticleHttpSource = typeof PublishedNewsArticleHttpSourceSchema.Type; /** Authoritative current-pointer and immutable-version source for news detail. */ -export const readPublishedNewsArticleHttpSourcePostgres = ( - slug: string, - versionNumber?: number, -): Effect.Effect< - PublishedNewsArticleHttpSource, - ContentArticleNotFound | ContentDecodeError | ContentPersistenceError, - Database -> => - Database.use((database) => - Effect.gen(function* () { - const rows = yield* database` +export const readPublishedNewsArticleHttpSourcePostgres: { + ( + versionNumber?: number, + ): ( + slug: string, + ) => Effect.Effect< + PublishedNewsArticleHttpSource, + ContentArticleNotFound | ContentDecodeError | ContentPersistenceError, + Database + >; + ( + slug: string, + versionNumber?: number, + ): Effect.Effect< + PublishedNewsArticleHttpSource, + ContentArticleNotFound | ContentDecodeError | ContentPersistenceError, + Database + >; +} = dual( + (args) => Predicate.isString(args[0]), + ( + slug: string, + versionNumber?: number, + ): Effect.Effect< + PublishedNewsArticleHttpSource, + ContentArticleNotFound | ContentDecodeError | ContentPersistenceError, + Database + > => + Database.use((database) => + Effect.gen(function* () { + const rows = yield* database` SELECT article.article_id::integer AS "articleId", article.current_version_number AS "currentVersionNumber", @@ -1427,19 +1448,20 @@ export const readPublishedNewsArticleHttpSourcePostgres = ( ORDER BY version.version_number DESC LIMIT 1 `.pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("read public news HTTP article source", cause)), - ), - ); + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("read public news HTTP article source", cause)), + ), + ); - const row = rows[0]; + const row = rows[0]; - if (row === undefined) return yield* new ContentArticleNotFound({}); + if (row === undefined) return yield* ContentArticleNotFound.make({}); - return yield* Schema.decodeUnknownEffect(PublishedNewsArticleHttpSourceSchema)(row, { - onExcessProperty: "error", - }).pipe( - Effect.mapError((cause) => decodeError("decode public news HTTP article source", cause)), - ); - }), - ); + return yield* Schema.decodeUnknownEffect(PublishedNewsArticleHttpSourceSchema)(row, { + onExcessProperty: "error", + }).pipe( + Effect.mapError((cause) => decodeError("decode public news HTTP article source", cause)), + ); + }), + ), +); diff --git a/packages/database/src/database-construction.ts b/packages/database/src/database-construction.ts index 6f6cdd780..6bda5e5af 100644 --- a/packages/database/src/database-construction.ts +++ b/packages/database/src/database-construction.ts @@ -1,4 +1,5 @@ -import { Effect, FileSystem, Path } from "effect"; +import { Effect, FileSystem, Path, Predicate } from "effect"; +import { dual } from "effect/Function"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { databaseSchemaRevision, @@ -18,7 +19,7 @@ export interface DatabaseLayerObserver { readonly onRelease: () => void; } -export const databaseWithMigrations = ( +const makeDatabaseWithMigrations = ( executeMigration: ExecuteMigration, json: DatabaseOperations["json"], observer?: DatabaseLayerObserver, @@ -62,3 +63,17 @@ export const databaseWithMigrations = ( return database; }); + +type DatabaseWithMigrations = ReturnType; + +export const databaseWithMigrations: { + ( + json: DatabaseOperations["json"], + observer?: DatabaseLayerObserver, + ): (executeMigration: ExecuteMigration) => DatabaseWithMigrations; + ( + executeMigration: ExecuteMigration, + json: DatabaseOperations["json"], + observer?: DatabaseLayerObserver, + ): DatabaseWithMigrations; +} = dual((args) => Predicate.isFunction(args[1]), makeDatabaseWithMigrations); diff --git a/packages/database/src/database.test.ts b/packages/database/src/database.test.ts index ca5fe2b79..a585e21da 100644 --- a/packages/database/src/database.test.ts +++ b/packages/database/src/database.test.ts @@ -40,7 +40,11 @@ import { departmentIdForCommand, Organization, OrganizationCommandId, + OrganizationPersonAuthoritySchema, + requireOrganizationAdministrator, + requireTeamInterestScope, } from "@vektorprogrammet/domain/organization"; +import { activeAdministratorAuthority } from "@vektorprogrammet/domain/organization/authority-fixtures"; import { RecruitmentAssignmentObservationSchema, RecruitmentScheduleObservationSchema, @@ -91,7 +95,7 @@ import { listStaleReceiptOutboxClaimIds, recoverStaleReceiptOutbox, } from "./receipt/outbox.js"; -import { Context, Match, Predicate, Effect, FileSystem, Layer, Path } from "effect"; +import { Context, Match, Predicate, Effect, FileSystem, Layer, Path, Result } from "effect"; import { DatabaseTestLive, TestPlatform } from "./test-support/platform.js"; import { databaseMigrationDefinitions, @@ -134,7 +138,7 @@ const disposablePglite = Effect.acquireRelease( /** The PGlite that the shared recruitment layer owns, for tests that replay migration SQL on it. */ class RecruitmentPglite extends Context.Service()( - "@vektorprogrammet/database/test/RecruitmentPglite", + "@vektorprogrammet/database/database.test/RecruitmentPglite", ) {} const sharedRecruitmentLayer = Layer.unwrap( @@ -433,20 +437,19 @@ describe("DatabaseTest", () => { "constructs the complete schema in PGlite before it exposes the capability", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* database.health; + const database = yield* Database; + yield* database.health; - const migrations = yield* database<{ - readonly migration_id: number; - readonly name: string; - }>` + const migrations = yield* database<{ + readonly migration_id: number; + readonly name: string; + }>` SELECT migration_id, name FROM vektorprogrammet_schema_migrations ORDER BY migration_id `; - const tables = yield* database<{ readonly table_name: string }>` + const tables = yield* database<{ readonly table_name: string }>` SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' @@ -488,12 +491,11 @@ describe("DatabaseTest", () => { ORDER BY table_name `; - return { - revision: database.schemaRevision, - migrations, - tables: tables.map((row) => row.table_name), - }; - }); + const evidence = { + revision: database.schemaRevision, + migrations, + tables: tables.map((row) => row.table_name), + }; expect(evidence).toEqual({ revision: databaseSchemaRevision, @@ -562,89 +564,87 @@ describe("DatabaseTest", () => { it.effect("executes Admissions and Organization authority adapters against PGlite", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* database` + const database = yield* Database; + yield* database` INSERT INTO admission_period_departments (department_id, name) VALUES ('adapter-department', 'Adapter Department') `; - yield* database` + yield* database` INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ('adapter-semester', '2035-01-01T00:00:00.000Z', '2035-07-01T00:00:00.000Z') `; - const created = yield* executeAdmissionPeriodCommand( - AdmissionPeriodCommandSchema.cases.CreateAdmissionPeriod.make({ - commandId: AdmissionPeriodCommandId.make("adapter-create"), - departmentId: DepartmentId.make("adapter-department"), - semesterId: SemesterId.make("adapter-semester"), - startAt: "2035-02-01T00:00:00.000Z", - endAt: "2035-03-01T00:00:00.000Z", + const created = yield* executeAdmissionPeriodCommand( + AdmissionPeriodCommandSchema.cases.CreateAdmissionPeriod.make({ + commandId: AdmissionPeriodCommandId.make("adapter-create"), + departmentId: DepartmentId.make("adapter-department"), + semesterId: SemesterId.make("adapter-semester"), + startAt: "2035-02-01T00:00:00.000Z", + endAt: "2035-03-01T00:00:00.000Z", + }), + { + actor: AdmissionPeriodActorSchema.cases.GlobalAdmin.make({ + personId: PersonId.make("adapter-admin"), + active: true, }), - { - actor: AdmissionPeriodActorSchema.cases.GlobalAdmin.make({ - personId: PersonId.make("adapter-admin"), - active: true, - }), - now: "2035-01-15T00:00:00.000Z", - admissionPeriodId: AdmissionPeriodId.make("adapter-period"), - }, - ); + now: "2035-01-15T00:00:00.000Z", + admissionPeriodId: AdmissionPeriodId.make("adapter-period"), + }, + ); - const open = yield* listOpenAdmissionPeriods("2035-02-15T00:00:00.000Z"); + const open = yield* listOpenAdmissionPeriods("2035-02-15T00:00:00.000Z"); - const organizationSnapshot = { - identities: { - persons: {}, - departments: { "700": "700" }, - teams: {}, - memberships: {}, - positions: {}, + const organizationSnapshot = { + identities: { + persons: {}, + departments: { "700": "700" }, + teams: {}, + memberships: {}, + positions: {}, + }, + sourceRepository: "database-test", + sourceRevision: "adapter-revision-1", + snapshotId: "adapter-snapshot-1", + transformationRevision: "adapter-transform-1", + departments: [ + { + id: 700, + name: "Organization Adapter", + shortName: "OA", + email: "adapter@example.invalid", + city: "Bergen", }, - sourceRepository: "database-test", - sourceRevision: "adapter-revision-1", - snapshotId: "adapter-snapshot-1", - transformationRevision: "adapter-transform-1", - departments: [ - { - id: 700, - name: "Organization Adapter", - shortName: "OA", - email: "adapter@example.invalid", - city: "Bergen", - }, - ], - teams: [], - memberships: [{ id: "malformed" }], - } as const; - - const imported = yield* importOrganizationSnapshot(organizationSnapshot); - yield* importOrganizationSnapshot({ - ...organizationSnapshot, - sourceRevision: "adapter-revision-2", - snapshotId: "adapter-snapshot-2", - departments: [ - { - ...organizationSnapshot.departments[0], - name: "Conflicting Organization Adapter", - }, - ], - memberships: [], - }); + ], + teams: [], + memberships: [{ id: "malformed" }], + } as const; + + const imported = yield* importOrganizationSnapshot(organizationSnapshot); + yield* importOrganizationSnapshot({ + ...organizationSnapshot, + sourceRevision: "adapter-revision-2", + snapshotId: "adapter-snapshot-2", + departments: [ + { + ...organizationSnapshot.departments[0], + name: "Conflicting Organization Adapter", + }, + ], + memberships: [], + }); - const collisions = yield* database<{ readonly reason: string }>` + const collisions = yield* database<{ readonly reason: string }>` SELECT reason FROM organization_membership_quarantine WHERE source_revision = 'adapter-revision-2' `; - return { - created: created.observation._tag, - open: open.map((period) => period.id), - quarantined: imported.quarantined.map((row) => row.reason), - collisions: collisions.map((row) => row.reason), - }; - }); + const evidence = { + created: created.observation._tag, + open: open.map((period) => period.id), + quarantined: imported.quarantined.map((row) => row.reason), + collisions: collisions.map((row) => row.reason), + }; expect(evidence).toEqual({ created: "Created", @@ -734,14 +734,13 @@ describe("DatabaseTest", () => { it.effect("runs the Economy authority contract against PGlite", () => Effect.gen(function* () { - yield* Effect.gen(function* () { - const database = yield* Database; - yield* database.unsafe(` + const database = yield* Database; + yield* database.unsafe(` INSERT INTO person_profiles (person_id, first_name, last_name) VALUES ('pglite-owner', 'PGlite', 'Owner') ON CONFLICT (person_id) DO NOTHING `); - yield* database.unsafe(` + yield* database.unsafe(` INSERT INTO organization_departments ( department_id, name, short_name, email, city ) VALUES ( @@ -749,12 +748,12 @@ describe("DatabaseTest", () => { 'pglite@example.invalid', 'Bergen' ) ON CONFLICT (department_id) DO NOTHING `); - yield* database.unsafe(` + yield* database.unsafe(` INSERT INTO organization_teams (team_id, department_id, name) VALUES ('pglite-team', 'pglite-department', 'PGlite Team') ON CONFLICT (team_id) DO NOTHING `); - yield* database.unsafe(` + yield* database.unsafe(` INSERT INTO organization_memberships ( membership_id, person_id, team_id, start_at ) VALUES ( @@ -762,7 +761,7 @@ describe("DatabaseTest", () => { '2026-08-01T00:00:00.000Z' ) ON CONFLICT (membership_id) DO NOTHING `); - yield* database.unsafe(` + yield* database.unsafe(` INSERT INTO economy_payment_authorities ( payment_authority_id, person_id, department_id, payment_account_ciphertext, start_at @@ -771,7 +770,6 @@ describe("DatabaseTest", () => { 'ciphertext:v1:pglite-account', '2026-08-01T00:00:00.000Z' ) ON CONFLICT (payment_authority_id) DO NOTHING `); - }); const command = { _tag: "SubmitReceipt" as const, @@ -801,11 +799,11 @@ describe("DatabaseTest", () => { { const actual = yield* Effect.flip( - executeReceiptCommand( + executeReceiptCommand({ command, - { ...principal, authorizationInstant: "2026-08-23 12:00:00" }, + principal: { ...principal, authorizationInstant: "2026-08-23 12:00:00" }, allocation, - ), + }), ); expect(actual).toHaveProperty("_tag", "ReceiptDecodeError"); @@ -988,18 +986,17 @@ describe("DatabaseTest", () => { it.effect("keeps missing Receipt targets opaque to active approvers", () => Effect.gen(function* () { - const failures = yield* Effect.gen(function* () { - const database = yield* Database; - const economy = yield* Economy; - const departmentId = DepartmentId.make("missing-receipt-department"); - const globalDirect = PersonId.make("missing-receipt-global-direct"); - const globalRule = PersonId.make("missing-receipt-global-rule"); - const receiptRule = PersonId.make("missing-receipt-receipt-rule"); - const departmentDirect = PersonId.make("missing-receipt-department-direct"); - const departmentRule = PersonId.make("missing-receipt-department-rule"); - const authorizationInstant = "2036-06-15T12:00:00.000Z"; - - yield* database` + const database = yield* Database; + const economy = yield* Economy; + const departmentId = DepartmentId.make("missing-receipt-department"); + const globalDirect = PersonId.make("missing-receipt-global-direct"); + const globalRule = PersonId.make("missing-receipt-global-rule"); + const receiptRule = PersonId.make("missing-receipt-receipt-rule"); + const departmentDirect = PersonId.make("missing-receipt-department-direct"); + const departmentRule = PersonId.make("missing-receipt-department-rule"); + const authorizationInstant = "2036-06-15T12:00:00.000Z"; + + yield* database` INSERT INTO person_profiles (person_id, first_name, last_name) VALUES (${globalDirect}, 'Global', 'Direct'), @@ -1008,7 +1005,7 @@ describe("DatabaseTest", () => { (${departmentDirect}, 'Department', 'Direct'), (${departmentRule}, 'Department', 'Rule') `; - yield* database` + yield* database` INSERT INTO organization_departments ( department_id, name, short_name, email, city ) VALUES ( @@ -1016,11 +1013,11 @@ describe("DatabaseTest", () => { 'missing-receipt@example.invalid', 'Bergen' ) `; - yield* database` + yield* database` INSERT INTO organization_teams (team_id, department_id, name) VALUES ('missing-receipt-team', ${departmentId}, 'Missing Receipt Team') `; - yield* database` + yield* database` INSERT INTO organization_memberships ( membership_id, person_id, team_id, start_at ) VALUES @@ -1035,7 +1032,7 @@ describe("DatabaseTest", () => { ('missing-receipt-membership-department-rule', ${departmentRule}, 'missing-receipt-team', '2036-01-01T00:00:00.000Z') `; - yield* database` + yield* database` INSERT INTO economy_receipt_approval_grants ( approval_grant_id, person_id, scope, department_id, start_at ) VALUES @@ -1044,7 +1041,7 @@ describe("DatabaseTest", () => { ('missing-receipt-department-direct-grant', ${departmentDirect}, 'Department', ${departmentId}, '2036-01-01T00:00:00.000Z') `; - yield* database` + yield* database` INSERT INTO public.authz_rules ( rule_id, capability_id, effect_kind, subject_kind, subject_person_id, subject_tag_id, scope, domain_id, department_id, params, start_at @@ -1069,69 +1066,68 @@ describe("DatabaseTest", () => { ) `; - const directGlobalFailure = yield* Effect.flip( - economy.executeReceipt( - ReceiptCommandRequestSchema.cases.ApproveReceipt.make({ - commandId: "missing-receipt-command-global-direct", - receiptId: ReceiptId.make("missing-receipt-global-direct-target"), - expectedRevision: 0, - }), - { personId: globalDirect, authorizationInstant }, - ), - ); + const directGlobalFailure = yield* Effect.flip( + economy.executeReceipt( + ReceiptCommandRequestSchema.cases.ApproveReceipt.make({ + commandId: "missing-receipt-command-global-direct", + receiptId: ReceiptId.make("missing-receipt-global-direct-target"), + expectedRevision: 0, + }), + { personId: globalDirect, authorizationInstant }, + ), + ); - const globalRuleFailure = yield* Effect.flip( - economy.executeReceipt( - ReceiptCommandRequestSchema.cases.RejectReceipt.make({ - commandId: "missing-receipt-command-global-rule", - receiptId: ReceiptId.make("missing-receipt-global-rule-target"), - expectedRevision: 0, - }), - { personId: globalRule, authorizationInstant }, - ), - ); + const globalRuleFailure = yield* Effect.flip( + economy.executeReceipt( + ReceiptCommandRequestSchema.cases.RejectReceipt.make({ + commandId: "missing-receipt-command-global-rule", + receiptId: ReceiptId.make("missing-receipt-global-rule-target"), + expectedRevision: 0, + }), + { personId: globalRule, authorizationInstant }, + ), + ); - const receiptRuleFailure = yield* Effect.flip( - economy.executeReceipt( - ReceiptCommandRequestSchema.cases.ApproveReceipt.make({ - commandId: "missing-receipt-command-receipt-rule", - receiptId: ReceiptId.make("missing-receipt-receipt-rule-target"), - expectedRevision: 0, - }), - { personId: receiptRule, authorizationInstant }, - ), - ); + const receiptRuleFailure = yield* Effect.flip( + economy.executeReceipt( + ReceiptCommandRequestSchema.cases.ApproveReceipt.make({ + commandId: "missing-receipt-command-receipt-rule", + receiptId: ReceiptId.make("missing-receipt-receipt-rule-target"), + expectedRevision: 0, + }), + { personId: receiptRule, authorizationInstant }, + ), + ); - const directDepartmentFailure = yield* Effect.flip( - economy.executeReceipt( - ReceiptCommandRequestSchema.cases.RejectReceipt.make({ - commandId: "missing-receipt-command-department-direct", - receiptId: ReceiptId.make("missing-receipt-department-direct-target"), - expectedRevision: 0, - }), - { personId: departmentDirect, authorizationInstant }, - ), - ); + const directDepartmentFailure = yield* Effect.flip( + economy.executeReceipt( + ReceiptCommandRequestSchema.cases.RejectReceipt.make({ + commandId: "missing-receipt-command-department-direct", + receiptId: ReceiptId.make("missing-receipt-department-direct-target"), + expectedRevision: 0, + }), + { personId: departmentDirect, authorizationInstant }, + ), + ); - const departmentRuleFailure = yield* Effect.flip( - economy.executeReceipt( - ReceiptCommandRequestSchema.cases.ApproveReceipt.make({ - commandId: "missing-receipt-command-department-rule", - receiptId: ReceiptId.make("missing-receipt-department-rule-target"), - expectedRevision: 0, - }), - { personId: departmentRule, authorizationInstant }, - ), - ); + const departmentRuleFailure = yield* Effect.flip( + economy.executeReceipt( + ReceiptCommandRequestSchema.cases.ApproveReceipt.make({ + commandId: "missing-receipt-command-department-rule", + receiptId: ReceiptId.make("missing-receipt-department-rule-target"), + expectedRevision: 0, + }), + { personId: departmentRule, authorizationInstant }, + ), + ); - return { - directGlobal: directGlobalFailure._tag, - globalRule: globalRuleFailure._tag, - receiptRule: receiptRuleFailure._tag, - directDepartment: directDepartmentFailure._tag, - departmentRule: departmentRuleFailure._tag, - }; - }); + const failures = { + directGlobal: directGlobalFailure._tag, + globalRule: globalRuleFailure._tag, + receiptRule: receiptRuleFailure._tag, + directDepartment: directDepartmentFailure._tag, + departmentRule: departmentRuleFailure._tag, + }; expect(failures).toEqual({ directGlobal: "ReceiptNotFound", @@ -1147,201 +1143,199 @@ describe("DatabaseTest", () => { Effect.gen(function* () { const interpreter = makeRecordingPublicApplicationEffectInterpreter(); - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* outboxReferenceFixture; - yield* executePublicApplicationCommand( - { - commandId: "outbox-application-submit", - departmentId: "outbox-department", - firstName: "Ada", - lastName: "Lovelace", - phone: "+47 12345678", - email: "ada.outbox@example.invalid", - gender: 1, - fieldOfStudyId: "outbox-field", - yearOfStudy: 3, - availability: applicationAvailability, - }, - { - now: "2031-09-15T12:00:00.000Z", - applicantId: ApplicantIdSchema.make("outbox-applicant"), - applicationId: PublicApplicationIdSchema.make("outbox-application"), - activationToken: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ", - }, - ); + const database = yield* Database; + yield* outboxReferenceFixture; + yield* executePublicApplicationCommand( + { + commandId: "outbox-application-submit", + departmentId: "outbox-department", + firstName: "Ada", + lastName: "Lovelace", + phone: "+47 12345678", + email: "ada.outbox@example.invalid", + gender: 1, + fieldOfStudyId: "outbox-field", + yearOfStudy: 3, + availability: applicationAvailability, + }, + { + now: "2031-09-15T12:00:00.000Z", + applicantId: ApplicantIdSchema.make("outbox-applicant"), + applicationId: PublicApplicationIdSchema.make("outbox-application"), + activationToken: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ", + }, + ); - const rows = yield* database<{ - readonly effect_id: string; - readonly effect_type: string; - readonly ordinal: number; - readonly payload_json: unknown; - }>` + const rows = yield* database<{ + readonly effect_id: string; + readonly effect_type: string; + readonly ordinal: number; + readonly payload_json: unknown; + }>` SELECT effect_id, effect_type, ordinal, payload_json FROM admission_application_outbox WHERE command_id = 'outbox-application-submit' ORDER BY ordinal `; - const firstEffectId = rows[0]?.effect_id; + const firstEffectId = rows[0]?.effect_id; - if (firstEffectId === undefined) throw new Error("missing applicant outbox effect"); + if (firstEffectId === undefined) throw new Error("missing applicant outbox effect"); - const applicantRows = yield* database<{ readonly activation_digest: string | null }>` + const applicantRows = yield* database<{ readonly activation_digest: string | null }>` SELECT activation_digest FROM admission_applicants WHERE applicant_id = 'outbox-applicant' `; - interpreter.failOnce(firstEffectId); + interpreter.failOnce(firstEffectId); - const failed = yield* deliverNextPublicApplicationOutbox( - "outbox-failed-claim", - "2031-09-15T12:00:01.000Z", - interpreter, - ); + const failed = yield* deliverNextPublicApplicationOutbox( + "outbox-failed-claim", + "2031-09-15T12:00:01.000Z", + interpreter, + ); - const failedRows = yield* database<{ - readonly status: string; - readonly claim_id: string | null; - readonly last_failure_tag: string | null; - }>` + const failedRows = yield* database<{ + readonly status: string; + readonly claim_id: string | null; + readonly last_failure_tag: string | null; + }>` SELECT status, claim_id, last_failure_tag FROM admission_application_outbox WHERE effect_id = ${firstEffectId} `; - const retried = yield* deliverNextPublicApplicationOutbox( - "outbox-retry-claim", - "2031-09-15T12:00:02.000Z", - interpreter, - ); + const retried = yield* deliverNextPublicApplicationOutbox( + "outbox-retry-claim", + "2031-09-15T12:00:02.000Z", + interpreter, + ); - const remaining = [ - yield* deliverNextPublicApplicationOutbox( - "outbox-remaining-claim-1", - "2031-09-15T12:00:03.000Z", - interpreter, - ), - yield* deliverNextPublicApplicationOutbox( - "outbox-remaining-claim-2", - "2031-09-15T12:00:04.000Z", - interpreter, - ), - ]; + const remaining = [ + yield* deliverNextPublicApplicationOutbox( + "outbox-remaining-claim-1", + "2031-09-15T12:00:03.000Z", + interpreter, + ), + yield* deliverNextPublicApplicationOutbox( + "outbox-remaining-claim-2", + "2031-09-15T12:00:04.000Z", + interpreter, + ), + ]; - const deliveredRows = yield* database<{ - readonly ordinal: number; - readonly status: string; - readonly payload_json: unknown; - }>` + const deliveredRows = yield* database<{ + readonly ordinal: number; + readonly status: string; + readonly payload_json: unknown; + }>` SELECT ordinal, status, payload_json FROM admission_application_outbox WHERE command_id = 'outbox-application-submit' ORDER BY ordinal `; - const deliveryEvidence = interpreter.snapshot(); - yield* executePublicApplicationCommand( - { - commandId: "outbox-fairness-old", - departmentId: "outbox-department", - firstName: "Old", - lastName: "Failure", - phone: "+47 11111111", - email: "old.failure@example.invalid", - gender: 0, - fieldOfStudyId: "outbox-field", - yearOfStudy: 1, - availability: applicationAvailability, - }, - { - now: "2031-09-15T12:10:00.000Z", - applicantId: ApplicantIdSchema.make("outbox-fairness-old-applicant"), - applicationId: PublicApplicationIdSchema.make("outbox-fairness-old-application"), - activationToken: "oldfailureabcdefghijklmnopqrstuvwxyzABCDEFG", - }, - ); - yield* executePublicApplicationCommand( - { - commandId: "outbox-fairness-new", - departmentId: "outbox-department", - firstName: "New", - lastName: "Application", - phone: "+47 22222222", - email: "new.application@example.invalid", - gender: 1, - fieldOfStudyId: "outbox-field", - yearOfStudy: 2, - availability: applicationAvailability, - }, - { - now: "2031-09-15T12:11:00.000Z", - applicantId: ApplicantIdSchema.make("outbox-fairness-new-applicant"), - applicationId: PublicApplicationIdSchema.make("outbox-fairness-new-application"), - activationToken: "newapplicationabcdefghijklmnopqrstuvwxyzABC", - }, - ); + const deliveryEvidence = interpreter.snapshot(); + yield* executePublicApplicationCommand( + { + commandId: "outbox-fairness-old", + departmentId: "outbox-department", + firstName: "Old", + lastName: "Failure", + phone: "+47 11111111", + email: "old.failure@example.invalid", + gender: 0, + fieldOfStudyId: "outbox-field", + yearOfStudy: 1, + availability: applicationAvailability, + }, + { + now: "2031-09-15T12:10:00.000Z", + applicantId: ApplicantIdSchema.make("outbox-fairness-old-applicant"), + applicationId: PublicApplicationIdSchema.make("outbox-fairness-old-application"), + activationToken: "oldfailureabcdefghijklmnopqrstuvwxyzABCDEFG", + }, + ); + yield* executePublicApplicationCommand( + { + commandId: "outbox-fairness-new", + departmentId: "outbox-department", + firstName: "New", + lastName: "Application", + phone: "+47 22222222", + email: "new.application@example.invalid", + gender: 1, + fieldOfStudyId: "outbox-field", + yearOfStudy: 2, + availability: applicationAvailability, + }, + { + now: "2031-09-15T12:11:00.000Z", + applicantId: ApplicantIdSchema.make("outbox-fairness-new-applicant"), + applicationId: PublicApplicationIdSchema.make("outbox-fairness-new-application"), + activationToken: "newapplicationabcdefghijklmnopqrstuvwxyzABC", + }, + ); - const fairnessRows = yield* database<{ readonly effect_id: string }>` + const fairnessRows = yield* database<{ readonly effect_id: string }>` SELECT effect_id FROM admission_application_outbox WHERE command_id = 'outbox-fairness-old' AND ordinal = 0 `; - const fairnessOldEffectId = fairnessRows[0]?.effect_id; + const fairnessOldEffectId = fairnessRows[0]?.effect_id; - if (fairnessOldEffectId === undefined) throw new Error("missing fairness outbox effect"); - interpreter.failOnce(fairnessOldEffectId); + if (fairnessOldEffectId === undefined) throw new Error("missing fairness outbox effect"); + interpreter.failOnce(fairnessOldEffectId); - const fairnessFailure = yield* deliverNextPublicApplicationOutbox( - "outbox-fairness-failed", - "2031-09-15T12:12:00.000Z", - interpreter, - ); + const fairnessFailure = yield* deliverNextPublicApplicationOutbox( + "outbox-fairness-failed", + "2031-09-15T12:12:00.000Z", + interpreter, + ); - const fairnessNext = yield* deliverNextPublicApplicationOutbox( - "outbox-fairness-next", - "2031-09-15T12:12:01.000Z", - interpreter, - ); + const fairnessNext = yield* deliverNextPublicApplicationOutbox( + "outbox-fairness-next", + "2031-09-15T12:12:01.000Z", + interpreter, + ); - const fairnessDrain = yield* Effect.forEach( - Array.from({ length: 5 }, (_, index) => index), - (index) => - deliverNextPublicApplicationOutbox( - `outbox-fairness-drain-${index}`, - `2031-09-15T12:12:0${index + 2}.000Z`, - interpreter, - ), - ); + const fairnessDrain = yield* Effect.forEach( + Array.from({ length: 5 }, (_, index) => index), + (index) => + deliverNextPublicApplicationOutbox( + `outbox-fairness-drain-${index}`, + `2031-09-15T12:12:0${index + 2}.000Z`, + interpreter, + ), + ); - const fairnessIdle = yield* deliverNextPublicApplicationOutbox( - "outbox-fairness-idle", - "2031-09-15T12:12:07.000Z", - interpreter, - ); + const fairnessIdle = yield* deliverNextPublicApplicationOutbox( + "outbox-fairness-idle", + "2031-09-15T12:12:07.000Z", + interpreter, + ); - return { - activationPayload: rows[0]?.payload_json, - applicantDigest: applicantRows[0]?.activation_digest, - failed, - failedRow: failedRows[0], - retried, - deliveredPayload: deliveredRows, - effects: rows.map(({ effect_id, effect_type, ordinal }) => ({ - effectId: effect_id, - effectType: effect_type, - ordinal, - })), - remaining, - deliveryEvidence, - fairnessFailure, - fairnessNext, - fairnessDrain, - fairnessIdle, - }; - }); + const evidence = { + activationPayload: rows[0]?.payload_json, + applicantDigest: applicantRows[0]?.activation_digest, + failed, + failedRow: failedRows[0], + retried, + deliveredPayload: deliveredRows, + effects: rows.map(({ effect_id, effect_type, ordinal }) => ({ + effectId: effect_id, + effectType: effect_type, + ordinal, + })), + remaining, + deliveryEvidence, + fairnessFailure, + fairnessNext, + fairnessDrain, + fairnessIdle, + }; expect(evidence.activationPayload).toMatchObject({ activationToken: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ", @@ -1417,31 +1411,29 @@ describe("DatabaseTest", () => { it.effect("makes applicant transaction linkage and effect ordering unrepresentable", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; + const database = yield* Database; - const orderFailure = yield* Effect.flip(database` + const orderFailure = yield* Effect.flip(database` UPDATE admission_application_outbox SET effect_type = 'CreateAdmissionSubscription' WHERE command_id = 'outbox-application-submit' AND ordinal = 0 `); - const receiptFailure = yield* Effect.flip(database` + const receiptFailure = yield* Effect.flip(database` DELETE FROM admission_application_command_receipts WHERE command_id = 'outbox-application-submit' `); - const rows = yield* database<{ - readonly ordinal: number; - readonly effect_type: string; - }>` + const rows = yield* database<{ + readonly ordinal: number; + readonly effect_type: string; + }>` SELECT ordinal, effect_type FROM admission_application_outbox WHERE command_id = 'outbox-application-submit' AND ordinal = 0 `; - return { orderFailure, receiptFailure, row: rows[0] }; - }); + const evidence = { orderFailure, receiptFailure, row: rows[0] }; expect(evidence.orderFailure).toHaveProperty("_tag", "SqlError"); expect(evidence.receiptFailure).toHaveProperty("_tag", "SqlError"); @@ -1454,59 +1446,57 @@ describe("DatabaseTest", () => { it.effect("quarantines an invalid persisted payload without stopping the queue", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* executePublicApplicationCommand( - { - commandId: "malformed-effect-application-submit", - departmentId: "outbox-department", - firstName: "Malformed", - lastName: "Payload", - phone: "+47 44444444", - email: "malformed.payload@example.invalid", - gender: 1, - fieldOfStudyId: "outbox-field", - yearOfStudy: 2, - availability: applicationAvailability, - }, - { - now: "2031-09-15T12:21:00.000Z", - applicantId: ApplicantIdSchema.make("malformed-effect-applicant"), - applicationId: PublicApplicationIdSchema.make("malformed-effect-application"), - activationToken: "malformedpayloadabcdefghijklmnopqrstuvwxyzA", - }, - ); - yield* database` + const database = yield* Database; + yield* executePublicApplicationCommand( + { + commandId: "malformed-effect-application-submit", + departmentId: "outbox-department", + firstName: "Malformed", + lastName: "Payload", + phone: "+47 44444444", + email: "malformed.payload@example.invalid", + gender: 1, + fieldOfStudyId: "outbox-field", + yearOfStudy: 2, + availability: applicationAvailability, + }, + { + now: "2031-09-15T12:21:00.000Z", + applicantId: ApplicantIdSchema.make("malformed-effect-applicant"), + applicationId: PublicApplicationIdSchema.make("malformed-effect-application"), + activationToken: "malformedpayloadabcdefghijklmnopqrstuvwxyzA", + }, + ); + yield* database` UPDATE admission_application_outbox SET payload_json = '{"_tag":"SendApplicantActivationOrConfirmation"}'::jsonb WHERE command_id = 'malformed-effect-application-submit' AND ordinal = 0 `; - const result = yield* deliverNextPublicApplicationOutbox( - "malformed-effect-claim", - "2031-09-15T12:21:01.000Z", - makeRecordingPublicApplicationEffectInterpreter(), - ); + const result = yield* deliverNextPublicApplicationOutbox( + "malformed-effect-claim", + "2031-09-15T12:21:01.000Z", + makeRecordingPublicApplicationEffectInterpreter(), + ); - const rows = yield* database<{ - readonly status: string; - readonly attempts: number; - readonly claim_id: string | null; - readonly last_failure_tag: string | null; - readonly payload_json: unknown; - }>` + const rows = yield* database<{ + readonly status: string; + readonly attempts: number; + readonly claim_id: string | null; + readonly last_failure_tag: string | null; + readonly payload_json: unknown; + }>` SELECT status, attempts, claim_id, last_failure_tag, payload_json FROM admission_application_outbox WHERE command_id = 'malformed-effect-application-submit' AND ordinal = 0 `; - yield* database` + yield* database` DELETE FROM admission_application_outbox WHERE command_id = 'malformed-effect-application-submit' `; - return { result, row: rows[0] }; - }); + const evidence = { result, row: rows[0] }; expect(evidence.result).toEqual(PublicApplicationOutboxDeliveryResult.Idle()); expect(evidence.row).toEqual({ @@ -1521,29 +1511,28 @@ describe("DatabaseTest", () => { it.effect("quarantines a valid outbox payload that diverges from application authority", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* executePublicApplicationCommand( - { - commandId: "tampered-effect-application-submit", - departmentId: "outbox-department", - firstName: "Tampered", - lastName: "Payload", - phone: "+47 45555555", - email: "tampered.payload@example.invalid", - gender: 1, - fieldOfStudyId: "outbox-field", - yearOfStudy: 2, - availability: applicationAvailability, - }, - { - now: "2031-09-15T12:22:00.000Z", - applicantId: ApplicantIdSchema.make("tampered-effect-applicant"), - applicationId: PublicApplicationIdSchema.make("tampered-effect-application"), - activationToken: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ", - }, - ); - yield* database` + const database = yield* Database; + yield* executePublicApplicationCommand( + { + commandId: "tampered-effect-application-submit", + departmentId: "outbox-department", + firstName: "Tampered", + lastName: "Payload", + phone: "+47 45555555", + email: "tampered.payload@example.invalid", + gender: 1, + fieldOfStudyId: "outbox-field", + yearOfStudy: 2, + availability: applicationAvailability, + }, + { + now: "2031-09-15T12:22:00.000Z", + applicantId: ApplicantIdSchema.make("tampered-effect-applicant"), + applicationId: PublicApplicationIdSchema.make("tampered-effect-application"), + activationToken: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ", + }, + ); + yield* database` UPDATE admission_application_outbox SET payload_json = jsonb_set( payload_json, @@ -1553,31 +1542,30 @@ describe("DatabaseTest", () => { WHERE command_id = 'tampered-effect-application-submit' AND ordinal = 0 `; - const result = yield* deliverNextPublicApplicationOutbox( - "tampered-effect-claim", - "2031-09-15T12:22:01.000Z", - makeRecordingPublicApplicationEffectInterpreter(), - ); + const result = yield* deliverNextPublicApplicationOutbox( + "tampered-effect-claim", + "2031-09-15T12:22:01.000Z", + makeRecordingPublicApplicationEffectInterpreter(), + ); - const rows = yield* database<{ - readonly status: string; - readonly attempts: number; - readonly claim_id: string | null; - readonly last_failure_tag: string | null; - readonly payload_json: unknown; - }>` + const rows = yield* database<{ + readonly status: string; + readonly attempts: number; + readonly claim_id: string | null; + readonly last_failure_tag: string | null; + readonly payload_json: unknown; + }>` SELECT status, attempts, claim_id, last_failure_tag, payload_json FROM admission_application_outbox WHERE command_id = 'tampered-effect-application-submit' AND ordinal = 0 `; - yield* database` + yield* database` DELETE FROM admission_application_outbox WHERE command_id = 'tampered-effect-application-submit' `; - return { result, row: rows[0] }; - }); + const evidence = { result, row: rows[0] }; expect(evidence.result).toEqual(PublicApplicationOutboxDeliveryResult.Idle()); expect(evidence.row).toEqual({ @@ -1596,37 +1584,36 @@ describe("DatabaseTest", () => { const secondToken = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq"; const interpreter = makeRecordingPublicApplicationEffectInterpreter(); - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* executePublicApplicationCommand( - { - commandId: "snapshot-first-period-submit", - departmentId: "outbox-department", - firstName: "Snapshot", - lastName: "Applicant", - phone: "+47 48888888", - email: "snapshot.applicant@example.invalid", - gender: 1, - fieldOfStudyId: "outbox-field", - yearOfStudy: 2, - availability: applicationAvailability, - }, - { - now: "2031-09-15T12:22:30.000Z", - applicantId: ApplicantIdSchema.make("snapshot-applicant"), - applicationId: PublicApplicationIdSchema.make("snapshot-first-application"), - activationToken: firstToken, - }, - ); - yield* database` - INSERT INTO admission_period_semesters (semester_id, start_at, end_at) + const database = yield* Database; + yield* executePublicApplicationCommand( + { + commandId: "snapshot-first-period-submit", + departmentId: "outbox-department", + firstName: "Snapshot", + lastName: "Applicant", + phone: "+47 48888888", + email: "snapshot.applicant@example.invalid", + gender: 1, + fieldOfStudyId: "outbox-field", + yearOfStudy: 2, + availability: applicationAvailability, + }, + { + now: "2031-09-15T12:22:30.000Z", + applicantId: ApplicantIdSchema.make("snapshot-applicant"), + applicationId: PublicApplicationIdSchema.make("snapshot-first-application"), + activationToken: firstToken, + }, + ); + yield* database` + INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ( 'outbox-second-semester', '2032-08-01T00:00:00.000Z', '2032-12-31T00:00:00.000Z' ) `; - yield* database` + yield* database` INSERT INTO admission_periods ( admission_period_id, department_id, semester_id, start_at, end_at, revision, last_command_id @@ -1640,37 +1627,37 @@ describe("DatabaseTest", () => { 'outbox-second-period-seed' ) `; - yield* executePublicApplicationCommand( - { - commandId: "snapshot-second-period-submit", - departmentId: "outbox-department", - firstName: "Snapshot", - lastName: "Applicant", - phone: "+47 49999999", - email: "SNAPSHOT.APPLICANT@example.invalid", - gender: 1, - fieldOfStudyId: "outbox-field", - yearOfStudy: 3, - availability: applicationAvailability, - }, - { - now: "2032-09-15T12:22:30.000Z", - applicantId: ApplicantIdSchema.make("ignored-existing-applicant"), - applicationId: PublicApplicationIdSchema.make("snapshot-second-application"), - activationToken: secondToken, - }, - ); + yield* executePublicApplicationCommand( + { + commandId: "snapshot-second-period-submit", + departmentId: "outbox-department", + firstName: "Snapshot", + lastName: "Applicant", + phone: "+47 49999999", + email: "SNAPSHOT.APPLICANT@example.invalid", + gender: 1, + fieldOfStudyId: "outbox-field", + yearOfStudy: 3, + availability: applicationAvailability, + }, + { + now: "2032-09-15T12:22:30.000Z", + applicantId: ApplicantIdSchema.make("ignored-existing-applicant"), + applicationId: PublicApplicationIdSchema.make("snapshot-second-application"), + activationToken: secondToken, + }, + ); - const delivery = yield* deliverNextPublicApplicationOutbox( - "snapshot-old-period-claim", - "2032-09-15T12:22:31.000Z", - interpreter, - ); + const delivery = yield* deliverNextPublicApplicationOutbox( + "snapshot-old-period-claim", + "2032-09-15T12:22:31.000Z", + interpreter, + ); - const applications = yield* database<{ - readonly application_id: string; - readonly activation_digest: string | null; - }>` + const applications = yield* database<{ + readonly application_id: string; + readonly activation_digest: string | null; + }>` SELECT application_id, activation_digest FROM admission_applications WHERE application_id IN ( @@ -1680,13 +1667,13 @@ describe("DatabaseTest", () => { ORDER BY application_id `; - const applicants = yield* database<{ readonly activation_digest: string | null }>` + const applicants = yield* database<{ readonly activation_digest: string | null }>` SELECT activation_digest FROM admission_applicants WHERE applicant_id = 'snapshot-applicant' `; - yield* database` + yield* database` DELETE FROM admission_application_outbox WHERE command_id IN ( 'snapshot-first-period-submit', @@ -1694,12 +1681,11 @@ describe("DatabaseTest", () => { ) `; - return { - delivery, - applications, - applicantDigest: applicants[0]?.activation_digest, - }; - }); + const evidence = { + delivery, + applications, + applicantDigest: applicants[0]?.activation_digest, + }; expect(evidence.delivery).toHaveProperty("_tag", "Delivered"); expect(evidence.delivery).toMatchObject({ @@ -1724,88 +1710,86 @@ describe("DatabaseTest", () => { it.effect("quarantines an outbox row cross-linked to another command transaction", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* executePublicApplicationCommand( - { - commandId: "cross-linked-target-submit", - departmentId: "outbox-department", - firstName: "Target", - lastName: "Application", - phone: "+47 46666666", - email: "cross.linked.target@example.invalid", - gender: 0, - fieldOfStudyId: "outbox-field", - yearOfStudy: 2, - availability: applicationAvailability, - }, - { - now: "2031-09-15T12:23:00.000Z", - applicantId: ApplicantIdSchema.make("cross-linked-target-applicant"), - applicationId: PublicApplicationIdSchema.make("cross-linked-target-application"), - activationToken: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ", - }, - ); - yield* database` + const database = yield* Database; + yield* executePublicApplicationCommand( + { + commandId: "cross-linked-target-submit", + departmentId: "outbox-department", + firstName: "Target", + lastName: "Application", + phone: "+47 46666666", + email: "cross.linked.target@example.invalid", + gender: 0, + fieldOfStudyId: "outbox-field", + yearOfStudy: 2, + availability: applicationAvailability, + }, + { + now: "2031-09-15T12:23:00.000Z", + applicantId: ApplicantIdSchema.make("cross-linked-target-applicant"), + applicationId: PublicApplicationIdSchema.make("cross-linked-target-application"), + activationToken: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ", + }, + ); + yield* database` DELETE FROM admission_application_outbox WHERE command_id = 'cross-linked-target-submit' `; - yield* executePublicApplicationCommand( - { - commandId: "cross-linked-source-submit", - departmentId: "outbox-department", - firstName: "Source", - lastName: "Application", - phone: "+47 47777777", - email: "cross.linked.source@example.invalid", - gender: 1, - fieldOfStudyId: "outbox-field", - yearOfStudy: 3, - availability: applicationAvailability, - }, - { - now: "2031-09-15T12:23:01.000Z", - applicantId: ApplicantIdSchema.make("cross-linked-source-applicant"), - applicationId: PublicApplicationIdSchema.make("cross-linked-source-application"), - activationToken: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq", - }, - ); - yield* database` + yield* executePublicApplicationCommand( + { + commandId: "cross-linked-source-submit", + departmentId: "outbox-department", + firstName: "Source", + lastName: "Application", + phone: "+47 47777777", + email: "cross.linked.source@example.invalid", + gender: 1, + fieldOfStudyId: "outbox-field", + yearOfStudy: 3, + availability: applicationAvailability, + }, + { + now: "2031-09-15T12:23:01.000Z", + applicantId: ApplicantIdSchema.make("cross-linked-source-applicant"), + applicationId: PublicApplicationIdSchema.make("cross-linked-source-application"), + activationToken: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq", + }, + ); + yield* database` UPDATE admission_application_command_receipts SET application_id = 'cross-linked-target-application' WHERE command_id = 'cross-linked-source-submit' `; - const result = yield* deliverNextPublicApplicationOutbox( - "cross-linked-claim", - "2031-09-15T12:23:02.000Z", - makeRecordingPublicApplicationEffectInterpreter(), - ); + const result = yield* deliverNextPublicApplicationOutbox( + "cross-linked-claim", + "2031-09-15T12:23:02.000Z", + makeRecordingPublicApplicationEffectInterpreter(), + ); - const rows = yield* database<{ - readonly status: string; - readonly attempts: number; - readonly claim_id: string | null; - readonly last_failure_tag: string | null; - readonly payload_json: unknown; - }>` + const rows = yield* database<{ + readonly status: string; + readonly attempts: number; + readonly claim_id: string | null; + readonly last_failure_tag: string | null; + readonly payload_json: unknown; + }>` SELECT status, attempts, claim_id, last_failure_tag, payload_json FROM admission_application_outbox WHERE command_id = 'cross-linked-source-submit' AND ordinal = 0 `; - yield* database` + yield* database` DELETE FROM admission_application_outbox WHERE command_id = 'cross-linked-source-submit' `; - yield* database` + yield* database` UPDATE admission_application_command_receipts SET application_id = 'cross-linked-source-application' WHERE command_id = 'cross-linked-source-submit' `; - return { result, row: rows[0] }; - }); + const evidence = { result, row: rows[0] }; expect(evidence.result).toEqual(PublicApplicationOutboxDeliveryResult.Idle()); expect(evidence.row).toEqual({ @@ -1822,14 +1806,13 @@ describe("DatabaseTest", () => { layer(sharedRecruitmentLayer, { excludeTestServices: true })((it) => { it.effect("executes native Recruitment assignment atomically against PGlite", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const recruitment = yield* Recruitment; - yield* database` + const database = yield* Database; + const recruitment = yield* Recruitment; + yield* database` INSERT INTO admission_period_departments (department_id, name) VALUES ('recruitment-department', 'Recruitment Department') `; - yield* database` + yield* database` INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ( 'recruitment-semester', @@ -1837,7 +1820,7 @@ describe("DatabaseTest", () => { '2032-01-01T00:00:00.000Z' ) `; - yield* database` + yield* database` INSERT INTO admission_periods ( admission_period_id, department_id, @@ -1855,7 +1838,7 @@ describe("DatabaseTest", () => { 'recruitment-period-created' ) `; - yield* database` + yield* database` INSERT INTO admission_period_fields_of_study ( field_of_study_id, department_id, @@ -1863,7 +1846,7 @@ describe("DatabaseTest", () => { ) VALUES ('recruitment-field', 'recruitment-department', 'Computer Science') `; - yield* database` + yield* database` INSERT INTO admission_applicants ( applicant_id, normalized_email, @@ -1887,7 +1870,7 @@ describe("DatabaseTest", () => { 2 ) `; - yield* database` + yield* database` INSERT INTO admission_applications ( application_id, applicant_id, @@ -1907,7 +1890,7 @@ describe("DatabaseTest", () => { '2031-09-10T12:00:00.000Z' ) `; - yield* database` + yield* database` INSERT INTO organization_departments ( department_id, name, @@ -1923,17 +1906,17 @@ describe("DatabaseTest", () => { 'Bergen' ) `; - yield* database` + yield* database` INSERT INTO organization_teams (team_id, department_id, name) VALUES ('recruitment-team', 'recruitment-department', 'Recruitment Team') `; - yield* database` + yield* database` INSERT INTO person_profiles (person_id, first_name, last_name) VALUES ('recruitment-leader', 'Lise', 'Leader'), ('recruitment-interviewer', 'Ivar', 'Interviewer') `; - yield* database` + yield* database` INSERT INTO organization_memberships ( membership_id, person_id, @@ -1960,7 +1943,7 @@ describe("DatabaseTest", () => { FALSE ) `; - yield* database` + yield* database` INSERT INTO recruitment_interview_schemas ( interview_schema_id, name, @@ -1968,7 +1951,7 @@ describe("DatabaseTest", () => { ) VALUES ('recruitment-schema', 'Standard interview', 8) `; - yield* database` + yield* database` INSERT INTO public.recruitment_interview_schema_questions ( interview_schema_id, question_id, ordinal, prompt, help_text, kind, alternatives ) @@ -1983,76 +1966,76 @@ describe("DatabaseTest", () => { ('recruitment-schema', 'recruitment-q7', 7, 'Question 7', NULL, 'text', '[]'::jsonb) `; - const actor = { - _tag: "DepartmentAdministrator" as const, - personId: PersonId.make("recruitment-leader"), - departmentId: DepartmentId.make("recruitment-department"), - active: true, - }; + const actor = { + _tag: "DepartmentAdministrator" as const, + personId: PersonId.make("recruitment-leader"), + departmentId: DepartmentId.make("recruitment-department"), + active: true, + }; - const now = "2031-09-15T12:00:00.000Z"; - const before = yield* recruitment.readAssignmentBoard({ status: "new" }, { actor, now }); + const now = "2031-09-15T12:00:00.000Z"; + const before = yield* recruitment.readAssignmentBoard({ status: "new" }, { actor, now }); - const command = { - commandId: RecruitmentAssignmentCommandId.make("recruitment-command"), - applicationId: PublicApplicationIdSchema.make("recruitment-application"), - interviewerPersonId: PersonId.make("recruitment-interviewer"), - interviewSchemaId: InterviewSchemaId.make("recruitment-schema"), - }; + const command = { + commandId: RecruitmentAssignmentCommandId.make("recruitment-command"), + applicationId: PublicApplicationIdSchema.make("recruitment-application"), + interviewerPersonId: PersonId.make("recruitment-interviewer"), + interviewSchemaId: InterviewSchemaId.make("recruitment-schema"), + }; - yield* database` + yield* database` UPDATE recruitment_interview_schemas SET active = FALSE WHERE interview_schema_id = 'recruitment-schema' `; - const inactiveSchema = yield* Effect.flip( - recruitment.assignApplicant( - { - ...command, - commandId: RecruitmentAssignmentCommandId.make("inactive-schema-command"), - }, - { - actor, - now, - interviewId: RecruitmentInterviewId.make("inactive-schema-interview"), - }, - ), - ); + const inactiveSchema = yield* Effect.flip( + recruitment.assignApplicant( + { + ...command, + commandId: RecruitmentAssignmentCommandId.make("inactive-schema-command"), + }, + { + actor, + now, + interviewId: RecruitmentInterviewId.make("inactive-schema-interview"), + }, + ), + ); - yield* database` + yield* database` UPDATE recruitment_interview_schemas SET active = TRUE WHERE interview_schema_id = 'recruitment-schema' `; - const sourceBefore = yield* database<{ - readonly interviews: string; - readonly receipts: string; - readonly snapshots: string; - }>` + const sourceBefore = yield* database<{ + readonly interviews: string; + readonly receipts: string; + readonly snapshots: string; + }>` SELECT (SELECT count(*)::text FROM recruitment_interviews) AS interviews, (SELECT count(*)::text FROM recruitment_assignment_command_receipts) AS receipts, (SELECT count(*)::text FROM public.recruitment_interview_question_snapshots) AS snapshots `; - yield* database` + yield* database` DELETE FROM public.recruitment_interview_schema_questions WHERE interview_schema_id = 'recruitment-schema' `; - const missingSource = yield* Effect.flip( - recruitment.assignApplicant( - { - ...command, - commandId: RecruitmentAssignmentCommandId.make("missing-source-command"), - }, - { actor, now, interviewId: RecruitmentInterviewId.make("missing-source-interview") }, - ), - ); + const missingSource = yield* Effect.flip( + recruitment.assignApplicant( + { + ...command, + commandId: RecruitmentAssignmentCommandId.make("missing-source-command"), + }, + { actor, now, interviewId: RecruitmentInterviewId.make("missing-source-interview") }, + ), + ); - yield* database` + yield* database` INSERT INTO public.recruitment_interview_schema_questions ( interview_schema_id, question_id, ordinal, prompt, help_text, kind, alternatives ) @@ -2066,173 +2049,173 @@ describe("DatabaseTest", () => { ('recruitment-schema', 'recruitment-q6', 6, 'Question 6', NULL, 'text', '[]'::jsonb), ('recruitment-schema', 'recruitment-q7', 7, 'Question 7', NULL, 'text', '[]'::jsonb) `; - yield* database` + yield* database` DELETE FROM public.recruitment_interview_schema_questions WHERE interview_schema_id = 'recruitment-schema' AND ordinal = 7 `; - const partialSource = yield* Effect.flip( - recruitment.assignApplicant( - { - ...command, - commandId: RecruitmentAssignmentCommandId.make("partial-source-command"), - }, - { actor, now, interviewId: RecruitmentInterviewId.make("partial-source-interview") }, - ), - ); + const partialSource = yield* Effect.flip( + recruitment.assignApplicant( + { + ...command, + commandId: RecruitmentAssignmentCommandId.make("partial-source-command"), + }, + { actor, now, interviewId: RecruitmentInterviewId.make("partial-source-interview") }, + ), + ); - yield* database` + yield* database` INSERT INTO public.recruitment_interview_schema_questions ( interview_schema_id, question_id, ordinal, prompt, help_text, kind, alternatives ) VALUES ('recruitment-schema', 'recruitment-q7', 7, 'Question 7', NULL, 'text', '[]'::jsonb) `; - yield* database` + yield* database` UPDATE public.recruitment_interview_schema_questions SET ordinal = 8 WHERE interview_schema_id = 'recruitment-schema' AND question_id = 'recruitment-q7' `; - const invalidSource = yield* Effect.flip( - recruitment.assignApplicant( - { - ...command, - commandId: RecruitmentAssignmentCommandId.make("invalid-source-command"), - }, - { actor, now, interviewId: RecruitmentInterviewId.make("invalid-source-interview") }, - ), - ); + const invalidSource = yield* Effect.flip( + recruitment.assignApplicant( + { + ...command, + commandId: RecruitmentAssignmentCommandId.make("invalid-source-command"), + }, + { actor, now, interviewId: RecruitmentInterviewId.make("invalid-source-interview") }, + ), + ); - yield* database` + yield* database` UPDATE public.recruitment_interview_schema_questions SET ordinal = 7 WHERE interview_schema_id = 'recruitment-schema' AND question_id = 'recruitment-q7' `; - const sourceAfter = yield* database<{ - readonly interviews: string; - readonly receipts: string; - readonly snapshots: string; - }>` + const sourceAfter = yield* database<{ + readonly interviews: string; + readonly receipts: string; + readonly snapshots: string; + }>` SELECT (SELECT count(*)::text FROM recruitment_interviews) AS interviews, (SELECT count(*)::text FROM recruitment_assignment_command_receipts) AS receipts, (SELECT count(*)::text FROM public.recruitment_interview_question_snapshots) AS snapshots `; - const assigned = yield* recruitment.assignApplicant(command, { - actor, - now, - interviewId: RecruitmentInterviewId.make("recruitment-interview"), - }); + const assigned = yield* recruitment.assignApplicant(command, { + actor, + now, + interviewId: RecruitmentInterviewId.make("recruitment-interview"), + }); - const snapshotMutation = { - update: yield* Effect.result(database` + const snapshotMutation = { + update: yield* Effect.result(database` UPDATE public.recruitment_interview_question_snapshots SET prompt = 'Mutated' WHERE interview_id = 'recruitment-interview' AND ordinal = 0 `), - delete: yield* Effect.result(database` + delete: yield* Effect.result(database` DELETE FROM public.recruitment_interview_question_snapshots WHERE interview_id = 'recruitment-interview' AND ordinal = 0 `), - }; - - const replayed = yield* recruitment.assignApplicant(command, { - actor, - now, - interviewId: RecruitmentInterviewId.make("ignored-replay-interview"), - }); + }; - const conflictingReplay = yield* Effect.flip( - recruitment.assignApplicant( - { - ...command, - interviewerPersonId: PersonId.make("recruitment-leader"), - }, - { - actor, - now, - interviewId: RecruitmentInterviewId.make("conflicting-replay-interview"), - }, - ), - ); + const replayed = yield* recruitment.assignApplicant(command, { + actor, + now, + interviewId: RecruitmentInterviewId.make("ignored-replay-interview"), + }); - const duplicateAssignment = yield* Effect.flip( - recruitment.assignApplicant( - { - ...command, - commandId: RecruitmentAssignmentCommandId.make("duplicate-assignment-command"), - }, - { - actor, - now, - interviewId: RecruitmentInterviewId.make("duplicate-assignment-interview"), - }, - ), - ); + const conflictingReplay = yield* Effect.flip( + recruitment.assignApplicant( + { + ...command, + interviewerPersonId: PersonId.make("recruitment-leader"), + }, + { + actor, + now, + interviewId: RecruitmentInterviewId.make("conflicting-replay-interview"), + }, + ), + ); - const closedPeriodReplay = yield* Effect.flip( - recruitment.assignApplicant(command, { + const duplicateAssignment = yield* Effect.flip( + recruitment.assignApplicant( + { + ...command, + commandId: RecruitmentAssignmentCommandId.make("duplicate-assignment-command"), + }, + { actor, - now: "2031-10-02T12:00:00.000Z", - interviewId: RecruitmentInterviewId.make("closed-period-replay-interview"), - }), - ); + now, + interviewId: RecruitmentInterviewId.make("duplicate-assignment-interview"), + }, + ), + ); - const after = yield* recruitment.readAssignmentBoard({ status: "new" }, { actor, now }); + const closedPeriodReplay = yield* Effect.flip( + recruitment.assignApplicant(command, { + actor, + now: "2031-10-02T12:00:00.000Z", + interviewId: RecruitmentInterviewId.make("closed-period-replay-interview"), + }), + ); - const persistence = yield* database<{ - readonly receipts: string; - readonly audits: string; - readonly interviews: string; - }>` + const after = yield* recruitment.readAssignmentBoard({ status: "new" }, { actor, now }); + + const persistence = yield* database<{ + readonly receipts: string; + readonly audits: string; + readonly interviews: string; + }>` SELECT (SELECT count(*)::text FROM recruitment_assignment_command_receipts) AS receipts, (SELECT count(*)::text FROM recruitment_assignment_audit) AS audits, (SELECT count(*)::text FROM recruitment_interviews) AS interviews `; - const applicationScopeResult = yield* Effect.result( - database.withTransaction( - Effect.gen(function* () { - yield* database` + const applicationScopeResult = yield* Effect.result( + database.withTransaction( + Effect.gen(function* () { + yield* database` INSERT INTO admission_period_departments (department_id, name) VALUES ('other-department', 'Other Department') `; - yield* database` + yield* database` UPDATE admission_applications SET department_id = 'other-department' WHERE application_id = 'recruitment-application' `; - }), - ), - ); + }), + ), + ); - const blankAssignerResult = yield* Effect.result(database` + const blankAssignerResult = yield* Effect.result(database` UPDATE recruitment_interviews SET assigned_by_person_id = '' WHERE interview_id = 'recruitment-interview' `); - const blankAuditActorResult = yield* Effect.result(database` + const blankAuditActorResult = yield* Effect.result(database` UPDATE recruitment_assignment_audit SET actor_person_id = '' WHERE command_id = 'recruitment-command' `); - const receiptLinkResult = yield* Effect.result( - database.withTransaction( - Effect.gen(function* () { - yield* database` + const receiptLinkResult = yield* Effect.result( + database.withTransaction( + Effect.gen(function* () { + yield* database` DELETE FROM recruitment_assignment_audit WHERE command_id = 'recruitment-command' `; - yield* database` + yield* database` DELETE FROM recruitment_assignment_command_receipts WHERE command_id = 'recruitment-command' `; - yield* database` + yield* database` INSERT INTO recruitment_assignment_command_receipts ( command_id, command_sha256, @@ -2252,32 +2235,31 @@ describe("DatabaseTest", () => { '2031-09-15T12:00:00.000Z' ) `; - }), - ), - ); + }), + ), + ); - return { - before, - inactiveSchema, - assigned, - snapshotMutation, - replayed, - conflictingReplay, - duplicateAssignment, - closedPeriodReplay, - after, - persistence, - sourceBefore, - missingSource, - partialSource, - invalidSource, - sourceAfter, - applicationScopeResult, - receiptLinkResult, - blankAssignerResult, - blankAuditActorResult, - }; - }); + const evidence = { + before, + inactiveSchema, + assigned, + snapshotMutation, + replayed, + conflictingReplay, + duplicateAssignment, + closedPeriodReplay, + after, + persistence, + sourceBefore, + missingSource, + partialSource, + invalidSource, + sourceAfter, + applicationScopeResult, + receiptLinkResult, + blankAssignerResult, + blankAuditActorResult, + }; expect(evidence.before.candidates).toEqual([ expect.objectContaining({ @@ -2368,38 +2350,37 @@ describe("DatabaseTest", () => { const deliveredAt = "2031-09-15T12:05:00.000Z"; const gateway = makeRecordingNotificationGateway(deliveredAt); - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const recruitment = yield* Recruitment; - const fixture = yield* seedSchedulingFixture(fixtureId); + const database = yield* Database; + const recruitment = yield* Recruitment; + const fixture = yield* seedSchedulingFixture(fixtureId); - const context = { - actor: fixture.actor, - now: fixture.now, - invitationId: fixture.invitationId, - responseCapability: fixture.responseCapability, - }; + const context = { + actor: fixture.actor, + now: fixture.now, + invitationId: fixture.invitationId, + responseCapability: fixture.responseCapability, + }; - const before = yield* recruitment.readSchedulingBoard({ - actor: fixture.actor, - now: fixture.now, - }); + const before = yield* recruitment.readSchedulingBoard({ + actor: fixture.actor, + now: fixture.now, + }); - const accepted = yield* recruitment.scheduleInterview(fixture.command, context); - const replayed = yield* recruitment.scheduleInterview(fixture.command, context); + const accepted = yield* recruitment.scheduleInterview(fixture.command, context); + const replayed = yield* recruitment.scheduleInterview(fixture.command, context); - const conflictingReplay = yield* Effect.flip( - recruitment.scheduleInterview({ ...fixture.command, room: "A-102" }, context), - ); + const conflictingReplay = yield* Effect.flip( + recruitment.scheduleInterview({ ...fixture.command, room: "A-102" }, context), + ); - const pendingPersistence = yield* database<{ - readonly schedules: string; - readonly invitations: string; - readonly receipts: string; - readonly audits: string; - readonly outbox: string; - readonly interviewRevision: string; - }>` + const pendingPersistence = yield* database<{ + readonly schedules: string; + readonly invitations: string; + readonly receipts: string; + readonly audits: string; + readonly outbox: string; + readonly interviewRevision: string; + }>` SELECT ( SELECT count(*)::text @@ -2433,27 +2414,27 @@ describe("DatabaseTest", () => { ) AS "interviewRevision" `; - const pendingBoard = yield* recruitment.readSchedulingBoard({ - actor: fixture.actor, - now: fixture.now, - }); + const pendingBoard = yield* recruitment.readSchedulingBoard({ + actor: fixture.actor, + now: fixture.now, + }); - const delivery = yield* deliverNextRecruitmentInvitation( - `${fixtureId}-claim`, - "2031-09-15T12:04:00.000Z", - ).pipe(Effect.provide(gateway.layer)); + const delivery = yield* deliverNextRecruitmentInvitation( + `${fixtureId}-claim`, + "2031-09-15T12:04:00.000Z", + ).pipe(Effect.provide(gateway.layer)); - const deliveredBoard = yield* recruitment.readSchedulingBoard({ - actor: fixture.actor, - now: fixture.now, - }); + const deliveredBoard = yield* recruitment.readSchedulingBoard({ + actor: fixture.actor, + now: fixture.now, + }); - const deliveredOutbox = yield* database<{ - readonly status: string; - readonly attempts: number; - readonly payload: string; - readonly deliveredAt: string | null; - }>` + const deliveredOutbox = yield* database<{ + readonly status: string; + readonly attempts: number; + readonly payload: string; + readonly deliveredAt: string | null; + }>` SELECT status, attempts, @@ -2468,18 +2449,17 @@ describe("DatabaseTest", () => { WHERE command_id = ${fixture.command.commandId} `; - return { - before, - accepted, - replayed, - conflictingReplay, - pendingPersistence, - pendingBoard, - delivery, - deliveredBoard, - deliveredOutbox, - }; - }); + const evidence = { + before, + accepted, + replayed, + conflictingReplay, + pendingPersistence, + pendingBoard, + delivery, + deliveredBoard, + deliveredOutbox, + }; expect(evidence.before).toEqual({ departmentId: `${fixtureId}-department`, @@ -2598,43 +2578,42 @@ describe("DatabaseTest", () => { const laterFixtureId = "scheduling-poison-later"; const gateway = makeRecordingNotificationGateway("2031-09-15T12:04:00.000Z"); - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const recruitment = yield* Recruitment; - const first = yield* seedSchedulingFixture(firstFixtureId); - const later = yield* seedSchedulingFixture(laterFixtureId); - yield* recruitment.scheduleInterview(first.command, { - actor: first.actor, - now: first.now, - invitationId: first.invitationId, - responseCapability: first.responseCapability, - }); - yield* recruitment.scheduleInterview(later.command, { - actor: later.actor, - now: "2031-09-15T12:01:00.000Z", - invitationId: later.invitationId, - responseCapability: later.responseCapability, - }); - yield* database` + const database = yield* Database; + const recruitment = yield* Recruitment; + const first = yield* seedSchedulingFixture(firstFixtureId); + const later = yield* seedSchedulingFixture(laterFixtureId); + yield* recruitment.scheduleInterview(first.command, { + actor: first.actor, + now: first.now, + invitationId: first.invitationId, + responseCapability: first.responseCapability, + }); + yield* recruitment.scheduleInterview(later.command, { + actor: later.actor, + now: "2031-09-15T12:01:00.000Z", + invitationId: later.invitationId, + responseCapability: later.responseCapability, + }); + yield* database` UPDATE recruitment_invitation_outbox SET payload_json = '{"_tag":"Poison"}'::jsonb WHERE command_id = ${first.command.commandId} `; - const quarantinePass = yield* deliverNextRecruitmentInvitation( - "scheduling-poison-claim", - "2031-09-15T12:02:00.000Z", - ).pipe(Effect.provide(gateway.layer)); + const quarantinePass = yield* deliverNextRecruitmentInvitation( + "scheduling-poison-claim", + "2031-09-15T12:02:00.000Z", + ).pipe(Effect.provide(gateway.layer)); - const requestsAfterQuarantine = gateway.requests.length; + const requestsAfterQuarantine = gateway.requests.length; - const afterQuarantine = yield* database<{ - readonly commandId: string; - readonly status: string; - readonly attempts: number; - readonly payloadScrubbed: boolean; - readonly lastFailureTag: string | null; - }>` + const afterQuarantine = yield* database<{ + readonly commandId: string; + readonly status: string; + readonly attempts: number; + readonly payloadScrubbed: boolean; + readonly lastFailureTag: string | null; + }>` SELECT command_id AS "commandId", status, @@ -2652,18 +2631,18 @@ describe("DatabaseTest", () => { END `; - const continuedPass = yield* deliverNextRecruitmentInvitation( - "scheduling-later-claim", - "2031-09-15T12:03:00.000Z", - ).pipe(Effect.provide(gateway.layer)); - - const finalRows = yield* database<{ - readonly commandId: string; - readonly status: string; - readonly attempts: number; - readonly payloadScrubbed: boolean; - readonly lastFailureTag: string | null; - }>` + const continuedPass = yield* deliverNextRecruitmentInvitation( + "scheduling-later-claim", + "2031-09-15T12:03:00.000Z", + ).pipe(Effect.provide(gateway.layer)); + + const finalRows = yield* database<{ + readonly commandId: string; + readonly status: string; + readonly attempts: number; + readonly payloadScrubbed: boolean; + readonly lastFailureTag: string | null; + }>` SELECT command_id AS "commandId", status, @@ -2681,14 +2660,13 @@ describe("DatabaseTest", () => { END `; - return { - quarantinePass, - requestsAfterQuarantine, - afterQuarantine, - continuedPass, - finalRows, - }; - }); + const evidence = { + quarantinePass, + requestsAfterQuarantine, + afterQuarantine, + continuedPass, + finalRows, + }; expect(evidence.quarantinePass).toEqual(RecruitmentInvitationDeliveryResult.Idle()); expect(evidence.requestsAfterQuarantine).toBe(0); @@ -2734,19 +2712,18 @@ describe("DatabaseTest", () => { "rolls back failed schedules and leaves stale or already-scheduled interviews unchanged", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const recruitment = yield* Recruitment; + const database = yield* Database; + const recruitment = yield* Recruitment; - const readScheduleWrites = (interviewId: string) => - database<{ - readonly revision: string; - readonly schedules: string; - readonly invitations: string; - readonly receipts: string; - readonly audits: string; - readonly outbox: string; - }>` + const readScheduleWrites = (interviewId: string) => + database<{ + readonly revision: string; + readonly schedules: string; + readonly invitations: string; + readonly receipts: string; + readonly audits: string; + readonly outbox: string; + }>` SELECT ( SELECT revision::text @@ -2780,76 +2757,73 @@ describe("DatabaseTest", () => { ) AS outbox `; - const staleFixture = yield* seedSchedulingFixture("scheduling-stale"); + const staleFixture = yield* seedSchedulingFixture("scheduling-stale"); - const staleFailure = yield* Effect.flip( - recruitment.scheduleInterview( - { ...staleFixture.command, expectedRevision: 1 }, - { - actor: staleFixture.actor, - now: staleFixture.now, - invitationId: staleFixture.invitationId, - responseCapability: staleFixture.responseCapability, - }, - ), - ); + const staleFailure = yield* Effect.flip( + recruitment.scheduleInterview( + { ...staleFixture.command, expectedRevision: 1 }, + { + actor: staleFixture.actor, + now: staleFixture.now, + invitationId: staleFixture.invitationId, + responseCapability: staleFixture.responseCapability, + }, + ), + ); - const staleWrites = yield* readScheduleWrites(staleFixture.interviewId); + const staleWrites = yield* readScheduleWrites(staleFixture.interviewId); - const scheduledFixture = yield* seedSchedulingFixture("scheduling-already"); - yield* recruitment.scheduleInterview(scheduledFixture.command, { - actor: scheduledFixture.actor, - now: scheduledFixture.now, - invitationId: scheduledFixture.invitationId, - responseCapability: scheduledFixture.responseCapability, - }); - const beforeAlreadyScheduled = yield* readScheduleWrites(scheduledFixture.interviewId); + const scheduledFixture = yield* seedSchedulingFixture("scheduling-already"); + yield* recruitment.scheduleInterview(scheduledFixture.command, { + actor: scheduledFixture.actor, + now: scheduledFixture.now, + invitationId: scheduledFixture.invitationId, + responseCapability: scheduledFixture.responseCapability, + }); + const beforeAlreadyScheduled = yield* readScheduleWrites(scheduledFixture.interviewId); - const alreadyScheduledFailure = yield* Effect.flip( - recruitment.scheduleInterview( - { - ...scheduledFixture.command, - commandId: RecruitmentScheduleCommandId.make( - "scheduling-already-second-schedule-command", - ), - expectedRevision: 1, - }, - { - actor: scheduledFixture.actor, - now: scheduledFixture.now, - invitationId: RecruitmentInvitationId.make( - "scheduling-already-second-invitation", - ), - responseCapability: "scheduling-already-second".padEnd(43, "_"), - }, - ), - ); + const alreadyScheduledFailure = yield* Effect.flip( + recruitment.scheduleInterview( + { + ...scheduledFixture.command, + commandId: RecruitmentScheduleCommandId.make( + "scheduling-already-second-schedule-command", + ), + expectedRevision: 1, + }, + { + actor: scheduledFixture.actor, + now: scheduledFixture.now, + invitationId: RecruitmentInvitationId.make("scheduling-already-second-invitation"), + responseCapability: "scheduling-already-second".padEnd(43, "_"), + }, + ), + ); - const afterAlreadyScheduled = yield* readScheduleWrites(scheduledFixture.interviewId); + const afterAlreadyScheduled = yield* readScheduleWrites(scheduledFixture.interviewId); - const rollbackFixture = yield* seedSchedulingFixture("scheduling-rollback"); + const rollbackFixture = yield* seedSchedulingFixture("scheduling-rollback"); - const rollbackFailure = yield* Effect.flip( - recruitment.scheduleInterview(rollbackFixture.command, { - actor: rollbackFixture.actor, - now: rollbackFixture.now, - invitationId: scheduledFixture.invitationId, - responseCapability: rollbackFixture.responseCapability, - }), - ); + const rollbackFailure = yield* Effect.flip( + recruitment.scheduleInterview(rollbackFixture.command, { + actor: rollbackFixture.actor, + now: rollbackFixture.now, + invitationId: scheduledFixture.invitationId, + responseCapability: rollbackFixture.responseCapability, + }), + ); - const rollbackWrites = yield* readScheduleWrites(rollbackFixture.interviewId); + const rollbackWrites = yield* readScheduleWrites(rollbackFixture.interviewId); - return { - staleFailure, - staleWrites, - alreadyScheduledFailure, - beforeAlreadyScheduled, - afterAlreadyScheduled, - rollbackFailure, - rollbackWrites, - }; - }); + const evidence = { + staleFailure, + staleWrites, + alreadyScheduledFailure, + beforeAlreadyScheduled, + afterAlreadyScheduled, + rollbackFailure, + rollbackWrites, + }; expect(evidence.staleFailure).toHaveProperty("_tag", "RecruitmentInterviewStaleRevision"); expect(evidence.staleFailure).toMatchObject({ @@ -2905,74 +2879,73 @@ describe("DatabaseTest", () => { Effect.gen(function* () { const gateway = makeRecordingNotificationGateway("2031-09-15T12:10:00.000Z"); - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const recruitment = yield* Recruitment; + const database = yield* Database; + const recruitment = yield* Recruitment; - const cases = [ - { fixtureId: "response-accepted", action: "Accepted" as const }, - { fixtureId: "response-rejected", action: "Rejected" as const }, - { fixtureId: "response-new-time", action: "RequestedNewTime" as const }, - ]; - - const observations = []; - - for (const item of cases) { - const fixture = yield* seedSchedulingFixture(item.fixtureId); - yield* recruitment.scheduleInterview(fixture.command, { - actor: fixture.actor, - now: fixture.now, - invitationId: fixture.invitationId, - responseCapability: fixture.responseCapability, - }); - - const capability = RecruitmentInvitationCapabilitySchema.make( - fixture.responseCapability, - ); + const cases = [ + { fixtureId: "response-accepted", action: "Accepted" as const }, + { fixtureId: "response-rejected", action: "Rejected" as const }, + { fixtureId: "response-new-time", action: "RequestedNewTime" as const }, + ]; - const pending = yield* recruitment.readInvitationResponse(capability); - const context = { now: "2031-09-15T12:03:00.000Z" }; + const observations = []; - const result = yield* Match.value(item.action).pipe( - Match.when("Accepted", () => recruitment.confirmInvitation(capability, context)), - Match.when("Rejected", () => - recruitment.rejectInvitation(capability, { message: " " }, context), - ), - Match.when("RequestedNewTime", () => - recruitment.requestNewInvitationTime( - capability, - { message: " Please offer an afternoon time. " }, - context, - ), + for (const item of cases) { + const fixture = yield* seedSchedulingFixture(item.fixtureId); + yield* recruitment.scheduleInterview(fixture.command, { + actor: fixture.actor, + now: fixture.now, + invitationId: fixture.invitationId, + responseCapability: fixture.responseCapability, + }); + + const capability = RecruitmentInvitationCapabilitySchema.make( + fixture.responseCapability, + ); + + const pending = yield* recruitment.readInvitationResponse(capability); + const context = { now: "2031-09-15T12:03:00.000Z" }; + + const result = yield* Match.value(item.action).pipe( + Match.when("Accepted", () => recruitment.confirmInvitation(capability, context)), + Match.when("Rejected", () => + recruitment.rejectInvitation(capability, { message: " " }, context), + ), + Match.when("RequestedNewTime", () => + recruitment.requestNewInvitationTime( + capability, + { message: " Please offer an afternoon time. " }, + context, ), - Match.exhaustive, - ); + ), + Match.exhaustive, + ); - const freshApplicant = yield* recruitment.readInvitationResponse(capability); + const freshApplicant = yield* recruitment.readInvitationResponse(capability); - const freshLeader = yield* recruitment.readSchedulingBoard({ - actor: fixture.actor, - now: context.now, - }); + const freshLeader = yield* recruitment.readSchedulingBoard({ + actor: fixture.actor, + now: context.now, + }); - const freshMember = yield* recruitment.readSchedulingBoard({ - actor: AdmissionPeriodActorSchema.cases.Member.make({ - personId: fixture.interviewerPersonId, - departmentId: fixture.departmentId, - active: true, - }), - now: context.now, - }); - - const persisted = yield* database<{ - readonly responseState: string; - readonly responseMessage: string | null; - readonly responded: boolean; - readonly responseRevision: number; - readonly audits: string; - readonly outbox: string; - readonly capabilityAbsent: boolean; - }>` + const freshMember = yield* recruitment.readSchedulingBoard({ + actor: AdmissionPeriodActorSchema.cases.Member.make({ + personId: fixture.interviewerPersonId, + departmentId: fixture.departmentId, + active: true, + }), + now: context.now, + }); + + const persisted = yield* database<{ + readonly responseState: string; + readonly responseMessage: string | null; + readonly responded: boolean; + readonly responseRevision: number; + readonly audits: string; + readonly outbox: string; + readonly capabilityAbsent: boolean; + }>` SELECT invitation.response_state AS "responseState", invitation.response_message AS "responseMessage", @@ -3009,36 +2982,35 @@ describe("DatabaseTest", () => { WHERE invitation.invitation_id = ${fixture.invitationId} `; - observations.push({ - action: item.action, - pending, - result, - freshApplicant, - leader: freshLeader.interviews.map((interview) => ({ - responseState: interview.responseState, - responseMessage: interview.responseMessage, - })), - member: freshMember.interviews.map((interview) => ({ - responseState: interview.responseState, - responseMessage: interview.responseMessage, - })), - persisted, - }); - } + observations.push({ + action: item.action, + pending, + result, + freshApplicant, + leader: freshLeader.interviews.map((interview) => ({ + responseState: interview.responseState, + responseMessage: interview.responseMessage, + })), + member: freshMember.interviews.map((interview) => ({ + responseState: interview.responseState, + responseMessage: interview.responseMessage, + })), + persisted, + }); + } - const deliveries = [ - yield* deliverNextRecruitmentInvitationResponse( - "response-recording-claim-1", - "2031-09-15T12:08:00.000Z", - ).pipe(Effect.provide(gateway.layer)), - yield* deliverNextRecruitmentInvitationResponse( - "response-recording-claim-2", - "2031-09-15T12:09:00.000Z", - ).pipe(Effect.provide(gateway.layer)), - ]; - - return { observations, deliveries }; - }); + const deliveries = [ + yield* deliverNextRecruitmentInvitationResponse( + "response-recording-claim-1", + "2031-09-15T12:08:00.000Z", + ).pipe(Effect.provide(gateway.layer)), + yield* deliverNextRecruitmentInvitationResponse( + "response-recording-claim-2", + "2031-09-15T12:09:00.000Z", + ).pipe(Effect.provide(gateway.layer)), + ]; + + const evidence = { observations, deliveries }; expect(evidence.observations.map((item) => item.pending.responseState)).toEqual([ "Pending", @@ -3157,41 +3129,40 @@ describe("DatabaseTest", () => { const migrationSource = yield* readMigrationSource(migration.url); - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const recruitment = yield* Recruitment; - const pglite = yield* RecruitmentPglite; - yield* Effect.promise(() => pglite.exec(migrationSource)); + const database = yield* Database; + const recruitment = yield* Recruitment; + const pglite = yield* RecruitmentPglite; + yield* Effect.promise(() => pglite.exec(migrationSource)); - const [replayedMigration] = yield* database<{ readonly count: string }>` + const [replayedMigration] = yield* database<{ readonly count: string }>` SELECT count(*)::text AS count FROM vektorprogrammet_schema_migrations WHERE migration_id = 15 `; - const fixture = yield* seedSchedulingFixture( - "response-message-confinement-with-long-stable-identifier", - ); + const fixture = yield* seedSchedulingFixture( + "response-message-confinement-with-long-stable-identifier", + ); - yield* recruitment.scheduleInterview(fixture.command, { - actor: fixture.actor, - now: fixture.now, - invitationId: fixture.invitationId, - responseCapability: fixture.responseCapability, - }); - const responseInstant = "2031-09-15T12:03:00.000Z"; - const ordinaryMessage = "Cannot attend the proposed time."; - const capabilitySequence = "C".repeat(43); - const embeddedCapabilitySequence = `Do not persist (${capabilitySequence}) here`; - const outboxEffectId = `recruitment-invitation-response:${fixture.invitationId}:1`; + yield* recruitment.scheduleInterview(fixture.command, { + actor: fixture.actor, + now: fixture.now, + invitationId: fixture.invitationId, + responseCapability: fixture.responseCapability, + }); + const responseInstant = "2031-09-15T12:03:00.000Z"; + const ordinaryMessage = "Cannot attend the proposed time."; + const capabilitySequence = "C".repeat(43); + const embeddedCapabilitySequence = `Do not persist (${capabilitySequence}) here`; + const outboxEffectId = `recruitment-invitation-response:${fixture.invitationId}:1`; - const before = yield* database<{ - readonly responseState: string; - readonly responseMessage: string | null; - readonly responseRevision: number; - readonly audits: string; - readonly outbox: string; - }>` + const before = yield* database<{ + readonly responseState: string; + readonly responseMessage: string | null; + readonly responseRevision: number; + readonly audits: string; + readonly outbox: string; + }>` SELECT invitation.response_state AS "responseState", invitation.response_message AS "responseMessage", @@ -3210,7 +3181,7 @@ describe("DatabaseTest", () => { WHERE invitation.invitation_id = ${fixture.invitationId} `; - const stageRejectedInvitation = (message: string) => database` + const stageRejectedInvitation = (message: string) => database` UPDATE recruitment_invitations SET response_state = 'Rejected', response_message = ${message}, @@ -3219,7 +3190,7 @@ describe("DatabaseTest", () => { WHERE invitation_id = ${fixture.invitationId} `; - const insertAudit = (message: string) => database` + const insertAudit = (message: string) => database` INSERT INTO recruitment_invitation_response_audit ( invitation_id, interview_id, @@ -3239,33 +3210,33 @@ describe("DatabaseTest", () => { ) `; - const invitationMessage = yield* Effect.result( - database.withTransaction( - Effect.gen(function* () { - yield* stageRejectedInvitation(capabilitySequence); + const invitationMessage = yield* Effect.result( + database.withTransaction( + Effect.gen(function* () { + yield* stageRejectedInvitation(capabilitySequence); - return yield* Effect.fail("InvitationMessageConfinementMissing"); - }), - ), - ); + return yield* Effect.fail("InvitationMessageConfinementMissing"); + }), + ), + ); - const auditMessage = yield* Effect.result( - database.withTransaction( - Effect.gen(function* () { - yield* stageRejectedInvitation(ordinaryMessage); - yield* insertAudit(embeddedCapabilitySequence); + const auditMessage = yield* Effect.result( + database.withTransaction( + Effect.gen(function* () { + yield* stageRejectedInvitation(ordinaryMessage); + yield* insertAudit(embeddedCapabilitySequence); - return yield* Effect.fail("AuditMessageConfinementMissing"); - }), - ), - ); + return yield* Effect.fail("AuditMessageConfinementMissing"); + }), + ), + ); - const outboxMessage = yield* Effect.result( - database.withTransaction( - Effect.gen(function* () { - yield* stageRejectedInvitation(ordinaryMessage); - yield* insertAudit(ordinaryMessage); - yield* database` + const outboxMessage = yield* Effect.result( + database.withTransaction( + Effect.gen(function* () { + yield* stageRejectedInvitation(ordinaryMessage); + yield* insertAudit(ordinaryMessage); + yield* database` INSERT INTO recruitment_invitation_response_outbox ( effect_id, effect_type, @@ -3291,17 +3262,17 @@ describe("DatabaseTest", () => { ) `; - return yield* Effect.fail("OutboxMessageConfinementMissing"); - }), - ), - ); + return yield* Effect.fail("OutboxMessageConfinementMissing"); + }), + ), + ); - const outboxPayload = yield* Effect.result( - database.withTransaction( - Effect.gen(function* () { - yield* stageRejectedInvitation(ordinaryMessage); - yield* insertAudit(ordinaryMessage); - yield* database` + const outboxPayload = yield* Effect.result( + database.withTransaction( + Effect.gen(function* () { + yield* stageRejectedInvitation(ordinaryMessage); + yield* insertAudit(ordinaryMessage); + yield* database` INSERT INTO recruitment_invitation_response_outbox ( effect_id, effect_type, @@ -3327,18 +3298,18 @@ describe("DatabaseTest", () => { ) `; - return yield* Effect.fail("OutboxPayloadConfinementMissing"); - }), - ), - ); + return yield* Effect.fail("OutboxPayloadConfinementMissing"); + }), + ), + ); - const afterCounterexamples = yield* database<{ - readonly responseState: string; - readonly responseMessage: string | null; - readonly responseRevision: number; - readonly audits: string; - readonly outbox: string; - }>` + const afterCounterexamples = yield* database<{ + readonly responseState: string; + readonly responseMessage: string | null; + readonly responseRevision: number; + readonly audits: string; + readonly outbox: string; + }>` SELECT invitation.response_state AS "responseState", invitation.response_message AS "responseMessage", @@ -3357,20 +3328,20 @@ describe("DatabaseTest", () => { WHERE invitation.invitation_id = ${fixture.invitationId} `; - const validNearbyMessage = "V".repeat(42); + const validNearbyMessage = "V".repeat(42); - const validResult = yield* recruitment.rejectInvitation( - RecruitmentInvitationCapabilitySchema.make(fixture.responseCapability), - { message: validNearbyMessage }, - { now: responseInstant }, - ); + const validResult = yield* recruitment.rejectInvitation( + RecruitmentInvitationCapabilitySchema.make(fixture.responseCapability), + { message: validNearbyMessage }, + { now: responseInstant }, + ); - const validRows = yield* database<{ - readonly invitationMessage: string | null; - readonly auditMessage: string | null; - readonly outboxMessage: string | null; - readonly payloadMessage: string | null; - }>` + const validRows = yield* database<{ + readonly invitationMessage: string | null; + readonly auditMessage: string | null; + readonly outboxMessage: string | null; + readonly payloadMessage: string | null; + }>` SELECT invitation.response_message AS "invitationMessage", audit.response_message AS "auditMessage", @@ -3384,16 +3355,15 @@ describe("DatabaseTest", () => { WHERE invitation.invitation_id = ${fixture.invitationId} `; - return { - replayedMigration, - counterexamples: [invitationMessage, auditMessage, outboxMessage, outboxPayload], - before, - afterCounterexamples, - validNearbyMessage, - validResult, - validRows, - }; - }); + const evidence = { + replayedMigration, + counterexamples: [invitationMessage, auditMessage, outboxMessage, outboxPayload], + before, + afterCounterexamples, + validNearbyMessage, + validResult, + validRows, + }; expect(evidence.replayedMigration).toEqual({ count: "1" }); @@ -3429,33 +3399,32 @@ describe("DatabaseTest", () => { it.effect("rejects relationally incomplete invitation response commits", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const recruitment = yield* Recruitment; - const missingAuditFixture = yield* seedSchedulingFixture("response-link-missing-audit"); - const missingOutboxFixture = yield* seedSchedulingFixture("response-link-missing-outbox"); + const database = yield* Database; + const recruitment = yield* Recruitment; + const missingAuditFixture = yield* seedSchedulingFixture("response-link-missing-audit"); + const missingOutboxFixture = yield* seedSchedulingFixture("response-link-missing-outbox"); - const mismatchedOutboxFixture = yield* seedSchedulingFixture( - "response-link-mismatched-outbox", - ); + const mismatchedOutboxFixture = yield* seedSchedulingFixture( + "response-link-mismatched-outbox", + ); - for (const fixture of [ - missingAuditFixture, - missingOutboxFixture, - mismatchedOutboxFixture, - ]) { - yield* recruitment.scheduleInterview(fixture.command, { - actor: fixture.actor, - now: fixture.now, - invitationId: fixture.invitationId, - responseCapability: fixture.responseCapability, - }); - } + for (const fixture of [ + missingAuditFixture, + missingOutboxFixture, + mismatchedOutboxFixture, + ]) { + yield* recruitment.scheduleInterview(fixture.command, { + actor: fixture.actor, + now: fixture.now, + invitationId: fixture.invitationId, + responseCapability: fixture.responseCapability, + }); + } - const missingAudit = yield* Effect.result( - database.withTransaction( - Effect.gen(function* () { - yield* database` + const missingAudit = yield* Effect.result( + database.withTransaction( + Effect.gen(function* () { + yield* database` UPDATE recruitment_invitations SET response_state = 'Accepted', response_message = NULL, @@ -3463,15 +3432,15 @@ describe("DatabaseTest", () => { response_revision = 1 WHERE invitation_id = ${missingAuditFixture.invitationId} `; - yield* database`SET CONSTRAINTS ALL IMMEDIATE`; - }), - ), - ); + yield* database`SET CONSTRAINTS ALL IMMEDIATE`; + }), + ), + ); - const missingOutbox = yield* Effect.result( - database.withTransaction( - Effect.gen(function* () { - yield* database` + const missingOutbox = yield* Effect.result( + database.withTransaction( + Effect.gen(function* () { + yield* database` UPDATE recruitment_invitations SET response_state = 'Rejected', response_message = NULL, @@ -3479,7 +3448,7 @@ describe("DatabaseTest", () => { response_revision = 1 WHERE invitation_id = ${missingOutboxFixture.invitationId} `; - yield* database` + yield* database` INSERT INTO recruitment_invitation_response_audit ( invitation_id, interview_id, @@ -3498,15 +3467,15 @@ describe("DatabaseTest", () => { '2031-09-15T12:03:00.000Z' ) `; - yield* database`SET CONSTRAINTS ALL IMMEDIATE`; - }), - ), - ); + yield* database`SET CONSTRAINTS ALL IMMEDIATE`; + }), + ), + ); - const mismatchedOutbox = yield* Effect.result( - database.withTransaction( - Effect.gen(function* () { - yield* database` + const mismatchedOutbox = yield* Effect.result( + database.withTransaction( + Effect.gen(function* () { + yield* database` UPDATE recruitment_invitations SET response_state = 'Rejected', response_message = NULL, @@ -3514,7 +3483,7 @@ describe("DatabaseTest", () => { response_revision = 1 WHERE invitation_id = ${mismatchedOutboxFixture.invitationId} `; - yield* database` + yield* database` INSERT INTO recruitment_invitation_response_audit ( invitation_id, interview_id, @@ -3533,7 +3502,7 @@ describe("DatabaseTest", () => { '2031-09-15T12:03:00.000Z' ) `; - yield* database` + yield* database` INSERT INTO recruitment_invitation_response_outbox ( effect_id, effect_type, @@ -3558,18 +3527,18 @@ describe("DatabaseTest", () => { '{}'::jsonb ) `; - yield* database`SET CONSTRAINTS ALL IMMEDIATE`; - }), - ), - ); + yield* database`SET CONSTRAINTS ALL IMMEDIATE`; + }), + ), + ); - const rows = yield* database<{ - readonly invitationId: string; - readonly responseState: string; - readonly responseRevision: number; - readonly audits: string; - readonly outbox: string; - }>` + const rows = yield* database<{ + readonly invitationId: string; + readonly responseState: string; + readonly responseRevision: number; + readonly audits: string; + readonly outbox: string; + }>` SELECT invitation.invitation_id AS "invitationId", invitation.response_state AS "responseState", @@ -3593,11 +3562,10 @@ describe("DatabaseTest", () => { ORDER BY invitation.invitation_id `; - return { - failures: [missingAudit, missingOutbox, mismatchedOutbox].map((result) => result._tag), - rows, - }; - }); + const evidence = { + failures: [missingAudit, missingOutbox, mismatchedOutbox].map((result) => result._tag), + rows, + }; expect(evidence.failures).toEqual(["Failure", "Failure", "Failure"]); expect(evidence.rows).toEqual([ @@ -3628,49 +3596,48 @@ describe("DatabaseTest", () => { it.effect("isolates unknown and superseded capabilities and keeps one response winner", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const recruitment = yield* Recruitment; - - const winnerFixture = yield* seedSchedulingFixture("response-one-winner"); - yield* recruitment.scheduleInterview(winnerFixture.command, { - actor: winnerFixture.actor, - now: winnerFixture.now, - invitationId: winnerFixture.invitationId, - responseCapability: winnerFixture.responseCapability, - }); + const database = yield* Database; + const recruitment = yield* Recruitment; + + const winnerFixture = yield* seedSchedulingFixture("response-one-winner"); + yield* recruitment.scheduleInterview(winnerFixture.command, { + actor: winnerFixture.actor, + now: winnerFixture.now, + invitationId: winnerFixture.invitationId, + responseCapability: winnerFixture.responseCapability, + }); - const winnerCapability = RecruitmentInvitationCapabilitySchema.make( - winnerFixture.responseCapability, - ); + const winnerCapability = RecruitmentInvitationCapabilitySchema.make( + winnerFixture.responseCapability, + ); - const outcomes = yield* Effect.all( - [ - Effect.result( - recruitment.confirmInvitation(winnerCapability, { - now: "2031-09-15T12:03:00.000Z", - }), - ), - Effect.result( - recruitment.confirmInvitation(winnerCapability, { - now: "2031-09-15T12:03:00.000Z", - }), - ), - ], - { concurrency: "unbounded" }, - ); + const outcomes = yield* Effect.all( + [ + Effect.result( + recruitment.confirmInvitation(winnerCapability, { + now: "2031-09-15T12:03:00.000Z", + }), + ), + Effect.result( + recruitment.confirmInvitation(winnerCapability, { + now: "2031-09-15T12:03:00.000Z", + }), + ), + ], + { concurrency: "unbounded" }, + ); - const outcomeTags = outcomes.map((outcome) => - Predicate.isTagged(outcome, "Success") - ? `Recorded:${outcome.success.responseState}` - : outcome.failure._tag, - ); + const outcomeTags = outcomes.map((outcome) => + Predicate.isTagged(outcome, "Success") + ? `Recorded:${outcome.success.responseState}` + : outcome.failure._tag, + ); - const winnerRows = yield* database<{ - readonly audits: string; - readonly outbox: string; - readonly responseRevision: number; - }>` + const winnerRows = yield* database<{ + readonly audits: string; + readonly outbox: string; + readonly responseRevision: number; + }>` SELECT ( SELECT count(*)::text @@ -3687,67 +3654,67 @@ describe("DatabaseTest", () => { WHERE invitation_id = ${winnerFixture.invitationId} `; - const unknown = yield* Effect.flip( - recruitment.readInvitationResponse( - RecruitmentInvitationCapabilitySchema.make("u".repeat(43)), - ), - ); + const unknown = yield* Effect.flip( + recruitment.readInvitationResponse( + RecruitmentInvitationCapabilitySchema.make("u".repeat(43)), + ), + ); - const supersededFixture = yield* seedSchedulingFixture("response-superseded"); - yield* recruitment.scheduleInterview(supersededFixture.command, { - actor: supersededFixture.actor, - now: supersededFixture.now, - invitationId: supersededFixture.invitationId, - responseCapability: supersededFixture.responseCapability, - }); - yield* database` + const supersededFixture = yield* seedSchedulingFixture("response-superseded"); + yield* recruitment.scheduleInterview(supersededFixture.command, { + actor: supersededFixture.actor, + now: supersededFixture.now, + invitationId: supersededFixture.invitationId, + responseCapability: supersededFixture.responseCapability, + }); + yield* database` UPDATE recruitment_invitations SET superseded_at = '2031-09-15T12:01:00.000Z' WHERE invitation_id = ${supersededFixture.invitationId} `; - const supersededCapability = RecruitmentInvitationCapabilitySchema.make( - supersededFixture.responseCapability, - ); + const supersededCapability = RecruitmentInvitationCapabilitySchema.make( + supersededFixture.responseCapability, + ); - const supersededRead = yield* Effect.flip( - recruitment.readInvitationResponse(supersededCapability), - ); + const supersededRead = yield* Effect.flip( + recruitment.readInvitationResponse(supersededCapability), + ); - const supersededWrite = yield* Effect.flip( - recruitment.confirmInvitation(supersededCapability, { - now: "2031-09-15T12:03:00.000Z", - }), - ); + const supersededWrite = yield* Effect.flip( + recruitment.confirmInvitation(supersededCapability, { + now: "2031-09-15T12:03:00.000Z", + }), + ); - const invalidFixture = yield* seedSchedulingFixture("response-invalid-new-time"); - yield* recruitment.scheduleInterview(invalidFixture.command, { - actor: invalidFixture.actor, - now: invalidFixture.now, - invitationId: invalidFixture.invitationId, - responseCapability: invalidFixture.responseCapability, - }); + const invalidFixture = yield* seedSchedulingFixture("response-invalid-new-time"); + yield* recruitment.scheduleInterview(invalidFixture.command, { + actor: invalidFixture.actor, + now: invalidFixture.now, + invitationId: invalidFixture.invitationId, + responseCapability: invalidFixture.responseCapability, + }); - const invalidInput = yield* Effect.flip( - recruitment.requestNewInvitationTime( - RecruitmentInvitationCapabilitySchema.make(invalidFixture.responseCapability), - { message: " " }, - { now: "2031-09-15T12:03:00.000Z" }, - ), - ); + const invalidInput = yield* Effect.flip( + recruitment.requestNewInvitationTime( + RecruitmentInvitationCapabilitySchema.make(invalidFixture.responseCapability), + { message: " " }, + { now: "2031-09-15T12:03:00.000Z" }, + ), + ); - const rollbackFixture = yield* seedSchedulingFixture("response-rollback"); - yield* recruitment.scheduleInterview(rollbackFixture.command, { - actor: rollbackFixture.actor, - now: rollbackFixture.now, - invitationId: rollbackFixture.invitationId, - responseCapability: rollbackFixture.responseCapability, - }); + const rollbackFixture = yield* seedSchedulingFixture("response-rollback"); + yield* recruitment.scheduleInterview(rollbackFixture.command, { + actor: rollbackFixture.actor, + now: rollbackFixture.now, + invitationId: rollbackFixture.invitationId, + responseCapability: rollbackFixture.responseCapability, + }); - const rollback = yield* Effect.result( - database.withTransaction( - Effect.gen(function* () { - yield* database` + const rollback = yield* Effect.result( + database.withTransaction( + Effect.gen(function* () { + yield* database` UPDATE recruitment_invitations SET response_state = 'Accepted', response_message = NULL, @@ -3755,7 +3722,7 @@ describe("DatabaseTest", () => { response_revision = 1 WHERE invitation_id = ${rollbackFixture.invitationId} `; - yield* database` + yield* database` INSERT INTO recruitment_invitation_response_audit ( invitation_id, interview_id, @@ -3774,16 +3741,16 @@ describe("DatabaseTest", () => { '2031-09-15T12:03:00.000Z' ) `; - }), - ), - ); + }), + ), + ); - const afterRollback = yield* database<{ - readonly responseState: string; - readonly responseRevision: number; - readonly audits: string; - readonly outbox: string; - }>` + const afterRollback = yield* database<{ + readonly responseState: string; + readonly responseRevision: number; + readonly audits: string; + readonly outbox: string; + }>` SELECT invitation.response_state AS "responseState", invitation.response_revision AS "responseRevision", @@ -3801,7 +3768,7 @@ describe("DatabaseTest", () => { WHERE invitation.invitation_id = ${rollbackFixture.invitationId} `; - const illegalRow = yield* Effect.result(database` + const illegalRow = yield* Effect.result(database` UPDATE recruitment_invitations SET response_state = 'RequestedNewTime', response_message = NULL, @@ -3810,18 +3777,17 @@ describe("DatabaseTest", () => { WHERE invitation_id = ${rollbackFixture.invitationId} `); - return { - outcomeTags, - winnerRows, - unknown, - supersededRead, - supersededWrite, - invalidInput, - rollback, - afterRollback, - illegalRow, - }; - }); + const evidence = { + outcomeTags, + winnerRows, + unknown, + supersededRead, + supersededWrite, + invalidInput, + rollback, + afterRollback, + illegalRow, + }; expect(evidence.outcomeTags).toHaveLength(2); expect(evidence.outcomeTags.filter((tag) => tag.startsWith("Recorded:"))).toHaveLength(1); @@ -3852,58 +3818,57 @@ describe("DatabaseTest", () => { NotificationGateway.of({ deliverInterviewCompletionReceipt: (request) => Effect.fail( - new RecruitmentNotificationDeliveryError({ + RecruitmentNotificationDeliveryError.make({ effectId: request.effectId, message: "Recording delivery failed", }), ), deliverInterviewInvitation: (request) => Effect.fail( - new RecruitmentNotificationDeliveryError({ + RecruitmentNotificationDeliveryError.make({ effectId: request.effectId, message: "Recording delivery failed", }), ), deliverInterviewInvitationResponse: (request) => Effect.fail( - new RecruitmentNotificationDeliveryError({ + RecruitmentNotificationDeliveryError.make({ effectId: request.effectId, message: "Recording delivery failed", }), - ), - }), - ); - - const recording = makeRecordingNotificationGateway("2031-09-15T12:08:00.000Z"); - - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const recruitment = yield* Recruitment; - const fixture = yield* seedSchedulingFixture(fixtureId); - yield* recruitment.scheduleInterview(fixture.command, { - actor: fixture.actor, - now: fixture.now, - invitationId: fixture.invitationId, - responseCapability: fixture.responseCapability, - }); + ), + }), + ); - const recorded = yield* recruitment.rejectInvitation( - RecruitmentInvitationCapabilitySchema.make(fixture.responseCapability), - { message: "Cannot attend." }, - { now: "2031-09-15T12:03:00.000Z" }, - ); + const recording = makeRecordingNotificationGateway("2031-09-15T12:08:00.000Z"); - const failedDelivery = yield* deliverNextRecruitmentInvitationResponse( - "response-failure-claim", - "2031-09-15T12:04:00.000Z", - ).pipe(Effect.provide(failingGateway)); + const database = yield* Database; + const recruitment = yield* Recruitment; + const fixture = yield* seedSchedulingFixture(fixtureId); + yield* recruitment.scheduleInterview(fixture.command, { + actor: fixture.actor, + now: fixture.now, + invitationId: fixture.invitationId, + responseCapability: fixture.responseCapability, + }); - const afterFailure = yield* database<{ - readonly responseState: string; - readonly responseRevision: number; - readonly audits: string; - readonly outboxStatus: string; - }>` + const recorded = yield* recruitment.rejectInvitation( + RecruitmentInvitationCapabilitySchema.make(fixture.responseCapability), + { message: "Cannot attend." }, + { now: "2031-09-15T12:03:00.000Z" }, + ); + + const failedDelivery = yield* deliverNextRecruitmentInvitationResponse( + "response-failure-claim", + "2031-09-15T12:04:00.000Z", + ).pipe(Effect.provide(failingGateway)); + + const afterFailure = yield* database<{ + readonly responseState: string; + readonly responseRevision: number; + readonly audits: string; + readonly outboxStatus: string; + }>` SELECT invitation.response_state AS "responseState", invitation.response_revision AS "responseRevision", @@ -3921,13 +3886,12 @@ describe("DatabaseTest", () => { WHERE invitation.invitation_id = ${fixture.invitationId} `; - const recoveredDelivery = yield* deliverNextRecruitmentInvitationResponse( - "response-retry-claim", - "2031-09-15T12:05:00.000Z", - ).pipe(Effect.provide(recording.layer)); + const recoveredDelivery = yield* deliverNextRecruitmentInvitationResponse( + "response-retry-claim", + "2031-09-15T12:05:00.000Z", + ).pipe(Effect.provide(recording.layer)); - return { recorded, failedDelivery, afterFailure, recoveredDelivery }; - }); + const evidence = { recorded, failedDelivery, afterFailure, recoveredDelivery }; expect(evidence.recorded.responseState).toBe("Rejected"); expect(evidence.failedDelivery._tag).toBe("Failed"); @@ -3949,11 +3913,10 @@ describe("DatabaseTest", () => { Effect.gen(function* () { const fixtureId = "scheduling-constraints"; - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const fixture = yield* seedSchedulingFixture(fixtureId); + const database = yield* Database; + const fixture = yield* seedSchedulingFixture(fixtureId); - const invalidMap = yield* Effect.result(database` + const invalidMap = yield* Effect.result(database` INSERT INTO recruitment_interview_schedules ( interview_id, scheduled_at, @@ -3978,17 +3941,17 @@ describe("DatabaseTest", () => { ) `); - const invalidEmail = yield* Effect.result(database` + const invalidEmail = yield* Effect.result(database` UPDATE person_contact_profiles SET email = 'not-an-email' WHERE person_id = ${fixture.interviewerPersonId} `); - const persisted = yield* database<{ - readonly schedules: string; - readonly interviewRevision: string; - readonly interviewerEmail: string; - }>` + const persisted = yield* database<{ + readonly schedules: string; + readonly interviewRevision: string; + readonly interviewerEmail: string; + }>` SELECT ( SELECT count(*)::text @@ -4007,8 +3970,7 @@ describe("DatabaseTest", () => { ) AS "interviewerEmail" `; - return { invalidMap, invalidEmail, persisted }; - }); + const evidence = { invalidMap, invalidEmail, persisted }; expect([evidence.invalidMap._tag, evidence.invalidEmail._tag]).toEqual([ "Failure", @@ -4037,25 +3999,24 @@ describe("DatabaseTest", () => { "lists authorized team-interest rows in registration order and narrows optional filters against PGlite", () => Effect.gen(function* () { - const rows = yield* Effect.gen(function* () { - const database = yield* Database; - const departmentA = DepartmentId.make("team-interest-scope-a"); - const departmentB = DepartmentId.make("team-interest-scope-b"); + const database = yield* Database; + const departmentA = DepartmentId.make("team-interest-scope-a"); + const departmentB = DepartmentId.make("team-interest-scope-b"); - yield* database` + yield* database` INSERT INTO organization_departments ( department_id, name, short_name, email, city ) VALUES (${departmentA}, 'Scope A', 'SCA', 'scope-a@example.invalid', 'Trondheim'), (${departmentB}, 'Scope B', 'SCB', 'scope-b@example.invalid', 'Bergen') `; - yield* database` + yield* database` INSERT INTO organization_teams (team_id, department_id, name) VALUES ('team-interest-scope-team-a', ${departmentA}, 'Team A'), ('team-interest-scope-team-b', ${departmentB}, 'Team B') `; - yield* database` + yield* database` INSERT INTO public.organization_team_interest_registrations ( submitter_name, submitter_email, @@ -4082,20 +4043,29 @@ describe("DatabaseTest", () => { ) `; - const authorizedRows = yield* listOrganizationTeamInterestRegistrations({ - authorizedDepartmentIds: [departmentA, departmentB], - authorizedTeamIds: [], - }); + // An organization-wide reader, narrowed to one department for the filtered read. + const reader = activeAdministratorAuthority("team-interest-scope-reader"); - const filteredRows = yield* listOrganizationTeamInterestRegistrations({ - authorizedDepartmentIds: [departmentA, departmentB], - authorizedTeamIds: [], - departmentId: departmentB, - semesterId: SemesterId.make("semester-scope"), - }); + const authorizedRows = yield* listOrganizationTeamInterestRegistrations( + Result.getOrThrow( + requireTeamInterestScope(reader, { + requested: undefined, + departments: [departmentA, departmentB], + }), + ), + ); - return { authorizedRows, filteredRows }; - }); + const filteredRows = yield* listOrganizationTeamInterestRegistrations( + Result.getOrThrow( + requireTeamInterestScope(reader, { + requested: departmentB, + departments: [departmentA, departmentB], + }), + ), + SemesterId.make("semester-scope"), + ); + + const rows = { authorizedRows, filteredRows }; expect(rows.authorizedRows.map((row) => row.submitterName)).toEqual([ "Interested A", @@ -4113,166 +4083,143 @@ describe("DatabaseTest", () => { it.effect("executes native Organization administration atomically against PGlite", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const organization = yield* Organization; - yield* database.migrate; - yield* database.migrate; - - const administrator = { - _tag: "OrganizationAdministrator" as const, - personId: PersonId.make("organization-pglite-administrator"), - }; - - const member = { - _tag: "OrganizationMember" as const, - personId: PersonId.make("organization-pglite-member"), - }; - - const deniedCommand = { - _tag: "CreateDepartment" as const, - commandId: OrganizationCommandId.make("organization-pglite-denied-department"), - name: "Denied Department", - shortName: "DENY", - email: "denied@example.invalid", - address: null, - city: "Bergen", - latitude: null, - longitude: null, - }; - - const denied = yield* Effect.flip(organization.createDepartment(deniedCommand, member)); + const database = yield* Database; + const organization = yield* Organization; + yield* database.migrate; + yield* database.migrate; + + const administrator = yield* Effect.fromResult( + requireOrganizationAdministrator( + OrganizationPersonAuthoritySchema.make({ + personId: PersonId.make("organization-pglite-administrator"), + evaluatedAt: "2026-09-28T12:00:00.000Z", + globalAdministrator: "Active", + memberships: [], + nationalBoardSeats: [], + delegations: [], + }), + ), + ); - const deniedRows = yield* database<{ readonly count: string }>` - SELECT ( - (SELECT count(*) FROM organization_departments - WHERE native_creation_command_id = ${deniedCommand.commandId}) - + (SELECT count(*) FROM organization_command_receipts - WHERE command_id = ${deniedCommand.commandId}) - + (SELECT count(*) FROM organization_creation_audit - WHERE command_id = ${deniedCommand.commandId}) - )::text AS count - `; + const departmentCommand = { + _tag: "CreateDepartment" as const, + commandId: OrganizationCommandId.make("organization-pglite-department"), + name: "PGlite Department", + shortName: "PGL", + email: "pglite-department@example.invalid", + address: "Test Street 1", + city: "Bergen", + latitude: "60.3913", + longitude: "5.3221", + }; - const departmentCommand = { - _tag: "CreateDepartment" as const, - commandId: OrganizationCommandId.make("organization-pglite-department"), - name: "PGlite Department", - shortName: "PGL", - email: "pglite-department@example.invalid", - address: "Test Street 1", - city: "Bergen", - latitude: "60.3913", - longitude: "5.3221", - }; + const departmentCreated = yield* organization.createDepartment( + departmentCommand, + administrator, + ); - const departmentCreated = yield* organization.createDepartment( - departmentCommand, - administrator, - ); + const departmentReplayed = yield* organization.createDepartment( + departmentCommand, + administrator, + ); - const departmentReplayed = yield* organization.createDepartment( - departmentCommand, + const departmentConflict = yield* Effect.flip( + organization.createDepartment( + { ...departmentCommand, name: "Changed PGlite Department" }, administrator, - ); - - const departmentConflict = yield* Effect.flip( - organization.createDepartment( - { ...departmentCommand, name: "Changed PGlite Department" }, - administrator, - ), - ); + ), + ); - if (!Predicate.isTagged(departmentCreated.observation, "DepartmentCreated")) { - return yield* Effect.die(new Error("expected DepartmentCreated")); - } + if (!Predicate.isTagged(departmentCreated.observation, "DepartmentCreated")) { + return yield* Effect.die(new Error("expected DepartmentCreated")); + } - if (!Predicate.isTagged(departmentReplayed.observation, "Replayed")) { - return yield* Effect.die(new Error("expected Department replay")); - } + if (!Predicate.isTagged(departmentReplayed.observation, "Replayed")) { + return yield* Effect.die(new Error("expected Department replay")); + } - expect(departmentReplayed.observation.original).toEqual(departmentCreated.observation); - const departmentId = departmentCreated.observation.department.departmentId; - - const teamCommand = { - _tag: "CreateTeam" as const, - commandId: OrganizationCommandId.make("organization-pglite-team"), - departmentId, - name: "PGlite Team", - email: null, - description: "Native Organization team", - shortDescription: null, - acceptApplication: true, - deadline: "2036-09-20T10:00:00.000Z", - active: true, - }; + expect(departmentReplayed.observation.original).toEqual(departmentCreated.observation); + const departmentId = departmentCreated.observation.department.departmentId; + + const teamCommand = { + _tag: "CreateTeam" as const, + commandId: OrganizationCommandId.make("organization-pglite-team"), + departmentId, + name: "PGlite Team", + email: null, + description: "Native Organization team", + shortDescription: null, + acceptApplication: true, + deadline: "2036-09-20T10:00:00.000Z", + active: true, + }; - const teamCreated = yield* organization.createTeam(teamCommand, administrator); - const teamReplayed = yield* organization.createTeam(teamCommand, administrator); + const teamCreated = yield* organization.createTeam(teamCommand, administrator); + const teamReplayed = yield* organization.createTeam(teamCommand, administrator); - if ( - !Predicate.isTagged(teamCreated.observation, "TeamCreated") || - !Predicate.isTagged(teamReplayed.observation, "Replayed") - ) { - return yield* Effect.die(new Error("expected Team create and replay")); - } + if ( + !Predicate.isTagged(teamCreated.observation, "TeamCreated") || + !Predicate.isTagged(teamReplayed.observation, "Replayed") + ) { + return yield* Effect.die(new Error("expected Team create and replay")); + } - expect(teamReplayed.observation.original).toEqual(teamCreated.observation); + expect(teamReplayed.observation.original).toEqual(teamCreated.observation); - const fieldCommand = { - _tag: "CreateFieldOfStudy" as const, - commandId: OrganizationCommandId.make("organization-pglite-field"), - name: "Computer Science", - shortName: "CS", - departmentId: null, - }; + const fieldCommand = { + _tag: "CreateFieldOfStudy" as const, + commandId: OrganizationCommandId.make("organization-pglite-field"), + name: "Computer Science", + shortName: "CS", + departmentId: null, + }; - const fieldCreated = yield* organization.createFieldOfStudy(fieldCommand, administrator); - const fieldReplayed = yield* organization.createFieldOfStudy(fieldCommand, administrator); + const fieldCreated = yield* organization.createFieldOfStudy(fieldCommand, administrator); + const fieldReplayed = yield* organization.createFieldOfStudy(fieldCommand, administrator); - if ( - !Predicate.isTagged(fieldCreated.observation, "FieldOfStudyCreated") || - !Predicate.isTagged(fieldReplayed.observation, "Replayed") - ) { - return yield* Effect.die(new Error("expected FieldOfStudy create and replay")); - } + if ( + !Predicate.isTagged(fieldCreated.observation, "FieldOfStudyCreated") || + !Predicate.isTagged(fieldReplayed.observation, "Replayed") + ) { + return yield* Effect.die(new Error("expected FieldOfStudy create and replay")); + } - expect(fieldReplayed.observation.original).toEqual(fieldCreated.observation); + expect(fieldReplayed.observation.original).toEqual(fieldCreated.observation); - const scopedFieldCommand = { - ...fieldCommand, - commandId: OrganizationCommandId.make("organization-pglite-scoped-field"), - name: "Department Computer Science", - shortName: "DCS", - departmentId, - }; + const scopedFieldCommand = { + ...fieldCommand, + commandId: OrganizationCommandId.make("organization-pglite-scoped-field"), + name: "Department Computer Science", + shortName: "DCS", + departmentId, + }; - const scopedFieldCreated = yield* organization.createFieldOfStudy( - scopedFieldCommand, - administrator, - ); + const scopedFieldCreated = yield* organization.createFieldOfStudy( + scopedFieldCommand, + administrator, + ); - const invalidTeamCommand = { - ...teamCommand, - commandId: OrganizationCommandId.make("organization-pglite-invalid-team"), - departmentId: DepartmentId.make("organization-pglite-unknown-department"), - }; + const invalidTeamCommand = { + ...teamCommand, + commandId: OrganizationCommandId.make("organization-pglite-invalid-team"), + departmentId: DepartmentId.make("organization-pglite-unknown-department"), + }; - const invalidReference = yield* Effect.flip( - organization.createTeam(invalidTeamCommand, administrator), - ); + const invalidReference = yield* Effect.flip( + organization.createTeam(invalidTeamCommand, administrator), + ); - const invalidFieldCommand = { - ...fieldCommand, - commandId: OrganizationCommandId.make("organization-pglite-invalid-field"), - departmentId: DepartmentId.make("organization-pglite-unknown-field-department"), - }; + const invalidFieldCommand = { + ...fieldCommand, + commandId: OrganizationCommandId.make("organization-pglite-invalid-field"), + departmentId: DepartmentId.make("organization-pglite-unknown-field-department"), + }; - const invalidFieldReference = yield* Effect.flip( - organization.createFieldOfStudy(invalidFieldCommand, administrator), - ); + const invalidFieldReference = yield* Effect.flip( + organization.createFieldOfStudy(invalidFieldCommand, administrator), + ); - const invalidRows = yield* database<{ readonly count: string }>` + const invalidRows = yield* database<{ readonly count: string }>` SELECT ( (SELECT count(*) FROM organization_teams WHERE native_creation_command_id = ${invalidTeamCommand.commandId}) @@ -4289,15 +4236,15 @@ describe("DatabaseTest", () => { )::text AS count `; - const rollbackCommandId = OrganizationCommandId.make( - "organization-pglite-deferred-rollback", - ); + const rollbackCommandId = OrganizationCommandId.make( + "organization-pglite-deferred-rollback", + ); - const rollbackDepartmentId = departmentIdForCommand(rollbackCommandId); + const rollbackDepartmentId = departmentIdForCommand(rollbackCommandId); - const rollback = yield* Effect.exit( - database.withTransaction( - database` + const rollback = yield* Effect.exit( + database.withTransaction( + database` INSERT INTO organization_departments ( department_id, name, @@ -4314,16 +4261,16 @@ describe("DatabaseTest", () => { ${rollbackCommandId} ) `, - ), - ); + ), + ); - const rollbackRows = yield* database<{ readonly count: string }>` + const rollbackRows = yield* database<{ readonly count: string }>` SELECT count(*)::text AS count FROM organization_departments WHERE department_id = ${rollbackDepartmentId} `; - yield* database` + yield* database` INSERT INTO organization_departments ( department_id, name, @@ -4338,7 +4285,7 @@ describe("DatabaseTest", () => { 'Bergen' ) `; - yield* database` + yield* database` INSERT INTO organization_teams (team_id, department_id, name) VALUES ( 'organization-pglite-imported-team', @@ -4347,10 +4294,10 @@ describe("DatabaseTest", () => { ) `; - const imported = yield* database<{ - readonly departmentCommandId: string | null; - readonly teamCommandId: string | null; - }>` + const imported = yield* database<{ + readonly departmentCommandId: string | null; + readonly teamCommandId: string | null; + }>` SELECT department.native_creation_command_id AS "departmentCommandId", team.native_creation_command_id AS "teamCommandId" @@ -4361,12 +4308,12 @@ describe("DatabaseTest", () => { AND team.team_id = 'organization-pglite-imported-team' `; - const linkage = yield* database<{ - readonly receipts: string; - readonly audits: string; - readonly entities: string; - readonly exactLinks: string; - }>` + const linkage = yield* database<{ + readonly receipts: string; + readonly audits: string; + readonly entities: string; + readonly exactLinks: string; + }>` WITH accepted(command_id) AS ( VALUES (${departmentCommand.commandId}::text), @@ -4420,55 +4367,74 @@ describe("DatabaseTest", () => { AND entity_command_id = command_id) AS "exactLinks" `; - const departments = yield* organization.listDepartments; - const teams = yield* organization.listTeams(); - const fields = yield* organization.listFieldOfStudies; - - return { - schemaRevision: database.schemaRevision, - denied, - deniedRows: Number(deniedRows[0]?.count ?? "-1"), - departmentCreated, - departmentReplayed, - departmentConflict, - teamCreated, - teamReplayed, - fieldCreated, - fieldReplayed, - scopedFieldCreated, - invalidReference, - invalidFieldReference, - invalidRows: Number(invalidRows[0]?.count ?? "-1"), - rollbackTag: rollback._tag, - rollbackRows: Number(rollbackRows[0]?.count ?? "-1"), - imported: imported[0], - linkage: { - receipts: Number(linkage[0]?.receipts ?? "-1"), - audits: Number(linkage[0]?.audits ?? "-1"), - entities: Number(linkage[0]?.entities ?? "-1"), - exactLinks: Number(linkage[0]?.exactLinks ?? "-1"), - }, - publicLists: { - departments: departments.some( - (department) => department.departmentId === departmentId, - ), - teams: teams.some( - (team) => - Predicate.isTagged(teamCreated.observation, "TeamCreated") && - team.teamId === teamCreated.observation.team.teamId, - ), - fields: fields.some( - (field) => - Predicate.isTagged(fieldCreated.observation, "FieldOfStudyCreated") && - field.fieldOfStudyId === fieldCreated.observation.fieldOfStudy.fieldOfStudyId, - ), - }, - }; - }); + const departments = yield* organization.listDepartments; + const teams = yield* organization.listTeams(); + const fields = yield* organization.listFieldOfStudies; + + const actors = yield* database<{ + readonly tag: string; + readonly personId: string; + readonly keyCount: string; + }>` + SELECT DISTINCT + actor_json::jsonb ->> '_tag' AS tag, + actor_json::jsonb ->> 'personId' AS "personId", + (SELECT count(*) FROM jsonb_object_keys(actor_json::jsonb))::text AS "keyCount" + FROM organization_command_receipts + WHERE command_id IN ( + ${departmentCommand.commandId}, + ${teamCommand.commandId}, + ${fieldCommand.commandId} + ) + `; + + const evidence = { + schemaRevision: database.schemaRevision, + actors, + departmentCreated, + departmentReplayed, + departmentConflict, + teamCreated, + teamReplayed, + fieldCreated, + fieldReplayed, + scopedFieldCreated, + invalidReference, + invalidFieldReference, + invalidRows: Number(invalidRows[0]?.count ?? "-1"), + rollbackTag: rollback._tag, + rollbackRows: Number(rollbackRows[0]?.count ?? "-1"), + imported: imported[0], + linkage: { + receipts: Number(linkage[0]?.receipts ?? "-1"), + audits: Number(linkage[0]?.audits ?? "-1"), + entities: Number(linkage[0]?.entities ?? "-1"), + exactLinks: Number(linkage[0]?.exactLinks ?? "-1"), + }, + publicLists: { + departments: departments.some((department) => department.departmentId === departmentId), + teams: teams.some( + (team) => + Predicate.isTagged(teamCreated.observation, "TeamCreated") && + team.teamId === teamCreated.observation.team.teamId, + ), + fields: fields.some( + (field) => + Predicate.isTagged(fieldCreated.observation, "FieldOfStudyCreated") && + field.fieldOfStudyId === fieldCreated.observation.fieldOfStudy.fieldOfStudyId, + ), + }, + }; expect(evidence.schemaRevision).toBe(databaseSchemaRevision); - expect(evidence.denied._tag).toBe("OrganizationRoleDenied"); - expect(evidence.deniedRows).toBe(0); + // The audit identity keeps its stored shape: evidence records the administrator actor. + expect(evidence.actors).toEqual([ + { + tag: "OrganizationAdministrator", + personId: "organization-pglite-administrator", + keyCount: "2", + }, + ]); expect(evidence.departmentCreated.committed).toBe(true); expect(evidence.departmentReplayed.committed).toBe(false); expect(evidence.departmentReplayed.observation._tag).toBe("Replayed"); @@ -4507,26 +4473,25 @@ describe("DatabaseTest", () => { Effect.gen(function* () { const fixtureId = "conduct-core"; - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const recruitment = yield* Recruitment; - const fixture = yield* seedSchedulingFixture(fixtureId); - yield* recruitment.scheduleInterview(fixture.command, { - actor: fixture.actor, - now: fixture.now, - invitationId: fixture.invitationId, - responseCapability: fixture.responseCapability, - }); - yield* database.withTransaction( - Effect.gen(function* () { - yield* database` + const database = yield* Database; + const recruitment = yield* Recruitment; + const fixture = yield* seedSchedulingFixture(fixtureId); + yield* recruitment.scheduleInterview(fixture.command, { + actor: fixture.actor, + now: fixture.now, + invitationId: fixture.invitationId, + responseCapability: fixture.responseCapability, + }); + yield* database.withTransaction( + Effect.gen(function* () { + yield* database` UPDATE recruitment_invitations SET response_state = 'Accepted', responded_at = '2031-09-15T12:01:00.000Z', response_revision = 1 WHERE invitation_id = ${fixture.invitationId} `; - yield* database` + yield* database` INSERT INTO recruitment_invitation_response_audit ( invitation_id, interview_id, schedule_revision, response_revision, response_state, response_message, responded_at @@ -4535,60 +4500,60 @@ describe("DatabaseTest", () => { 'Accepted', NULL, '2031-09-15T12:01:00.000Z' ) `; - }), - ); + }), + ); - const context = { - actor: { - _tag: "Member" as const, - personId: fixture.interviewerPersonId, - departmentId: fixture.departmentId, - active: true, - }, - now: "2031-09-15T12:02:00.000Z", - }; + const context = { + actor: { + _tag: "Member" as const, + personId: fixture.interviewerPersonId, + departmentId: fixture.departmentId, + active: true, + }, + now: "2031-09-15T12:02:00.000Z", + }; - const before = yield* recruitment.readInterviewConduct(fixture.interviewId, context); + const before = yield* recruitment.readInterviewConduct(fixture.interviewId, context); - const answers = Array.from({ length: 8 }, (_, ordinal) => ({ - questionId: `${fixtureId}-q${ordinal}`, - answer: `Answer ${ordinal}`, - })); + const answers = Array.from({ length: 8 }, (_, ordinal) => ({ + questionId: `${fixtureId}-q${ordinal}`, + answer: `Answer ${ordinal}`, + })); - const command = { - commandId: RecruitmentConductCommandId.make(`${fixtureId}-finalize`), - interviewId: fixture.interviewId, - expectedRevision: 1, - answers, - score: { explanatoryPower: 0, roleModel: 10, suitability: 5 }, - recommendation: "Ja" as const, - }; + const command = { + commandId: RecruitmentConductCommandId.make(`${fixtureId}-finalize`), + interviewId: fixture.interviewId, + expectedRevision: 1, + answers, + score: { explanatoryPower: 0, roleModel: 10, suitability: 5 }, + recommendation: "Ja" as const, + }; - const finalized = yield* recruitment.finalizeInterview(command, context); - const replayed = yield* recruitment.finalizeInterview(command, context); - const after = yield* recruitment.readInterviewConduct(fixture.interviewId, context); - const recording = makeRecordingNotificationGateway("2031-09-15T12:03:00.000Z"); + const finalized = yield* recruitment.finalizeInterview(command, context); + const replayed = yield* recruitment.finalizeInterview(command, context); + const after = yield* recruitment.readInterviewConduct(fixture.interviewId, context); + const recording = makeRecordingNotificationGateway("2031-09-15T12:03:00.000Z"); - const delivery = yield* deliverNextRecruitmentInterviewCompletion( - `${fixtureId}-completion-claim`, - "2031-09-15T12:02:30.000Z", - ).pipe(Effect.provide(recording.layer)); + const delivery = yield* deliverNextRecruitmentInterviewCompletion( + `${fixtureId}-completion-claim`, + "2031-09-15T12:02:30.000Z", + ).pipe(Effect.provide(recording.layer)); - const idle = yield* deliverNextRecruitmentInterviewCompletion( - `${fixtureId}-completion-idle`, - "2031-09-15T12:04:00.000Z", - ).pipe(Effect.provide(recording.layer)); + const idle = yield* deliverNextRecruitmentInterviewCompletion( + `${fixtureId}-completion-idle`, + "2031-09-15T12:04:00.000Z", + ).pipe(Effect.provide(recording.layer)); - const counts = yield* database<{ - readonly conducts: string; - readonly receipts: string; - readonly audits: string; - readonly outbox: string; - readonly outboxStatus: string; - readonly payloadCleared: boolean; - readonly providerReference: string | null; - readonly revision: string; - }>` + const counts = yield* database<{ + readonly conducts: string; + readonly receipts: string; + readonly audits: string; + readonly outbox: string; + readonly outboxStatus: string; + readonly payloadCleared: boolean; + readonly providerReference: string | null; + readonly revision: string; + }>` SELECT (SELECT count(*)::text FROM public.recruitment_interview_conducts WHERE interview_id = ${fixture.interviewId}) AS conducts, (SELECT count(*)::text FROM public.recruitment_interview_lifecycle_command_receipts WHERE interview_id = ${fixture.interviewId}) AS receipts, @@ -4600,17 +4565,16 @@ describe("DatabaseTest", () => { (SELECT revision::text FROM recruitment_interviews WHERE interview_id = ${fixture.interviewId}) AS revision `; - return { - before: before.completionState, - finalized: [finalized.replayed, finalized.observation.notificationState], - replayed: [replayed.replayed, replayed.observation.notificationState], - delivery: delivery._tag, - idle: idle._tag, - completionRequests: recording.completionRequests, - after: [after.completionState, after.score], - counts, - }; - }); + const evidence = { + before: before.completionState, + finalized: [finalized.replayed, finalized.observation.notificationState], + replayed: [replayed.replayed, replayed.observation.notificationState], + delivery: delivery._tag, + idle: idle._tag, + completionRequests: recording.completionRequests, + after: [after.completionState, after.score], + counts, + }; const completionRequests = evidence.completionRequests.map( ({ _tag: _, ...request }) => request, @@ -4659,26 +4623,25 @@ describe("DatabaseTest", () => { it.effect("cancels native Recruitment interviews atomically in PGlite", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const recruitment = yield* Recruitment; - const fixture = yield* seedSchedulingFixture("conduct-cancel"); - yield* recruitment.scheduleInterview(fixture.command, { - actor: fixture.actor, - now: fixture.now, - invitationId: fixture.invitationId, - responseCapability: fixture.responseCapability, - }); - yield* database.withTransaction( - Effect.gen(function* () { - yield* database` + const database = yield* Database; + const recruitment = yield* Recruitment; + const fixture = yield* seedSchedulingFixture("conduct-cancel"); + yield* recruitment.scheduleInterview(fixture.command, { + actor: fixture.actor, + now: fixture.now, + invitationId: fixture.invitationId, + responseCapability: fixture.responseCapability, + }); + yield* database.withTransaction( + Effect.gen(function* () { + yield* database` UPDATE recruitment_invitations SET response_state = 'Accepted', responded_at = '2031-09-15T12:01:00.000Z', response_revision = 1 WHERE invitation_id = ${fixture.invitationId} `; - yield* database` + yield* database` INSERT INTO recruitment_invitation_response_audit ( invitation_id, interview_id, schedule_revision, response_revision, response_state, response_message, responded_at @@ -4687,36 +4650,36 @@ describe("DatabaseTest", () => { 'Accepted', NULL, '2031-09-15T12:01:00.000Z' ) `; - }), - ); + }), + ); - const context = { - actor: { - _tag: "Member" as const, - personId: fixture.interviewerPersonId, - departmentId: fixture.departmentId, - active: true, - }, - now: "2031-09-15T12:02:00.000Z", - }; + const context = { + actor: { + _tag: "Member" as const, + personId: fixture.interviewerPersonId, + departmentId: fixture.departmentId, + active: true, + }, + now: "2031-09-15T12:02:00.000Z", + }; - const cancelled = yield* recruitment.cancelInterview( - { - commandId: RecruitmentCancellationCommandId.make("conduct-cancel-command"), - interviewId: fixture.interviewId, - expectedRevision: 1, - }, - context, - ); + const cancelled = yield* recruitment.cancelInterview( + { + commandId: RecruitmentCancellationCommandId.make("conduct-cancel-command"), + interviewId: fixture.interviewId, + expectedRevision: 1, + }, + context, + ); - const after = yield* recruitment.readInterviewConduct(fixture.interviewId, context); + const after = yield* recruitment.readInterviewConduct(fixture.interviewId, context); - const counts = yield* database<{ - readonly cancellations: string; - readonly receipts: string; - readonly audits: string; - readonly revision: string; - }>` + const counts = yield* database<{ + readonly cancellations: string; + readonly receipts: string; + readonly audits: string; + readonly revision: string; + }>` SELECT (SELECT count(*)::text FROM public.recruitment_interview_cancellations WHERE interview_id = ${fixture.interviewId}) AS cancellations, (SELECT count(*)::text FROM public.recruitment_interview_lifecycle_command_receipts WHERE interview_id = ${fixture.interviewId}) AS receipts, @@ -4724,8 +4687,7 @@ describe("DatabaseTest", () => { (SELECT revision::text FROM recruitment_interviews WHERE interview_id = ${fixture.interviewId}) AS revision `; - return { replayed: cancelled.replayed, state: after.cancellationState, counts }; - }); + const evidence = { replayed: cancelled.replayed, state: after.cancellationState, counts }; expect(evidence).toEqual({ replayed: false, @@ -5224,8 +5186,8 @@ describe("claim-fenced outbox delivery", () => { }); const submitReceipt = (index: number) => - executeReceiptCommand( - { + executeReceiptCommand({ + command: { _tag: "SubmitReceipt" as const, commandId: `fence-submit-${index}`, departmentId: DepartmentId.make("fence-department"), @@ -5240,15 +5202,15 @@ describe("claim-fenced outbox delivery", () => { sha256: String(index).repeat(64), }, }, - { + principal: { personId: PersonId.make("fence-owner"), authorizationInstant: `2026-08-23T12:0${index}:00.000Z`, }, - { + allocation: { receiptId: ReceiptId.make(`fence-receipt-${index}`), visualId: ReceiptVisualId.make(`FENCE-000${index}`), }, - ); + }); it.live( "reports lost applicant effect claims as ClaimLost without recording an outcome", @@ -5300,7 +5262,7 @@ describe("claim-fenced outbox delivery", () => { loseClaim.pipe( Effect.andThen( Effect.fail( - new PublicApplicationEffectDeliveryError({ effectId: request.effectId }), + PublicApplicationEffectDeliveryError.make({ effectId: request.effectId }), ), ), ), @@ -5445,7 +5407,9 @@ describe("claim-fenced outbox delivery", () => { yield* submitReceipt(1); const loseClaim = Effect.gen(function* () { - for (const claimId of yield* listStaleReceiptOutboxClaimIds(staleCutoff)) + for (const claimId of yield* listStaleReceiptOutboxClaimIds({ + claimedBefore: staleCutoff, + })) yield* recoverStaleReceiptOutbox(claimId, staleCutoff); }).pipe(Effect.provideService(Database, database), Effect.orDie); @@ -5462,10 +5426,10 @@ describe("claim-fenced outbox delivery", () => { WHERE command_id = 'fence-submit-1' AND ordinal = 0 `; - const lost = yield* deliverNextReceiptOutbox( - "fence-receipt-claim-1", - "2026-08-23T12:10:00.000Z", - ).pipe( + const lost = yield* deliverNextReceiptOutbox({ + claimId: "fence-receipt-claim-1", + claimedAt: "2026-08-23T12:10:00.000Z", + }).pipe( Effect.provideService(ReceiptFileService, { stage: () => Effect.void, apply: () => loseClaim, @@ -5475,10 +5439,10 @@ describe("claim-fenced outbox delivery", () => { const afterLoss = yield* firstEffect; - const delivered = yield* deliverNextReceiptOutbox( - "fence-receipt-claim-2", - "2026-08-23T12:11:00.000Z", - ).pipe( + const delivered = yield* deliverNextReceiptOutbox({ + claimId: "fence-receipt-claim-2", + claimedAt: "2026-08-23T12:11:00.000Z", + }).pipe( Effect.provideService(ReceiptFileService, { stage: () => Effect.void, apply: () => Effect.void, @@ -5540,10 +5504,10 @@ describe("claim-fenced outbox delivery", () => { const results: Array = []; for (let index = 0; index < 8; index++) { - const result = yield* deliverNextReceiptOutbox( - `fence-quarantine-claim-${index}`, - "2026-08-23T12:20:00.000Z", - ).pipe( + const result = yield* deliverNextReceiptOutbox({ + claimId: `fence-quarantine-claim-${index}`, + claimedAt: "2026-08-23T12:20:00.000Z", + }).pipe( Effect.provideService(ReceiptFileService, { stage: () => Effect.void, apply: () => Effect.void, diff --git a/packages/database/src/historical-service-cohort.ts b/packages/database/src/historical-service-cohort.ts index 5b38d2e83..2c59885be 100644 --- a/packages/database/src/historical-service-cohort.ts +++ b/packages/database/src/historical-service-cohort.ts @@ -274,7 +274,7 @@ export const importHistoricalServiceCohort = Effect.fn("importHistoricalServiceC for (const occurrence of decoded) { increment(sourceCounts, sourceIdOf(occurrence.row)); - if (!occurrence.value) continue; + if (occurrence.value === undefined) continue; const mappings = mappingsBySource.get(occurrence.value.sourceHistoryId) ?? []; if (mappings.length !== 1 || !referencesMatch(occurrence.value, mappings[0]!)) continue; @@ -317,7 +317,7 @@ export const importHistoricalServiceCohort = Effect.fn("importHistoricalServiceC [snapshot.sourceRepository, snapshot.snapshotId], )).rows[0]; - if (!evidence) + if (evidence === undefined) return yield* new HistoricalServiceFailure({ code: "ReferenceProvenanceMissing" }); if ( @@ -386,7 +386,7 @@ export const importHistoricalServiceCohort = Effect.fn("importHistoricalServiceC return yield* new HistoricalServiceFailure({ code: "ReferenceProvenanceConflict" }); } - if (prior.rows[0]) return yield* cohortReport(tx, snapshotKey); + if (prior.rows[0] !== undefined) return yield* cohortReport(tx, snapshotKey); const acceptedImports = yield* pgQuery<{ source_history_id: string; @@ -426,15 +426,23 @@ export const importHistoricalServiceCohort = Effect.fn("importHistoricalServiceC for (const occurrence of decoded) { const sourceHistoryId = sourceIdOf(occurrence.row); - const previous = sourceHistoryId ? importedBySource.get(sourceHistoryId) : undefined; + + const previous = + sourceHistoryId !== undefined && sourceHistoryId !== "" + ? importedBySource.get(sourceHistoryId) + : undefined; if (previous !== undefined && !occurrence.digestMismatch) { - const mappings = sourceHistoryId ? (mappingsBySource.get(sourceHistoryId) ?? []) : []; + const mappings = + sourceHistoryId !== undefined && sourceHistoryId !== "" + ? (mappingsBySource.get(sourceHistoryId) ?? []) + : []; + const mapping = mappings.length === 1 ? mappings[0] : undefined; if ( - !occurrence.value || - !mapping || + occurrence.value === undefined || + mapping === undefined || !referencesMatch(occurrence.value, mapping) || previous.source_kind !== snapshot.sourceKind || (snapshot.sourceKind === "LegacyBackup" && @@ -465,12 +473,12 @@ export const importHistoricalServiceCohort = Effect.fn("importHistoricalServiceC for (const occurrence of decoded) { const row = occurrence.value; - const mappings = row ? (mappingsBySource.get(row.sourceHistoryId) ?? []) : []; + const mappings = row !== undefined ? (mappingsBySource.get(row.sourceHistoryId) ?? []) : []; const mapping = mappings.length === 1 ? mappings[0] : undefined; let reason: HistoricalServiceReason; let sourceDigest: string | undefined; - if (!row) reason = "InvalidRow"; + if (row === undefined) reason = "InvalidRow"; else if ((sourceCounts.get(row.sourceHistoryId) ?? 0) > 1) reason = "DuplicateSource"; else if (mappings.length === 0) reason = "MappingMissing"; else if (mappings.length > 1) reason = "MappingAmbiguous"; @@ -489,7 +497,7 @@ export const importHistoricalServiceCohort = Effect.fn("importHistoricalServiceC [snapshot.sourceRepository, row.sourceUserId, mapping!.personId], ); - if (!personEvidence.rowCount) reason = "PersonReconciliationMissing"; + if ((personEvidence.rowCount ?? 0) === 0) reason = "PersonReconciliationMissing"; else { const references = (yield* pgQuery<{ department_exists: boolean; @@ -549,7 +557,13 @@ export const importHistoricalServiceCohort = Effect.fn("importHistoricalServiceC ], ); - if (reason === "Imported" && row && mapping && sourceDigest) { + if ( + reason === "Imported" && + row !== undefined && + mapping !== undefined && + sourceDigest !== undefined && + sourceDigest !== "" + ) { yield* pgQuery( tx, `INSERT INTO public.assistant_service_history diff --git a/packages/database/src/identity-access.ts b/packages/database/src/identity-access.ts index 5df75111d..86ece072d 100644 --- a/packages/database/src/identity-access.ts +++ b/packages/database/src/identity-access.ts @@ -1,4 +1,5 @@ import { Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import type { SqlError } from "effect/unstable/sql/SqlError"; import type { PersonId } from "@vektorprogrammet/domain/organization"; import { @@ -31,16 +32,29 @@ import type { DatabaseOperations } from "./service.js"; * * @construct sql-lifecycle */ -export const accountAccessEnabled = ( - sql: DatabaseOperations, - personId: PersonId, - lock: "None" | "ForShare", -): Effect.Effect => - sql<{ - readonly enabled: boolean; - }>`SELECT NOT access_disabled AS enabled FROM auth."user" WHERE id=${personId} ${lock === "ForShare" ? sql`FOR SHARE` : sql``}`.pipe( - Effect.map((rows) => rows[0]?.enabled === true), - ); +export const accountAccessEnabled: { + ( + personId: PersonId, + lock: "None" | "ForShare", + ): (sql: DatabaseOperations) => Effect.Effect; + ( + sql: DatabaseOperations, + personId: PersonId, + lock: "None" | "ForShare", + ): Effect.Effect; +} = dual( + 3, + ( + sql: DatabaseOperations, + personId: PersonId, + lock: "None" | "ForShare", + ): Effect.Effect => + sql<{ + readonly enabled: boolean; + }>`SELECT NOT access_disabled AS enabled FROM auth."user" WHERE id=${personId} ${lock === "ForShare" ? sql`FOR SHARE` : sql``}`.pipe( + Effect.map((rows) => rows[0]?.enabled === true), + ), +); /** Caller holds the administrator-set lock and sorted actor/subject person locks. */ export const changeNativeAccountAccess = Effect.fn("changeNativeAccountAccess")(function* ( @@ -59,7 +73,7 @@ export const changeNativeAccountAccess = Effect.fn("changeNativeAccountAccess")( const current = rows[0]; - if (!current) return yield* new AccountAccessFailure({ code: "NotFound" }); + if (current === undefined) return yield* AccountAccessFailure.make({ code: "NotFound" }); let anotherUsableAdministrator = true; if (input.disabled) { diff --git a/packages/database/src/identity-cohort.ts b/packages/database/src/identity-cohort.ts index b67165b41..ad2d6cf07 100644 --- a/packages/database/src/identity-cohort.ts +++ b/packages/database/src/identity-cohort.ts @@ -188,9 +188,10 @@ export const importIdentityCohort = Effect.fn("importIdentityCohort")(function* for (const occurrence of decoded) { const sourceId = sourceIdOf(occurrence.row); - if (sourceId) sourceCounts.set(sourceId, (sourceCounts.get(sourceId) ?? 0) + 1); + if (sourceId !== undefined && sourceId !== "") + sourceCounts.set(sourceId, (sourceCounts.get(sourceId) ?? 0) + 1); - if (occurrence.value) { + if (occurrence.value !== undefined) { const email = occurrence.value.email.toLowerCase(); emailCounts.set(email, (emailCounts.get(email) ?? 0) + 1); } @@ -209,7 +210,7 @@ export const importIdentityCohort = Effect.fn("importIdentityCohort")(function* [key], ); - if (prior.rows[0]) { + if (prior.rows[0] !== undefined) { if (prior.rows[0].snapshot_digest !== snapshotDigest) return yield* new IdentityCohortFailure({ code: "SnapshotConflict" }); @@ -234,11 +235,11 @@ export const importIdentityCohort = Effect.fn("importIdentityCohort")(function* for (const occurrence of decoded) { const row = occurrence.value; let reason: CohortReason | undefined; - const mappings = row ? (mappingsBySource.get(row.sourceUserId) ?? []) : []; + const mappings = row !== undefined ? (mappingsBySource.get(row.sourceUserId) ?? []) : []; const mapping = mappings.length === 1 ? mappings[0] : undefined; const sourceId = sourceIdOf(occurrence.row); - if (sourceId) { + if (sourceId !== undefined && sourceId !== "") { const previousSource = yield* pgQuery<{ source_digest: string }>( tx, "SELECT source_digest FROM auth.account_cohort_imports WHERE source_repository=$1 AND source_user_id=$2", @@ -246,13 +247,15 @@ export const importIdentityCohort = Effect.fn("importIdentityCohort")(function* ); if ( - previousSource.rows[0] && - (!row || !mapping || previousSource.rows[0].source_digest !== digest({ row, mapping })) + previousSource.rows[0] !== undefined && + (row === undefined || + mapping === undefined || + previousSource.rows[0].source_digest !== digest({ row, mapping })) ) return yield* new IdentityCohortFailure({ code: "SourceIdentityConflict" }); } - if (!row) reason = "InvalidRow"; + if (row === undefined) reason = "InvalidRow"; else if ((sourceCounts.get(row.sourceUserId) ?? 0) > 1) reason = "DuplicateSource"; else if ((emailCounts.get(row.email.toLowerCase()) ?? 0) > 1) reason = "DuplicateEmail"; else if (!row.active) reason = "Inactive"; @@ -267,7 +270,7 @@ export const importIdentityCohort = Effect.fn("importIdentityCohort")(function* !isSupportedLegacyPasswordHash(row.passwordHash) ) reason = "UnsupportedHash"; - else if (!mappings.length) reason = "MappingMissing"; + else if (mappings.length === 0) reason = "MappingMissing"; else if (mappings.length > 1) reason = "MappingAmbiguous"; else if (mapping!.emailOwnership.email.toLowerCase() !== row.email.toLowerCase()) reason = "EmailUnattested"; @@ -281,7 +284,7 @@ export const importIdentityCohort = Effect.fn("importIdentityCohort")(function* | { first_name: string; last_name: string; contact_email: string | null } | undefined; - if (!reason && row && mapping) { + if (reason === undefined && row !== undefined && mapping !== undefined) { sourceDigest = digest({ row, mapping }); accountId = passwordless ? null @@ -298,7 +301,7 @@ export const importIdentityCohort = Effect.fn("importIdentityCohort")(function* [snapshot.sourceRepository, row.sourceUserId], ); - if (imported.rows[0]) { + if (imported.rows[0] !== undefined) { if ( imported.rows[0].source_digest !== sourceDigest || imported.rows[0].person_id !== mapping.personId || @@ -325,25 +328,26 @@ export const importIdentityCohort = Effect.fn("importIdentityCohort")(function* [snapshot.sourceRepository, row.sourceUserId, mapping.personId], )).rows[0]; - if (!person) reason = "PersonReconciliationMissing"; + if (person === undefined) reason = "PersonReconciliationMissing"; else if (person.contact_email?.toLowerCase() !== row.email.toLowerCase()) reason = "EmailConflict"; else if ( - (yield* pgQuery(tx, 'SELECT 1 FROM auth."user" WHERE id=$1', [mapping.personId])) - .rowCount + ((yield* pgQuery(tx, 'SELECT 1 FROM auth."user" WHERE id=$1', [mapping.personId])) + .rowCount ?? 0) !== 0 ) reason = "TargetConflict"; else if ( - (yield* pgQuery(tx, 'SELECT 1 FROM auth."user" WHERE lower(email)=$1', [ + ((yield* pgQuery(tx, 'SELECT 1 FROM auth."user" WHERE lower(email)=$1', [ row.email.toLowerCase(), - ])).rowCount + ])).rowCount ?? 0) !== 0 ) reason = "EmailConflict"; else reason = passwordless ? "RecoveryPending" : "Imported"; } } - if (!reason) return yield* new IdentityCohortFailure({ code: "PersistenceFailure" }); + if (reason === undefined) + return yield* new IdentityCohortFailure({ code: "PersistenceFailure" }); const accepted = reason === "Imported" || reason === "RecoveryPending" || reason === "ExactReplay"; @@ -356,11 +360,12 @@ export const importIdentityCohort = Effect.fn("importIdentityCohort")(function* if ( (reason === "Imported" || reason === "RecoveryPending") && - row && - mapping && - person && + row !== undefined && + mapping !== undefined && + person !== undefined && accountId !== undefined && - sourceDigest + sourceDigest !== undefined && + sourceDigest !== "" ) { yield* pgQuery( tx, diff --git a/packages/database/src/layers.ts b/packages/database/src/layers.ts index e35c58914..b4c810131 100644 --- a/packages/database/src/layers.ts +++ b/packages/database/src/layers.ts @@ -1,5 +1,6 @@ import * as PgClient from "@effect/sql-pg/PgClient"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Predicate } from "effect"; +import { dual } from "effect/Function"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { databaseWithMigrations, type DatabaseLayerObserver } from "./database-construction.js"; import { sharedPgLayer } from "./pg-pool.js"; @@ -27,12 +28,27 @@ const DatabaseFromPg = (observer?: DatabaseLayerObserver) => }), ); +const makeDatabaseLive = ( + config: Parameters[0], + observer?: DatabaseLayerObserver, +) => DatabaseFromPg(observer).pipe(Layer.provideMerge(sharedPgLayer(config))); + +type DatabaseLiveLayer = ReturnType; + /** * PostgreSQL through the shared pool, migrated to the head revision when the layer is built. It * requires `FileSystem` and `Path`, which read the migration files; the composition root selects * their platform. */ -export const DatabaseLive = ( - config: Parameters[0], - observer?: DatabaseLayerObserver, -) => DatabaseFromPg(observer).pipe(Layer.provideMerge(sharedPgLayer(config))); +export const DatabaseLive: { + ( + observer?: DatabaseLayerObserver, + ): (config: Parameters[0]) => DatabaseLiveLayer; + ( + config: Parameters[0], + observer?: DatabaseLayerObserver, + ): DatabaseLiveLayer; +} = dual( + (args) => args.length > 0 && !Predicate.hasProperty(args[0], "onAcquire"), + makeDatabaseLive, +); diff --git a/packages/database/src/migration-registry.ts b/packages/database/src/migration-registry.ts index 0219eb546..4ed3e86d6 100644 --- a/packages/database/src/migration-registry.ts +++ b/packages/database/src/migration-registry.ts @@ -15,6 +15,7 @@ */ import { createHash } from "node:crypto"; import { Effect, FileSystem, Path, Schema } from "effect"; +import { dual } from "effect/Function"; export interface RegisteredMigration { readonly id: string; @@ -59,97 +60,137 @@ const migrationFile = (url: URL) => decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf("/") + 1)); /** Findings for ids, names, and files that break the registry rules. */ -export const registryFindings = ( - definitions: ReadonlyArray, - files: ReadonlyArray, -): ReadonlyArray => { - const findings: MigrationFinding[] = []; - const finding = (id: string, message: string) => findings.push({ kind: "registry", id, message }); - const seen = new Set(); +export const registryFindings: { + ( + files: ReadonlyArray, + ): (definitions: ReadonlyArray) => ReadonlyArray; + ( + definitions: ReadonlyArray, + files: ReadonlyArray, + ): ReadonlyArray; +} = dual( + 2, + ( + definitions: ReadonlyArray, + files: ReadonlyArray, + ): ReadonlyArray => { + const findings: MigrationFinding[] = []; - for (const [index, { id, name, url }] of definitions.entries()) { - if (seen.has(id)) finding(id, "The id is registered more than once."); - seen.add(id); + const finding = (id: string, message: string) => + findings.push({ kind: "registry", id, message }); - const match = idPattern.exec(id); + const seen = new Set(); - if (match === null) { - finding(id, "The id is not `_`."); - continue; - } + for (const [index, { id, name, url }] of definitions.entries()) { + if (seen.has(id)) finding(id, "The id is registered more than once."); + seen.add(id); - const [, number = "", suffix = ""] = match; + const match = idPattern.exec(id); - if (Number(number) !== index + 1) - finding(id, `The id is at position ${index + 1}; ids count up from 1 without gaps.`); + if (match === null) { + finding(id, "The id is not `_`."); + continue; + } - if (name !== suffix) finding(id, `The name ${JSON.stringify(name)} is not the id's suffix.`); + const [, number = "", suffix = ""] = match; - const exception = Object.entries(migrationFileExceptions).find(([except]) => except === id); - const expected = exception?.[1].file ?? `${number.padStart(4, "0")}-${suffix}.sql`; + if (Number(number) !== index + 1) + finding(id, `The id is at position ${index + 1}; ids count up from 1 without gaps.`); - const file = migrationFile(url); + if (name !== suffix) finding(id, `The name ${JSON.stringify(name)} is not the id's suffix.`); - if (file !== expected) finding(id, `The file is ${file}; the registry expects ${expected}.`); - } + const exception = Object.entries(migrationFileExceptions).find(([except]) => except === id); + const expected = exception?.[1].file ?? `${number.padStart(4, "0")}-${suffix}.sql`; - const referenced = new Set(definitions.map(({ url }) => migrationFile(url))); + const file = migrationFile(url); - for (const file of files) - if (!referenced.has(file)) finding(file, "No registered migration reads this file."); + if (file !== expected) finding(id, `The file is ${file}; the registry expects ${expected}.`); + } - return findings; -}; + const referenced = new Set(definitions.map(({ url }) => migrationFile(url))); -/** Findings for manifest entries that differ from the files or from the registry. */ -export const manifestFindings = ( - definitions: ReadonlyArray, - manifest: MigrationManifest, - digests: ReadonlyMap, -): ReadonlyArray => [ - ...definitions.flatMap(({ id }): MigrationFinding[] => { - const recorded = manifest[id]; - - if (recorded === undefined) - return [ - { - kind: "unrecorded", - id, - message: - "The checksum manifest has no entry for this migration; run `just migration-hashes write`.", - }, - ]; + for (const file of files) + if (!referenced.has(file)) finding(file, "No registered migration reads this file."); - return recorded === digests.get(id) - ? [] - : [ + return findings; + }, +); + +/** Findings for manifest entries that differ from the files or from the registry. */ +export const manifestFindings: { + ( + manifest: MigrationManifest, + digests: ReadonlyMap, + ): (definitions: ReadonlyArray) => ReadonlyArray; + ( + definitions: ReadonlyArray, + manifest: MigrationManifest, + digests: ReadonlyMap, + ): ReadonlyArray; +} = dual( + 3, + ( + definitions: ReadonlyArray, + manifest: MigrationManifest, + digests: ReadonlyMap, + ): ReadonlyArray => [ + ...definitions.flatMap(({ id }): MigrationFinding[] => { + const recorded = manifest[id]; + + if (recorded === undefined) + return [ { - kind: "changed", + kind: "unrecorded", id, message: - "The file differs from its recorded checksum. An applied migration is immutable; add a new migration instead.", + "The checksum manifest has no entry for this migration; run `just migration-hashes write`.", }, ]; - }), - ...Object.keys(manifest) - .filter((id) => !definitions.some((definition) => definition.id === id)) - .map( - (id): MigrationFinding => ({ - kind: "unregistered", - id, - message: - "The checksum manifest records a migration that the registry does not define. An applied migration stays registered.", - }), - ), -]; + + return recorded === digests.get(id) + ? [] + : [ + { + kind: "changed", + id, + message: + "The file differs from its recorded checksum. An applied migration is immutable; add a new migration instead.", + }, + ]; + }), + ...Object.keys(manifest) + .filter((id) => !definitions.some((definition) => definition.id === id)) + .map( + (id): MigrationFinding => ({ + kind: "unregistered", + id, + message: + "The checksum manifest records a migration that the registry does not define. An applied migration stays registered.", + }), + ), + ], +); /** The manifest in registry order with an entry for every registered migration; existing entries stay. */ -export const appendToManifest = ( - definitions: ReadonlyArray, - manifest: MigrationManifest, - digests: ReadonlyMap, -): MigrationManifest => - Object.fromEntries(definitions.map(({ id }) => [id, manifest[id] ?? digests.get(id)!])); +export const appendToManifest: { + ( + manifest: MigrationManifest, + digests: ReadonlyMap, + ): (definitions: ReadonlyArray) => MigrationManifest; + ( + definitions: ReadonlyArray, + manifest: MigrationManifest, + digests: ReadonlyMap, + ): MigrationManifest; +} = dual( + 3, + ( + definitions: ReadonlyArray, + manifest: MigrationManifest, + digests: ReadonlyMap, + ): MigrationManifest => + Object.fromEntries(definitions.map(({ id }) => [id, manifest[id] ?? digests.get(id)!])), +); /** The SHA-256 of every definition's file, by id. */ export const digestMigrations = Effect.fn("digestMigrations")(function* ( diff --git a/packages/database/src/migrations.test.ts b/packages/database/src/migrations.test.ts index 49cbda0d1..f2138922d 100644 --- a/packages/database/src/migrations.test.ts +++ b/packages/database/src/migrations.test.ts @@ -120,31 +120,29 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( "keeps qualified inventory reads independent of search_path", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* database` + const database = yield* Database; + yield* database` CREATE TABLE auth.content_articles (article_id bigint NOT NULL) `; - yield* database` + yield* database` INSERT INTO auth.content_articles (article_id) VALUES (9007199254740991) `; - yield* database`SET search_path TO auth, public`; + yield* database`SET search_path TO auth, public`; - const authFirst = yield* database<{ readonly count: string }>` + const authFirst = yield* database<{ readonly count: string }>` SELECT count(*)::text AS "count" FROM public.content_articles `; - yield* database`SET search_path TO public`; + yield* database`SET search_path TO public`; - const publicFirst = yield* database<{ readonly count: string }>` + const publicFirst = yield* database<{ readonly count: string }>` SELECT count(*)::text AS "count" FROM public.content_articles `; - yield* database`DROP TABLE auth.content_articles`; - yield* database`SET search_path TO auth, public`; + yield* database`DROP TABLE auth.content_articles`; + yield* database`SET search_path TO auth, public`; - return { authFirst, publicFirst }; - }); + const evidence = { authFirst, publicFirst }; expect(evidence.authFirst).toEqual(evidence.publicFirst); expect(evidence.authFirst).toEqual([{ count: "0" }]); @@ -154,13 +152,12 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( it.effect("places the complete post-identity inventory in public on fresh replay", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; + const database = yield* Database; - const relations = yield* database<{ - readonly tableName: string; - readonly schemaName: string; - }>` + const relations = yield* database<{ + readonly tableName: string; + readonly schemaName: string; + }>` SELECT relation.relname AS "tableName", namespace.nspname AS "schemaName" FROM pg_catalog.pg_class AS relation INNER JOIN pg_catalog.pg_namespace AS namespace @@ -171,7 +168,7 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( ORDER BY relation.relname, namespace.nspname `; - const authTables = yield* database<{ readonly tableName: string }>` + const authTables = yield* database<{ readonly tableName: string }>` SELECT relation.relname AS "tableName" FROM pg_catalog.pg_class AS relation INNER JOIN pg_catalog.pg_namespace AS namespace @@ -184,10 +181,10 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( ORDER BY relation.relname `; - const triggerFunctions = yield* database<{ - readonly functionName: string; - readonly schemaName: string; - }>` + const triggerFunctions = yield* database<{ + readonly functionName: string; + readonly schemaName: string; + }>` SELECT procedure.proname AS "functionName", namespace.nspname AS "schemaName" FROM pg_catalog.pg_proc AS procedure INNER JOIN pg_catalog.pg_namespace AS namespace @@ -202,8 +199,7 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( ORDER BY procedure.proname `; - return { relations, authTables, triggerFunctions }; - }); + const evidence = { relations, authTables, triggerFunctions }; expect(evidence.relations).toEqual( [...inventory].sort().map((tableName) => ({ tableName, schemaName: "public" })), @@ -508,14 +504,13 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( "enforces the closed bounded append-only event contract", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* database` + const database = yield* Database; + yield* database` INSERT INTO public.person_profiles (person_id, first_name, last_name) VALUES ('identity-audit-person', 'Identity', 'Audit') ON CONFLICT (person_id) DO NOTHING `; - yield* database` + yield* database` INSERT INTO auth.identity_security_audit ( event_id, event_kind, @@ -539,23 +534,23 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( ) `; - const update = yield* Effect.exit( - database` + const update = yield* Effect.exit( + database` UPDATE auth.identity_security_audit SET actor_principal = 'person:changed' WHERE event_id = 'identity-audit-valid' `.pipe(Effect.asVoid), - ); + ); - const deletion = yield* Effect.exit( - database` + const deletion = yield* Effect.exit( + database` DELETE FROM auth.identity_security_audit WHERE event_id = 'identity-audit-valid' `.pipe(Effect.asVoid), - ); + ); - const invalidKind = yield* Effect.exit( - database` + const invalidKind = yield* Effect.exit( + database` INSERT INTO auth.identity_security_audit ( event_id, event_kind, subject_person_id, actor_principal, request_correlation, details @@ -568,10 +563,10 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( ${database.json({ outcomeCode: "owned-session-revoked", affectedSessionCount: 1 })} ) `.pipe(Effect.asVoid), - ); + ); - const unboundedDetails = yield* Effect.exit( - database` + const unboundedDetails = yield* Effect.exit( + database` INSERT INTO auth.identity_security_audit ( event_id, event_kind, request_correlation, details ) VALUES ( @@ -585,10 +580,10 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( })} ) `.pipe(Effect.asVoid), - ); + ); - const missingCorrelation = yield* Effect.exit( - database` + const missingCorrelation = yield* Effect.exit( + database` INSERT INTO auth.identity_security_audit ( event_id, event_kind, subject_person_id, actor_principal, details ) VALUES ( @@ -599,26 +594,25 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( ${database.json({ outcomeCode: "owned-session-revoked", affectedSessionCount: 1 })} ) `.pipe(Effect.asVoid), - ); + ); - const rows = yield* database<{ - readonly eventId: string; - readonly details: unknown; - }>` + const rows = yield* database<{ + readonly eventId: string; + readonly details: unknown; + }>` SELECT event_id AS "eventId", details FROM auth.identity_security_audit WHERE event_id = 'identity-audit-valid' `; - return { - updateRejected: Predicate.isTagged(update, "Failure"), - deleteRejected: Predicate.isTagged(deletion, "Failure"), - invalidKindRejected: Predicate.isTagged(invalidKind, "Failure"), - unboundedDetailsRejected: Predicate.isTagged(unboundedDetails, "Failure"), - missingCorrelationRejected: Predicate.isTagged(missingCorrelation, "Failure"), - rows, - }; - }); + const evidence = { + updateRejected: Predicate.isTagged(update, "Failure"), + deleteRejected: Predicate.isTagged(deletion, "Failure"), + invalidKindRejected: Predicate.isTagged(invalidKind, "Failure"), + unboundedDetailsRejected: Predicate.isTagged(unboundedDetails, "Failure"), + missingCorrelationRejected: Predicate.isTagged(missingCorrelation, "Failure"), + rows, + }; expect(evidence).toEqual({ updateRejected: true, @@ -643,16 +637,15 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( "uses supported JSONB operators and rejects incomplete or inexact Submit params", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* database` + const database = yield* Database; + yield* database` INSERT INTO public.person_profiles (person_id, first_name, last_name) VALUES ('authz-params-person', 'Authz', 'Params') ON CONFLICT (person_id) DO NOTHING `; - const insertSubmitRule = (ruleId: string, params: Schema.Json) => - database` + const insertSubmitRule = (ruleId: string, params: Schema.Json) => + database` INSERT INTO public.authz_rules ( rule_id, capability_id, @@ -682,109 +675,106 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( ) `.pipe(Effect.asVoid); - const valid = yield* Effect.exit( - insertSubmitRule("authz-params-valid", { - slot: "EconomyPaymentAuthority", - paymentAccountCiphertext: "ciphertext", - }), - ); + const valid = yield* Effect.exit( + insertSubmitRule("authz-params-valid", { + slot: "EconomyPaymentAuthority", + paymentAccountCiphertext: "ciphertext", + }), + ); - const internalWhitespace = yield* Effect.exit( - insertSubmitRule("authz-params-internal-whitespace", { - slot: "EconomyPaymentAuthority", - paymentAccountCiphertext: "ciphertext\u00a0account", - }), - ); + const internalWhitespace = yield* Effect.exit( + insertSubmitRule("authz-params-internal-whitespace", { + slot: "EconomyPaymentAuthority", + paymentAccountCiphertext: "ciphertext\u00a0account", + }), + ); - const remainingEcmaScriptTrimWhitespace = ecmaScriptTrimBoundaryCharacters.slice(2); + const remainingEcmaScriptTrimWhitespace = ecmaScriptTrimBoundaryCharacters.slice(2); - const invalidParams: ReadonlyArray = [ - ["missingKey", { slot: "EconomyPaymentAuthority" }], - ["arbitraryKey", { slot: "EconomyPaymentAuthority", arbitrary: "ciphertext" }], - [ - "extraKey", - { - slot: "EconomyPaymentAuthority", - paymentAccountCiphertext: "ciphertext", - arbitrary: true, - }, - ], - ["nonStringSlot", { slot: null, paymentAccountCiphertext: "ciphertext" }], - [ - "nonStringCiphertext", - { slot: "EconomyPaymentAuthority", paymentAccountCiphertext: 42 }, - ], - [ - "emptyCiphertext", - { slot: "EconomyPaymentAuthority", paymentAccountCiphertext: "" }, - ], - [ - "paddedCiphertext", - { - slot: "EconomyPaymentAuthority", - paymentAccountCiphertext: " ciphertext ", - }, - ], - [ - "tabPaddedCiphertext", - { - slot: "EconomyPaymentAuthority", - paymentAccountCiphertext: "ciphertext\t", - }, - ], - [ - "newlinePaddedCiphertext", - { - slot: "EconomyPaymentAuthority", - paymentAccountCiphertext: "\nciphertext", - }, - ], - [ - "carriageReturnPaddedCiphertext", - { - slot: "EconomyPaymentAuthority", - paymentAccountCiphertext: "ciphertext\r", - }, - ], - [ - "nbspPaddedCiphertext", - { - slot: "EconomyPaymentAuthority", - paymentAccountCiphertext: "\u00a0ciphertext", - }, - ], - ]; + const invalidParams: ReadonlyArray = [ + ["missingKey", { slot: "EconomyPaymentAuthority" }], + ["arbitraryKey", { slot: "EconomyPaymentAuthority", arbitrary: "ciphertext" }], + [ + "extraKey", + { + slot: "EconomyPaymentAuthority", + paymentAccountCiphertext: "ciphertext", + arbitrary: true, + }, + ], + ["nonStringSlot", { slot: null, paymentAccountCiphertext: "ciphertext" }], + [ + "nonStringCiphertext", + { slot: "EconomyPaymentAuthority", paymentAccountCiphertext: 42 }, + ], + [ + "emptyCiphertext", + { slot: "EconomyPaymentAuthority", paymentAccountCiphertext: "" }, + ], + [ + "paddedCiphertext", + { + slot: "EconomyPaymentAuthority", + paymentAccountCiphertext: " ciphertext ", + }, + ], + [ + "tabPaddedCiphertext", + { + slot: "EconomyPaymentAuthority", + paymentAccountCiphertext: "ciphertext\t", + }, + ], + [ + "newlinePaddedCiphertext", + { + slot: "EconomyPaymentAuthority", + paymentAccountCiphertext: "\nciphertext", + }, + ], + [ + "carriageReturnPaddedCiphertext", + { + slot: "EconomyPaymentAuthority", + paymentAccountCiphertext: "ciphertext\r", + }, + ], + [ + "nbspPaddedCiphertext", + { + slot: "EconomyPaymentAuthority", + paymentAccountCiphertext: "\u00a0ciphertext", + }, + ], + ]; - const rejected: Record = {}; + const rejected: Record = {}; - for (const [name, params] of invalidParams) { - const outcome = yield* Effect.exit( - insertSubmitRule(`authz-params-${name}`, params), - ); + for (const [name, params] of invalidParams) { + const outcome = yield* Effect.exit(insertSubmitRule(`authz-params-${name}`, params)); - rejected[name] = Predicate.isTagged(outcome, "Failure"); - } + rejected[name] = Predicate.isTagged(outcome, "Failure"); + } - let remainingEcmaScriptBoundariesRejected = true; + let remainingEcmaScriptBoundariesRejected = true; - for (const [index, whitespace] of remainingEcmaScriptTrimWhitespace.entries()) { - const outcome = yield* Effect.exit( - insertSubmitRule(`authz-params-ecma-boundary-${index}`, { - slot: "EconomyPaymentAuthority", - paymentAccountCiphertext: `${whitespace}ciphertext`, - }), - ); + for (const [index, whitespace] of remainingEcmaScriptTrimWhitespace.entries()) { + const outcome = yield* Effect.exit( + insertSubmitRule(`authz-params-ecma-boundary-${index}`, { + slot: "EconomyPaymentAuthority", + paymentAccountCiphertext: `${whitespace}ciphertext`, + }), + ); - remainingEcmaScriptBoundariesRejected &&= Predicate.isTagged(outcome, "Failure"); - } + remainingEcmaScriptBoundariesRejected &&= Predicate.isTagged(outcome, "Failure"); + } - return { - validAccepted: Predicate.isTagged(valid, "Success"), - internalWhitespaceAccepted: Predicate.isTagged(internalWhitespace, "Success"), - rejected, - remainingEcmaScriptBoundariesRejected, - }; - }); + const evidence = { + validAccepted: Predicate.isTagged(valid, "Success"), + internalWhitespaceAccepted: Predicate.isTagged(internalWhitespace, "Success"), + rejected, + remainingEcmaScriptBoundariesRejected, + }; expect(evidence).toEqual({ validAccepted: true, @@ -812,70 +802,69 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( "rejects every ECMAScript-trimmed authorization identifier at the SQL boundary", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const personId = "authz-identifiers-person"; - const validTagId = "authz-identifiers-valid-tag"; - yield* database` + const database = yield* Database; + const personId = "authz-identifiers-person"; + const validTagId = "authz-identifiers-valid-tag"; + yield* database` INSERT INTO public.person_profiles (person_id, first_name, last_name) VALUES (${personId}, 'Authz', 'Identifiers') ON CONFLICT (person_id) DO NOTHING `; - yield* database` + yield* database` INSERT INTO public.authz_tags (tag_id, name) VALUES (${validTagId}, 'Authz Identifiers Valid Tag') ON CONFLICT (tag_id) DO NOTHING `; - const boundaryCases = [ - { name: "empty", makeValue: (_base: string) => "" }, - ...ecmaScriptTrimBoundaryCharacters.flatMap((whitespace, index) => [ - { - name: `leading-${index}`, - makeValue: (base: string) => `${whitespace}${base}`, - }, - { - name: `trailing-${index}`, - makeValue: (base: string) => `${base}${whitespace}`, - }, - ]), - ]; - - const unexpectedlyAccepted: Array = []; - let attemptedCases = 0; - - for (const [index, boundaryCase] of boundaryCases.entries()) { - const base = `authz-identifiers-${index}`; - const invalid = boundaryCase.makeValue(base); - - const attempts = [ - [ - "tagId", - database` + const boundaryCases = [ + { name: "empty", makeValue: (_base: string) => "" }, + ...ecmaScriptTrimBoundaryCharacters.flatMap((whitespace, index) => [ + { + name: `leading-${index}`, + makeValue: (base: string) => `${whitespace}${base}`, + }, + { + name: `trailing-${index}`, + makeValue: (base: string) => `${base}${whitespace}`, + }, + ]), + ]; + + const unexpectedlyAccepted: Array = []; + let attemptedCases = 0; + + for (const [index, boundaryCase] of boundaryCases.entries()) { + const base = `authz-identifiers-${index}`; + const invalid = boundaryCase.makeValue(base); + + const attempts = [ + [ + "tagId", + database` INSERT INTO public.authz_tags (tag_id, name) VALUES (${invalid}, ${`${base}-tag-name`}) `.pipe(Effect.asVoid), - ], - [ - "tagName", - database` + ], + [ + "tagName", + database` INSERT INTO public.authz_tags (tag_id, name) VALUES (${`${base}-tag-id`}, ${invalid}) `.pipe(Effect.asVoid), - ], - [ - "assignmentId", - database` + ], + [ + "assignmentId", + database` INSERT INTO public.authz_tag_assignments ( assignment_id, tag_id, person_id, start_at ) VALUES ( ${invalid}, ${validTagId}, ${personId}, '2030-01-01T00:00:00.000Z' ) `.pipe(Effect.asVoid), - ], - [ - "assignmentTagId", - database` + ], + [ + "assignmentTagId", + database` INSERT INTO public.authz_tag_assignments ( assignment_id, tag_id, person_id, start_at ) VALUES ( @@ -885,10 +874,10 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( '2030-01-01T00:00:00.000Z' ) `.pipe(Effect.asVoid), - ], - [ - "ruleId", - database` + ], + [ + "ruleId", + database` INSERT INTO public.authz_rules ( rule_id, capability_id, effect_kind, subject_kind, subject_person_id, subject_tag_id, scope, department_id, @@ -900,10 +889,10 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( '2030-01-01T00:00:00.000Z' ) `.pipe(Effect.asVoid), - ], - [ - "ruleSubjectTagId", - database` + ], + [ + "ruleSubjectTagId", + database` INSERT INTO public.authz_rules ( rule_id, capability_id, effect_kind, subject_kind, subject_person_id, subject_tag_id, scope, department_id, @@ -921,10 +910,10 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( '2030-01-01T00:00:00.000Z' ) `.pipe(Effect.asVoid), - ], - [ - "capabilityId", - database` + ], + [ + "capabilityId", + database` INSERT INTO public.authz_rules ( rule_id, capability_id, effect_kind, subject_kind, subject_person_id, subject_tag_id, scope, department_id, @@ -942,10 +931,10 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( '2030-01-01T00:00:00.000Z' ) `.pipe(Effect.asVoid), - ], - [ - "subjectKind", - database` + ], + [ + "subjectKind", + database` INSERT INTO public.authz_rules ( rule_id, capability_id, effect_kind, subject_kind, subject_person_id, subject_tag_id, scope, department_id, @@ -963,28 +952,28 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( '2030-01-01T00:00:00.000Z' ) `.pipe(Effect.asVoid), - ], - ] as const; + ], + ] as const; - for (const [category, attempt] of attempts) { - attemptedCases += 1; - const outcome = yield* Effect.exit(attempt); + for (const [category, attempt] of attempts) { + attemptedCases += 1; + const outcome = yield* Effect.exit(attempt); - if (Predicate.isTagged(outcome, "Success")) { - unexpectedlyAccepted.push(`${category}:${boundaryCase.name}`); - } + if (Predicate.isTagged(outcome, "Success")) { + unexpectedlyAccepted.push(`${category}:${boundaryCase.name}`); } } + } - const internalTagId = "authz\tidentifiers-tag"; + const internalTagId = "authz\tidentifiers-tag"; - const internalWhitespace = yield* Effect.exit( - Effect.gen(function* () { - yield* database` + const internalWhitespace = yield* Effect.exit( + Effect.gen(function* () { + yield* database` INSERT INTO public.authz_tags (tag_id, name) VALUES (${internalTagId}, ${"Authz\u00a0Identifiers Tag"}) `; - yield* database` + yield* database` INSERT INTO public.authz_tag_assignments ( assignment_id, tag_id, person_id, start_at ) VALUES ( @@ -994,7 +983,7 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( '2030-01-01T00:00:00.000Z' ) `; - yield* database` + yield* database` INSERT INTO public.authz_rules ( rule_id, capability_id, effect_kind, subject_kind, subject_person_id, subject_tag_id, scope, department_id, @@ -1012,15 +1001,14 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( '2030-01-01T00:00:00.000Z' ) `; - }), - ); + }), + ); - return { - attemptedCases, - internalWhitespaceAccepted: Predicate.isTagged(internalWhitespace, "Success"), - unexpectedlyAccepted, - }; - }); + const evidence = { + attemptedCases, + internalWhitespaceAccepted: Predicate.isTagged(internalWhitespace, "Success"), + unexpectedlyAccepted, + }; expect(evidence).toEqual({ attemptedCases: (1 + ecmaScriptTrimBoundaryCharacters.length * 2) * 8, @@ -1428,20 +1416,19 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( "accepts only exact requirements on allowed rule scopes", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* database` + const database = yield* Database; + yield* database` INSERT INTO public.person_profiles (person_id, first_name, last_name) VALUES ('migration-requirement-person', 'Requirement', 'Rule') `; - const insert = ( - ruleId: string, - scope: string, - domainId: string | null, - params: Schema.Json, - ) => - database` + const insert = ( + ruleId: string, + scope: string, + domainId: string | null, + params: Schema.Json, + ) => + database` INSERT INTO public.authz_rules ( rule_id, capability_id, effect_kind, subject_kind, subject_person_id, subject_tag_id, scope, domain_id, department_id, @@ -1454,49 +1441,49 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( ) `.pipe(Effect.asVoid); - yield* insert("migration-require-pending", "Domain", "receipts", { - requirementId: "receipts.pending", - parameters: {}, - }); - yield* insert("migration-require-approver", "Global", null, { - requirementId: "receipts.approver-relationship", - parameters: {}, - }); + yield* insert("migration-require-pending", "Domain", "receipts", { + requirementId: "receipts.pending", + parameters: {}, + }); + yield* insert("migration-require-approver", "Global", null, { + requirementId: "receipts.approver-relationship", + parameters: {}, + }); - const unsupported = yield* Effect.exit( - insert("migration-require-unsupported", "Domain", "receipts", { - requirementId: "receipts.owner", - parameters: {}, - }), - ); + const unsupported = yield* Effect.exit( + insert("migration-require-unsupported", "Domain", "receipts", { + requirementId: "receipts.owner", + parameters: {}, + }), + ); - const nonempty = yield* Effect.exit( - insert("migration-require-nonempty", "Domain", "receipts", { - requirementId: "receipts.pending", - parameters: { unexpected: true }, - }), - ); + const nonempty = yield* Effect.exit( + insert("migration-require-nonempty", "Domain", "receipts", { + requirementId: "receipts.pending", + parameters: { unexpected: true }, + }), + ); - const excess = yield* Effect.exit( - insert("migration-require-excess", "Domain", "receipts", { - requirementId: "receipts.pending", - parameters: {}, - unexpected: true, - }), - ); + const excess = yield* Effect.exit( + insert("migration-require-excess", "Domain", "receipts", { + requirementId: "receipts.pending", + parameters: {}, + unexpected: true, + }), + ); - const receiptScope = yield* Effect.exit( - insert("migration-require-receipt-scope", "Receipt", null, { - requirementId: "receipts.pending", - parameters: {}, - }), - ); + const receiptScope = yield* Effect.exit( + insert("migration-require-receipt-scope", "Receipt", null, { + requirementId: "receipts.pending", + parameters: {}, + }), + ); - const rows = yield* database<{ - readonly domainId: string | null; - readonly ruleId: string; - readonly scope: string; - }>` + const rows = yield* database<{ + readonly domainId: string | null; + readonly ruleId: string; + readonly scope: string; + }>` SELECT rule_id AS "ruleId", scope, @@ -1506,23 +1493,22 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( ORDER BY rule_id `; - yield* database` + yield* database` DELETE FROM public.authz_rules WHERE subject_person_id = 'migration-requirement-person' `; - yield* database` + yield* database` DELETE FROM public.person_profiles WHERE person_id = 'migration-requirement-person' `; - return { - excess: excess._tag, - nonempty: nonempty._tag, - receiptScope: receiptScope._tag, - rows, - unsupported: unsupported._tag, - }; - }); + const evidence = { + excess: excess._tag, + nonempty: nonempty._tag, + receiptScope: receiptScope._tag, + rows, + unsupported: unsupported._tag, + }; expect(evidence).toEqual({ excess: "Failure", @@ -1550,16 +1536,15 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( describe("instant precision boundary", () => { it.effect("guards every stored instant and its clock default at millisecond precision", () => Effect.gen(function* () { - const columns = yield* Effect.gen(function* () { - const database = yield* Database; - - // The Migrator writes its own bookkeeping column with an untruncated default, and the - // PersistedQueue store writes NOW() into its lease and visibility columns. - return yield* database<{ - readonly column: string; - readonly guarded: boolean; - readonly untruncatedClockDefault: boolean; - }>` + const database = yield* Database; + + // The Migrator writes its own bookkeeping column with an untruncated default, and the + // PersistedQueue store writes NOW() into its lease and visibility columns. + const columns = yield* database<{ + readonly column: string; + readonly guarded: boolean; + readonly untruncatedClockDefault: boolean; + }>` SELECT format('%I.%I.%I', namespace.nspname, relation.relname, attribute.attname) AS "column", EXISTS ( @@ -1597,7 +1582,6 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( ) ORDER BY 1 `; - }); expect(columns.length).toBeGreaterThan(0); expect(columns.flatMap(({ column, guarded }) => (guarded ? [] : [column]))).toEqual([]); diff --git a/packages/database/src/oauth-config.ts b/packages/database/src/oauth-config.ts index c57b32e2f..55b0c7b39 100644 --- a/packages/database/src/oauth-config.ts +++ b/packages/database/src/oauth-config.ts @@ -7,6 +7,7 @@ import { } from "@better-auth/oauth-provider"; import { jwt } from "better-auth/plugins"; import { Schema } from "effect"; +import { dual } from "effect/Function"; export const OAUTH_NATIVE_API_RESOURCE = "urn:vektorprogrammet:native-api" as const; @@ -71,18 +72,26 @@ export interface OAuthProviderRuntimeConfig { export const oauthIssuer = (config: OAuthProviderRuntimeConfig): string => `${config.canonicalOrigin}${OAUTH_ISSUER_PATH}`; -export const sha256Base64Url = (domain: string, value: string): string => +export const sha256Base64Url: { + (value: string): (domain: string) => string; + (domain: string, value: string): string; +} = dual(2, (domain: string, value: string): string => createHash("sha256") .update(domain, "utf8") .update("\0", "utf8") .update(value, "utf8") - .digest("base64url"); + .digest("base64url"), +); export const hashOAuthClientSecret = (secret: string): string => sha256Base64Url("vektor-oauth-client-secret", secret); -export const hashOAuthToken = (token: string, type: StoreTokenType): string => - sha256Base64Url(`vektor-oauth-${type}`, token); +export const hashOAuthToken: { + (type: StoreTokenType): (token: string) => string; + (token: string, type: StoreTokenType): string; +} = dual(2, (token: string, type: StoreTokenType): string => + sha256Base64Url(`vektor-oauth-${type}`, token), +); export const makeOAuthOptions = (config: OAuthProviderRuntimeConfig): OAuthOptions => ({ loginPage: new URL(OAUTH_DASHBOARD_LOGIN_PATH, config.dashboardOrigin).toString(), diff --git a/packages/database/src/oauth-live.ts b/packages/database/src/oauth-live.ts index 9078eec7f..cb2449543 100644 --- a/packages/database/src/oauth-live.ts +++ b/packages/database/src/oauth-live.ts @@ -25,8 +25,9 @@ import { Result, Schema, } from "effect"; +import { dual } from "effect/Function"; import type { SqlError } from "effect/unstable/sql/SqlError"; -import type { Pool, PoolClient, QueryResultRow } from "pg"; +import type { Pool, PoolClient, QueryResult, QueryResultRow } from "pg"; import { CredentialEvidenceRef, ServicePrincipalId, @@ -89,7 +90,7 @@ export interface OAuthCredentialAuthorityService { export class OAuthCredentialAuthority extends Context.Service< OAuthCredentialAuthority, OAuthCredentialAuthorityService ->()("@vektorprogrammet/database/OAuthCredentialAuthority") {} +>()("@vektorprogrammet/database/oauth-live/OAuthCredentialAuthority") {} export interface OAuthOperatorExecution { readonly dryRun: boolean; @@ -164,7 +165,7 @@ export interface OAuthClientOperatorService { export class OAuthClientOperator extends Context.Service< OAuthClientOperator, OAuthClientOperatorService ->()("@vektorprogrammet/database/OAuthClientOperator") {} +>()("@vektorprogrammet/database/oauth-live/OAuthClientOperator") {} type OAuthEngineBoundary = Pick & { readonly api: Pick; @@ -616,25 +617,28 @@ const resolveOAuthCredential = ( }); }); -export const makeOAuthCredentialAuthorityService = ( - pool: Pool, - config: OAuthProviderRuntimeConfig, -): OAuthCredentialAuthorityService => ({ - resolve: (request, expected, now) => - Effect.flatMap(now === undefined ? DateTime.now : Effect.succeed(now), (instant) => - resolveOAuthCredential( - request, - expected, - instant, - (claims, kid) => selectTokenState(pool, claims, kid), - config, +export const makeOAuthCredentialAuthorityService: { + (config: OAuthProviderRuntimeConfig): (pool: Pool) => OAuthCredentialAuthorityService; + (pool: Pool, config: OAuthProviderRuntimeConfig): OAuthCredentialAuthorityService; +} = dual( + 2, + (pool: Pool, config: OAuthProviderRuntimeConfig): OAuthCredentialAuthorityService => ({ + resolve: (request, expected, now) => + Effect.flatMap(now === undefined ? DateTime.now : Effect.succeed(now), (instant) => + resolveOAuthCredential( + request, + expected, + instant, + (claims, kid) => selectTokenState(pool, claims, kid), + config, + ), ), - ), - resolveInTransaction: (request, expected, now) => - Effect.flatMap(now === undefined ? DateTime.now : Effect.succeed(now), (instant) => - resolveOAuthCredential(request, expected, instant, selectTokenStateInTransaction, config), - ), -}); + resolveInTransaction: (request, expected, now) => + Effect.flatMap(now === undefined ? DateTime.now : Effect.succeed(now), (instant) => + resolveOAuthCredential(request, expected, instant, selectTokenStateInTransaction, config), + ), + }), +); const sanitizedRequestContext = (context: IdentityRequestContext) => ({ correlation: context.requestCorrelation.slice(0, 160), @@ -873,358 +877,361 @@ const selectActiveSigningKeys = (pool: Pool) => ORDER BY "createdAt" DESC`, ); -export const makeOAuthClientOperatorService = ( - pool: Pool, - engine: OAuthEngineBoundary, -): OAuthClientOperatorService => ({ - provision: (unsafeManifest, execution) => - Effect.gen(function* () { - const manifest = yield* Effect.try({ - try: () => { - requireOperatorExecution(execution); +export const makeOAuthClientOperatorService: { + (engine: OAuthEngineBoundary): (pool: Pool) => OAuthClientOperatorService; + (pool: Pool, engine: OAuthEngineBoundary): OAuthClientOperatorService; +} = dual( + 2, + (pool: Pool, engine: OAuthEngineBoundary): OAuthClientOperatorService => ({ + provision: (unsafeManifest, execution) => + Effect.gen(function* () { + const manifest = yield* Effect.try({ + try: () => { + requireOperatorExecution(execution); - return validateManifest(unsafeManifest); - }, - catch: operatorFailure("provision"), - }); + return validateManifest(unsafeManifest); + }, + catch: operatorFailure("provision"), + }); - if (execution.dryRun) { - const result: OAuthProvisionResult = { clientId: manifest.clientId }; + if (execution.dryRun) { + const result: OAuthProvisionResult = { clientId: manifest.clientId }; - if (manifest.servicePrincipalId !== undefined) - return { ...result, servicePrincipalId: manifest.servicePrincipalId }; + if (manifest.servicePrincipalId !== undefined) + return { ...result, servicePrincipalId: manifest.servicePrincipalId }; - return result; - } + return result; + } - const confidential = manifest.clientKind !== "DelegatedPublic"; - const rawSecret = confidential ? randomBytes(32).toString("base64url") : undefined; - const clientSecret = rawSecret === undefined ? undefined : `vkr_cs_${rawSecret}`; + const confidential = manifest.clientKind !== "DelegatedPublic"; + const rawSecret = confidential ? randomBytes(32).toString("base64url") : undefined; + const clientSecret = rawSecret === undefined ? undefined : `vkr_cs_${rawSecret}`; - const storedSecret = rawSecret === undefined ? null : hashOAuthClientSecret(rawSecret); + const storedSecret = rawSecret === undefined ? null : hashOAuthClientSecret(rawSecret); - const instant = yield* DateTime.now; - const now = DateTime.toDateUtc(instant); + const instant = yield* DateTime.now; + const now = DateTime.toDateUtc(instant); - const context = yield* Effect.tryPromise({ - try: () => engine.$context, - catch: operatorFailure("provision"), - }); + const context = yield* Effect.tryPromise({ + try: () => engine.$context, + catch: operatorFailure("provision"), + }); - yield* Effect.tryPromise({ - try: () => - context.adapter.create({ - model: "oauthClient", - data: clientProviderRecord(manifest, storedSecret, now), - }), - catch: operatorFailure("provision"), - }); - yield* Effect.tryPromise({ - try: () => - context.adapter.create({ - model: "oauthClientResource", - data: { - clientId: manifest.clientId, - resourceId: OAUTH_NATIVE_API_RESOURCE, - createdAt: now, - }, - }), - catch: operatorFailure("provision"), - }); - yield* pgTransaction(pool, (client) => - Effect.gen(function* () { - if (manifest.clientKind === "Service") { - yield* pgQuery( - client, - `INSERT INTO public.service_principals ( + yield* Effect.tryPromise({ + try: () => + context.adapter.create({ + model: "oauthClient", + data: clientProviderRecord(manifest, storedSecret, now), + }), + catch: operatorFailure("provision"), + }); + yield* Effect.tryPromise({ + try: () => + context.adapter.create({ + model: "oauthClientResource", + data: { + clientId: manifest.clientId, + resourceId: OAUTH_NATIVE_API_RESOURCE, + createdAt: now, + }, + }), + catch: operatorFailure("provision"), + }); + yield* pgTransaction(pool, (client) => + Effect.gen(function* () { + if (manifest.clientKind === "Service") { + yield* pgQuery( + client, + `INSERT INTO public.service_principals ( service_principal_id, name, state, revision, created_at, updated_at ) VALUES ($1, $2, 'Active', 0, $3, $3)`, - [manifest.servicePrincipalId, manifest.servicePrincipalName, now], - ); - } + [manifest.servicePrincipalId, manifest.servicePrincipalName, now], + ); + } - yield* pgQuery( - client, - `INSERT INTO auth.oauth_client_bindings ( + yield* pgQuery( + client, + `INSERT INTO auth.oauth_client_bindings ( client_id, client_kind, service_principal_id, secret_expires_at, revision, created_at, updated_at ) VALUES ($1, $2, $3, $4, 0, $5, $5)`, - [ - manifest.clientId, - manifest.clientKind, - manifest.servicePrincipalId ?? null, - confidential - ? DateTime.toDateUtc( - DateTime.add(instant, { milliseconds: CLIENT_SECRET_LIFETIME_MS }), - ) - : null, - now, - ], - ); - yield* appendAudit(client, { - eventKind: "oauth-client-provisioned", - clientId: manifest.clientId, - servicePrincipalId: manifest.servicePrincipalId, - actorPrincipal: "operator", - requestCorrelation: execution.requestCorrelation, - details: { client_kind: manifest.clientKind, resource: OAUTH_NATIVE_API_RESOURCE }, - }); - }), - ); + [ + manifest.clientId, + manifest.clientKind, + manifest.servicePrincipalId ?? null, + confidential + ? DateTime.toDateUtc( + DateTime.add(instant, { milliseconds: CLIENT_SECRET_LIFETIME_MS }), + ) + : null, + now, + ], + ); + yield* appendAudit(client, { + eventKind: "oauth-client-provisioned", + clientId: manifest.clientId, + servicePrincipalId: manifest.servicePrincipalId, + actorPrincipal: "operator", + requestCorrelation: execution.requestCorrelation, + details: { client_kind: manifest.clientKind, resource: OAUTH_NATIVE_API_RESOURCE }, + }); + }), + ); - const result: Types.Mutable = { clientId: manifest.clientId }; + const result: Types.Mutable = { clientId: manifest.clientId }; - if (manifest.servicePrincipalId !== undefined) - result.servicePrincipalId = manifest.servicePrincipalId; + if (manifest.servicePrincipalId !== undefined) + result.servicePrincipalId = manifest.servicePrincipalId; - if (clientSecret !== undefined) result.clientSecret = clientSecret; + if (clientSecret !== undefined) result.clientSecret = clientSecret; - return result; - }).pipe(Effect.mapError(operatorFailure("provision"))), - rotateSecret: (clientId, execution) => - Effect.gen(function* () { - yield* Effect.try({ - try: () => requireOperatorExecution(execution), - catch: operatorFailure("rotateSecret"), - }); + return result; + }).pipe(Effect.mapError(operatorFailure("provision"))), + rotateSecret: (clientId, execution) => + Effect.gen(function* () { + yield* Effect.try({ + try: () => requireOperatorExecution(execution), + catch: operatorFailure("rotateSecret"), + }); - if (execution.dryRun) return { clientId }; - const rawSecret = randomBytes(32).toString("base64url"); - const secret = `vkr_cs_${rawSecret}`; + if (execution.dryRun) return { clientId }; + const rawSecret = randomBytes(32).toString("base64url"); + const secret = `vkr_cs_${rawSecret}`; - const digest = hashOAuthClientSecret(rawSecret); + const digest = hashOAuthClientSecret(rawSecret); - yield* pgTransaction(pool, (client) => - Effect.gen(function* () { - yield* pgQuery(client, "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [ - clientId, - ]); + yield* pgTransaction(pool, (client) => + Effect.gen(function* () { + yield* pgQuery(client, "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [ + clientId, + ]); - const updated = yield* pgQuery( - client, - `UPDATE auth."oauthClient" provider + const updated = yield* pgQuery( + client, + `UPDATE auth."oauthClient" provider SET "clientSecret" = $2, "updatedAt" = date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC') FROM auth.oauth_client_bindings binding WHERE provider."clientId" = $1 AND binding.client_id = provider."clientId" AND binding.client_kind <> 'DelegatedPublic' AND COALESCE(provider.disabled, false) = false`, - [clientId, digest], - ); + [clientId, digest], + ); - if (updated.rowCount !== 1) { - return yield* new OAuthClientOperatorError({ - operation: "rotateSecret", - message: "live confidential client not found", - cause: undefined, - }); - } + if (updated.rowCount !== 1) { + return yield* new OAuthClientOperatorError({ + operation: "rotateSecret", + message: "live confidential client not found", + cause: undefined, + }); + } - yield* pgQuery( - client, - `UPDATE auth.oauth_client_bindings + yield* pgQuery( + client, + `UPDATE auth.oauth_client_bindings SET secret_expires_at = date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC') + make_interval(secs => $2), revision = revision + 1, updated_at = date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC') WHERE client_id = $1`, - [clientId, CLIENT_SECRET_LIFETIME_MS / 1_000], - ); - yield* appendAudit(client, { - eventKind: "oauth-client-secret-rotated", - clientId, - actorPrincipal: "operator", - requestCorrelation: execution.requestCorrelation, - }); - }), - ); + [clientId, CLIENT_SECRET_LIFETIME_MS / 1_000], + ); + yield* appendAudit(client, { + eventKind: "oauth-client-secret-rotated", + clientId, + actorPrincipal: "operator", + requestCorrelation: execution.requestCorrelation, + }); + }), + ); - return { clientId, clientSecret: secret }; - }).pipe(Effect.mapError(operatorFailure("rotateSecret"))), - disableClient: (clientId, execution) => - Effect.gen(function* () { - yield* Effect.try({ - try: () => requireOperatorExecution(execution), - catch: operatorFailure("disableClient"), - }); + return { clientId, clientSecret: secret }; + }).pipe(Effect.mapError(operatorFailure("rotateSecret"))), + disableClient: (clientId, execution) => + Effect.gen(function* () { + yield* Effect.try({ + try: () => requireOperatorExecution(execution), + catch: operatorFailure("disableClient"), + }); - if (execution.dryRun) return; - yield* pgTransaction(pool, (client) => - Effect.gen(function* () { - yield* pgQuery(client, "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [ - clientId, - ]); + if (execution.dryRun) return; + yield* pgTransaction(pool, (client) => + Effect.gen(function* () { + yield* pgQuery(client, "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [ + clientId, + ]); - const updated = yield* pgQuery( - client, - `UPDATE auth."oauthClient" SET disabled = true, "updatedAt" = date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC') + const updated = yield* pgQuery( + client, + `UPDATE auth."oauthClient" SET disabled = true, "updatedAt" = date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC') WHERE "clientId" = $1 AND COALESCE(disabled, false) = false`, - [clientId], - ); + [clientId], + ); - if (updated.rowCount !== 1) { - return yield* new OAuthClientOperatorError({ - operation: "disableClient", - message: "live OAuth client not found", - cause: undefined, - }); - } + if (updated.rowCount !== 1) { + return yield* new OAuthClientOperatorError({ + operation: "disableClient", + message: "live OAuth client not found", + cause: undefined, + }); + } - yield* pgQuery( - client, - `UPDATE auth.oauth_access_token_state SET revoked_at = date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC'), + yield* pgQuery( + client, + `UPDATE auth.oauth_access_token_state SET revoked_at = date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC'), revocation_reason = 'client-disabled' WHERE client_id = $1 AND revoked_at IS NULL`, - [clientId], - ); - yield* pgQuery( - client, - `UPDATE auth.oauth_refresh_families SET revoked_at = date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC'), + [clientId], + ); + yield* pgQuery( + client, + `UPDATE auth.oauth_refresh_families SET revoked_at = date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC'), revocation_reason = 'client-disabled' WHERE client_id = $1 AND revoked_at IS NULL`, - [clientId], - ); - yield* appendAudit(client, { - eventKind: "oauth-client-disabled", - clientId, - actorPrincipal: "operator", - requestCorrelation: execution.requestCorrelation, - }); - }), - ); - }).pipe(Effect.mapError(operatorFailure("disableClient"))), - disableServicePrincipal: (servicePrincipalId, execution) => - Effect.gen(function* () { - yield* Effect.try({ - try: () => requireOperatorExecution(execution), - catch: operatorFailure("disableServicePrincipal"), - }); + [clientId], + ); + yield* appendAudit(client, { + eventKind: "oauth-client-disabled", + clientId, + actorPrincipal: "operator", + requestCorrelation: execution.requestCorrelation, + }); + }), + ); + }).pipe(Effect.mapError(operatorFailure("disableClient"))), + disableServicePrincipal: (servicePrincipalId, execution) => + Effect.gen(function* () { + yield* Effect.try({ + try: () => requireOperatorExecution(execution), + catch: operatorFailure("disableServicePrincipal"), + }); - if (execution.dryRun) return; - yield* pgTransaction(pool, (client) => - Effect.gen(function* () { - const selected = yield* pgQuery<{ readonly client_id: string }>( - client, - `SELECT client_id FROM auth.oauth_client_bindings + if (execution.dryRun) return; + yield* pgTransaction(pool, (client) => + Effect.gen(function* () { + const selected = yield* pgQuery<{ readonly client_id: string }>( + client, + `SELECT client_id FROM auth.oauth_client_bindings WHERE service_principal_id = $1 FOR UPDATE`, - [servicePrincipalId], - ); + [servicePrincipalId], + ); - const binding = selected.rows[0]; + const binding = selected.rows[0]; - if (binding === undefined) { - return yield* new OAuthClientOperatorError({ - operation: "disableServicePrincipal", - message: "service-principal binding not found", - cause: undefined, - }); - } + if (binding === undefined) { + return yield* new OAuthClientOperatorError({ + operation: "disableServicePrincipal", + message: "service-principal binding not found", + cause: undefined, + }); + } - yield* pgQuery( - client, - `UPDATE public.service_principals + yield* pgQuery( + client, + `UPDATE public.service_principals SET state = 'Disabled', revision = revision + 1, updated_at = date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC') WHERE service_principal_id = $1 AND state = 'Active'`, - [servicePrincipalId], - ); - yield* pgQuery( - client, - `UPDATE auth."oauthClient" SET disabled = true, "updatedAt" = date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC') + [servicePrincipalId], + ); + yield* pgQuery( + client, + `UPDATE auth."oauthClient" SET disabled = true, "updatedAt" = date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC') WHERE "clientId" = $1`, - [binding.client_id], - ); - yield* pgQuery( - client, - `UPDATE auth.oauth_access_token_state + [binding.client_id], + ); + yield* pgQuery( + client, + `UPDATE auth.oauth_access_token_state SET revoked_at = date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC'), revocation_reason = 'service-principal-disabled' WHERE service_principal_id = $1 AND revoked_at IS NULL`, - [servicePrincipalId], - ); - yield* appendAudit(client, { - eventKind: "oauth-service-principal-disabled", - clientId: binding.client_id, - servicePrincipalId, - actorPrincipal: "operator", - requestCorrelation: execution.requestCorrelation, + [servicePrincipalId], + ); + yield* appendAudit(client, { + eventKind: "oauth-service-principal-disabled", + clientId: binding.client_id, + servicePrincipalId, + actorPrincipal: "operator", + requestCorrelation: execution.requestCorrelation, + }); + }), + ); + }).pipe(Effect.mapError(operatorFailure("disableServicePrincipal"))), + bootstrapSigningKey: (execution) => + Effect.gen(function* () { + yield* Effect.try({ + try: () => requireOperatorExecution(execution), + catch: operatorFailure("bootstrapSigningKey"), + }); + + const selected = yield* selectActiveSigningKeys(pool); + + if (selected.rows.length > 1) { + return yield* new OAuthClientOperatorError({ + operation: "bootstrapSigningKey", + message: "multiple active ES256 signing keys", + cause: undefined, }); - }), - ); - }).pipe(Effect.mapError(operatorFailure("disableServicePrincipal"))), - bootstrapSigningKey: (execution) => - Effect.gen(function* () { - yield* Effect.try({ - try: () => requireOperatorExecution(execution), - catch: operatorFailure("bootstrapSigningKey"), - }); + } - const selected = yield* selectActiveSigningKeys(pool); + if (selected.rows[0] !== undefined) return { keyId: selected.rows[0].id }; - if (selected.rows.length > 1) { - return yield* new OAuthClientOperatorError({ - operation: "bootstrapSigningKey", - message: "multiple active ES256 signing keys", - cause: undefined, + if (execution.dryRun) return {}; + yield* Effect.tryPromise({ + try: () => + engine.api.signJWT({ + body: { + payload: { sub: "oauth-signing-key-bootstrap", aud: OAUTH_NATIVE_API_RESOURCE }, + }, + }), + catch: operatorFailure("bootstrapSigningKey"), }); - } - if (selected.rows[0] !== undefined) return { keyId: selected.rows[0].id }; + const created = yield* selectActiveSigningKeys(pool); - if (execution.dryRun) return {}; - yield* Effect.tryPromise({ - try: () => - engine.api.signJWT({ - body: { - payload: { sub: "oauth-signing-key-bootstrap", aud: OAUTH_NATIVE_API_RESOURCE }, - }, - }), - catch: operatorFailure("bootstrapSigningKey"), - }); + const [key] = created.rows; - const created = yield* selectActiveSigningKeys(pool); + if (created.rows.length !== 1 || key === undefined) { + return yield* new OAuthClientOperatorError({ + operation: "bootstrapSigningKey", + message: "signing key bootstrap did not create exactly one key", + cause: undefined, + }); + } - const [key] = created.rows; + yield* pgTransaction(pool, (client) => + appendAudit(client, { + eventKind: "oauth-signing-key-rotated", + actorPrincipal: "operator", + requestCorrelation: execution.requestCorrelation, + details: { key_id: key.id }, + }), + ); - if (created.rows.length !== 1 || key === undefined) { - return yield* new OAuthClientOperatorError({ - operation: "bootstrapSigningKey", - message: "signing key bootstrap did not create exactly one key", - cause: undefined, + return { keyId: key.id }; + }).pipe(Effect.mapError(operatorFailure("bootstrapSigningKey"))), + retireSigningKeys: (execution, now) => + Effect.gen(function* () { + yield* Effect.try({ + try: () => requireOperatorExecution(execution), + catch: operatorFailure("retireSigningKeys"), }); - } - yield* pgTransaction(pool, (client) => - appendAudit(client, { - eventKind: "oauth-signing-key-rotated", - actorPrincipal: "operator", - requestCorrelation: execution.requestCorrelation, - details: { key_id: key.id }, - }), - ); - - return { keyId: key.id }; - }).pipe(Effect.mapError(operatorFailure("bootstrapSigningKey"))), - retireSigningKeys: (execution, now) => - Effect.gen(function* () { - yield* Effect.try({ - try: () => requireOperatorExecution(execution), - catch: operatorFailure("retireSigningKeys"), - }); - - if (execution.dryRun) return 0; + if (execution.dryRun) return 0; - const cutoff = now ?? (yield* DateTime.now); + const cutoff = now ?? (yield* DateTime.now); - const result = yield* pgQuery( - pool, - `DELETE FROM auth.jwks + const result = yield* pgQuery( + pool, + `DELETE FROM auth.jwks WHERE "expiresAt" IS NOT NULL AND "expiresAt" + interval '15 minutes' <= $1`, - [DateTime.toDateUtc(cutoff)], - ); + [DateTime.toDateUtc(cutoff)], + ); - return result.rowCount ?? 0; - }).pipe(Effect.mapError(operatorFailure("retireSigningKeys"))), -}); + return result.rowCount ?? 0; + }).pipe(Effect.mapError(operatorFailure("retireSigningKeys"))), + }), +); const clientAuthoritySql = `SELECT binding.client_id, binding.client_kind, binding.service_principal_id, binding.secret_expires_at, client.disabled, client."clientSecret" AS client_secret, @@ -1385,65 +1392,69 @@ const inactiveIntrospectionResponse = (): Response => { status: 200, headers: { "cache-control": "no-store", pragma: "no-cache" } }, ); -export const makeOAuthInternalIntrospectionHandler = +export const makeOAuthInternalIntrospectionHandler: { + (pool: Pool): (engine: OAuthEngineBoundary) => OAuthIntrospectionHandler; + (engine: OAuthEngineBoundary, pool: Pool): OAuthIntrospectionHandler; +} = dual( + 2, (engine: OAuthEngineBoundary, pool: Pool): OAuthIntrospectionHandler => - (request, context) => - Effect.gen(function* () { - const pathname = new URL(request.url).pathname; + (request, context) => + Effect.gen(function* () { + const pathname = new URL(request.url).pathname; - if (request.method !== "POST" || pathname !== "/api/auth/oauth2/introspect") { - return new Response("Not Found", { status: 404 }); - } + if (request.method !== "POST" || pathname !== "/api/auth/oauth2/introspect") { + return new Response("Not Found", { status: 404 }); + } - // The rejection answers inactive even when its audit row cannot be written. - const reject = (reason: string) => - pgTransaction(pool, (transaction) => - appendAudit(transaction, { - eventKind: "oauth-introspection-rejected", - actorPrincipal: "resource-server", - requestCorrelation: context.requestCorrelation, - sourceIp: sanitizedRequestContext(context).sourceIp, - userAgent: sanitizedRequestContext(context).userAgent, - details: { denial_category: reason }, - }), - ).pipe(Effect.ignore, Effect.map(inactiveIntrospectionResponse)); + // The rejection answers inactive even when its audit row cannot be written. + const reject = (reason: string) => + pgTransaction(pool, (transaction) => + appendAudit(transaction, { + eventKind: "oauth-introspection-rejected", + actorPrincipal: "resource-server", + requestCorrelation: context.requestCorrelation, + sourceIp: sanitizedRequestContext(context).sourceIp, + userAgent: sanitizedRequestContext(context).userAgent, + details: { denial_category: reason }, + }), + ).pipe(Effect.ignore, Effect.map(inactiveIntrospectionResponse)); - if ( - request.headers.get("cookie") !== null || - request.headers.get("content-type")?.split(";", 1)[0] !== - "application/x-www-form-urlencoded" - ) { - return yield* reject("invalid-request"); - } + if ( + request.headers.get("cookie") !== null || + request.headers.get("content-type")?.split(";", 1)[0] !== + "application/x-www-form-urlencoded" + ) { + return yield* reject("invalid-request"); + } - const body = yield* requestText(request); + const body = yield* requestText(request); - if (new TextEncoder().encode(body).byteLength > FORM_INPUT_LIMIT) { - return yield* reject("input-limit"); - } + if (new TextEncoder().encode(body).byteLength > FORM_INPUT_LIMIT) { + return yield* reject("input-limit"); + } - const now = yield* DateTime.now; + const now = yield* DateTime.now; - if (!(yield* authorizeOAuthIntrospectionClient(pool, request, now))) { - return yield* reject("invalid-client"); - } + if (!(yield* authorizeOAuthIntrospectionClient(pool, request, now))) { + return yield* reject("invalid-client"); + } - const provider = yield* boundedProviderResponse(engine, request); - const parsed = yield* decodeUnknownRecordJson(provider.body); + const provider = yield* boundedProviderResponse(engine, request); + const parsed = yield* decodeUnknownRecordJson(provider.body); - if ( - parsed.active !== true || - !Predicate.isString(parsed.jti) || - !Predicate.isString(parsed.client_id) || - !Predicate.isString(parsed.sub) || - !Predicate.isString(parsed.scope) - ) { - return inactiveIntrospectionResponse(); - } + if ( + parsed.active !== true || + !Predicate.isString(parsed.jti) || + !Predicate.isString(parsed.client_id) || + !Predicate.isString(parsed.sub) || + !Predicate.isString(parsed.scope) + ) { + return inactiveIntrospectionResponse(); + } - const tracked = yield* pgQuery( - pool, - `SELECT 1 + const tracked = yield* pgQuery( + pool, + `SELECT 1 FROM auth.oauth_access_token_state state JOIN auth.oauth_client_bindings binding ON binding.client_id = state.client_id JOIN auth."oauthClient" client ON client."clientId" = state.client_id @@ -1492,43 +1503,44 @@ export const makeOAuthInternalIntrospectionHandler = AND state.client_id = $3 AND $4::text IS NULL) )`, - [ - parsed.jti, - parsed.client_id, - parsed.sub, - Predicate.isString(parsed.sid) ? parsed.sid : null, - OAUTH_NATIVE_API_RESOURCE, - parsed.scope, - ], - ); + [ + parsed.jti, + parsed.client_id, + parsed.sub, + Predicate.isString(parsed.sid) ? parsed.sid : null, + OAUTH_NATIVE_API_RESOURCE, + parsed.scope, + ], + ); - if (tracked.rowCount !== 1) return inactiveIntrospectionResponse(); - - const allowed = [ - "active", - "client_id", - "token_type", - "scope", - "sub", - "aud", - "iss", - "exp", - "iat", - "jti", - "sid", - ] as const; - - const bounded: Partial> = {}; - - for (const name of allowed) { - if (parsed[name] !== undefined) bounded[name] = parsed[name]; - } + if (tracked.rowCount !== 1) return inactiveIntrospectionResponse(); + + const allowed = [ + "active", + "client_id", + "token_type", + "scope", + "sub", + "aud", + "iss", + "exp", + "iat", + "jti", + "sid", + ] as const; + + const bounded: Partial> = {}; + + for (const name of allowed) { + if (parsed[name] !== undefined) bounded[name] = parsed[name]; + } - return Response.json(bounded, { - status: 200, - headers: { "cache-control": "no-store", pragma: "no-cache" }, - }); - }); + return Response.json(bounded, { + status: 200, + headers: { "cache-control": "no-store", pragma: "no-cache" }, + }); + }), +); const invalidOAuthResponse = (status: number, error: string): Response => Response.json( @@ -1624,7 +1636,15 @@ interface OpenedRefreshFamily { * Opens a refresh family at the issue instant of its first token. Migration 0077 defines its * windows, which its checks enforce. */ -export const openRefreshFamily = (transaction: PoolClient, family: OpenedRefreshFamily) => +export const openRefreshFamily: { + ( + family: OpenedRefreshFamily, + ): (transaction: PoolClient) => Effect.Effect, PgQueryError>; + ( + transaction: PoolClient, + family: OpenedRefreshFamily, + ): Effect.Effect, PgQueryError>; +} = dual(2, (transaction: PoolClient, family: OpenedRefreshFamily) => pgQuery( transaction, `INSERT INTO auth.oauth_refresh_families ( @@ -1643,10 +1663,21 @@ export const openRefreshFamily = (transaction: PoolClient, family: OpenedRefresh family.sessionId, DateTime.toDateUtc(family.issuedAt), ], - ); + ), +); /** Records a refresh at `usedAt`, the issue instant of the new token in epoch seconds. */ -export const recordRefreshFamilyUse = (transaction: PoolClient, familyId: string, usedAt: number) => +export const recordRefreshFamilyUse: { + ( + familyId: string, + usedAt: number, + ): (transaction: PoolClient) => Effect.Effect, PgQueryError>; + ( + transaction: PoolClient, + familyId: string, + usedAt: number, + ): Effect.Effect, PgQueryError>; +} = dual(3, (transaction: PoolClient, familyId: string, usedAt: number) => pgQuery( transaction, `UPDATE auth.oauth_refresh_families @@ -1655,7 +1686,8 @@ export const recordRefreshFamilyUse = (transaction: PoolClient, familyId: string auth.oauth_refresh_inactivity_expires_at(to_timestamp($2), absolute_expires_at) WHERE family_id = $1 AND revoked_at IS NULL`, [familyId, usedAt], - ); + ), +); const initialCodeExchange = ( engine: OAuthEngineBoundary, @@ -2309,101 +2341,115 @@ const handlePublicClient = (engine: OAuthEngineBoundary, pool: Pool, request: Re ); }); -export const makeOAuthReleaseBarrier = +export const makeOAuthReleaseBarrier: { + ( + pool: Pool, + config: OAuthProviderRuntimeConfig, + ): (engine: OAuthEngineBoundary) => OAuthReleaseHandler; + ( + engine: OAuthEngineBoundary, + pool: Pool, + config: OAuthProviderRuntimeConfig, + ): OAuthReleaseHandler; +} = dual( + 3, ( engine: OAuthEngineBoundary, pool: Pool, config: OAuthProviderRuntimeConfig, ): OAuthReleaseHandler => - (request, context) => - Effect.gen(function* () { - const pathname = new URL(request.url).pathname; + (request, context) => + Effect.gen(function* () { + const pathname = new URL(request.url).pathname; - if (request.method === "GET" && pathname === "/api/auth/oauth2/public-client") { - return yield* handlePublicClient(engine, pool, request); - } + if (request.method === "GET" && pathname === "/api/auth/oauth2/public-client") { + return yield* handlePublicClient(engine, pool, request); + } - if (request.method === "POST" && pathname === "/api/auth/oauth2/token") { - return yield* handleToken(engine, pool, request, context, config); - } + if (request.method === "POST" && pathname === "/api/auth/oauth2/token") { + return yield* handleToken(engine, pool, request, context, config); + } - if (request.method === "POST" && pathname === "/api/auth/oauth2/revoke") { - return yield* handleRevocation(engine, pool, request, context); - } + if (request.method === "POST" && pathname === "/api/auth/oauth2/revoke") { + return yield* handleRevocation(engine, pool, request, context); + } - if (request.method === "POST" && pathname === "/api/auth/oauth2/delete-consent") { - return yield* handleConsentWithdrawal(engine, pool, request, context); - } + if (request.method === "POST" && pathname === "/api/auth/oauth2/delete-consent") { + return yield* handleConsentWithdrawal(engine, pool, request, context); + } - const response = yield* providerResponse(engine, request); + const response = yield* providerResponse(engine, request); - if (pathname === "/api/auth/jwks" && response.ok) { - const body = yield* responseText(response); - const etag = `"${createHash("sha256").update(body, "utf8").digest("base64url")}"`; + if (pathname === "/api/auth/jwks" && response.ok) { + const body = yield* responseText(response); + const etag = `"${createHash("sha256").update(body, "utf8").digest("base64url")}"`; - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers: { - ...Object.fromEntries(response.headers), - "cache-control": "public, max-age=300, must-revalidate", - etag, - }, - }); - } + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: { + ...Object.fromEntries(response.headers), + "cache-control": "public, max-age=300, must-revalidate", + etag, + }, + }); + } - if (pathname.startsWith("/api/auth/oauth2/")) { - response.headers.set("cache-control", "no-store"); - response.headers.set("pragma", "no-cache"); - } + if (pathname.startsWith("/api/auth/oauth2/")) { + response.headers.set("cache-control", "no-store"); + response.headers.set("pragma", "no-cache"); + } - return response; - }).pipe( - // The barrier answers every failure and defect of the exchange as temporarily unavailable. - Effect.catchCause(() => - Effect.succeed( - Response.json( - { error: "temporarily_unavailable" }, - { - status: 503, - headers: { "cache-control": "no-store", pragma: "no-cache" }, - }, + return response; + }).pipe( + // The barrier answers every failure and defect of the exchange as temporarily unavailable. + Effect.catchCause(() => + Effect.succeed( + Response.json( + { error: "temporarily_unavailable" }, + { + status: 503, + headers: { "cache-control": "no-store", pragma: "no-cache" }, + }, + ), ), ), ), - ); +); -export const exactRedirectAccepted = ( - pool: Pool, - clientId: string, - redirectUri: string, -): Effect.Effect => - Effect.gen(function* () { - const now = DateTime.toEpochMillis(yield* DateTime.now); - const client = yield* readClientAuthority(pool, clientId); +export const exactRedirectAccepted: { + (clientId: string, redirectUri: string): (pool: Pool) => Effect.Effect; + (pool: Pool, clientId: string, redirectUri: string): Effect.Effect; +} = dual( + 3, + (pool: Pool, clientId: string, redirectUri: string): Effect.Effect => + Effect.gen(function* () { + const now = DateTime.toEpochMillis(yield* DateTime.now); + const client = yield* readClientAuthority(pool, clientId); - if ( - client === undefined || - client.disabled === true || - (client.client_kind !== "DelegatedPublic" && - client.client_kind !== "DelegatedConfidential") || - client.require_pkce !== true || - client.grant_types?.join(" ") !== "authorization_code refresh_token" || - (client.secret_expires_at !== null && client.secret_expires_at.getTime() <= now) || - !client.redirect_uris.some((registered) => registered === redirectUri) - ) { - return false; - } + if ( + client === undefined || + client.disabled === true || + (client.client_kind !== "DelegatedPublic" && + client.client_kind !== "DelegatedConfidential") || + client.require_pkce !== true || + client.grant_types?.join(" ") !== "authorization_code refresh_token" || + (client.secret_expires_at !== null && client.secret_expires_at.getTime() <= now) || + !client.redirect_uris.some((registered) => registered === redirectUri) + ) { + return false; + } - const linked = yield* pgQuery( - pool, - `SELECT 1 FROM auth."oauthClientResource" + const linked = yield* pgQuery( + pool, + `SELECT 1 FROM auth."oauthClientResource" WHERE "clientId" = $1 AND "resourceId" = $2`, - [clientId, OAUTH_NATIVE_API_RESOURCE], - ); + [clientId, OAUTH_NATIVE_API_RESOURCE], + ); - return linked.rowCount === 1; - }); + return linked.rowCount === 1; + }), +); export class OAuthHandlers extends Context.Service< OAuthHandlers, @@ -2411,7 +2457,7 @@ export class OAuthHandlers extends Context.Service< readonly release: OAuthReleaseHandler; readonly introspection: OAuthIntrospectionHandler; } ->()("@vektorprogrammet/database/OAuthHandlers") {} +>()("@vektorprogrammet/database/oauth-live/OAuthHandlers") {} export const OAuthLive = (config: OAuthProviderRuntimeConfig) => Layer.effectContext( diff --git a/packages/database/src/oauth-refresh-window.test.ts b/packages/database/src/oauth-refresh-window.test.ts index 860f14fba..1c02437db 100644 --- a/packages/database/src/oauth-refresh-window.test.ts +++ b/packages/database/src/oauth-refresh-window.test.ts @@ -18,7 +18,7 @@ const windows = selectDatabaseMigration("77_oauth-refresh-elapsed-windows"); /** A disposable cluster. Its databases run their sessions in Europe/Oslo. */ class OsloCluster extends Context.Service()( - "oauth-refresh-window.test/OsloCluster", + "@vektorprogrammet/database/oauth-refresh-window.test/OsloCluster", ) {} const osloCluster = Layer.effect( diff --git a/packages/database/src/onboarding-account.ts b/packages/database/src/onboarding-account.ts index b6691e190..5723301d2 100644 --- a/packages/database/src/onboarding-account.ts +++ b/packages/database/src/onboarding-account.ts @@ -13,7 +13,7 @@ export const provisionOnboardingAccount = (input: AccountProvisionInput) => const collision = yield* sql`SELECT id FROM auth."user" WHERE lower(email)=lower(${input.email})`; - if (collision.length) + if (collision.length > 0) return yield* new OnboardingFailure({ code: "onboarding.sign-in-required", status: 409 }); yield* sql`INSERT INTO public.person_profiles(person_id,first_name,last_name) VALUES(${input.personId},${input.firstName},${input.lastName})`; yield* sql`INSERT INTO public.person_contact_profiles(person_id,email,phone) VALUES(${input.personId},${input.email},${input.phone})`; diff --git a/packages/database/src/onboarding.test.ts b/packages/database/src/onboarding.test.ts index 01ce37421..bfee3ffcc 100644 --- a/packages/database/src/onboarding.test.ts +++ b/packages/database/src/onboarding.test.ts @@ -1,3 +1,4 @@ +import { administratorDepartmentReach } from "@vektorprogrammet/domain/organization/authority-fixtures"; import { expect, layer } from "@effect/vitest"; import { Database } from "./service.js"; import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; @@ -16,6 +17,8 @@ const dept = DepartmentId.make("onboarding-dept"); const actor = PersonId.make("onboarding-leader"); +const coordinator = administratorDepartmentReach("onboarding-leader", "admissions.outcomes", dept); + const currentInstant = Effect.map(DateTime.now, DateTime.formatIso); const setup = (now: string) => @@ -38,12 +41,11 @@ const issue = (i: number, now: string) => Database.use((sql) => sql.withTransaction( commandOnboarding({ - departmentId: dept, + coordinator, command: { applicationId: PublicApplicationIdSchema.make("onboard-app-" + i), action: "Issue", }, - actor, now, invitationId: "onboard-invite-" + i, token: "onboard_" + String(i).repeat(64), @@ -151,12 +153,11 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "15 seconds" })( yield* Database.use((sql) => sql.withTransaction( commandOnboarding({ - departmentId: dept, + coordinator, command: { applicationId: PublicApplicationIdSchema.make("onboard-app-6"), action: "Issue", }, - actor, now, invitationId: "aaa-reissued", token: "onboard_" + "7".repeat(64), @@ -194,21 +195,23 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "15 seconds" })( yield* sql`INSERT INTO admission_period_fields_of_study VALUES('onboarding-other-fos',${other},'Other Math',true)`; yield* sql`INSERT INTO admission_applications(application_id,applicant_id,admission_period_id,department_id,field_of_study_id,year_of_study,submitted_at) VALUES(${applicationId},'onboard-applicant-3','onboarding-other-period',${other},'onboarding-other-fos',2,${now})`; yield* commandOnboarding({ - departmentId: other, + coordinator: administratorDepartmentReach( + "onboarding-leader", + "admissions.outcomes", + "onboarding-other", + ), command: { applicationId, action: "Issue" }, - actor, now, invitationId: "other-invite", token: "onboard_" + "9".repeat(64), digest: "9".repeat(64), }); yield* commandOnboarding({ - departmentId: dept, + coordinator, command: { applicationId: PublicApplicationIdSchema.make("onboard-app-3"), action: "Revoke", }, - actor, now, invitationId: "original-revoke", token: "unused", diff --git a/packages/database/src/onboarding/postgres.ts b/packages/database/src/onboarding/postgres.ts index 87c120f13..69ef3b854 100644 --- a/packages/database/src/onboarding/postgres.ts +++ b/packages/database/src/onboarding/postgres.ts @@ -1,6 +1,8 @@ +import { dual } from "effect/Function"; import { DateTime, Effect } from "effect"; import { AdvisoryLockKey, lockAdvisory } from "../advisory-lock.js"; import { Database } from "../service.js"; +import type { DepartmentReach } from "@vektorprogrammet/domain/authz"; import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; import { OnboardingFailure, @@ -11,19 +13,26 @@ import { const fail = (code: OnboardingFailure["code"], status: OnboardingFailure["status"] = 409) => Effect.fail(new OnboardingFailure({ code, status })); -export const onboardingApplication = (applicationId: string, departmentId: DepartmentId) => +const onboardingApplicationImpl = (applicationId: string, departmentId: DepartmentId) => Database.use((sql) => Effect.gen(function* () { const rows = yield* sql<{ applicantId: string; }>`SELECT applicant_id AS "applicantId" FROM public.admission_applications WHERE application_id=${applicationId} AND department_id=${departmentId}`; - if (!rows[0]) return yield* fail("resource.not-found", 404); + if (rows[0] === undefined) return yield* fail("resource.not-found", 404); return rows[0]; }), ); +export const onboardingApplication: { + ( + departmentId: DepartmentId, + ): (applicationId: string) => ReturnType; + (applicationId: string, departmentId: DepartmentId): ReturnType; +} = dual(2, onboardingApplicationImpl); + export const lockOnboardingApplicant = (applicantId: string) => Database.use( (sql) => @@ -40,10 +49,14 @@ export const readOnboardingBoard = (departmentId: DepartmentId) => }), ); +/** + * Issues, revokes, or retries one onboarding invitation in the coordinator's department. The + * department and the audited actor come from the evidence, so the command acts only where the + * coordinator holds `admissions.outcomes`. + */ export const commandOnboarding = (input: { - departmentId: DepartmentId; + coordinator: DepartmentReach<"admissions.outcomes">; command: OnboardingCommand; - actor: PersonId; now: string; invitationId: string; token: string; @@ -53,7 +66,7 @@ export const commandOnboarding = (input: { Effect.gen(function* () { const { applicantId } = yield* onboardingApplication( input.command.applicationId, - input.departmentId, + input.coordinator.departmentId, ); yield* lockOnboardingApplicant(applicantId); @@ -63,7 +76,7 @@ export const commandOnboarding = (input: { const linked = yield* sql`SELECT applicant_id FROM public.applicant_account_links WHERE applicant_id=${applicantId}`; - if (linked.length) return yield* fail("onboarding.already-linked"); + if (linked.length > 0) return yield* fail("onboarding.already-linked"); const old = yield* sql<{ invitationId: string; @@ -71,7 +84,7 @@ export const commandOnboarding = (input: { for (const row of old) { yield* sql`UPDATE public.applicant_account_delivery SET state='Cancelled',secret=NULL,envelope=NULL,claim_id=NULL,claimed_at=NULL WHERE invitation_id=${row.invitationId} AND state<>'Delivered'`; - yield* sql`INSERT INTO public.applicant_account_audit VALUES(${input.invitationId + ":revoke:" + row.invitationId},${applicantId},${row.invitationId},${input.actor},'Revoked',${input.now})`; + yield* sql`INSERT INTO public.applicant_account_audit VALUES(${input.invitationId + ":revoke:" + row.invitationId},${applicantId},${row.invitationId},${input.coordinator.personId},'Revoked',${input.now})`; } if (input.command.action === "Revoke") return; @@ -80,9 +93,9 @@ export const commandOnboarding = (input: { DateTime.add(DateTime.makeUnsafe(input.now), { days: 1 }), ); - yield* sql`INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES(${input.invitationId},${input.command.applicationId},${applicantId},${input.digest},${expiresAt},'Open',${input.actor},${input.now})`; + yield* sql`INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES(${input.invitationId},${input.command.applicationId},${applicantId},${input.digest},${expiresAt},'Open',${input.coordinator.personId},${input.now})`; yield* sql`INSERT INTO public.applicant_account_delivery(invitation_id,state,secret,recipient) SELECT ${input.invitationId},'Pending',${input.token},email FROM public.admission_applicants WHERE applicant_id=${applicantId}`; - yield* sql`INSERT INTO public.applicant_account_audit VALUES(${input.invitationId + ":issue"},${applicantId},${input.invitationId},${input.actor},'Issued',${input.now})`; + yield* sql`INSERT INTO public.applicant_account_audit VALUES(${input.invitationId + ":issue"},${applicantId},${input.invitationId},${input.coordinator.personId},'Issued',${input.now})`; }), ); @@ -101,7 +114,7 @@ export const claimOnboarding = (input: { applicantId: string; }>`SELECT applicant_id AS "applicantId" FROM public.applicant_account_invitations WHERE token_digest=${input.digest}`; - if (!found[0]) return yield* fail("onboarding.claim-invalid", 400); + if (found[0] === undefined) return yield* fail("onboarding.claim-invalid", 400); yield* lockAdvisory(sql, AdvisoryLockKey.personAuthorization(input.identity.personId)); yield* lockOnboardingApplicant(found[0].applicantId); @@ -118,7 +131,7 @@ export const claimOnboarding = (input: { const row = rows[0]; - if (!row) return yield* fail("onboarding.claim-invalid", 400); + if (row === undefined) return yield* fail("onboarding.claim-invalid", 400); if (input.identity.mode === "NewAccount") yield* input.provision({ ...row, ...input.identity, now: row.observedAt }); @@ -132,12 +145,17 @@ export const claimOnboarding = (input: { ), ); -export const checkOnboardingClaim = (digest: string, now: string) => +const checkOnboardingClaimImpl = (digest: string, now: string) => Database.use((sql) => Effect.gen(function* () { const rows = yield* sql`SELECT invitation_id FROM public.applicant_account_invitations i WHERE token_digest=${digest} AND state='Open' AND expires_at>${now}::timestamptz AND NOT EXISTS(SELECT 1 FROM public.applicant_account_links l WHERE l.applicant_id=i.applicant_id)`; - if (!rows.length) return yield* fail("onboarding.claim-invalid", 400); + if (rows.length === 0) return yield* fail("onboarding.claim-invalid", 400); }), ); + +export const checkOnboardingClaim: { + (now: string): (digest: string) => ReturnType; + (digest: string, now: string): ReturnType; +} = dual(2, checkOnboardingClaimImpl); diff --git a/packages/database/src/organization/administration-postgres.ts b/packages/database/src/organization/administration-postgres.ts index 00f1f11af..0529a783d 100644 --- a/packages/database/src/organization/administration-postgres.ts +++ b/packages/database/src/organization/administration-postgres.ts @@ -1,16 +1,15 @@ import { AdvisoryLockKey, lockAdvisory } from "../advisory-lock.js"; import { Database, type DatabaseOperations } from "../service.js"; import { Equal, flow, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import type { SqlError } from "effect/unstable/sql/SqlError"; import { DepartmentCreatedObservationSchema, TeamCreatedObservationSchema, FieldOfStudyCreatedObservationSchema, - authorizeOrganizationActor, decodeCreateDepartmentCommand, decodeCreateFieldOfStudyCommand, decodeCreateTeamCommand, - decodeOrganizationActor, departmentIdForCommand, fieldOfStudyIdForCommand, organizationCommandDigest, @@ -26,6 +25,7 @@ import { type DepartmentCreatedObservation, type FieldOfStudyCreatedObservation, type OrganizationActor, + type OrganizationAdministratorEvidence, type OrganizationCommandId, type OrganizationCreateCommand, type OrganizationCreatedObservation, @@ -63,13 +63,13 @@ interface ExistsRow { } const persistenceError = (operation: string, cause?: SqlError) => - new OrganizationPersistenceError({ + OrganizationPersistenceError.make({ operation, message: cause === undefined ? operation : String(cause), }); const decodeError = (operation: string, cause: unknown) => - new OrganizationDecodeError({ operation, message: String(cause) }); + OrganizationDecodeError.make({ operation, message: String(cause) }); const decodeDepartment = flow( Schema.decodeUnknownEffect(Department, { onExcessProperty: "error" }), @@ -108,7 +108,7 @@ const requireDepartment = (sql: DatabaseOperations, departmentId: DepartmentId) Effect.flatMap((rows) => rows[0]?.exists === true ? Effect.void - : Effect.fail(new OrganizationInvalidReference({ referenceKind: "Department" })), + : Effect.fail(OrganizationInvalidReference.make({ referenceKind: "Department" })), ), ); @@ -359,7 +359,7 @@ const receiptOrConflict = ( ): Effect.Effect => { if (receipt === undefined || receipt.commandSha256 === digest) return Effect.succeed(receipt); - return Effect.fail(new OrganizationCommandConflict({ commandId })); + return Effect.fail(OrganizationCommandConflict.make({ commandId })); }; /** Decoded models compare structurally, field by field. */ @@ -542,181 +542,216 @@ const insertFieldOfStudy = ( ); }; -export const createOrganizationDepartment = ( - commandInput: CreateDepartmentCommand, - actorInput: OrganizationActor, -): Effect.Effect => - Effect.gen(function* () { - const command = yield* decodeCreateDepartmentCommand(commandInput); - const actor = yield* decodeOrganizationActor(actorInput); - const sql = yield* Database; - const digest = organizationCommandDigest(command); - - return yield* sql - .withTransaction( - Effect.gen(function* () { - yield* lockAdvisory(sql, AdvisoryLockKey.organizationCommand(command.commandId)); - - const receipt = yield* readReceipt(sql, command.commandId).pipe( - Effect.flatMap((stored) => receiptOrConflict(stored, digest, command.commandId)), - ); - - if (receipt !== undefined) - return yield* decodeDepartmentReplay(command.commandId, receipt); - yield* authorizeOrganizationActor(actor); - const inserted = yield* insertDepartment(sql, command); - - const observation: DepartmentCreatedObservation = DepartmentCreatedObservationSchema.make( - { commandId: command.commandId, department: inserted }, - ); - - yield* storeReceiptAndAudit( - sql, - command, - digest, - "Department", - inserted.departmentId, - actor, - observation, - ); - const selected = yield* readDepartment(sql, inserted.departmentId); - - const department = yield* ensureCanonical( - inserted, - selected, - "validate created Department projection", - ); - - return { - committed: true as const, - observation: { ...observation, department }, - }; - }), - ) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("create Organization Department transaction", cause)), - ), - ); - }); - -export const createOrganizationTeam = ( - commandInput: CreateTeamCommand, - actorInput: OrganizationActor, -): Effect.Effect => - Effect.gen(function* () { - const command = yield* decodeCreateTeamCommand(commandInput); - const actor = yield* decodeOrganizationActor(actorInput); - const sql = yield* Database; - const digest = organizationCommandDigest(command); - - return yield* sql - .withTransaction( - Effect.gen(function* () { - yield* lockAdvisory(sql, AdvisoryLockKey.organizationCommand(command.commandId)); - - const receipt = yield* readReceipt(sql, command.commandId).pipe( - Effect.flatMap((stored) => receiptOrConflict(stored, digest, command.commandId)), - ); - - if (receipt !== undefined) return yield* decodeTeamReplay(command.commandId, receipt); - yield* authorizeOrganizationActor(actor); - yield* requireDepartment(sql, command.departmentId); - const inserted = yield* insertTeam(sql, command); - - const observation: TeamCreatedObservation = TeamCreatedObservationSchema.make({ - commandId: command.commandId, - team: inserted, - }); - - yield* storeReceiptAndAudit( - sql, - command, - digest, - "Team", - inserted.teamId, - actor, - observation, - ); - const selected = yield* readTeam(sql, inserted.teamId); - - const team = yield* ensureCanonical( - inserted, - selected, - "validate created Team projection", - ); - - return { - committed: true as const, - observation: { ...observation, team }, - }; - }), - ) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("create Organization Team transaction", cause)), - ), - ); - }); - -export const createOrganizationFieldOfStudy = ( - commandInput: CreateFieldOfStudyCommand, - actorInput: OrganizationActor, -): Effect.Effect => - Effect.gen(function* () { - const command = yield* decodeCreateFieldOfStudyCommand(commandInput); - const actor = yield* decodeOrganizationActor(actorInput); - const sql = yield* Database; - const digest = organizationCommandDigest(command); - - return yield* sql - .withTransaction( - Effect.gen(function* () { - yield* lockAdvisory(sql, AdvisoryLockKey.organizationCommand(command.commandId)); - - const receipt = yield* readReceipt(sql, command.commandId).pipe( - Effect.flatMap((stored) => receiptOrConflict(stored, digest, command.commandId)), - ); - - if (receipt !== undefined) - return yield* decodeFieldOfStudyReplay(command.commandId, receipt); - yield* authorizeOrganizationActor(actor); - - if (command.departmentId !== null) yield* requireDepartment(sql, command.departmentId); - const inserted = yield* insertFieldOfStudy(sql, command); +export const createOrganizationDepartment: { + ( + administrator: OrganizationAdministratorEvidence, + ): ( + commandInput: CreateDepartmentCommand, + ) => Effect.Effect; + ( + commandInput: CreateDepartmentCommand, + administrator: OrganizationAdministratorEvidence, + ): Effect.Effect; +} = dual( + 2, + ( + commandInput: CreateDepartmentCommand, + administrator: OrganizationAdministratorEvidence, + ): Effect.Effect => + Effect.gen(function* () { + const command = yield* decodeCreateDepartmentCommand(commandInput); + const sql = yield* Database; + const digest = organizationCommandDigest(command); + + return yield* sql + .withTransaction( + Effect.gen(function* () { + yield* lockAdvisory(sql, AdvisoryLockKey.organizationCommand(command.commandId)); + + const receipt = yield* readReceipt(sql, command.commandId).pipe( + Effect.flatMap((stored) => receiptOrConflict(stored, digest, command.commandId)), + ); + + if (receipt !== undefined) + return yield* decodeDepartmentReplay(command.commandId, receipt); + const inserted = yield* insertDepartment(sql, command); + + const observation: DepartmentCreatedObservation = + DepartmentCreatedObservationSchema.make({ + commandId: command.commandId, + department: inserted, + }); + + yield* storeReceiptAndAudit( + sql, + command, + digest, + "Department", + inserted.departmentId, + administrator.actor, + observation, + ); + const selected = yield* readDepartment(sql, inserted.departmentId); + + const department = yield* ensureCanonical( + inserted, + selected, + "validate created Department projection", + ); + + return { + committed: true as const, + observation: { ...observation, department }, + }; + }), + ) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("create Organization Department transaction", cause)), + ), + ); + }), +); - const observation: FieldOfStudyCreatedObservation = - FieldOfStudyCreatedObservationSchema.make({ +export const createOrganizationTeam: { + ( + administrator: OrganizationAdministratorEvidence, + ): ( + commandInput: CreateTeamCommand, + ) => Effect.Effect; + ( + commandInput: CreateTeamCommand, + administrator: OrganizationAdministratorEvidence, + ): Effect.Effect; +} = dual( + 2, + ( + commandInput: CreateTeamCommand, + administrator: OrganizationAdministratorEvidence, + ): Effect.Effect => + Effect.gen(function* () { + const command = yield* decodeCreateTeamCommand(commandInput); + const sql = yield* Database; + const digest = organizationCommandDigest(command); + + return yield* sql + .withTransaction( + Effect.gen(function* () { + yield* lockAdvisory(sql, AdvisoryLockKey.organizationCommand(command.commandId)); + + const receipt = yield* readReceipt(sql, command.commandId).pipe( + Effect.flatMap((stored) => receiptOrConflict(stored, digest, command.commandId)), + ); + + if (receipt !== undefined) return yield* decodeTeamReplay(command.commandId, receipt); + yield* requireDepartment(sql, command.departmentId); + const inserted = yield* insertTeam(sql, command); + + const observation: TeamCreatedObservation = TeamCreatedObservationSchema.make({ commandId: command.commandId, - fieldOfStudy: inserted, + team: inserted, }); - yield* storeReceiptAndAudit( - sql, - command, - digest, - "FieldOfStudy", - inserted.fieldOfStudyId, - actor, - observation, - ); - const selected = yield* readFieldOfStudy(sql, inserted.fieldOfStudyId); - - const fieldOfStudy = yield* ensureCanonical( - inserted, - selected, - "validate created FieldOfStudy projection", - ); - - return { - committed: true as const, - observation: { ...observation, fieldOfStudy }, - }; - }), - ) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("create Organization FieldOfStudy transaction", cause)), - ), - ); - }); + yield* storeReceiptAndAudit( + sql, + command, + digest, + "Team", + inserted.teamId, + administrator.actor, + observation, + ); + const selected = yield* readTeam(sql, inserted.teamId); + + const team = yield* ensureCanonical( + inserted, + selected, + "validate created Team projection", + ); + + return { + committed: true as const, + observation: { ...observation, team }, + }; + }), + ) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("create Organization Team transaction", cause)), + ), + ); + }), +); + +export const createOrganizationFieldOfStudy: { + ( + administrator: OrganizationAdministratorEvidence, + ): ( + commandInput: CreateFieldOfStudyCommand, + ) => Effect.Effect; + ( + commandInput: CreateFieldOfStudyCommand, + administrator: OrganizationAdministratorEvidence, + ): Effect.Effect; +} = dual( + 2, + ( + commandInput: CreateFieldOfStudyCommand, + administrator: OrganizationAdministratorEvidence, + ): Effect.Effect => + Effect.gen(function* () { + const command = yield* decodeCreateFieldOfStudyCommand(commandInput); + const sql = yield* Database; + const digest = organizationCommandDigest(command); + + return yield* sql + .withTransaction( + Effect.gen(function* () { + yield* lockAdvisory(sql, AdvisoryLockKey.organizationCommand(command.commandId)); + + const receipt = yield* readReceipt(sql, command.commandId).pipe( + Effect.flatMap((stored) => receiptOrConflict(stored, digest, command.commandId)), + ); + + if (receipt !== undefined) + return yield* decodeFieldOfStudyReplay(command.commandId, receipt); + + if (command.departmentId !== null) yield* requireDepartment(sql, command.departmentId); + const inserted = yield* insertFieldOfStudy(sql, command); + + const observation: FieldOfStudyCreatedObservation = + FieldOfStudyCreatedObservationSchema.make({ + commandId: command.commandId, + fieldOfStudy: inserted, + }); + + yield* storeReceiptAndAudit( + sql, + command, + digest, + "FieldOfStudy", + inserted.fieldOfStudyId, + administrator.actor, + observation, + ); + const selected = yield* readFieldOfStudy(sql, inserted.fieldOfStudyId); + + const fieldOfStudy = yield* ensureCanonical( + inserted, + selected, + "validate created FieldOfStudy projection", + ); + + return { + committed: true as const, + observation: { ...observation, fieldOfStudy }, + }; + }), + ) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("create Organization FieldOfStudy transaction", cause)), + ), + ); + }), +); diff --git a/packages/database/src/organization/authority-postgres.ts b/packages/database/src/organization/authority-postgres.ts index cf3b55709..5a503d405 100644 --- a/packages/database/src/organization/authority-postgres.ts +++ b/packages/database/src/organization/authority-postgres.ts @@ -1,4 +1,5 @@ import { Effect, Option, Schema } from "effect"; +import { dual } from "effect/Function"; import { AdvisoryLockKey, lockAdvisory } from "../advisory-lock.js"; import { Database, type DatabaseOperations } from "../service.js"; import { certificateIssuerBasis, Delegation, IssuerBasis } from "@vektorprogrammet/domain/authz"; @@ -28,7 +29,7 @@ import { import { PersonId } from "@vektorprogrammet/domain/organization"; const decodeError = (operation: string, cause: unknown) => - new OrganizationDecodeError({ operation, message: String(cause) }); + OrganizationDecodeError.make({ operation, message: String(cause) }); export type OrganizationAuthorityRowLockMode = "None" | "ForShare"; @@ -63,21 +64,29 @@ export const lockOrganizationAdministratorSet = (sql: DatabaseOperations) => * * @construct sql-lock */ -export const lockPersonAuthorization = ( - sql: DatabaseOperations, - personId: PersonId, -): Effect.Effect => - lockAdvisory(sql, AdvisoryLockKey.personAuthorization(personId)).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail( - new OrganizationPersistenceError({ - operation: "lock person authorization", - message: String(cause), - cause, - }), +export const lockPersonAuthorization: { + ( + personId: PersonId, + ): (sql: DatabaseOperations) => Effect.Effect; + (sql: DatabaseOperations, personId: PersonId): Effect.Effect; +} = dual( + 2, + ( + sql: DatabaseOperations, + personId: PersonId, + ): Effect.Effect => + lockAdvisory(sql, AdvisoryLockKey.personAuthorization(personId)).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail( + OrganizationPersistenceError.make({ + operation: "lock person authorization", + message: String(cause), + cause, + }), + ), ), ), - ); +); const OrganizationAuthorityPersonRowSchema = Schema.Struct({ personId: PersonId }); @@ -94,38 +103,51 @@ export type OrganizationAuthorityWriteFailure = * The unlocked person lookup is repeated under the advisory and row locks so * a delete/reinsert cannot move the authority to another person. */ -export const lockOrganizationGlobalAdministratorGrantForWrite = ( - sql: DatabaseOperations, - grantId: OrganizationGlobalAdministratorGrant["grantId"], - expectedRevision: number, -): Effect.Effect => - Effect.gen(function* () { - yield* lockOrganizationAdministratorSet(sql); - - const observedRows = yield* sql` +export const lockOrganizationGlobalAdministratorGrantForWrite: { + ( + grantId: OrganizationGlobalAdministratorGrant["grantId"], + expectedRevision: number, + ): ( + sql: DatabaseOperations, + ) => Effect.Effect; + ( + sql: DatabaseOperations, + grantId: OrganizationGlobalAdministratorGrant["grantId"], + expectedRevision: number, + ): Effect.Effect; +} = dual( + 3, + ( + sql: DatabaseOperations, + grantId: OrganizationGlobalAdministratorGrant["grantId"], + expectedRevision: number, + ): Effect.Effect => + Effect.gen(function* () { + yield* lockOrganizationAdministratorSet(sql); + + const observedRows = yield* sql` SELECT person_id AS "personId" FROM public.organization_global_administrator_grants WHERE grant_id = ${grantId} `; - const observed = yield* Schema.decodeEffect(Schema.Array(OrganizationAuthorityPersonRowSchema))( - observedRows, - { onExcessProperty: "error" }, - ).pipe( - Effect.mapError((cause) => - decodeError("decode Organization global-administrator grant person", cause), - ), - ); + const observed = yield* Schema.decodeEffect( + Schema.Array(OrganizationAuthorityPersonRowSchema), + )(observedRows, { onExcessProperty: "error" }).pipe( + Effect.mapError((cause) => + decodeError("decode Organization global-administrator grant person", cause), + ), + ); - const observedPerson = observed[0]?.personId; + const observedPerson = observed[0]?.personId; - if (observedPerson === undefined) { - return yield* new OrganizationAuthorityRecordNotFound({ grantId }); - } + if (observedPerson === undefined) { + return yield* OrganizationAuthorityRecordNotFound.make({ grantId }); + } - yield* lockPersonAuthorization(sql, observedPerson); + yield* lockPersonAuthorization(sql, observedPerson); - const lockedRows = yield* sql` + const lockedRows = yield* sql` SELECT grant_id AS "grantId", person_id AS "personId", @@ -140,35 +162,36 @@ export const lockOrganizationGlobalAdministratorGrantForWrite = ( FOR UPDATE `; - const locked = yield* Schema.decodeEffect( - Schema.Array(OrganizationGlobalAdministratorGrantSchema), - )(lockedRows, { onExcessProperty: "error" }).pipe( - Effect.mapError((cause) => - decodeError("decode locked Organization global-administrator grant", cause), - ), - ); + const locked = yield* Schema.decodeEffect( + Schema.Array(OrganizationGlobalAdministratorGrantSchema), + )(lockedRows, { onExcessProperty: "error" }).pipe( + Effect.mapError((cause) => + decodeError("decode locked Organization global-administrator grant", cause), + ), + ); - const grant = locked[0]; - - if ( - grant === undefined || - grant.personId !== observedPerson || - grant.revision !== expectedRevision - ) { - return yield* new OrganizationAuthorityWriteConflict({ grantId, expectedRevision }); - } - - return grant; - }).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail( - new OrganizationPersistenceError({ - operation: "lock Organization global-administrator grant", - message: String(cause), - }), + const grant = locked[0]; + + if ( + grant === undefined || + grant.personId !== observedPerson || + grant.revision !== expectedRevision + ) { + return yield* OrganizationAuthorityWriteConflict.make({ grantId, expectedRevision }); + } + + return grant; + }).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail( + OrganizationPersistenceError.make({ + operation: "lock Organization global-administrator grant", + message: String(cause), + }), + ), ), ), - ); +); export const createOrganizationGlobalAdministratorGrant = ( input: typeof CreateOrganizationGlobalAdministratorGrantInputSchema.Encoded, @@ -223,7 +246,7 @@ export const createOrganizationGlobalAdministratorGrant = ( .pipe( Effect.catchTag("SqlError", (cause) => Effect.fail( - new OrganizationPersistenceError({ + OrganizationPersistenceError.make({ operation: "create Organization global-administrator grant", message: String(cause), }), @@ -282,7 +305,7 @@ export const endOrganizationGlobalAdministratorGrant = ( `; if (updated.length !== 1) { - return yield* new OrganizationAuthorityWriteConflict({ + return yield* OrganizationAuthorityWriteConflict.make({ grantId: command.grantId, expectedRevision: command.expectedRevision, }); @@ -294,7 +317,7 @@ export const endOrganizationGlobalAdministratorGrant = ( .pipe( Effect.catchTag("SqlError", (cause) => Effect.fail( - new OrganizationPersistenceError({ + OrganizationPersistenceError.make({ operation: "end Organization global-administrator grant", message: String(cause), }), @@ -338,7 +361,7 @@ export const removeOrganizationGlobalAdministratorGrant = ( `; if (removed.length !== 1) { - return yield* new OrganizationAuthorityWriteConflict({ + return yield* OrganizationAuthorityWriteConflict.make({ grantId: command.grantId, expectedRevision: command.expectedRevision, }); @@ -350,7 +373,7 @@ export const removeOrganizationGlobalAdministratorGrant = ( .pipe( Effect.catchTag("SqlError", (cause) => Effect.fail( - new OrganizationPersistenceError({ + OrganizationPersistenceError.make({ operation: "remove Organization global-administrator grant", message: String(cause), }), @@ -376,23 +399,47 @@ const OrganizationAuthorityFactsSchema = Schema.Struct({ * person's teams. `ForShare` locks every row that the decision reads and is command-safe only * when the supplied SQL client is the state-transition transaction client. */ -export const resolveOrganizationPersonAuthorityWithSql = ( - sql: DatabaseOperations, - personId: PersonId, - authorizationInstant: OrganizationAuthorityInstant, - lockMode: OrganizationAuthorityRowLockMode, -): Effect.Effect< - OrganizationPersonAuthority, - OrganizationDecodeError | OrganizationPersistenceError -> => - Effect.gen(function* () { - const evaluatedAt = yield* Schema.decodeEffect(OrganizationAuthorityInstantSchema)( - authorizationInstant, - ).pipe(Effect.mapError((cause) => decodeError("decode Organization authority instant", cause))); +export const resolveOrganizationPersonAuthorityWithSql: { + ( + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + lockMode: OrganizationAuthorityRowLockMode, + ): ( + sql: DatabaseOperations, + ) => Effect.Effect< + OrganizationPersonAuthority, + OrganizationDecodeError | OrganizationPersistenceError + >; + ( + sql: DatabaseOperations, + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + lockMode: OrganizationAuthorityRowLockMode, + ): Effect.Effect< + OrganizationPersonAuthority, + OrganizationDecodeError | OrganizationPersistenceError + >; +} = dual( + 4, + ( + sql: DatabaseOperations, + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + lockMode: OrganizationAuthorityRowLockMode, + ): Effect.Effect< + OrganizationPersonAuthority, + OrganizationDecodeError | OrganizationPersistenceError + > => + Effect.gen(function* () { + const evaluatedAt = yield* Schema.decodeEffect(OrganizationAuthorityInstantSchema)( + authorizationInstant, + ).pipe( + Effect.mapError((cause) => decodeError("decode Organization authority instant", cause)), + ); - const shared = lockMode === "ForShare"; + const shared = lockMode === "ForShare"; - const globalAdministrator = yield* sql` + const globalAdministrator = yield* sql` WITH locked_global_administrator_grants AS MATERIALIZED ( SELECT grant_id, start_at, end_at FROM public.organization_global_administrator_grants AS administrator_grant @@ -414,7 +461,7 @@ export const resolveOrganizationPersonAuthorityWithSql = ( FROM locked_global_administrator_grants `; - const memberships = yield* sql` + const memberships = yield* sql` SELECT membership.membership_id AS "membershipId", team.team_id AS "teamId", @@ -439,7 +486,7 @@ export const resolveOrganizationPersonAuthorityWithSql = ( ${shared ? sql`FOR SHARE OF membership, team, department` : sql``} `; - const nationalBoardSeats = yield* sql` + const nationalBoardSeats = yield* sql` SELECT membership.membership_id AS "membershipId", membership.board_id AS "boardId", @@ -455,7 +502,7 @@ export const resolveOrganizationPersonAuthorityWithSql = ( ${shared ? sql`FOR SHARE OF membership` : sql``} `; - const delegations = yield* sql` + const delegations = yield* sql` SELECT delegation.delegation_id AS "delegationId", delegation.name, @@ -483,42 +530,44 @@ export const resolveOrganizationPersonAuthorityWithSql = ( ${shared ? sql`FOR SHARE OF delegation` : sql``} `; - const facts = yield* Schema.decodeUnknownEffect(OrganizationAuthorityFactsSchema)( - { globalAdministrator, memberships, nationalBoardSeats, delegations }, - { onExcessProperty: "error" }, - ).pipe(Effect.mapError((cause) => decodeError("decode Organization person authority", cause))); - - const status = facts.globalAdministrator[0]; - - if (status === undefined || facts.globalAdministrator.length !== 1) { - return yield* decodeError( - "decode Organization person authority", - "authority projection returned no single global-administrator status", + const facts = yield* Schema.decodeUnknownEffect(OrganizationAuthorityFactsSchema)( + { globalAdministrator, memberships, nationalBoardSeats, delegations }, + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => decodeError("decode Organization person authority", cause)), ); - } - return { - personId, - evaluatedAt, - globalAdministrator: status.globalAdministrator, - memberships: facts.memberships, - nationalBoardSeats: facts.nationalBoardSeats, - delegations: facts.delegations, - }; - }).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail( - new OrganizationPersistenceError({ - operation: "resolve Organization person authority", - message: String(cause), - cause, - }), + const status = facts.globalAdministrator[0]; + + if (status === undefined || facts.globalAdministrator.length !== 1) { + return yield* decodeError( + "decode Organization person authority", + "authority projection returned no single global-administrator status", + ); + } + + return { + personId, + evaluatedAt, + globalAdministrator: status.globalAdministrator, + memberships: facts.memberships, + nationalBoardSeats: facts.nationalBoardSeats, + delegations: facts.delegations, + }; + }).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail( + OrganizationPersistenceError.make({ + operation: "resolve Organization person authority", + message: String(cause), + cause, + }), + ), ), ), - ); +); -/** Existing service projection; receipt commands use the caller-SQL form. */ -export const resolveOrganizationPersonAuthority = ( +const resolveOrganizationPersonAuthorityImpl = ( personId: PersonId, authorizationInstant: OrganizationAuthorityInstant, ) => @@ -533,8 +582,18 @@ export const resolveOrganizationPersonAuthority = ( ); }); -/** Read projection for a caller-owned repeatable-read, read-only snapshot. */ -export const resolveOrganizationPersonAuthorityForRead = ( +/** Existing service projection; receipt commands use the caller-SQL form. */ +export const resolveOrganizationPersonAuthority: { + ( + authorizationInstant: OrganizationAuthorityInstant, + ): (personId: PersonId) => ReturnType; + ( + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + ): ReturnType; +} = dual(2, resolveOrganizationPersonAuthorityImpl); + +const resolveOrganizationPersonAuthorityForReadImpl = ( personId: PersonId, authorizationInstant: OrganizationAuthorityInstant, ) => @@ -549,6 +608,17 @@ export const resolveOrganizationPersonAuthorityForRead = ( ); }); +/** Read projection for a caller-owned repeatable-read, read-only snapshot. */ +export const resolveOrganizationPersonAuthorityForRead: { + ( + authorizationInstant: OrganizationAuthorityInstant, + ): (personId: PersonId) => ReturnType; + ( + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + ): ReturnType; +} = dual(2, resolveOrganizationPersonAuthorityForReadImpl); + /** A department with the facts that decide which board governs it. */ export const GovernedDepartmentRow = Schema.Struct({ departmentId: DepartmentId, @@ -562,36 +632,57 @@ export type GovernedDepartmentRow = typeof GovernedDepartmentRow.Type; * The active departments, or one department, with their independence. `ForShare` keeps the * independence of the read departments fixed until the caller's transaction ends. */ -export const readGovernedDepartmentsWithSql = ( - sql: DatabaseOperations, - departmentId: DepartmentId | null, - lockMode: OrganizationAuthorityRowLockMode, -): Effect.Effect< - ReadonlyArray, - OrganizationDecodeError | OrganizationPersistenceError -> => - sql` +export const readGovernedDepartmentsWithSql: { + ( + departmentId: DepartmentId | null, + lockMode: OrganizationAuthorityRowLockMode, + ): ( + sql: DatabaseOperations, + ) => Effect.Effect< + ReadonlyArray, + OrganizationDecodeError | OrganizationPersistenceError + >; + ( + sql: DatabaseOperations, + departmentId: DepartmentId | null, + lockMode: OrganizationAuthorityRowLockMode, + ): Effect.Effect< + ReadonlyArray, + OrganizationDecodeError | OrganizationPersistenceError + >; +} = dual( + 3, + ( + sql: DatabaseOperations, + departmentId: DepartmentId | null, + lockMode: OrganizationAuthorityRowLockMode, + ): Effect.Effect< + ReadonlyArray, + OrganizationDecodeError | OrganizationPersistenceError + > => + sql` SELECT department_id AS "departmentId", name, independent FROM public.organization_departments WHERE active AND (${departmentId}::text IS NULL OR department_id = ${departmentId}) ORDER BY name, department_id ${lockMode === "ForShare" ? sql`FOR SHARE` : sql``} `.pipe( - Effect.flatMap((rows) => - Schema.decodeUnknownEffect(Schema.Array(GovernedDepartmentRow))(rows, { - onExcessProperty: "error", - }).pipe(Effect.mapError((cause) => decodeError("decode governed departments", cause))), - ), - Effect.catchTag("SqlError", (cause) => - Effect.fail( - new OrganizationPersistenceError({ - operation: "read governed departments", - message: String(cause), - cause, - }), + Effect.flatMap((rows) => + Schema.decodeUnknownEffect(Schema.Array(GovernedDepartmentRow))(rows, { + onExcessProperty: "error", + }).pipe(Effect.mapError((cause) => decodeError("decode governed departments", cause))), + ), + Effect.catchTag("SqlError", (cause) => + Effect.fail( + OrganizationPersistenceError.make({ + operation: "read governed departments", + message: String(cause), + cause, + }), + ), ), ), - ); +); const SeatFactsRow = Schema.Struct({ issuerName: Schema.String, @@ -603,26 +694,46 @@ const SeatFactsRow = Schema.Struct({ * The issuer that a certificate of the department names for this authority: the person's name * and the title of the seat, or the grant, that authorizes them. None without a basis. */ -export const certificateIssuerWithSql = ( - sql: DatabaseOperations, - authority: OrganizationPersonAuthority, - department: GovernedDepartmentRow, -): Effect.Effect< - Option.Option, - OrganizationDecodeError | OrganizationPersistenceError -> => - Effect.gen(function* () { - const basis = certificateIssuerBasis(authority, department); - - if (Option.isNone(basis)) return Option.none(); - - const membershipId = IssuerBasis.$match(basis.value, { - BoardSeat: ({ membershipId }): string | null => membershipId, - DerivedSeat: ({ membershipId }) => membershipId, - GlobalAdministrator: () => null, - }); - - const rows = yield* sql` +export const certificateIssuerWithSql: { + ( + authority: OrganizationPersonAuthority, + department: GovernedDepartmentRow, + ): ( + sql: DatabaseOperations, + ) => Effect.Effect< + Option.Option, + OrganizationDecodeError | OrganizationPersistenceError + >; + ( + sql: DatabaseOperations, + authority: OrganizationPersonAuthority, + department: GovernedDepartmentRow, + ): Effect.Effect< + Option.Option, + OrganizationDecodeError | OrganizationPersistenceError + >; +} = dual( + 3, + ( + sql: DatabaseOperations, + authority: OrganizationPersonAuthority, + department: GovernedDepartmentRow, + ): Effect.Effect< + Option.Option, + OrganizationDecodeError | OrganizationPersistenceError + > => + Effect.gen(function* () { + const basis = certificateIssuerBasis(authority, department); + + if (Option.isNone(basis)) return Option.none(); + + const membershipId = IssuerBasis.$match(basis.value, { + BoardSeat: ({ membershipId }): string | null => membershipId, + DerivedSeat: ({ membershipId }) => membershipId, + GlobalAdministrator: () => null, + }); + + const rows = yield* sql` SELECT profile.first_name || ' ' || profile.last_name AS "issuerName", membership.position_name AS position, @@ -635,44 +746,48 @@ export const certificateIssuerWithSql = ( WHERE profile.person_id = ${authority.personId} `; - const [facts] = yield* Schema.decodeUnknownEffect(Schema.Array(SeatFactsRow))(rows, { - onExcessProperty: "error", - }).pipe(Effect.mapError((cause) => decodeError("decode certificate issuer seat", cause))); - - if (facts === undefined || (membershipId !== null && facts.unitName === null)) - return yield* decodeError("decode certificate issuer seat", "the authorizing seat is absent"); - - const unitName = facts.unitName ?? ""; - - const title = issuerSeatTitle( - IssuerBasis.$match(basis.value, { - BoardSeat: () => - IssuerSeatFacts.BoardSeat({ position: facts.position, boardName: unitName }), - DerivedSeat: () => - IssuerSeatFacts.DerivedSeat({ position: facts.position, teamName: unitName }), - GlobalAdministrator: () => IssuerSeatFacts.GlobalAdministrator(), - }), - ); - - return Option.some( - yield* Schema.decodeEffect(CertificateIssuer)( - { - personId: authority.personId, - name: facts.issuerName, - seatTitle: title, - basis: basis.value._tag, - }, - { onExcessProperty: "error" }, - ).pipe(Effect.mapError((cause) => decodeError("decode certificate issuer", cause))), - ); - }).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail( - new OrganizationPersistenceError({ - operation: "read certificate issuer seat", - message: String(cause), - cause, + const [facts] = yield* Schema.decodeUnknownEffect(Schema.Array(SeatFactsRow))(rows, { + onExcessProperty: "error", + }).pipe(Effect.mapError((cause) => decodeError("decode certificate issuer seat", cause))); + + if (facts === undefined || (membershipId !== null && facts.unitName === null)) + return yield* decodeError( + "decode certificate issuer seat", + "the authorizing seat is absent", + ); + + const unitName = facts.unitName ?? ""; + + const title = issuerSeatTitle( + IssuerBasis.$match(basis.value, { + BoardSeat: () => + IssuerSeatFacts.BoardSeat({ position: facts.position, boardName: unitName }), + DerivedSeat: () => + IssuerSeatFacts.DerivedSeat({ position: facts.position, teamName: unitName }), + GlobalAdministrator: () => IssuerSeatFacts.GlobalAdministrator(), }), + ); + + return Option.some( + yield* Schema.decodeEffect(CertificateIssuer)( + { + personId: authority.personId, + name: facts.issuerName, + seatTitle: title, + basis: basis.value._tag, + }, + { onExcessProperty: "error" }, + ).pipe(Effect.mapError((cause) => decodeError("decode certificate issuer", cause))), + ); + }).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail( + OrganizationPersistenceError.make({ + operation: "read certificate issuer seat", + message: String(cause), + cause, + }), + ), ), ), - ); +); diff --git a/packages/database/src/organization/directory-postgres.ts b/packages/database/src/organization/directory-postgres.ts index 6c134d4c5..434a36a52 100644 --- a/packages/database/src/organization/directory-postgres.ts +++ b/packages/database/src/organization/directory-postgres.ts @@ -1,4 +1,5 @@ import { Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { Database } from "../service.js"; import type { OrganizationAuthorityInstant } from "@vektorprogrammet/domain/organization"; import { @@ -42,24 +43,46 @@ type DirectoryGrantRow = typeof DirectoryGrantRowSchema.Type; * an Inactive row rather than an exclusion: the person stays in the result * with isActive false. */ -export const deriveOrganizationDirectoryFacts = ( - personIds: ReadonlyArray, - authorizationInstant: OrganizationAuthorityInstant, -): Effect.Effect< - OrganizationDirectoryFacts, - OrganizationDecodeError | OrganizationPersistenceError, - Database -> => - Effect.gen(function* () { - if (personIds.length === 0) return new Map(); +export const deriveOrganizationDirectoryFacts: { + ( + authorizationInstant: OrganizationAuthorityInstant, + ): ( + personIds: ReadonlyArray, + ) => Effect.Effect< + OrganizationDirectoryFacts, + OrganizationDecodeError | OrganizationPersistenceError, + Database + >; + ( + personIds: ReadonlyArray, + authorizationInstant: OrganizationAuthorityInstant, + ): Effect.Effect< + OrganizationDirectoryFacts, + OrganizationDecodeError | OrganizationPersistenceError, + Database + >; +} = dual( + 2, + ( + personIds: ReadonlyArray, + authorizationInstant: OrganizationAuthorityInstant, + ): Effect.Effect< + OrganizationDirectoryFacts, + OrganizationDecodeError | OrganizationPersistenceError, + Database + > => + Effect.gen(function* () { + if (personIds.length === 0) return new Map(); - const evaluatedAt = yield* Schema.decodeEffect(OrganizationAuthorityInstantSchema)( - authorizationInstant, - ).pipe(Effect.mapError((cause) => decodeError("decode Organization directory instant", cause))); + const evaluatedAt = yield* Schema.decodeEffect(OrganizationAuthorityInstantSchema)( + authorizationInstant, + ).pipe( + Effect.mapError((cause) => decodeError("decode Organization directory instant", cause)), + ); - const sql = yield* Database; + const sql = yield* Database; - const membershipRows = yield* sql` + const membershipRows = yield* sql` SELECT DISTINCT membership.person_id AS "personId", department.department_id AS "departmentId", @@ -81,23 +104,23 @@ export const deriveOrganizationDirectoryFacts = ( ON department.department_id = team.department_id WHERE ${sql.in("membership.person_id", personIds)} `.pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail( - new OrganizationPersistenceError({ - operation: "derive Organization directory memberships", - message: String(cause), - }), + Effect.catchTag("SqlError", (cause) => + Effect.fail( + OrganizationPersistenceError.make({ + operation: "derive Organization directory memberships", + message: String(cause), + }), + ), ), - ), - ); + ); - const decodedMemberships = yield* Schema.decodeEffect( - Schema.Array(DirectoryMembershipRowSchema), - )(membershipRows, { onExcessProperty: "error" }).pipe( - Effect.mapError((cause) => decodeError("decode Organization directory memberships", cause)), - ); + const decodedMemberships = yield* Schema.decodeEffect( + Schema.Array(DirectoryMembershipRowSchema), + )(membershipRows, { onExcessProperty: "error" }).pipe( + Effect.mapError((cause) => decodeError("decode Organization directory memberships", cause)), + ); - const grantRows = yield* sql` + const grantRows = yield* sql` SELECT g.person_id AS "personId", CASE @@ -115,28 +138,31 @@ export const deriveOrganizationDirectoryFacts = ( WHERE ${sql.in("g.person_id", personIds)} GROUP BY g.person_id `.pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail( - new OrganizationPersistenceError({ - operation: "derive Organization directory grants", - message: String(cause), - }), + Effect.catchTag("SqlError", (cause) => + Effect.fail( + OrganizationPersistenceError.make({ + operation: "derive Organization directory grants", + message: String(cause), + }), + ), ), - ), - ); + ); - const decodedGrants = yield* Schema.decodeEffect(Schema.Array(DirectoryGrantRowSchema))( - grantRows, - { onExcessProperty: "error" }, - ).pipe(Effect.mapError((cause) => decodeError("decode Organization directory grants", cause))); + const decodedGrants = yield* Schema.decodeEffect(Schema.Array(DirectoryGrantRowSchema))( + grantRows, + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => decodeError("decode Organization directory grants", cause)), + ); - return accumulateOrganizationDirectoryFacts({ - personIds, - instant: evaluatedAt, - memberships: decodedMemberships, - grants: decodedGrants, - }); - }); + return accumulateOrganizationDirectoryFacts({ + personIds, + instant: evaluatedAt, + memberships: decodedMemberships, + grants: decodedGrants, + }); + }), +); const decodeError = (operation: string, cause: unknown) => - new OrganizationDecodeError({ operation, message: String(cause) }); + OrganizationDecodeError.make({ operation, message: String(cause) }); diff --git a/packages/database/src/organization/lifecycle-postgres.ts b/packages/database/src/organization/lifecycle-postgres.ts index 08a62a0c6..820ca3b38 100644 --- a/packages/database/src/organization/lifecycle-postgres.ts +++ b/packages/database/src/organization/lifecycle-postgres.ts @@ -40,7 +40,7 @@ import { } from "./authority-postgres.js"; const fail = (code: OrganizationLifecycleFailure["code"]) => - new OrganizationLifecycleFailure({ code }); + OrganizationLifecycleFailure.make({ code }); const decode = (schema: S) => flow( @@ -159,7 +159,7 @@ const isLifecycleFailure = Schema.is(OrganizationLifecycleFailure); const failure = (cause: unknown) => isLifecycleFailure(cause) ? cause - : new OrganizationLifecycleFailure({ code: "Unavailable", cause }); + : OrganizationLifecycleFailure.make({ code: "Unavailable", cause }); export const readAppointmentManagement = Effect.fn("readAppointmentManagement")(function* ( actorPersonId: PersonId, @@ -452,7 +452,12 @@ export const executeOrganizationLifecycle = Effect.fn("executeOrganizationLifecy const subject = "personId" in command ? command.personId : observed?.personId; - for (const id of [...new Set([actorPersonId, ...(subject ? [subject] : [])])].sort()) + for (const id of [ + ...new Set([ + actorPersonId, + ...(subject !== undefined && subject !== "" ? [subject] : []), + ]), + ].sort()) yield* lockPersonAuthorization(sql, id); const now = DateTime.formatIso(yield* DateTime.now); const authority = yield* authorityFor(sql, actorPersonId, now); @@ -464,15 +469,18 @@ export const executeOrganizationLifecycle = Effect.fn("executeOrganizationLifecy ) : undefined; - if ("appointmentId" in command && (!current || current.personId !== observed?.personId)) + if ( + "appointmentId" in command && + (current === undefined || current.personId !== observed?.personId) + ) return yield* fail("NotFound"); const target = Predicate.isTagged(command, "Appoint") ? command.target : current?.target; const units = yield* unitsFor(sql); - if (target) { + if (target !== undefined) { const unit = units.find((unit) => unit.kind === target.kind && unit.id === target.id); - if (!unit) return yield* fail("NotFound"); + if (unit === undefined) return yield* fail("NotFound"); if (!allowedUnit(authority, unit)) return yield* fail("Denied"); } else if ( @@ -496,7 +504,7 @@ export const executeOrganizationLifecycle = Effect.fn("executeOrganizationLifecy result: unknown; }>`SELECT command_digest AS digest,result_json AS result FROM organization_lifecycle_history WHERE command_id=${command.commandId}`)[0]; - if (receipt) { + if (receipt !== undefined) { if (receipt.digest !== digest) return yield* fail("Conflict"); return yield* decode(OrganizationLifecycleResult)(receipt.result); diff --git a/packages/database/src/organization/mailing-lists-postgres.ts b/packages/database/src/organization/mailing-lists-postgres.ts index b74275e15..014a0c943 100644 --- a/packages/database/src/organization/mailing-lists-postgres.ts +++ b/packages/database/src/organization/mailing-lists-postgres.ts @@ -50,7 +50,7 @@ export const readOrganizationMailingLists = Effect.fn("readOrganizationMailingLi (reachedDepartmentIds.size === 0 || (input.departmentId !== undefined && !reachedDepartmentIds.has(input.departmentId))) ) { - return yield* new OrganizationRoleDenied({ + return yield* OrganizationRoleDenied.make({ actorPersonId: input.actorPersonId, requiredRole: "DepartmentAdministrator", }); @@ -62,7 +62,7 @@ export const readOrganizationMailingLists = Effect.fn("readOrganizationMailingLi input.departmentId !== undefined && !departments.some((department) => department.departmentId === input.departmentId) ) { - return yield* new OrganizationInvalidReference({ referenceKind: "Department" }); + return yield* OrganizationInvalidReference.make({ referenceKind: "Department" }); } const authorizedDepartmentIds = departments @@ -93,7 +93,7 @@ export const readOrganizationMailingLists = Effect.fn("readOrganizationMailingLi const semester = semesters[0]; if (semesters.length !== 1 || semester === undefined) { - return yield* new OrganizationInvalidReference({ + return yield* OrganizationInvalidReference.make({ referenceKind: input.semesterId === undefined ? "CurrentSemester" : "Semester", }); } @@ -174,7 +174,7 @@ export const readOrganizationMailingLists = Effect.fn("readOrganizationMailingLi .pipe( Effect.catchTag(["SqlError", "SchemaError"], (cause) => Effect.fail( - new OrganizationPersistenceError({ + OrganizationPersistenceError.make({ operation: "read Organization mailing recipients", message: String(cause), cause, diff --git a/packages/database/src/organization/postgres-layer.ts b/packages/database/src/organization/postgres-layer.ts index af28d44d9..8473dd88d 100644 --- a/packages/database/src/organization/postgres-layer.ts +++ b/packages/database/src/organization/postgres-layer.ts @@ -76,8 +76,8 @@ export const OrganizationLive = Layer.effect( listHistoricalMemberships: listOrganizationHistoricalMemberships.pipe( Effect.provideService(Database, database), ), - listTeamInterestRegistrations: (filter) => - listOrganizationTeamInterestRegistrations(filter).pipe( + listTeamInterestRegistrations: (scope, semesterId) => + listOrganizationTeamInterestRegistrations(scope, semesterId).pipe( Effect.provideService(Database, database), ), projectMailingLists: (input) => diff --git a/packages/database/src/organization/postgres.ts b/packages/database/src/organization/postgres.ts index 4f10ef346..bbbb1d14e 100644 --- a/packages/database/src/organization/postgres.ts +++ b/packages/database/src/organization/postgres.ts @@ -2,7 +2,8 @@ import { canonicalJsonValue } from "@vektorprogrammet/domain/shared-kernel"; import { Database, type DatabaseOperations } from "../service.js"; import { lockPersonAuthorization } from "./authority-postgres.js"; import * as Statement from "effect/unstable/sql/Statement"; -import { flow, Effect, Schema } from "effect"; +import { flow, Effect, Schema, Predicate } from "effect"; +import { dual } from "effect/Function"; import { DepartmentNotFound, MembershipNotFound, @@ -25,7 +26,7 @@ import { type DepartmentId, type TeamId, } from "@vektorprogrammet/domain/organization"; -import type { TeamInterestFilter } from "@vektorprogrammet/domain/organization"; +import type { SemesterId, TeamInterestReadScope } from "@vektorprogrammet/domain/organization"; import { importLegacyOrganizationEffect, type LegacyOrganizationSnapshot, @@ -35,35 +36,32 @@ import { } from "@vektorprogrammet/domain/organization"; const persistenceError = (operation: string, cause: unknown) => - new OrganizationPersistenceError({ operation, message: String(cause) }); + OrganizationPersistenceError.make({ operation, message: String(cause) }); const decodeDepartment = flow( Schema.decodeUnknownEffect(Department, { onExcessProperty: "error" }), - Effect.mapError( - (cause) => - new OrganizationDecodeError({ - operation: "decode Department select", - message: String(cause), - }), + Effect.mapError((cause) => + OrganizationDecodeError.make({ + operation: "decode Department select", + message: String(cause), + }), ), ); const decodeTeam = flow( Schema.decodeUnknownEffect(Team, { onExcessProperty: "error" }), - Effect.mapError( - (cause) => - new OrganizationDecodeError({ operation: "decode Team select", message: String(cause) }), + Effect.mapError((cause) => + OrganizationDecodeError.make({ operation: "decode Team select", message: String(cause) }), ), ); const decodeMembership = flow( Schema.decodeUnknownEffect(MembershipInvariantSchema, { onExcessProperty: "error" }), - Effect.mapError( - (cause) => - new OrganizationDecodeError({ - operation: "decode Membership select", - message: String(cause), - }), + Effect.mapError((cause) => + OrganizationDecodeError.make({ + operation: "decode Membership select", + message: String(cause), + }), ), ); @@ -107,7 +105,7 @@ export const readOrganizationDepartment = ( const sql = yield* Database; const department = yield* findDepartment(sql, departmentId); - return department === undefined ? yield* new DepartmentNotFound({ departmentId }) : department; + return department === undefined ? yield* DepartmentNotFound.make({ departmentId }) : department; }); export const listOrganizationDepartments: Effect.Effect< @@ -180,7 +178,7 @@ export const readOrganizationTeam = ( const sql = yield* Database; const team = yield* findTeam(sql, teamId); - return team === undefined ? yield* new TeamNotFound({ teamId }) : team; + return team === undefined ? yield* TeamNotFound.make({ teamId }) : team; }); export const listOrganizationTeams = ( @@ -308,7 +306,7 @@ export const readOrganizationMembership = ( const sql = yield* Database; const membership = yield* findMembership(sql, membershipId, false); - return membership === undefined ? yield* new MembershipNotFound({ membershipId }) : membership; + return membership === undefined ? yield* MembershipNotFound.make({ membershipId }) : membership; }); export const listOrganizationMembershipsForTeam = ( @@ -591,54 +589,70 @@ export const importOrganizationSnapshot = ( const decodeTeamInterestRegistration = flow( Schema.decodeUnknownEffect(TeamInterestRegistration, { onExcessProperty: "error" }), - Effect.mapError( - (cause) => - new OrganizationDecodeError({ - operation: "decode TeamInterestRegistration select", - message: String(cause), - }), + Effect.mapError((cause) => + OrganizationDecodeError.make({ + operation: "decode TeamInterestRegistration select", + message: String(cause), + }), ), ); -const teamInterestScopeClause = (database: DatabaseOperations, filter: TeamInterestFilter) => +const teamInterestScopeClause = (database: DatabaseOperations, scope: TeamInterestReadScope) => Statement.or([ - ...filter.authorizedDepartmentIds.map( + ...scope.departmentIds.map( (departmentId) => database`registration.department_id = ${departmentId}`, ), - ...filter.authorizedTeamIds.map((teamId) => database`registration.team_id = ${teamId}`), + ...scope.teams.map(({ teamId }) => database`registration.team_id = ${teamId}`), ]); const teamInterestPredicate = ( database: DatabaseOperations, - filter: TeamInterestFilter, + scope: TeamInterestReadScope, + semesterId: SemesterId | undefined, ): Statement.Fragment => { - const clauses: Array = [teamInterestScopeClause(database, filter)]; + const clauses: Array = [teamInterestScopeClause(database, scope)]; - if (filter.semesterId !== undefined) { - clauses.push(database`registration.semester_id = ${filter.semesterId}`); - } - - if (filter.departmentId !== undefined) { - clauses.push(database`registration.department_id = ${filter.departmentId}`); + if (semesterId !== undefined) { + clauses.push(database`registration.semester_id = ${semesterId}`); } return Statement.and(clauses); }; -export const listOrganizationTeamInterestRegistrations = ( - filter: TeamInterestFilter, -): Effect.Effect< - ReadonlyArray, - OrganizationDecodeError | OrganizationPersistenceError, - Database -> => { - if (filter.authorizedDepartmentIds.length === 0 && filter.authorizedTeamIds.length === 0) - return Effect.succeed([]); - - return Effect.gen(function* () { - const database = yield* Database; - - const rows = yield* database` +export const listOrganizationTeamInterestRegistrations: { + ( + semesterId?: SemesterId, + ): ( + scope: TeamInterestReadScope, + ) => Effect.Effect< + ReadonlyArray, + OrganizationDecodeError | OrganizationPersistenceError, + Database + >; + ( + scope: TeamInterestReadScope, + semesterId?: SemesterId, + ): Effect.Effect< + ReadonlyArray, + OrganizationDecodeError | OrganizationPersistenceError, + Database + >; +} = dual( + (args) => Predicate.isObject(args[0]), + ( + scope: TeamInterestReadScope, + semesterId?: SemesterId, + ): Effect.Effect< + ReadonlyArray, + OrganizationDecodeError | OrganizationPersistenceError, + Database + > => { + if (scope.departmentIds.length === 0 && scope.teams.length === 0) return Effect.succeed([]); + + return Effect.gen(function* () { + const database = yield* Database; + + const rows = yield* database` SELECT registration.registration_id::text AS "registrationId", registration.submitter_name AS "submitterName", @@ -653,14 +667,15 @@ export const listOrganizationTeamInterestRegistrations = ( FROM public.organization_team_interest_registrations AS registration INNER JOIN organization_teams AS team ON team.team_id = registration.team_id - WHERE ${teamInterestPredicate(database, filter)} + WHERE ${teamInterestPredicate(database, scope, semesterId)} ORDER BY registration.registration_id ASC `.pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("list organization team interest registrations", cause)), - ), - ); + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("list organization team interest registrations", cause)), + ), + ); - return yield* Effect.forEach(rows, (row) => decodeTeamInterestRegistration(row)); - }); -}; + return yield* Effect.forEach(rows, (row) => decodeTeamInterestRegistration(row)); + }); + }, +); diff --git a/packages/database/src/organization/reviewed-cohort.ts b/packages/database/src/organization/reviewed-cohort.ts index 32f376392..1f473e5d5 100644 --- a/packages/database/src/organization/reviewed-cohort.ts +++ b/packages/database/src/organization/reviewed-cohort.ts @@ -20,6 +20,10 @@ export { OrganizationCohortFailure, } from "@vektorprogrammet/domain/organization"; +/** The truthiness test of a `rowCount`: present, not zero, and not NaN. */ +const hasRowCount = (rowCount: number | null): boolean => + rowCount !== null && rowCount !== 0 && !Number.isNaN(rowCount); + export const organizationImportSourceDigest = Effect.fn("organizationImportSourceDigest")( function* () { const fs = yield* FileSystem.FileSystem; @@ -113,7 +117,7 @@ const resolvedEvidence = Effect.fnUntraced(function* ( )).rows[0]; if ( - !reference || + reference === undefined || reference.source_revision !== snapshot.sourceRevision || reference.reference_digest !== snapshot.referenceDigest ) @@ -146,7 +150,7 @@ const resolvedEvidence = Effect.fnUntraced(function* ( ], ); - if (!personSnapshot.rowCount) + if (!hasRowCount(personSnapshot.rowCount)) return yield* new OrganizationCohortFailure({ code: "PersonSnapshotConflict" }); const people = yield* pgQuery<{ source_user_id: string; person_id: string }>( @@ -175,7 +179,12 @@ const resolvedEvidence = Effect.fnUntraced(function* ( const native = nativeDepartments.rows.find((row) => row.departmentId === mapping.departmentId); const sourceId = mapping.sourceDepartmentId.replace(/^legacy-department:/, ""); - if (!native || !/^-?\d+$/.test(sourceId) || !Number.isSafeInteger(Number(sourceId))) return []; + if ( + native === undefined || + !/^-?\d+$/.test(sourceId) || + !Number.isSafeInteger(Number(sourceId)) + ) + return []; const { departmentId: _departmentId, ...department } = native; return [{ ...department, id: Number(sourceId) }]; @@ -206,7 +215,7 @@ export const importReviewedOrganizationCohort = Effect.fn("importReviewedOrganiz [snapshotKey], )).rows[0]; - if (prior) { + if (prior !== undefined) { if (prior.snapshot_digest !== snapshot.snapshotDigest) return yield* new OrganizationCohortFailure({ code: "SnapshotConflict" }); @@ -244,7 +253,11 @@ export const importReviewedOrganizationCohort = Effect.fn("importReviewedOrganiz canonicalJson([occurrence.sourceKind, occurrence.sourceId]), ); - if (priorDigest && priorDigest !== organizationOccurrenceSourceDigest(snapshot, occurrence)) + if ( + priorDigest !== undefined && + priorDigest !== "" && + priorDigest !== organizationOccurrenceSourceDigest(snapshot, occurrence) + ) return yield* new OrganizationCohortFailure({ code: "SourceConflict" }); } @@ -287,7 +300,10 @@ export const importReviewedOrganizationCohort = Effect.fn("importReviewedOrganiz [snapshot.sourceRepository, kind, sourceId], )).rows[0]; - if (prior && (prior.source_digest !== sourceDigest || prior.target_id !== targetId)) + if ( + prior !== undefined && + (prior.source_digest !== sourceDigest || prior.target_id !== targetId) + ) return yield* new OrganizationCohortFailure({ code: "SourceConflict" }); return prior !== undefined; @@ -367,7 +383,7 @@ export const importReviewedOrganizationCohort = Effect.fn("importReviewedOrganiz ], ); - unitAvailable = !!inserted.rowCount; + unitAvailable = hasRowCount(inserted.rowCount); if (unitAvailable) yield* recordSource("Team", teamSourceId, teamDigest, team.teamId); } @@ -386,25 +402,29 @@ export const importReviewedOrganizationCohort = Effect.fn("importReviewedOrganiz [boardId, board.name], ); - unitAvailable = !!inserted.rowCount; + unitAvailable = hasRowCount(inserted.rowCount); if (unitAvailable) yield* recordSource("Board", String(board.id), boardDigest, boardId); } } if (unitAvailable && membership.teamId !== null) - unitAvailable = !!(yield* pgQuery( - tx, - "SELECT 1 FROM public.organization_teams WHERE team_id=$1 FOR SHARE", - [membership.teamId], - )).rowCount; + unitAvailable = hasRowCount( + (yield* pgQuery( + tx, + "SELECT 1 FROM public.organization_teams WHERE team_id=$1 FOR SHARE", + [membership.teamId], + )).rowCount, + ); if (unitAvailable && boardId !== null) - unitAvailable = !!(yield* pgQuery( - tx, - "SELECT 1 FROM public.organization_national_boards WHERE board_id=$1 FOR SHARE", - [boardId], - )).rowCount; + unitAvailable = hasRowCount( + (yield* pgQuery( + tx, + "SELECT 1 FROM public.organization_national_boards WHERE board_id=$1 FOR SHARE", + [boardId], + )).rowCount, + ); let inserted = false; if (unitAvailable) { @@ -426,7 +446,7 @@ export const importReviewedOrganizationCohort = Effect.fn("importReviewedOrganiz ], ); - inserted = !!result.rowCount; + inserted = hasRowCount(result.rowCount); } if (inserted) diff --git a/packages/database/src/outbox-lifecycle.test.ts b/packages/database/src/outbox-lifecycle.test.ts index d527b8d07..901d44010 100644 --- a/packages/database/src/outbox-lifecycle.test.ts +++ b/packages/database/src/outbox-lifecycle.test.ts @@ -24,9 +24,8 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( Effect.gen(function* () { const claim = { effectId: "probe-effect", claimId: "probe-worker:1" }; - const evidence = yield* Effect.gen(function* () { - const sql = yield* Database; - yield* sql` + const sql = yield* Database; + yield* sql` CREATE TABLE outbox_lifecycle_probe ( effect_id text PRIMARY KEY, status text NOT NULL, @@ -38,52 +37,51 @@ layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( payload_json jsonb NOT NULL ) `; - yield* sql` + yield* sql` INSERT INTO outbox_lifecycle_probe (effect_id, status, payload_json) VALUES (${claim.effectId}, 'Pending', '{"probe":true}'::jsonb) `; - yield* sql` + yield* sql` UPDATE outbox_lifecycle_probe AS outbox SET ${outboxClaimAssignments(sql, "outbox", claim.claimId, "2031-01-01T00:00:00.000Z")} WHERE effect_id = ${claim.effectId} `; - const recovered = yield* recoverStaleOutboxClaims( - sql, - probe, - "2031-01-01T00:01:00.000Z", - { - status: "Failed", - failureTag: "StaleClaim", - }, - ); + const recovered = yield* recoverStaleOutboxClaims( + sql, + probe, + "2031-01-01T00:01:00.000Z", + { + status: "Failed", + failureTag: "StaleClaim", + }, + ); - const lost = yield* Effect.all([ - Effect.flip( - markOutboxDelivered(sql, probe, claim, { deliveredAt: "2031-01-01T00:02:00.000Z" }), - ), - Effect.flip(markOutboxFailed(sql, probe, claim, "ProviderRejected")), - Effect.flip(quarantineOutboxClaim(sql, probe, claim, "EnvelopeMismatch")), - ]); + const lost = yield* Effect.all([ + Effect.flip( + markOutboxDelivered(sql, probe, claim, { deliveredAt: "2031-01-01T00:02:00.000Z" }), + ), + Effect.flip(markOutboxFailed(sql, probe, claim, "ProviderRejected")), + Effect.flip(quarantineOutboxClaim(sql, probe, claim, "EnvelopeMismatch")), + ]); - yield* releaseOutboxClaim(sql, probe, claim, "InterruptedClaim"); + yield* releaseOutboxClaim(sql, probe, claim, "InterruptedClaim"); - const rows = yield* sql` + const rows = yield* sql` SELECT status, attempts, claim_id AS "claimId", last_failure_tag AS "lastFailureTag", delivered_at AS "deliveredAt", payload_json AS payload FROM outbox_lifecycle_probe `; - return { - recovered, - lost: lost.map((failure) => - Predicate.isTagged(failure, "OutboxClaimLost") - ? { effectId: failure.effectId, claimId: failure.claimId } - : failure._tag, - ), - rows, - }; - }); + const evidence = { + recovered, + lost: lost.map((failure) => + Predicate.isTagged(failure, "OutboxClaimLost") + ? { effectId: failure.effectId, claimId: failure.claimId } + : failure._tag, + ), + rows, + }; expect(evidence.recovered).toBe(1); expect(evidence.lost).toEqual([claim, claim, claim]); diff --git a/packages/database/src/outbox-lifecycle.ts b/packages/database/src/outbox-lifecycle.ts index ecc260425..ebcd177c4 100644 --- a/packages/database/src/outbox-lifecycle.ts +++ b/packages/database/src/outbox-lifecycle.ts @@ -27,7 +27,8 @@ * `payload_json` with `{}`. A table with only the lifecycle columns uses `Retain` and * records no delivery evidence. SQL failures remain `SqlError` for the caller to map. */ -import { Data, Effect } from "effect"; +import { Data, Effect, Predicate } from "effect"; +import { dual } from "effect/Function"; import type { SqlError } from "effect/unstable/sql/SqlError"; import * as Statement from "effect/unstable/sql/Statement"; import type { DatabaseOperations } from "./service.js"; @@ -137,14 +138,29 @@ const recoverStale = ( * * @construct sql-lifecycle */ -export const outboxClaimAssignments = ( - sql: DatabaseOperations, - targetAlias: string, - claimId: string, - claimedAt: string, -): Statement.Fragment => - sql`status = 'Processing', claim_id = ${claimId}, claimed_at = ${claimedAt}, - attempts = ${sql(targetAlias)}.attempts + 1, last_failure_tag = NULL`; +export const outboxClaimAssignments: { + ( + targetAlias: string, + claimId: string, + claimedAt: string, + ): (sql: DatabaseOperations) => Statement.Fragment; + ( + sql: DatabaseOperations, + targetAlias: string, + claimId: string, + claimedAt: string, + ): Statement.Fragment; +} = dual( + 4, + ( + sql: DatabaseOperations, + targetAlias: string, + claimId: string, + claimedAt: string, + ): Statement.Fragment => + sql`status = 'Processing', claim_id = ${claimId}, claimed_at = ${claimedAt}, + attempts = ${sql(targetAlias)}.attempts + 1, last_failure_tag = NULL`, +); /** * Settles the claimed row as Delivered, with delivery evidence when the table records it. @@ -169,29 +185,44 @@ export const outboxClaimAssignments = ( * * @construct sql-lifecycle */ -export const markOutboxDelivered = ( - sql: DatabaseOperations, - table: OutboxTable, - claim: OutboxClaim, - evidence?: OutboxDeliveryEvidence, -): Effect.Effect => { - const payload = table.terminalPayload === "Scrub" ? scrubbedPayload : noAssignments; +export const markOutboxDelivered: { + ( + table: OutboxTable, + claim: OutboxClaim, + evidence?: OutboxDeliveryEvidence, + ): (sql: DatabaseOperations) => Effect.Effect; + ( + sql: DatabaseOperations, + table: OutboxTable, + claim: OutboxClaim, + evidence?: OutboxDeliveryEvidence, + ): Effect.Effect; +} = dual( + (args) => Predicate.isFunction(args[0]), + ( + sql: DatabaseOperations, + table: OutboxTable, + claim: OutboxClaim, + evidence?: OutboxDeliveryEvidence, + ): Effect.Effect => { + const payload = table.terminalPayload === "Scrub" ? scrubbedPayload : noAssignments; - const deliveredAt = - evidence === undefined ? noAssignments : sql`, delivered_at = ${evidence.deliveredAt}`; + const deliveredAt = + evidence === undefined ? noAssignments : sql`, delivered_at = ${evidence.deliveredAt}`; - const providerReference = - evidence?.providerReference === undefined - ? noAssignments - : sql`, provider_reference = ${evidence.providerReference}`; + const providerReference = + evidence?.providerReference === undefined + ? noAssignments + : sql`, provider_reference = ${evidence.providerReference}`; - return settleClaim( - sql, - table, - claim, - sql`status = 'Delivered', last_failure_tag = NULL${payload}${deliveredAt}${providerReference}`, - ); -}; + return settleClaim( + sql, + table, + claim, + sql`status = 'Delivered', last_failure_tag = NULL${payload}${deliveredAt}${providerReference}`, + ); + }, +); /** * Settles the claimed row as Failed with its failure tag, so a later claim retries it. @@ -213,13 +244,28 @@ export const markOutboxDelivered = ( * * @construct sql-lifecycle */ -export const markOutboxFailed = ( - sql: DatabaseOperations, - table: OutboxTable, - claim: OutboxClaim, - failureTag: string, -): Effect.Effect => - settleClaim(sql, table, claim, sql`status = 'Failed', last_failure_tag = ${failureTag}`); +export const markOutboxFailed: { + ( + table: OutboxTable, + claim: OutboxClaim, + failureTag: string, + ): (sql: DatabaseOperations) => Effect.Effect; + ( + sql: DatabaseOperations, + table: OutboxTable, + claim: OutboxClaim, + failureTag: string, + ): Effect.Effect; +} = dual( + 4, + ( + sql: DatabaseOperations, + table: OutboxTable, + claim: OutboxClaim, + failureTag: string, + ): Effect.Effect => + settleClaim(sql, table, claim, sql`status = 'Failed', last_failure_tag = ${failureTag}`), +); /** * Settles the claimed row as Quarantined, a terminal status, with its failure tag. @@ -242,21 +288,36 @@ export const markOutboxFailed = ( * * @construct sql-lifecycle */ -export const quarantineOutboxClaim = ( - sql: DatabaseOperations, - table: OutboxTable, - claim: OutboxClaim, - failureTag: string, -): Effect.Effect => { - const payload = table.terminalPayload === "Scrub" ? scrubbedPayload : noAssignments; +export const quarantineOutboxClaim: { + ( + table: OutboxTable, + claim: OutboxClaim, + failureTag: string, + ): (sql: DatabaseOperations) => Effect.Effect; + ( + sql: DatabaseOperations, + table: OutboxTable, + claim: OutboxClaim, + failureTag: string, + ): Effect.Effect; +} = dual( + 4, + ( + sql: DatabaseOperations, + table: OutboxTable, + claim: OutboxClaim, + failureTag: string, + ): Effect.Effect => { + const payload = table.terminalPayload === "Scrub" ? scrubbedPayload : noAssignments; - return settleClaim( - sql, - table, - claim, - sql`status = 'Quarantined', last_failure_tag = ${failureTag}${payload}`, - ); -}; + return settleClaim( + sql, + table, + claim, + sql`status = 'Quarantined', last_failure_tag = ${failureTag}${payload}`, + ); + }, +); /** * Returns an interrupted claim to Pending without a provider outcome; a lost claim needs none. @@ -279,18 +340,33 @@ export const quarantineOutboxClaim = ( * * @construct sql-lifecycle */ -export const releaseOutboxClaim = ( - sql: DatabaseOperations, - table: OutboxTable, - claim: OutboxClaim, - failureTag: string, -): Effect.Effect => - leaveProcessing( - sql, - table, - claim, - sql`status = 'Pending', last_failure_tag = ${failureTag}`, - ).pipe(Effect.asVoid); +export const releaseOutboxClaim: { + ( + table: OutboxTable, + claim: OutboxClaim, + failureTag: string, + ): (sql: DatabaseOperations) => Effect.Effect; + ( + sql: DatabaseOperations, + table: OutboxTable, + claim: OutboxClaim, + failureTag: string, + ): Effect.Effect; +} = dual( + 4, + ( + sql: DatabaseOperations, + table: OutboxTable, + claim: OutboxClaim, + failureTag: string, + ): Effect.Effect => + leaveProcessing( + sql, + table, + claim, + sql`status = 'Pending', last_failure_tag = ${failureTag}`, + ).pipe(Effect.asVoid), +); /** * Recovers every Processing row claimed before `claimedBefore`. @@ -314,22 +390,59 @@ export const releaseOutboxClaim = ( * * @construct sql-lifecycle */ -export const recoverStaleOutboxClaims = ( - sql: DatabaseOperations, - table: OutboxTable, - claimedBefore: string, - recovery: OutboxStaleRecovery, -): Effect.Effect => - recoverStale(sql, table, recovery, sql`claimed_at < ${claimedBefore}`); +export const recoverStaleOutboxClaims: { + ( + table: OutboxTable, + claimedBefore: string, + recovery: OutboxStaleRecovery, + ): (sql: DatabaseOperations) => Effect.Effect; + ( + sql: DatabaseOperations, + table: OutboxTable, + claimedBefore: string, + recovery: OutboxStaleRecovery, + ): Effect.Effect; +} = dual( + 4, + ( + sql: DatabaseOperations, + table: OutboxTable, + claimedBefore: string, + recovery: OutboxStaleRecovery, + ): Effect.Effect => + recoverStale(sql, table, recovery, sql`claimed_at < ${claimedBefore}`), +); /** * Recovers the rows of one claim when that claim was taken before `claimedBefore`. */ -export const recoverStaleOutboxClaim = ( - sql: DatabaseOperations, - table: OutboxTable, - claimId: string, - claimedBefore: string, - recovery: OutboxStaleRecovery, -): Effect.Effect => - recoverStale(sql, table, recovery, sql`claim_id = ${claimId} AND claimed_at < ${claimedBefore}`); +export const recoverStaleOutboxClaim: { + ( + table: OutboxTable, + claimId: string, + claimedBefore: string, + recovery: OutboxStaleRecovery, + ): (sql: DatabaseOperations) => Effect.Effect; + ( + sql: DatabaseOperations, + table: OutboxTable, + claimId: string, + claimedBefore: string, + recovery: OutboxStaleRecovery, + ): Effect.Effect; +} = dual( + 5, + ( + sql: DatabaseOperations, + table: OutboxTable, + claimId: string, + claimedBefore: string, + recovery: OutboxStaleRecovery, + ): Effect.Effect => + recoverStale( + sql, + table, + recovery, + sql`claim_id = ${claimId} AND claimed_at < ${claimedBefore}`, + ), +); diff --git a/packages/database/src/password-recovery.test.ts b/packages/database/src/password-recovery.test.ts index 82b8ab49b..715fa1dda 100644 --- a/packages/database/src/password-recovery.test.ts +++ b/packages/database/src/password-recovery.test.ts @@ -19,7 +19,7 @@ const config: AuthEngineConfig = { secureCookies: false, } as const; -const context = new IdentityRequestContext({ +const context = IdentityRequestContext.make({ requestCorrelation: "focused-recovery", sourceIp: null, userAgent: null, diff --git a/packages/database/src/password-recovery.ts b/packages/database/src/password-recovery.ts index 319c9e328..a51bc2d2e 100644 --- a/packages/database/src/password-recovery.ts +++ b/packages/database/src/password-recovery.ts @@ -13,6 +13,7 @@ import { Predicate, Schema, } from "effect"; +import { dual } from "effect/Function"; import type { Pool, PoolClient } from "pg"; import { IdentityEngineError, @@ -93,10 +94,10 @@ export interface PasswordRecoveryService { } /** One AsyncLocalStorage scope per HTTP request; never keyed by caller-controlled correlation. */ -export const makePasswordRecovery = ( - pool: Pool, - config: AuthEngineConfig, -): PasswordRecoveryService => { +export const makePasswordRecovery: { + (config: AuthEngineConfig): (pool: Pool) => PasswordRecoveryService; + (pool: Pool, config: AuthEngineConfig): PasswordRecoveryService; +} = dual(2, (pool: Pool, config: AuthEngineConfig): PasswordRecoveryService => { const local = new AsyncLocalStorage(); const callback = `${config.oauth.dashboardOrigin}/tilbakestill-passord`; @@ -109,7 +110,7 @@ export const makePasswordRecovery = ( url !== `${config.oauth.canonicalOrigin}/api/auth/reset-password/${token}?callbackURL=${encodeURIComponent(callback)}` ) { - return yield* new IdentityEngineError({ + return yield* IdentityEngineError.make({ operation: "sendResetPassword", message: "Recovery callback mismatch", }); @@ -146,7 +147,7 @@ export const makePasswordRecovery = ( const [row] = verification.rows; if (verification.rows.length !== 1 || row === undefined) { - return yield* new IdentityEngineError({ + return yield* IdentityEngineError.make({ operation: "sendResetPassword", message: "Recovery verification missing", }); @@ -186,9 +187,9 @@ export const makePasswordRecovery = ( }) => { const state = local.getStore(); - if (!state) { + if (state === undefined) { return Effect.fail( - new IdentityEngineError({ + IdentityEngineError.make({ operation: "sendResetPassword", message: "Recovery requires an owned request", }), @@ -208,7 +209,7 @@ export const makePasswordRecovery = ( }).pipe( Effect.andThen( Effect.fail( - new IdentityEngineError({ + IdentityEngineError.make({ operation: "sendResetPassword", message: "Recovery enqueue unavailable", }), @@ -223,7 +224,7 @@ export const makePasswordRecovery = ( const state = local.getStore(); return Effect.sync(() => { - if (state) state.subject = user.id; + if (state !== undefined) state.subject = user.id; }); }, handler: ( @@ -245,7 +246,7 @@ export const makePasswordRecovery = ( return Effect.tryPromise({ try: () => engineHandler(request), catch: (cause) => - new IdentityEngineError({ + IdentityEngineError.make({ operation: "handler", message: cause instanceof Error ? cause.message : "identity engine failure", }), @@ -290,13 +291,13 @@ export const makePasswordRecovery = ( return yield* reject("redirect-not-allowed"); } - if (resetting && url.search) return yield* reject("redirect-not-allowed"); + if (resetting && url.search !== "") return yield* reject("redirect-not-allowed"); // Better Auth calls sendResetPassword and onPasswordReset inside this request scope. const response = yield* Effect.tryPromise({ try: () => local.run(state, () => engineHandler(request)), catch: (cause) => - new IdentityEngineError({ + IdentityEngineError.make({ operation: "handler", message: cause instanceof Error ? cause.message : "identity engine failure", }), @@ -375,172 +376,195 @@ export const makePasswordRecovery = ( ); }, }; -}; +}); /** One bounded attempt. SKIP LOCKED and claim fencing allow independent operators safely. */ -export const drainPasswordResetMail = ( - pool: Pool, - config: Pick, - mail: MailOperations, - sender: string, -): Effect.Effect<"Empty" | "Delivered" | "Failed" | "Quarantined" | "LostClaim"> => - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const claim = randomUUID(); +export const drainPasswordResetMail: { + ( + config: Pick, + mail: MailOperations, + sender: string, + ): (pool: Pool) => Effect.Effect<"Empty" | "Delivered" | "Failed" | "Quarantined" | "LostClaim">; + ( + pool: Pool, + config: Pick, + mail: MailOperations, + sender: string, + ): Effect.Effect<"Empty" | "Delivered" | "Failed" | "Quarantined" | "LostClaim">; +} = dual( + 4, + ( + pool: Pool, + config: Pick, + mail: MailOperations, + sender: string, + ): Effect.Effect<"Empty" | "Delivered" | "Failed" | "Quarantined" | "LostClaim"> => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const claim = randomUUID(); + + const row = yield* pgTransaction(pool, (client) => + Effect.gen(function* () { + yield* pgQuery( + client, + `UPDATE auth.password_reset_email_outbox SET status='Quarantined',claim_id=NULL,claimed_at=NULL,last_failure_code='stale-claim' WHERE status='Processing' AND claimed_at - Effect.gen(function* () { - yield* pgQuery( - client, - `UPDATE auth.password_reset_email_outbox SET status='Quarantined',claim_id=NULL,claimed_at=NULL,last_failure_code='stale-claim' WHERE status='Processing' AND claimed_at( + client, + `WITH next AS (SELECT effect_id FROM auth.password_reset_email_outbox WHERE status='Pending' OR (status='Failed' AND attempts<3) ORDER BY created_at,effect_id FOR UPDATE SKIP LOCKED LIMIT 1) UPDATE auth.password_reset_email_outbox o SET status='Processing',claim_id=$1,claimed_at=date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'),attempts=attempts+1 FROM next WHERE o.effect_id=next.effect_id RETURNING o.*`, + [claim], + )).rows[0]; + }), + ).pipe(Effect.orDie); - return (yield* pgQuery<{ - effect_id: string; - verification_id: string; - subject_person_id: string; - attempts: number; - payload_sha256: string | null; - }>( - client, - `WITH next AS (SELECT effect_id FROM auth.password_reset_email_outbox WHERE status='Pending' OR (status='Failed' AND attempts<3) ORDER BY created_at,effect_id FOR UPDATE SKIP LOCKED LIMIT 1) UPDATE auth.password_reset_email_outbox o SET status='Processing',claim_id=$1,claimed_at=date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'),attempts=attempts+1 FROM next WHERE o.effect_id=next.effect_id RETURNING o.*`, - [claim], - )).rows[0]; - }), - ).pipe(Effect.orDie); - - if (!row) return "Empty"; - - const verification = (yield* pgQuery<{ - identifier: string; - value: string; - expiresAt: Date; - email: string | null; - }>( - pool, - `SELECT v.identifier,v.value,v."expiresAt",u.email FROM auth.verification v LEFT JOIN auth."user" u ON u.id=$2 AND NOT u.access_disabled WHERE v.id=$1`, - [row.verification_id, row.subject_person_id], - ).pipe(Effect.orDie)).rows[0]; - - let failure: - | "verification-invalid" - | "verification-expired" - | "authority-mismatch" - | "provider-rejected" - | "provider-unavailable" - | "delivery-timeout" - | null = null; - - let providerReference: string | null = null; - let interruptedCause: Cause.Cause | undefined; - let quarantined = false; - - if (!verification || !/^reset-password:[A-Za-z0-9_-]+$/.test(verification.identifier)) { - failure = "verification-invalid"; - } else if (verification.value !== row.subject_person_id || !verification.email) { - failure = "authority-mismatch"; - } else if (verification.expiresAt.getTime() <= (yield* Clock.currentTimeMillis)) { - failure = "verification-expired"; - } + if (row === undefined) return "Empty"; - quarantined = failure !== null; - - if (!failure && verification) { - const token = verification.identifier.slice("reset-password:".length); - - const request = { - deliveryId: row.effect_id, - sender, - recipient: verification.email!, - subject: "Tilbakestill passordet ditt", - text: [ - "Det ble bedt om et nytt passord for Vektorprogrammet-kontoen din.", - "", - "Bruk denne lenken for å velge et nytt passord:", - `${config.oauth.canonicalOrigin}/api/auth/reset-password/${token}?callbackURL=${encodeURIComponent(`${config.oauth.dashboardOrigin}/tilbakestill-passord`)}`, - `Lenken utløper ${verification.expiresAt.toISOString()}.`, - "", - "Hvis du ikke ba om dette, kan du se bort fra e-posten.", - ].join("\n"), - }; - - const fingerprint = createHash("sha256") - .update(yield* encodeMailRequestJson(request).pipe(Effect.orDie)) - .digest("hex"); - - const frozen = yield* pgQuery( + const verification = (yield* pgQuery<{ + identifier: string; + value: string; + expiresAt: Date; + email: string | null; + }>( pool, - "UPDATE auth.password_reset_email_outbox SET payload_sha256=COALESCE(payload_sha256,$3) WHERE effect_id=$1 AND claim_id=$2 AND status='Processing' RETURNING payload_sha256", - [row.effect_id, claim, fingerprint], - ).pipe(Effect.orDie); - - if (frozen.rowCount !== 1) return "LostClaim"; + `SELECT v.identifier,v.value,v."expiresAt",u.email FROM auth.verification v LEFT JOIN auth."user" u ON u.id=$2 AND NOT u.access_disabled WHERE v.id=$1`, + [row.verification_id, row.subject_person_id], + ).pipe(Effect.orDie)).rows[0]; + + let failure: + | "verification-invalid" + | "verification-expired" + | "authority-mismatch" + | "provider-rejected" + | "provider-unavailable" + | "delivery-timeout" + | null = null; + + let providerReference: string | null = null; + let interruptedCause: Cause.Cause | undefined; + let quarantined = false; - if (frozen.rows[0]?.payload_sha256 !== fingerprint) { + if ( + verification === undefined || + !/^reset-password:[A-Za-z0-9_-]+$/.test(verification.identifier) + ) { failure = "verification-invalid"; - quarantined = true; - } else { - const deliveryExit = yield* Effect.exit(restore(Effect.result(mail.deliver(request)))); - - if (Exit.isFailure(deliveryExit)) { - // Interruption is ambiguous. Persist quarantine before the owning fiber releases the pool. - if (!Cause.hasInterruptsOnly(deliveryExit.cause)) - return yield* Effect.failCause(deliveryExit.cause); - interruptedCause = deliveryExit.cause; - failure = "delivery-timeout"; + } else if ( + verification.value !== row.subject_person_id || + verification.email === null || + verification.email === "" + ) { + failure = "authority-mismatch"; + } else if (verification.expiresAt.getTime() <= (yield* Clock.currentTimeMillis)) { + failure = "verification-expired"; + } + + quarantined = failure !== null; + + if (failure === null && verification !== undefined) { + const token = verification.identifier.slice("reset-password:".length); + + const request = { + deliveryId: row.effect_id, + sender, + recipient: verification.email!, + subject: "Tilbakestill passordet ditt", + text: [ + "Det ble bedt om et nytt passord for Vektorprogrammet-kontoen din.", + "", + "Bruk denne lenken for å velge et nytt passord:", + `${config.oauth.canonicalOrigin}/api/auth/reset-password/${token}?callbackURL=${encodeURIComponent(`${config.oauth.dashboardOrigin}/tilbakestill-passord`)}`, + `Lenken utløper ${verification.expiresAt.toISOString()}.`, + "", + "Hvis du ikke ba om dette, kan du se bort fra e-posten.", + ].join("\n"), + }; + + const fingerprint = createHash("sha256") + .update(yield* encodeMailRequestJson(request).pipe(Effect.orDie)) + .digest("hex"); + + const frozen = yield* pgQuery( + pool, + "UPDATE auth.password_reset_email_outbox SET payload_sha256=COALESCE(payload_sha256,$3) WHERE effect_id=$1 AND claim_id=$2 AND status='Processing' RETURNING payload_sha256", + [row.effect_id, claim, fingerprint], + ).pipe(Effect.orDie); + + if (frozen.rowCount !== 1) return "LostClaim"; + + if (frozen.rows[0]?.payload_sha256 !== fingerprint) { + failure = "verification-invalid"; quarantined = true; } else { - const result = deliveryExit.value; - - if (Predicate.isTagged(result, "Failure")) { - failure = Match.value(result.failure.kind).pipe( - Match.when("permanent-rejection", () => "provider-rejected" as const), - Match.when("ambiguous-outcome", () => "delivery-timeout" as const), - Match.orElse(() => "provider-unavailable" as const), - ); - quarantined = result.failure.kind !== "temporary-unavailability" || row.attempts >= 3; + const deliveryExit = yield* Effect.exit(restore(Effect.result(mail.deliver(request)))); + + if (Exit.isFailure(deliveryExit)) { + // Interruption is ambiguous. Persist quarantine before the owning fiber releases the pool. + if (!Cause.hasInterruptsOnly(deliveryExit.cause)) + return yield* Effect.failCause(deliveryExit.cause); + interruptedCause = deliveryExit.cause; + failure = "delivery-timeout"; + quarantined = true; } else { - providerReference = result.success.providerReference; + const result = deliveryExit.value; + + if (Predicate.isTagged(result, "Failure")) { + failure = Match.value(result.failure.kind).pipe( + Match.when("permanent-rejection", () => "provider-rejected" as const), + Match.when("ambiguous-outcome", () => "delivery-timeout" as const), + Match.orElse(() => "provider-unavailable" as const), + ); + quarantined = + result.failure.kind !== "temporary-unavailability" || row.attempts >= 3; + } else { + providerReference = result.success.providerReference; + } } } } - } - const outcome = yield* pgTransaction(pool, (client) => - Effect.gen(function* () { - const updated = yield* pgQuery( - client, - `UPDATE auth.password_reset_email_outbox SET status=$3,claim_id=NULL,claimed_at=NULL,delivered_at=CASE WHEN $3='Delivered' THEN date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC') ELSE NULL END,last_failure_code=$4,provider_reference=$5 WHERE effect_id=$1 AND claim_id=$2 AND status='Processing'`, - [ - row.effect_id, - claim, - failure === null ? "Delivered" : quarantined ? "Quarantined" : "Failed", - failure, - providerReference, - ], - ); + const outcome = yield* pgTransaction(pool, (client) => + Effect.gen(function* () { + const updated = yield* pgQuery( + client, + `UPDATE auth.password_reset_email_outbox SET status=$3,claim_id=NULL,claimed_at=NULL,delivered_at=CASE WHEN $3='Delivered' THEN date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC') ELSE NULL END,last_failure_code=$4,provider_reference=$5 WHERE effect_id=$1 AND claim_id=$2 AND status='Processing'`, + [ + row.effect_id, + claim, + failure === null ? "Delivered" : quarantined ? "Quarantined" : "Failed", + failure, + providerReference, + ], + ); - if (updated.rowCount !== 1) return "LostClaim" as const; - yield* audit( - client, - failure === null ? "password-reset-mail-delivered" : "password-reset-mail-failed", - failure ?? "provider-acknowledged", - row.subject_person_id, - null, - ); + if (updated.rowCount !== 1) return "LostClaim" as const; + yield* audit( + client, + failure === null ? "password-reset-mail-delivered" : "password-reset-mail-failed", + failure ?? "provider-acknowledged", + row.subject_person_id, + null, + ); - return failure === null ? "Delivered" : quarantined ? "Quarantined" : "Failed"; - }), - ).pipe(Effect.orDie); + return failure === null ? "Delivered" : quarantined ? "Quarantined" : "Failed"; + }), + ).pipe(Effect.orDie); - if (interruptedCause) return yield* Effect.failCause(interruptedCause); + if (interruptedCause !== undefined) return yield* Effect.failCause(interruptedCause); - return outcome; - }), - ); + return outcome; + }), + ), +); export class PasswordRecovery extends Context.Service()( - "@vektorprogrammet/database/PasswordRecovery", + "@vektorprogrammet/database/password-recovery/PasswordRecovery", ) {} export const PasswordRecoveryLive = (config: AuthEngineConfig) => diff --git a/packages/database/src/person-cohort.ts b/packages/database/src/person-cohort.ts index 5fb45f6a2..c56b2ed6e 100644 --- a/packages/database/src/person-cohort.ts +++ b/packages/database/src/person-cohort.ts @@ -229,7 +229,7 @@ const appendAcceptedPersonMapping = Effect.fnUntraced(function* ( values, ); - if (inserted.rowCount) return; + if ((inserted.rowCount ?? 0) !== 0) return; const existing = yield* pgQuery( tx, @@ -241,7 +241,8 @@ const appendAcceptedPersonMapping = Effect.fnUntraced(function* ( values, ); - if (!existing.rowCount) return yield* new PersonCohortFailure({ code: "SourceIdentityConflict" }); + if ((existing.rowCount ?? 0) === 0) + return yield* new PersonCohortFailure({ code: "SourceIdentityConflict" }); }); /** Person/profile writes and source evidence share the caller transaction when supplied. */ @@ -302,7 +303,7 @@ export const importPersonCohort = Effect.fn("importPersonCohort")(function* ( [snapshotKey], ); - if (prior.rows[0]) { + if (prior.rows[0] !== undefined) { if (prior.rows[0].snapshot_digest !== snapshotDigest) return yield* new PersonCohortFailure({ code: "SnapshotConflict" }); const result = yield* cohortReport(tx, snapshotKey, true); @@ -317,9 +318,9 @@ export const importPersonCohort = Effect.fn("importPersonCohort")(function* ( for (const occurrence of decoded) { if (!acceptedOccurrences.has(occurrence.occurrenceId)) continue; const row = occurrence.value; - const mappings = row ? mappingsBySource.get(row.sourceUserId) : undefined; + const mappings = row !== undefined ? mappingsBySource.get(row.sourceUserId) : undefined; - if (!row || mappings?.length !== 1) + if (row === undefined || mappings?.length !== 1) return yield* new PersonCohortFailure({ code: "SourceIdentityConflict" }); const mapping = mappings[0]!; yield* appendAcceptedPersonMapping(tx, { @@ -362,15 +363,23 @@ export const importPersonCohort = Effect.fn("importPersonCohort")(function* ( for (const occurrence of decoded) { const sourceUserId = sourceIdOf(occurrence.row); - const previousDigest = sourceUserId ? importedDigests.get(sourceUserId) : undefined; + + const previousDigest = + sourceUserId !== undefined && sourceUserId !== "" + ? importedDigests.get(sourceUserId) + : undefined; if (previousDigest !== undefined) { - const mappings = sourceUserId ? (mappingsBySource.get(sourceUserId) ?? []) : []; + const mappings = + sourceUserId !== undefined && sourceUserId !== "" + ? (mappingsBySource.get(sourceUserId) ?? []) + : []; + const mapping = mappings.length === 1 ? mappings[0] : undefined; if ( - !occurrence.value || - !mapping || + occurrence.value === undefined || + mapping === undefined || previousDigest !== digest({ row: occurrence.value, mapping }) ) return yield* new PersonCohortFailure({ code: "SourceIdentityConflict" }); @@ -396,12 +405,12 @@ export const importPersonCohort = Effect.fn("importPersonCohort")(function* ( for (const occurrence of decoded) { const row = occurrence.value; - const mappings = row ? (mappingsBySource.get(row.sourceUserId) ?? []) : []; + const mappings = row !== undefined ? (mappingsBySource.get(row.sourceUserId) ?? []) : []; const mapping = mappings.length === 1 ? mappings[0] : undefined; let reason: PersonCohortReason; let sourceDigest: string | undefined; - if (!row) reason = "InvalidRow"; + if (row === undefined) reason = "InvalidRow"; else if (!row.active) reason = "Inactive"; else if ((sourceCounts.get(row.sourceUserId) ?? 0) > 1) reason = "DuplicateSource"; else if ((emailCounts.get(row.email.toLowerCase()) ?? 0) > 1) reason = "DuplicateEmail"; @@ -422,7 +431,7 @@ export const importPersonCohort = Effect.fn("importPersonCohort")(function* ( [mapping!.personId], ); - if (target.rowCount) reason = "TargetConflict"; + if ((target.rowCount ?? 0) !== 0) reason = "TargetConflict"; else { const emailOwner = yield* pgQuery( tx, @@ -430,7 +439,7 @@ export const importPersonCohort = Effect.fn("importPersonCohort")(function* ( [row.email.toLowerCase()], ); - reason = emailOwner.rowCount ? "EmailConflict" : "CreatedPerson"; + reason = (emailOwner.rowCount ?? 0) !== 0 ? "EmailConflict" : "CreatedPerson"; } } else { const existing = (yield* pgQuery<{ @@ -447,7 +456,7 @@ export const importPersonCohort = Effect.fn("importPersonCohort")(function* ( [mapping!.personId], )).rows[0]; - if (!existing) reason = "PersonMissing"; + if (existing === undefined) reason = "PersonMissing"; else if ( existing.name_revision !== mapping!.expectedNameRevision || existing.contact_revision !== mapping!.expectedContactRevision @@ -470,9 +479,10 @@ export const importPersonCohort = Effect.fn("importPersonCohort")(function* ( if ( (reason === "CreatedPerson" || reason === "LinkedExistingPerson") && - row && - mapping && - sourceDigest + row !== undefined && + mapping !== undefined && + sourceDigest !== undefined && + sourceDigest !== "" ) { if (reason === "CreatedPerson") { yield* pgQuery( @@ -508,7 +518,13 @@ export const importPersonCohort = Effect.fn("importPersonCohort")(function* ( ); } - if (accepted && row && mapping && sourceDigest) + if ( + accepted && + row !== undefined && + mapping !== undefined && + sourceDigest !== undefined && + sourceDigest !== "" + ) yield* appendAcceptedPersonMapping(tx, { snapshotKey, occurrenceId: occurrence.occurrenceId, diff --git a/packages/database/src/pg-pool.ts b/packages/database/src/pg-pool.ts index a9bffb8d3..9308c7f03 100644 --- a/packages/database/src/pg-pool.ts +++ b/packages/database/src/pg-pool.ts @@ -1,10 +1,11 @@ import * as PgClient from "@effect/sql-pg/PgClient"; -import { Context, Data, Duration, Effect, Layer, Redacted } from "effect"; +import { Context, Data, Duration, Effect, Layer, Predicate, Redacted } from "effect"; +import { dual } from "effect/Function"; import { Pool, type PoolClient, type PoolConfig, type QueryResult, type QueryResultRow } from "pg"; /** The one native PostgreSQL pool shared by Database and Better Auth. */ export class DatabasePgPool extends Context.Service()( - "@vektorprogrammet/database/DatabasePgPool", + "@vektorprogrammet/database/pg-pool/DatabasePgPool", ) {} /** A node-postgres connection or statement failure, carrying the driver's message. */ @@ -20,39 +21,74 @@ const pgFailure = (cause: unknown): PgQueryError => }); /** Runs one parameterized statement on a node-postgres pool or on a client it lent. */ -export const pgQuery = ( - database: Pool | PoolClient, - text: string, - values: ReadonlyArray = [], -): Effect.Effect, PgQueryError> => - Effect.tryPromise({ try: () => database.query(text, [...values]), catch: pgFailure }); +export const pgQuery: { + ( + text: string, + values?: ReadonlyArray, + ): (database: Pool | PoolClient) => Effect.Effect, PgQueryError>; + ( + database: Pool | PoolClient, + text: string, + values?: ReadonlyArray, + ): Effect.Effect, PgQueryError>; +} = dual( + (args) => !Predicate.isString(args[0]), + ( + database: Pool | PoolClient, + text: string, + values: ReadonlyArray = [], + ): Effect.Effect, PgQueryError> => + Effect.tryPromise({ try: () => database.query(text, [...values]), catch: pgFailure }), +); /** Runs `use` on one client lent by `pool`; the client returns to the pool when `use` ends. */ -export const pgWithClient = ( - pool: Pool, - use: (client: PoolClient) => Effect.Effect, -): Effect.Effect => - Effect.acquireUseRelease( - Effect.tryPromise({ try: () => pool.connect(), catch: pgFailure }), - use, - (client) => Effect.sync(() => client.release()), - ); +export const pgWithClient: { + ( + use: (client: PoolClient) => Effect.Effect, + ): (pool: Pool) => Effect.Effect; + ( + pool: Pool, + use: (client: PoolClient) => Effect.Effect, + ): Effect.Effect; +} = dual( + 2, + ( + pool: Pool, + use: (client: PoolClient) => Effect.Effect, + ): Effect.Effect => + Effect.acquireUseRelease( + Effect.tryPromise({ try: () => pool.connect(), catch: pgFailure }), + use, + (client) => Effect.sync(() => client.release()), + ), +); /** * Runs `use` in one transaction on a client lent by `pool`: BEGIN, then COMMIT when `use` * succeeds, or ROLLBACK when it fails or is interrupted. The client returns to the pool either way. */ -export const pgTransaction = ( - pool: Pool, - use: (client: PoolClient) => Effect.Effect, -): Effect.Effect => - pgWithClient(pool, (client) => - pgQuery(client, "BEGIN").pipe( - Effect.andThen(use(client)), - Effect.tap(() => pgQuery(client, "COMMIT")), - Effect.onError(() => Effect.ignore(pgQuery(client, "ROLLBACK"))), +export const pgTransaction: { + ( + use: (client: PoolClient) => Effect.Effect, + ): (pool: Pool) => Effect.Effect; + ( + pool: Pool, + use: (client: PoolClient) => Effect.Effect, + ): Effect.Effect; +} = dual( + 2, + ( + pool: Pool, + use: (client: PoolClient) => Effect.Effect, + ): Effect.Effect => + pgWithClient(pool, (client) => + pgQuery(client, "BEGIN").pipe( + Effect.andThen(use(client)), + Effect.tap(() => pgQuery(client, "COMMIT")), + Effect.onError(() => Effect.ignore(pgQuery(client, "ROLLBACK"))), + ), ), - ); +); const makeSharedPgPool = (config: Parameters[0]) => Effect.acquireRelease( diff --git a/packages/database/src/placements/certificates.test.ts b/packages/database/src/placements/certificates.test.ts index 75b154b54..38783917e 100644 --- a/packages/database/src/placements/certificates.test.ts +++ b/packages/database/src/placements/certificates.test.ts @@ -103,11 +103,17 @@ const confirm = ( return yield* transaction( Placements.use((placements) => - placements.confirmDaysServed( - reader, - { commandId: nextCommandId(), departmentId, semesterId, personId, total }, - () => Effect.void, - ), + placements + .authorizeDaysServedConfirmation(reader, { departmentId, semesterId }) + .pipe( + Effect.flatMap((authorization) => + placements.confirmDaysServed( + authorization, + { commandId: nextCommandId(), personId, total }, + () => Effect.void, + ), + ), + ), ), ); }); @@ -118,11 +124,17 @@ const issue = (actor: PersonId, departmentId: DepartmentId, personId: PersonId) return yield* transaction( Placements.use((placements) => - placements.issueCertificate( - reader, - { commandId: nextCommandId(), departmentId, personId }, - () => Effect.void, - ), + placements + .authorizeCertificateIssue(reader, departmentId, personId) + .pipe( + Effect.flatMap((authorization) => + placements.issueCertificate( + authorization, + { commandId: nextCommandId() }, + () => Effect.void, + ), + ), + ), ), ); }); @@ -537,20 +549,23 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "60 seconds" })( const rejected = yield* Effect.flip( transaction( Placements.use((placements) => - placements.confirmDaysServed( - reader, - { - commandId: nextCommandId(), + placements + .authorizeDaysServedConfirmation(reader, { departmentId: trondheim, semesterId: autumn, - personId: person.bo, - total: 5, - }, - (current) => - daysServedEntryVersion(current) === stale - ? Effect.void - : Effect.fail("precondition.failed" as const), - ), + }) + .pipe( + Effect.flatMap((authorization) => + placements.confirmDaysServed( + authorization, + { commandId: nextCommandId(), personId: person.bo, total: 5 }, + (current) => + daysServedEntryVersion(current) === stale + ? Effect.void + : Effect.fail("precondition.failed" as const), + ), + ), + ), ), ), ); @@ -561,7 +576,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "60 seconds" })( // Without the delegated capability a team leader confirms nothing. const denied = yield* Effect.flip(confirm(person.itLeader, autumn, person.bo, 1)); - expect(denied).toEqual(new CertificateAccessDenied({ reason: "NotInScope" })); + expect(denied).toEqual(CertificateAccessDenied.make({ reason: "NotInScope" })); expect(yield* confirmationRows(person.bo)).toHaveLength(2); }), ); @@ -580,7 +595,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "60 seconds" })( { semesterId: autumn, status: "Unconfirmed" }, ]); expect(yield* Effect.flip(issue(person.styretLeader, trondheim, person.ada))).toEqual( - new CertificateEmpty(), + CertificateEmpty.make({}), ); // Spring as calculated, autumn adjusted above its count of one; Ås confirms its own. @@ -771,7 +786,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "60 seconds" })( expect(stored).toEqual([{ teamId: "cert-event", boardId: null }]); expect((yield* rosters(person.eventLeader)).boards).toEqual([]); expect(yield* Effect.flip(readDaysServed(person.eventLeader, autumn))).toEqual( - new CertificateAccessDenied({ reason: "NotInScope" }), + CertificateAccessDenied.make({ reason: "NotInScope" }), ); const { authorizationInstant } = yield* principal(person.eventLeader); @@ -800,7 +815,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "60 seconds" })( expect(after.map((seat) => seat.personId)).not.toContain(person.eventLeader); expect(yield* Effect.flip(issue(person.eventLeader, trondheim, person.cato))).toEqual( - new CertificateAccessDenied({ reason: "NotInScope" }), + CertificateAccessDenied.make({ reason: "NotInScope" }), ); }), ); @@ -809,15 +824,15 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "60 seconds" })( Effect.gen(function* () { // An assistant without a seat is no issuer. expect(yield* Effect.flip(readCertificate(person.ada, trondheim, person.ada))).toEqual( - new CertificateAccessDenied({ reason: "NotInScope" }), + CertificateAccessDenied.make({ reason: "NotInScope" }), ); // An issuing seat does not reach the holder's own certificate. expect( yield* Effect.flip(readCertificate(person.styretMember, trondheim, person.styretMember)), - ).toEqual(new CertificateAccessDenied({ reason: "OwnCertificate" })); + ).toEqual(CertificateAccessDenied.make({ reason: "OwnCertificate" })); expect( yield* Effect.flip(issue(person.styretMember, trondheim, person.styretMember)), - ).toEqual(new CertificateAccessDenied({ reason: "OwnCertificate" })); + ).toEqual(CertificateAccessDenied.make({ reason: "OwnCertificate" })); expect(yield* issueRows(person.styretMember)).toEqual([]); // The table refuses a self-issue that bypasses the service. diff --git a/packages/database/src/placements/certificates.ts b/packages/database/src/placements/certificates.ts index 36b11b0e1..805f502d5 100644 --- a/packages/database/src/placements/certificates.ts +++ b/packages/database/src/placements/certificates.ts @@ -4,6 +4,7 @@ * placement, a commitment, a decision, an occurrence, or legacy history. */ import { Effect, Option, Predicate, Schema } from "effect"; +import { dual } from "effect/Function"; import { SqlSchema } from "effect/unstable/sql"; import { isSqlError } from "effect/unstable/sql/SqlError"; import { certificateIssuerBasis, reaches, ReachTarget } from "@vektorprogrammet/domain/authz"; @@ -21,7 +22,8 @@ import { CertificateAccessDenied, CertificateAssistant, CertificateAssistantNotFound, - CertificateCommandTarget, + type CertificateIssueAuthorization, + type DaysServedConfirmationAuthorization, certificateContent, CertificateContent, certificateContentSha256, @@ -70,7 +72,7 @@ const conflict = (cause: unknown, depth: number): boolean => const persistenceFailure = (operation: string) => (cause: unknown): CertificatePersistenceError => - new CertificatePersistenceError({ operation, conflict: conflict(cause, 0), cause }); + CertificatePersistenceError.make({ operation, conflict: conflict(cause, 0), cause }); const SemesterRow = Schema.Struct({ semesterId: SemesterId, @@ -451,14 +453,14 @@ const requireDaysServedAuthority = ( ) => reaches(authority, "placements.days-served", ReachTarget.Department({ departmentId })) ? Effect.void - : Effect.fail(new CertificateAccessDenied({ reason: "NotInScope" })); + : Effect.fail(CertificateAccessDenied.make({ reason: "NotInScope" })); const requireDepartment = (departmentId: DepartmentId, lock: boolean) => findDepartment({ departmentId, lock }).pipe( Effect.mapError(persistenceFailure("read certificate department")), Effect.flatMap( Option.match({ - onNone: () => Effect.fail(new CertificateScopeNotFound()), + onNone: () => Effect.fail(CertificateScopeNotFound.make({})), onSome: Effect.succeed, }), ), @@ -469,7 +471,7 @@ const requireSemester = (semesterId: SemesterId) => Effect.mapError(persistenceFailure("read certificate semester")), Effect.flatMap(([semester]) => semester === undefined - ? Effect.fail(new CertificateScopeNotFound()) + ? Effect.fail(CertificateScopeNotFound.make({})) : Effect.succeed(semester), ), ); @@ -501,13 +503,13 @@ const requireIssuer = ( command ? "ForShare" : "None", ).pipe(Effect.mapError(persistenceFailure("read governed department"))); - if (department === undefined) return yield* new CertificateScopeNotFound(); + if (department === undefined) return yield* CertificateScopeNotFound.make({}); const issuer = yield* certificateIssuerWithSql(sql, authority, department).pipe( Effect.mapError(persistenceFailure("read certificate issuer")), ); - if (Option.isNone(issuer)) return yield* new CertificateAccessDenied({ reason: "NotInScope" }); + if (Option.isNone(issuer)) return yield* CertificateAccessDenied.make({ reason: "NotInScope" }); return issuer.value; }); @@ -526,7 +528,7 @@ const buildPreview = ( personId, }).pipe(Effect.mapError(persistenceFailure("read certificate assistant"))); - if (assistant === undefined) return yield* new CertificateAssistantNotFound({ personId }); + if (assistant === undefined) return yield* CertificateAssistantNotFound.make({ personId }); const services = [ ...(yield* readServices(department.departmentId, null, [personId]).pipe( @@ -614,7 +616,7 @@ export const readCertificateScopes = (principal: CertificatePrincipal) => }); if (departments.length === 0) - return yield* new CertificateAccessDenied({ reason: "NotInScope" }); + return yield* CertificateAccessDenied.make({ reason: "NotInScope" }); const semesters = yield* findSemesters(null).pipe( Effect.mapError(persistenceFailure("read certificate semesters")), @@ -630,8 +632,7 @@ export const readCertificateScopes = (principal: CertificatePrincipal) => }).pipe(Effect.mapError(persistenceFailure("build certificate scopes"))); }); -/** One page of the assistants of a department and semester with their days served. */ -export const readDaysServed = ( +const readDaysServedImpl = ( principal: CertificatePrincipal, scope: PlacementScope, cursor?: string, @@ -671,6 +672,19 @@ export const readDaysServed = ( }; }); +/** One page of the assistants of a department and semester with their days served. */ +export const readDaysServed: { + ( + scope: PlacementScope, + cursor?: string, + ): (principal: CertificatePrincipal) => ReturnType; + ( + principal: CertificatePrincipal, + scope: PlacementScope, + cursor?: string, + ): ReturnType; +} = dual((args) => Predicate.isObject(args[1]), readDaysServedImpl); + /** Current days-served authority in the department, under the department lock of every command. */ const authorizeConfirmation = ( sql: DatabaseOperations, @@ -699,48 +713,86 @@ const authorizeIssue = ( const issuer = yield* requireIssuer(sql, authority, departmentId, true); if (personId === principal.personId) - return yield* new CertificateAccessDenied({ reason: "OwnCertificate" }); + return yield* CertificateAccessDenied.make({ reason: "OwnCertificate" }); return { department, issuer }; }); +const authorizeDaysServedConfirmationImpl = ( + principal: CertificatePrincipal, + scope: PlacementScope, +) => + Effect.gen(function* () { + const sql = yield* Database; + yield* authorizeConfirmation(sql, principal, scope); + + // SAFETY: the one constructor of the evidence brand; authorizeConfirmation above is what it proves. + return { + principal, + departmentId: scope.departmentId, + semesterId: scope.semesterId, + } as DaysServedConfirmationAuthorization; + }); + /** - * Resolves the principal's current authority for one command on the caller's transaction, with - * the locks that the command takes, so a stored response replays only to a current holder. + * Resolves the principal's current days-served authority in the scope on the caller's transaction, + * under the department lock, so a stored response replays only to a current holder. */ -export const authorizeCertificateCommand = ( +export const authorizeDaysServedConfirmation: { + ( + scope: PlacementScope, + ): (principal: CertificatePrincipal) => ReturnType; + ( + principal: CertificatePrincipal, + scope: PlacementScope, + ): ReturnType; +} = dual(2, authorizeDaysServedConfirmationImpl); + +const authorizeCertificateIssueImpl = ( principal: CertificatePrincipal, - target: CertificateCommandTarget, + departmentId: DepartmentId, + personId: PersonId, ) => Effect.gen(function* () { const sql = yield* Database; + const { department, issuer } = yield* authorizeIssue(sql, principal, departmentId, personId); - yield* CertificateCommandTarget.$match(target, { - ConfirmDaysServed: (scope) => authorizeConfirmation(sql, principal, scope), - IssueCertificate: ({ departmentId, personId }) => - Effect.asVoid(authorizeIssue(sql, principal, departmentId, personId)), - }); + // SAFETY: the one constructor of the evidence brand; authorizeIssue above is what it proves. + return { principal, department, personId, issuer } as CertificateIssueAuthorization; }); /** - * Appends the next confirmation of one assistant's total under the department lock. The - * precondition sees the fresh entry; the earlier confirmations and the service facts stay. + * Resolves whether the principal currently issues the department's certificates for another + * person, on the caller's transaction and under the department lock, so a stored response replays + * only to a current issuer. */ -export const confirmDaysServed = ( - principal: CertificatePrincipal, - command: ConfirmDaysServedCommand, +export const authorizeCertificateIssue: { + ( + departmentId: DepartmentId, + personId: PersonId, + ): (principal: CertificatePrincipal) => ReturnType; + ( + principal: CertificatePrincipal, + departmentId: DepartmentId, + personId: PersonId, + ): ReturnType; +} = dual(3, authorizeCertificateIssueImpl); + +const confirmDaysServedImpl = ( + authorization: DaysServedConfirmationAuthorization, + input: Omit, checkPrecondition: (current: DaysServedEntry) => Effect.Effect, ) => Effect.gen(function* () { const sql = yield* Database; - const scope = { departmentId: command.departmentId, semesterId: command.semesterId }; - - yield* authorizeConfirmation(sql, principal, scope); + const { principal, departmentId, semesterId } = authorization; + const command = { ...input, departmentId, semesterId }; + const scope = { departmentId, semesterId }; const current = yield* readEntry(scope, command.personId); if (Option.isNone(current)) - return yield* new CertificateAssistantNotFound({ personId: command.personId }); + return yield* CertificateAssistantNotFound.make({ personId: command.personId }); yield* checkPrecondition(current.value); @@ -764,13 +816,30 @@ export const confirmDaysServed = ( const confirmed = yield* readEntry(scope, command.personId); if (Option.isNone(confirmed)) - return yield* new CertificateAssistantNotFound({ personId: command.personId }); + return yield* CertificateAssistantNotFound.make({ personId: command.personId }); return confirmed.value; }); -/** One page of the assistants with service facts in a department, for its issuers. */ -export const listCertificates = ( +/** + * Appends the next confirmation of one assistant's total under the department lock. The + * precondition sees the fresh entry; the earlier confirmations and the service facts stay. + */ +export const confirmDaysServed: { + ( + input: Omit, + checkPrecondition: (current: DaysServedEntry) => Effect.Effect, + ): ( + authorization: DaysServedConfirmationAuthorization, + ) => ReturnType>; + ( + authorization: DaysServedConfirmationAuthorization, + input: Omit, + checkPrecondition: (current: DaysServedEntry) => Effect.Effect, + ): ReturnType>; +} = dual(3, confirmDaysServedImpl); + +const listCertificatesImpl = ( principal: CertificatePrincipal, departmentId: DepartmentId, cursor?: string, @@ -823,8 +892,20 @@ export const listCertificates = ( return { ...page, items, departmentName: department.name }; }); -/** The certificate that the principal would issue now, and the semesters it leaves out. */ -export const readCertificate = ( +/** One page of the assistants with service facts in a department, for its issuers. */ +export const listCertificates: { + ( + departmentId: DepartmentId, + cursor?: string, + ): (principal: CertificatePrincipal) => ReturnType; + ( + principal: CertificatePrincipal, + departmentId: DepartmentId, + cursor?: string, + ): ReturnType; +} = dual((args) => Predicate.isObject(args[0]), listCertificatesImpl); + +const readCertificateImpl = ( principal: CertificatePrincipal, departmentId: DepartmentId, personId: PersonId, @@ -836,36 +917,40 @@ export const readCertificate = ( const issuer = yield* requireIssuer(sql, authority, departmentId, false); if (personId === principal.personId) - return yield* new CertificateAccessDenied({ reason: "OwnCertificate" }); + return yield* CertificateAccessDenied.make({ reason: "OwnCertificate" }); return yield* buildPreview(department, personId, issuer); }); -/** - * Records one issue of the current certificate under the department lock: who issued it, when, - * under which seat, and the hash of the content. The precondition sees the fresh preview. - */ -export const issueCertificate = ( - principal: CertificatePrincipal, - command: IssueCertificateCommand, +/** The certificate that the principal would issue now, and the semesters it leaves out. */ +export const readCertificate: { + ( + departmentId: DepartmentId, + personId: PersonId, + ): (principal: CertificatePrincipal) => ReturnType; + ( + principal: CertificatePrincipal, + departmentId: DepartmentId, + personId: PersonId, + ): ReturnType; +} = dual(3, readCertificateImpl); + +const issueCertificateImpl = ( + authorization: CertificateIssueAuthorization, + input: Pick, checkPrecondition: (current: CertificatePreview) => Effect.Effect, ) => Effect.gen(function* () { const sql = yield* Database; - - const { department, issuer } = yield* authorizeIssue( - sql, - principal, - command.departmentId, - command.personId, - ); + const { principal, department, personId, issuer } = authorization; + const command = { ...input, departmentId: department.departmentId, personId }; const preview = yield* buildPreview(department, command.personId, issuer); yield* checkPrecondition(preview); if (preview.content === null || preview.contentSha256 === null) - return yield* new CertificateEmpty(); + return yield* CertificateEmpty.make({}); const content = yield* Schema.encodeEffect(CertificateContent)(preview.content).pipe( Effect.mapError(persistenceFailure("encode certificate content")), @@ -895,3 +980,21 @@ export const issueCertificate = ( issuer, }).pipe(Effect.mapError(persistenceFailure("decode certificate issue"))); }); + +/** + * Records one issue of the current certificate under the department lock: who issued it, when, + * under which seat, and the hash of the content. The precondition sees the fresh preview. + */ +export const issueCertificate: { + ( + input: Pick, + checkPrecondition: (current: CertificatePreview) => Effect.Effect, + ): ( + authorization: CertificateIssueAuthorization, + ) => ReturnType>; + ( + authorization: CertificateIssueAuthorization, + input: Pick, + checkPrecondition: (current: CertificatePreview) => Effect.Effect, + ): ReturnType>; +} = dual(3, issueCertificateImpl); diff --git a/packages/database/src/placements/coverage.ts b/packages/database/src/placements/coverage.ts index badaada24..e3047402e 100644 --- a/packages/database/src/placements/coverage.ts +++ b/packages/database/src/placements/coverage.ts @@ -1,4 +1,5 @@ -import { Match, Effect, Schema } from "effect"; +import { Match, Effect, Schema, Predicate } from "effect"; +import { dual } from "effect/Function"; import type * as Statement from "effect/unstable/sql/Statement"; import { Database, type DatabaseOperations } from "../service.js"; import { @@ -25,7 +26,7 @@ const fail = (code: PlacementFailure["code"], status: PlacementFailure["status"] const decode = (schema: Schema.ConstraintDecoder) => Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }); -export const readSchoolServiceCommitments = ( +const readSchoolServiceCommitmentsImpl = ( sql: DatabaseOperations, scope: PlacementScope, personId?: PersonId, @@ -73,6 +74,18 @@ export const readSchoolServiceCommitments = ( return yield* decode(Schema.Array(SchoolServiceCommitment))(rows); }); +export const readSchoolServiceCommitments: { + ( + scope: PlacementScope, + personId?: PersonId, + ): (sql: DatabaseOperations) => ReturnType; + ( + sql: DatabaseOperations, + scope: PlacementScope, + personId?: PersonId, + ): ReturnType; +} = dual((args) => Predicate.isObject(args[1]), readSchoolServiceCommitmentsImpl); + const ensureCoverageScope = (sql: DatabaseOperations, scope: PlacementScope) => Effect.gen(function* () { const rows = yield* sql`SELECT 1 FROM public.organization_departments AS department @@ -264,7 +277,7 @@ const writeAudit = ( ) => sql`INSERT INTO public.school_service_coverage_audit(department_id,semester_id,actor_person_id,action,occurred_at,snapshot) VALUES(${scope.departmentId},${scope.semesterId},${actor},${action},${now},${sql.json(snapshot)})`; -export const readOwnCoverage = (scope: PlacementScope, personId: PersonId) => +const readOwnCoverageImpl = (scope: PlacementScope, personId: PersonId) => Database.use((sql) => Effect.gen(function* () { yield* ensureCoverageScope(sql, scope); @@ -343,6 +356,11 @@ export const readOwnCoverage = (scope: PlacementScope, personId: PersonId) => }), ); +export const readOwnCoverage: { + (personId: PersonId): (scope: PlacementScope) => ReturnType; + (scope: PlacementScope, personId: PersonId): ReturnType; +} = dual(2, readOwnCoverageImpl); + export const readCoverageBoard = (scope: PlacementScope) => Database.use((sql) => Effect.gen(function* () { @@ -494,7 +512,7 @@ const withdrawCoverage = ( }); }); -export const mutateOwnCoverage = ( +const mutateOwnCoverageImpl = ( scope: PlacementScope, command: OwnCoverageCommand, actor: PersonId, @@ -526,6 +544,22 @@ export const mutateOwnCoverage = ( }), ); +export const mutateOwnCoverage: { + ( + command: OwnCoverageCommand, + actor: PersonId, + now: string, + ids: { readonly absenceId: string; readonly coverageId: string }, + ): (scope: PlacementScope) => ReturnType; + ( + scope: PlacementScope, + command: OwnCoverageCommand, + actor: PersonId, + now: string, + ids: { readonly absenceId: string; readonly coverageId: string }, + ): ReturnType; +} = dual(5, mutateOwnCoverageImpl); + const decideService = ( sql: DatabaseOperations, scope: PlacementScope, @@ -625,7 +659,7 @@ const decideService = ( }); }); -export const mutateCoverageBoard = ( +const mutateCoverageBoardImpl = ( scope: PlacementScope, command: CoverageCommand, actor: PersonId, @@ -658,3 +692,27 @@ export const mutateCoverageBoard = ( return yield* readCoverageBoard(scope); }), ); + +export const mutateCoverageBoard: { + ( + command: CoverageCommand, + actor: PersonId, + now: string, + ids: { + readonly absenceId: string; + readonly coverageId: string; + readonly occurrenceId: string; + }, + ): (scope: PlacementScope) => ReturnType; + ( + scope: PlacementScope, + command: CoverageCommand, + actor: PersonId, + now: string, + ids: { + readonly absenceId: string; + readonly coverageId: string; + readonly occurrenceId: string; + }, + ): ReturnType; +} = dual(5, mutateCoverageBoardImpl); diff --git a/packages/database/src/placements/current-assignment-cohort.ts b/packages/database/src/placements/current-assignment-cohort.ts index 3d8ee94f1..f1a3637f8 100644 --- a/packages/database/src/placements/current-assignment-cohort.ts +++ b/packages/database/src/placements/current-assignment-cohort.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { Data, Effect, FileSystem, flow, Option, Path, Schema } from "effect"; +import { dual } from "effect/Function"; import type { Pool, PoolClient } from "pg"; import { canonicalJson } from "@vektorprogrammet/domain/shared-kernel"; import { DepartmentId, SemesterId } from "@vektorprogrammet/domain/organization"; @@ -12,6 +13,10 @@ import { } from "@vektorprogrammet/domain/placements"; import { pgQuery, pgTransaction } from "../pg-pool.js"; +/** The truthiness test of a `rowCount`: present, not zero, and not NaN. */ +const hasRowCount = (rowCount: number | null): boolean => + rowCount !== null && rowCount !== 0 && !Number.isNaN(rowCount); + export const currentAssignmentImportSourceDigest = Effect.fn("currentAssignmentImportSourceDigest")( function* () { const fs = yield* FileSystem.FileSystem; @@ -122,11 +127,14 @@ const declaredSnapshotDigest = (snapshot: AssignmentSnapshot): string => { return digest(unsignedSnapshot); }; -export const currentAssignmentPlacementId = ( - sourceRepository: string, - sourceAssignmentId: string, -): string => - `placement-${digest(["current-assignment-placement", sourceRepository, sourceAssignmentId])}`; +export const currentAssignmentPlacementId: { + (sourceAssignmentId: string): (sourceRepository: string) => string; + (sourceRepository: string, sourceAssignmentId: string): string; +} = dual( + 2, + (sourceRepository: string, sourceAssignmentId: string): string => + `placement-${digest(["current-assignment-placement", sourceRepository, sourceAssignmentId])}`, +); const sourceIdOf = flow( Schema.decodeUnknownOption(Schema.Struct({ sourceAssignmentId: Schema.String })), @@ -213,7 +221,7 @@ export const decodeReconciledCurrentAssignmentSnapshot = flow( if ( sourceIds.has(row.sourceAssignmentId) || - !entry || + entry === undefined || row.sourceSemesterId !== review.sourceSemesterId || row.active !== entry.active || row.affiliationEvidenceRef !== entry.affiliationEvidenceRef || @@ -290,7 +298,8 @@ const validateReconciledProvenance = Effect.fnUntraced(function* ( [snapshot.sourceRepository, snapshot.snapshotId], )).rows[0]; - if (!evidence) return yield* new CurrentAssignmentFailure({ code: "ReferenceProvenanceMissing" }); + if (evidence === undefined) + return yield* new CurrentAssignmentFailure({ code: "ReferenceProvenanceMissing" }); if ( evidence.source_revision !== snapshot.sourceRevision || @@ -334,7 +343,8 @@ const validateReconciledProvenance = Effect.fnUntraced(function* ( semesters.size !== references.semesters.length || schools.size !== references.schools.length || relationships.size !== references.relationships.length || - !semesterId || + semesterId === undefined || + semesterId === "" || references.relationships.some( ({ sourceDepartmentId, sourceSchoolId, departmentId, schoolId }) => departments.get(sourceDepartmentId) !== departmentId || @@ -358,7 +368,8 @@ const validateReconciledProvenance = Effect.fnUntraced(function* ( [semesterId, snapshot.review.asOf], ); - if (!semester.rowCount) return yield* new CurrentAssignmentFailure({ code: "InvalidSnapshot" }); + if (!hasRowCount(semester.rowCount)) + return yield* new CurrentAssignmentFailure({ code: "InvalidSnapshot" }); const personSnapshot = yield* pgQuery( tx, @@ -373,7 +384,7 @@ const validateReconciledProvenance = Effect.fnUntraced(function* ( ], ); - if (!personSnapshot.rowCount) + if (!hasRowCount(personSnapshot.rowCount)) return yield* new CurrentAssignmentFailure({ code: "PersonSnapshotConflict" }); return relationships; @@ -453,14 +464,14 @@ const importAssignmentCohort = Effect.fnUntraced(function* ( [snapshotKey], ); - if (prior.rows[0] && prior.rows[0].snapshot_digest !== snapshotDigest) + if (prior.rows[0] !== undefined && prior.rows[0].snapshot_digest !== snapshotDigest) return yield* new CurrentAssignmentFailure({ code: "SnapshotConflict" }); const sourceRelationships = snapshot.synthetic ? undefined : yield* validateReconciledProvenance(tx, snapshot); - if (prior.rows[0]) { + if (prior.rows[0] !== undefined) { if (!snapshot.synthetic) { const review = yield* pgQuery( tx, @@ -477,7 +488,7 @@ const importAssignmentCohort = Effect.fnUntraced(function* ( ], ); - if (!review.rowCount) + if (!hasRowCount(review.rowCount)) return yield* new CurrentAssignmentFailure({ code: "SnapshotConflict" }); } @@ -487,13 +498,13 @@ const importAssignmentCohort = Effect.fnUntraced(function* ( for (const occurrence of decoded) { const row = occurrence.value; - if (!row?.active || !rowDigestMatches(row)) continue; + if (row?.active !== true || !rowDigestMatches(row)) continue; const mappings = mappingsBySource.get(row.sourceAssignmentId) ?? []; if (mappings.length !== 1 || !referencesMatch(row, mappings[0]!)) continue; if ( - sourceRelationships && + sourceRelationships !== undefined && !sourceRelationships.has(canonicalJson([row.sourceDepartmentId, row.sourceSchoolId])) ) continue; @@ -527,17 +538,23 @@ const importAssignmentCohort = Effect.fnUntraced(function* ( for (const occurrence of decoded) { const sourceAssignmentId = sourceIdOf(occurrence.row); - const previousDigest = sourceAssignmentId - ? importedBySource.get(sourceAssignmentId) - : undefined; + const previousDigest = + sourceAssignmentId !== undefined && sourceAssignmentId !== "" + ? importedBySource.get(sourceAssignmentId) + : undefined; if (previousDigest === undefined) continue; - const mappings = sourceAssignmentId ? (mappingsBySource.get(sourceAssignmentId) ?? []) : []; + + const mappings = + sourceAssignmentId !== undefined && sourceAssignmentId !== "" + ? (mappingsBySource.get(sourceAssignmentId) ?? []) + : []; + const mapping = mappings.length === 1 ? mappings[0] : undefined; if ( - !occurrence.value || - !mapping || + occurrence.value === undefined || + mapping === undefined || !referencesMatch(occurrence.value, mapping) || !rowDigestMatches(occurrence.value) || previousDigest !== assignmentSourceDigest(snapshot, occurrence.value, mapping) @@ -581,21 +598,24 @@ const importAssignmentCohort = Effect.fnUntraced(function* ( for (const occurrence of decoded) { const row = occurrence.value; - const mappings = row ? (mappingsBySource.get(row.sourceAssignmentId) ?? []) : []; + + const mappings = + row !== undefined ? (mappingsBySource.get(row.sourceAssignmentId) ?? []) : []; + const mapping = mappings.length === 1 ? mappings[0] : undefined; let reason: CurrentAssignmentReason; let sourceDigest: string | undefined; let placementId: string | undefined; let createAffiliation = false; - if (!row || !rowDigestMatches(row)) reason = "InvalidRow"; + if (row === undefined || !rowDigestMatches(row)) reason = "InvalidRow"; else if (!row.active) reason = "Inactive"; else if ((sourceCounts.get(row.sourceAssignmentId) ?? 0) > 1) reason = "DuplicateSource"; else if (mappings.length === 0) reason = "MappingMissing"; else if (mappings.length > 1) reason = "MappingAmbiguous"; else if (!referencesMatch(row, mapping!)) reason = "SourceReferenceMismatch"; else if ( - sourceRelationships && + sourceRelationships !== undefined && !sourceRelationships.has(canonicalJson([row.sourceDepartmentId, row.sourceSchoolId])) ) reason = "SchoolDepartmentMismatch"; @@ -624,7 +644,7 @@ const importAssignmentCohort = Effect.fnUntraced(function* ( ], ); - if (!personEvidence.rowCount) reason = "PersonReconciliationMissing"; + if (!hasRowCount(personEvidence.rowCount)) reason = "PersonReconciliationMissing"; else { const references = (yield* pgQuery<{ department_exists: boolean; @@ -693,7 +713,7 @@ const importAssignmentCohort = Effect.fnUntraced(function* ( ], ); - if (affiliation) { + if (affiliation !== undefined) { if ( affiliation.status !== "Active" || affiliation.revision !== 1 || @@ -701,12 +721,15 @@ const importAssignmentCohort = Effect.fnUntraced(function* ( affiliationProvenance.rows[0]!.source_repository !== snapshot.sourceRepository ) reason = "TargetConflict"; - else if (deterministicPlacement.rowCount) reason = "TargetConflict"; - else if (overlappingPlacement.rowCount) reason = "PlacementOverlap"; + else if (hasRowCount(deterministicPlacement.rowCount)) reason = "TargetConflict"; + else if (hasRowCount(overlappingPlacement.rowCount)) reason = "PlacementOverlap"; else reason = "Imported"; - } else if (affiliationProvenance.rowCount || deterministicPlacement.rowCount) { + } else if ( + hasRowCount(affiliationProvenance.rowCount) || + hasRowCount(deterministicPlacement.rowCount) + ) { reason = "TargetConflict"; - } else if (overlappingPlacement.rowCount) reason = "PlacementOverlap"; + } else if (hasRowCount(overlappingPlacement.rowCount)) reason = "PlacementOverlap"; else { createAffiliation = true; reason = "Imported"; @@ -725,7 +748,15 @@ const importAssignmentCohort = Effect.fnUntraced(function* ( [snapshotKey, occurrence.occurrenceId, accepted ? "Accepted" : "Quarantined", reason], ); - if (reason === "Imported" && row && mapping && sourceDigest && placementId) { + if ( + reason === "Imported" && + row !== undefined && + mapping !== undefined && + sourceDigest !== undefined && + sourceDigest !== "" && + placementId !== undefined && + placementId !== "" + ) { if (createAffiliation) yield* pgQuery( tx, diff --git a/packages/database/src/placements/outbox.ts b/packages/database/src/placements/outbox.ts index 960a4431a..656bf3d73 100644 --- a/packages/database/src/placements/outbox.ts +++ b/packages/database/src/placements/outbox.ts @@ -6,6 +6,7 @@ import { } from "@vektorprogrammet/domain/placements"; import { canonicalJson } from "@vektorprogrammet/domain/shared-kernel"; import { flow, Data, DateTime, Predicate, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { Database, type DatabaseOperations } from "../service.js"; import { markOutboxDelivered, @@ -207,7 +208,7 @@ const claimInTransaction = (sql: DatabaseOperations, claimId: string, claimedAt: }; }); -export const claimNextSchoolServiceNotification = (claimId: string, claimedAt: string) => +const claimNextSchoolServiceNotificationImpl = (claimId: string, claimedAt: string) => Database.use((sql) => sql .withTransaction(claimInTransaction(sql, claimId, claimedAt)) @@ -218,6 +219,13 @@ export const claimNextSchoolServiceNotification = (claimId: string, claimedAt: s ), ); +export const claimNextSchoolServiceNotification: { + ( + claimedAt: string, + ): (claimId: string) => ReturnType; + (claimId: string, claimedAt: string): ReturnType; +} = dual(2, claimNextSchoolServiceNotificationImpl); + export const recoverStaleSchoolServiceNotifications = (claimedBefore: string) => Database.use((sql) => recoverStaleOutboxClaims(sql, notificationOutbox, claimedBefore, { @@ -230,61 +238,84 @@ export const recoverStaleSchoolServiceNotifications = (claimedBefore: string) => ), ); -export const deliverNextSchoolServiceNotification = ( - claimId: string, - claimedAt: string, - interpreter: SchoolServiceNotificationInterpreter, -): Effect.Effect< - SchoolServiceNotificationDeliveryResult, - SchoolServiceNotificationOutboxError, - Database -> => - Effect.gen(function* () { - const selected = yield* claimNextSchoolServiceNotification(claimId, claimedAt); - - if (selected === undefined) return SchoolServiceNotificationDeliveryResult.Idle(); - - if (Predicate.isTagged(selected, "Quarantined")) { - return SchoolServiceNotificationDeliveryResult.Quarantined({ - effectId: selected.effectId, - failureTag: selected.failureTag, - }); - } - - const claim = selected.claim; - - return yield* interpreter(claim.request).pipe( - Effect.matchEffect({ - onFailure: (failure) => - Database.use((sql) => - markOutboxFailed(sql, notificationOutbox, claim, failure._tag), - ).pipe( - Effect.as( - SchoolServiceNotificationDeliveryResult.Failed({ claim, failureTag: failure._tag }), - ), - Effect.catchTag("SqlError", (cause) => - Effect.fail(outboxError("fail school service notification", cause)), - ), - ), - // Record when the provider acknowledged, not when the claim was taken. - onSuccess: () => - DateTime.now.pipe( - Effect.flatMap((acknowledgedAt) => - Database.use((sql) => - markOutboxDelivered(sql, notificationOutbox, claim, { - deliveredAt: DateTime.formatIso(acknowledgedAt), - }), +export const deliverNextSchoolServiceNotification: { + ( + claimedAt: string, + interpreter: SchoolServiceNotificationInterpreter, + ): ( + claimId: string, + ) => Effect.Effect< + SchoolServiceNotificationDeliveryResult, + SchoolServiceNotificationOutboxError, + Database + >; + ( + claimId: string, + claimedAt: string, + interpreter: SchoolServiceNotificationInterpreter, + ): Effect.Effect< + SchoolServiceNotificationDeliveryResult, + SchoolServiceNotificationOutboxError, + Database + >; +} = dual( + 3, + ( + claimId: string, + claimedAt: string, + interpreter: SchoolServiceNotificationInterpreter, + ): Effect.Effect< + SchoolServiceNotificationDeliveryResult, + SchoolServiceNotificationOutboxError, + Database + > => + Effect.gen(function* () { + const selected = yield* claimNextSchoolServiceNotification(claimId, claimedAt); + + if (selected === undefined) return SchoolServiceNotificationDeliveryResult.Idle(); + + if (Predicate.isTagged(selected, "Quarantined")) { + return SchoolServiceNotificationDeliveryResult.Quarantined({ + effectId: selected.effectId, + failureTag: selected.failureTag, + }); + } + + const claim = selected.claim; + + return yield* interpreter(claim.request).pipe( + Effect.matchEffect({ + onFailure: (failure) => + Database.use((sql) => + markOutboxFailed(sql, notificationOutbox, claim, failure._tag), + ).pipe( + Effect.as( + SchoolServiceNotificationDeliveryResult.Failed({ claim, failureTag: failure._tag }), + ), + Effect.catchTag("SqlError", (cause) => + Effect.fail(outboxError("fail school service notification", cause)), ), ), - Effect.as(SchoolServiceNotificationDeliveryResult.Delivered({ claim })), - Effect.catchTag("SqlError", (cause) => - Effect.fail(outboxError("deliver school service notification", cause)), + // Record when the provider acknowledged, not when the claim was taken. + onSuccess: () => + DateTime.now.pipe( + Effect.flatMap((acknowledgedAt) => + Database.use((sql) => + markOutboxDelivered(sql, notificationOutbox, claim, { + deliveredAt: DateTime.formatIso(acknowledgedAt), + }), + ), + ), + Effect.as(SchoolServiceNotificationDeliveryResult.Delivered({ claim })), + Effect.catchTag("SqlError", (cause) => + Effect.fail(outboxError("deliver school service notification", cause)), + ), ), - ), - }), - ); - }).pipe( - Effect.catchTag("OutboxClaimLost", ({ effectId }) => - Effect.succeed(SchoolServiceNotificationDeliveryResult.ClaimLost({ effectId })), + }), + ); + }).pipe( + Effect.catchTag("OutboxClaimLost", ({ effectId }) => + Effect.succeed(SchoolServiceNotificationDeliveryResult.ClaimLost({ effectId })), + ), ), - ); +); diff --git a/packages/database/src/placements/postgres.ts b/packages/database/src/placements/postgres.ts index 0ccef8ec2..055f1a956 100644 --- a/packages/database/src/placements/postgres.ts +++ b/packages/database/src/placements/postgres.ts @@ -1,4 +1,5 @@ import { Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { readSchoolServiceCommitments } from "./coverage.js"; import { Database, type DatabaseOperations } from "../service.js"; import type { OrganizationPersonAuthority } from "@vektorprogrammet/domain/organization"; @@ -90,17 +91,17 @@ export const lockPlacementDepartment = (departmentId: DepartmentId) => const rows = yield* sql`SELECT department_id FROM public.organization_departments WHERE department_id=${departmentId} FOR UPDATE`; - if (!rows.length) return yield* fail("scope.invalid"); + if (rows.length === 0) return yield* fail("scope.invalid"); }), ); -export const readOwnAffiliation = (personId: PersonId, departmentId: DepartmentId) => +const readOwnAffiliationImpl = (personId: PersonId, departmentId: DepartmentId) => Database.use((sql) => Effect.gen(function* () { const departments = yield* sql`SELECT department_id FROM public.organization_departments WHERE department_id=${departmentId}`; - if (!departments.length) return yield* fail("scope.invalid"); + if (departments.length === 0) return yield* fail("scope.invalid"); const rows = yield* sql`SELECT status,revision FROM public.organization_volunteer_affiliations WHERE person_id=${personId} AND department_id=${departmentId}`; @@ -113,7 +114,12 @@ export const readOwnAffiliation = (personId: PersonId, departmentId: DepartmentI }), ); -export const mutateAffiliation = ( +export const readOwnAffiliation: { + (departmentId: DepartmentId): (personId: PersonId) => ReturnType; + (personId: PersonId, departmentId: DepartmentId): ReturnType; +} = dual(2, readOwnAffiliationImpl); + +const mutateAffiliationImpl = ( current: Affiliation, action: OwnAffiliationCommand["action"] | "Establish" | "Reject" | "Revoke", actor: PersonId, @@ -132,13 +138,27 @@ export const mutateAffiliation = ( }), ); +export const mutateAffiliation: { + ( + action: OwnAffiliationCommand["action"] | "Establish" | "Reject" | "Revoke", + actor: PersonId, + now: string, + ): (current: Affiliation) => ReturnType; + ( + current: Affiliation, + action: OwnAffiliationCommand["action"] | "Establish" | "Reject" | "Revoke", + actor: PersonId, + now: string, + ): ReturnType; +} = dual(4, mutateAffiliationImpl); + export const readPlacementBoard = (scope: PlacementScope) => Database.use((sql) => Effect.gen(function* () { const semesters = yield* sql`SELECT semester_id FROM public.admission_period_semesters WHERE semester_id=${scope.semesterId}`; - if (!semesters.length) return yield* fail("scope.invalid"); + if (semesters.length === 0) return yield* fail("scope.invalid"); const affiliations = yield* sql`SELECT a.person_id AS "personId",a.department_id AS "departmentId",a.status,a.revision,p.first_name AS "firstName",p.last_name AS "lastName" FROM public.organization_volunteer_affiliations a JOIN public.person_profiles p USING(person_id) WHERE a.department_id=${scope.departmentId} ORDER BY p.last_name,p.first_name,a.person_id`; @@ -184,8 +204,7 @@ export const readPlacementBoard = (scope: PlacementScope) => }), ); -/** Caller holds the department lock and HTTP receipt transaction. */ -export const mutatePlacementBoard = ( +const mutatePlacementBoardImpl = ( scope: PlacementScope, command: PlacementCommand, actor: PersonId, @@ -380,9 +399,10 @@ export const mutatePlacementBoard = ( ? undefined : board.placements.find((placement) => placement.placementId === command.placementId); - if (command.action !== "Create" && !existing) return yield* fail("resource.not-found", 404); + if (command.action !== "Create" && existing === undefined) + return yield* fail("resource.not-found", 404); - if (existing && !existing.active) return yield* fail("placement.inactive"); + if (existing !== undefined && !existing.active) return yield* fail("placement.inactive"); const personId = command.action === "Create" ? command.personId : existing!.personId; const placementId = command.action === "Create" ? newId : existing!.placementId; const revision = (existing?.revision ?? 0) + 1; @@ -399,7 +419,7 @@ export const mutatePlacementBoard = ( const overlaps = yield* sql`SELECT placement_id FROM public.assistant_placements WHERE active AND person_id=${personId} AND school_id=${command.schoolId} AND semester_id=${scope.semesterId} AND placement_id<>${placementId} AND block=${command.block}`; - if (overlaps.length) return yield* fail("placement.overlap", 409); + if (overlaps.length > 0) return yield* fail("placement.overlap", 409); yield* sql`INSERT INTO public.assistant_placements(placement_id,person_id,department_id,semester_id,school_id,day,workdays,block,active,revision) VALUES(${placementId},${personId},${scope.departmentId},${scope.semesterId},${command.schoolId},${command.day},${command.workdays},${command.block},true,${revision}) ON CONFLICT(placement_id) DO UPDATE SET school_id=EXCLUDED.school_id,day=EXCLUDED.day,workdays=EXCLUDED.workdays,block=EXCLUDED.block,revision=EXCLUDED.revision`; } else { yield* sql`UPDATE public.assistant_placements SET active=false,revision=${revision} WHERE placement_id=${placementId}`; @@ -410,3 +430,20 @@ export const mutatePlacementBoard = ( return yield* readPlacementBoard(scope); }), ); + +/** Caller holds the department lock and HTTP receipt transaction. */ +export const mutatePlacementBoard: { + ( + command: PlacementCommand, + actor: PersonId, + now: string, + newId: string, + ): (scope: PlacementScope) => ReturnType; + ( + scope: PlacementScope, + command: PlacementCommand, + actor: PersonId, + now: string, + newId: string, + ): ReturnType; +} = dual(5, mutatePlacementBoardImpl); diff --git a/packages/database/src/placements/service.test.ts b/packages/database/src/placements/service.test.ts index 25e186930..e774aaddd 100644 --- a/packages/database/src/placements/service.test.ts +++ b/packages/database/src/placements/service.test.ts @@ -1,3 +1,4 @@ +import { administratorDepartmentReach } from "@vektorprogrammet/domain/organization/authority-fixtures"; import { expect, layer } from "@effect/vitest"; import { Database } from "../service.js"; import { DatabaseTestLive } from "../test-support/platform.js"; @@ -20,9 +21,24 @@ const scope = { const personId = PersonId.make("command-volunteer"); +/** Self modes act as the volunteer; board and coverage modes as a coordinator of the scope. */ const command = (mutation: PlacementMutation, commandId = "a".repeat(64)) => Placements.use((placements) => - placements.execute({ mutation, actor: personId, now, commandId }, () => Effect.void), + placements.execute( + mutation.mode === "board" || mutation.mode === "coverage" + ? { + mutation, + coordinator: administratorDepartmentReach( + personId, + "placements.coordinate", + mutation.scope.departmentId, + ), + now, + commandId, + } + : { mutation, actor: personId, now, commandId }, + () => Effect.void, + ), ); const seed = Database.use((sql) => diff --git a/packages/database/src/placements/service.ts b/packages/database/src/placements/service.ts index 68ad1f4a5..f8f959b01 100644 --- a/packages/database/src/placements/service.ts +++ b/packages/database/src/placements/service.ts @@ -23,7 +23,8 @@ import { } from "./coverage.js"; import { readPlacementDraft } from "./draft.js"; import { - authorizeCertificateCommand, + authorizeCertificateIssue, + authorizeDaysServedConfirmation, confirmDaysServed, issueCertificate, listCertificates, @@ -89,7 +90,18 @@ export const PlacementsLive = Layer.effect( checkPrecondition: (current: PlacementSnapshot) => Effect.Effect, ) => Effect.gen(function* () { - const { mutation, actor, now, commandId } = input; + const { mutation, now, commandId } = input; + + // A coordinator acts only in the department its evidence names. + if ( + "coordinator" in input && + input.coordinator.departmentId !== mutation.scope.departmentId + ) + return yield* Effect.die( + new Error("placement coordinator evidence names another department"), + ); + + const actor = "coordinator" in input ? input.coordinator.personId : input.actor; yield* run(lockPlacementDepartment(mutation.scope.departmentId)); return yield* Match.value(mutation).pipe( @@ -158,12 +170,12 @@ export const PlacementsLive = Layer.effect( readCertificateScopes(principal).pipe(Effect.provideService(Database, database)), readDaysServed: (principal, scope, cursor) => readDaysServed(principal, scope, cursor).pipe(Effect.provideService(Database, database)), - authorizeCertificateCommand: (principal, target) => - authorizeCertificateCommand(principal, target).pipe( + authorizeDaysServedConfirmation: (principal, scope) => + authorizeDaysServedConfirmation(principal, scope).pipe( Effect.provideService(Database, database), ), - confirmDaysServed: (principal, command, checkPrecondition) => - confirmDaysServed(principal, command, checkPrecondition).pipe( + confirmDaysServed: (authorization, command, checkPrecondition) => + confirmDaysServed(authorization, command, checkPrecondition).pipe( Effect.provideService(Database, database), ), listCertificates: (principal, departmentId, cursor) => @@ -174,8 +186,12 @@ export const PlacementsLive = Layer.effect( readCertificate(principal, departmentId, personId).pipe( Effect.provideService(Database, database), ), - issueCertificate: (principal, command, checkPrecondition) => - issueCertificate(principal, command, checkPrecondition).pipe( + authorizeCertificateIssue: (principal, departmentId, personId) => + authorizeCertificateIssue(principal, departmentId, personId).pipe( + Effect.provideService(Database, database), + ), + issueCertificate: (authorization, command, checkPrecondition) => + issueCertificate(authorization, command, checkPrecondition).pipe( Effect.provideService(Database, database), ), execute, diff --git a/packages/database/src/profile/postgres.test.ts b/packages/database/src/profile/postgres.test.ts index 35c108670..7984e7a8b 100644 --- a/packages/database/src/profile/postgres.test.ts +++ b/packages/database/src/profile/postgres.test.ts @@ -16,19 +16,17 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })((it) => const missing = PersonId.make("person-directory-missing-contact"); const present = PersonId.make("person-directory-with-contact"); - const failure = yield* Effect.gen(function* () { - const sql = yield* Database; - yield* sql` + const sql = yield* Database; + yield* sql` INSERT INTO person_profiles (person_id, first_name, last_name) VALUES (${missing}, 'Ann', 'Aardvark'), (${present}, 'Bob', 'Zebra') `; - yield* sql` + yield* sql` INSERT INTO person_contact_profiles (person_id, email, phone) VALUES (${present}, 'bob@example.invalid', '+4700000001') `; - return yield* Effect.flip(readDirectoryPage({ limit: 10 })); - }); + const failure = yield* Effect.flip(readDirectoryPage({ limit: 10 })); expect(failure._tag).toBe("ProfileContactNotFound"); @@ -47,23 +45,21 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })((it) => Effect.gen(function* () { const personId = PersonId.make("person-profile-http-source"); - const source = yield* Effect.gen(function* () { - const sql = yield* Database; - yield* sql` + const sql = yield* Database; + yield* sql` INSERT INTO person_profiles (person_id, first_name, last_name, revision) VALUES (${personId}, 'Ada', 'Lovelace', 4) `; - yield* sql` + yield* sql` INSERT INTO person_contact_profiles (person_id, email, phone, revision) VALUES (${personId}, 'ada@example.invalid', '+4712345678', 6) `; - yield* sql` + yield* sql` UPDATE profile_http_versions SET representation_revision = 9 WHERE person_id = ${personId} `; - return yield* readOwnProfileHttpSourcePostgres(personId); - }); + const source = yield* readOwnProfileHttpSourcePostgres(personId); expect(source.representationRevision).toBe(9); expect(source.profile).toEqual({ diff --git a/packages/database/src/profile/postgres.ts b/packages/database/src/profile/postgres.ts index 30f0eb4e8..3b1360b45 100644 --- a/packages/database/src/profile/postgres.ts +++ b/packages/database/src/profile/postgres.ts @@ -40,7 +40,7 @@ import type { UpdateOwnProfileInput } from "@vektorprogrammet/domain/profile"; export const PROFILE_READ_LIMIT = 100; const persistenceError = (operation: string, cause?: unknown): ProfilePersistenceError => - new ProfilePersistenceError({ + ProfilePersistenceError.make({ operation, message: cause instanceof Error ? cause.message : "profile persistence failed", }); @@ -51,7 +51,7 @@ const decodeProfile = ( Schema.decodeEffect(PersonProfile)(row, { onExcessProperty: "error" }).pipe( Effect.mapError( (cause) => - new ProfileDecodeError({ + ProfileDecodeError.make({ message: cause instanceof Error ? cause.message : "invalid person profile row", }), ), @@ -63,7 +63,7 @@ const decodeContact = ( Schema.decodeEffect(PersonContactProfile)(row, { onExcessProperty: "error" }).pipe( Effect.mapError( (cause) => - new ProfileDecodeError({ + ProfileDecodeError.make({ message: cause instanceof Error ? cause.message : "invalid person contact row", }), ), @@ -116,7 +116,7 @@ export const readPersonProfiles = ( yield* Organization; if (personIds.length > PROFILE_READ_LIMIT) { - return yield* new ProfileQueryLimitExceeded({ limit: PROFILE_READ_LIMIT }); + return yield* ProfileQueryLimitExceeded.make({ limit: PROFILE_READ_LIMIT }); } const sql = yield* Database; @@ -134,7 +134,7 @@ export const readPersonProfiles = ( for (const personId of uniqueIds) { const profile = yield* readProfile(sql, personId); - if (profile === undefined) return yield* new ProfileNotFound({ personId }); + if (profile === undefined) return yield* ProfileNotFound.make({ personId }); profiles.push(profile); } @@ -148,7 +148,7 @@ export const readPersonContacts = ( yield* Organization; if (personIds.length > PROFILE_READ_LIMIT) { - return yield* new ProfileQueryLimitExceeded({ limit: PROFILE_READ_LIMIT }); + return yield* ProfileQueryLimitExceeded.make({ limit: PROFILE_READ_LIMIT }); } const sql = yield* Database; @@ -166,7 +166,7 @@ export const readPersonContacts = ( for (const personId of uniqueIds) { const contact = yield* readContact(sql, personId); - if (contact === undefined) return yield* new ProfileContactNotFound({ personId }); + if (contact === undefined) return yield* ProfileContactNotFound.make({ personId }); contacts.push(contact); } @@ -212,7 +212,7 @@ const decodePersonId = flow( Schema.decodeUnknownEffect(PersonId, { onExcessProperty: "error" }), Effect.mapError( (cause) => - new ProfileDecodeError({ + ProfileDecodeError.make({ message: cause instanceof Error ? cause.message : "invalid Profile actor person ID", }), ), @@ -222,7 +222,7 @@ const decodeOwnProfileValue = flow( Schema.decodeUnknownEffect(OwnProfile, { onExcessProperty: "error" }), Effect.mapError( (cause) => - new ProfileDecodeError({ + ProfileDecodeError.make({ message: cause instanceof Error ? cause.message : "invalid own Profile observation", }), ), @@ -234,7 +234,7 @@ const decodeUpdateOwnProfileCommand = flow( }), Effect.mapError( (cause) => - new ProfileDecodeError({ + ProfileDecodeError.make({ message: cause instanceof Error ? cause.message : "invalid own Profile command", }), ), @@ -266,10 +266,10 @@ const readOwnProfileHttpSourceWith = ( Effect.gen(function* () { const row = rows[0]; - if (row === undefined) return yield* new ProfileNotFound({ personId }); + if (row === undefined) return yield* ProfileNotFound.make({ personId }); if (row.contactPersonId === null) { - return yield* new ProfileContactNotFound({ personId }); + return yield* ProfileContactNotFound.make({ personId }); } const profile = yield* decodeOwnProfileValue({ @@ -288,7 +288,7 @@ const readOwnProfileHttpSourceWith = ( }).pipe( Effect.mapError( (cause) => - new ProfileDecodeError({ + ProfileDecodeError.make({ message: cause instanceof Error ? cause.message : "invalid own Profile HTTP source", }), ), @@ -475,7 +475,7 @@ const replayOwnProfile = ( const storedCommandJson = canonicalJson(receipt.commandJson); if (storedCommandJson !== commandJson) { - return yield* new ProfileCommandConflict({ commandId: command.commandId }); + return yield* ProfileCommandConflict.make({ commandId: command.commandId }); } const storedDigest = sha256Hex(canonicalJsonBytes(receipt.commandJson)); @@ -591,20 +591,20 @@ export const updateOwnProfile = ( const profile = yield* lockPersonProfile(sql, actorPersonId); if (profile === undefined) { - return yield* new ProfileNotFound({ personId: actorPersonId }); + return yield* ProfileNotFound.make({ personId: actorPersonId }); } const contact = yield* lockPersonContact(sql, actorPersonId); if (contact === undefined) { - return yield* new ProfileContactNotFound({ personId: actorPersonId }); + return yield* ProfileContactNotFound.make({ personId: actorPersonId }); } if ( profile.revision !== command.expectedNameRevision || contact.revision !== command.expectedContactRevision ) { - return yield* new ProfileStaleRevision({ + return yield* ProfileStaleRevision.make({ personId: actorPersonId, expectedNameRevision: command.expectedNameRevision, actualNameRevision: profile.revision, @@ -671,7 +671,7 @@ export const readDirectoryPage = ( yield* Organization; if (!Number.isSafeInteger(input.limit) || input.limit <= 0) { - return yield* new ProfileQueryLimitExceeded({ limit: input.limit }); + return yield* ProfileQueryLimitExceeded.make({ limit: input.limit }); } const cursorTuple = @@ -705,7 +705,7 @@ export const readDirectoryPage = ( for (const row of rows) { if (row.email === null || row.phone === null) { - return yield* new ProfileContactNotFound({ personId: PersonId.make(row.personId) }); + return yield* ProfileContactNotFound.make({ personId: PersonId.make(row.personId) }); } entries.push({ diff --git a/packages/database/src/receipt-approval-list.test.ts b/packages/database/src/receipt-approval-list.test.ts index 320723f5a..b17190596 100644 --- a/packages/database/src/receipt-approval-list.test.ts +++ b/packages/database/src/receipt-approval-list.test.ts @@ -17,23 +17,22 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( "unions direct and rule scopes without broadening a Department rule", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const economy = yield* Economy; - const departmentA = DepartmentId.make("approval-query-department-a"); - const departmentB = DepartmentId.make("approval-query-department-b"); - const authorizationInstant = "2038-06-15T12:00:00.000Z"; - const directGlobal = PersonId.make("approval-query-direct-global"); - const directDepartment = PersonId.make("approval-query-direct-department"); - const ruleDepartment = PersonId.make("approval-query-rule-department"); - const ruleGlobal = PersonId.make("approval-query-rule-global"); - const scopedGlobalSlot = PersonId.make("approval-query-scoped-global-slot"); - const multipleScopes = PersonId.make("approval-query-multiple-scopes"); - const expiredRule = PersonId.make("approval-query-expired-rule"); - const detachedTag = PersonId.make("approval-query-detached-tag"); - const noRule = PersonId.make("approval-query-no-rule"); + const database = yield* Database; + const economy = yield* Economy; + const departmentA = DepartmentId.make("approval-query-department-a"); + const departmentB = DepartmentId.make("approval-query-department-b"); + const authorizationInstant = "2038-06-15T12:00:00.000Z"; + const directGlobal = PersonId.make("approval-query-direct-global"); + const directDepartment = PersonId.make("approval-query-direct-department"); + const ruleDepartment = PersonId.make("approval-query-rule-department"); + const ruleGlobal = PersonId.make("approval-query-rule-global"); + const scopedGlobalSlot = PersonId.make("approval-query-scoped-global-slot"); + const multipleScopes = PersonId.make("approval-query-multiple-scopes"); + const expiredRule = PersonId.make("approval-query-expired-rule"); + const detachedTag = PersonId.make("approval-query-detached-tag"); + const noRule = PersonId.make("approval-query-no-rule"); - yield* database` + yield* database` INSERT INTO public.person_profiles (person_id, first_name, last_name) VALUES (${directGlobal}, 'Direct', 'Global'), @@ -46,7 +45,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( (${detachedTag}, 'Detached', 'Tag'), (${noRule}, 'No', 'Rule') `; - yield* database` + yield* database` INSERT INTO public.organization_departments ( department_id, name, short_name, email, city ) VALUES @@ -55,13 +54,13 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( (${departmentB}, 'Approval Department B', 'AQB', 'approval-b@example.invalid', 'Trondheim') `; - yield* database` + yield* database` INSERT INTO public.organization_teams (team_id, department_id, name) VALUES ('approval-query-team-a', ${departmentA}, 'Approval Team A'), ('approval-query-team-b', ${departmentB}, 'Approval Team B') `; - yield* database` + yield* database` INSERT INTO public.organization_memberships ( membership_id, person_id, team_id, start_at ) VALUES @@ -86,7 +85,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( ('approval-query-membership-none', ${noRule}, 'approval-query-team-a', '2038-01-01T00:00:00.000Z') `; - yield* database` + yield* database` INSERT INTO public.economy_receipt_approval_grants ( approval_grant_id, person_id, scope, department_id, start_at ) VALUES @@ -97,11 +96,11 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( ('approval-query-grant-multiple', ${multipleScopes}, 'Department', ${departmentA}, '2038-01-01T00:00:00.000Z') `; - yield* database` + yield* database` INSERT INTO public.authz_tags (tag_id, name) VALUES ('approval-query-tag', 'Approval Query Tag') `; - yield* database` + yield* database` INSERT INTO public.authz_tag_assignments ( assignment_id, tag_id, person_id, start_at, end_at ) VALUES ( @@ -109,7 +108,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( '2038-01-01T00:00:00.000Z', ${authorizationInstant} ) `; - yield* database` + yield* database` INSERT INTO public.authz_rules ( rule_id, capability_id, effect_kind, subject_kind, subject_person_id, subject_tag_id, scope, department_id, params, start_at, end_at @@ -151,7 +150,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( '2038-01-01T00:00:00.000Z', NULL ) `; - yield* database` + yield* database` INSERT INTO public.economy_receipts ( receipt_id, visual_id, owner_person_id, department_id, amount_ore, currency, description, receipt_date, submitted_at, status, approved_at, @@ -174,21 +173,21 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( ) `; - const list = (queryPersonId: PersonId, status?: "Pending" | "Rejected") => - economy.listReceiptsForApproval(queryPersonId, authorizationInstant, status); + const list = (queryPersonId: PersonId, status?: "Pending" | "Rejected") => + economy.listReceiptsForApproval(queryPersonId, authorizationInstant, status); - const directGlobalRows = yield* list(directGlobal); - const directDepartmentRows = yield* list(directDepartment); - const ruleDepartmentRows = yield* list(ruleDepartment); - const ruleGlobalRows = yield* list(ruleGlobal); - const scopedGlobalRows = yield* list(scopedGlobalSlot); - const multipleRows = yield* list(multipleScopes); - const filteredRows = yield* list(directGlobal, "Rejected"); - const expiredFailure = yield* Effect.flip(list(expiredRule)); - const detachedFailure = yield* Effect.flip(list(detachedTag)); - const noRuleFailure = yield* Effect.flip(list(noRule)); + const directGlobalRows = yield* list(directGlobal); + const directDepartmentRows = yield* list(directDepartment); + const ruleDepartmentRows = yield* list(ruleDepartment); + const ruleGlobalRows = yield* list(ruleGlobal); + const scopedGlobalRows = yield* list(scopedGlobalSlot); + const multipleRows = yield* list(multipleScopes); + const filteredRows = yield* list(directGlobal, "Rejected"); + const expiredFailure = yield* Effect.flip(list(expiredRule)); + const detachedFailure = yield* Effect.flip(list(detachedTag)); + const noRuleFailure = yield* Effect.flip(list(noRule)); - yield* database` + yield* database` INSERT INTO public.economy_receipts ( receipt_id, visual_id, owner_person_id, department_id, amount_ore, currency, description, receipt_date, submitted_at, status, approved_at, @@ -207,14 +206,14 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( FROM generate_series(0, 59) AS n CROSS JOIN (VALUES ('a', ${departmentA}), ('b', ${departmentB})) AS departments(side, department_id) `; - const firstPage = yield* list(ruleDepartment, "Pending"); + const firstPage = yield* list(ruleDepartment, "Pending"); - if (firstPage.nextCursor === undefined) - throw new Error("First approval page lost continuation"); - const firstCursor = yield* decodeReceiptCursor(firstPage.nextCursor); - yield* database`UPDATE public.economy_receipts SET status = 'Approved', approved_at = '2038-06-15T11:00:00Z' + if (firstPage.nextCursor === undefined) + throw new Error("First approval page lost continuation"); + const firstCursor = yield* decodeReceiptCursor(firstPage.nextCursor); + yield* database`UPDATE public.economy_receipts SET status = 'Approved', approved_at = '2038-06-15T11:00:00Z' WHERE receipt_id = 'approval-page-a-000'`; - yield* database` + yield* database` INSERT INTO public.economy_receipts ( receipt_id, visual_id, owner_person_id, department_id, amount_ore, currency, description, receipt_date, submitted_at, status, approved_at, @@ -227,59 +226,58 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( FROM public.economy_receipts WHERE receipt_id = 'approval-page-a-000' `; - const nextPage = yield* economy.listReceiptsForApproval( - ruleDepartment, + const nextPage = yield* economy.listReceiptsForApproval( + ruleDepartment, + authorizationInstant, + "Pending", + firstPage.nextCursor, + ); + + const pageDenied = yield* Effect.flip( + economy.listReceiptsForApproval( + noRule, authorizationInstant, "Pending", firstPage.nextCursor, - ); - - const pageDenied = yield* Effect.flip( - economy.listReceiptsForApproval( - noRule, - authorizationInstant, - "Pending", - firstPage.nextCursor, - ), - ); + ), + ); - const ownerFirst = yield* economy.listOwnedReceipts("approval-query-owner", "Pending"); + const ownerFirst = yield* economy.listOwnedReceipts("approval-query-owner", "Pending"); - if (ownerFirst.nextCursor === undefined) - throw new Error("First owner page lost continuation"); + if (ownerFirst.nextCursor === undefined) + throw new Error("First owner page lost continuation"); - const ownerNext = yield* economy.listOwnedReceipts( - "approval-query-owner", - "Pending", - ownerFirst.nextCursor, - ); + const ownerNext = yield* economy.listOwnedReceipts( + "approval-query-owner", + "Pending", + ownerFirst.nextCursor, + ); - const pagination = { - first: firstPage.items.map((row) => row.receiptId), - cursor: firstCursor, - next: nextPage.items.map((row) => row.receiptId), - nextCursor: nextPage.nextCursor, - denied: pageDenied._tag, - ownerFirst: ownerFirst.items.map((row) => row.receiptId), - ownerNext: ownerNext.items.map((row) => row.receiptId), - }; + const pagination = { + first: firstPage.items.map((row) => row.receiptId), + cursor: firstCursor, + next: nextPage.items.map((row) => row.receiptId), + nextCursor: nextPage.nextCursor, + denied: pageDenied._tag, + ownerFirst: ownerFirst.items.map((row) => row.receiptId), + ownerNext: ownerNext.items.map((row) => row.receiptId), + }; - return { - pagination, - directGlobal: directGlobalRows.items.map((row) => row.receiptId), - directDepartment: directDepartmentRows.items.map((row) => row.receiptId), - ruleDepartment: ruleDepartmentRows.items.map((row) => row.receiptId), - ruleGlobal: ruleGlobalRows.items.map((row) => row.receiptId), - scopedGlobal: scopedGlobalRows.items.map((row) => row.receiptId), - multiple: multipleRows.items.map((row) => row.receiptId), - filtered: filteredRows.items.map((row) => row.receiptId), - expiredFailure: expiredFailure._tag, - detachedFailure: detachedFailure._tag, - noRuleFailure: noRuleFailure._tag, - }; - }); + const evidence = { + pagination, + directGlobal: directGlobalRows.items.map((row) => row.receiptId), + directDepartment: directDepartmentRows.items.map((row) => row.receiptId), + ruleDepartment: ruleDepartmentRows.items.map((row) => row.receiptId), + ruleGlobal: ruleGlobalRows.items.map((row) => row.receiptId), + scopedGlobal: scopedGlobalRows.items.map((row) => row.receiptId), + multiple: multipleRows.items.map((row) => row.receiptId), + filtered: filteredRows.items.map((row) => row.receiptId), + expiredFailure: expiredFailure._tag, + detachedFailure: detachedFailure._tag, + noRuleFailure: noRuleFailure._tag, + }; - const { pagination, ...authorityEvidence } = evidence; + const { pagination: _pagination, ...authorityEvidence } = evidence; expect(authorityEvidence).toEqual({ directGlobal: ["approval-query-receipt-b", "approval-query-receipt-a"], directDepartment: ["approval-query-receipt-a"], diff --git a/packages/database/src/receipt-settlement.test.ts b/packages/database/src/receipt-settlement.test.ts index 43699d195..7fd17455a 100644 --- a/packages/database/src/receipt-settlement.test.ts +++ b/packages/database/src/receipt-settlement.test.ts @@ -51,18 +51,17 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( "records immutable evidence once with CAS, replay, reference, authority, and queue boundaries", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const economy = yield* Economy; + const database = yield* Database; + const economy = yield* Economy; - yield* database` + yield* database` INSERT INTO public.person_profiles (person_id, first_name, last_name) VALUES (${ownerPersonId}, 'Settlement', 'Owner'), (${settlerPersonId}, 'Settlement', 'Settler'), (${deniedPersonId}, 'Settlement', 'Denied') `; - yield* database` + yield* database` INSERT INTO public.organization_departments ( department_id, name, short_name, email, city ) VALUES ( @@ -70,11 +69,11 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( 'settlement-test@example.invalid', 'Bergen' ) `; - yield* database` + yield* database` INSERT INTO public.organization_teams (team_id, department_id, name) VALUES ('settlement-test-team', ${departmentId}, 'Settlement Test Team') `; - yield* database` + yield* database` INSERT INTO public.organization_memberships ( membership_id, person_id, team_id, start_at ) VALUES @@ -83,7 +82,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( ('settlement-test-denied-membership', ${deniedPersonId}, 'settlement-test-team', '2038-01-01T00:00:00.000Z') `; - yield* database` + yield* database` INSERT INTO public.economy_receipt_settlement_grants ( settlement_grant_id, person_id, scope, department_id, start_at ) VALUES ( @@ -91,7 +90,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( '2038-01-01T00:00:00.000Z' ) `; - yield* database` + yield* database` INSERT INTO public.economy_receipts ( receipt_id, visual_id, owner_person_id, department_id, amount_ore, currency, description, receipt_date, submitted_at, status, approved_at, @@ -125,131 +124,131 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( 'application/pdf', 128, ${"e".repeat(64)}, 0) `; - const recorded = yield* economy.recordReceiptSettlement( - command("settlement-test-command-1", "settlement-test-receipt-1", 0, "reference-1"), - principal(settlerPersonId), - ); + const recorded = yield* economy.recordReceiptSettlement( + command("settlement-test-command-1", "settlement-test-receipt-1", 0, "reference-1"), + principal(settlerPersonId), + ); - const immutableUpdate = yield* Effect.exit(database` + const immutableUpdate = yield* Effect.exit(database` UPDATE public.economy_receipt_settlements SET external_reference = 'mutated-reference' WHERE settlement_id = ${recorded.settlement.settlementId} `); - const replay = yield* economy.recordReceiptSettlement( - command("settlement-test-command-1", "settlement-test-receipt-1", 0, "reference-1"), - principal(settlerPersonId), - ); - - const changedReplay = yield* Effect.flip( - economy.recordReceiptSettlement( - command( - "settlement-test-command-1", - "settlement-test-receipt-1", - 0, - "changed-reference", - ), - principal(settlerPersonId), - ), - ); + const replay = yield* economy.recordReceiptSettlement( + command("settlement-test-command-1", "settlement-test-receipt-1", 0, "reference-1"), + principal(settlerPersonId), + ); - const duplicateReference = yield* Effect.flip( - economy.recordReceiptSettlement( - command("settlement-test-command-2", "settlement-test-receipt-2", 0, "reference-1"), - principal(settlerPersonId), + const changedReplay = yield* Effect.flip( + economy.recordReceiptSettlement( + command( + "settlement-test-command-1", + "settlement-test-receipt-1", + 0, + "changed-reference", ), - ); + principal(settlerPersonId), + ), + ); - const staleRevision = yield* Effect.flip( - economy.recordReceiptSettlement( - command("settlement-test-command-3", "settlement-test-receipt-3", 0, "reference-3"), - principal(settlerPersonId), - ), - ); + const duplicateReference = yield* Effect.flip( + economy.recordReceiptSettlement( + command("settlement-test-command-2", "settlement-test-receipt-2", 0, "reference-1"), + principal(settlerPersonId), + ), + ); - const unapproved = yield* Effect.flip( - economy.recordReceiptSettlement( - command("settlement-test-command-4", "settlement-test-receipt-5", 0, "reference-5"), - principal(settlerPersonId), - ), - ); + const staleRevision = yield* Effect.flip( + economy.recordReceiptSettlement( + command("settlement-test-command-3", "settlement-test-receipt-3", 0, "reference-3"), + principal(settlerPersonId), + ), + ); - const concealedAuthority = yield* Effect.flip( - economy.recordReceiptSettlement( - command("settlement-test-command-5", "settlement-test-receipt-2", 0, "reference-2"), - principal(deniedPersonId), - ), - ); - - const concurrent = yield* Effect.all( - [ - economy - .recordReceiptSettlement( - command( - "settlement-test-command-6a", - "settlement-test-receipt-4", - 0, - "reference-4a", - ), - principal(settlerPersonId), - ) - .pipe( - Effect.match({ - onFailure: (error) => ({ _tag: "Failure" as const, error }), - onSuccess: (value) => ({ _tag: "Success" as const, value }), - }), + const unapproved = yield* Effect.flip( + economy.recordReceiptSettlement( + command("settlement-test-command-4", "settlement-test-receipt-5", 0, "reference-5"), + principal(settlerPersonId), + ), + ); + + const concealedAuthority = yield* Effect.flip( + economy.recordReceiptSettlement( + command("settlement-test-command-5", "settlement-test-receipt-2", 0, "reference-2"), + principal(deniedPersonId), + ), + ); + + const concurrent = yield* Effect.all( + [ + economy + .recordReceiptSettlement( + command( + "settlement-test-command-6a", + "settlement-test-receipt-4", + 0, + "reference-4a", ), - economy - .recordReceiptSettlement( - command( - "settlement-test-command-6b", - "settlement-test-receipt-4", - 0, - "reference-4b", - ), - principal(settlerPersonId), - ) - .pipe( - Effect.match({ - onFailure: (error) => ({ _tag: "Failure" as const, error }), - onSuccess: (value) => ({ _tag: "Success" as const, value }), - }), + principal(settlerPersonId), + ) + .pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "Failure" as const, error }), + onSuccess: (value) => ({ _tag: "Success" as const, value }), + }), + ), + economy + .recordReceiptSettlement( + command( + "settlement-test-command-6b", + "settlement-test-receipt-4", + 0, + "reference-4b", ), - ], - { concurrency: "unbounded" }, - ); - - const queue = yield* economy.listReceiptsForSettlement( - settlerPersonId, - authorizationInstant, - ); - - const owned = yield* economy.listOwnedReceipts(ownerPersonId); - - const finance = yield* economy.readReceiptSettlementForFinance( - "settlement-test-receipt-1", - settlerPersonId, - authorizationInstant, - ); - - const outbox = yield* database<{ - readonly effectType: string; - readonly commandId: string; - readonly ordinal: number; - readonly status: string; - readonly attempts: number; - }>` + principal(settlerPersonId), + ) + .pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "Failure" as const, error }), + onSuccess: (value) => ({ _tag: "Success" as const, value }), + }), + ), + ], + { concurrency: "unbounded" }, + ); + + const queue = yield* economy.listReceiptsForSettlement( + settlerPersonId, + authorizationInstant, + ); + + const owned = yield* economy.listOwnedReceipts(ownerPersonId); + + const finance = yield* economy.readReceiptSettlementForFinance( + "settlement-test-receipt-1", + settlerPersonId, + authorizationInstant, + ); + + const outbox = yield* database<{ + readonly effectType: string; + readonly commandId: string; + readonly ordinal: number; + readonly status: string; + readonly attempts: number; + }>` SELECT effect_type AS "effectType", command_id AS "commandId", ordinal, status, attempts FROM public.economy_receipt_outbox WHERE command_id = 'settlement-test-command-1' `; - const audit = yield* database<{ - readonly commandId: string; - readonly action: string; - readonly actorPersonId: string; - readonly receiptRevision: number; - }>` + const audit = yield* database<{ + readonly commandId: string; + readonly action: string; + readonly actorPersonId: string; + readonly receiptRevision: number; + }>` SELECT command_id AS "commandId", action, @@ -259,7 +258,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( WHERE command_id = 'settlement-test-command-1' `; - yield* database` + yield* database` INSERT INTO public.economy_receipts ( receipt_id, visual_id, owner_person_id, department_id, amount_ore, currency, description, receipt_date, submitted_at, status, approved_at, payment_account_ciphertext, @@ -273,59 +272,58 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( WHERE receipt_id = 'settlement-test-receipt-1' `; - const firstPage = yield* economy.listReceiptsForSettlement( - settlerPersonId, - authorizationInstant, - ); + const firstPage = yield* economy.listReceiptsForSettlement( + settlerPersonId, + authorizationInstant, + ); - if (firstPage.nextCursor === undefined) - throw new Error("Settlement page lost continuation"); - yield* economy.recordReceiptSettlement( - command("settlement-page-command", "settlement-page-000", 0, "page-reference"), - principal(settlerPersonId), - ); - - const nextPage = yield* economy.listReceiptsForSettlement( - settlerPersonId, - authorizationInstant, - firstPage.nextCursor, - ); - - const deniedPage = yield* economy.listReceiptsForSettlement( - deniedPersonId, - authorizationInstant, - firstPage.nextCursor, - ); - - const pagination = { - first: firstPage.items.map((row) => row.receiptId), - next: nextPage.items.map((row) => row.receiptId), - nextCursor: nextPage.nextCursor, - denied: deniedPage, - }; - - return { - pagination, - recorded, - replay, - immutableUpdate: immutableUpdate._tag, - changedReplay: changedReplay._tag, - duplicateReference: duplicateReference._tag, - staleRevision: staleRevision._tag, - unapproved: unapproved._tag, - concealedAuthority: concealedAuthority._tag, - concurrent: concurrent.map((result) => - Predicate.isTagged(result, "Success") ? "Accepted" : result.error._tag, - ), - queue: queue.items.map(({ receiptId }) => receiptId), - ownerSettlement: owned.items.find( - ({ receiptId }) => receiptId === "settlement-test-receipt-1", - )?.settlement, - finance, - outbox, - audit, - }; - }); + if (firstPage.nextCursor === undefined) + throw new Error("Settlement page lost continuation"); + yield* economy.recordReceiptSettlement( + command("settlement-page-command", "settlement-page-000", 0, "page-reference"), + principal(settlerPersonId), + ); + + const nextPage = yield* economy.listReceiptsForSettlement( + settlerPersonId, + authorizationInstant, + firstPage.nextCursor, + ); + + const deniedPage = yield* economy.listReceiptsForSettlement( + deniedPersonId, + authorizationInstant, + firstPage.nextCursor, + ); + + const pagination = { + first: firstPage.items.map((row) => row.receiptId), + next: nextPage.items.map((row) => row.receiptId), + nextCursor: nextPage.nextCursor, + denied: deniedPage, + }; + + const evidence = { + pagination, + recorded, + replay, + immutableUpdate: immutableUpdate._tag, + changedReplay: changedReplay._tag, + duplicateReference: duplicateReference._tag, + staleRevision: staleRevision._tag, + unapproved: unapproved._tag, + concealedAuthority: concealedAuthority._tag, + concurrent: concurrent.map((result) => + Predicate.isTagged(result, "Success") ? "Accepted" : result.error._tag, + ), + queue: queue.items.map(({ receiptId }) => receiptId), + ownerSettlement: owned.items.find( + ({ receiptId }) => receiptId === "settlement-test-receipt-1", + )?.settlement, + finance, + outbox, + audit, + }; expect(evidence.pagination.first).toEqual( Array.from( diff --git a/packages/database/src/receipt/authority-postgres.ts b/packages/database/src/receipt/authority-postgres.ts index 7dfafaded..7714e558f 100644 --- a/packages/database/src/receipt/authority-postgres.ts +++ b/packages/database/src/receipt/authority-postgres.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { Match, Predicate, Effect, Schema } from "effect"; import { Database, type DatabaseOperations } from "../service.js"; import type { @@ -104,10 +105,10 @@ const ReceiptAuthorityDatabaseRowSchema = Schema.Struct({ type ReceiptAuthorityDatabaseRow = typeof ReceiptAuthorityDatabaseRowSchema.Type; const decodeError = (operation: string, cause: unknown) => - new ReceiptDecodeError({ message: `${operation}: ${String(cause)}` }); + ReceiptDecodeError.make({ message: `${operation}: ${String(cause)}` }); const persistenceError = (operation: string, cause: unknown) => - new ReceiptPersistenceError({ operation, message: String(cause) }); + ReceiptPersistenceError.make({ operation, message: String(cause) }); const paymentRecord = ( row: ReceiptAuthorityDatabaseRow, @@ -233,37 +234,51 @@ const lockAuthorityPerson = ( Effect.mapError((cause) => persistenceError(cause.operation, cause.message)), ); -export const lockReceiptPaymentAuthorityForWrite = ( - sql: DatabaseOperations, - paymentAuthorityId: ReceiptPaymentAuthority["paymentAuthorityId"], - expectedRevision: number, -): Effect.Effect => - Effect.gen(function* () { - const observedRows = yield* sql` +export const lockReceiptPaymentAuthorityForWrite: { + ( + paymentAuthorityId: ReceiptPaymentAuthority["paymentAuthorityId"], + expectedRevision: number, + ): ( + sql: DatabaseOperations, + ) => Effect.Effect; + ( + sql: DatabaseOperations, + paymentAuthorityId: ReceiptPaymentAuthority["paymentAuthorityId"], + expectedRevision: number, + ): Effect.Effect; +} = dual( + 3, + ( + sql: DatabaseOperations, + paymentAuthorityId: ReceiptPaymentAuthority["paymentAuthorityId"], + expectedRevision: number, + ): Effect.Effect => + Effect.gen(function* () { + const observedRows = yield* sql` SELECT person_id AS "personId" FROM public.economy_payment_authorities WHERE payment_authority_id = ${paymentAuthorityId} `; - const observed = yield* Schema.decodeEffect(Schema.Array(ReceiptAuthorityPersonRowSchema))( - observedRows, - { onExcessProperty: "error" }, - ).pipe( - Effect.mapError((cause) => decodeError("decode Receipt payment authority person", cause)), - ); + const observed = yield* Schema.decodeEffect(Schema.Array(ReceiptAuthorityPersonRowSchema))( + observedRows, + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => decodeError("decode Receipt payment authority person", cause)), + ); - const observedPerson = observed[0]?.personId; + const observedPerson = observed[0]?.personId; - if (observedPerson === undefined) { - return yield* new ReceiptAuthorityRecordNotFound({ - entity: "PaymentAuthority", - id: paymentAuthorityId, - }); - } + if (observedPerson === undefined) { + return yield* ReceiptAuthorityRecordNotFound.make({ + entity: "PaymentAuthority", + id: paymentAuthorityId, + }); + } - yield* lockAuthorityPerson(sql, observedPerson); + yield* lockAuthorityPerson(sql, observedPerson); - const lockedRows = yield* sql` + const lockedRows = yield* sql` SELECT 'Payment'::text AS "authorityKind", payment_authority_id AS "authorityId", @@ -283,59 +298,78 @@ export const lockReceiptPaymentAuthorityForWrite = ( FOR UPDATE `; - const locked = yield* Schema.decodeEffect(Schema.Array(ReceiptAuthorityDatabaseRowSchema))( - lockedRows, - { onExcessProperty: "error" }, - ).pipe( - Effect.mapError((cause) => decodeError("decode locked Receipt payment authority", cause)), - ); - - const row = locked[0]; + const locked = yield* Schema.decodeEffect(Schema.Array(ReceiptAuthorityDatabaseRowSchema))( + lockedRows, + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => decodeError("decode locked Receipt payment authority", cause)), + ); - if (row === undefined || row.personId !== observedPerson || row.revision !== expectedRevision) { - return yield* new ReceiptAuthorityWriteConflict({ - entity: "PaymentAuthority", - id: paymentAuthorityId, - expectedRevision, - }); - } + const row = locked[0]; + + if ( + row === undefined || + row.personId !== observedPerson || + row.revision !== expectedRevision + ) { + return yield* ReceiptAuthorityWriteConflict.make({ + entity: "PaymentAuthority", + id: paymentAuthorityId, + expectedRevision, + }); + } - return yield* paymentRecord(row); - }).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("lock Receipt payment authority", cause)), + return yield* paymentRecord(row); + }).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("lock Receipt payment authority", cause)), + ), ), - ); +); -export const lockReceiptApprovalGrantForWrite = ( - sql: DatabaseOperations, - approvalGrantId: ReceiptApprovalGrant["approvalGrantId"], - expectedRevision: number, -): Effect.Effect => - Effect.gen(function* () { - const observedRows = yield* sql` +export const lockReceiptApprovalGrantForWrite: { + ( + approvalGrantId: ReceiptApprovalGrant["approvalGrantId"], + expectedRevision: number, + ): (sql: DatabaseOperations) => Effect.Effect; + ( + sql: DatabaseOperations, + approvalGrantId: ReceiptApprovalGrant["approvalGrantId"], + expectedRevision: number, + ): Effect.Effect; +} = dual( + 3, + ( + sql: DatabaseOperations, + approvalGrantId: ReceiptApprovalGrant["approvalGrantId"], + expectedRevision: number, + ): Effect.Effect => + Effect.gen(function* () { + const observedRows = yield* sql` SELECT person_id AS "personId" FROM public.economy_receipt_approval_grants WHERE approval_grant_id = ${approvalGrantId} `; - const observed = yield* Schema.decodeEffect(Schema.Array(ReceiptAuthorityPersonRowSchema))( - observedRows, - { onExcessProperty: "error" }, - ).pipe(Effect.mapError((cause) => decodeError("decode Receipt approval grant person", cause))); + const observed = yield* Schema.decodeEffect(Schema.Array(ReceiptAuthorityPersonRowSchema))( + observedRows, + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => decodeError("decode Receipt approval grant person", cause)), + ); - const observedPerson = observed[0]?.personId; + const observedPerson = observed[0]?.personId; - if (observedPerson === undefined) { - return yield* new ReceiptAuthorityRecordNotFound({ - entity: "ApprovalGrant", - id: approvalGrantId, - }); - } + if (observedPerson === undefined) { + return yield* ReceiptAuthorityRecordNotFound.make({ + entity: "ApprovalGrant", + id: approvalGrantId, + }); + } - yield* lockAuthorityPerson(sql, observedPerson); + yield* lockAuthorityPerson(sql, observedPerson); - const lockedRows = yield* sql` + const lockedRows = yield* sql` SELECT 'Approval'::text AS "authorityKind", approval_grant_id AS "authorityId", @@ -355,59 +389,80 @@ export const lockReceiptApprovalGrantForWrite = ( FOR UPDATE `; - const locked = yield* Schema.decodeEffect(Schema.Array(ReceiptAuthorityDatabaseRowSchema))( - lockedRows, - { onExcessProperty: "error" }, - ).pipe(Effect.mapError((cause) => decodeError("decode locked Receipt approval grant", cause))); - - const row = locked[0]; + const locked = yield* Schema.decodeEffect(Schema.Array(ReceiptAuthorityDatabaseRowSchema))( + lockedRows, + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => decodeError("decode locked Receipt approval grant", cause)), + ); - if (row === undefined || row.personId !== observedPerson || row.revision !== expectedRevision) { - return yield* new ReceiptAuthorityWriteConflict({ - entity: "ApprovalGrant", - id: approvalGrantId, - expectedRevision, - }); - } + const row = locked[0]; + + if ( + row === undefined || + row.personId !== observedPerson || + row.revision !== expectedRevision + ) { + return yield* ReceiptAuthorityWriteConflict.make({ + entity: "ApprovalGrant", + id: approvalGrantId, + expectedRevision, + }); + } - return yield* approvalGrantRecord(row); - }).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("lock Receipt approval grant", cause)), + return yield* approvalGrantRecord(row); + }).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("lock Receipt approval grant", cause)), + ), ), - ); +); -export const lockReceiptSettlementGrantForWrite = ( - sql: DatabaseOperations, - settlementGrantId: ReceiptSettlementGrant["settlementGrantId"], - expectedRevision: number, -): Effect.Effect => - Effect.gen(function* () { - const observedRows = yield* sql` +export const lockReceiptSettlementGrantForWrite: { + ( + settlementGrantId: ReceiptSettlementGrant["settlementGrantId"], + expectedRevision: number, + ): ( + sql: DatabaseOperations, + ) => Effect.Effect; + ( + sql: DatabaseOperations, + settlementGrantId: ReceiptSettlementGrant["settlementGrantId"], + expectedRevision: number, + ): Effect.Effect; +} = dual( + 3, + ( + sql: DatabaseOperations, + settlementGrantId: ReceiptSettlementGrant["settlementGrantId"], + expectedRevision: number, + ): Effect.Effect => + Effect.gen(function* () { + const observedRows = yield* sql` SELECT person_id AS "personId" FROM public.economy_receipt_settlement_grants WHERE settlement_grant_id = ${settlementGrantId} `; - const observed = yield* Schema.decodeEffect(Schema.Array(ReceiptAuthorityPersonRowSchema))( - observedRows, - { onExcessProperty: "error" }, - ).pipe( - Effect.mapError((cause) => decodeError("decode Receipt settlement grant person", cause)), - ); + const observed = yield* Schema.decodeEffect(Schema.Array(ReceiptAuthorityPersonRowSchema))( + observedRows, + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => decodeError("decode Receipt settlement grant person", cause)), + ); - const observedPerson = observed[0]?.personId; + const observedPerson = observed[0]?.personId; - if (observedPerson === undefined) { - return yield* new ReceiptAuthorityRecordNotFound({ - entity: "SettlementGrant", - id: settlementGrantId, - }); - } + if (observedPerson === undefined) { + return yield* ReceiptAuthorityRecordNotFound.make({ + entity: "SettlementGrant", + id: settlementGrantId, + }); + } - yield* lockAuthorityPerson(sql, observedPerson); + yield* lockAuthorityPerson(sql, observedPerson); - const lockedRows = yield* sql` + const lockedRows = yield* sql` SELECT 'Settlement'::text AS "authorityKind", settlement_grant_id AS "authorityId", @@ -427,29 +482,34 @@ export const lockReceiptSettlementGrantForWrite = ( FOR UPDATE `; - const locked = yield* Schema.decodeEffect(Schema.Array(ReceiptAuthorityDatabaseRowSchema))( - lockedRows, - { onExcessProperty: "error" }, - ).pipe( - Effect.mapError((cause) => decodeError("decode locked Receipt settlement grant", cause)), - ); - - const row = locked[0]; + const locked = yield* Schema.decodeEffect(Schema.Array(ReceiptAuthorityDatabaseRowSchema))( + lockedRows, + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => decodeError("decode locked Receipt settlement grant", cause)), + ); - if (row === undefined || row.personId !== observedPerson || row.revision !== expectedRevision) { - return yield* new ReceiptAuthorityWriteConflict({ - entity: "SettlementGrant", - id: settlementGrantId, - expectedRevision, - }); - } + const row = locked[0]; + + if ( + row === undefined || + row.personId !== observedPerson || + row.revision !== expectedRevision + ) { + return yield* ReceiptAuthorityWriteConflict.make({ + entity: "SettlementGrant", + id: settlementGrantId, + expectedRevision, + }); + } - return yield* settlementGrantRecord(row); - }).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("lock Receipt settlement grant", cause)), + return yield* settlementGrantRecord(row); + }).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("lock Receipt settlement grant", cause)), + ), ), - ); +); export const createReceiptPaymentAuthority = ( input: typeof CreateReceiptPaymentAuthorityInputSchema.Encoded, @@ -547,7 +607,7 @@ export const endReceiptPaymentAuthority = ( `; if (updated.length !== 1) { - return yield* new ReceiptAuthorityWriteConflict({ + return yield* ReceiptAuthorityWriteConflict.make({ entity: "PaymentAuthority", id: command.paymentAuthorityId, expectedRevision: command.expectedRevision, @@ -593,7 +653,7 @@ export const removeReceiptPaymentAuthority = ( `; if (removed.length !== 1) { - return yield* new ReceiptAuthorityWriteConflict({ + return yield* ReceiptAuthorityWriteConflict.make({ entity: "PaymentAuthority", id: command.paymentAuthorityId, expectedRevision: command.expectedRevision, @@ -705,7 +765,7 @@ export const endReceiptApprovalGrant = ( `; if (updated.length !== 1) { - return yield* new ReceiptAuthorityWriteConflict({ + return yield* ReceiptAuthorityWriteConflict.make({ entity: "ApprovalGrant", id: command.approvalGrantId, expectedRevision: command.expectedRevision, @@ -751,7 +811,7 @@ export const removeReceiptApprovalGrant = ( `; if (removed.length !== 1) { - return yield* new ReceiptAuthorityWriteConflict({ + return yield* ReceiptAuthorityWriteConflict.make({ entity: "ApprovalGrant", id: command.approvalGrantId, expectedRevision: command.expectedRevision, @@ -867,7 +927,7 @@ export const endReceiptSettlementGrant = ( `; if (updated.length !== 1) { - return yield* new ReceiptAuthorityWriteConflict({ + return yield* ReceiptAuthorityWriteConflict.make({ entity: "SettlementGrant", id: command.settlementGrantId, expectedRevision: command.expectedRevision, @@ -913,7 +973,7 @@ export const removeReceiptSettlementGrant = ( `; if (removed.length !== 1) { - return yield* new ReceiptAuthorityWriteConflict({ + return yield* ReceiptAuthorityWriteConflict.make({ entity: "SettlementGrant", id: command.settlementGrantId, expectedRevision: command.expectedRevision, @@ -937,33 +997,49 @@ export type ReceiptAuthorityRowLockMode = "None" | "ForShare"; * state-transition SQL client and keep every selected authority row locked * until that transaction commits or rolls back. */ -export const resolveReceiptAuthorityWithSql = ( - sql: DatabaseOperations, - personId: PersonId, - authorizationInstant: OrganizationAuthorityInstant, - organizationProjection: OrganizationPersonAuthority, - lockMode: ReceiptAuthorityRowLockMode, -): Effect.Effect => - Effect.gen(function* () { - const evaluatedAt = yield* Schema.decodeEffect(ReceiptAuthorityInstantSchema)( - authorizationInstant, - ).pipe(Effect.mapError((cause) => decodeError("decode Receipt authority instant", cause))); - - if ( - organizationProjection.personId !== personId || - organizationProjection.evaluatedAt !== evaluatedAt - ) { - return yield* new ReceiptAuthorityProjectionMismatch({ - personId, - authorizationInstant: evaluatedAt, - organizationPersonId: organizationProjection.personId, - organizationEvaluatedAt: organizationProjection.evaluatedAt, - }); - } +export const resolveReceiptAuthorityWithSql: { + ( + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + organizationProjection: OrganizationPersonAuthority, + lockMode: ReceiptAuthorityRowLockMode, + ): (sql: DatabaseOperations) => Effect.Effect; + ( + sql: DatabaseOperations, + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + organizationProjection: OrganizationPersonAuthority, + lockMode: ReceiptAuthorityRowLockMode, + ): Effect.Effect; +} = dual( + 5, + ( + sql: DatabaseOperations, + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + organizationProjection: OrganizationPersonAuthority, + lockMode: ReceiptAuthorityRowLockMode, + ): Effect.Effect => + Effect.gen(function* () { + const evaluatedAt = yield* Schema.decodeEffect(ReceiptAuthorityInstantSchema)( + authorizationInstant, + ).pipe(Effect.mapError((cause) => decodeError("decode Receipt authority instant", cause))); + + if ( + organizationProjection.personId !== personId || + organizationProjection.evaluatedAt !== evaluatedAt + ) { + return yield* ReceiptAuthorityProjectionMismatch.make({ + personId, + authorizationInstant: evaluatedAt, + organizationPersonId: organizationProjection.personId, + organizationEvaluatedAt: organizationProjection.evaluatedAt, + }); + } - const authorityLock = lockMode === "ForShare" ? sql`FOR SHARE` : sql``; + const authorityLock = lockMode === "ForShare" ? sql`FOR SHARE` : sql``; - const selected = yield* sql` + const selected = yield* sql` WITH locked_payment_authorities AS MATERIALIZED ( SELECT payment_authority_id, @@ -1075,59 +1151,75 @@ export const resolveReceiptAuthorityWithSql = ( start_at ASC, authority_id ASC `.pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("resolve Receipt authority", cause)), - ), - ); + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("resolve Receipt authority", cause)), + ), + ); - const rows = yield* Schema.decodeEffect(Schema.Array(ReceiptAuthorityDatabaseRowSchema))( - selected, - { onExcessProperty: "error" }, - ).pipe(Effect.mapError((cause) => decodeError("decode Receipt authority rows", cause))); - - const paymentAuthorities: Array = []; - const approvalGrants: Array = []; - const settlementGrants: Array = []; - - for (const row of rows) { - if (row.personId !== personId) { - return yield* decodeError( - "decode Receipt authority rows", - "query returned an authority for another person", - ); - } + const rows = yield* Schema.decodeEffect(Schema.Array(ReceiptAuthorityDatabaseRowSchema))( + selected, + { onExcessProperty: "error" }, + ).pipe(Effect.mapError((cause) => decodeError("decode Receipt authority rows", cause))); + + const paymentAuthorities: Array = []; + const approvalGrants: Array = []; + const settlementGrants: Array = []; - if (row.authorityKind === "Payment") { - paymentAuthorities.push(yield* paymentRecord(row)); - } else if (row.authorityKind === "Approval") { - approvalGrants.push(yield* approvalGrantRecord(row)); - } else { - settlementGrants.push(yield* settlementGrantRecord(row)); + for (const row of rows) { + if (row.personId !== personId) { + return yield* decodeError( + "decode Receipt authority rows", + "query returned an authority for another person", + ); + } + + if (row.authorityKind === "Payment") { + paymentAuthorities.push(yield* paymentRecord(row)); + } else if (row.authorityKind === "Approval") { + approvalGrants.push(yield* approvalGrantRecord(row)); + } else { + settlementGrants.push(yield* settlementGrantRecord(row)); + } } - } - return projectReceiptAuthority( - organizationProjection, - paymentAuthorities, - approvalGrants, - settlementGrants, - ); - }); + return projectReceiptAuthority( + organizationProjection, + paymentAuthorities, + approvalGrants, + settlementGrants, + ); + }), +); /** Read projection for a caller-owned repeatable-read, read-only snapshot. */ -export const resolveReceiptAuthorityForRead = ( - personId: PersonId, - authorizationInstant: OrganizationAuthorityInstant, - organizationProjection: OrganizationPersonAuthority, -): Effect.Effect => - Effect.gen(function* () { - const sql = yield* Database; - - return yield* resolveReceiptAuthorityWithSql( - sql, - personId, - authorizationInstant, - organizationProjection, - "None", - ); - }); +export const resolveReceiptAuthorityForRead: { + ( + authorizationInstant: OrganizationAuthorityInstant, + organizationProjection: OrganizationPersonAuthority, + ): ( + personId: PersonId, + ) => Effect.Effect; + ( + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + organizationProjection: OrganizationPersonAuthority, + ): Effect.Effect; +} = dual( + 3, + ( + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + organizationProjection: OrganizationPersonAuthority, + ): Effect.Effect => + Effect.gen(function* () { + const sql = yield* Database; + + return yield* resolveReceiptAuthorityWithSql( + sql, + personId, + authorizationInstant, + organizationProjection, + "None", + ); + }), +); diff --git a/packages/database/src/receipt/cursor.ts b/packages/database/src/receipt/cursor.ts index 5b80ab254..52765267c 100644 --- a/packages/database/src/receipt/cursor.ts +++ b/packages/database/src/receipt/cursor.ts @@ -6,6 +6,7 @@ * `receiptCursorPage`. The cursor keeps the microsecond precision of PostgreSQL timestamps, which a * JavaScript `Date` would lose. */ +import { dual } from "effect/Function"; import { receiptPage, type ReceiptCursorPosition, @@ -59,11 +60,14 @@ export type CursorPositioned = A & { readonly cursorTimestamp: string }; * * @construct pagination */ -export const receiptCursorTimestamp = ( - sql: DatabaseOperations, - column: Statement.Fragment, -): Statement.Fragment => - sql`to_char(${column} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "cursorTimestamp"`; +export const receiptCursorTimestamp: { + (column: Statement.Fragment): (sql: DatabaseOperations) => Statement.Fragment; + (sql: DatabaseOperations, column: Statement.Fragment): Statement.Fragment; +} = dual( + 2, + (sql: DatabaseOperations, column: Statement.Fragment): Statement.Fragment => + sql`to_char(${column} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "cursorTimestamp"`, +); /** * Drops the ordering text from a row before the row leaves the adapter. diff --git a/packages/database/src/receipt/file-proof.ts b/packages/database/src/receipt/file-proof.ts index ae8b7b9a2..284a83ab2 100644 --- a/packages/database/src/receipt/file-proof.ts +++ b/packages/database/src/receipt/file-proof.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import type { PgPoolConfig } from "@effect/sql-pg/PgClient"; import * as Migrator from "effect/unstable/sql/Migrator"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -180,11 +181,11 @@ const submit = ( ) => { const commandContext = context(receiptId, visualId, now); - return executeReceiptCommand( - submitCommand(commandId, "Receipt file proof", file), - principal(ownerPersonId, now), - allocation(commandContext), - ); + return executeReceiptCommand({ + command: submitCommand(commandId, "Receipt file proof", file), + principal: principal(ownerPersonId, now), + allocation: allocation(commandContext), + }); }; const hasFailureTag = ( @@ -214,7 +215,7 @@ const drain = ( > => Effect.forEach( Array.from({ length: count }, (_, index) => index), - (index) => deliverNextReceiptOutbox(`${prefix}-${index}`, claimedAt), + (index) => deliverNextReceiptOutbox({ claimId: `${prefix}-${index}`, claimedAt }), ); /** Constraint and index definitions of the tables that `0001-receipt-authority.sql` creates. */ @@ -331,7 +332,7 @@ const upgradedLegacyReceiptSchema = (database: PgPoolConfig) => return yield* receiptSchemaDefinition(sql); }).pipe(Effect.provide(sharedPgLayer(database))); -export const runReceiptFileProof = ( +const runReceiptFileProofImpl = ( fileSnapshot: Effect.Effect, failNextFileEffect: (effectId: string) => Effect.Effect, auxiliaryEffectIds: Effect.Effect>, @@ -405,8 +406,8 @@ export const runReceiptFileProof = ( ); const submitDelivery = yield* drain(3, "claim-submit", "2026-08-20T16:01:00.000Z"); - yield* executeReceiptCommand( - ReceiptCommandRequestSchema.cases.RevisePendingReceipt.make({ + yield* executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.RevisePendingReceipt.make({ commandId: "file-proof-revise", receiptId: ReceiptId.make("file-proof-receipt"), expectedRevision: 0, @@ -415,20 +416,20 @@ export const runReceiptFileProof = ( receiptDate: "2026-08-20", file: replacement, }), - principal(ownerPersonId, "2026-08-20T16:02:00.000Z"), - ); + principal: principal(ownerPersonId, "2026-08-20T16:02:00.000Z"), + }); - const staleClaim = yield* claimNextReceiptOutbox( - "claim-stale-dead-process", - "2026-08-20T16:03:00.000Z", - ); + const staleClaim = yield* claimNextReceiptOutbox({ + claimId: "claim-stale-dead-process", + claimedAt: "2026-08-20T16:03:00.000Z", + }); if (staleClaim === undefined) throw new Error("expected replacement promote claim"); - const staleClaimIds = yield* listStaleReceiptOutboxClaimIds( - "2026-08-20T16:04:00.000Z", - "file-proof-receipt", - ); + const staleClaimIds = yield* listStaleReceiptOutboxClaimIds({ + claimedBefore: "2026-08-20T16:04:00.000Z", + receiptId: "file-proof-receipt", + }); if (!staleClaimIds.includes(staleClaim.claimId)) { throw new Error("stale Receipt outbox claim was not explicitly discovered"); @@ -452,28 +453,28 @@ export const runReceiptFileProof = ( yield* failNextFileEffect("file-proof-revise:PromoteReceiptFile"); - const failedDelivery = yield* deliverNextReceiptOutbox( - "claim-replacement-failure", - "2026-08-20T16:05:00.000Z", - ); + const failedDelivery = yield* deliverNextReceiptOutbox({ + claimId: "claim-replacement-failure", + claimedAt: "2026-08-20T16:05:00.000Z", + }); const currentAfterFailure = yield* fileSnapshot; - const replacementDelivery = yield* deliverNextReceiptOutbox( - "claim-replacement-retry", - "2026-08-20T16:06:00.000Z", - ); + const replacementDelivery = yield* deliverNextReceiptOutbox({ + claimId: "claim-replacement-retry", + claimedAt: "2026-08-20T16:06:00.000Z", + }); const reviseRemainder = yield* drain(2, "claim-revise", "2026-08-20T16:07:00.000Z"); - yield* executeReceiptCommand( - ReceiptCommandRequestSchema.cases.WithdrawPendingReceipt.make({ + yield* executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.WithdrawPendingReceipt.make({ commandId: "file-proof-withdraw", receiptId: ReceiptId.make("file-proof-receipt"), expectedRevision: 1, }), - principal(ownerPersonId, "2026-08-20T16:08:00.000Z"), - ); + principal: principal(ownerPersonId, "2026-08-20T16:08:00.000Z"), + }); const withdrawDelivery = yield* drain(2, "claim-withdraw", "2026-08-20T16:09:00.000Z"); yield* submit( @@ -492,8 +493,14 @@ export const runReceiptFileProof = ( const workerClaims = yield* Effect.all( [ - claimNextReceiptOutbox("claim-worker-a", "2026-08-20T16:10:30.000Z"), - claimNextReceiptOutbox("claim-worker-b", "2026-08-20T16:10:30.000Z"), + claimNextReceiptOutbox({ + claimId: "claim-worker-a", + claimedAt: "2026-08-20T16:10:30.000Z", + }), + claimNextReceiptOutbox({ + claimId: "claim-worker-b", + claimedAt: "2026-08-20T16:10:30.000Z", + }), ], { concurrency: "unbounded" }, ); @@ -519,24 +526,24 @@ export const runReceiptFileProof = ( const [approve, reject] = yield* Effect.all( [ Effect.exit( - executeReceiptCommand( - ReceiptCommandRequestSchema.cases.ApproveReceipt.make({ + executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.ApproveReceipt.make({ commandId: "file-proof-race-approve", receiptId: ReceiptId.make("file-proof-race-receipt"), expectedRevision: 0, }), - principal(approverPersonId, raceContext.now), - ), + principal: principal(approverPersonId, raceContext.now), + }), ), Effect.exit( - executeReceiptCommand( - ReceiptCommandRequestSchema.cases.RejectReceipt.make({ + executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.RejectReceipt.make({ commandId: "file-proof-race-reject", receiptId: ReceiptId.make("file-proof-race-receipt"), expectedRevision: 0, }), - principal(approverPersonId, raceContext.now), - ), + principal: principal(approverPersonId, raceContext.now), + }), ), ], { concurrency: "unbounded" }, @@ -578,18 +585,18 @@ export const runReceiptFileProof = ( const identicalCommandResults = yield* Effect.all( [ Effect.exit( - executeReceiptCommand( - identicalCommand, - principal(ownerPersonId, identicalContext.now), - allocation(identicalContext), - ), + executeReceiptCommand({ + command: identicalCommand, + principal: principal(ownerPersonId, identicalContext.now), + allocation: allocation(identicalContext), + }), ), Effect.exit( - executeReceiptCommand( - identicalCommand, - principal(ownerPersonId, identicalContext.now), - allocation(identicalContext), - ), + executeReceiptCommand({ + command: identicalCommand, + principal: principal(ownerPersonId, identicalContext.now), + allocation: allocation(identicalContext), + }), ), ], { concurrency: "unbounded" }, @@ -610,26 +617,26 @@ export const runReceiptFileProof = ( const conflictingCommandResults = yield* Effect.all( [ Effect.exit( - executeReceiptCommand( - submitCommand( + executeReceiptCommand({ + command: submitCommand( "file-proof-concurrent-conflict", "Concurrent conflicting submission A", conflictFile, ), - principal(ownerPersonId, conflictingContext.now), - allocation(conflictingContext), - ), + principal: principal(ownerPersonId, conflictingContext.now), + allocation: allocation(conflictingContext), + }), ), Effect.exit( - executeReceiptCommand( - submitCommand( + executeReceiptCommand({ + command: submitCommand( "file-proof-concurrent-conflict", "Concurrent conflicting submission B", conflictFile, ), - principal(ownerPersonId, conflictingContext.now), - allocation(conflictingContext), - ), + principal: principal(ownerPersonId, conflictingContext.now), + allocation: allocation(conflictingContext), + }), ), ], { concurrency: "unbounded" }, @@ -793,3 +800,19 @@ export const runReceiptFileProof = ( return evidence; }); + +export const runReceiptFileProof: { + ( + failNextFileEffect: (effectId: string) => Effect.Effect, + auxiliaryEffectIds: Effect.Effect>, + legacyUpgradeDatabase: PgPoolConfig, + ): ( + fileSnapshot: Effect.Effect, + ) => ReturnType; + ( + fileSnapshot: Effect.Effect, + failNextFileEffect: (effectId: string) => Effect.Effect, + auxiliaryEffectIds: Effect.Effect>, + legacyUpgradeDatabase: PgPoolConfig, + ): ReturnType; +} = dual(4, runReceiptFileProofImpl); diff --git a/packages/database/src/receipt/outbox.ts b/packages/database/src/receipt/outbox.ts index c74bf2150..70e0a7ddb 100644 --- a/packages/database/src/receipt/outbox.ts +++ b/packages/database/src/receipt/outbox.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { Database } from "../service.js"; import { markOutboxDelivered, @@ -38,7 +39,20 @@ interface ClaimIdRow { } const persistenceError = (operation: string, cause: unknown) => - new ReceiptPersistenceError({ operation, message: String(cause) }); + ReceiptPersistenceError.make({ operation, message: String(cause) }); + +/** The claim that a worker takes on the next deliverable effect, optionally scoped to one receipt. */ +export interface ReceiptOutboxClaimInput { + readonly claimId: string; + readonly claimedAt: string; + readonly receiptId?: string | undefined; +} + +/** The instant before which a held claim is stale, optionally scoped to one receipt. */ +export interface StaleReceiptOutboxClaimsInput { + readonly claimedBefore: string; + readonly receiptId?: string | undefined; +} const receiptOutbox: OutboxTable = { name: "economy_receipt_outbox", terminalPayload: "Retain" }; @@ -46,11 +60,11 @@ const receiptOutbox: OutboxTable = { name: "economy_receipt_outbox", terminalPay * Claims the next deliverable effect. An envelope that does not decode, or that names another * effect or command, is quarantined in the claim transaction and yields `undefined`. */ -export const claimNextReceiptOutbox = ( - claimId: string, - claimedAt: string, - receiptId?: string, -): Effect.Effect< +export const claimNextReceiptOutbox = ({ + claimId, + claimedAt, + receiptId, +}: ReceiptOutboxClaimInput): Effect.Effect< ClaimedReceiptOutbox | undefined, ReceiptPersistenceError | OutboxClaimLost, Database @@ -157,20 +171,37 @@ export const completeReceiptOutbox = ( ), ); -export const failReceiptOutbox = ( - claim: ClaimedReceiptOutbox, - failureTag: string, -): Effect.Effect => - Database.use((sql) => markOutboxFailed(sql, receiptOutbox, claim, failureTag)).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("fail Receipt outbox", cause)), +export const failReceiptOutbox: { + ( + failureTag: string, + ): ( + claim: ClaimedReceiptOutbox, + ) => Effect.Effect; + ( + claim: ClaimedReceiptOutbox, + failureTag: string, + ): Effect.Effect; +} = dual( + 2, + ( + claim: ClaimedReceiptOutbox, + failureTag: string, + ): Effect.Effect => + Database.use((sql) => markOutboxFailed(sql, receiptOutbox, claim, failureTag)).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("fail Receipt outbox", cause)), + ), ), - ); +); -export const listStaleReceiptOutboxClaimIds = ( - claimedBefore: string, - receiptId?: string, -): Effect.Effect, ReceiptPersistenceError, Database> => +export const listStaleReceiptOutboxClaimIds = ({ + claimedBefore, + receiptId, +}: StaleReceiptOutboxClaimsInput): Effect.Effect< + ReadonlyArray, + ReceiptPersistenceError, + Database +> => Effect.gen(function* () { const sql = yield* Database; const receiptScope = receiptId ?? null; @@ -198,20 +229,31 @@ export const listStaleReceiptOutboxClaimIds = ( return rows.map((row) => row.claim_id); }); -export const recoverStaleReceiptOutbox = ( - claimId: string, - claimedBefore: string, -): Effect.Effect => - Database.use((sql) => - recoverStaleOutboxClaim(sql, receiptOutbox, claimId, claimedBefore, { - status: "Failed", - failureTag: "StaleReceiptOutboxClaim", - }), - ).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("recover stale Receipt outbox", cause)), +export const recoverStaleReceiptOutbox: { + ( + claimedBefore: string, + ): (claimId: string) => Effect.Effect; + ( + claimId: string, + claimedBefore: string, + ): Effect.Effect; +} = dual( + 2, + ( + claimId: string, + claimedBefore: string, + ): Effect.Effect => + Database.use((sql) => + recoverStaleOutboxClaim(sql, receiptOutbox, claimId, claimedBefore, { + status: "Failed", + failureTag: "StaleReceiptOutboxClaim", + }), + ).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("recover stale Receipt outbox", cause)), + ), ), - ); +); const interpretReceiptOutbox = ( request: ReceiptOutboxRequest, @@ -220,30 +262,27 @@ const interpretReceiptOutbox = ( void, ReceiptFileFailure | ReceiptAuxiliaryEffectConflict | ReceiptDeliveryUnavailable, ReceiptFileService | ReceiptAuxiliaryEffects -> => { - return Match.value(request).pipe( - Match.tag("PromoteReceiptFile", "DeleteReceiptFile", (request) => { - return ReceiptFileService.use(({ apply }) => apply(request)); - }), +> => + Match.value(request).pipe( + Match.tag("PromoteReceiptFile", "DeleteReceiptFile", (request) => + ReceiptFileService.use(({ apply }) => apply(request)), + ), Match.tag( "NotifyEconomyReceiptSubmitted", "NotifyReceiptApproved", "NotifyReceiptRejected", "NotifyReceiptSettled", "WriteReceiptAudit", - (request) => { - return ReceiptAuxiliaryEffects.use(({ apply }) => apply(request, claimId)); - }, + (request) => ReceiptAuxiliaryEffects.use(({ apply }) => apply(request, claimId)), ), Match.exhaustive, ); -}; -export const deliverNextReceiptOutbox = ( - claimId: string, - claimedAt: string, - receiptId?: string, -): Effect.Effect< +export const deliverNextReceiptOutbox = ({ + claimId, + claimedAt, + receiptId, +}: ReceiptOutboxClaimInput): Effect.Effect< ReceiptOutboxDeliveryResult, ReceiptPersistenceError, Database | ReceiptFileService | ReceiptAuxiliaryEffects @@ -259,7 +298,7 @@ export const deliverNextReceiptOutbox = ( }), ); - const claim = yield* claimNextReceiptOutbox(claimId, acquiredAt, receiptId); + const claim = yield* claimNextReceiptOutbox({ claimId, claimedAt: acquiredAt, receiptId }); if (claim === undefined) return ReceiptOutboxDeliveryResult.Idle(); diff --git a/packages/database/src/receipt/postgres-layer.ts b/packages/database/src/receipt/postgres-layer.ts index d08854c5d..e0d2a9fbc 100644 --- a/packages/database/src/receipt/postgres-layer.ts +++ b/packages/database/src/receipt/postgres-layer.ts @@ -32,13 +32,13 @@ export const EconomyLive = Layer.effect( return Economy.of({ executeReceipt: (input, principal, allocation) => - executeReceiptCommand(input, principal, allocation).pipe( + executeReceiptCommand({ command: input, principal, allocation }).pipe( Effect.provideService(Database, database), ), authorizeReceiptMutation: (target, principal) => authorizeReceiptMutation(target, principal).pipe(Effect.provideService(Database, database)), executeAuthorizedReceipt: (input, authorization, allocation) => - executeAuthorizedReceiptCommand(input, authorization, allocation).pipe( + executeAuthorizedReceiptCommand({ command: input, authorization, allocation }).pipe( Effect.provideService(Database, database), ), readReceiptSettlementRevision: (receiptId, principal) => @@ -48,15 +48,15 @@ export const EconomyLive = Layer.effect( recordReceiptSettlement: (input, principal) => recordReceiptSettlement(input, principal).pipe(Effect.provideService(Database, database)), listOwnedReceipts: (ownerPersonId, status, after) => - listOwnedReceiptProjection(ownerPersonId, status, after).pipe( + listOwnedReceiptProjection({ ownerPersonId, status, after }).pipe( Effect.provideService(Database, database), ), listReceiptsForApproval: (personId, authorizationInstant, status, after) => - listReceiptsForApprovalPostgres(personId, authorizationInstant, status, after).pipe( + listReceiptsForApprovalPostgres({ personId, authorizationInstant, status, after }).pipe( Effect.provideService(Database, database), ), listReceiptsForSettlement: (personId, authorizationInstant, after) => - listReceiptsForSettlement(personId, authorizationInstant, after).pipe( + listReceiptsForSettlement({ personId, authorizationInstant, after }).pipe( Effect.provideService(Database, database), ), readReceiptFileForApproval: (receiptId, personId, authorizationInstant) => @@ -73,7 +73,7 @@ export const EconomyLive = Layer.effect( ), receiptStatusTotals: receiptStatusTotals.pipe(Effect.provideService(Database, database)), listStaleOutboxClaims: (claimedBefore, receiptId) => - listStaleReceiptOutboxClaimIds(claimedBefore, receiptId).pipe( + listStaleReceiptOutboxClaimIds({ claimedBefore, receiptId }).pipe( Effect.provideService(Database, database), ), recoverStaleOutboxClaim: (claimId, claimedBefore) => @@ -81,7 +81,7 @@ export const EconomyLive = Layer.effect( Effect.provideService(Database, database), ), deliverNextOutboxEffect: (claimId, claimedAt, receiptId) => - deliverNextReceiptOutbox(claimId, claimedAt, receiptId).pipe( + deliverNextReceiptOutbox({ claimId, claimedAt, receiptId }).pipe( Effect.provideService(Database, database), ), }); diff --git a/packages/database/src/receipt/postgres-proof.ts b/packages/database/src/receipt/postgres-proof.ts index 36794ffd5..6fd91aeef 100644 --- a/packages/database/src/receipt/postgres-proof.ts +++ b/packages/database/src/receipt/postgres-proof.ts @@ -188,66 +188,66 @@ export const runReceiptPostgresProof = Effect.gen(function* () { const firstContext = context("proof-receipt-1", "PROOF-0001", "2026-08-20T12:00:00.000Z"); - const submitted = yield* executeReceiptCommand( - submit("proof-command-submit-1", "Travel"), - principal(ownerPersonId, firstContext.now), - allocation(firstContext), - ); + const submitted = yield* executeReceiptCommand({ + command: submit("proof-command-submit-1", "Travel"), + principal: principal(ownerPersonId, firstContext.now), + allocation: allocation(firstContext), + }); - const replay = yield* executeReceiptCommand( - submit("proof-command-submit-1", "Travel"), - principal(ownerPersonId, firstContext.now), - allocation(firstContext), - ); + const replay = yield* executeReceiptCommand({ + command: submit("proof-command-submit-1", "Travel"), + principal: principal(ownerPersonId, firstContext.now), + allocation: allocation(firstContext), + }); const conflictingReplay = yield* Effect.exit( - executeReceiptCommand( - submit("proof-command-submit-1", "Changed travel"), - principal(ownerPersonId, firstContext.now), - allocation(firstContext), - ), + executeReceiptCommand({ + command: submit("proof-command-submit-1", "Changed travel"), + principal: principal(ownerPersonId, firstContext.now), + allocation: allocation(firstContext), + }), ); const wrongScope = yield* Effect.exit( - executeReceiptCommand( - ReceiptCommandRequestSchema.cases.ApproveReceipt.make({ + executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.ApproveReceipt.make({ commandId: "proof-command-wrong-scope", receiptId: ReceiptId.make("proof-receipt-1"), expectedRevision: 0, }), - principal(wrongScopeApproverPersonId, "2026-08-20T12:01:00.000Z"), - ), + principal: principal(wrongScopeApproverPersonId, "2026-08-20T12:01:00.000Z"), + }), ); - const approved = yield* executeReceiptCommand( - ReceiptCommandRequestSchema.cases.ApproveReceipt.make({ + const approved = yield* executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.ApproveReceipt.make({ commandId: "proof-command-approve", receiptId: ReceiptId.make("proof-receipt-1"), expectedRevision: 0, }), - principal(approverPersonId, "2026-08-20T12:02:00.000Z"), - ); + principal: principal(approverPersonId, "2026-08-20T12:02:00.000Z"), + }); const terminalTransition = yield* Effect.exit( - executeReceiptCommand( - ReceiptCommandRequestSchema.cases.RejectReceipt.make({ + executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.RejectReceipt.make({ commandId: "proof-command-terminal", receiptId: ReceiptId.make("proof-receipt-1"), expectedRevision: 1, }), - principal(approverPersonId, "2026-08-20T12:03:00.000Z"), - ), + principal: principal(approverPersonId, "2026-08-20T12:03:00.000Z"), + }), ); const secondContext = context("proof-receipt-2", "PROOF-0002", "2026-08-20T13:00:00.000Z"); - yield* executeReceiptCommand( - submit("proof-command-submit-2", "Supplies", secondFile), - principal(ownerPersonId, secondContext.now), - allocation(secondContext), - ); + yield* executeReceiptCommand({ + command: submit("proof-command-submit-2", "Supplies", secondFile), + principal: principal(ownerPersonId, secondContext.now), + allocation: allocation(secondContext), + }); - const revised = yield* executeReceiptCommand( - ReceiptCommandRequestSchema.cases.RevisePendingReceipt.make({ + const revised = yield* executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.RevisePendingReceipt.make({ commandId: "proof-command-revise", receiptId: ReceiptId.make("proof-receipt-2"), expectedRevision: 0, @@ -256,17 +256,17 @@ export const runReceiptPostgresProof = Effect.gen(function* () { receiptDate: "2026-08-19", file: secondFile, }), - principal(ownerPersonId, "2026-08-20T13:01:00.000Z"), - ); + principal: principal(ownerPersonId, "2026-08-20T13:01:00.000Z"), + }); - const withdrawn = yield* executeReceiptCommand( - ReceiptCommandRequestSchema.cases.WithdrawPendingReceipt.make({ + const withdrawn = yield* executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.WithdrawPendingReceipt.make({ commandId: "proof-command-withdraw", receiptId: ReceiptId.make("proof-receipt-2"), expectedRevision: 1, }), - principal(ownerPersonId, "2026-08-20T13:02:00.000Z"), - ); + principal: principal(ownerPersonId, "2026-08-20T13:02:00.000Z"), + }); yield* sql.unsafe(` CREATE FUNCTION reject_receipt_proof_audit() RETURNS trigger AS $$ @@ -284,11 +284,11 @@ export const runReceiptPostgresProof = Effect.gen(function* () { const rollbackContext = context("proof-receipt-3", "PROOF-0003", "2026-08-20T14:00:00.000Z"); const failedTransaction = yield* Effect.exit( - executeReceiptCommand( - submit("proof-command-rollback", "Rollback after durable writes", rollbackFile), - principal(ownerPersonId, rollbackContext.now), - allocation(rollbackContext), - ), + executeReceiptCommand({ + command: submit("proof-command-rollback", "Rollback after durable writes", rollbackFile), + principal: principal(ownerPersonId, rollbackContext.now), + allocation: allocation(rollbackContext), + }), ); yield* sql.unsafe(` @@ -354,18 +354,18 @@ export const runReceiptPostgresProof = Effect.gen(function* () { ); const invalidAmount = yield* Effect.exit( - executeReceiptCommand( - { ...submit("proof-command-invalid-amount", "Invalid amount"), amountOre: 0 }, - principal(ownerPersonId, invalidContext.now), - allocation(invalidContext), - ), + executeReceiptCommand({ + command: { ...submit("proof-command-invalid-amount", "Invalid amount"), amountOre: 0 }, + principal: principal(ownerPersonId, invalidContext.now), + allocation: allocation(invalidContext), + }), ); const assistantReceiptIds: string[] = []; let ownerCursor: string | undefined; do { - const page = yield* listOwnedReceiptProjection(ownerPersonId, undefined, ownerCursor); + const page = yield* listOwnedReceiptProjection({ ownerPersonId, after: ownerCursor }); for (const row of page.items) assistantReceiptIds.push(row.receiptId); ownerCursor = page.nextCursor; @@ -375,7 +375,7 @@ export const runReceiptPostgresProof = Effect.gen(function* () { let position: ReceiptCursorPosition | undefined; while (true) { - const rows = yield* listApproverReceipts(undefined, position); + const rows = yield* listApproverReceipts({ after: position }); for (const row of rows) approverReceiptIds.push(row.receiptId); diff --git a/packages/database/src/receipt/postgres.ts b/packages/database/src/receipt/postgres.ts index f9096dac0..13cbc3f0a 100644 --- a/packages/database/src/receipt/postgres.ts +++ b/packages/database/src/receipt/postgres.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { PrincipalSchema, AuthorityVersion, @@ -149,7 +150,7 @@ const StoredReceiptCommandEnvelopeSchema = Schema.Struct({ }); const persistenceError = (operation: string, cause: unknown) => - new ReceiptPersistenceError({ operation, message: String(cause), cause }); + ReceiptPersistenceError.make({ operation, message: String(cause), cause }); const receiptFromRow = ( row: typeof Receipt.Encoded, @@ -277,7 +278,7 @@ const storeReceipt = ( rows.length === 1 ? Effect.void : Effect.fail( - new StaleReceiptRevision({ + StaleReceiptRevision.make({ receiptId: receipt.receiptId, expected: previous.revision, actual: previous.revision, @@ -309,16 +310,29 @@ const storeOutbox = ( ); /** All native and reviewed importers serialize ownership of a legacy source receipt. */ -export const lockReceiptImportSource = ( - sql: DatabaseOperations, - sourceRepository: string, - sourcePrimaryKey: string, -): Effect.Effect => - lockAdvisory(sql, AdvisoryLockKey.receiptImportSource(sourceRepository, sourcePrimaryKey)).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("lock receipt source ownership", cause)), +export const lockReceiptImportSource: { + ( + sourceRepository: string, + sourcePrimaryKey: string, + ): (sql: DatabaseOperations) => Effect.Effect; + ( + sql: DatabaseOperations, + sourceRepository: string, + sourcePrimaryKey: string, + ): Effect.Effect; +} = dual( + 3, + ( + sql: DatabaseOperations, + sourceRepository: string, + sourcePrimaryKey: string, + ): Effect.Effect => + lockAdvisory(sql, AdvisoryLockKey.receiptImportSource(sourceRepository, sourcePrimaryKey)).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("lock receipt source ownership", cause)), + ), ), - ); +); export const storeReceiptImportResult = ( result: ReceiptImportResult, @@ -523,18 +537,30 @@ export const storeReceiptImportResult = ( * authority to skip subsequent reads. A failed observation durably returns it * to Pending. Filesystem and SQL do not form a distributed transaction. */ -export const reconcileReceiptImport = ( - expected: Extract, - observe: (receipt: Receipt) => Effect.Effect, -): Effect.Effect => - Effect.gen(function* () { - const sql = yield* Database; - const p = expected.provenance; - - return yield* sql - .withTransaction( - Effect.gen(function* () { - const ledger = yield* sql<{ readonly source_digest: string; readonly result: string }>` +export const reconcileReceiptImport: { + ( + observe: (receipt: Receipt) => Effect.Effect, + ): ( + expected: Extract, + ) => Effect.Effect; + ( + expected: Extract, + observe: (receipt: Receipt) => Effect.Effect, + ): Effect.Effect; +} = dual( + 2, + ( + expected: Extract, + observe: (receipt: Receipt) => Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const sql = yield* Database; + const p = expected.provenance; + + return yield* sql + .withTransaction( + Effect.gen(function* () { + const ledger = yield* sql<{ readonly source_digest: string; readonly result: string }>` SELECT source_digest, result FROM economy_receipt_import_ledger WHERE source_repository = ${p.sourceRepository} AND source_revision = ${p.sourceRevision} AND snapshot_id = ${p.snapshotId} AND source_primary_key = ${expected.sourcePrimaryKey} @@ -546,29 +572,29 @@ export const reconcileReceiptImport = ( FOR UPDATE `; - if ( - ledger.length !== 1 || - ledger[0]?.source_digest !== p.sourceDigest || - ledger[0]?.result !== "Accepted" - ) { - return yield* persistenceError( - "reconcile receipt import", - "exact accepted occurrence required", - ); - } + if ( + ledger.length !== 1 || + ledger[0]?.source_digest !== p.sourceDigest || + ledger[0]?.result !== "Accepted" + ) { + return yield* persistenceError( + "reconcile receipt import", + "exact accepted occurrence required", + ); + } - const actual = yield* findReceipt(sql, expected.receipt.receiptId); + const actual = yield* findReceipt(sql, expected.receipt.receiptId); - // The import result carries a plain receipt; Equal compares models only with models. - const matches = - actual !== undefined && Equal.equals(actual, new Receipt(expected.receipt)); + // The import result carries a plain receipt; Equal compares models only with models. + const matches = + actual !== undefined && Equal.equals(actual, Receipt.make(expected.receipt)); - const observed = - matches && actual !== undefined - ? yield* observe(actual).pipe(Effect.orElseSucceed(() => false)) - : false; + const observed = + matches && actual !== undefined + ? yield* observe(actual).pipe(Effect.orElseSucceed(() => false)) + : false; - yield* sql` + yield* sql` UPDATE economy_receipt_import_ledger SET reconciliation_result = ${observed ? "Reconciled" : "Pending"} WHERE source_repository = ${p.sourceRepository} AND source_revision = ${p.sourceRevision} AND snapshot_id = ${p.snapshotId} AND source_primary_key = ${expected.sourcePrimaryKey} @@ -576,26 +602,39 @@ export const reconcileReceiptImport = ( AND transformation_revision = ${p.transformationRevision} `; - return observed; - }), - ) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("reconcile receipt import", cause)), - ), - ); - }); + return observed; + }), + ) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("reconcile receipt import", cause)), + ), + ); + }), +); + +/** The person, the authorization instant, the status filter, and the cursor of an approval queue read. */ +export interface ReceiptApprovalQueueInput { + readonly personId: PersonId; + readonly authorizationInstant: OrganizationAuthorityInstant; + readonly status?: ReceiptStatus | undefined; + readonly after?: string | undefined; +} /** * Rule-aware approval projection. Session identity and the instant are explicit * query inputs; every authority source is read without locks in one snapshot. */ -export const listReceiptsForApproval = ( - personId: PersonId, - authorizationInstant: OrganizationAuthorityInstant, - status?: ReceiptStatus, - after?: string, -): Effect.Effect, ReceiptApprovalListFailure, Database> => +export const listReceiptsForApproval = ({ + personId, + authorizationInstant, + status, + after, +}: ReceiptApprovalQueueInput): Effect.Effect< + ReceiptPage, + ReceiptApprovalListFailure, + Database +> => Effect.gen(function* () { const sql = yield* Database; @@ -616,7 +655,7 @@ export const listReceiptsForApproval = ( "resolve Receipt approval list Organization authority", cause.message, ) - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `${cause.operation}: ${cause.message}`, }), ), @@ -632,7 +671,7 @@ export const listReceiptsForApproval = ( ? cause : Predicate.isTagged(cause, "ReceiptDecodeError") ? cause - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `Receipt authority projection mismatch for ${cause.personId}`, }), ), @@ -652,7 +691,7 @@ export const listReceiptsForApproval = ( visible.length <= RECEIPT_PAGE_SIZE && directAuthority.organizationAuthority === "Active" ) { - const candidates = yield* listApproverReceipts(status, position); + const candidates = yield* listApproverReceipts({ status, after: position }); const applicable = yield* Effect.forEach(candidates, (candidate) => readApplicableAuthorizationRules( @@ -666,7 +705,7 @@ export const listReceiptsForApproval = ( Effect.mapError((cause) => Predicate.isTagged(cause, "AuthzPersistenceError") ? persistenceError(cause.operation, cause.message) - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `${cause.entity}: ${cause.message}`, }), ), @@ -727,10 +766,10 @@ export const listReceiptsForApproval = ( if (compositionFailure !== undefined) return yield* compositionFailure; if (decision.reason === "AuthorityInactive") { - return yield* new InactiveActor({ personId }); + return yield* InactiveActor.make({ personId }); } - return yield* new ReceiptScopeDenied({ + return yield* ReceiptScopeDenied.make({ receiptId: "approval-projection", departmentId: "", }); @@ -751,15 +790,27 @@ export const listReceiptsForApproval = ( * read-only transaction. The request credential, row, Organization authority, * direct authority, and rules must share that caller-owned snapshot. */ -export const readReceiptFileForApproval = ( - receiptId: string, - personId: PersonId, - authorizationInstant: OrganizationAuthorityInstant, -): Effect.Effect => - Effect.gen(function* () { - const sql = yield* Database; - - const rows = yield* sql` +export const readReceiptFileForApproval: { + ( + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + ): (receiptId: string) => Effect.Effect; + ( + receiptId: string, + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + ): Effect.Effect; +} = dual( + 3, + ( + receiptId: string, + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + ): Effect.Effect => + Effect.gen(function* () { + const sql = yield* Database; + + const rows = yield* sql` SELECT receipt_id AS "receiptId", owner_person_id AS "ownerPersonId", @@ -776,115 +827,121 @@ export const readReceiptFileForApproval = ( FROM economy_receipts WHERE receipt_id = ${receiptId} `.pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("read Receipt approval file metadata", cause)), - ), - ); + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("read Receipt approval file metadata", cause)), + ), + ); - const selected = rows[0]; + const selected = rows[0]; - if (selected === undefined) return yield* new ReceiptNotFound({ receiptId }); + if (selected === undefined) return yield* ReceiptNotFound.make({ receiptId }); - const row = yield* Schema.decodeEffect(ReceiptApprovalFileReadRowSchema)(selected, { - onExcessProperty: "error", - }).pipe( - Effect.mapError((cause) => persistenceError("decode Receipt approval file metadata", cause)), - ); + const row = yield* Schema.decodeEffect(ReceiptApprovalFileReadRowSchema)(selected, { + onExcessProperty: "error", + }).pipe( + Effect.mapError((cause) => + persistenceError("decode Receipt approval file metadata", cause), + ), + ); - const candidate: ReceiptApprovalCandidate = { - receiptId: row.receiptId, - ownerPersonId: row.ownerPersonId, - departmentId: row.departmentId, - status: row.status, - revision: row.revision, - }; + const candidate: ReceiptApprovalCandidate = { + receiptId: row.receiptId, + ownerPersonId: row.ownerPersonId, + departmentId: row.departmentId, + status: row.status, + revision: row.revision, + }; - const organization = yield* resolveOrganizationPersonAuthorityWithSql( - sql, - personId, - authorizationInstant, - "None", - ).pipe( - Effect.mapError((cause) => - Predicate.isTagged(cause, "OrganizationPersistenceError") - ? persistenceError("resolve Receipt approval file Organization authority", cause.message) - : new ReceiptDecodeError({ - message: `${cause.operation}: ${cause.message}`, - }), - ), - ); + const organization = yield* resolveOrganizationPersonAuthorityWithSql( + sql, + personId, + authorizationInstant, + "None", + ).pipe( + Effect.mapError((cause) => + Predicate.isTagged(cause, "OrganizationPersistenceError") + ? persistenceError( + "resolve Receipt approval file Organization authority", + cause.message, + ) + : ReceiptDecodeError.make({ + message: `${cause.operation}: ${cause.message}`, + }), + ), + ); - const directAuthority = yield* resolveReceiptAuthorityWithSql( - sql, - personId, - authorizationInstant, - organization, - "None", - ).pipe( - Effect.mapError((cause) => - Predicate.isTagged(cause, "ReceiptPersistenceError") - ? cause - : Predicate.isTagged(cause, "ReceiptDecodeError") + const directAuthority = yield* resolveReceiptAuthorityWithSql( + sql, + personId, + authorizationInstant, + organization, + "None", + ).pipe( + Effect.mapError((cause) => + Predicate.isTagged(cause, "ReceiptPersistenceError") ? cause - : new ReceiptDecodeError({ - message: `Receipt authority projection mismatch for ${cause.personId}`, + : Predicate.isTagged(cause, "ReceiptDecodeError") + ? cause + : ReceiptDecodeError.make({ + message: `Receipt authority projection mismatch for ${cause.personId}`, + }), + ), + ); + + const applicable = yield* readApplicableAuthorizationRules( + sql, + PrincipalSchema.cases.Person.make({ personId }), + "approveReceipt", + authorizationInstant, + makeReceiptApprovalContext(candidate, organization, directAuthority, []), + "None", + ).pipe( + Effect.mapError((cause) => + Predicate.isTagged(cause, "AuthzPersistenceError") + ? persistenceError(cause.operation, cause.message) + : ReceiptDecodeError.make({ + message: `${cause.entity}: ${cause.message}`, }), - ), - ); + ), + ); - const applicable = yield* readApplicableAuthorizationRules( - sql, - PrincipalSchema.cases.Person.make({ personId }), - "approveReceipt", - authorizationInstant, - makeReceiptApprovalContext(candidate, organization, directAuthority, []), - "None", - ).pipe( - Effect.mapError((cause) => - Predicate.isTagged(cause, "AuthzPersistenceError") - ? persistenceError(cause.operation, cause.message) - : new ReceiptDecodeError({ - message: `${cause.entity}: ${cause.message}`, - }), - ), - ); + const decision = selectAuthorizedReceiptFileForApproval( + organization, + directAuthority, + candidate, + applicable.rules, + applicable.tagAssignments, + ); - const decision = selectAuthorizedReceiptFileForApproval( - organization, - directAuthority, - candidate, - applicable.rules, - applicable.tagAssignments, - ); + if (Predicate.isTagged(decision, "Deny")) { + const compositionFailure = receiptCompositionFailure( + decision.reason, + personId, + "approveReceipt", + ); - if (Predicate.isTagged(decision, "Deny")) { - const compositionFailure = receiptCompositionFailure( - decision.reason, - personId, - "approveReceipt", - ); + if (compositionFailure !== undefined) return yield* compositionFailure; - if (compositionFailure !== undefined) return yield* compositionFailure; + if (decision.reason === "AuthorityInactive") { + return yield* InactiveActor.make({ personId }); + } - if (decision.reason === "AuthorityInactive") { - return yield* new InactiveActor({ personId }); + return yield* ReceiptScopeDenied.make({ + receiptId: candidate.receiptId, + departmentId: candidate.departmentId, + }); } - return yield* new ReceiptScopeDenied({ - receiptId: candidate.receiptId, - departmentId: candidate.departmentId, - }); - } - - if (!decision.value.receiptIds.includes(candidate.receiptId)) { - return yield* new ReceiptScopeDenied({ - receiptId: candidate.receiptId, - departmentId: candidate.departmentId, - }); - } + if (!decision.value.receiptIds.includes(candidate.receiptId)) { + return yield* ReceiptScopeDenied.make({ + receiptId: candidate.receiptId, + departmentId: candidate.departmentId, + }); + } - return row.file; - }); + return row.file; + }), +); const authorizeReceiptMutationWithSql = ( sql: DatabaseOperations, @@ -897,7 +954,7 @@ const authorizeReceiptMutationWithSql = ( : yield* findReceipt(sql, target.receiptId); if (!Predicate.isTagged(target, "SubmitReceipt") && current === undefined) { - return yield* new ReceiptNotFound({ receiptId: target.receiptId }); + return yield* ReceiptNotFound.make({ receiptId: target.receiptId }); } yield* lockPersonAuthorization(sql, principal.personId).pipe( @@ -912,7 +969,7 @@ const authorizeReceiptMutationWithSql = ( ).pipe( Effect.mapError((cause) => Predicate.isTagged(cause, "OrganizationDecodeError") - ? new ReceiptDecodeError({ + ? ReceiptDecodeError.make({ message: `${cause.operation}: ${cause.message}`, }) : persistenceError(cause.operation, cause.message), @@ -934,7 +991,7 @@ const authorizeReceiptMutationWithSql = ( ? cause : Predicate.isTagged(cause, "ReceiptDecodeError") ? cause - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `Receipt authority projection mismatch for ${cause.personId}`, }), ), @@ -945,7 +1002,7 @@ const authorizeReceiptMutationWithSql = ( (yield* mapReceiptSubmissionPrincipal(directAuthority).pipe( Effect.mapError((cause: ReceiptAuthorityMappingError) => Predicate.isTagged(cause, "AmbiguousReceiptPaymentAuthority") - ? new AmbiguousPaymentSelection({ + ? AmbiguousPaymentSelection.make({ personId: cause.personId, departmentIds: cause.departmentIds, }) @@ -980,7 +1037,7 @@ const authorizeReceiptMutationWithSql = ( Effect.mapError((cause) => Predicate.isTagged(cause, "AuthzPersistenceError") ? persistenceError(cause.operation, cause.message) - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `${cause.entity}: ${cause.message}`, }), ), @@ -1007,7 +1064,7 @@ const authorizeReceiptMutationWithSql = ( if (compositionFailure !== undefined) return yield* compositionFailure; - return yield* new ReceiptAuthorityDenied({ + return yield* ReceiptAuthorityDenied.make({ personId: principal.personId, operation: "Submission", departmentId: canonicalDepartment, @@ -1026,7 +1083,7 @@ const authorizeReceiptMutationWithSql = ( ).pipe( Effect.mapError((cause: ReceiptAuthorityMappingError) => Predicate.isTagged(cause, "AmbiguousReceiptPaymentAuthority") - ? new AmbiguousPaymentSelection({ + ? AmbiguousPaymentSelection.make({ personId: cause.personId, departmentIds: cause.departmentIds, }) @@ -1059,7 +1116,7 @@ const authorizeReceiptMutationWithSql = ( ? cause : Predicate.isTagged(cause, "ReceiptDecodeError") ? cause - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `Receipt authority projection mismatch for ${cause.personId}`, }), ), @@ -1083,7 +1140,7 @@ const authorizeReceiptMutationWithSql = ( Effect.mapError((cause) => Predicate.isTagged(cause, "AuthzPersistenceError") ? persistenceError(cause.operation, cause.message) - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `${cause.entity}: ${cause.message}`, }), ), @@ -1155,31 +1212,44 @@ const authorizeReceiptMutationWithSql = ( return authorization; }); -export const authorizeReceiptMutation = ( - target: ReceiptMutationAuthorizationTarget, - principalInput: ReceiptCommandPrincipal, -): Effect.Effect => - Effect.gen(function* () { - const sql = yield* Database; - - const principal = yield* Schema.decodeEffect(ReceiptCommandPrincipalSchema)(principalInput, { - onExcessProperty: "error", - }).pipe(Effect.mapError((cause) => new ReceiptDecodeError({ message: String(cause) }))); +export const authorizeReceiptMutation: { + ( + principalInput: ReceiptCommandPrincipal, + ): ( + target: ReceiptMutationAuthorizationTarget, + ) => Effect.Effect; + ( + target: ReceiptMutationAuthorizationTarget, + principalInput: ReceiptCommandPrincipal, + ): Effect.Effect; +} = dual( + 2, + ( + target: ReceiptMutationAuthorizationTarget, + principalInput: ReceiptCommandPrincipal, + ): Effect.Effect => + Effect.gen(function* () { + const sql = yield* Database; + + const principal = yield* Schema.decodeEffect(ReceiptCommandPrincipalSchema)(principalInput, { + onExcessProperty: "error", + }).pipe(Effect.mapError((cause) => ReceiptDecodeError.make({ message: String(cause) }))); - return yield* authorizeReceiptMutationWithSql(sql, target, principal); - }); + return yield* authorizeReceiptMutationWithSql(sql, target, principal); + }), +); const decodeReceiptCommand = flow( Schema.decodeUnknownEffect(ReceiptCommandRequestSchema, { onExcessProperty: "error", }), - Effect.mapError((cause) => new ReceiptDecodeError({ message: String(cause) })), + Effect.mapError((cause) => ReceiptDecodeError.make({ message: String(cause) })), ); const decodeReceiptPrincipal = (input: typeof ReceiptCommandPrincipalSchema.Encoded) => Schema.decodeEffect(ReceiptCommandPrincipalSchema)(input, { onExcessProperty: "error", - }).pipe(Effect.mapError((cause) => new ReceiptDecodeError({ message: String(cause) }))); + }).pipe(Effect.mapError((cause) => ReceiptDecodeError.make({ message: String(cause) }))); const targetFromCommand = ( command: typeof ReceiptCommandRequestSchema.Type, @@ -1215,7 +1285,7 @@ const executeAuthorizedReceiptCommandWithSql = ( ): Effect.Effect => Effect.gen(function* () { if (!authorizationMatchesCommand(authorization, command)) { - return yield* new ReceiptDecodeError({ + return yield* ReceiptDecodeError.make({ message: "Receipt mutation authorization does not match the command", }); } @@ -1239,7 +1309,7 @@ const executeAuthorizedReceiptCommandWithSql = ( if (stored !== undefined) { if (stored.command_sha256 !== commandDigest) { - return yield* new DuplicateReceiptCommandConflict({ + return yield* DuplicateReceiptCommandConflict.make({ commandId: command.commandId, }); } @@ -1252,7 +1322,7 @@ const executeAuthorizedReceiptCommandWithSql = ( const replayedReceipt = yield* findReceipt(sql, storedObservation.receiptId); if (replayedReceipt === undefined) { - return yield* new ReceiptNotFound({ receiptId: storedObservation.receiptId }); + return yield* ReceiptNotFound.make({ receiptId: storedObservation.receiptId }); } return { @@ -1267,11 +1337,10 @@ const executeAuthorizedReceiptCommandWithSql = ( ? yield* Schema.decodeUnknownEffect(ReceiptSubmissionAllocationSchema)(allocationInput, { onExcessProperty: "error", }).pipe( - Effect.mapError( - (cause) => - new ReceiptDecodeError({ - message: `decode Receipt submission allocation: ${String(cause)}`, - }), + Effect.mapError((cause) => + ReceiptDecodeError.make({ + message: `decode Receipt submission allocation: ${String(cause)}`, + }), ), ) : undefined; @@ -1282,13 +1351,13 @@ const executeAuthorizedReceiptCommandWithSql = ( if (Predicate.isTagged(command, "SubmitReceipt")) { if (!Predicate.isTagged(authorization, "SubmitReceipt")) { - return yield* new ReceiptDecodeError({ + return yield* ReceiptDecodeError.make({ message: "Receipt submission authorization does not match the command", }); } if (allocation === undefined) { - return yield* new ReceiptDecodeError({ + return yield* ReceiptDecodeError.make({ message: "Receipt submission allocation is required", }); } @@ -1297,7 +1366,7 @@ const executeAuthorizedReceiptCommandWithSql = ( previous = yield* findReceipt(sql, receiptId); if (previous !== undefined) { - return yield* new ReceiptAlreadyExists({ receiptId }); + return yield* ReceiptAlreadyExists.make({ receiptId }); } authorizedCommand = { @@ -1308,7 +1377,7 @@ const executeAuthorizedReceiptCommandWithSql = ( }; } else { if (Predicate.isTagged(authorization, "SubmitReceipt")) { - return yield* new ReceiptDecodeError({ + return yield* ReceiptDecodeError.make({ message: "Existing Receipt authorization does not match the command", }); } @@ -1367,11 +1436,22 @@ const executeAuthorizedReceiptCommandWithSql = ( * connection. Native HTTP replay invokes authority resolution before deciding * whether this effect runs. */ -export const executeAuthorizedReceiptCommand = ( - input: typeof ReceiptCommandRequestSchema.Encoded, - authorization: ReceiptMutationAuthorization, - allocationInput?: ReceiptSubmissionAllocation, -): Effect.Effect => +/** An encoded receipt command, decoded inside the transaction, and the authority already resolved for it. */ +export interface AuthorizedReceiptCommandInput { + readonly command: typeof ReceiptCommandRequestSchema.Encoded; + readonly authorization: ReceiptMutationAuthorization; + readonly allocation?: ReceiptSubmissionAllocation | undefined; +} + +export const executeAuthorizedReceiptCommand = ({ + command: input, + authorization, + allocation: allocationInput, +}: AuthorizedReceiptCommandInput): Effect.Effect< + ReceiptTransactionResult, + ReceiptFailure, + Database +> => Effect.gen(function* () { const sql = yield* Database; const command = yield* decodeReceiptCommand(input); @@ -1384,11 +1464,18 @@ export const executeAuthorizedReceiptCommand = ( ); }); -export const executeReceiptCommand = ( - input: typeof ReceiptCommandRequestSchema.Encoded, - principalInput: typeof ReceiptCommandPrincipalSchema.Encoded, - allocationInput?: ReceiptSubmissionAllocation, -): Effect.Effect => +/** An encoded receipt command and its encoded principal, both decoded inside the transaction. */ +export interface ReceiptCommandInput { + readonly command: typeof ReceiptCommandRequestSchema.Encoded; + readonly principal: typeof ReceiptCommandPrincipalSchema.Encoded; + readonly allocation?: ReceiptSubmissionAllocation | undefined; +} + +export const executeReceiptCommand = ({ + command: input, + principal: principalInput, + allocation: allocationInput, +}: ReceiptCommandInput): Effect.Effect => Effect.gen(function* () { const sql = yield* Database; @@ -1419,8 +1506,7 @@ export const executeReceiptCommand = ( ); }); -/** Owner-only metadata selection precedes all private-file IO. */ -export const readOwnedReceiptFile = (receiptId: string, personId: string) => +const readOwnedReceiptFileImpl = (receiptId: string, personId: string) => Effect.gen(function* () { const sql = yield* Database; @@ -1442,3 +1528,9 @@ export const readOwnedReceiptFile = (receiptId: string, personId: string) => revision: Number(revision), }; }).pipe(Effect.mapError((cause) => persistenceError("read owned receipt file", cause))); + +/** Owner-only metadata selection precedes all private-file IO. */ +export const readOwnedReceiptFile: { + (personId: string): (receiptId: string) => ReturnType; + (receiptId: string, personId: string): ReturnType; +} = dual(2, readOwnedReceiptFileImpl); diff --git a/packages/database/src/receipt/projections.ts b/packages/database/src/receipt/projections.ts index fa8656650..9115c870a 100644 --- a/packages/database/src/receipt/projections.ts +++ b/packages/database/src/receipt/projections.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { Database, type DatabaseOperations } from "../service.js"; import { receiptCursorPage, receiptCursorTimestamp } from "./cursor.js"; import { flow, Effect, Schema } from "effect"; @@ -26,7 +27,7 @@ import type { } from "@vektorprogrammet/domain/receipt"; const projectionError = (operation: string, cause: unknown) => - new ReceiptPersistenceError({ operation, message: String(cause) }); + ReceiptPersistenceError.make({ operation, message: String(cause) }); const decodeSettlementEvidence = flow( Schema.decodeUnknownEffect(ReceiptSettlementEvidenceSelectSchema, { @@ -67,10 +68,20 @@ export interface ReceiptCandidateRow extends ReceiptListItem { readonly cursorTimestamp: string; } -export const listApproverReceipts = ( - status?: ReceiptStatus, - after?: ReceiptCursorPosition, -): Effect.Effect, ReceiptPersistenceError, Database> => +/** The status filter and the cursor position of an approver candidate read. */ +export interface ApproverReceiptsInput { + readonly status?: ReceiptStatus | undefined; + readonly after?: ReceiptCursorPosition | undefined; +} + +export const listApproverReceipts = ({ + status, + after, +}: ApproverReceiptsInput): Effect.Effect< + ReadonlyArray, + ReceiptPersistenceError, + Database +> => Effect.gen(function* () { const sql = yield* Database; const statusPredicate = status === undefined ? sql`TRUE` : sql`status = ${status}`; @@ -195,11 +206,18 @@ const findOwnedReceiptProjection = SqlSchema.findAll({ }), }); -export const listOwnedReceiptProjection = ( - ownerPersonId: string, - status?: ReceiptListItem["status"], - after?: string, -): Effect.Effect< +/** The owner, status filter, and cursor of an owned receipt page. */ +export interface OwnedReceiptProjectionInput { + readonly ownerPersonId: string; + readonly status?: ReceiptListItem["status"] | undefined; + readonly after?: string | undefined; +} + +export const listOwnedReceiptProjection = ({ + ownerPersonId, + status, + after, +}: OwnedReceiptProjectionInput): Effect.Effect< ReceiptPage, ReceiptPersistenceError | ReceiptDecodeError, Database @@ -241,18 +259,38 @@ export interface ReceiptLifecycleFileProjection { readonly sha256: string; } -export const readReceiptLifecycleEvidence = ( - receiptId: string, - ownerPersonId: string, -): Effect.Effect< - ReceiptLifecycleEvidenceProjection, - ReceiptPersistenceError | ReceiptNotFound, - Database -> => - Effect.gen(function* () { - const sql = yield* Database; +export const readReceiptLifecycleEvidence: { + ( + ownerPersonId: string, + ): ( + receiptId: string, + ) => Effect.Effect< + ReceiptLifecycleEvidenceProjection, + ReceiptPersistenceError | ReceiptNotFound, + Database + >; + ( + receiptId: string, + ownerPersonId: string, + ): Effect.Effect< + ReceiptLifecycleEvidenceProjection, + ReceiptPersistenceError | ReceiptNotFound, + Database + >; +} = dual( + 2, + ( + receiptId: string, + ownerPersonId: string, + ): Effect.Effect< + ReceiptLifecycleEvidenceProjection, + ReceiptPersistenceError | ReceiptNotFound, + Database + > => + Effect.gen(function* () { + const sql = yield* Database; - const receipts = yield* sql` + const receipts = yield* sql` SELECT file_ref AS "fileRef", file_object_key AS "objectKey", file_content_type AS "contentType", file_byte_length::text AS "byteLength", file_sha256 AS "sha256" @@ -260,12 +298,12 @@ export const readReceiptLifecycleEvidence = ( WHERE receipt_id = ${receiptId} AND owner_person_id = ${ownerPersonId} `; - const receipt = receipts[0]; + const receipt = receipts[0]; - if (receipt === undefined) return yield* new ReceiptNotFound({ receiptId }); - const settlement = yield* selectSettlementEvidence(sql, receiptId); + if (receipt === undefined) return yield* ReceiptNotFound.make({ receiptId }); + const settlement = yield* selectSettlementEvidence(sql, receiptId); - const outbox = yield* sql` + const outbox = yield* sql` SELECT effect_id AS "effectId", effect_type AS "effectType", command_id AS "commandId", receipt_id AS "receiptId", ordinal, status, attempts, last_failure_tag AS "lastFailureTag" @@ -274,7 +312,7 @@ export const readReceiptLifecycleEvidence = ( ORDER BY command_id, ordinal `; - const audit = yield* sql` + const audit = yield* sql` SELECT command_id AS "commandId", receipt_id AS "receiptId", action, receipt_revision AS "receiptRevision" FROM economy_receipt_audit @@ -282,21 +320,22 @@ export const readReceiptLifecycleEvidence = ( ORDER BY occurred_at, command_id `; - return { - receiptId, - file: { - fileRef: receipt.fileRef, - objectKey: receipt.objectKey, - contentType: receipt.contentType, - byteLength: Number(receipt.byteLength), - sha256: receipt.sha256, - }, - settlement: settlement ?? null, - outbox, - audit, - }; - }).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(projectionError("read Receipt lifecycle evidence", cause)), + return { + receiptId, + file: { + fileRef: receipt.fileRef, + objectKey: receipt.objectKey, + contentType: receipt.contentType, + byteLength: Number(receipt.byteLength), + sha256: receipt.sha256, + }, + settlement: settlement ?? null, + outbox, + audit, + }; + }).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(projectionError("read Receipt lifecycle evidence", cause)), + ), ), - ); +); diff --git a/packages/database/src/receipt/reviewed-cohort.ts b/packages/database/src/receipt/reviewed-cohort.ts index 2c472e6c8..2c0a77cf2 100644 --- a/packages/database/src/receipt/reviewed-cohort.ts +++ b/packages/database/src/receipt/reviewed-cohort.ts @@ -166,7 +166,7 @@ const readLedger = Effect.fnUntraced(function* ( if ( ledger.length !== 1 || - !stored || + stored === undefined || stored.source_digest !== p.sourceDigest || stored.destination_identity !== p.destinationIdentity || stored.target_semantic_identity !== result.targetSemanticIdentity || @@ -261,7 +261,7 @@ const resolveEvidence = Effect.fnUntraced(function* ( const reference = references[0]; if ( - !reference || + reference === undefined || reference.source_revision !== r.sourceRevision || reference.reference_digest !== r.referenceDigest ) @@ -367,7 +367,7 @@ const validatePrepared = ( const result = bySource.get(item.row.sourcePrimaryKey); if ( - !result || + result === undefined || result.sourceOccurrence !== 0 || canonicalJson(result.provenance) !== canonicalJson(item.provenance) ) @@ -488,7 +488,7 @@ export const importReviewedReceiptCohort = Effect.fn("importReviewedReceiptCohor SELECT snapshot_digest FROM public.receipt_cohort_snapshots WHERE snapshot_key = ${snapshotKey} `; - if (prior[0]) { + if (prior[0] !== undefined) { if (prior[0].snapshot_digest !== snapshotDigest) return yield* invalid("SnapshotConflict"); @@ -518,7 +518,7 @@ export const importReviewedReceiptCohort = Effect.fn("importReviewedReceiptCohor for (const entry of r.entries) { const binding = boundSources.get(entry.sourcePrimaryKey); - if (!binding) continue; + if (binding === undefined) continue; if ( binding.source_digest !== entry.sourceRowDigest || @@ -535,7 +535,7 @@ export const importReviewedReceiptCohort = Effect.fn("importReviewedReceiptCohor const current = resolvedBySource.get(entry.sourcePrimaryKey); if ( - !current || + current === undefined || current.ownerPersonId !== accepted.receipt.ownerPersonId || current.departmentId !== accepted.receipt.departmentId || current.receiptId !== accepted.receipt.receiptId || @@ -571,7 +571,7 @@ export const importReviewedReceiptCohort = Effect.fn("importReviewedReceiptCohor const accepted = reused.get(owner.source_primary_key); if ( - !accepted || + accepted === undefined || ownedSources.has(owner.source_primary_key) || owner.destination_identity !== accepted.provenance.destinationIdentity || owner.source_revision !== accepted.provenance.sourceRevision || diff --git a/packages/database/src/receipt/settlement.ts b/packages/database/src/receipt/settlement.ts index 909bd29af..ace4645da 100644 --- a/packages/database/src/receipt/settlement.ts +++ b/packages/database/src/receipt/settlement.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { randomUUID } from "node:crypto"; import { flow, Predicate, Effect, Schema, Struct } from "effect"; import { @@ -76,7 +77,7 @@ interface FinanceSettlementRow { } const persistenceError = (operation: string, cause: unknown) => - new ReceiptPersistenceError({ operation, message: String(cause), cause }); + ReceiptPersistenceError.make({ operation, message: String(cause), cause }); const receiptFromRow = ( row: typeof Receipt.Encoded, @@ -204,13 +205,13 @@ const findCommandReceipt = ( const decodePrincipal = (input: ReceiptCommandPrincipal) => Schema.decodeEffect(ReceiptCommandPrincipalSchema)(input, { onExcessProperty: "error", - }).pipe(Effect.mapError((cause) => new ReceiptDecodeError({ message: String(cause) }))); + }).pipe(Effect.mapError((cause) => ReceiptDecodeError.make({ message: String(cause) }))); const decodeCommand = flow( Schema.decodeUnknownEffect(ReceiptSettlementCommandRequestSchema, { onExcessProperty: "error", }), - Effect.mapError((cause) => new ReceiptDecodeError({ message: String(cause) })), + Effect.mapError((cause) => ReceiptDecodeError.make({ message: String(cause) })), ); const resolveSettlementAuthorizationWithSql = ( @@ -221,7 +222,7 @@ const resolveSettlementAuthorizationWithSql = ( Effect.gen(function* () { const current = yield* findReceiptForSettlement(sql, receiptId); - if (current === undefined) return yield* new ReceiptNotFound({ receiptId }); + if (current === undefined) return yield* ReceiptNotFound.make({ receiptId }); yield* lockPersonAuthorization(sql, principal.personId).pipe( Effect.mapError((cause) => persistenceError(cause.operation, cause.message)), ); @@ -234,7 +235,7 @@ const resolveSettlementAuthorizationWithSql = ( ).pipe( Effect.mapError((cause) => Predicate.isTagged(cause, "OrganizationDecodeError") - ? new ReceiptDecodeError({ message: `${cause.operation}: ${cause.message}` }) + ? ReceiptDecodeError.make({ message: `${cause.operation}: ${cause.message}` }) : persistenceError(cause.operation, cause.message), ), ); @@ -251,7 +252,7 @@ const resolveSettlementAuthorizationWithSql = ( ? cause : Predicate.isTagged(cause, "ReceiptDecodeError") ? cause - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `Receipt authority projection mismatch for ${cause.personId}`, }), ), @@ -266,17 +267,30 @@ const resolveSettlementAuthorizationWithSql = ( return { principal, actor, current }; }); -export const readReceiptSettlementRevision = ( - receiptId: ReceiptId, - principalInput: ReceiptCommandPrincipal, -): Effect.Effect => - Effect.gen(function* () { - const sql = yield* Database; - const principal = yield* decodePrincipal(principalInput); - const authorization = yield* resolveSettlementAuthorizationWithSql(sql, receiptId, principal); - - return authorization.current.revision; - }); +export const readReceiptSettlementRevision: { + ( + principalInput: ReceiptCommandPrincipal, + ): ( + receiptId: ReceiptId, + ) => Effect.Effect; + ( + receiptId: ReceiptId, + principalInput: ReceiptCommandPrincipal, + ): Effect.Effect; +} = dual( + 2, + ( + receiptId: ReceiptId, + principalInput: ReceiptCommandPrincipal, + ): Effect.Effect => + Effect.gen(function* () { + const sql = yield* Database; + const principal = yield* decodePrincipal(principalInput); + const authorization = yield* resolveSettlementAuthorizationWithSql(sql, receiptId, principal); + + return authorization.current.revision; + }), +); const executeAuthorizedReceiptSettlementWithSql = ( sql: DatabaseOperations, @@ -300,7 +314,7 @@ const executeAuthorizedReceiptSettlementWithSql = ( if (stored !== undefined) { if (stored.command_sha256 !== commandDigest) { - return yield* new DuplicateReceiptCommandConflict({ commandId: command.commandId }); + return yield* DuplicateReceiptCommandConflict.make({ commandId: command.commandId }); } const observation = yield* Schema.decodeUnknownEffect(ReceiptSettlementObservationSchema)( @@ -334,11 +348,11 @@ const executeAuthorizedReceiptSettlementWithSql = ( const existingSettlement = yield* findSettlementByReceipt(sql, current.receiptId); if (existingSettlement !== undefined) { - return yield* new ReceiptAlreadySettled({ receiptId: current.receiptId }); + return yield* ReceiptAlreadySettled.make({ receiptId: current.receiptId }); } if (current.revision !== command.expectedRevision) { - return yield* new StaleReceiptRevision({ + return yield* StaleReceiptRevision.make({ receiptId: current.receiptId, expected: command.expectedRevision, actual: current.revision, @@ -346,7 +360,7 @@ const executeAuthorizedReceiptSettlementWithSql = ( } if (current.status !== "Approved") { - return yield* new InvalidReceiptTransition({ + return yield* InvalidReceiptTransition.make({ receiptId: current.receiptId, status: current.status, command: command._tag, @@ -356,7 +370,7 @@ const executeAuthorizedReceiptSettlementWithSql = ( const recordedAt = authorization.principal.authorizationInstant; if (compareRfc3339Instants(command.settledAt, recordedAt) > 0) { - return yield* new SettlementAfterRecordedAt({ settledAt: command.settledAt, recordedAt }); + return yield* SettlementAfterRecordedAt.make({ settledAt: command.settledAt, recordedAt }); } yield* lockAdvisory( @@ -384,7 +398,7 @@ const executeAuthorizedReceiptSettlementWithSql = ( ); if (duplicateExternal[0] !== undefined) { - return yield* new DuplicateExternalSettlementReference({ + return yield* DuplicateExternalSettlementReference.make({ externalAuthority: command.externalAuthority, externalReference: command.externalReference, }); @@ -405,7 +419,7 @@ const executeAuthorizedReceiptSettlementWithSql = ( const revision = updated[0]?.revision; if (revision === undefined) { - return yield* new StaleReceiptRevision({ + return yield* StaleReceiptRevision.make({ receiptId: current.receiptId, expected: current.revision, actual: current.revision, @@ -525,40 +539,64 @@ const executeAuthorizedReceiptSettlementWithSql = ( }; }); -export const recordReceiptSettlement = ( - input: typeof ReceiptSettlementCommandRequestSchema.Encoded, - principalInput: ReceiptCommandPrincipal, -): Effect.Effect => - Effect.gen(function* () { - const sql = yield* Database; - - return yield* sql - .withTransaction( - Effect.gen(function* () { - const command = yield* decodeCommand(input); - const principal = yield* decodePrincipal(principalInput); +export const recordReceiptSettlement: { + ( + principalInput: ReceiptCommandPrincipal, + ): ( + input: typeof ReceiptSettlementCommandRequestSchema.Encoded, + ) => Effect.Effect; + ( + input: typeof ReceiptSettlementCommandRequestSchema.Encoded, + principalInput: ReceiptCommandPrincipal, + ): Effect.Effect; +} = dual( + 2, + ( + input: typeof ReceiptSettlementCommandRequestSchema.Encoded, + principalInput: ReceiptCommandPrincipal, + ): Effect.Effect => + Effect.gen(function* () { + const sql = yield* Database; + + return yield* sql + .withTransaction( + Effect.gen(function* () { + const command = yield* decodeCommand(input); + const principal = yield* decodePrincipal(principalInput); + + const authorization = yield* resolveSettlementAuthorizationWithSql( + sql, + command.receiptId, + principal, + ); - const authorization = yield* resolveSettlementAuthorizationWithSql( - sql, - command.receiptId, - principal, - ); + return yield* executeAuthorizedReceiptSettlementWithSql(sql, command, authorization); + }), + ) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("Receipt settlement transaction", cause)), + ), + ); + }), +); - return yield* executeAuthorizedReceiptSettlementWithSql(sql, command, authorization); - }), - ) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("Receipt settlement transaction", cause)), - ), - ); - }); +/** The person, the authorization instant, and the cursor of a settlement queue read. */ +export interface ReceiptSettlementQueueInput { + readonly personId: PersonId; + readonly authorizationInstant: OrganizationAuthorityInstant; + readonly after?: string | undefined; +} -export const listReceiptsForSettlement = ( - personId: PersonId, - authorizationInstant: OrganizationAuthorityInstant, - after?: string, -): Effect.Effect, ReceiptSettlementListFailure, Database> => +export const listReceiptsForSettlement = ({ + personId, + authorizationInstant, + after, +}: ReceiptSettlementQueueInput): Effect.Effect< + ReceiptPage, + ReceiptSettlementListFailure, + Database +> => Effect.gen(function* () { const sql = yield* Database; @@ -579,7 +617,7 @@ export const listReceiptsForSettlement = ( "resolve Receipt settlement queue Organization authority", cause.message, ) - : new ReceiptDecodeError({ message: `${cause.operation}: ${cause.message}` }), + : ReceiptDecodeError.make({ message: `${cause.operation}: ${cause.message}` }), ), ); @@ -593,7 +631,7 @@ export const listReceiptsForSettlement = ( ? cause : Predicate.isTagged(cause, "ReceiptDecodeError") ? cause - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `Receipt authority projection mismatch for ${cause.personId}`, }), ), @@ -654,22 +692,36 @@ export const listReceiptsForSettlement = ( ); }); -export const readReceiptSettlementForFinance = ( - receiptId: string, - personId: PersonId, - authorizationInstant: OrganizationAuthorityInstant, -): Effect.Effect => - Effect.gen(function* () { - const sql = yield* Database; - - return yield* sql - .withTransaction( - Effect.gen(function* () { - yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`.pipe( - Effect.asVoid, - ); +export const readReceiptSettlementForFinance: { + ( + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + ): ( + receiptId: string, + ) => Effect.Effect; + ( + receiptId: string, + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + ): Effect.Effect; +} = dual( + 3, + ( + receiptId: string, + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + ): Effect.Effect => + Effect.gen(function* () { + const sql = yield* Database; + + return yield* sql + .withTransaction( + Effect.gen(function* () { + yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`.pipe( + Effect.asVoid, + ); - const rows = yield* sql` + const rows = yield* sql` SELECT receipt.department_id AS "departmentId", json_build_object( @@ -690,54 +742,55 @@ export const readReceiptSettlementForFinance = ( ON settlement.receipt_id = receipt.receipt_id WHERE receipt.receipt_id = ${receiptId} `.pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("read Receipt finance settlement", cause)), - ), - ); - - const row = rows[0]; - - if (row === undefined) return yield* new ReceiptNotFound({ receiptId }); - const settlement = yield* decodeSettlementEvidence(row.settlement); + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("read Receipt finance settlement", cause)), + ), + ); - const organization = yield* resolveOrganizationPersonAuthorityForRead( - personId, - authorizationInstant, - ).pipe( - Effect.mapError((cause) => - Predicate.isTagged(cause, "OrganizationPersistenceError") - ? persistenceError( - "resolve Receipt finance settlement Organization authority", - cause.message, - ) - : new ReceiptDecodeError({ message: `${cause.operation}: ${cause.message}` }), - ), - ); + const row = rows[0]; + + if (row === undefined) return yield* ReceiptNotFound.make({ receiptId }); + const settlement = yield* decodeSettlementEvidence(row.settlement); + + const organization = yield* resolveOrganizationPersonAuthorityForRead( + personId, + authorizationInstant, + ).pipe( + Effect.mapError((cause) => + Predicate.isTagged(cause, "OrganizationPersistenceError") + ? persistenceError( + "resolve Receipt finance settlement Organization authority", + cause.message, + ) + : ReceiptDecodeError.make({ message: `${cause.operation}: ${cause.message}` }), + ), + ); - const authority = yield* resolveReceiptAuthorityForRead( - personId, - authorizationInstant, - organization, - ).pipe( - Effect.mapError((cause) => - Predicate.isTagged(cause, "ReceiptPersistenceError") - ? cause - : Predicate.isTagged(cause, "ReceiptDecodeError") + const authority = yield* resolveReceiptAuthorityForRead( + personId, + authorizationInstant, + organization, + ).pipe( + Effect.mapError((cause) => + Predicate.isTagged(cause, "ReceiptPersistenceError") ? cause - : new ReceiptDecodeError({ - message: `Receipt authority projection mismatch for ${cause.personId}`, - }), - ), - ); + : Predicate.isTagged(cause, "ReceiptDecodeError") + ? cause + : ReceiptDecodeError.make({ + message: `Receipt authority projection mismatch for ${cause.personId}`, + }), + ), + ); - yield* mapExistingReceiptSettlementActor(authority, receiptId, row.departmentId); + yield* mapExistingReceiptSettlementActor(authority, receiptId, row.departmentId); - return settlement; - }), - ) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("read Receipt finance settlement snapshot", cause)), - ), - ); - }); + return settlement; + }), + ) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("read Receipt finance settlement snapshot", cause)), + ), + ); + }), +); diff --git a/packages/database/src/recruitment/completion-outbox.ts b/packages/database/src/recruitment/completion-outbox.ts index 51d9ef2b4..448c98df7 100644 --- a/packages/database/src/recruitment/completion-outbox.ts +++ b/packages/database/src/recruitment/completion-outbox.ts @@ -21,6 +21,7 @@ import { sha256Hex, } from "@vektorprogrammet/domain/shared-kernel"; import { flow, Data, Predicate, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { RecruitmentNotificationEffectId, RecruitmentConductCommandId, @@ -120,7 +121,7 @@ export const RecruitmentInterviewCompletionDeliveryResult = Data.taggedEnum(); const persistenceError = (operation: string, cause?: unknown): RecruitmentPersistenceError => - new RecruitmentPersistenceError({ + RecruitmentPersistenceError.make({ operation, cause, message: cause instanceof Error ? cause.message : "recruitment completion outbox failed", @@ -415,57 +416,104 @@ const claimInTransaction = ( return { effectId: row.effectId, claimId: row.claimId, attempts: row.attempts, request }; }); -export const claimNextRecruitmentInterviewCompletion = ( - claimId: string, - claimedAt: string, -): Effect.Effect< - ClaimedRecruitmentInterviewCompletion | undefined, - RecruitmentPersistenceError | OutboxClaimLost, - Admissions | Database | Profile -> => - Effect.gen(function* () { - const admissions = yield* Admissions; - const sql = yield* Database; - const profile = yield* Profile; - - return yield* sql - .withTransaction(claimInTransaction(sql, admissions, profile, claimId, claimedAt)) - .pipe( +export const claimNextRecruitmentInterviewCompletion: { + ( + claimedAt: string, + ): ( + claimId: string, + ) => Effect.Effect< + ClaimedRecruitmentInterviewCompletion | undefined, + RecruitmentPersistenceError | OutboxClaimLost, + Admissions | Database | Profile + >; + ( + claimId: string, + claimedAt: string, + ): Effect.Effect< + ClaimedRecruitmentInterviewCompletion | undefined, + RecruitmentPersistenceError | OutboxClaimLost, + Admissions | Database | Profile + >; +} = dual( + 2, + ( + claimId: string, + claimedAt: string, + ): Effect.Effect< + ClaimedRecruitmentInterviewCompletion | undefined, + RecruitmentPersistenceError | OutboxClaimLost, + Admissions | Database | Profile + > => + Effect.gen(function* () { + const admissions = yield* Admissions; + const sql = yield* Database; + const profile = yield* Profile; + + return yield* sql + .withTransaction(claimInTransaction(sql, admissions, profile, claimId, claimedAt)) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("interview completion claim transaction", cause)), + ), + ); + }), +); + +export const completeRecruitmentInterviewCompletion: { + ( + evidence: RecruitmentNotificationEvidence, + ): ( + claim: ClaimedRecruitmentInterviewCompletion, + ) => Effect.Effect; + ( + claim: ClaimedRecruitmentInterviewCompletion, + evidence: RecruitmentNotificationEvidence, + ): Effect.Effect; +} = dual( + 2, + ( + claim: ClaimedRecruitmentInterviewCompletion, + evidence: RecruitmentNotificationEvidence, + ): Effect.Effect => + Effect.gen(function* () { + if (evidence.effectId !== claim.effectId) + return yield* persistenceError("completion delivery evidence effect mismatch"); + + yield* Database.use((sql) => + markOutboxDelivered(sql, completionOutbox, claim, { + deliveredAt: evidence.deliveredAt, + providerReference: evidence.providerReference, + }), + ).pipe( Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("interview completion claim transaction", cause)), + Effect.fail(persistenceError("complete interview completion claim", cause)), ), ); - }); - -export const completeRecruitmentInterviewCompletion = ( - claim: ClaimedRecruitmentInterviewCompletion, - evidence: RecruitmentNotificationEvidence, -): Effect.Effect => - Effect.gen(function* () { - if (evidence.effectId !== claim.effectId) - return yield* persistenceError("completion delivery evidence effect mismatch"); - - yield* Database.use((sql) => - markOutboxDelivered(sql, completionOutbox, claim, { - deliveredAt: evidence.deliveredAt, - providerReference: evidence.providerReference, - }), - ).pipe( + }), +); + +export const failRecruitmentInterviewCompletion: { + ( + failureTag: string, + ): ( + claim: ClaimedRecruitmentInterviewCompletion, + ) => Effect.Effect; + ( + claim: ClaimedRecruitmentInterviewCompletion, + failureTag: string, + ): Effect.Effect; +} = dual( + 2, + ( + claim: ClaimedRecruitmentInterviewCompletion, + failureTag: string, + ): Effect.Effect => + Database.use((sql) => markOutboxFailed(sql, completionOutbox, claim, failureTag)).pipe( Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("complete interview completion claim", cause)), + Effect.fail(persistenceError("fail interview completion claim", cause)), ), - ); - }); - -export const failRecruitmentInterviewCompletion = ( - claim: ClaimedRecruitmentInterviewCompletion, - failureTag: string, -): Effect.Effect => - Database.use((sql) => markOutboxFailed(sql, completionOutbox, claim, failureTag)).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("fail interview completion claim", cause)), ), - ); +); export const releaseRecruitmentInterviewCompletion = ( claim: ClaimedRecruitmentInterviewCompletion, @@ -497,56 +545,77 @@ export const recoverStaleRecruitmentInterviewCompletions = ( ), ); -export const deliverNextRecruitmentInterviewCompletion = ( - claimId: string, - claimedAt: string, -): Effect.Effect< - RecruitmentInterviewCompletionDeliveryResult, - RecruitmentPersistenceError, - Admissions | Database | NotificationGateway | Profile -> => - Effect.acquireUseRelease( - claimNextRecruitmentInterviewCompletion(claimId, claimedAt), - ( - claim, - ): Effect.Effect< - RecruitmentInterviewCompletionDeliveryResult, - RecruitmentPersistenceError | OutboxClaimLost, - Admissions | Database | NotificationGateway | Profile - > => { - if (claim === undefined) - return Effect.succeed(RecruitmentInterviewCompletionDeliveryResult.Idle()); - - return Effect.gen(function* () { - const gateway = yield* NotificationGateway; - - return yield* gateway.deliverInterviewCompletionReceipt(claim.request).pipe( - Effect.matchEffect({ - onFailure: (failure) => - failRecruitmentInterviewCompletion(claim, failure._tag).pipe( - Effect.as( - RecruitmentInterviewCompletionDeliveryResult.Failed({ - claim, - failureTag: failure._tag, - }), +export const deliverNextRecruitmentInterviewCompletion: { + ( + claimedAt: string, + ): ( + claimId: string, + ) => Effect.Effect< + RecruitmentInterviewCompletionDeliveryResult, + RecruitmentPersistenceError, + Admissions | Database | NotificationGateway | Profile + >; + ( + claimId: string, + claimedAt: string, + ): Effect.Effect< + RecruitmentInterviewCompletionDeliveryResult, + RecruitmentPersistenceError, + Admissions | Database | NotificationGateway | Profile + >; +} = dual( + 2, + ( + claimId: string, + claimedAt: string, + ): Effect.Effect< + RecruitmentInterviewCompletionDeliveryResult, + RecruitmentPersistenceError, + Admissions | Database | NotificationGateway | Profile + > => + Effect.acquireUseRelease( + claimNextRecruitmentInterviewCompletion(claimId, claimedAt), + ( + claim, + ): Effect.Effect< + RecruitmentInterviewCompletionDeliveryResult, + RecruitmentPersistenceError | OutboxClaimLost, + Admissions | Database | NotificationGateway | Profile + > => { + if (claim === undefined) + return Effect.succeed(RecruitmentInterviewCompletionDeliveryResult.Idle()); + + return Effect.gen(function* () { + const gateway = yield* NotificationGateway; + + return yield* gateway.deliverInterviewCompletionReceipt(claim.request).pipe( + Effect.matchEffect({ + onFailure: (failure) => + failRecruitmentInterviewCompletion(claim, failure._tag).pipe( + Effect.as( + RecruitmentInterviewCompletionDeliveryResult.Failed({ + claim, + failureTag: failure._tag, + }), + ), ), - ), - onSuccess: (evidence) => - completeRecruitmentInterviewCompletion(claim, evidence).pipe( - Effect.as( - RecruitmentInterviewCompletionDeliveryResult.Delivered({ claim, evidence }), + onSuccess: (evidence) => + completeRecruitmentInterviewCompletion(claim, evidence).pipe( + Effect.as( + RecruitmentInterviewCompletionDeliveryResult.Delivered({ claim, evidence }), + ), ), - ), - }), - ); - }); - }, - (claim) => (claim === undefined ? Effect.void : releaseRecruitmentInterviewCompletion(claim)), - ).pipe( - Effect.catchTag("OutboxClaimLost", ({ effectId }) => - Effect.succeed(RecruitmentInterviewCompletionDeliveryResult.ClaimLost({ effectId })), + }), + ); + }); + }, + (claim) => (claim === undefined ? Effect.void : releaseRecruitmentInterviewCompletion(claim)), + ).pipe( + Effect.catchTag("OutboxClaimLost", ({ effectId }) => + Effect.succeed(RecruitmentInterviewCompletionDeliveryResult.ClaimLost({ effectId })), + ), ), - ); +); export type RecruitmentInterviewCompletionOutboxFailure = | RecruitmentPersistenceError diff --git a/packages/database/src/recruitment/conduct-identity.ts b/packages/database/src/recruitment/conduct-identity.ts index 8c0b9e303..5dcb7726f 100644 --- a/packages/database/src/recruitment/conduct-identity.ts +++ b/packages/database/src/recruitment/conduct-identity.ts @@ -1,4 +1,5 @@ import { Effect } from "effect"; +import { dual } from "effect/Function"; import { Database } from "../service.js"; import { lockOnboardingApplicant } from "../onboarding/postgres.js"; import type { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; @@ -19,14 +20,13 @@ export const readInterviewApplicantIdentity = (interviewId: RecruitmentInterview linkedApplicantPersonId: PersonId | null; }>`SELECT a.applicant_id AS "applicantId",i.department_id AS "departmentId",l.person_id AS "linkedApplicantPersonId" FROM public.recruitment_interviews i JOIN public.admission_applications a USING(application_id) LEFT JOIN public.applicant_account_links l USING(applicant_id) WHERE i.interview_id=${interviewId}`; - if (!rows[0]) return yield* new RecruitmentInterviewNotFound({ interviewId }); + if (rows[0] === undefined) return yield* RecruitmentInterviewNotFound.make({ interviewId }); return rows[0]; }), ); -/** Caller holds a transaction. Applicant custody precedes interview/receipt locks. */ -export const guardInterviewApplicantIdentity = ( +const guardInterviewApplicantIdentityImpl = ( interviewId: RecruitmentInterviewId, personId: PersonId, ) => @@ -36,7 +36,20 @@ export const guardInterviewApplicantIdentity = ( const current = yield* readInterviewApplicantIdentity(interviewId); if (isKnownSelfInterview(current.linkedApplicantPersonId, personId)) - return yield* new RecruitmentScopeDenied({ personId, departmentId: current.departmentId }); + return yield* RecruitmentScopeDenied.make({ personId, departmentId: current.departmentId }); return current; }); + +/** Caller holds a transaction. Applicant custody precedes interview/receipt locks. */ +export const guardInterviewApplicantIdentity: { + ( + personId: PersonId, + ): ( + interviewId: RecruitmentInterviewId, + ) => ReturnType; + ( + interviewId: RecruitmentInterviewId, + personId: PersonId, + ): ReturnType; +} = dual(2, guardInterviewApplicantIdentityImpl); diff --git a/packages/database/src/recruitment/conduct-postgres.test.ts b/packages/database/src/recruitment/conduct-postgres.test.ts index 1bd2c674c..6a2328f93 100644 --- a/packages/database/src/recruitment/conduct-postgres.test.ts +++ b/packages/database/src/recruitment/conduct-postgres.test.ts @@ -30,44 +30,42 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })((it) => "denies incomplete conduct to a co-interviewer while admitting the primary interviewer", () => Effect.gen(function* () { - const observed = yield* Effect.gen(function* () { - const sql = yield* Database; - yield* sql`INSERT INTO organization_departments (department_id, name, short_name, email, city) VALUES ('department-1', 'Realfag', 'RF', 'rf@example.invalid', 'Trondheim')`; - yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES ('department-1', 'Realfag')`; - yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ('semester-1', '2031-08-01', '2032-01-01')`; - yield* sql`INSERT INTO admission_periods (admission_period_id, department_id, semester_id, start_at, end_at, last_command_id) VALUES ('period-1', 'department-1', 'semester-1', '2031-08-01', '2031-10-01', 'seed')`; - yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name, active) VALUES ('field-1', 'department-1', 'Matematikk', TRUE)`; - yield* sql`INSERT INTO person_profiles (person_id, first_name, last_name) VALUES ('primary-1', 'Ivar', 'Intervjuer'), ('co-1', 'Kari', 'Kollega'), ('other-1', 'Andre', 'Medlem')`; - yield* sql`INSERT INTO person_contact_profiles (person_id, email, phone) VALUES ('primary-1', 'primary@example.invalid', '90000000')`; - yield* sql`INSERT INTO organization_teams (team_id, department_id, name) VALUES ('team-1', 'department-1', 'Team')`; - yield* sql`INSERT INTO organization_memberships (membership_id, person_id, team_id, start_at) VALUES ('membership-1', 'co-1', 'team-1', '2031-08-01'), ('membership-2', 'primary-1', 'team-1', '2031-08-01')`; - yield* sql`INSERT INTO admission_applicants (applicant_id, normalized_email, email, first_name, last_name, phone, gender, field_of_study_id, year_of_study) VALUES ('applicant-1', 'applicant@example.invalid', 'applicant@example.invalid', 'Ada', 'Søker', '90000001', 0, 'field-1', 2)`; - yield* sql`INSERT INTO admission_applications (application_id, applicant_id, admission_period_id, department_id, field_of_study_id, year_of_study, submitted_at) VALUES ('application-1', 'applicant-1', 'period-1', 'department-1', 'field-1', 2, '2031-08-15')`; - yield* sql`INSERT INTO recruitment_interview_schemas (interview_schema_id, name, question_count, active, revision) VALUES ('schema-1', 'Interview', 0, TRUE, 0)`; - yield* sql`INSERT INTO recruitment_interviews (interview_id, application_id, department_id, interviewer_person_id, co_interviewer_person_id, interview_schema_id, assigned_by_person_id, assigned_at, revision) VALUES ('interview-1', 'application-1', 'department-1', 'primary-1', 'co-1', 'schema-1', 'primary-1', '2031-09-01', 3)`; - yield* sql.withTransaction( - Effect.gen(function* () { - yield* sql`INSERT INTO recruitment_interview_schedules (interview_id, scheduled_at, room, campus, message, scheduled_by_person_id, committed_at, schedule_revision) VALUES ('interview-1', '2031-09-15T12:00:00.000Z', 'A1', 'Gløshaugen', 'Welcome', 'primary-1', '2031-09-01', 2)`; - yield* sql`INSERT INTO recruitment_invitations (invitation_id, interview_id, schedule_revision, capability_sha256, response_state, response_revision, responded_at, created_at) VALUES ('invitation-1', 'interview-1', 2, repeat('a', 64), 'Accepted', 1, '2031-09-02', '2031-09-01')`; - yield* sql`INSERT INTO recruitment_invitation_response_audit (invitation_id, interview_id, schedule_revision, response_revision, response_state, responded_at) VALUES ('invitation-1', 'interview-1', 2, 1, 'Accepted', '2031-09-02')`; - }), - ); + const sql = yield* Database; + yield* sql`INSERT INTO organization_departments (department_id, name, short_name, email, city) VALUES ('department-1', 'Realfag', 'RF', 'rf@example.invalid', 'Trondheim')`; + yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES ('department-1', 'Realfag')`; + yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ('semester-1', '2031-08-01', '2032-01-01')`; + yield* sql`INSERT INTO admission_periods (admission_period_id, department_id, semester_id, start_at, end_at, last_command_id) VALUES ('period-1', 'department-1', 'semester-1', '2031-08-01', '2031-10-01', 'seed')`; + yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name, active) VALUES ('field-1', 'department-1', 'Matematikk', TRUE)`; + yield* sql`INSERT INTO person_profiles (person_id, first_name, last_name) VALUES ('primary-1', 'Ivar', 'Intervjuer'), ('co-1', 'Kari', 'Kollega'), ('other-1', 'Andre', 'Medlem')`; + yield* sql`INSERT INTO person_contact_profiles (person_id, email, phone) VALUES ('primary-1', 'primary@example.invalid', '90000000')`; + yield* sql`INSERT INTO organization_teams (team_id, department_id, name) VALUES ('team-1', 'department-1', 'Team')`; + yield* sql`INSERT INTO organization_memberships (membership_id, person_id, team_id, start_at) VALUES ('membership-1', 'co-1', 'team-1', '2031-08-01'), ('membership-2', 'primary-1', 'team-1', '2031-08-01')`; + yield* sql`INSERT INTO admission_applicants (applicant_id, normalized_email, email, first_name, last_name, phone, gender, field_of_study_id, year_of_study) VALUES ('applicant-1', 'applicant@example.invalid', 'applicant@example.invalid', 'Ada', 'Søker', '90000001', 0, 'field-1', 2)`; + yield* sql`INSERT INTO admission_applications (application_id, applicant_id, admission_period_id, department_id, field_of_study_id, year_of_study, submitted_at) VALUES ('application-1', 'applicant-1', 'period-1', 'department-1', 'field-1', 2, '2031-08-15')`; + yield* sql`INSERT INTO recruitment_interview_schemas (interview_schema_id, name, question_count, active, revision) VALUES ('schema-1', 'Interview', 0, TRUE, 0)`; + yield* sql`INSERT INTO recruitment_interviews (interview_id, application_id, department_id, interviewer_person_id, co_interviewer_person_id, interview_schema_id, assigned_by_person_id, assigned_at, revision) VALUES ('interview-1', 'application-1', 'department-1', 'primary-1', 'co-1', 'schema-1', 'primary-1', '2031-09-01', 3)`; + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql`INSERT INTO recruitment_interview_schedules (interview_id, scheduled_at, room, campus, message, scheduled_by_person_id, committed_at, schedule_revision) VALUES ('interview-1', '2031-09-15T12:00:00.000Z', 'A1', 'Gløshaugen', 'Welcome', 'primary-1', '2031-09-01', 2)`; + yield* sql`INSERT INTO recruitment_invitations (invitation_id, interview_id, schedule_revision, capability_sha256, response_state, response_revision, responded_at, created_at) VALUES ('invitation-1', 'interview-1', 2, repeat('a', 64), 'Accepted', 1, '2031-09-02', '2031-09-01')`; + yield* sql`INSERT INTO recruitment_invitation_response_audit (invitation_id, interview_id, schedule_revision, response_revision, response_state, responded_at) VALUES ('invitation-1', 'interview-1', 2, 1, 'Accepted', '2031-09-02')`; + }), + ); - const denied = yield* Effect.flip( - readInterviewConduct(RecruitmentInterviewId.make("interview-1"), context), - ); + const denied = yield* Effect.flip( + readInterviewConduct(RecruitmentInterviewId.make("interview-1"), context), + ); - const primary = yield* readInterviewConduct(RecruitmentInterviewId.make("interview-1"), { - ...context, - actor: RecruitmentActorSchema.cases.Member.make({ - ...context.actor, - personId: PersonId.make("primary-1"), - }), - }); - - return { denied, primary }; + const primary = yield* readInterviewConduct(RecruitmentInterviewId.make("interview-1"), { + ...context, + actor: RecruitmentActorSchema.cases.Member.make({ + ...context.actor, + personId: PersonId.make("primary-1"), + }), }); + const observed = { denied, primary }; + expect(observed.denied._tag).toBe("RecruitmentScopeDenied"); expect(observed.primary.interviewId).toBe("interview-1"); }), diff --git a/packages/database/src/recruitment/conduct-postgres.ts b/packages/database/src/recruitment/conduct-postgres.ts index 237c45a09..40e20d830 100644 --- a/packages/database/src/recruitment/conduct-postgres.ts +++ b/packages/database/src/recruitment/conduct-postgres.ts @@ -15,6 +15,7 @@ import { sha256Hex, } from "@vektorprogrammet/domain/shared-kernel"; import { flow, Effect, Schema, Struct } from "effect"; +import { dual } from "effect/Function"; import { CorrectionHistoryOriginalSchema, RecruitmentConductValidationError, @@ -142,7 +143,7 @@ interface CancellationRow { } const persistenceError = (operation: string, cause?: Error) => - new RecruitmentPersistenceError({ + RecruitmentPersistenceError.make({ operation, cause, message: cause?.message ?? "recruitment persistence failed", @@ -151,12 +152,11 @@ const persistenceError = (operation: string, cause?: Error) => const decode = (schema: Schema.ConstraintDecoder, operation: string) => flow( Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }), - Effect.mapError( - (cause) => - new RecruitmentPersistenceError({ - operation: `decode ${operation}`, - message: String(cause), - }), + Effect.mapError((cause) => + RecruitmentPersistenceError.make({ + operation: `decode ${operation}`, + message: String(cause), + }), ), ); @@ -525,7 +525,7 @@ const authorityActor = ( ); if (membership === undefined) { - return yield* new RecruitmentScopeDenied({ personId, departmentId }); + return yield* RecruitmentScopeDenied.make({ personId, departmentId }); } return yield* decode( @@ -562,7 +562,7 @@ const authorizeAndLoad = ( const interview = yield* readInterview(sql, interviewId, lock); if (interview === undefined) - return yield* new RecruitmentInterviewNotFound({ + return yield* RecruitmentInterviewNotFound.make({ interviewId: RecruitmentInterviewId.make(interviewId), }); const authorizationInstant = context.authorizationInstant ?? context.now; @@ -580,7 +580,7 @@ const authorizeAndLoad = ( const isParticipant = interview.interviewerPersonId === actor.personId || isCoInterviewer; if (!isParticipant) { - return yield* new RecruitmentScopeDenied({ + return yield* RecruitmentScopeDenied.make({ personId: actor.personId, departmentId: actor.departmentId, }); @@ -591,7 +591,7 @@ const authorizeAndLoad = ( : undefined; if (isCoInterviewer && coInterviewerConduct === undefined) { - return yield* new RecruitmentScopeDenied({ + return yield* RecruitmentScopeDenied.make({ personId: actor.personId, departmentId: actor.departmentId, }); @@ -631,12 +631,12 @@ const observation = ( ): Effect.Effect => Effect.gen(function* () { if (state.schedule === null) - return yield* new RecruitmentInterviewNotScheduled({ + return yield* RecruitmentInterviewNotScheduled.make({ interviewId: state.interview.interviewId, }); if (state.invitationResponse !== "Accepted") { - return yield* new RecruitmentInvitationNotAccepted({ + return yield* RecruitmentInvitationNotAccepted.make({ interviewId: state.interview.interviewId, responseState: state.invitationResponse ?? "Absent", }); @@ -727,65 +727,115 @@ const readApplicant = (admissions: AdmissionsOperations, applicationId: string) Effect.mapError((cause) => persistenceError("resolve conduct applicant", cause)), ); -export const readInterviewConduct = ( - interviewId: RecruitmentInterviewId, - context: RecruitmentConductContext, -): Effect.Effect< - RecruitmentInterviewConductObservation, - RecruitmentFailure, - Database | Admissions | Organization -> => - Effect.gen(function* () { - const sql = yield* Database; - - return yield* sql - .withTransaction(readInterviewConductInTransaction(interviewId, context, sql)) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("conduct observation", cause)), - ), +export const readInterviewConduct: { + ( + context: RecruitmentConductContext, + ): ( + interviewId: RecruitmentInterviewId, + ) => Effect.Effect< + RecruitmentInterviewConductObservation, + RecruitmentFailure, + Database | Admissions | Organization + >; + ( + interviewId: RecruitmentInterviewId, + context: RecruitmentConductContext, + ): Effect.Effect< + RecruitmentInterviewConductObservation, + RecruitmentFailure, + Database | Admissions | Organization + >; +} = dual( + 2, + ( + interviewId: RecruitmentInterviewId, + context: RecruitmentConductContext, + ): Effect.Effect< + RecruitmentInterviewConductObservation, + RecruitmentFailure, + Database | Admissions | Organization + > => + Effect.gen(function* () { + const sql = yield* Database; + + return yield* sql + .withTransaction(readInterviewConductInTransaction(interviewId, context, sql)) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("conduct observation", cause)), + ), + ); + }), +); + +export const readInterviewConductInTransaction: { + ( + context: RecruitmentConductContext, + sql: DatabaseOperations, + ): ( + interviewId: RecruitmentInterviewId, + ) => Effect.Effect< + RecruitmentInterviewConductObservation, + RecruitmentFailure, + Admissions | Organization + >; + ( + interviewId: RecruitmentInterviewId, + context: RecruitmentConductContext, + sql: DatabaseOperations, + ): Effect.Effect< + RecruitmentInterviewConductObservation, + RecruitmentFailure, + Admissions | Organization + >; +} = dual( + 3, + ( + interviewId: RecruitmentInterviewId, + context: RecruitmentConductContext, + sql: DatabaseOperations, + ): Effect.Effect< + RecruitmentInterviewConductObservation, + RecruitmentFailure, + Admissions | Organization + > => + Effect.gen(function* () { + const admissions = yield* Admissions; + const organization = yield* Organization; + + const loaded = yield* authorizeAndLoad( + sql, + organization, + context, + interviewId, + false, + "InterviewParticipant", ); - }); - -export const readInterviewConductInTransaction = ( - interviewId: RecruitmentInterviewId, - context: RecruitmentConductContext, - sql: DatabaseOperations, -): Effect.Effect< - RecruitmentInterviewConductObservation, - RecruitmentFailure, - Admissions | Organization -> => - Effect.gen(function* () { - const admissions = yield* Admissions; - const organization = yield* Organization; - - const loaded = yield* authorizeAndLoad( - sql, - organization, - context, - interviewId, - false, - "InterviewParticipant", - ); - const state = yield* stateFor( - loaded.interview, - loaded.schedule, - loaded.invitation, - loaded.questions, - loaded.conduct, - loaded.cancellation, - ); + const state = yield* stateFor( + loaded.interview, + loaded.schedule, + loaded.invitation, + loaded.questions, + loaded.conduct, + loaded.cancellation, + ); - const applicant = yield* readApplicant(admissions, loaded.interview.applicationId); + const applicant = yield* readApplicant(admissions, loaded.interview.applicationId); - return yield* observation(state, applicant, loaded.corrections, loaded.effective, loaded.actor); - }).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("conduct observation", cause)), + return yield* observation( + state, + applicant, + loaded.corrections, + loaded.effective, + loaded.actor, + ); + }).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("conduct observation", cause)), + ), ), - ); +); const finalizeInTransaction = ( command: FinalizeInterviewCommand, @@ -818,7 +868,7 @@ const finalizeInTransaction = ( receipt.interviewId !== command.interviewId || receipt.kind !== "InterviewFinalized" ) { - return yield* new RecruitmentLifecycleCommandConflict({ commandId: command.commandId }); + return yield* RecruitmentLifecycleCommandConflict.make({ commandId: command.commandId }); } const stored = yield* decode( @@ -845,7 +895,7 @@ const finalizeInTransaction = ( const conduct = transition.state.conduct; if (conduct === null) - return yield* new RecruitmentConductValidationError({ + return yield* RecruitmentConductValidationError.make({ interviewId: command.interviewId, message: "finalization produced no conduct", }); @@ -855,7 +905,7 @@ const finalizeInTransaction = ( }>`UPDATE recruitment_interviews SET revision = revision + 1 WHERE interview_id = ${command.interviewId} AND revision = ${command.expectedRevision} RETURNING revision`; if (updated[0]?.revision !== transition.state.revision) - return yield* new RecruitmentInterviewStaleRevision({ + return yield* RecruitmentInterviewStaleRevision.make({ interviewId: command.interviewId, expectedRevision: command.expectedRevision, actualRevision: loaded.interview.revision, @@ -906,7 +956,7 @@ const cancelInTransaction = ( receipt.interviewId !== command.interviewId || receipt.kind !== "InterviewCancelled" ) { - return yield* new RecruitmentLifecycleCommandConflict({ commandId: command.commandId }); + return yield* RecruitmentLifecycleCommandConflict.make({ commandId: command.commandId }); } const stored = yield* decode( @@ -933,7 +983,7 @@ const cancelInTransaction = ( const cancellation = transition.state.cancellation; if (cancellation === null) - return yield* new RecruitmentConductValidationError({ + return yield* RecruitmentConductValidationError.make({ interviewId: command.interviewId, message: "cancellation produced no record", }); @@ -943,7 +993,7 @@ const cancelInTransaction = ( }>`UPDATE recruitment_interviews SET revision = revision + 1 WHERE interview_id = ${command.interviewId} AND revision = ${command.expectedRevision} RETURNING revision`; if (updated[0]?.revision !== transition.state.revision) - return yield* new RecruitmentInterviewStaleRevision({ + return yield* RecruitmentInterviewStaleRevision.make({ interviewId: command.interviewId, expectedRevision: command.expectedRevision, actualRevision: loaded.interview.revision, @@ -989,7 +1039,7 @@ const correctInTransaction = ( if (receipt !== undefined) { if (receipt.commandSha256 !== digest || receipt.interviewId !== command.interviewId) - return yield* new RecruitmentLifecycleCommandConflict({ commandId: command.commandId }); + return yield* RecruitmentLifecycleCommandConflict.make({ commandId: command.commandId }); const stored = yield* decode( CorrectInterviewAssessmentObservationSchema, @@ -1031,7 +1081,7 @@ const correctInTransaction = ( `; if (updated[0]?.revision !== transition.state.revision) - return yield* new RecruitmentInterviewStaleRevision({ + return yield* RecruitmentInterviewStaleRevision.make({ interviewId: command.interviewId, expectedRevision: command.expectedRevision, actualRevision: loaded.interview.revision, @@ -1078,84 +1128,127 @@ const correctInTransaction = ( ), ); -export const correctInterviewAssessment = ( - command: CorrectInterviewAssessmentCommand, - context: RecruitmentConductContext, -): Effect.Effect => - Effect.gen(function* () { - const decoded = yield* decode( - CorrectInterviewAssessmentCommandSchema, - "correction command", - )(command); - - const sql = yield* Database; - const organization = yield* Organization; - - return yield* sql - .withTransaction( - correctInTransaction( - decoded, - context, - sql, - organization, - sha256Hex(canonicalJsonBytes(decoded)), - ), - ) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("correction transaction", cause)), - ), - ); - }); - -export const finalizeInterview = ( - command: FinalizeInterviewCommand, - context: RecruitmentConductContext, -): Effect.Effect => - Effect.gen(function* () { - const decoded = yield* decode(FinalizeInterviewCommandSchema, "finalization command")(command); - const sql = yield* Database; - const organization = yield* Organization; - - return yield* sql - .withTransaction( - finalizeInTransaction( - decoded, - context, - sql, - organization, - sha256Hex(canonicalJsonBytes(decoded)), - ), - ) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("finalization transaction", cause)), - ), - ); - }); - -export const cancelInterview = ( - command: CancelInterviewCommand, - context: RecruitmentConductContext, -): Effect.Effect => - Effect.gen(function* () { - const decoded = yield* decode(CancelInterviewCommandSchema, "cancellation command")(command); - const sql = yield* Database; - const organization = yield* Organization; - - return yield* sql - .withTransaction( - cancelInTransaction( - decoded, - context, - sql, - organization, - sha256Hex(canonicalJsonBytes(decoded)), - ), - ) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("cancellation transaction", cause)), - ), - ); - }); +export const correctInterviewAssessment: { + ( + context: RecruitmentConductContext, + ): ( + command: CorrectInterviewAssessmentCommand, + ) => Effect.Effect; + ( + command: CorrectInterviewAssessmentCommand, + context: RecruitmentConductContext, + ): Effect.Effect; +} = dual( + 2, + ( + command: CorrectInterviewAssessmentCommand, + context: RecruitmentConductContext, + ): Effect.Effect => + Effect.gen(function* () { + const decoded = yield* decode( + CorrectInterviewAssessmentCommandSchema, + "correction command", + )(command); + + const sql = yield* Database; + const organization = yield* Organization; + + return yield* sql + .withTransaction( + correctInTransaction( + decoded, + context, + sql, + organization, + sha256Hex(canonicalJsonBytes(decoded)), + ), + ) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("correction transaction", cause)), + ), + ); + }), +); + +export const finalizeInterview: { + ( + context: RecruitmentConductContext, + ): ( + command: FinalizeInterviewCommand, + ) => Effect.Effect; + ( + command: FinalizeInterviewCommand, + context: RecruitmentConductContext, + ): Effect.Effect; +} = dual( + 2, + ( + command: FinalizeInterviewCommand, + context: RecruitmentConductContext, + ): Effect.Effect => + Effect.gen(function* () { + const decoded = yield* decode( + FinalizeInterviewCommandSchema, + "finalization command", + )(command); + + const sql = yield* Database; + const organization = yield* Organization; + + return yield* sql + .withTransaction( + finalizeInTransaction( + decoded, + context, + sql, + organization, + sha256Hex(canonicalJsonBytes(decoded)), + ), + ) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("finalization transaction", cause)), + ), + ); + }), +); + +export const cancelInterview: { + ( + context: RecruitmentConductContext, + ): ( + command: CancelInterviewCommand, + ) => Effect.Effect; + ( + command: CancelInterviewCommand, + context: RecruitmentConductContext, + ): Effect.Effect; +} = dual( + 2, + ( + command: CancelInterviewCommand, + context: RecruitmentConductContext, + ): Effect.Effect => + Effect.gen(function* () { + const decoded = yield* decode(CancelInterviewCommandSchema, "cancellation command")(command); + const sql = yield* Database; + const organization = yield* Organization; + + return yield* sql + .withTransaction( + cancelInTransaction( + decoded, + context, + sql, + organization, + sha256Hex(canonicalJsonBytes(decoded)), + ), + ) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("cancellation transaction", cause)), + ), + ); + }), +); diff --git a/packages/database/src/recruitment/http-postgres.test.ts b/packages/database/src/recruitment/http-postgres.test.ts index 8c238f8fc..ec6a41cf3 100644 --- a/packages/database/src/recruitment/http-postgres.test.ts +++ b/packages/database/src/recruitment/http-postgres.test.ts @@ -31,74 +31,72 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })((it) => const capability = RecruitmentInvitationCapabilitySchema.make("a".repeat(43)); const digest = sha256Hex(new TextEncoder().encode(capability)); - const observed = yield* Effect.gen(function* () { - const sql = yield* Database; - yield* sql`INSERT INTO organization_departments (department_id, name, short_name, email, city) VALUES ('department-1', 'Realfag', 'RF', 'rf@example.invalid', 'Trondheim')`; - yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES ('department-1', 'Realfag')`; - yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ('semester-1', '2031-08-01', '2032-01-01')`; - yield* sql`INSERT INTO admission_periods (admission_period_id, department_id, semester_id, start_at, end_at, last_command_id) VALUES ('period-1', 'department-1', 'semester-1', '2031-08-01', '2031-10-01', 'seed')`; - yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name, active) VALUES ('field-1', 'department-1', 'Matematikk', TRUE)`; - yield* sql`INSERT INTO person_profiles (person_id, first_name, last_name) VALUES ('primary-1', 'Ivar', 'Intervjuer'), ('co-1', 'Kari', 'Kollega'), ('other-1', 'Andre', 'Medlem')`; - yield* sql`INSERT INTO person_contact_profiles (person_id, email, phone) VALUES ('primary-1', 'primary@example.invalid', '90000000')`; - yield* sql`INSERT INTO organization_teams (team_id, department_id, name) VALUES ('team-1', 'department-1', 'Team')`; - yield* sql`INSERT INTO organization_memberships (membership_id, person_id, team_id, start_at) VALUES ('membership-1', 'co-1', 'team-1', '2031-08-01'), ('membership-2', 'primary-1', 'team-1', '2031-08-01')`; - yield* sql`INSERT INTO admission_applicants (applicant_id, normalized_email, email, first_name, last_name, phone, gender, field_of_study_id, year_of_study) VALUES ('applicant-1', 'applicant@example.invalid', 'applicant@example.invalid', 'Ada', 'Søker', '90000001', 0, 'field-1', 2)`; - yield* sql`INSERT INTO admission_applications (application_id, applicant_id, admission_period_id, department_id, field_of_study_id, year_of_study, submitted_at) VALUES ('application-1', 'applicant-1', 'period-1', 'department-1', 'field-1', 2, '2031-08-15')`; - yield* sql`INSERT INTO recruitment_interview_schemas (interview_schema_id, name, question_count, active, revision) VALUES ('schema-1', 'Interview', 0, TRUE, 0)`; - yield* sql`INSERT INTO recruitment_interviews (interview_id, application_id, department_id, interviewer_person_id, co_interviewer_person_id, interview_schema_id, assigned_by_person_id, assigned_at, revision) VALUES ('interview-1', 'application-1', 'department-1', 'primary-1', 'co-1', 'schema-1', 'primary-1', '2031-09-01', 3)`; - yield* sql`INSERT INTO organization_global_administrator_grants (grant_id, person_id, start_at, revision) VALUES ('grant-1', 'co-1', '2031-08-01', 2)`; - yield* sql`UPDATE organization_memberships SET revision = 3 WHERE membership_id = 'membership-1'`; - yield* sql`UPDATE organization_teams SET revision = 5 WHERE team_id = 'team-1'`; - yield* sql`UPDATE organization_departments SET revision = 7 WHERE department_id = 'department-1'`; - yield* sql.withTransaction( - Effect.gen(function* () { - yield* sql`INSERT INTO recruitment_interview_schedules (interview_id, scheduled_at, room, campus, message, scheduled_by_person_id, committed_at, schedule_revision) VALUES ('interview-1', '2031-09-15T12:00:00.000Z', 'A1', 'Gløshaugen', 'Welcome', 'primary-1', '2031-09-01', 2)`; - yield* sql`INSERT INTO recruitment_invitations (invitation_id, interview_id, schedule_revision, capability_sha256, response_state, response_revision, created_at) VALUES ('invitation-1', 'interview-1', 2, ${digest}, 'Pending', 0, '2031-09-01')`; - }), - ); + const sql = yield* Database; + yield* sql`INSERT INTO organization_departments (department_id, name, short_name, email, city) VALUES ('department-1', 'Realfag', 'RF', 'rf@example.invalid', 'Trondheim')`; + yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES ('department-1', 'Realfag')`; + yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ('semester-1', '2031-08-01', '2032-01-01')`; + yield* sql`INSERT INTO admission_periods (admission_period_id, department_id, semester_id, start_at, end_at, last_command_id) VALUES ('period-1', 'department-1', 'semester-1', '2031-08-01', '2031-10-01', 'seed')`; + yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name, active) VALUES ('field-1', 'department-1', 'Matematikk', TRUE)`; + yield* sql`INSERT INTO person_profiles (person_id, first_name, last_name) VALUES ('primary-1', 'Ivar', 'Intervjuer'), ('co-1', 'Kari', 'Kollega'), ('other-1', 'Andre', 'Medlem')`; + yield* sql`INSERT INTO person_contact_profiles (person_id, email, phone) VALUES ('primary-1', 'primary@example.invalid', '90000000')`; + yield* sql`INSERT INTO organization_teams (team_id, department_id, name) VALUES ('team-1', 'department-1', 'Team')`; + yield* sql`INSERT INTO organization_memberships (membership_id, person_id, team_id, start_at) VALUES ('membership-1', 'co-1', 'team-1', '2031-08-01'), ('membership-2', 'primary-1', 'team-1', '2031-08-01')`; + yield* sql`INSERT INTO admission_applicants (applicant_id, normalized_email, email, first_name, last_name, phone, gender, field_of_study_id, year_of_study) VALUES ('applicant-1', 'applicant@example.invalid', 'applicant@example.invalid', 'Ada', 'Søker', '90000001', 0, 'field-1', 2)`; + yield* sql`INSERT INTO admission_applications (application_id, applicant_id, admission_period_id, department_id, field_of_study_id, year_of_study, submitted_at) VALUES ('application-1', 'applicant-1', 'period-1', 'department-1', 'field-1', 2, '2031-08-15')`; + yield* sql`INSERT INTO recruitment_interview_schemas (interview_schema_id, name, question_count, active, revision) VALUES ('schema-1', 'Interview', 0, TRUE, 0)`; + yield* sql`INSERT INTO recruitment_interviews (interview_id, application_id, department_id, interviewer_person_id, co_interviewer_person_id, interview_schema_id, assigned_by_person_id, assigned_at, revision) VALUES ('interview-1', 'application-1', 'department-1', 'primary-1', 'co-1', 'schema-1', 'primary-1', '2031-09-01', 3)`; + yield* sql`INSERT INTO organization_global_administrator_grants (grant_id, person_id, start_at, revision) VALUES ('grant-1', 'co-1', '2031-08-01', 2)`; + yield* sql`UPDATE organization_memberships SET revision = 3 WHERE membership_id = 'membership-1'`; + yield* sql`UPDATE organization_teams SET revision = 5 WHERE team_id = 'team-1'`; + yield* sql`UPDATE organization_departments SET revision = 7 WHERE department_id = 'department-1'`; + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql`INSERT INTO recruitment_interview_schedules (interview_id, scheduled_at, room, campus, message, scheduled_by_person_id, committed_at, schedule_revision) VALUES ('interview-1', '2031-09-15T12:00:00.000Z', 'A1', 'Gløshaugen', 'Welcome', 'primary-1', '2031-09-01', 2)`; + yield* sql`INSERT INTO recruitment_invitations (invitation_id, interview_id, schedule_revision, capability_sha256, response_state, response_revision, created_at) VALUES ('invitation-1', 'interview-1', 2, ${digest}, 'Pending', 0, '2031-09-01')`; + }), + ); - const authority = yield* Recruitment.use((service) => - service.readPersonAuthoritySources(PersonId.make("co-1")), - ); + const authority = yield* Recruitment.use((service) => + service.readPersonAuthoritySources(PersonId.make("co-1")), + ); - const interview = yield* Recruitment.use((service) => - service.readInterviewSource( - RecruitmentInterviewId.make("interview-1"), - PersonId.make("co-1"), - ), - ); + const interview = yield* Recruitment.use((service) => + service.readInterviewSource( + RecruitmentInterviewId.make("interview-1"), + PersonId.make("co-1"), + ), + ); - const snapshot = yield* Recruitment.use((service) => - service.transitionInvitation({ - capability, - transition: RecruitmentInvitationTransition.Confirm(), - now: "2031-09-02T00:00:00.000Z", - }), - ); + const snapshot = yield* Recruitment.use((service) => + service.transitionInvitation({ + capability, + transition: RecruitmentInvitationTransition.Confirm(), + now: "2031-09-02T00:00:00.000Z", + }), + ); - yield* sql`UPDATE recruitment_invitations SET superseded_at = '2031-09-16' WHERE invitation_id = 'invitation-1'`; + yield* sql`UPDATE recruitment_invitations SET superseded_at = '2031-09-16' WHERE invitation_id = 'invitation-1'`; - const superseded = yield* Effect.flip( - Recruitment.use((service) => service.readInvitationSnapshot(capability)), - ); + const superseded = yield* Effect.flip( + Recruitment.use((service) => service.readInvitationSnapshot(capability)), + ); - yield* sql`INSERT INTO applicant_account_invitations (invitation_id, application_id, applicant_id, token_digest, expires_at, state, issued_by, issued_at) VALUES ('account-invitation', 'application-1', 'applicant-1', repeat('b',64), '2031-10-01', 'Claimed', 'primary-1', '2031-09-01')`; - yield* sql`INSERT INTO applicant_account_links (applicant_id, person_id, linked_at, invitation_id) VALUES ('applicant-1', 'co-1', '2031-09-02', 'account-invitation')`; + yield* sql`INSERT INTO applicant_account_invitations (invitation_id, application_id, applicant_id, token_digest, expires_at, state, issued_by, issued_at) VALUES ('account-invitation', 'application-1', 'applicant-1', repeat('b',64), '2031-10-01', 'Claimed', 'primary-1', '2031-09-01')`; + yield* sql`INSERT INTO applicant_account_links (applicant_id, person_id, linked_at, invitation_id) VALUES ('applicant-1', 'co-1', '2031-09-02', 'account-invitation')`; - const selfDenied = yield* Effect.flip( - sql.withTransaction( - Recruitment.use((service) => - service.prepareInterview({ - interviewId: RecruitmentInterviewId.make("interview-1"), - personId: PersonId.make("co-1"), - authorizationInstant: "2031-09-15T12:00:00.000Z", - }), - ), + const selfDenied = yield* Effect.flip( + sql.withTransaction( + Recruitment.use((service) => + service.prepareInterview({ + interviewId: RecruitmentInterviewId.make("interview-1"), + personId: PersonId.make("co-1"), + authorizationInstant: "2031-09-15T12:00:00.000Z", + }), ), - ); + ), + ); - return { authority, interview, snapshot, superseded, selfDenied }; - }); + const observed = { authority, interview, snapshot, superseded, selfDenied }; expect(observed.authority).toEqual([ { kind: "GlobalAdministrator", identity: "grant-1", revisions: [2] }, diff --git a/packages/database/src/recruitment/http-postgres.ts b/packages/database/src/recruitment/http-postgres.ts index b8178111c..2908a51cf 100644 --- a/packages/database/src/recruitment/http-postgres.ts +++ b/packages/database/src/recruitment/http-postgres.ts @@ -14,6 +14,7 @@ import { import { resolveOrganizationPersonAuthorityWithSql } from "../organization/authority-postgres.js"; import { sha256Hex } from "@vektorprogrammet/domain/shared-kernel"; import { Match, Effect, Predicate, Schema } from "effect"; +import { dual } from "effect/Function"; import { RecruitmentApplicationNotFound, RecruitmentDecodeError, @@ -49,10 +50,10 @@ import { } from "@vektorprogrammet/domain/recruitment"; const decodeError = (operation: string, cause: unknown) => - new RecruitmentDecodeError({ message: `${operation}: ${String(cause)}` }); + RecruitmentDecodeError.make({ message: `${operation}: ${String(cause)}` }); const persistenceError = (operation: string, cause: unknown) => - new RecruitmentPersistenceError({ operation, message: String(cause), cause }); + RecruitmentPersistenceError.make({ operation, message: String(cause), cause }); const capabilityDigest = (capability: RecruitmentInvitationCapability): string => sha256Hex(new TextEncoder().encode(capability)); @@ -70,7 +71,7 @@ export const readRecruitmentInvitationHttpSnapshotPostgres = ( const capability = yield* Schema.decodeEffect(RecruitmentInvitationCapabilitySchema)( capabilityInput, { onExcessProperty: "error" }, - ).pipe(Effect.mapError(() => new RecruitmentInvitationNotFound({}))); + ).pipe(Effect.mapError(() => RecruitmentInvitationNotFound.make({}))); const capabilitySha256 = capabilityDigest(capability); @@ -114,7 +115,7 @@ export const readRecruitmentInvitationHttpSnapshotPostgres = ( const row = rows[0]; - if (row === undefined) return yield* new RecruitmentInvitationNotFound({}); + if (row === undefined) return yield* RecruitmentInvitationNotFound.make({}); return yield* Schema.decodeUnknownEffect(RecruitmentInvitationHttpSnapshotSchema)( { @@ -190,7 +191,7 @@ export const readRecruitmentApplicationHttpAccessPostgres = (input: { const row = rows[0]; if (row === undefined) { - return yield* new RecruitmentApplicationNotFound({ + return yield* RecruitmentApplicationNotFound.make({ applicationId: input.applicationId, }); } @@ -322,17 +323,37 @@ export const readRecruitmentPersonAuthorityHttpSourcesPostgres = ( > => Database.use((database) => readRecruitmentPersonAuthorityHttpSources(database, personId)); /** Interview identity, revision, relationship, and ordered authority sources for HTTP ETags. */ -export const readRecruitmentInterviewHttpSourcePostgres = ( - interviewId: RecruitmentInterviewId, - personId: PersonId, -): Effect.Effect< - RecruitmentInterviewHttpSource, - RecruitmentInterviewNotFound | RecruitmentDecodeError | RecruitmentPersistenceError, - Database -> => - Database.use((database) => - Effect.gen(function* () { - const interviewRows = yield* database` +export const readRecruitmentInterviewHttpSourcePostgres: { + ( + personId: PersonId, + ): ( + interviewId: RecruitmentInterviewId, + ) => Effect.Effect< + RecruitmentInterviewHttpSource, + RecruitmentInterviewNotFound | RecruitmentDecodeError | RecruitmentPersistenceError, + Database + >; + ( + interviewId: RecruitmentInterviewId, + personId: PersonId, + ): Effect.Effect< + RecruitmentInterviewHttpSource, + RecruitmentInterviewNotFound | RecruitmentDecodeError | RecruitmentPersistenceError, + Database + >; +} = dual( + 2, + ( + interviewId: RecruitmentInterviewId, + personId: PersonId, + ): Effect.Effect< + RecruitmentInterviewHttpSource, + RecruitmentInterviewNotFound | RecruitmentDecodeError | RecruitmentPersistenceError, + Database + > => + Database.use((database) => + Effect.gen(function* () { + const interviewRows = yield* database` SELECT interview_id AS "interviewId", department_id AS "departmentId", @@ -342,35 +363,39 @@ export const readRecruitmentInterviewHttpSourcePostgres = ( FROM public.recruitment_interviews WHERE interview_id = ${interviewId} `.pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("read recruitment interview HTTP source", cause)), - ), - ); + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("read recruitment interview HTTP source", cause)), + ), + ); - const interview = interviewRows[0]; + const interview = interviewRows[0]; - if (interview === undefined) return yield* new RecruitmentInterviewNotFound({ interviewId }); + if (interview === undefined) + return yield* RecruitmentInterviewNotFound.make({ interviewId }); - const identity = yield* readInterviewApplicantIdentity(interviewId).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("read interview applicant identity", cause)), - ), - ); + const identity = yield* readInterviewApplicantIdentity(interviewId).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("read interview applicant identity", cause)), + ), + ); - const authority = yield* readRecruitmentPersonAuthorityHttpSources(database, personId); + const authority = yield* readRecruitmentPersonAuthorityHttpSources(database, personId); - return yield* Schema.decodeUnknownEffect(RecruitmentInterviewHttpSourceSchema)( - { - ...interview, - linkedApplicantPersonId: identity.linkedApplicantPersonId, - authority, - }, - { onExcessProperty: "error" }, - ).pipe( - Effect.mapError((cause) => decodeError("decode recruitment interview HTTP source", cause)), - ); - }), - ); + return yield* Schema.decodeUnknownEffect(RecruitmentInterviewHttpSourceSchema)( + { + ...interview, + linkedApplicantPersonId: identity.linkedApplicantPersonId, + authority, + }, + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => + decodeError("decode recruitment interview HTTP source", cause), + ), + ); + }), + ), +); /** Target selection and business identity custody belong to Recruitment, not its transport. */ export const prepareRecruitmentAssignment = (input: { diff --git a/packages/database/src/recruitment/invitation-response-postgres.ts b/packages/database/src/recruitment/invitation-response-postgres.ts index 02f0a2c57..64ab5085a 100644 --- a/packages/database/src/recruitment/invitation-response-postgres.ts +++ b/packages/database/src/recruitment/invitation-response-postgres.ts @@ -10,6 +10,7 @@ import { sha256Hex, } from "@vektorprogrammet/domain/shared-kernel"; import { Match, flow, Predicate, Effect, Schema, SchemaIssue } from "effect"; +import { dual } from "effect/Function"; import { RecruitmentInvitationResponseStateSchema, RecruitmentInvitationResponseMessageSchema, @@ -64,7 +65,7 @@ const RecruitmentInvitationResponseContextSchema = Schema.Struct({ type RecordedResponseState = "Accepted" | "Rejected" | "RequestedNewTime"; const persistenceError = (operation: string, cause?: unknown): RecruitmentPersistenceError => - new RecruitmentPersistenceError({ + RecruitmentPersistenceError.make({ operation, cause, message: cause instanceof Error ? cause.message : "recruitment response persistence failed", @@ -75,11 +76,10 @@ const formatIssue = SchemaIssue.makeFormatterDefault(); const decode = (schema: Schema.ConstraintDecoder, operation: string) => flow( Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }), - Effect.mapError( - (cause) => - new RecruitmentDecodeError({ - message: cause instanceof Error ? cause.message : `invalid ${operation}`, - }), + Effect.mapError((cause) => + RecruitmentDecodeError.make({ + message: cause instanceof Error ? cause.message : `invalid ${operation}`, + }), ), ); @@ -87,7 +87,7 @@ const decodeCapability = flow( Schema.decodeUnknownEffect(RecruitmentInvitationCapabilitySchema, { onExcessProperty: "error", }), - Effect.mapError(() => new RecruitmentInvitationNotFound({})), + Effect.mapError(() => RecruitmentInvitationNotFound.make({})), ); const readInvitationRow = ( @@ -196,12 +196,11 @@ const observationFromRow = ( responseState: row.responseState, responseMessage: row.responseMessage, }).pipe( - Effect.mapError( - (cause) => - new RecruitmentDecodeError({ - message: - cause instanceof Error ? cause.message : `invalid ${"invitation response observation"}`, - }), + Effect.mapError((cause) => + RecruitmentDecodeError.make({ + message: + cause instanceof Error ? cause.message : `invalid ${"invitation response observation"}`, + }), ), ); @@ -263,7 +262,7 @@ const recordInvitationResponse = ( Effect.gen(function* () { const candidate = yield* readInvitationRow(sql, capabilitySha256); - if (candidate === undefined) return yield* new RecruitmentInvitationNotFound({}); + if (candidate === undefined) return yield* RecruitmentInvitationNotFound.make({}); // Serialize the response with schedule, cancellation, and staffing writers before row locks. yield* lockAdvisory(sql, AdvisoryLockKey.recruitmentInterview(candidate.interviewId)).pipe( Effect.catchTag("SqlError", (cause) => @@ -272,10 +271,10 @@ const recordInvitationResponse = ( ); const row = yield* lockInvitationRow(sql, capabilitySha256); - if (row === undefined) return yield* new RecruitmentInvitationNotFound({}); + if (row === undefined) return yield* RecruitmentInvitationNotFound.make({}); if (row.responseState !== "Pending" || row.responseRevision !== 0) { - return yield* new RecruitmentInvitationAlreadyResponded({}); + return yield* RecruitmentInvitationAlreadyResponded.make({}); } const notificationRequired = responseState !== "Accepted"; @@ -308,7 +307,7 @@ const recordInvitationResponse = ( const responseRevision = updated[0]?.responseRevision; if (responseRevision !== 1) { - return yield* new RecruitmentInvitationAlreadyResponded({}); + return yield* RecruitmentInvitationAlreadyResponded.make({}); } yield* sql` @@ -376,7 +375,7 @@ const recordInvitationResponse = ( }); const request = yield* requestEffect.pipe( - Effect.mapError((issue) => new RecruitmentDecodeError({ message: formatIssue(issue) })), + Effect.mapError((issue) => RecruitmentDecodeError.make({ message: formatIssue(issue) })), ); yield* sql`UPDATE public.recruitment_invitation_response_audit SET envelope_sha256=${sha256Hex(canonicalJsonBytes(request))} WHERE invitation_id=${row.invitationId} AND response_revision=${responseRevision} AND envelope_sha256 IS NULL`.pipe( @@ -467,11 +466,10 @@ const recordInvitationResponse = ( ); return yield* responseEffect.pipe( - Effect.mapError( - (cause) => - new RecruitmentDecodeError({ - message: SchemaIssue.isIssue(cause) ? formatIssue(cause) : String(cause), - }), + Effect.mapError((cause) => + RecruitmentDecodeError.make({ + message: SchemaIssue.isIssue(cause) ? formatIssue(cause) : String(cause), + }), ), ); }); @@ -527,62 +525,129 @@ export const readInvitationResponse = ( const capabilitySha256 = sha256Hex(new TextEncoder().encode(decodedCapability)); const row = yield* readInvitationRow(sql, capabilitySha256); - if (row === undefined) return yield* new RecruitmentInvitationNotFound({}); + if (row === undefined) return yield* RecruitmentInvitationNotFound.make({}); return yield* observationFromRow(row); }); -export const confirmInvitation = ( - capability: RecruitmentInvitationCapability, - context: RecruitmentInvitationResponseContext, -): Effect.Effect< - RecruitmentInvitationResponseResult, - RecruitmentFailure, - Admissions | Database | Profile -> => transitionInvitation(capability, "Accepted", null, context); +export const confirmInvitation: { + ( + context: RecruitmentInvitationResponseContext, + ): ( + capability: RecruitmentInvitationCapability, + ) => Effect.Effect< + RecruitmentInvitationResponseResult, + RecruitmentFailure, + Admissions | Database | Profile + >; + ( + capability: RecruitmentInvitationCapability, + context: RecruitmentInvitationResponseContext, + ): Effect.Effect< + RecruitmentInvitationResponseResult, + RecruitmentFailure, + Admissions | Database | Profile + >; +} = dual( + 2, + ( + capability: RecruitmentInvitationCapability, + context: RecruitmentInvitationResponseContext, + ): Effect.Effect< + RecruitmentInvitationResponseResult, + RecruitmentFailure, + Admissions | Database | Profile + > => transitionInvitation(capability, "Accepted", null, context), +); -export const rejectInvitation = ( - capability: RecruitmentInvitationCapability, - input: RecruitmentInvitationRejectInput, - context: RecruitmentInvitationResponseContext, -): Effect.Effect< - RecruitmentInvitationResponseResult, - RecruitmentFailure, - Admissions | Database | Profile -> => - Effect.gen(function* () { - const decodedInput = yield* decode( - RecruitmentInvitationRejectInputSchema, - "invitation rejection", - )(input); - - return yield* transitionInvitation( - capability, - "Rejected", - decodedInput.message ?? null, - context, - ); - }); +export const rejectInvitation: { + ( + input: RecruitmentInvitationRejectInput, + context: RecruitmentInvitationResponseContext, + ): ( + capability: RecruitmentInvitationCapability, + ) => Effect.Effect< + RecruitmentInvitationResponseResult, + RecruitmentFailure, + Admissions | Database | Profile + >; + ( + capability: RecruitmentInvitationCapability, + input: RecruitmentInvitationRejectInput, + context: RecruitmentInvitationResponseContext, + ): Effect.Effect< + RecruitmentInvitationResponseResult, + RecruitmentFailure, + Admissions | Database | Profile + >; +} = dual( + 3, + ( + capability: RecruitmentInvitationCapability, + input: RecruitmentInvitationRejectInput, + context: RecruitmentInvitationResponseContext, + ): Effect.Effect< + RecruitmentInvitationResponseResult, + RecruitmentFailure, + Admissions | Database | Profile + > => + Effect.gen(function* () { + const decodedInput = yield* decode( + RecruitmentInvitationRejectInputSchema, + "invitation rejection", + )(input); + + return yield* transitionInvitation( + capability, + "Rejected", + decodedInput.message ?? null, + context, + ); + }), +); -export const requestNewInvitationTime = ( - capability: RecruitmentInvitationCapability, - input: RecruitmentInvitationRequestNewTimeInput, - context: RecruitmentInvitationResponseContext, -): Effect.Effect< - RecruitmentInvitationResponseResult, - RecruitmentFailure, - Admissions | Database | Profile -> => - Effect.gen(function* () { - const decodedInput = yield* decode( - RecruitmentInvitationRequestNewTimeInputSchema, - "invitation new-time request", - )(input); - - return yield* transitionInvitation( - capability, - "RequestedNewTime", - decodedInput.message, - context, - ); - }); +export const requestNewInvitationTime: { + ( + input: RecruitmentInvitationRequestNewTimeInput, + context: RecruitmentInvitationResponseContext, + ): ( + capability: RecruitmentInvitationCapability, + ) => Effect.Effect< + RecruitmentInvitationResponseResult, + RecruitmentFailure, + Admissions | Database | Profile + >; + ( + capability: RecruitmentInvitationCapability, + input: RecruitmentInvitationRequestNewTimeInput, + context: RecruitmentInvitationResponseContext, + ): Effect.Effect< + RecruitmentInvitationResponseResult, + RecruitmentFailure, + Admissions | Database | Profile + >; +} = dual( + 3, + ( + capability: RecruitmentInvitationCapability, + input: RecruitmentInvitationRequestNewTimeInput, + context: RecruitmentInvitationResponseContext, + ): Effect.Effect< + RecruitmentInvitationResponseResult, + RecruitmentFailure, + Admissions | Database | Profile + > => + Effect.gen(function* () { + const decodedInput = yield* decode( + RecruitmentInvitationRequestNewTimeInputSchema, + "invitation new-time request", + )(input); + + return yield* transitionInvitation( + capability, + "RequestedNewTime", + decodedInput.message, + context, + ); + }), +); diff --git a/packages/database/src/recruitment/maintenance-postgres.ts b/packages/database/src/recruitment/maintenance-postgres.ts index 27d73a11e..77fb390fa 100644 --- a/packages/database/src/recruitment/maintenance-postgres.ts +++ b/packages/database/src/recruitment/maintenance-postgres.ts @@ -1,4 +1,5 @@ import { DateTime, Effect, Predicate, Schema } from "effect"; +import { dual } from "effect/Function"; import { SqlSchema } from "effect/unstable/sql"; import { canonicalJsonBytes, sha256Hex } from "@vektorprogrammet/domain/shared-kernel"; import { @@ -20,6 +21,7 @@ import { InterviewStaffing, InterviewStaffingHistory, InterviewStaffingManagement, + type RecruitmentMaintenanceAuthorization, RecruitmentMaintenanceCommand, RecruitmentMaintenanceResult, RecruitmentMaintenanceFailure, @@ -37,10 +39,10 @@ import { sealInterviewInvitationEnvelopes } from "./outbox.js"; import { sealInterviewResponseEnvelopes } from "./response-outbox.js"; const fail = (code: RecruitmentMaintenanceFailure["code"]) => - new RecruitmentMaintenanceFailure({ code }); + RecruitmentMaintenanceFailure.make({ code }); const persistence = (cause: unknown) => - new RecruitmentPersistenceError({ + RecruitmentPersistenceError.make({ operation: "recruitment maintenance", message: String(cause), cause, @@ -234,21 +236,33 @@ const authorizeWithSql = Effect.fn("Recruitment.authorizeMaintenance")(function* yield* lockAdvisory(sql, AdvisoryLockKey.recruitmentInterview(command.interviewId)); }); -export const authorizeMaintenance = (command: RecruitmentMaintenanceCommand, personId: PersonId) => +const authorizeMaintenanceImpl = (command: RecruitmentMaintenanceCommand, personId: PersonId) => Effect.gen(function* () { const sql = yield* Database; const decoded = yield* decodeCommand(command).pipe(Effect.mapError(() => fail("Invalid"))); yield* authorizeWithSql(sql, decoded, personId); + + // SAFETY: the one constructor of the evidence brand; authorizeWithSql above is what it proves. + return { personId, command: decoded } as RecruitmentMaintenanceAuthorization; }).pipe(mapFailure); -export const maintainRecruitment = (input: RecruitmentMaintenanceCommand, personId: PersonId) => +export const authorizeMaintenance: { + ( + personId: PersonId, + ): (command: RecruitmentMaintenanceCommand) => ReturnType; + ( + command: RecruitmentMaintenanceCommand, + personId: PersonId, + ): ReturnType; +} = dual(2, authorizeMaintenanceImpl); + +export const maintainRecruitment = (authorization: RecruitmentMaintenanceAuthorization) => Effect.gen(function* () { - const command = yield* decodeCommand(input).pipe(Effect.mapError(() => fail("Invalid"))); + const { command, personId } = authorization; const sql = yield* Database; return yield* sql.withTransaction( Effect.gen(function* () { - yield* authorizeWithSql(sql, command, personId); yield* lockAdvisory( sql, AdvisoryLockKey.recruitmentMaintenanceCommand(personId, command.commandId), @@ -260,7 +274,7 @@ export const maintainRecruitment = (input: RecruitmentMaintenanceCommand, person result: unknown; }>`SELECT command_digest AS digest,result_json AS result FROM public.recruitment_maintenance_command_receipts WHERE actor_person_id=${personId} AND command_id=${command.commandId}`; - if (receipts[0]) { + if (receipts[0] !== undefined) { if (receipts[0].digest !== digest) return yield* fail("Conflict"); return yield* Schema.decodeUnknownEffect(RecruitmentMaintenanceResult)( @@ -274,7 +288,7 @@ export const maintainRecruitment = (input: RecruitmentMaintenanceCommand, person yield* sealInterviewResponseEnvelopes(command.interviewId); const before = (yield* staffingRows(sql, null, command.interviewId, true))[0]; - if (!before) return yield* fail("NotFound"); + if (before === undefined) return yield* fail("NotFound"); if (before.terminal) return yield* fail("Terminal"); diff --git a/packages/database/src/recruitment/outbox.ts b/packages/database/src/recruitment/outbox.ts index 81a395f70..082e93883 100644 --- a/packages/database/src/recruitment/outbox.ts +++ b/packages/database/src/recruitment/outbox.ts @@ -20,6 +20,7 @@ import { sha256Hex, } from "@vektorprogrammet/domain/shared-kernel"; import { flow, Data, Predicate, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { RecruitmentPersistenceError } from "@vektorprogrammet/domain/recruitment"; import { RecruitmentInvitationOutboxRequestSchema, @@ -153,7 +154,7 @@ export const RecruitmentInvitationDeliveryResult = Data.taggedEnum(); const persistenceError = (operation: string, cause?: unknown): RecruitmentPersistenceError => - new RecruitmentPersistenceError({ + RecruitmentPersistenceError.make({ operation, cause, message: cause instanceof Error ? cause.message : "recruitment outbox persistence failed", @@ -581,54 +582,101 @@ export const sealInterviewInvitationEnvelopes = (interviewId: string) => ), ); -export const claimNextRecruitmentInvitation = ( - claimId: string, - claimedAt: string, -): Effect.Effect< - ClaimedRecruitmentInvitation | undefined, - RecruitmentPersistenceError | OutboxClaimLost, - Admissions | Database | Profile -> => - Effect.gen(function* () { - const admissions = yield* Admissions; - const sql = yield* Database; - const profile = yield* Profile; - - return yield* sql - .withTransaction(claimInTransaction(sql, admissions, profile, claimId, claimedAt)) - .pipe( +export const claimNextRecruitmentInvitation: { + ( + claimedAt: string, + ): ( + claimId: string, + ) => Effect.Effect< + ClaimedRecruitmentInvitation | undefined, + RecruitmentPersistenceError | OutboxClaimLost, + Admissions | Database | Profile + >; + ( + claimId: string, + claimedAt: string, + ): Effect.Effect< + ClaimedRecruitmentInvitation | undefined, + RecruitmentPersistenceError | OutboxClaimLost, + Admissions | Database | Profile + >; +} = dual( + 2, + ( + claimId: string, + claimedAt: string, + ): Effect.Effect< + ClaimedRecruitmentInvitation | undefined, + RecruitmentPersistenceError | OutboxClaimLost, + Admissions | Database | Profile + > => + Effect.gen(function* () { + const admissions = yield* Admissions; + const sql = yield* Database; + const profile = yield* Profile; + + return yield* sql + .withTransaction(claimInTransaction(sql, admissions, profile, claimId, claimedAt)) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("invitation outbox claim transaction", cause)), + ), + ); + }), +); + +export const completeRecruitmentInvitation: { + ( + evidence: RecruitmentNotificationEvidence, + ): ( + claim: ClaimedRecruitmentInvitation, + ) => Effect.Effect; + ( + claim: ClaimedRecruitmentInvitation, + evidence: RecruitmentNotificationEvidence, + ): Effect.Effect; +} = dual( + 2, + ( + claim: ClaimedRecruitmentInvitation, + evidence: RecruitmentNotificationEvidence, + ): Effect.Effect => + Effect.gen(function* () { + if (evidence.effectId !== claim.effectId) + return yield* persistenceError("invitation delivery evidence effect mismatch"); + + yield* Database.use((sql) => + markOutboxDelivered(sql, invitationOutbox, claim, { deliveredAt: evidence.deliveredAt }), + ).pipe( Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("invitation outbox claim transaction", cause)), + Effect.fail(persistenceError("complete invitation outbox claim", cause)), ), ); - }); - -export const completeRecruitmentInvitation = ( - claim: ClaimedRecruitmentInvitation, - evidence: RecruitmentNotificationEvidence, -): Effect.Effect => - Effect.gen(function* () { - if (evidence.effectId !== claim.effectId) - return yield* persistenceError("invitation delivery evidence effect mismatch"); - - yield* Database.use((sql) => - markOutboxDelivered(sql, invitationOutbox, claim, { deliveredAt: evidence.deliveredAt }), - ).pipe( + }), +); + +export const failRecruitmentInvitation: { + ( + failureTag: string, + ): ( + claim: ClaimedRecruitmentInvitation, + ) => Effect.Effect; + ( + claim: ClaimedRecruitmentInvitation, + failureTag: string, + ): Effect.Effect; +} = dual( + 2, + ( + claim: ClaimedRecruitmentInvitation, + failureTag: string, + ): Effect.Effect => + Database.use((sql) => markOutboxFailed(sql, invitationOutbox, claim, failureTag)).pipe( Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("complete invitation outbox claim", cause)), + Effect.fail(persistenceError("fail invitation outbox claim", cause)), ), - ); - }); - -export const failRecruitmentInvitation = ( - claim: ClaimedRecruitmentInvitation, - failureTag: string, -): Effect.Effect => - Database.use((sql) => markOutboxFailed(sql, invitationOutbox, claim, failureTag)).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("fail invitation outbox claim", cause)), ), - ); +); export const releaseRecruitmentInvitation = ( claim: ClaimedRecruitmentInvitation, @@ -655,50 +703,71 @@ export const recoverStaleRecruitmentInvitations = ( ), ); -export const deliverNextRecruitmentInvitation = ( - claimId: string, - claimedAt: string, -): Effect.Effect< - RecruitmentInvitationDeliveryResult, - RecruitmentPersistenceError, - Admissions | Database | NotificationGateway | Profile -> => - Effect.acquireUseRelease( - claimNextRecruitmentInvitation(claimId, claimedAt), - ( - claim, - ): Effect.Effect< - RecruitmentInvitationDeliveryResult, - RecruitmentPersistenceError | OutboxClaimLost, - Admissions | Database | NotificationGateway | Profile - > => { - if (claim === undefined) return Effect.succeed(RecruitmentInvitationDeliveryResult.Idle()); - - return Effect.gen(function* () { - const gateway = yield* NotificationGateway; - - return yield* gateway.deliverInterviewInvitation(claim.request).pipe( - Effect.matchEffect({ - onFailure: (failure) => - failRecruitmentInvitation(claim, failure._tag).pipe( - Effect.as( - RecruitmentInvitationDeliveryResult.Failed({ claim, failureTag: failure._tag }), +export const deliverNextRecruitmentInvitation: { + ( + claimedAt: string, + ): ( + claimId: string, + ) => Effect.Effect< + RecruitmentInvitationDeliveryResult, + RecruitmentPersistenceError, + Admissions | Database | NotificationGateway | Profile + >; + ( + claimId: string, + claimedAt: string, + ): Effect.Effect< + RecruitmentInvitationDeliveryResult, + RecruitmentPersistenceError, + Admissions | Database | NotificationGateway | Profile + >; +} = dual( + 2, + ( + claimId: string, + claimedAt: string, + ): Effect.Effect< + RecruitmentInvitationDeliveryResult, + RecruitmentPersistenceError, + Admissions | Database | NotificationGateway | Profile + > => + Effect.acquireUseRelease( + claimNextRecruitmentInvitation(claimId, claimedAt), + ( + claim, + ): Effect.Effect< + RecruitmentInvitationDeliveryResult, + RecruitmentPersistenceError | OutboxClaimLost, + Admissions | Database | NotificationGateway | Profile + > => { + if (claim === undefined) return Effect.succeed(RecruitmentInvitationDeliveryResult.Idle()); + + return Effect.gen(function* () { + const gateway = yield* NotificationGateway; + + return yield* gateway.deliverInterviewInvitation(claim.request).pipe( + Effect.matchEffect({ + onFailure: (failure) => + failRecruitmentInvitation(claim, failure._tag).pipe( + Effect.as( + RecruitmentInvitationDeliveryResult.Failed({ claim, failureTag: failure._tag }), + ), ), - ), - onSuccess: (evidence) => - completeRecruitmentInvitation(claim, evidence).pipe( - Effect.as(RecruitmentInvitationDeliveryResult.Delivered({ claim, evidence })), - ), - }), - ); - }); - }, - (claim) => (claim === undefined ? Effect.void : releaseRecruitmentInvitation(claim)), - ).pipe( - Effect.catchTag("OutboxClaimLost", ({ effectId }) => - Effect.succeed(RecruitmentInvitationDeliveryResult.ClaimLost({ effectId })), + onSuccess: (evidence) => + completeRecruitmentInvitation(claim, evidence).pipe( + Effect.as(RecruitmentInvitationDeliveryResult.Delivered({ claim, evidence })), + ), + }), + ); + }); + }, + (claim) => (claim === undefined ? Effect.void : releaseRecruitmentInvitation(claim)), + ).pipe( + Effect.catchTag("OutboxClaimLost", ({ effectId }) => + Effect.succeed(RecruitmentInvitationDeliveryResult.ClaimLost({ effectId })), + ), ), - ); +); export const invitationPayloadForEvidence = (request: RecruitmentInvitationOutboxRequest): string => canonicalJson({ diff --git a/packages/database/src/recruitment/postgres-layer.ts b/packages/database/src/recruitment/postgres-layer.ts index df5c6701b..ad1252cf6 100644 --- a/packages/database/src/recruitment/postgres-layer.ts +++ b/packages/database/src/recruitment/postgres-layer.ts @@ -85,8 +85,8 @@ export const RecruitmentLive = Layer.effect( Effect.provideService(Admissions, admissions), Effect.provideService(Profile, profile), ), - maintainRecruitment: (command, personId) => - maintainRecruitment(command, personId).pipe( + maintainRecruitment: (authorization) => + maintainRecruitment(authorization).pipe( Effect.provideService(Database, database), Effect.provideService(Admissions, admissions), Effect.provideService(Profile, profile), diff --git a/packages/database/src/recruitment/postgres.ts b/packages/database/src/recruitment/postgres.ts index 5fb1ed784..1a6dc117a 100644 --- a/packages/database/src/recruitment/postgres.ts +++ b/packages/database/src/recruitment/postgres.ts @@ -13,6 +13,7 @@ import { sha256Hex, } from "@vektorprogrammet/domain/shared-kernel"; import { flow, Predicate, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { RecruitmentAssignmentObservationSchema, RecruitmentAdmissionPeriodNotFound, @@ -197,7 +198,7 @@ const AssignmentApplicationRowSchema = Schema.Struct({ }); const persistenceError = (operation: string, cause?: unknown): RecruitmentPersistenceError => - new RecruitmentPersistenceError({ + RecruitmentPersistenceError.make({ operation, message: cause instanceof Error ? cause.message : "recruitment persistence failed", cause, @@ -206,11 +207,10 @@ const persistenceError = (operation: string, cause?: unknown): RecruitmentPersis const decode = (schema: Schema.ConstraintDecoder, operation: string) => flow( Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }), - Effect.mapError( - (cause) => - new RecruitmentDecodeError({ - message: cause instanceof Error ? cause.message : `invalid ${operation}`, - }), + Effect.mapError((cause) => + RecruitmentDecodeError.make({ + message: cause instanceof Error ? cause.message : `invalid ${operation}`, + }), ), ); @@ -230,18 +230,18 @@ const checkContext = ( interviewId?: RecruitmentInterviewId, ): Effect.Effect => Effect.gen(function* () { - if (!actor.active) return yield* new RecruitmentInactiveActor({ personId: actor.personId }); + if (!actor.active) return yield* RecruitmentInactiveActor.make({ personId: actor.personId }); if (!Predicate.isTagged(actor, "DepartmentAdministrator")) { - return yield* new RecruitmentRoleDenied({ personId: actor.personId }); + return yield* RecruitmentRoleDenied.make({ personId: actor.personId }); } if (!isRecruitmentNow(now)) { - return yield* new RecruitmentInvalidContext({ message: "now must be an RFC3339 instant" }); + return yield* RecruitmentInvalidContext.make({ message: "now must be an RFC3339 instant" }); } if (interviewId !== undefined && interviewId.trim().length === 0) { - return yield* new RecruitmentInvalidContext({ message: "interviewId must be non-empty" }); + return yield* RecruitmentInvalidContext.make({ message: "interviewId must be non-empty" }); } return actor; @@ -257,11 +257,11 @@ const currentPeriod = ( const scoped = periods.filter((period) => period.departmentId === departmentId); if (scoped.length === 0) { - return yield* new RecruitmentAdmissionPeriodNotFound({ departmentId }); + return yield* RecruitmentAdmissionPeriodNotFound.make({ departmentId }); } if (scoped.length > 1) { - return yield* new RecruitmentAmbiguousAdmissionPeriod({ departmentId }); + return yield* RecruitmentAmbiguousAdmissionPeriod.make({ departmentId }); } return scoped[0]!; @@ -611,7 +611,7 @@ const questionsUnavailable = ( interviewSchemaId: string, reason: string, ): InterviewQuestionsUnavailable => - new InterviewQuestionsUnavailable({ + InterviewQuestionsUnavailable.make({ interviewSchemaId: InterviewSchemaId.make(interviewSchemaId), reason, }); @@ -882,11 +882,11 @@ const assignmentInTransaction = ( const application = yield* readAssignmentApplication(sql, command.applicationId); if (application === undefined) { - return yield* new RecruitmentApplicationNotFound({ applicationId: command.applicationId }); + return yield* RecruitmentApplicationNotFound.make({ applicationId: command.applicationId }); } if (application.departmentId !== context.actor.departmentId) { - return yield* new RecruitmentScopeDenied({ + return yield* RecruitmentScopeDenied.make({ personId: context.actor.personId, departmentId: DepartmentId.make(application.departmentId), applicationId: command.applicationId, @@ -896,7 +896,7 @@ const assignmentInTransaction = ( const period = yield* currentPeriod(admissions, context.actor.departmentId, context.now); if (application.admissionPeriodId !== period.id) { - return yield* new RecruitmentScopeDenied({ + return yield* RecruitmentScopeDenied.make({ personId: context.actor.personId, departmentId: context.actor.departmentId, applicationId: command.applicationId, @@ -911,7 +911,7 @@ const assignmentInTransaction = ( } if (storedReceipt.commandSha256 !== digest) { - return yield* new RecruitmentAssignmentCommandConflict({ commandId: command.commandId }); + return yield* RecruitmentAssignmentCommandConflict.make({ commandId: command.commandId }); } const observation = yield* decode( @@ -936,20 +936,20 @@ const assignmentInTransaction = ( const existing = yield* readInterviewForApplication(sql, command.applicationId); if (existing !== undefined) - return yield* new RecruitmentApplicationAlreadyAssigned({ + return yield* RecruitmentApplicationAlreadyAssigned.make({ applicationId: command.applicationId, interviewId: RecruitmentInterviewId.make(existing.interviewId), }); const interviewSchema = yield* readInterviewSchema(sql, command.interviewSchemaId); if (interviewSchema === undefined) { - return yield* new RecruitmentInterviewSchemaNotFound({ + return yield* RecruitmentInterviewSchemaNotFound.make({ interviewSchemaId: command.interviewSchemaId, }); } if (!interviewSchema.active) { - return yield* new RecruitmentInterviewSchemaInactive({ + return yield* RecruitmentInterviewSchemaInactive.make({ interviewSchemaId: command.interviewSchemaId, }); } @@ -967,7 +967,7 @@ const assignmentInTransaction = ( ); if (!eligibleIds.some((personId) => personId === command.interviewerPersonId)) { - return yield* new RecruitmentInterviewerNotEligible({ + return yield* RecruitmentInterviewerNotEligible.make({ personId: command.interviewerPersonId, departmentId: context.actor.departmentId, }); @@ -981,11 +981,10 @@ const assignmentInTransaction = ( commandId: command.commandId, interview, }).pipe( - Effect.mapError( - (cause) => - new RecruitmentDecodeError({ - message: cause instanceof Error ? cause.message : `invalid ${"assignment observation"}`, - }), + Effect.mapError((cause) => + RecruitmentDecodeError.make({ + message: cause instanceof Error ? cause.message : `invalid ${"assignment observation"}`, + }), ), ); @@ -998,73 +997,115 @@ const assignmentInTransaction = ( )({ observation, replayed: false }); }); -export const readAssignmentBoard = ( - query: RecruitmentAssignmentBoardQuery, - context: RecruitmentReadAssignmentBoardContext, -): Effect.Effect< - RecruitmentAssignmentBoard, - RecruitmentFailure, - Database | Admissions | Organization | Profile -> => - Effect.gen(function* () { - const decodedContext = yield* decode( - RecruitmentActorSchema, - "recruitment actor", - )(context.actor); - - const sql = yield* Database; - const admissions = yield* Admissions; - const organization = yield* Organization; - const profile = yield* Profile; - - return yield* assignmentBoard( - query, - { actor: decodedContext, now: context.now }, - sql, - admissions, - organization, - profile, - ); - }); - -export const assignApplicant = ( - command: RecruitmentAssignmentCommand, - context: RecruitmentAssignmentContext, -): Effect.Effect< - RecruitmentAssignmentResult, - RecruitmentFailure, - Database | Admissions | Organization | Profile -> => - Effect.gen(function* () { - const decodedCommand = yield* decodeCommand(command); - - const decodedContext = yield* decode( - RecruitmentActorSchema, - "recruitment actor", - )(context.actor); - - const actor = yield* checkContext(decodedContext, context.now, context.interviewId); - const sql = yield* Database; - const admissions = yield* Admissions; - const organization = yield* Organization; - const profile = yield* Profile; - const digest = sha256Hex(canonicalJsonBytes(decodedCommand)); - - return yield* sql - .withTransaction( - assignmentInTransaction( - decodedCommand, - { actor, now: context.now, interviewId: context.interviewId }, - sql, - admissions, - organization, - profile, - digest, - ), - ) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("assignment transaction", cause)), - ), +export const readAssignmentBoard: { + ( + context: RecruitmentReadAssignmentBoardContext, + ): ( + query: RecruitmentAssignmentBoardQuery, + ) => Effect.Effect< + RecruitmentAssignmentBoard, + RecruitmentFailure, + Database | Admissions | Organization | Profile + >; + ( + query: RecruitmentAssignmentBoardQuery, + context: RecruitmentReadAssignmentBoardContext, + ): Effect.Effect< + RecruitmentAssignmentBoard, + RecruitmentFailure, + Database | Admissions | Organization | Profile + >; +} = dual( + 2, + ( + query: RecruitmentAssignmentBoardQuery, + context: RecruitmentReadAssignmentBoardContext, + ): Effect.Effect< + RecruitmentAssignmentBoard, + RecruitmentFailure, + Database | Admissions | Organization | Profile + > => + Effect.gen(function* () { + const decodedContext = yield* decode( + RecruitmentActorSchema, + "recruitment actor", + )(context.actor); + + const sql = yield* Database; + const admissions = yield* Admissions; + const organization = yield* Organization; + const profile = yield* Profile; + + return yield* assignmentBoard( + query, + { actor: decodedContext, now: context.now }, + sql, + admissions, + organization, + profile, ); - }); + }), +); + +export const assignApplicant: { + ( + context: RecruitmentAssignmentContext, + ): ( + command: RecruitmentAssignmentCommand, + ) => Effect.Effect< + RecruitmentAssignmentResult, + RecruitmentFailure, + Database | Admissions | Organization | Profile + >; + ( + command: RecruitmentAssignmentCommand, + context: RecruitmentAssignmentContext, + ): Effect.Effect< + RecruitmentAssignmentResult, + RecruitmentFailure, + Database | Admissions | Organization | Profile + >; +} = dual( + 2, + ( + command: RecruitmentAssignmentCommand, + context: RecruitmentAssignmentContext, + ): Effect.Effect< + RecruitmentAssignmentResult, + RecruitmentFailure, + Database | Admissions | Organization | Profile + > => + Effect.gen(function* () { + const decodedCommand = yield* decodeCommand(command); + + const decodedContext = yield* decode( + RecruitmentActorSchema, + "recruitment actor", + )(context.actor); + + const actor = yield* checkContext(decodedContext, context.now, context.interviewId); + const sql = yield* Database; + const admissions = yield* Admissions; + const organization = yield* Organization; + const profile = yield* Profile; + const digest = sha256Hex(canonicalJsonBytes(decodedCommand)); + + return yield* sql + .withTransaction( + assignmentInTransaction( + decodedCommand, + { actor, now: context.now, interviewId: context.interviewId }, + sql, + admissions, + organization, + profile, + digest, + ), + ) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("assignment transaction", cause)), + ), + ); + }), +); diff --git a/packages/database/src/recruitment/report-postgres.ts b/packages/database/src/recruitment/report-postgres.ts index ad24ab27b..af7caa4a9 100644 --- a/packages/database/src/recruitment/report-postgres.ts +++ b/packages/database/src/recruitment/report-postgres.ts @@ -1,4 +1,5 @@ import { Predicate, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { Database } from "../service.js"; import { listAdmissionPeriodsForManagement } from "../admission-period/postgres.js"; import { Organization } from "@vektorprogrammet/domain/organization"; @@ -19,8 +20,7 @@ import { orderInterviewReport, } from "@vektorprogrammet/domain/recruitment"; -/** Same single-department recruitment policy, reconstructed from current Organization authority. */ -export const resolveInterviewReportLeader = (personId: PersonId, now: string) => +const resolveInterviewReportLeaderImpl = (personId: PersonId, now: string) => Effect.gen(function* () { const organization = yield* Organization; const authority = yield* organization.resolvePersonAuthority(personId, now); @@ -33,7 +33,7 @@ export const resolveInterviewReportLeader = (personId: PersonId, now: string) => ), ]; - if (departments.length !== 1) return yield* new RecruitmentRoleDenied({ personId }); + if (departments.length !== 1) return yield* RecruitmentRoleDenied.make({ personId }); const decision = mapOrganizationAuthorityToDepartmentActor( authority, @@ -46,13 +46,18 @@ export const resolveInterviewReportLeader = (personId: PersonId, now: string) => !Predicate.isTagged(decision.value, "DepartmentAdministrator") || !decision.value.active ) - return yield* new RecruitmentRoleDenied({ personId }); + return yield* RecruitmentRoleDenied.make({ personId }); return decision.value; }); -/** No writes: fixed candidate set, deterministic applicant custody, then fresh identity observation. */ -export const readCompletedInterviewReport = ( +/** Same single-department recruitment policy, reconstructed from current Organization authority. */ +export const resolveInterviewReportLeader: { + (now: string): (personId: PersonId) => ReturnType; + (personId: PersonId, now: string): ReturnType; +} = dual(2, resolveInterviewReportLeaderImpl); + +const readCompletedInterviewReportImpl = ( personId: PersonId, now: string, input: InterviewReportQuery, @@ -60,7 +65,9 @@ export const readCompletedInterviewReport = ( Effect.gen(function* () { const query = yield* Schema.decodeEffect(InterviewReportQuery)(input, { onExcessProperty: "error", - }).pipe(Effect.mapError(() => new RecruitmentDecodeError({ message: "invalid report query" }))); + }).pipe( + Effect.mapError(() => RecruitmentDecodeError.make({ message: "invalid report query" })), + ); const sql = yield* Database; @@ -74,7 +81,7 @@ export const readCompletedInterviewReport = ( query.admissionPeriodId !== undefined && !periods.some((period) => period.id === query.admissionPeriodId) ) - return yield* new RecruitmentScopeDenied({ + return yield* RecruitmentScopeDenied.make({ personId, departmentId: actor.departmentId, }); @@ -135,8 +142,8 @@ export const readCompletedInterviewReport = ( .map(({ linkedPersonId: _identity, ...row }) => row), { onExcessProperty: "error" }, ).pipe( - Effect.mapError( - () => new RecruitmentDecodeError({ message: "invalid persisted report row" }), + Effect.mapError(() => + RecruitmentDecodeError.make({ message: "invalid persisted report row" }), ), ); } @@ -156,7 +163,7 @@ export const readCompletedInterviewReport = ( .pipe( Effect.catchTag("SqlError", (cause) => Effect.fail( - new RecruitmentPersistenceError({ + RecruitmentPersistenceError.make({ operation: "read completed interview report", message: "report unavailable", cause, @@ -165,3 +172,16 @@ export const readCompletedInterviewReport = ( ), ); }); + +/** No writes: fixed candidate set, deterministic applicant custody, then fresh identity observation. */ +export const readCompletedInterviewReport: { + ( + now: string, + input: InterviewReportQuery, + ): (personId: PersonId) => ReturnType; + ( + personId: PersonId, + now: string, + input: InterviewReportQuery, + ): ReturnType; +} = dual(3, readCompletedInterviewReportImpl); diff --git a/packages/database/src/recruitment/response-outbox.ts b/packages/database/src/recruitment/response-outbox.ts index 0e81ed619..961ca61d4 100644 --- a/packages/database/src/recruitment/response-outbox.ts +++ b/packages/database/src/recruitment/response-outbox.ts @@ -21,6 +21,7 @@ import { sha256Hex, } from "@vektorprogrammet/domain/shared-kernel"; import { flow, Data, Predicate, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { RecruitmentPersistenceError } from "@vektorprogrammet/domain/recruitment"; import { RecruitmentInvitationResponseOutboxRequestSchema, @@ -134,7 +135,7 @@ export const RecruitmentInvitationResponseDeliveryResult = Data.taggedEnum(); const persistenceError = (operation: string, cause?: unknown): RecruitmentPersistenceError => - new RecruitmentPersistenceError({ + RecruitmentPersistenceError.make({ operation, cause, message: @@ -479,54 +480,101 @@ export const sealInterviewResponseEnvelopes = (interviewId: string) => ), ); -export const claimNextRecruitmentInvitationResponse = ( - claimId: string, - claimedAt: string, -): Effect.Effect< - ClaimedRecruitmentInvitationResponse | undefined, - RecruitmentPersistenceError | OutboxClaimLost, - Admissions | Database | Profile -> => - Effect.gen(function* () { - const admissions = yield* Admissions; - const sql = yield* Database; - const profile = yield* Profile; - - return yield* sql - .withTransaction(claimInTransaction(sql, admissions, profile, claimId, claimedAt)) - .pipe( +export const claimNextRecruitmentInvitationResponse: { + ( + claimedAt: string, + ): ( + claimId: string, + ) => Effect.Effect< + ClaimedRecruitmentInvitationResponse | undefined, + RecruitmentPersistenceError | OutboxClaimLost, + Admissions | Database | Profile + >; + ( + claimId: string, + claimedAt: string, + ): Effect.Effect< + ClaimedRecruitmentInvitationResponse | undefined, + RecruitmentPersistenceError | OutboxClaimLost, + Admissions | Database | Profile + >; +} = dual( + 2, + ( + claimId: string, + claimedAt: string, + ): Effect.Effect< + ClaimedRecruitmentInvitationResponse | undefined, + RecruitmentPersistenceError | OutboxClaimLost, + Admissions | Database | Profile + > => + Effect.gen(function* () { + const admissions = yield* Admissions; + const sql = yield* Database; + const profile = yield* Profile; + + return yield* sql + .withTransaction(claimInTransaction(sql, admissions, profile, claimId, claimedAt)) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("invitation response claim transaction", cause)), + ), + ); + }), +); + +export const completeRecruitmentInvitationResponse: { + ( + evidence: RecruitmentNotificationEvidence, + ): ( + claim: ClaimedRecruitmentInvitationResponse, + ) => Effect.Effect; + ( + claim: ClaimedRecruitmentInvitationResponse, + evidence: RecruitmentNotificationEvidence, + ): Effect.Effect; +} = dual( + 2, + ( + claim: ClaimedRecruitmentInvitationResponse, + evidence: RecruitmentNotificationEvidence, + ): Effect.Effect => + Effect.gen(function* () { + if (evidence.effectId !== claim.effectId) + return yield* persistenceError("invitation response delivery evidence effect mismatch"); + + yield* Database.use((sql) => + markOutboxDelivered(sql, responseOutbox, claim, { deliveredAt: evidence.deliveredAt }), + ).pipe( Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("invitation response claim transaction", cause)), + Effect.fail(persistenceError("complete invitation response claim", cause)), ), ); - }); - -export const completeRecruitmentInvitationResponse = ( - claim: ClaimedRecruitmentInvitationResponse, - evidence: RecruitmentNotificationEvidence, -): Effect.Effect => - Effect.gen(function* () { - if (evidence.effectId !== claim.effectId) - return yield* persistenceError("invitation response delivery evidence effect mismatch"); - - yield* Database.use((sql) => - markOutboxDelivered(sql, responseOutbox, claim, { deliveredAt: evidence.deliveredAt }), - ).pipe( + }), +); + +export const failRecruitmentInvitationResponse: { + ( + failureTag: string, + ): ( + claim: ClaimedRecruitmentInvitationResponse, + ) => Effect.Effect; + ( + claim: ClaimedRecruitmentInvitationResponse, + failureTag: string, + ): Effect.Effect; +} = dual( + 2, + ( + claim: ClaimedRecruitmentInvitationResponse, + failureTag: string, + ): Effect.Effect => + Database.use((sql) => markOutboxFailed(sql, responseOutbox, claim, failureTag)).pipe( Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("complete invitation response claim", cause)), + Effect.fail(persistenceError("fail invitation response claim", cause)), ), - ); - }); - -export const failRecruitmentInvitationResponse = ( - claim: ClaimedRecruitmentInvitationResponse, - failureTag: string, -): Effect.Effect => - Database.use((sql) => markOutboxFailed(sql, responseOutbox, claim, failureTag)).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("fail invitation response claim", cause)), ), - ); +); export const releaseRecruitmentInvitationResponse = ( claim: ClaimedRecruitmentInvitationResponse, @@ -553,56 +601,77 @@ export const recoverStaleRecruitmentInvitationResponses = ( ), ); -export const deliverNextRecruitmentInvitationResponse = ( - claimId: string, - claimedAt: string, -): Effect.Effect< - RecruitmentInvitationResponseDeliveryResult, - RecruitmentPersistenceError, - Admissions | Database | NotificationGateway | Profile -> => - Effect.acquireUseRelease( - claimNextRecruitmentInvitationResponse(claimId, claimedAt), - ( - claim, - ): Effect.Effect< - RecruitmentInvitationResponseDeliveryResult, - RecruitmentPersistenceError | OutboxClaimLost, - Admissions | Database | NotificationGateway | Profile - > => { - if (claim === undefined) - return Effect.succeed(RecruitmentInvitationResponseDeliveryResult.Idle()); - - return Effect.gen(function* () { - const gateway = yield* NotificationGateway; - - return yield* gateway.deliverInterviewInvitationResponse(claim.request).pipe( - Effect.matchEffect({ - onFailure: (failure) => - failRecruitmentInvitationResponse(claim, failure._tag).pipe( - Effect.as( - RecruitmentInvitationResponseDeliveryResult.Failed({ - claim, - failureTag: failure._tag, - }), +export const deliverNextRecruitmentInvitationResponse: { + ( + claimedAt: string, + ): ( + claimId: string, + ) => Effect.Effect< + RecruitmentInvitationResponseDeliveryResult, + RecruitmentPersistenceError, + Admissions | Database | NotificationGateway | Profile + >; + ( + claimId: string, + claimedAt: string, + ): Effect.Effect< + RecruitmentInvitationResponseDeliveryResult, + RecruitmentPersistenceError, + Admissions | Database | NotificationGateway | Profile + >; +} = dual( + 2, + ( + claimId: string, + claimedAt: string, + ): Effect.Effect< + RecruitmentInvitationResponseDeliveryResult, + RecruitmentPersistenceError, + Admissions | Database | NotificationGateway | Profile + > => + Effect.acquireUseRelease( + claimNextRecruitmentInvitationResponse(claimId, claimedAt), + ( + claim, + ): Effect.Effect< + RecruitmentInvitationResponseDeliveryResult, + RecruitmentPersistenceError | OutboxClaimLost, + Admissions | Database | NotificationGateway | Profile + > => { + if (claim === undefined) + return Effect.succeed(RecruitmentInvitationResponseDeliveryResult.Idle()); + + return Effect.gen(function* () { + const gateway = yield* NotificationGateway; + + return yield* gateway.deliverInterviewInvitationResponse(claim.request).pipe( + Effect.matchEffect({ + onFailure: (failure) => + failRecruitmentInvitationResponse(claim, failure._tag).pipe( + Effect.as( + RecruitmentInvitationResponseDeliveryResult.Failed({ + claim, + failureTag: failure._tag, + }), + ), ), - ), - onSuccess: (evidence) => - completeRecruitmentInvitationResponse(claim, evidence).pipe( - Effect.as( - RecruitmentInvitationResponseDeliveryResult.Delivered({ claim, evidence }), + onSuccess: (evidence) => + completeRecruitmentInvitationResponse(claim, evidence).pipe( + Effect.as( + RecruitmentInvitationResponseDeliveryResult.Delivered({ claim, evidence }), + ), ), - ), - }), - ); - }); - }, - (claim) => (claim === undefined ? Effect.void : releaseRecruitmentInvitationResponse(claim)), - ).pipe( - Effect.catchTag("OutboxClaimLost", ({ effectId }) => - Effect.succeed(RecruitmentInvitationResponseDeliveryResult.ClaimLost({ effectId })), + }), + ); + }); + }, + (claim) => (claim === undefined ? Effect.void : releaseRecruitmentInvitationResponse(claim)), + ).pipe( + Effect.catchTag("OutboxClaimLost", ({ effectId }) => + Effect.succeed(RecruitmentInvitationResponseDeliveryResult.ClaimLost({ effectId })), + ), ), - ); +); export const invitationResponsePayloadForEvidence = ( request: RecruitmentInvitationResponseOutboxRequest, diff --git a/packages/database/src/recruitment/scheduling-postgres.test.ts b/packages/database/src/recruitment/scheduling-postgres.test.ts index 58fa8ffc4..d86ad85de 100644 --- a/packages/database/src/recruitment/scheduling-postgres.test.ts +++ b/packages/database/src/recruitment/scheduling-postgres.test.ts @@ -27,29 +27,27 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })((it) => "shows a co-interviewer only their completed interviews", () => Effect.gen(function* () { - const observed = yield* Effect.gen(function* () { - const sql = yield* Database; - yield* sql`INSERT INTO organization_departments (department_id, name, short_name, email, city) VALUES ('department-1', 'Realfag', 'RF', 'rf@example.invalid', 'Trondheim')`; - yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES ('department-1', 'Realfag')`; - yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ('semester-1', '2031-08-01', '2032-01-01')`; - yield* sql`INSERT INTO admission_periods (admission_period_id, department_id, semester_id, start_at, end_at, last_command_id) VALUES ('period-1', 'department-1', 'semester-1', '2031-08-01', '2031-10-01', 'seed')`; - yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name, active) VALUES ('field-1', 'department-1', 'Matematikk', TRUE)`; - yield* sql`INSERT INTO person_profiles (person_id, first_name, last_name) VALUES ('primary-1', 'Ivar', 'Intervjuer'), ('co-1', 'Kari', 'Kollega'), ('other-1', 'Andre', 'Medlem')`; - yield* sql`INSERT INTO person_contact_profiles (person_id, email, phone) VALUES ('primary-1', 'primary@example.invalid', '90000000')`; - yield* sql`INSERT INTO organization_teams (team_id, department_id, name) VALUES ('team-1', 'department-1', 'Team')`; - yield* sql`INSERT INTO organization_memberships (membership_id, person_id, team_id, start_at) VALUES ('membership-1', 'co-1', 'team-1', '2031-08-01'), ('membership-2', 'primary-1', 'team-1', '2031-08-01')`; - yield* sql`INSERT INTO admission_applicants (applicant_id, normalized_email, email, first_name, last_name, phone, gender, field_of_study_id, year_of_study) VALUES ('applicant-1', 'applicant@example.invalid', 'applicant@example.invalid', 'Ada', 'Søker', '90000001', 0, 'field-1', 2)`; - yield* sql`INSERT INTO admission_applications (application_id, applicant_id, admission_period_id, department_id, field_of_study_id, year_of_study, submitted_at) VALUES ('application-1', 'applicant-1', 'period-1', 'department-1', 'field-1', 2, '2031-08-15')`; - yield* sql`INSERT INTO recruitment_interview_schemas (interview_schema_id, name, question_count, active, revision) VALUES ('schema-1', 'Interview', 0, TRUE, 0)`; - yield* sql`INSERT INTO recruitment_interviews (interview_id, application_id, department_id, interviewer_person_id, co_interviewer_person_id, interview_schema_id, assigned_by_person_id, assigned_at, revision) VALUES ('interview-1', 'application-1', 'department-1', 'primary-1', 'co-1', 'schema-1', 'primary-1', '2031-09-01', 3)`; - const before = yield* readSchedulingBoard(context); - yield* sql`INSERT INTO recruitment_interview_conducts (interview_id, answers, explanatory_power, role_model, suitability, recommendation, finalized_by_person_id, finalized_at, interview_revision) VALUES ('interview-1', '[]'::jsonb, 5, 6, 7, 'Ja', 'primary-1', '2031-09-14', 3)`; - const after = yield* readSchedulingBoard(context); - yield* sql`UPDATE recruitment_interviews SET co_interviewer_person_id = 'other-1' WHERE interview_id = 'interview-1'`; - const noLongerDesignated = yield* readSchedulingBoard(context); + const sql = yield* Database; + yield* sql`INSERT INTO organization_departments (department_id, name, short_name, email, city) VALUES ('department-1', 'Realfag', 'RF', 'rf@example.invalid', 'Trondheim')`; + yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES ('department-1', 'Realfag')`; + yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ('semester-1', '2031-08-01', '2032-01-01')`; + yield* sql`INSERT INTO admission_periods (admission_period_id, department_id, semester_id, start_at, end_at, last_command_id) VALUES ('period-1', 'department-1', 'semester-1', '2031-08-01', '2031-10-01', 'seed')`; + yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name, active) VALUES ('field-1', 'department-1', 'Matematikk', TRUE)`; + yield* sql`INSERT INTO person_profiles (person_id, first_name, last_name) VALUES ('primary-1', 'Ivar', 'Intervjuer'), ('co-1', 'Kari', 'Kollega'), ('other-1', 'Andre', 'Medlem')`; + yield* sql`INSERT INTO person_contact_profiles (person_id, email, phone) VALUES ('primary-1', 'primary@example.invalid', '90000000')`; + yield* sql`INSERT INTO organization_teams (team_id, department_id, name) VALUES ('team-1', 'department-1', 'Team')`; + yield* sql`INSERT INTO organization_memberships (membership_id, person_id, team_id, start_at) VALUES ('membership-1', 'co-1', 'team-1', '2031-08-01'), ('membership-2', 'primary-1', 'team-1', '2031-08-01')`; + yield* sql`INSERT INTO admission_applicants (applicant_id, normalized_email, email, first_name, last_name, phone, gender, field_of_study_id, year_of_study) VALUES ('applicant-1', 'applicant@example.invalid', 'applicant@example.invalid', 'Ada', 'Søker', '90000001', 0, 'field-1', 2)`; + yield* sql`INSERT INTO admission_applications (application_id, applicant_id, admission_period_id, department_id, field_of_study_id, year_of_study, submitted_at) VALUES ('application-1', 'applicant-1', 'period-1', 'department-1', 'field-1', 2, '2031-08-15')`; + yield* sql`INSERT INTO recruitment_interview_schemas (interview_schema_id, name, question_count, active, revision) VALUES ('schema-1', 'Interview', 0, TRUE, 0)`; + yield* sql`INSERT INTO recruitment_interviews (interview_id, application_id, department_id, interviewer_person_id, co_interviewer_person_id, interview_schema_id, assigned_by_person_id, assigned_at, revision) VALUES ('interview-1', 'application-1', 'department-1', 'primary-1', 'co-1', 'schema-1', 'primary-1', '2031-09-01', 3)`; + const before = yield* readSchedulingBoard(context); + yield* sql`INSERT INTO recruitment_interview_conducts (interview_id, answers, explanatory_power, role_model, suitability, recommendation, finalized_by_person_id, finalized_at, interview_revision) VALUES ('interview-1', '[]'::jsonb, 5, 6, 7, 'Ja', 'primary-1', '2031-09-14', 3)`; + const after = yield* readSchedulingBoard(context); + yield* sql`UPDATE recruitment_interviews SET co_interviewer_person_id = 'other-1' WHERE interview_id = 'interview-1'`; + const noLongerDesignated = yield* readSchedulingBoard(context); - return { before, after, noLongerDesignated }; - }); + const observed = { before, after, noLongerDesignated }; expect(observed.before.interviews).toEqual([]); expect(observed.after.interviews.map(({ interviewId }) => interviewId)).toEqual([ diff --git a/packages/database/src/recruitment/scheduling-postgres.ts b/packages/database/src/recruitment/scheduling-postgres.ts index 1f00974f4..df309b712 100644 --- a/packages/database/src/recruitment/scheduling-postgres.ts +++ b/packages/database/src/recruitment/scheduling-postgres.ts @@ -22,6 +22,7 @@ import { sha256Hex, } from "@vektorprogrammet/domain/shared-kernel"; import { flow, Predicate, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { RecruitmentScheduleObservationSchema, RecruitmentInvitationOutboxRequestSchema, @@ -137,7 +138,7 @@ const StoredScheduleReceiptRowSchema = Schema.Struct({ }); const persistenceError = (operation: string, cause?: unknown): RecruitmentPersistenceError => - new RecruitmentPersistenceError({ + RecruitmentPersistenceError.make({ operation, cause, message: cause instanceof Error ? cause.message : "recruitment persistence failed", @@ -152,7 +153,7 @@ const readApplicantContacts = ( .pipe( Effect.mapError((failure) => Predicate.isTagged(failure, "PublicApplicationNotFound") - ? new RecruitmentApplicationNotFound({ applicationId: failure.applicationId }) + ? RecruitmentApplicationNotFound.make({ applicationId: failure.applicationId }) : persistenceError("read Admissions applicant contacts", failure), ), ); @@ -160,11 +161,10 @@ const readApplicantContacts = ( const decode = (schema: Schema.ConstraintDecoder, operation: string) => flow( Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }), - Effect.mapError( - (cause) => - new RecruitmentDecodeError({ - message: cause instanceof Error ? cause.message : `invalid ${operation}`, - }), + Effect.mapError((cause) => + RecruitmentDecodeError.make({ + message: cause instanceof Error ? cause.message : `invalid ${operation}`, + }), ), ); @@ -205,19 +205,19 @@ const authorizeActor = ( ): Effect.Effect => Effect.gen(function* () { if (!isRecruitmentNow(now)) { - return yield* new RecruitmentInvalidContext({ message: "invalid scheduling instant" }); + return yield* RecruitmentInvalidContext.make({ message: "invalid scheduling instant" }); } - if (!actor.active) return yield* new RecruitmentInactiveActor({ personId: actor.personId }); + if (!actor.active) return yield* RecruitmentInactiveActor.make({ personId: actor.personId }); if (Predicate.isTagged(actor, "GlobalAdmin")) { - return yield* new RecruitmentRoleDenied({ personId: actor.personId }); + return yield* RecruitmentRoleDenied.make({ personId: actor.personId }); } if (Predicate.isTagged(actor, "Member")) { const active = yield* memberHasActiveDepartmentMembership(actor, now, organization); - if (!active) return yield* new RecruitmentInactiveActor({ personId: actor.personId }); + if (!active) return yield* RecruitmentInactiveActor.make({ personId: actor.personId }); } return actor; @@ -602,7 +602,7 @@ const writeScheduleRows = ( ); if (updated[0]?.revision !== scheduleRevision) { - return yield* new RecruitmentInterviewStaleRevision({ + return yield* RecruitmentInterviewStaleRevision.make({ interviewId: command.interviewId, expectedRevision: command.expectedRevision, actualRevision: interview.revision, @@ -662,11 +662,10 @@ const writeScheduleRows = ( responseState: "Pending", notificationState: "Pending", }).pipe( - Effect.mapError( - (cause) => - new RecruitmentDecodeError({ - message: cause instanceof Error ? cause.message : `invalid ${"schedule observation"}`, - }), + Effect.mapError((cause) => + RecruitmentDecodeError.make({ + message: cause instanceof Error ? cause.message : `invalid ${"schedule observation"}`, + }), ), ); @@ -719,14 +718,13 @@ const writeScheduleRows = ( message: command.message, responseCapability: context.responseCapability, }).pipe( - Effect.mapError( - (cause) => - new RecruitmentDecodeError({ - message: - cause instanceof Error - ? cause.message - : `invalid ${"invitation notification request"}`, - }), + Effect.mapError((cause) => + RecruitmentDecodeError.make({ + message: + cause instanceof Error + ? cause.message + : `invalid ${"invitation notification request"}`, + }), ), ); @@ -783,20 +781,20 @@ const scheduleInTransaction = ( const interview = yield* readSchedulingInterview(sql, command.interviewId); if (interview === undefined) { - return yield* new RecruitmentInterviewNotFound({ interviewId: command.interviewId }); + return yield* RecruitmentInterviewNotFound.make({ interviewId: command.interviewId }); } const actor = yield* authorizeActor(context.actor, context.now, organization); if (interview.departmentId !== actor.departmentId) { - return yield* new RecruitmentScopeDenied({ + return yield* RecruitmentScopeDenied.make({ personId: actor.personId, departmentId: DepartmentId.make(interview.departmentId), }); } if (Predicate.isTagged(actor, "Member") && interview.interviewerPersonId !== actor.personId) { - return yield* new RecruitmentScopeDenied({ + return yield* RecruitmentScopeDenied.make({ personId: actor.personId, departmentId: actor.departmentId, }); @@ -806,7 +804,7 @@ const scheduleInTransaction = ( if (storedReceipt !== undefined) { if (storedReceipt.commandSha256 !== digest) { - return yield* new RecruitmentScheduleCommandConflict({ commandId: command.commandId }); + return yield* RecruitmentScheduleCommandConflict.make({ commandId: command.commandId }); } if (storedReceipt.interviewId !== command.interviewId) { @@ -832,7 +830,7 @@ const scheduleInTransaction = ( } if (interview.revision !== command.expectedRevision) { - return yield* new RecruitmentInterviewStaleRevision({ + return yield* RecruitmentInterviewStaleRevision.make({ interviewId: command.interviewId, expectedRevision: command.expectedRevision, actualRevision: interview.revision, @@ -904,57 +902,78 @@ export const readSchedulingBoard = ( ); }); -export const scheduleInterview = ( - command: RecruitmentScheduleCommand, - context: RecruitmentScheduleContext, -): Effect.Effect< - RecruitmentScheduleResult, - RecruitmentFailure, - Database | Admissions | Organization | Profile -> => - Effect.gen(function* () { - const decodedCommand = yield* decode( - RecruitmentScheduleCommandSchema, - "schedule command", - )(command); - - const actor = yield* decode(RecruitmentActorSchema, "recruitment actor")(context.actor); - - const invitationId = yield* decode( - RecruitmentInvitationId, - "invitation identity", - )(context.invitationId); - - if (!capabilityIsValid(context.responseCapability)) { - return yield* new RecruitmentInvalidContext({ message: "invalid response capability" }); - } +export const scheduleInterview: { + ( + context: RecruitmentScheduleContext, + ): ( + command: RecruitmentScheduleCommand, + ) => Effect.Effect< + RecruitmentScheduleResult, + RecruitmentFailure, + Database | Admissions | Organization | Profile + >; + ( + command: RecruitmentScheduleCommand, + context: RecruitmentScheduleContext, + ): Effect.Effect< + RecruitmentScheduleResult, + RecruitmentFailure, + Database | Admissions | Organization | Profile + >; +} = dual( + 2, + ( + command: RecruitmentScheduleCommand, + context: RecruitmentScheduleContext, + ): Effect.Effect< + RecruitmentScheduleResult, + RecruitmentFailure, + Database | Admissions | Organization | Profile + > => + Effect.gen(function* () { + const decodedCommand = yield* decode( + RecruitmentScheduleCommandSchema, + "schedule command", + )(command); + + const actor = yield* decode(RecruitmentActorSchema, "recruitment actor")(context.actor); + + const invitationId = yield* decode( + RecruitmentInvitationId, + "invitation identity", + )(context.invitationId); + + if (!capabilityIsValid(context.responseCapability)) { + return yield* RecruitmentInvalidContext.make({ message: "invalid response capability" }); + } - const sql = yield* Database; - const admissions = yield* Admissions; - const organization = yield* Organization; - const profile = yield* Profile; - const digest = sha256Hex(canonicalJsonBytes(decodedCommand)); - - return yield* sql - .withTransaction( - scheduleInTransaction( - decodedCommand, - { - actor, - now: context.now, - invitationId, - responseCapability: context.responseCapability, - }, - sql, - admissions, - organization, - profile, - digest, - ), - ) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("schedule transaction", cause)), - ), - ); - }); + const sql = yield* Database; + const admissions = yield* Admissions; + const organization = yield* Organization; + const profile = yield* Profile; + const digest = sha256Hex(canonicalJsonBytes(decodedCommand)); + + return yield* sql + .withTransaction( + scheduleInTransaction( + decodedCommand, + { + actor, + now: context.now, + invitationId, + responseCapability: context.responseCapability, + }, + sql, + admissions, + organization, + profile, + digest, + ), + ) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("schedule transaction", cause)), + ), + ); + }), +); diff --git a/packages/database/src/rule-reconciliation-migration-postgres-proof.ts b/packages/database/src/rule-reconciliation-migration-postgres-proof.ts index dc863254a..a2880a543 100644 --- a/packages/database/src/rule-reconciliation-migration-postgres-proof.ts +++ b/packages/database/src/rule-reconciliation-migration-postgres-proof.ts @@ -189,10 +189,9 @@ export const proveRuleReconciliationMigration = (databaseUrl: Redacted.Redacted< if (!Predicate.isTagged(failed, "Failure")) throw new Error("migration 26 unexpectedly succeeded"); - const failureOption = Cause.findErrorOption(failed.cause); - assert(Option.isSome(failureOption)); - assert(isSqlError(failureOption.value)); - const databaseCause = failureOption.value.reason.cause; + const sqlFailure = Cause.findErrorOption(failed.cause).pipe(Option.filter(isSqlError)); + assert(Option.isSome(sqlFailure)); + const databaseCause = sqlFailure.value.reason.cause; const failure = databaseCause instanceof Error ? databaseCause.message : String(databaseCause); assert.deepEqual(parseReport(failure), expectedFailures); assert.equal(failure.includes("preflight-secret-ciphertext"), false); diff --git a/packages/database/src/rule-reconciliation-postgres-tracer-main.ts b/packages/database/src/rule-reconciliation-postgres-tracer-main.ts index a62a649a6..1e8e91e0a 100644 --- a/packages/database/src/rule-reconciliation-postgres-tracer-main.ts +++ b/packages/database/src/rule-reconciliation-postgres-tracer-main.ts @@ -199,14 +199,14 @@ const traceReceipt = (sql: DatabaseOperations, receiptId: string) => ); const command = yield* Effect.result( - executeReceiptCommand( - ReceiptCommandRequestSchema.cases.RejectReceipt.make({ + executeReceiptCommand({ + command: ReceiptCommandRequestSchema.cases.RejectReceipt.make({ commandId: `rule-reconciliation-command-${receiptId}`, receiptId: ReceiptId.make(receiptId), expectedRevision: before.revision, }), - { personId: principalId, authorizationInstant }, - ), + principal: { personId: principalId, authorizationInstant }, + }), ); const after = yield* readTraceRow(sql, receiptId); diff --git a/packages/database/src/schools.test.ts b/packages/database/src/schools.test.ts index b090492a2..6a4b6a5b8 100644 --- a/packages/database/src/schools.test.ts +++ b/packages/database/src/schools.test.ts @@ -24,9 +24,8 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( "replays the ordered application manifest through the final revision without changing rows", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* database` + const database = yield* Database; + yield* database` INSERT INTO public.schools_directory_schools ( name, contact_person, email, phone, language, active ) VALUES ( @@ -34,12 +33,12 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( 'Norwegian', TRUE ) `; - yield* database.migrate; + yield* database.migrate; - const migrationRows = yield* database<{ - readonly migrationId: number; - readonly name: string; - }>` + const migrationRows = yield* database<{ + readonly migrationId: number; + readonly name: string; + }>` SELECT migration.migration_id AS "migrationId", migration.name AS "name" @@ -47,23 +46,22 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( WHERE migration.migration_id = 19 `; - const schoolRows = yield* database<{ readonly count: string }>` + const schoolRows = yield* database<{ readonly count: string }>` SELECT count(*)::text AS "count" FROM public.schools_directory_schools AS school WHERE school.name = 'Replay School' `; - yield* database` + yield* database` DELETE FROM public.schools_directory_schools AS school WHERE school.name = 'Replay School' `; - return { - revision: database.schemaRevision, - migrationRows, - schoolCount: schoolRows[0]?.count, - }; - }); + const evidence = { + revision: database.schemaRevision, + migrationRows, + schoolCount: schoolRows[0]?.count, + }; expect(evidence).toEqual({ revision: databaseSchemaRevision, @@ -78,9 +76,8 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( "enforces both association foreign keys and restricts deleting associated departments and schools", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* database` + const database = yield* Database; + yield* database` INSERT INTO organization_departments ( department_id, name, short_name, email, city ) VALUES ( @@ -89,7 +86,7 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( ) `; - const schools = yield* database<{ readonly schoolId: string }>` + const schools = yield* database<{ readonly schoolId: string }>` INSERT INTO public.schools_directory_schools ( name, contact_person, email, phone, language, active ) VALUES ( @@ -99,68 +96,67 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( RETURNING school_id::text AS "schoolId" `; - const schoolId = schools[0]!.schoolId; - yield* database` + const schoolId = schools[0]!.schoolId; + yield* database` INSERT INTO public.schools_directory_departments (school_id, department_id) VALUES (${schoolId}::bigint, 'schools-fk-department') `; - const missingSchoolFailure = yield* Effect.flip( - database` + const missingSchoolFailure = yield* Effect.flip( + database` INSERT INTO public.schools_directory_departments (school_id, department_id) VALUES (9007199254740991, 'schools-fk-department') `, - ); + ); - const missingDepartmentFailure = yield* Effect.flip( - database` + const missingDepartmentFailure = yield* Effect.flip( + database` INSERT INTO public.schools_directory_departments (school_id, department_id) VALUES (${schoolId}::bigint, 'schools-missing-department') `, - ); + ); - const restrictFailure = yield* Effect.flip( - database` + const restrictFailure = yield* Effect.flip( + database` DELETE FROM organization_departments AS department WHERE department.department_id = 'schools-fk-department' `, - ); + ); - const restrictSchoolFailure = yield* Effect.flip( - database` + const restrictSchoolFailure = yield* Effect.flip( + database` DELETE FROM public.schools_directory_schools AS school WHERE school.school_id = ${schoolId}::bigint `, - ); + ); - const associations = yield* database<{ readonly count: string }>` + const associations = yield* database<{ readonly count: string }>` SELECT count(*)::text AS "count" FROM public.schools_directory_departments AS association WHERE association.school_id = ${schoolId}::bigint `; - yield* database` + yield* database` DELETE FROM public.schools_directory_departments AS association WHERE association.school_id = ${schoolId}::bigint `; - yield* database` + yield* database` DELETE FROM public.schools_directory_schools AS school WHERE school.school_id = ${schoolId}::bigint `; - yield* database` + yield* database` DELETE FROM organization_departments AS department WHERE department.department_id = 'schools-fk-department' `; - return { - missingSchoolTag: missingSchoolFailure._tag, - missingDepartmentTag: missingDepartmentFailure._tag, - restrictTag: restrictFailure._tag, - restrictSchoolTag: restrictSchoolFailure._tag, - associationCount: associations[0]?.count, - }; - }); + const evidence = { + missingSchoolTag: missingSchoolFailure._tag, + missingDepartmentTag: missingDepartmentFailure._tag, + restrictTag: restrictFailure._tag, + restrictSchoolTag: restrictSchoolFailure._tag, + associationCount: associations[0]?.count, + }; expect(evidence).toEqual({ missingSchoolTag: "SqlError", @@ -176,11 +172,10 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( "runs the named journey against the canonical non-locking Organization projection", () => Effect.gen(function* () { - const directory = yield* Effect.gen(function* () { - const database = yield* Database; - const departmentId = DepartmentId.make("schools-journey-pglite"); - const personId = PersonId.make("schools-journey-pglite-person"); - yield* database` + const database = yield* Database; + const departmentId = DepartmentId.make("schools-journey-pglite"); + const personId = PersonId.make("schools-journey-pglite-person"); + yield* database` INSERT INTO organization_departments ( department_id, name, short_name, email, city ) VALUES ( @@ -188,15 +183,15 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( 'schools-journey-pglite@example.invalid', 'Oslo' ) `; - yield* database` + yield* database` INSERT INTO organization_teams (team_id, department_id, name) VALUES ('schools-journey-pglite-team', ${departmentId}, 'Journey Team') `; - yield* database` + yield* database` INSERT INTO person_profiles (person_id, first_name, last_name) VALUES (${personId}, 'Schools', 'Journey') `; - yield* database` + yield* database` INSERT INTO organization_memberships ( membership_id, person_id, team_id, start_at, position_id ) VALUES ( @@ -208,7 +203,7 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( ) `; - const inserted = yield* database<{ readonly schoolId: string }>` + const inserted = yield* database<{ readonly schoolId: string }>` INSERT INTO public.schools_directory_schools ( name, contact_person, email, phone, language, active ) VALUES ( @@ -218,41 +213,38 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( RETURNING school_id::text AS "schoolId" `; - yield* database` + yield* database` INSERT INTO public.schools_directory_departments (school_id, department_id) VALUES (${inserted[0]!.schoolId}::bigint, ${departmentId}) `; - const directory = yield* readSchoolsDirectory( - personId, - OrganizationAuthorityInstantSchema.make("2032-01-01T00:00:00.000Z"), - {}, - ); + const directory = yield* readSchoolsDirectory( + personId, + OrganizationAuthorityInstantSchema.make("2032-01-01T00:00:00.000Z"), + {}, + ); - yield* database` + yield* database` DELETE FROM public.schools_directory_departments AS association WHERE association.school_id = ${inserted[0]!.schoolId}::bigint `; - yield* database` + yield* database` DELETE FROM public.schools_directory_schools AS school WHERE school.school_id = ${inserted[0]!.schoolId}::bigint `; - yield* database` + yield* database` DELETE FROM organization_memberships AS membership WHERE membership.membership_id = 'schools-journey-pglite-membership' `; - yield* database` + yield* database` DELETE FROM organization_teams AS team WHERE team.team_id = 'schools-journey-pglite-team' `; - yield* database` + yield* database` DELETE FROM organization_departments AS department WHERE department.department_id = ${departmentId} `; - return directory; - }); - expect(directory).toEqual({ activeSchools: [ { @@ -278,12 +270,11 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( it.effect("returns one deterministic full directory and intersects visible departments", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - const schools = yield* Schools; - const departmentA = DepartmentId.make("schools-full-a"); - const departmentB = DepartmentId.make("schools-full-b"); - yield* database` + const database = yield* Database; + const schools = yield* Schools; + const departmentA = DepartmentId.make("schools-full-a"); + const departmentB = DepartmentId.make("schools-full-b"); + yield* database` INSERT INTO organization_departments ( department_id, name, short_name, email, city ) VALUES @@ -297,11 +288,11 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( ) `; - const inserted = yield* database<{ - readonly schoolId: string; - readonly email: string; - readonly name: string; - }>` + const inserted = yield* database<{ + readonly schoolId: string; + readonly email: string; + readonly name: string; + }>` INSERT INTO public.schools_directory_schools ( name, contact_person, email, phone, language, active ) VALUES @@ -331,11 +322,11 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( name AS "name" `; - const idByEmail = new Map( - inserted.map((row) => [row.email, Number(row.schoolId)] as const), - ); + const idByEmail = new Map( + inserted.map((row) => [row.email, Number(row.schoolId)] as const), + ); - yield* database` + yield* database` INSERT INTO public.schools_directory_departments (school_id, department_id) VALUES (${idByEmail.get("alpha-a@example.invalid")}::bigint, ${departmentA}), @@ -345,60 +336,59 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( (${idByEmail.get("zulu@example.invalid")}::bigint, ${departmentA}) `; - const directory = yield* schools.listDirectory({ - scope: SchoolDirectoryScopeSchema.cases.All.make({}), - }); + const directory = yield* schools.listDirectory({ + scope: SchoolDirectoryScopeSchema.cases.All.make({}), + }); - const scoped = yield* schools.listDirectory({ - scope: SchoolDirectoryScopeSchema.cases.DepartmentIds.make({ - departmentIds: [departmentA, departmentB], - }), - }); + const scoped = yield* schools.listDirectory({ + scope: SchoolDirectoryScopeSchema.cases.DepartmentIds.make({ + departmentIds: [departmentA, departmentB], + }), + }); - const shared = scoped.activeSchools.find( - (school) => school.email === "shared@example.invalid", - ); + const shared = scoped.activeSchools.find( + (school) => school.email === "shared@example.invalid", + ); - const narrowed = yield* schools.listDirectory({ - scope: SchoolDirectoryScopeSchema.cases.DepartmentIds.make({ - departmentIds: [departmentA, departmentB], - }), - departmentId: departmentA, - }); + const narrowed = yield* schools.listDirectory({ + scope: SchoolDirectoryScopeSchema.cases.DepartmentIds.make({ + departmentIds: [departmentA, departmentB], + }), + departmentId: departmentA, + }); - const narrowedShared = narrowed.activeSchools.find( - (school) => school.email === "shared@example.invalid", - ); + const narrowedShared = narrowed.activeSchools.find( + (school) => school.email === "shared@example.invalid", + ); - const exceededScopeTag = yield* Effect.flip( - schools.listDirectory({ - scope: SchoolDirectoryScopeSchema.cases.DepartmentIds.make({ - departmentIds: [departmentA], - }), - departmentId: departmentB, + const exceededScopeTag = yield* Effect.flip( + schools.listDirectory({ + scope: SchoolDirectoryScopeSchema.cases.DepartmentIds.make({ + departmentIds: [departmentA], }), - ).pipe(Effect.map((failure) => failure._tag)); - - const fullSchoolIds = [ - ...directory.activeSchools.map((school) => school.schoolId), - ...directory.inactiveSchools.map((school) => school.schoolId), - ]; - - return { - fullActiveEmails: directory.activeSchools.map((school) => school.email), - fullInactiveEmails: directory.inactiveSchools.map((school) => school.email), - fullSchoolCount: fullSchoolIds.length, - uniqueFullSchoolIds: new Set(fullSchoolIds).size, - sharedDepartments: shared?.departments, - narrowedSharedDepartments: narrowedShared?.departments, - scopedActiveEmails: scoped.activeSchools.map((school) => school.email), - scopedInactiveEmails: scoped.inactiveSchools.map((school) => school.email), - adminOnlyDepartments: directory.activeSchools.find( - (school) => school.email === "admin-only@example.invalid", - )?.departments, - exceededScopeTag, - }; - }); + departmentId: departmentB, + }), + ).pipe(Effect.map((failure) => failure._tag)); + + const fullSchoolIds = [ + ...directory.activeSchools.map((school) => school.schoolId), + ...directory.inactiveSchools.map((school) => school.schoolId), + ]; + + const evidence = { + fullActiveEmails: directory.activeSchools.map((school) => school.email), + fullInactiveEmails: directory.inactiveSchools.map((school) => school.email), + fullSchoolCount: fullSchoolIds.length, + uniqueFullSchoolIds: new Set(fullSchoolIds).size, + sharedDepartments: shared?.departments, + narrowedSharedDepartments: narrowedShared?.departments, + scopedActiveEmails: scoped.activeSchools.map((school) => school.email), + scopedInactiveEmails: scoped.inactiveSchools.map((school) => school.email), + adminOnlyDepartments: directory.activeSchools.find( + (school) => school.email === "admin-only@example.invalid", + )?.departments, + exceededScopeTag, + }; expect(evidence).toEqual({ fullActiveEmails: [ diff --git a/packages/database/src/schools/AGENTS.md b/packages/database/src/schools/AGENTS.md index 1eb3ae845..411a22387 100644 --- a/packages/database/src/schools/AGENTS.md +++ b/packages/database/src/schools/AGENTS.md @@ -7,7 +7,6 @@ The persistence layer holds PostgreSQL adapters and service Layers. They keep st The Schools context in other folders: -- [apps/backend/src/schools](../../../../apps/backend/src/schools/AGENTS.md) - [apps/dashboard/app/foldkit/schools](../../../../apps/dashboard/app/foldkit/schools/AGENTS.md) - [packages/domain/src/schools](../../../domain/src/schools/AGENTS.md) diff --git a/packages/database/src/schools/administration.ts b/packages/database/src/schools/administration.ts index ae375e2ae..0baa1ca5a 100644 --- a/packages/database/src/schools/administration.ts +++ b/packages/database/src/schools/administration.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { DateTime, Effect, Predicate, Schema } from "effect"; import { SqlSchema } from "effect/unstable/sql"; import { canonicalJsonBytes, sha256Hex } from "@vektorprogrammet/domain/shared-kernel"; @@ -8,6 +9,7 @@ import { SchoolId, SchoolCapacityPlan, SchoolCommand, + type SchoolCommandAuthorization, SchoolCommandResult, ManagedSchool, SchoolDirectoryDepartmentSchema, @@ -24,10 +26,10 @@ import { resolveOrganizationPersonAuthorityWithSql, } from "../organization/authority-postgres.js"; -const fail = (code: SchoolCommandFailure["code"]) => new SchoolCommandFailure({ code }); +const fail = (code: SchoolCommandFailure["code"]) => SchoolCommandFailure.make({ code }); const persistence = (cause: unknown) => - new SchoolsPersistenceError({ + SchoolsPersistenceError.make({ operation: "school administration", message: String(cause), cause, @@ -115,7 +117,7 @@ const authorizeWithSql = Effect.fn("Schools.authorizeCommand")(function* ( yield* sql`SELECT department_id FROM organization_departments WHERE department_id=${command.departmentId} FOR SHARE`; const school = (yield* schoolRows(sql, command.schoolId, true))[0]; - if (!school) return yield* fail("NotFound"); + if (school === undefined) return yield* fail("NotFound"); const departments = yield* departmentsFor(sql, school.schoolId); if ( @@ -150,13 +152,21 @@ const mapFailure = (effect: Effect.Effect) => }), ); -export const authorizeSchoolCommand = (command: SchoolCommand, personId: PersonId) => +const authorizeSchoolCommandImpl = (command: SchoolCommand, personId: PersonId) => Effect.gen(function* () { const sql = yield* Database; const decoded = yield* decodeCommand(command).pipe(Effect.mapError(() => fail("Invalid"))); - yield* authorizeWithSql(sql, decoded, personId); + const { school, departments } = yield* authorizeWithSql(sql, decoded, personId); + + // SAFETY: the one constructor of the evidence brand; authorizeWithSql above is what it proves. + return { personId, command: decoded, school, departments } as SchoolCommandAuthorization; }).pipe(mapFailure); +export const authorizeSchoolCommand: { + (personId: PersonId): (command: SchoolCommand) => ReturnType; + (command: SchoolCommand, personId: PersonId): ReturnType; +} = dual(2, authorizeSchoolCommandImpl); + export const readSchoolManagement = (personId: PersonId) => Effect.gen(function* () { const sql = yield* Database; @@ -220,14 +230,13 @@ export const readSchoolManagement = (personId: PersonId) => ); }).pipe(mapFailure); -export const executeSchoolCommand = (input: SchoolCommand, personId: PersonId) => +export const executeSchoolCommand = (authorized: SchoolCommandAuthorization) => Effect.gen(function* () { const sql = yield* Database; - const command = yield* decodeCommand(input).pipe(Effect.mapError(() => fail("Invalid"))); + const { command, personId } = authorized; return yield* sql.withTransaction( Effect.gen(function* () { - const authorized = yield* authorizeWithSql(sql, command, personId); yield* lockAdvisory(sql, AdvisoryLockKey.schoolsCommand(personId, command.commandId)); const digest = sha256Hex( @@ -239,7 +248,7 @@ export const executeSchoolCommand = (input: SchoolCommand, personId: PersonId) = result: unknown; }>`SELECT command_digest AS digest,result_json AS result FROM schools_command_receipts WHERE actor_person_id=${personId} AND command_id=${command.commandId}`; - if (receipts[0]) { + if (receipts[0] !== undefined) { if (receipts[0].digest !== digest) return yield* fail("Conflict"); return yield* Schema.decodeUnknownEffect(SchoolCommandResult)(receipts[0].result); @@ -354,7 +363,7 @@ export const executeSchoolCommand = (input: SchoolCommand, personId: PersonId) = before = existing ?? null; if (Predicate.isTagged(command, "CreateCapacity")) { - if (existing) return yield* fail("CapacityExists"); + if (existing !== undefined) return yield* fail("CapacityExists"); const rows = yield* sql<{ capacityId: number; @@ -366,7 +375,7 @@ export const executeSchoolCommand = (input: SchoolCommand, personId: PersonId) = ); revision = 0; } else { - if (!existing) return yield* fail("NotFound"); + if (existing === undefined) return yield* fail("NotFound"); if (existing.revision !== command.expectedRevision) return yield* fail("Stale"); capacityId = existing.capacityId; diff --git a/packages/database/src/schools/directory.test.ts b/packages/database/src/schools/directory.test.ts index 7cae807fa..cb9d79bc5 100644 --- a/packages/database/src/schools/directory.test.ts +++ b/packages/database/src/schools/directory.test.ts @@ -134,24 +134,22 @@ layer(suiteSeed.pipe(Layer.provideMerge(suiteLayer)), { it.effect("rejects an Organization projection evaluated at another instant", () => Effect.gen(function* () { - const failure = yield* Effect.gen(function* () { - const organization = yield* Organization; - - const wrongInstant = Organization.of({ - ...organization, - resolvePersonAuthorityForRead: (resolvedPersonId, instant) => - organization - .resolvePersonAuthorityForRead(resolvedPersonId, instant) - .pipe(Effect.map((projection) => ({ ...projection, evaluatedAt: otherInstant }))), - }); - - return yield* Effect.flip( - readSchoolsDirectory(personId, authorizationInstant, {}).pipe( - Effect.provideService(Organization, wrongInstant), - ), - ); + const organization = yield* Organization; + + const wrongInstant = Organization.of({ + ...organization, + resolvePersonAuthorityForRead: (resolvedPersonId, instant) => + organization + .resolvePersonAuthorityForRead(resolvedPersonId, instant) + .pipe(Effect.map((projection) => ({ ...projection, evaluatedAt: otherInstant }))), }); + const failure = yield* Effect.flip( + readSchoolsDirectory(personId, authorizationInstant, {}).pipe( + Effect.provideService(Organization, wrongInstant), + ), + ); + expect(failure._tag).toBe("SchoolsDecodeError"); }), ); diff --git a/packages/database/src/schools/directory.ts b/packages/database/src/schools/directory.ts index f5c81479d..dae1ecb7d 100644 --- a/packages/database/src/schools/directory.ts +++ b/packages/database/src/schools/directory.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { Match, Predicate, Effect, Schema } from "effect"; import { Database } from "../service.js"; import type { OrganizationAuthorityInstant } from "@vektorprogrammet/domain/organization"; @@ -21,99 +22,122 @@ import { import { Schools } from "@vektorprogrammet/domain/schools"; const decodeError = (operation: string, cause: unknown): SchoolsDecodeError => - new SchoolsDecodeError({ operation, message: String(cause) }); + SchoolsDecodeError.make({ operation, message: String(cause) }); const persistenceError = (operation: string, cause: unknown): SchoolsPersistenceError => - new SchoolsPersistenceError({ operation, message: String(cause) }); + SchoolsPersistenceError.make({ operation, message: String(cause) }); /** * Authenticates no session and captures no clock: both values are explicit * inputs from the adapter. The journey resolves Organization once and reads * Schools in one repeatable-read, write-free transaction. */ -export const readSchoolsDirectory = ( - personId: PersonId, - authorizationInstant: OrganizationAuthorityInstant, - query: SchoolDirectoryQuery, -): Effect.Effect => - Effect.gen(function* () { - const decodedQuery = yield* Schema.decodeEffect(SchoolDirectoryQuerySchema)(query, { - onExcessProperty: "error", - }).pipe(Effect.mapError((cause) => decodeError("decode Schools directory query", cause))); +export const readSchoolsDirectory: { + ( + authorizationInstant: OrganizationAuthorityInstant, + query: SchoolDirectoryQuery, + ): ( + personId: PersonId, + ) => Effect.Effect< + SchoolDirectory, + ReadSchoolsDirectoryFailure, + Database | Organization | Schools + >; + ( + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + query: SchoolDirectoryQuery, + ): Effect.Effect; +} = dual( + 3, + ( + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + query: SchoolDirectoryQuery, + ): Effect.Effect< + SchoolDirectory, + ReadSchoolsDirectoryFailure, + Database | Organization | Schools + > => + Effect.gen(function* () { + const decodedQuery = yield* Schema.decodeEffect(SchoolDirectoryQuerySchema)(query, { + onExcessProperty: "error", + }).pipe(Effect.mapError((cause) => decodeError("decode Schools directory query", cause))); - const database = yield* Database; - const organization = yield* Organization; - const schools = yield* Schools; + const database = yield* Database; + const organization = yield* Organization; + const schools = yield* Schools; - return yield* database - .withTransaction( - Effect.gen(function* () { - // Organization supplies its canonical non-locking projection inside this snapshot. - yield* database` + return yield* database + .withTransaction( + Effect.gen(function* () { + // Organization supplies its canonical non-locking projection inside this snapshot. + yield* database` SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY `.pipe(Effect.asVoid); - const authority = yield* organization - .resolvePersonAuthorityForRead(personId, authorizationInstant) - .pipe( - Effect.catchTags({ - OrganizationDecodeError: (cause) => - Effect.fail(decodeError("resolve Schools directory authority", cause)), - OrganizationPersistenceError: (cause) => - Effect.fail(persistenceError("resolve Schools directory authority", cause)), - }), - ); + const authority = yield* organization + .resolvePersonAuthorityForRead(personId, authorizationInstant) + .pipe( + Effect.catchTags({ + OrganizationDecodeError: (cause) => + Effect.fail(decodeError("resolve Schools directory authority", cause)), + OrganizationPersistenceError: (cause) => + Effect.fail(persistenceError("resolve Schools directory authority", cause)), + }), + ); - if (authority.evaluatedAt !== authorizationInstant) { - return yield* decodeError( - "resolve Schools directory authority", - "Organization authority used a different authorization instant", - ); - } + if (authority.evaluatedAt !== authorizationInstant) { + return yield* decodeError( + "resolve Schools directory authority", + "Organization authority used a different authorization instant", + ); + } - const decision = resolveSchoolsDirectoryScope(authority); + const decision = resolveSchoolsDirectoryScope(authority); - if (Predicate.isTagged(decision, "Deny")) { - return yield* decision.reason === "AuthorityInactive" - ? new SchoolsAuthorityInactive({}) - : new SchoolsNotInScope({}); - } + if (Predicate.isTagged(decision, "Deny")) { + return yield* decision.reason === "AuthorityInactive" + ? SchoolsAuthorityInactive.make({}) + : SchoolsNotInScope.make({}); + } - const scope = decision.value; - const departmentId = decodedQuery.departmentId; + const scope = decision.value; + const departmentId = decodedQuery.departmentId; - if (departmentId !== undefined) { - yield* organization.readDepartment(departmentId).pipe( - Effect.asVoid, - Effect.mapError((cause) => { - return Match.value(cause).pipe( - Match.tag("DepartmentNotFound", () => { - return new SchoolsDepartmentNotFound({ departmentId }); - }), - Match.tag("OrganizationPersistenceError", (cause) => { - return persistenceError("read Schools directory department", cause); - }), - Match.orElse((cause) => { - return decodeError("read Schools directory department", cause); - }), - ); - }), - ); + if (departmentId !== undefined) { + yield* organization.readDepartment(departmentId).pipe( + Effect.asVoid, + Effect.mapError((cause) => + Match.value(cause).pipe( + Match.tag("DepartmentNotFound", () => + SchoolsDepartmentNotFound.make({ departmentId }), + ), + Match.tag("OrganizationPersistenceError", (cause) => + persistenceError("read Schools directory department", cause), + ), + Match.orElse((cause) => + decodeError("read Schools directory department", cause), + ), + ), + ), + ); - if ( - Predicate.isTagged(scope, "DepartmentIds") && - !scope.departmentIds.includes(departmentId) - ) { - return yield* new SchoolsDepartmentOutOfScope({ departmentId }); + if ( + Predicate.isTagged(scope, "DepartmentIds") && + !scope.departmentIds.includes(departmentId) + ) { + return yield* SchoolsDepartmentOutOfScope.make({ departmentId }); + } } - } - return yield* schools.listDirectory({ ...decodedQuery, scope }); - }), - ) - .pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("read Schools directory snapshot", cause)), - ), - ); - }); + return yield* schools.listDirectory({ ...decodedQuery, scope }); + }), + ) + .pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("read Schools directory snapshot", cause)), + ), + ); + }), +); diff --git a/packages/database/src/schools/postgres-layer.ts b/packages/database/src/schools/postgres-layer.ts index d149ac9bc..1059e4761 100644 --- a/packages/database/src/schools/postgres-layer.ts +++ b/packages/database/src/schools/postgres-layer.ts @@ -19,8 +19,8 @@ export const SchoolsLive: Layer.Layer = Layer.effect( readSchoolManagement(personId).pipe(Effect.provideService(Database, database)), authorizeCommand: (command, personId) => authorizeSchoolCommand(command, personId).pipe(Effect.provideService(Database, database)), - executeCommand: (command, personId) => - executeSchoolCommand(command, personId).pipe(Effect.provideService(Database, database)), + executeCommand: (authorization) => + executeSchoolCommand(authorization).pipe(Effect.provideService(Database, database)), listDirectory: (input) => listSchoolDirectoryPostgres(input).pipe(Effect.provideService(Database, database)), }); diff --git a/packages/database/src/schools/postgres.test.ts b/packages/database/src/schools/postgres.test.ts index 026a151d8..e19d6184d 100644 --- a/packages/database/src/schools/postgres.test.ts +++ b/packages/database/src/schools/postgres.test.ts @@ -12,22 +12,20 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })((it) => "rejects a persisted school that fails the directory schema", () => Effect.gen(function* () { - const observed = yield* Effect.gen(function* () { - const sql = yield* Database; - // PostgreSQL btrim accepts a tab; the domain requires visible name text. - yield* sql` + const sql = yield* Database; + // PostgreSQL btrim accepts a tab; the domain requires visible name text. + yield* sql` INSERT INTO schools_directory_schools ( name, contact_person, email, phone, language, active ) VALUES (${"\t"}, 'Strict Contact', 'strict@example.invalid', '+47 900 00 020', 'Norwegian', TRUE) `; - const scope = SchoolDirectoryScopeSchema.cases.All.make({}); - const failure = yield* Effect.flip(listSchoolDirectoryPostgres({ scope })); - yield* sql`UPDATE schools_directory_schools SET name = 'Strict School'`; - const repaired = yield* listSchoolDirectoryPostgres({ scope }); + const scope = SchoolDirectoryScopeSchema.cases.All.make({}); + const failure = yield* Effect.flip(listSchoolDirectoryPostgres({ scope })); + yield* sql`UPDATE schools_directory_schools SET name = 'Strict School'`; + const repaired = yield* listSchoolDirectoryPostgres({ scope }); - return { failure, repaired }; - }); + const observed = { failure, repaired }; expect(observed.failure._tag).toBe("SchoolsDecodeError"); expect(observed.repaired.activeSchools[0]?.name).toBe("Strict School"); diff --git a/packages/database/src/schools/postgres.ts b/packages/database/src/schools/postgres.ts index f1bba3299..56dbc4478 100644 --- a/packages/database/src/schools/postgres.ts +++ b/packages/database/src/schools/postgres.ts @@ -29,10 +29,10 @@ const DirectoryRowSchema = Schema.Struct({ type DirectoryRow = typeof DirectoryRowSchema.Type; const decodeError = (operation: string, cause: unknown): SchoolsDecodeError => - new SchoolsDecodeError({ operation, message: String(cause) }); + SchoolsDecodeError.make({ operation, message: String(cause) }); const persistenceError = (operation: string, cause: unknown): SchoolsPersistenceError => - new SchoolsPersistenceError({ operation, message: String(cause) }); + SchoolsPersistenceError.make({ operation, message: String(cause) }); const decodeRows = flow( flow( diff --git a/packages/database/src/service-principal-grants-migration.test.ts b/packages/database/src/service-principal-grants-migration.test.ts index 48a35f09f..1baad4336 100644 --- a/packages/database/src/service-principal-grants-migration.test.ts +++ b/packages/database/src/service-principal-grants-migration.test.ts @@ -12,13 +12,12 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( "registers 0028 and enforces bounded audit text plus both lock-order indexes", () => Effect.gen(function* () { - const evidence = yield* Effect.gen(function* () { - const database = yield* Database; - yield* database` + const database = yield* Database; + yield* database` INSERT INTO public.person_profiles (person_id, first_name, last_name) VALUES ('service-grant-migration-owner', 'Service grant', 'Owner') `; - yield* database` + yield* database` INSERT INTO public.organization_departments ( department_id, name, short_name, email, city ) VALUES ( @@ -26,7 +25,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( 'service-grant-migration@example.invalid', 'Oslo' ) `; - yield* database` + yield* database` INSERT INTO public.economy_receipts ( receipt_id, visual_id, owner_person_id, department_id, amount_ore, currency, description, receipt_date, submitted_at, status, approved_at, @@ -41,14 +40,14 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( 'application/pdf', 100, ${"a".repeat(64)}, 0 ) `; - yield* database` + yield* database` INSERT INTO public.service_principals ( service_principal_id, name, state ) VALUES ( 'service-grant-migration-principal', 'Service grant migration principal', 'Active' ) `; - yield* database` + yield* database` INSERT INTO auth."oauthClient" ( "id", "clientId", "redirectUris", "disabled", "scopes", "clientCredentialsScopes" @@ -58,7 +57,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( ${database.json(["native-api"])} ) `; - yield* database` + yield* database` INSERT INTO auth.oauth_client_bindings ( client_id, client_kind, service_principal_id, secret_expires_at ) VALUES ( @@ -66,7 +65,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( 'service-grant-migration-principal', '2033-06-01T00:00:00.000Z' ) `; - yield* database` + yield* database` INSERT INTO auth."oauthResource" ( "id", "identifier", "name", "disabled" ) VALUES ( @@ -74,7 +73,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( 'urn:vektorprogrammet:native-api', 'Native API', false ) `; - yield* database` + yield* database` INSERT INTO auth."oauthClientResource" ( "id", "clientId", "resourceId" ) VALUES ( @@ -82,7 +81,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( 'urn:vektorprogrammet:native-api' ) `; - yield* database` + yield* database` INSERT INTO public.service_principal_grants ( grant_id, service_principal_id, client_id, protected_resource, operation_id, capability_id, resource_kind, resource_id, start_at @@ -94,12 +93,12 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( ) `; - const appendAudit = ( - eventId: string, - operatorActor: string, - requestCorrelation: string, - ) => - database` + const appendAudit = ( + eventId: string, + operatorActor: string, + requestCorrelation: string, + ) => + database` INSERT INTO public.service_principal_grant_audit ( event_id, occurred_at, event_kind, grant_id, service_principal_id, client_id, protected_resource, operation_id, capability_id, @@ -114,45 +113,41 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( ) `.pipe(Effect.asVoid); - yield* appendAudit( - "service-grant-migration-valid", - "operator", - "service-grant-migration-valid-request", - ); + yield* appendAudit( + "service-grant-migration-valid", + "operator", + "service-grant-migration-valid-request", + ); - const whitespaceEventId = yield* Effect.exit( - appendAudit(" service-grant-migration-event ", "operator", "event-request"), - ); + const whitespaceEventId = yield* Effect.exit( + appendAudit(" service-grant-migration-event ", "operator", "event-request"), + ); - const whitespaceActor = yield* Effect.exit( - appendAudit("service-grant-migration-actor", "\u00a0", "actor-request"), - ); + const whitespaceActor = yield* Effect.exit( + appendAudit("service-grant-migration-actor", "\u00a0", "actor-request"), + ); - const whitespaceCorrelation = yield* Effect.exit( - appendAudit("service-grant-migration-correlation", "operator", " correlation "), - ); + const whitespaceCorrelation = yield* Effect.exit( + appendAudit("service-grant-migration-correlation", "operator", " correlation "), + ); - const overlongEventId = yield* Effect.exit( - appendAudit("e".repeat(161), "operator", "event-length-request"), - ); + const overlongEventId = yield* Effect.exit( + appendAudit("e".repeat(161), "operator", "event-length-request"), + ); - const overlongActor = yield* Effect.exit( - appendAudit( - "service-grant-migration-actor-length", - "a".repeat(161), - "actor-length-request", - ), - ); + const overlongActor = yield* Effect.exit( + appendAudit( + "service-grant-migration-actor-length", + "a".repeat(161), + "actor-length-request", + ), + ); - const overlongCorrelation = yield* Effect.exit( - appendAudit( - "service-grant-migration-correlation-length", - "operator", - "c".repeat(161), - ), - ); + const overlongCorrelation = yield* Effect.exit( + appendAudit("service-grant-migration-correlation-length", "operator", "c".repeat(161)), + ); - const indexes = yield* database<{ readonly indexName: string }>` + const indexes = yield* database<{ readonly indexName: string }>` SELECT indexname AS "indexName" FROM pg_catalog.pg_indexes WHERE schemaname = 'public' @@ -163,23 +158,22 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( ORDER BY indexname `; - const countRows = yield* database<{ readonly count: string }>` + const countRows = yield* database<{ readonly count: string }>` SELECT count(*)::text AS count FROM public.service_principal_grant_audit WHERE grant_id = 'service-grant-migration-grant' `; - return { - whitespaceEventId: whitespaceEventId._tag, - whitespaceActor: whitespaceActor._tag, - whitespaceCorrelation: whitespaceCorrelation._tag, - overlongEventId: overlongEventId._tag, - overlongActor: overlongActor._tag, - overlongCorrelation: overlongCorrelation._tag, - indexes: indexes.map(({ indexName }) => indexName), - count: countRows[0]?.count, - }; - }); + const evidence = { + whitespaceEventId: whitespaceEventId._tag, + whitespaceActor: whitespaceActor._tag, + whitespaceCorrelation: whitespaceCorrelation._tag, + overlongEventId: overlongEventId._tag, + overlongActor: overlongActor._tag, + overlongCorrelation: overlongCorrelation._tag, + indexes: indexes.map(({ indexName }) => indexName), + count: countRows[0]?.count, + }; expect(evidence).toEqual({ whitespaceEventId: "Failure", diff --git a/packages/database/src/service.ts b/packages/database/src/service.ts index 0eb89b135..3897592dc 100644 --- a/packages/database/src/service.ts +++ b/packages/database/src/service.ts @@ -56,7 +56,7 @@ export interface DatabaseOperations extends Omit { } export class Database extends Context.Service()( - "@vektorprogrammet/Database", + "@vektorprogrammet/database/service/Database", ) {} export const databaseHealth = Database.use((database) => database.health); diff --git a/packages/database/src/social-events/postgres-layer.ts b/packages/database/src/social-events/postgres-layer.ts index 365883297..d501da34f 100644 --- a/packages/database/src/social-events/postgres-layer.ts +++ b/packages/database/src/social-events/postgres-layer.ts @@ -25,8 +25,8 @@ export const SocialEventsLive = Layer.effect( readSocialEventListPostgres(input).pipe(Effect.provideService(Database, database)), validateScope: (scope) => validateSocialEventScopePostgres(scope).pipe(Effect.provideService(Database, database)), - create: (command) => - createSocialEventPostgres(command).pipe(Effect.provideService(Database, database)), + create: (creator, command) => + createSocialEventPostgres(creator, command).pipe(Effect.provideService(Database, database)), }); }), ); diff --git a/packages/database/src/social-events/postgres.ts b/packages/database/src/social-events/postgres.ts index 90132fdd7..94610a2ca 100644 --- a/packages/database/src/social-events/postgres.ts +++ b/packages/database/src/social-events/postgres.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { flow, Effect, Schema } from "effect"; import { Database } from "../service.js"; import { canonicalJsonBytes, sha256Hex } from "@vektorprogrammet/domain/shared-kernel"; @@ -9,6 +10,7 @@ import { SocialEventScope, SocialEventScopeResource, type CreateSocialEventCommand as CreateSocialEventCommandValue, + type SocialEventCreation, type SocialEventListResource as SocialEventListResourceValue, type SocialEventObservedAt as SocialEventObservedAtValue, type SocialEventResource as SocialEventResourceValue, @@ -278,24 +280,42 @@ export const readSocialEventListPostgres = ( ); /** - * Inserts the canonical event, one domain command receipt, and one audit row. - * The caller owns the serializable HTTP transaction and has already performed - * authority plus selected-scope validation before the generic receipt lookup. + * Inserts the canonical event, one domain command receipt, and one audit row, as the creator. + * The caller owns the serializable HTTP transaction and resolved the creator's evidence in it + * before the generic receipt lookup. The event's department is the evidence's department. */ -export const createSocialEventPostgres = ( - input: CreateSocialEventCommandValue, -): Effect.Effect => - Effect.gen(function* () { - const command = yield* decodeCreateCommand(input); - - const scope = yield* validateSocialEventScopePostgres({ - departmentId: command.request.departmentId, - semesterId: command.request.semesterId, - }); +export const createSocialEventPostgres: { + ( + input: Omit, + ): ( + creator: SocialEventCreation, + ) => Effect.Effect; + ( + creator: SocialEventCreation, + input: Omit, + ): Effect.Effect; +} = dual( + 2, + ( + creator: SocialEventCreation, + input: Omit, + ): Effect.Effect => + Effect.gen(function* () { + if (input.request.departmentId !== creator.departmentId) + return yield* Effect.die( + new Error("social event creator evidence names another department"), + ); + + const command = yield* decodeCreateCommand({ ...input, actorPersonId: creator.personId }); + + const scope = yield* validateSocialEventScopePostgres({ + departmentId: command.request.departmentId, + semesterId: command.request.semesterId, + }); - const sql = yield* Database; + const sql = yield* Database; - const rows = yield* sql` + const rows = yield* sql` INSERT INTO public.social_events ( event_id, department_id, @@ -334,25 +354,25 @@ export const createSocialEventPostgres = ( to_char(end_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "endAt" `; - const row = rows[0]; + const row = rows[0]; - if (row === undefined) { - return yield* persistenceError("insert social-event", "insert did not return an event"); - } + if (row === undefined) { + return yield* persistenceError("insert social-event", "insert did not return an event"); + } - const resource = yield* decodeResource("decode created social-event")(row); + const resource = yield* decodeResource("decode created social-event")(row); - const receiptCommand = { - _tag: "CreateSocialEvent" as const, - commandId: command.commandId, - eventId: command.eventId, - actorPersonId: command.actorPersonId, - occurredAt: command.occurredAt, - ...command.request, - }; + const receiptCommand = { + _tag: "CreateSocialEvent" as const, + commandId: command.commandId, + eventId: command.eventId, + actorPersonId: command.actorPersonId, + occurredAt: command.occurredAt, + ...command.request, + }; - const commandSha256 = sha256Hex(canonicalJsonBytes(receiptCommand)); - yield* sql` + const commandSha256 = sha256Hex(canonicalJsonBytes(receiptCommand)); + yield* sql` INSERT INTO public.social_event_command_receipts ( command_id, command_sha256, @@ -371,7 +391,7 @@ export const createSocialEventPostgres = ( ${command.occurredAt} ) `; - yield* sql` + yield* sql` INSERT INTO public.social_event_audit ( command_id, event_id, @@ -387,9 +407,10 @@ export const createSocialEventPostgres = ( ) `; - return resource; - }).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail(persistenceError("create social-event", cause)), + return resource; + }).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(persistenceError("create social-event", cause)), + ), ), - ); +); diff --git a/packages/database/src/team-application/delivery-pgbouncer.test.ts b/packages/database/src/team-application/delivery-pgbouncer.test.ts index 1e3670e16..2aa2cfaa0 100644 --- a/packages/database/src/team-application/delivery-pgbouncer.test.ts +++ b/packages/database/src/team-application/delivery-pgbouncer.test.ts @@ -26,7 +26,7 @@ import { TeamApplicationsLive } from "./index.js"; * transaction may run on another server connection, and a transaction holds one. */ class Pooler extends Context.Service()( - "team-application/delivery-pgbouncer.test/Pooler", + "@vektorprogrammet/database/team-application/delivery-pgbouncer.test/Pooler", ) {} /** diff --git a/packages/database/src/team-application/delivery.test.ts b/packages/database/src/team-application/delivery.test.ts index 2fcbd756f..1c4181b0b 100644 --- a/packages/database/src/team-application/delivery.test.ts +++ b/packages/database/src/team-application/delivery.test.ts @@ -24,6 +24,7 @@ import { TeamApplicationOutboxDelivery, TeamApplications, type TeamApplicationInput, + TeamApplicationAction, } from "@vektorprogrammet/domain/team-application"; import { DatabaseTestLive } from "../test-support/platform.js"; import { Database } from "../service.js"; @@ -114,16 +115,23 @@ const deleteApplication = (applicationId: string, command: string) => yield* Database.use((sql) => sql.withTransaction( TeamApplications.use((service) => - service.deleteApplication( - { - commandId: TeamApplicationCommandId.make(command), - applicationId: TeamApplicationId.make(applicationId), - }, - { - personId: PersonId.make("pilot-leader"), - authorizationInstant: DateTime.formatIso(now), - }, - ), + service + .authorize( + { + personId: PersonId.make("pilot-leader"), + authorizationInstant: DateTime.formatIso(now), + }, + TeamApplicationAction.DeleteTeamApplication({ + applicationId: TeamApplicationId.make(applicationId), + }), + ) + .pipe( + Effect.flatMap((authorization) => + service.deleteApplication(authorization, { + commandId: TeamApplicationCommandId.make(command), + }), + ), + ), ), ), ); @@ -158,7 +166,7 @@ const failingMail = (kind: MailDeliveryFailureKind, deliveries: Array Effect.sync(() => deliveries.push(request)).pipe( - Effect.andThen(Effect.fail(new MailDeliveryError({ kind }))), + Effect.andThen(Effect.fail(MailDeliveryError.make({ kind }))), ), }), ); diff --git a/packages/database/src/team-application/outbox.ts b/packages/database/src/team-application/outbox.ts index ecf72c164..35cff31b3 100644 --- a/packages/database/src/team-application/outbox.ts +++ b/packages/database/src/team-application/outbox.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { Context, Data, @@ -70,7 +71,7 @@ export class TeamApplicationDeliveryQueue extends Context.Service< readonly store: PersistedQueue.PersistedQueueStore["Service"]; readonly maxAttempts: number; } ->()("@vektorprogrammet/database/team-application/TeamApplicationDeliveryQueue") {} +>()("@vektorprogrammet/database/team-application/outbox/TeamApplicationDeliveryQueue") {} /** * Builds a queue store on the caller's Database, so an offer joins the caller's @@ -110,8 +111,7 @@ export const TeamApplicationDeliveryQueueLive = ( }), ); -/** Stores the envelope and enqueues its effect in the caller's transaction. */ -export const insertTeamApplicationOutbox = ( +const insertTeamApplicationOutboxImpl = ( notification: TeamApplicationNotification, ordinal: number, committedAt: string, @@ -141,6 +141,21 @@ export const insertTeamApplicationOutbox = ( .pipe(Effect.mapError(persistenceFailure("enqueue team application notification"))); }); +/** Stores the envelope and enqueues its effect in the caller's transaction. */ +export const insertTeamApplicationOutbox: { + ( + ordinal: number, + committedAt: string, + ): ( + notification: TeamApplicationNotification, + ) => ReturnType; + ( + notification: TeamApplicationNotification, + ordinal: number, + committedAt: string, + ): ReturnType; +} = dual(3, insertTeamApplicationOutboxImpl); + /** Deletion stops every undelivered notification of the application and clears its envelope. */ export const cancelTeamApplicationOutbox = (applicationId: string) => Database.use( @@ -313,14 +328,7 @@ const deliver = (lease: Lease, sender: string, maxAttempts: number) => ); }); -/** - * Takes the next due notification and attempts it, or returns Idle when none is taken - * within `idle`. The queue blocks while it is empty, so the first of the attempt and the - * idle wait to complete `window` decides: an item taken after the wait closed it is - * released uncounted, as an interruption. Interruption during the provider call also - * releases the item without counting the attempt. - */ -export const deliverNextTeamApplicationOutbox = (sender: string, idle: Duration.Duration) => +const deliverNextTeamApplicationOutboxImpl = (sender: string, idle: Duration.Duration) => Effect.gen(function* () { const { queue, maxAttempts } = yield* TeamApplicationDeliveryQueue; const window = yield* Deferred.make(); @@ -354,6 +362,23 @@ export const deliverNextTeamApplicationOutbox = (sender: string, idle: Duration. return yield* Effect.raceFirst(attempt, idleWait); }); +/** + * Takes the next due notification and attempts it, or returns Idle when none is taken + * within `idle`. The queue blocks while it is empty, so the first of the attempt and the + * idle wait to complete `window` decides: an item taken after the wait closed it is + * released uncounted, as an interruption. Interruption during the provider call also + * releases the item without counting the attempt. + */ +export const deliverNextTeamApplicationOutbox: { + ( + idle: Duration.Duration, + ): (sender: string) => ReturnType; + ( + sender: string, + idle: Duration.Duration, + ): ReturnType; +} = dual(2, deliverNextTeamApplicationOutboxImpl); + /** Removes queue items that completed before the retention; their outbox rows stay. */ export const cleanUpTeamApplicationDeliveryQueue = Effect.gen(function* () { const { store } = yield* TeamApplicationDeliveryQueue; diff --git a/packages/database/src/team-application/persistence.ts b/packages/database/src/team-application/persistence.ts index e369f5db4..64697c453 100644 --- a/packages/database/src/team-application/persistence.ts +++ b/packages/database/src/team-application/persistence.ts @@ -13,7 +13,7 @@ const serializationConflict = (cause: unknown, depth: number): boolean => export const persistenceFailure = (operation: string) => (cause: unknown): TeamApplicationPersistenceError => - new TeamApplicationPersistenceError({ + TeamApplicationPersistenceError.make({ operation, conflict: serializationConflict(cause, 0), cause, diff --git a/packages/database/src/team-application/postgres.ts b/packages/database/src/team-application/postgres.ts index c3235d201..95dbbdd8b 100644 --- a/packages/database/src/team-application/postgres.ts +++ b/packages/database/src/team-application/postgres.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { randomUUID } from "node:crypto"; import { DateTime, Effect, flow, Match, Option, Predicate, Schema } from "effect"; import { SqlSchema } from "effect/unstable/sql"; @@ -15,6 +16,7 @@ import { TeamApplication, TeamApplicationAccessDenied, TeamApplicationAction, + type TeamApplicationAuthorization, TeamApplicationCommandConflict, TeamApplicationConfirmation, TeamApplicationId, @@ -278,7 +280,7 @@ const committedObservation = if (Option.isNone(stored)) return Option.none(); if (stored.value.operation !== operation || stored.value.commandSha256 !== digest) { - return yield* new TeamApplicationCommandConflict({ commandId }); + return yield* TeamApplicationCommandConflict.make({ commandId }); } return Option.some( @@ -371,13 +373,13 @@ const resolveTeamActor = (principal: TeamApplicationPrincipal, teamId: TeamId, c const decision = mapOrganizationAuthorityToTeamApplicationActor(authority, teamId); if (Predicate.isTagged(decision, "Deny")) { - return yield* new TeamApplicationAccessDenied({ + return yield* TeamApplicationAccessDenied.make({ reason: decision.reason === "AuthorityInactive" ? "AuthorityInactive" : "NotInScope", }); } if (changes && !Predicate.isTagged(decision.value, "TeamLeader")) { - return yield* new TeamApplicationAccessDenied({ reason: "NotLeader" }); + return yield* TeamApplicationAccessDenied.make({ reason: "NotLeader" }); } return decision.value; @@ -398,16 +400,13 @@ const applicationTeam = (applicationId: TeamApplicationId, forDeletion: boolean) ), Effect.flatMap( Option.match({ - onNone: () => Effect.fail(new TeamApplicationNotFound({ applicationId })), + onNone: () => Effect.fail(TeamApplicationNotFound.make({ applicationId })), onSome: (row) => Effect.succeed(row.teamId), }), ), ); -export const authorizeTeamApplicationAction = ( - principal: TeamApplicationPrincipal, - action: TeamApplicationAction, -) => +const resolveActionActor = (principal: TeamApplicationPrincipal, action: TeamApplicationAction) => TeamApplicationAction.$match(action, { ReadTeamApplications: ({ teamId }) => resolveTeamActor(principal, teamId, false), ReviseTeamApplicationIntake: ({ teamId }) => authorizeIntakeRevision(principal, teamId), @@ -421,12 +420,40 @@ export const authorizeTeamApplicationAction = ( ), }); +const authorizeTeamApplicationActionImpl = ( + principal: TeamApplicationPrincipal, + action: A, +) => + resolveActionActor(principal, action).pipe( + Effect.map( + (actor) => + // SAFETY: the one constructor of the evidence brand; resolveActionActor above is what it proves. + ({ principal, action, actor }) as TeamApplicationAuthorization, + ), + ); + +/** + * Resolves current authority for one staff action on the caller's transaction, with the row locks + * a change takes, before a transport replay. + */ +export const authorizeTeamApplicationAction: { + ( + action: A, + ): ( + principal: TeamApplicationPrincipal, + ) => ReturnType>; + ( + principal: TeamApplicationPrincipal, + action: A, + ): ReturnType>; +} = dual(2, authorizeTeamApplicationActionImpl); + export const readPublicTeamApplicationIntake = (teamId: TeamId) => Effect.gen(function* () { const team = yield* findTeamIntake({ teamId, lock: "None" }); if (Option.isNone(team) || !team.value.teamActive || !team.value.departmentActive) { - return yield* new TeamApplicationTeamNotFound({ teamId }); + return yield* TeamApplicationTeamNotFound.make({ teamId }); } const now = yield* DateTime.now; @@ -473,14 +500,14 @@ export const submitTeamApplication = (command: SubmitTeamApplicationCommand) => const team = yield* findTeamIntake({ teamId: command.teamId, lock: "Share" }); if (Option.isNone(team) || !team.value.teamActive || !team.value.departmentActive) { - return yield* new TeamApplicationTeamNotFound({ teamId: command.teamId }); + return yield* TeamApplicationTeamNotFound.make({ teamId: command.teamId }); } const now = yield* DateTime.now; const intake = evaluateTeamApplicationIntake(team.value, now); if (!Predicate.isTagged(intake, "Open")) { - return yield* new TeamApplicationIntakeClosed({ teamId: command.teamId }); + return yield* TeamApplicationIntakeClosed.make({ teamId: command.teamId }); } const submittedAt = DateTime.formatIso(now); @@ -524,7 +551,7 @@ export const submitTeamApplication = (command: SubmitTeamApplicationCommand) => return { confirmation, replayed: false }; }); -export const listTeamApplications = ( +const listTeamApplicationsImpl = ( principal: TeamApplicationPrincipal, teamId: TeamId, cursor?: string, @@ -536,7 +563,7 @@ export const listTeamApplications = ( const team = yield* findTeamIntake({ teamId, lock: "None" }); if (Option.isNone(team)) - return yield* new TeamApplicationAccessDenied({ reason: "NotInScope" }); + return yield* TeamApplicationAccessDenied.make({ reason: "NotInScope" }); const now = yield* DateTime.now; @@ -561,34 +588,56 @@ export const listTeamApplications = ( }; }); -export const readTeamApplication = ( +export const listTeamApplications: { + ( + teamId: TeamId, + cursor?: string, + ): (principal: TeamApplicationPrincipal) => ReturnType; + ( + principal: TeamApplicationPrincipal, + teamId: TeamId, + cursor?: string, + ): ReturnType; +} = dual((args) => Predicate.isObject(args[0]), listTeamApplicationsImpl); + +const readTeamApplicationImpl = ( principal: TeamApplicationPrincipal, applicationId: TeamApplicationId, ) => Effect.gen(function* () { - const actor = yield* authorizeTeamApplicationAction( + const actor = yield* resolveActionActor( principal, TeamApplicationAction.ReadTeamApplication({ applicationId }), ); const row = yield* findApplication(applicationId); - if (Option.isNone(row)) return yield* new TeamApplicationNotFound({ applicationId }); + if (Option.isNone(row)) return yield* TeamApplicationNotFound.make({ applicationId }); const { teamName, ...application } = row.value; return { application, teamName, actor }; }); -export const deleteTeamApplication = ( - command: DeleteTeamApplicationCommand, - principal: TeamApplicationPrincipal, +export const readTeamApplication: { + ( + applicationId: TeamApplicationId, + ): (principal: TeamApplicationPrincipal) => ReturnType; + ( + principal: TeamApplicationPrincipal, + applicationId: TeamApplicationId, + ): ReturnType; +} = dual(2, readTeamApplicationImpl); + +const deleteTeamApplicationImpl = ( + authorization: TeamApplicationAuthorization< + Extract + >, + input: Pick, ) => Effect.gen(function* () { - const actor = yield* authorizeTeamApplicationAction( - principal, - TeamApplicationAction.DeleteTeamApplication({ applicationId: command.applicationId }), - ); + const { principal, actor } = authorization; + const command = { ...input, applicationId: authorization.action.applicationId }; const digest = commandDigest({ schema: "DeleteTeamApplication/v1", @@ -614,7 +663,7 @@ export const deleteTeamApplication = ( ).pipe(Effect.mapError(persistenceFailure("delete team application"))); if (deleted.length !== 1) { - return yield* new TeamApplicationNotFound({ applicationId: command.applicationId }); + return yield* TeamApplicationNotFound.make({ applicationId: command.applicationId }); } yield* cancelTeamApplicationOutbox(command.applicationId); @@ -643,13 +692,32 @@ export const deleteTeamApplication = ( }); }); -export const reviseTeamApplicationIntake = ( - command: ReviseTeamApplicationIntakeCommand, - principal: TeamApplicationPrincipal, +export const deleteTeamApplication: { + ( + input: Pick, + ): ( + authorization: TeamApplicationAuthorization< + Extract + >, + ) => ReturnType; + ( + authorization: TeamApplicationAuthorization< + Extract + >, + input: Pick, + ): ReturnType; +} = dual(2, deleteTeamApplicationImpl); + +const reviseTeamApplicationIntakeImpl = ( + authorization: TeamApplicationAuthorization< + Extract + >, + input: Omit, checkPrecondition: (current: TeamApplicationIntake) => Effect.Effect, ) => Effect.gen(function* () { - const actor = yield* authorizeIntakeRevision(principal, command.teamId); + const { principal, actor } = authorization; + const command = { ...input, teamId: authorization.action.teamId }; const digest = commandDigest({ schema: "ReviseTeamApplicationIntake/v1", @@ -673,7 +741,7 @@ export const reviseTeamApplicationIntake = ( const team = yield* findTeamIntake({ teamId: command.teamId, lock: "Update" }); if (Option.isNone(team)) - return yield* new TeamApplicationAccessDenied({ reason: "NotInScope" }); + return yield* TeamApplicationAccessDenied.make({ reason: "NotInScope" }); const now = yield* DateTime.now; @@ -741,3 +809,21 @@ export const reviseTeamApplicationIntake = ( return { teamId: command.teamId, intake, replayed: false }; }); + +export const reviseTeamApplicationIntake: { + ( + input: Omit, + checkPrecondition: (current: TeamApplicationIntake) => Effect.Effect, + ): ( + authorization: TeamApplicationAuthorization< + Extract + >, + ) => ReturnType>; + ( + authorization: TeamApplicationAuthorization< + Extract + >, + input: Omit, + checkPrecondition: (current: TeamApplicationIntake) => Effect.Effect, + ): ReturnType>; +} = dual(3, reviseTeamApplicationIntakeImpl); diff --git a/packages/database/src/team-application/queue-migration.test.ts b/packages/database/src/team-application/queue-migration.test.ts index 3e885490e..e9eb03ec2 100644 --- a/packages/database/src/team-application/queue-migration.test.ts +++ b/packages/database/src/team-application/queue-migration.test.ts @@ -372,7 +372,7 @@ const upgradeEvidence = { }; class MigrationCluster extends Context.Service()( - "team-application/queue-migration.test/MigrationCluster", + "@vektorprogrammet/database/team-application/queue-migration.test/MigrationCluster", ) {} const migrationCluster = Layer.effect( diff --git a/packages/database/src/team-application/service.test.ts b/packages/database/src/team-application/service.test.ts index c66fab6d9..2ef27feee 100644 --- a/packages/database/src/team-application/service.test.ts +++ b/packages/database/src/team-application/service.test.ts @@ -8,6 +8,7 @@ import { TeamApplications, type TeamApplicationInput, type TeamApplicationIntake, + TeamApplicationAction, } from "@vektorprogrammet/domain/team-application"; import { DatabaseTestLive } from "../test-support/platform.js"; import { Database } from "../service.js"; @@ -307,7 +308,7 @@ layer(teamApplicationsLayer, { excludeTestServices: true, timeout: "30 seconds" ["ta-suspended", "AuthorityInactive"], ["ta-admin", "NotInScope"], ] as const) { - const denied = new TeamApplicationAccessDenied({ reason }); + const denied = TeamApplicationAccessDenied.make({ reason }); expect(yield* Effect.flip(list(person))).toEqual(denied); expect(yield* Effect.flip(read(person))).toEqual(denied); @@ -343,7 +344,7 @@ layer(teamApplicationsLayer, { excludeTestServices: true, timeout: "30 seconds" .toReversed(), ); expect(yield* Effect.flip(page("not-a-cursor!"))).toEqual( - new TeamApplicationInvalidCursor(), + TeamApplicationInvalidCursor.make(), ); }), ); @@ -359,16 +360,24 @@ layer(teamApplicationsLayer, { excludeTestServices: true, timeout: "30 seconds" Effect.flatMap(principal(person), (actor) => inTransaction( TeamApplications.use((service) => - service.deleteApplication( - { commandId: commandId(command), applicationId }, - actor, - ), + service + .authorize( + actor, + TeamApplicationAction.DeleteTeamApplication({ applicationId }), + ) + .pipe( + Effect.flatMap((authorization) => + service.deleteApplication(authorization, { + commandId: commandId(command), + }), + ), + ), ), ), ); expect(yield* Effect.flip(remove("ta-member", "delete-by-member"))).toEqual( - new TeamApplicationAccessDenied({ reason: "NotLeader" }), + TeamApplicationAccessDenied.make({ reason: "NotLeader" }), ); expect( yield* count(`FROM team_applications WHERE application_id = '${applicationId}'`), @@ -422,16 +431,26 @@ layer(teamApplicationsLayer, { excludeTestServices: true, timeout: "30 seconds" Effect.flatMap(principal(person), (actor) => inTransaction( TeamApplications.use((service) => - service.reviseIntake( - { - commandId: commandId(command), - teamId: teamId("ta-revise"), - acceptApplication: false, - deadline: "2099-06-01T00:00:00+02:00", - }, - actor, - check, - ), + service + .authorize( + actor, + TeamApplicationAction.ReviseTeamApplicationIntake({ + teamId: teamId("ta-revise"), + }), + ) + .pipe( + Effect.flatMap((authorization) => + service.reviseIntake( + authorization, + { + commandId: commandId(command), + acceptApplication: false, + deadline: "2099-06-01T00:00:00+02:00", + }, + check, + ), + ), + ), ), ), ); @@ -445,7 +464,7 @@ layer(teamApplicationsLayer, { excludeTestServices: true, timeout: "30 seconds" ).toBe("stale"); expect( yield* Effect.flip(revise("ta-member", "revise-member", () => Effect.void)), - ).toEqual(new TeamApplicationAccessDenied({ reason: "NotLeader" })); + ).toEqual(TeamApplicationAccessDenied.make({ reason: "NotLeader" })); expect(yield* intake()).toEqual(before); expect(yield* count(`FROM team_application_audit WHERE team_id = 'ta-revise'`)).toBe(0); }), diff --git a/packages/database/src/team-application/service.ts b/packages/database/src/team-application/service.ts index d7c7f88d9..ee09920bf 100644 --- a/packages/database/src/team-application/service.ts +++ b/packages/database/src/team-application/service.ts @@ -54,10 +54,12 @@ export const TeamApplicationsLive = (delivery: Partial - deleteTeamApplication(command, principal).pipe(Effect.provideService(Database, database)), - reviseIntake: (command, principal, checkPrecondition) => - reviseTeamApplicationIntake(command, principal, checkPrecondition).pipe( + deleteApplication: (authorization, command) => + deleteTeamApplication(authorization, command).pipe( + Effect.provideService(Database, database), + ), + reviseIntake: (authorization, command, checkPrecondition) => + reviseTeamApplicationIntake(authorization, command, checkPrecondition).pipe( Effect.provideService(Database, database), ), deliverNextOutboxEffect: (sender, idle) => diff --git a/packages/database/src/test-support/disposable-person-authority-backfill.test.ts b/packages/database/src/test-support/disposable-person-authority-backfill.test.ts index a326e5b03..0fbb5ded1 100644 --- a/packages/database/src/test-support/disposable-person-authority-backfill.test.ts +++ b/packages/database/src/test-support/disposable-person-authority-backfill.test.ts @@ -286,12 +286,10 @@ layer(DatabaseTestLive(), { excludeTestServices: true })( expect(yield* encodeJson(first)).not.toContain(token); } - yield* Effect.gen(function* () { - const sql = yield* Database; - yield* sql`DELETE FROM public.economy_receipt_approval_grants`; - yield* sql`DELETE FROM public.economy_payment_authorities`; - yield* sql`DELETE FROM public.organization_global_administrator_grants`; - }); + const sql = yield* Database; + yield* sql`DELETE FROM public.economy_receipt_approval_grants`; + yield* sql`DELETE FROM public.economy_payment_authorities`; + yield* sql`DELETE FROM public.organization_global_administrator_grants`; const second = yield* backfillDisposablePersonAuthoritiesFromPreConfigEvidence( preConfigEvidence( diff --git a/packages/database/src/test-support/disposable-person-authority-backfill.ts b/packages/database/src/test-support/disposable-person-authority-backfill.ts index 117ddb96a..01f814adb 100644 --- a/packages/database/src/test-support/disposable-person-authority-backfill.ts +++ b/packages/database/src/test-support/disposable-person-authority-backfill.ts @@ -280,7 +280,7 @@ const isDeterministicJsonValue = ( if ( descriptor === undefined || - !descriptor.enumerable || + descriptor.enumerable !== true || !("value" in descriptor) || !isDeterministicJsonValue(descriptor.value, ancestors) ) { @@ -316,7 +316,7 @@ const consolidateFacts = ( const existing = byIdentity.get(identity); if (existing !== undefined && valueOf(existing) !== valueOf(fact)) { - return yield* new DisposableAuthorityEvidenceConflict({ + return yield* DisposableAuthorityEvidenceConflict.make({ factKey: identity, message: "legacy token evidence contains conflicting authority facts", }); @@ -343,22 +343,20 @@ const decodeAndPlan = flow( Schema.decodeUnknownEffect( Schema.declare((input): input is Schema.Json => isDeterministicJsonValue(input)), ), - Effect.mapError( - () => - new DisposableAuthorityEvidenceNondeterministicInput({ - message: "evidence must be an acyclic plain JSON object with data properties", - }), + Effect.mapError(() => + DisposableAuthorityEvidenceNondeterministicInput.make({ + message: "evidence must be an acyclic plain JSON object with data properties", + }), ), Effect.flatMap((input) => Effect.gen(function* () { const evidence = yield* Schema.decodeUnknownEffect( DisposablePreConfigPersonAuthorityEvidenceSchema, )(input, { onExcessProperty: "error" }).pipe( - Effect.mapError( - () => - new DisposableAuthorityEvidenceDecodeError({ - message: "invalid pre-config person-authority token-map evidence", - }), + Effect.mapError(() => + DisposableAuthorityEvidenceDecodeError.make({ + message: "invalid pre-config person-authority token-map evidence", + }), ), ); @@ -374,7 +372,7 @@ const decodeAndPlan = flow( const evaluatedMillis = instantMillis(evidence.evaluatedAt); if (startMillis > evaluatedMillis || (hasInactiveFact && startMillis === evaluatedMillis)) { - return yield* new DisposableAuthorityEvidenceDecodeError({ + return yield* DisposableAuthorityEvidenceDecodeError.make({ message: "authorityStartAt must not follow evaluatedAt and must precede it for inactive evidence", }); @@ -735,7 +733,7 @@ const insertionRequired = ( if (rows.every((row) => row.sameFact)) { return Effect.fail( - new DisposableAuthorityEvidenceAmbiguousDuplicate({ + DisposableAuthorityEvidenceAmbiguousDuplicate.make({ factKey, message: "an equivalent authority fact already has a different stable identity", }), @@ -743,7 +741,7 @@ const insertionRequired = ( } return Effect.fail( - new DisposableAuthorityEvidenceConflict({ + DisposableAuthorityEvidenceConflict.make({ factKey, message: "planned authority interval conflicts with an existing authority fact", }), @@ -755,14 +753,14 @@ const missingReference = ( personId: string, referenceId: string, ) => - new DisposableAuthorityEvidenceMissingReference({ + DisposableAuthorityEvidenceMissingReference.make({ referenceKind, personId, referenceId, }); const evidenceConflict = (factKey: string, message: string) => - new DisposableAuthorityEvidenceConflict({ factKey, message }); + DisposableAuthorityEvidenceConflict.make({ factKey, message }); /** * Backfills disposable authority rows from already JSON-decoded legacy test @@ -1075,7 +1073,7 @@ export const backfillDisposablePersonAuthoritiesFromPreConfigEvidence = flow( .pipe( Effect.catchTag("SqlError", () => Effect.fail( - new DisposableAuthorityEvidencePersistenceError({ + DisposableAuthorityEvidencePersistenceError.make({ operation: "backfill disposable person authorities", message: "database rejected disposable authority evidence", }), diff --git a/packages/database/src/test-support/observe-postgres.ts b/packages/database/src/test-support/observe-postgres.ts index 63e68f8a0..c088924d4 100644 --- a/packages/database/src/test-support/observe-postgres.ts +++ b/packages/database/src/test-support/observe-postgres.ts @@ -1,5 +1,6 @@ import * as PgClient from "@effect/sql-pg/PgClient"; import { Effect, Option } from "effect"; +import { dual } from "effect/Function"; import * as Statement from "effect/unstable/sql/Statement"; import type * as SqlConnection from "effect/unstable/sql/SqlConnection"; import type { SqlError } from "effect/unstable/sql/SqlError"; @@ -14,40 +15,47 @@ export interface PostgresStatementObserver { } /** Observe the real driver connection without replacing transaction ownership. */ -export const observePostgresStatements = ( - database: DatabaseOperations, - observe: PostgresStatementObserver, -): DatabaseOperations => { - const connection = Effect.flatMap( - Effect.serviceOption(database.transactionService), - Option.match({ - onNone: () => database.reserve, - onSome: ([current]) => Effect.succeed(current), - }), - ).pipe( - Effect.map( - (current): SqlConnection.Connection => ({ - executeStream: (text, parameters, transformRows) => - current.executeStream(text, parameters, transformRows), - execute: (text, parameters, transformRows) => - observe(current.execute(text, parameters, transformRows), text, parameters.map(String)), - executeRaw: (text, parameters) => - observe(current.executeRaw(text, parameters), text, parameters.map(String)), - executeValues: (text, parameters) => - observe(current.executeValues(text, parameters), text, parameters.map(String)), - executeValuesUnprepared: (text, parameters) => - observe(current.executeValuesUnprepared(text, parameters), text, parameters.map(String)), - executeUnprepared: (text, parameters, transformRows) => - observe( - current.executeUnprepared(text, parameters, transformRows), - text, - parameters.map(String), - ), +export const observePostgresStatements: { + (observe: PostgresStatementObserver): (database: DatabaseOperations) => DatabaseOperations; + (database: DatabaseOperations, observe: PostgresStatementObserver): DatabaseOperations; +} = dual( + 2, + (database: DatabaseOperations, observe: PostgresStatementObserver): DatabaseOperations => { + const connection = Effect.flatMap( + Effect.serviceOption(database.transactionService), + Option.match({ + onNone: () => database.reserve, + onSome: ([current]) => Effect.succeed(current), }), - ), - ); + ).pipe( + Effect.map( + (current): SqlConnection.Connection => ({ + executeStream: (text, parameters, transformRows) => + current.executeStream(text, parameters, transformRows), + execute: (text, parameters, transformRows) => + observe(current.execute(text, parameters, transformRows), text, parameters.map(String)), + executeRaw: (text, parameters) => + observe(current.executeRaw(text, parameters), text, parameters.map(String)), + executeValues: (text, parameters) => + observe(current.executeValues(text, parameters), text, parameters.map(String)), + executeValuesUnprepared: (text, parameters) => + observe( + current.executeValuesUnprepared(text, parameters), + text, + parameters.map(String), + ), + executeUnprepared: (text, parameters, transformRows) => + observe( + current.executeUnprepared(text, parameters, transformRows), + text, + parameters.map(String), + ), + }), + ), + ); - const statements = Statement.make(connection, PgClient.makeCompiler(), [], undefined); + const statements = Statement.make(connection, PgClient.makeCompiler(), [], undefined); - return Object.assign(statements, { ...database, ...statements }); -}; + return Object.assign(statements, { ...database, ...statements }); + }, +); diff --git a/packages/database/src/test-support/pglite-layer.ts b/packages/database/src/test-support/pglite-layer.ts index e615467b2..425576b31 100644 --- a/packages/database/src/test-support/pglite-layer.ts +++ b/packages/database/src/test-support/pglite-layer.ts @@ -1,6 +1,7 @@ import * as PgliteClient from "@effect/sql-pglite/PgliteClient"; import { btree_gist } from "@electric-sql/pglite/contrib/btree_gist"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Predicate } from "effect"; +import { dual } from "effect/Function"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { databaseWithMigrations, type DatabaseLayerObserver } from "../database-construction.js"; import { DatabaseMigrationExecutionError, type ExecuteMigration } from "../migrations.js"; @@ -42,8 +43,26 @@ const pgliteTestConfig = ( }; }; -/** In-memory PGlite, migrated like `DatabaseLive`; tests take it on Bun from `test-support/platform`. */ -export const DatabaseTest = ( +const makeDatabaseTest = ( config?: Parameters[0], observer?: DatabaseLayerObserver, ) => DatabaseFromPglite(observer).pipe(Layer.provide(PgliteClient.layer(pgliteTestConfig(config)))); + +type DatabaseTestLayer = ReturnType; + +/** The arguments of the data-first `DatabaseTest`: PGlite configuration, then a layer observer. */ +export type DatabaseTestOptions = Parameters; + +/** In-memory PGlite, migrated like `DatabaseLive`; tests take it on Bun from `test-support/platform`. */ +export const DatabaseTest: { + ( + config?: Parameters[0], + observer?: DatabaseLayerObserver, + ): DatabaseTestLayer; + ( + observer?: DatabaseLayerObserver, + ): (config?: Parameters[0]) => DatabaseTestLayer; +} = dual( + (args) => args.length === 0 || !Predicate.hasProperty(args[0], "onAcquire"), + makeDatabaseTest, +); diff --git a/packages/database/src/test-support/platform.ts b/packages/database/src/test-support/platform.ts index 74c16b2e1..8de0c0483 100644 --- a/packages/database/src/test-support/platform.ts +++ b/packages/database/src/test-support/platform.ts @@ -1,7 +1,7 @@ import * as BunFileSystem from "@effect/platform-bun/BunFileSystem"; import * as BunPath from "@effect/platform-bun/BunPath"; import { Layer } from "effect"; -import { DatabaseTest } from "./pglite-layer.js"; +import { DatabaseTest, type DatabaseTestOptions } from "./pglite-layer.js"; /** * The Bun file system and path services that the database tests, proofs, and CLIs run on. The @@ -10,5 +10,5 @@ import { DatabaseTest } from "./pglite-layer.js"; export const TestPlatform = Layer.mergeAll(BunFileSystem.layer, BunPath.layer); /** `DatabaseTest` on the Bun platform services of `TestPlatform`. */ -export const DatabaseTestLive = (...options: Parameters) => +export const DatabaseTestLive = (...options: DatabaseTestOptions) => DatabaseTest(...options).pipe(Layer.provide(TestPlatform)); diff --git a/packages/database/src/test-support/postgres.ts b/packages/database/src/test-support/postgres.ts index 05597a219..78de96931 100644 --- a/packages/database/src/test-support/postgres.ts +++ b/packages/database/src/test-support/postgres.ts @@ -1,59 +1,77 @@ import { PGlite } from "@electric-sql/pglite"; import { btree_gist } from "@electric-sql/pglite/contrib/btree_gist"; import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; -import { Effect, FileSystem, Path } from "effect"; +import { Effect, FileSystem, Path, type PlatformError, Predicate, type Scope } from "effect"; +import { dual } from "effect/Function"; import { Pool } from "pg"; import { type DatabaseMigrationDefinition, databaseMigrationDefinitions } from "../migrations.js"; +type PostgresTestDatabaseEffect = Effect.Effect< + A, + E | PlatformError.BadArgument | PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path | Exclude +>; + /** * Runs `use` on a PGlite database through `migrations`, by default every registered migration. * The pool, the socket server, and the database close in that order when `use` ends. */ -export const withPostgresTestDatabase = ( - use: (pool: Pool) => Effect.Effect, - migrations: ReadonlyArray = databaseMigrationDefinitions, -) => - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const database = yield* Effect.acquireRelease( - Effect.promise(() => PGlite.create({ extensions: { btree_gist } })), - (database) => Effect.promise(() => database.close()), - ); - - for (const migration of migrations) { - const source = yield* fs.readFileString(yield* path.fromFileUrl(migration.url)); - yield* Effect.promise(() => database.exec(source)); - } - - const server = yield* Effect.acquireRelease( - Effect.sync( - () => - new PGLiteSocketServer({ - db: database, - host: "127.0.0.1", - port: 0, - maxConnections: 1, - }), - ), - (server) => Effect.promise(() => server.stop()), - ); - - yield* Effect.promise(() => server.start()); - - const pool = yield* Effect.acquireRelease( - Effect.sync( - () => - new Pool({ - connectionString: `postgres://postgres@${server.getServerConn()}/postgres`, - max: 1, - }), - ), - (pool) => Effect.promise(() => pool.end()), - ); - - return yield* use(pool); - }), - ); +export const withPostgresTestDatabase: { + ( + migrations?: ReadonlyArray, + ): (use: (pool: Pool) => Effect.Effect) => PostgresTestDatabaseEffect; + ( + use: (pool: Pool) => Effect.Effect, + migrations?: ReadonlyArray, + ): PostgresTestDatabaseEffect; +} = dual( + (args) => Predicate.isFunction(args[0]), + ( + use: (pool: Pool) => Effect.Effect, + migrations: ReadonlyArray = databaseMigrationDefinitions, + ): PostgresTestDatabaseEffect => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const database = yield* Effect.acquireRelease( + Effect.promise(() => PGlite.create({ extensions: { btree_gist } })), + (database) => Effect.promise(() => database.close()), + ); + + for (const migration of migrations) { + const source = yield* fs.readFileString(yield* path.fromFileUrl(migration.url)); + yield* Effect.promise(() => database.exec(source)); + } + + const server = yield* Effect.acquireRelease( + Effect.sync( + () => + new PGLiteSocketServer({ + db: database, + host: "127.0.0.1", + port: 0, + maxConnections: 1, + }), + ), + (server) => Effect.promise(() => server.stop()), + ); + + yield* Effect.promise(() => server.start()); + + const pool = yield* Effect.acquireRelease( + Effect.sync( + () => + new Pool({ + connectionString: `postgres://postgres@${server.getServerConn()}/postgres`, + max: 1, + }), + ), + (pool) => Effect.promise(() => pool.end()), + ); + + return yield* use(pool); + }), + ), +); diff --git a/packages/domain/runtime/tutor-d1-proof-main.ts b/packages/domain/runtime/tutor-d1-proof-main.ts index 07fe8e83a..85043f807 100644 --- a/packages/domain/runtime/tutor-d1-proof-main.ts +++ b/packages/domain/runtime/tutor-d1-proof-main.ts @@ -271,7 +271,7 @@ const rowCounts = (db: LocalBinding): Effect.Effect => /** One disposable Miniflare D1 binding per journey run; every case resets its schema. */ class ProofRuntime extends Context.Service()( - "@vektorprogrammet/domain/runtime/ProofRuntime", + "@vektorprogrammet/domain/runtime/tutor-d1-proof-main/ProofRuntime", ) {} const ProofRuntimeLive = Layer.effect( diff --git a/packages/domain/runtime/tutor-d1.ts b/packages/domain/runtime/tutor-d1.ts index 0a9a03b70..9d1efed89 100644 --- a/packages/domain/runtime/tutor-d1.ts +++ b/packages/domain/runtime/tutor-d1.ts @@ -1,6 +1,8 @@ import { SqlError } from "effect/unstable/sql/SqlError"; import * as D1Client from "@effect/sql-d1/D1Client"; import { Data, Record, flow, Result, Predicate, Effect, Schema } from "effect"; +import type { ConfigError } from "effect/Config"; +import { dual } from "effect/Function"; import { canonicalJsonBytes, canonicalJson, sha256Hex } from "../src/shared-kernel/index.js"; import { ConductInterviewV1Schema, @@ -547,11 +549,13 @@ const decodeReplayEvent = ( return event; }); -export const validateReplayRows = ( - requestedStream: StreamKey, - rows: ReadonlyArray, -): Effect.Effect => - Effect.gen(function* () { +export const validateReplayRows: { + (rows: ReadonlyArray): (requestedStream: StreamKey) => Effect.Effect; + (requestedStream: StreamKey, rows: ReadonlyArray): Effect.Effect; +} = dual( + 2, + (requestedStream: StreamKey, rows: ReadonlyArray): Effect.Effect => + Effect.gen(function* () { const events: Array = []; for (const [index, row] of rows.entries()) { @@ -576,7 +580,8 @@ export const validateReplayRows = ( ); return { rows, events, folded }; - }); + }), +); const readStream = ( d1: D1Client.D1Client, @@ -644,13 +649,13 @@ const readReceipt = ( }; }); -export const buildBatchPlan = ( - command: ConductInterviewV1, - event: EventEnvelopeV1, - resultBytes: Uint8Array, - descriptorBytes: Uint8Array, - expectedLastCommandId: string, -): BatchPlan => { +export const buildBatchPlan: { + (event: EventEnvelopeV1, resultBytes: Uint8Array, descriptorBytes: Uint8Array, expectedLastCommandId: string): (command: ConductInterviewV1) => BatchPlan; + (command: ConductInterviewV1, event: EventEnvelopeV1, resultBytes: Uint8Array, descriptorBytes: Uint8Array, expectedLastCommandId: string): BatchPlan; +} = dual( + 5, + (command: ConductInterviewV1, event: EventEnvelopeV1, resultBytes: Uint8Array, descriptorBytes: Uint8Array, expectedLastCommandId: string): BatchPlan => + { const stream = command.stream; const commandBytes = canonicalJsonBytes(command); const streamValues = streamBinds(stream); @@ -694,7 +699,8 @@ export const buildBatchPlan = ( }, ], }; -}; +}, +); const classifyBatchFailure = ( d1: D1Client.D1Client, @@ -890,10 +896,22 @@ export const tutorD1Store = Effect.gen(function* () { export type TutorD1Store = Effect.Success; -export const runWithTutorD1 = ( - db: D1Binding, - effect: Effect.Effect, -) => Effect.scoped(effect.pipe(Effect.provide(D1Client.layer({ db })))); +export const runWithTutorD1: { + ( + effect: Effect.Effect, + ): (db: D1Binding) => Effect.Effect; + ( + db: D1Binding, + effect: Effect.Effect, + ): Effect.Effect; +} = dual( + 2, + ( + db: D1Binding, + effect: Effect.Effect, + ): Effect.Effect => + Effect.scoped(effect.pipe(Effect.provide(D1Client.layer({ db })))), +); export const decodePersistedResult = flow(normalizeBlobBytes, (bytes): Schema.Json => { const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); @@ -915,4 +933,7 @@ export const canonicalCommand = canonicalJson; export const streamKeyBinds = streamBinds; -export const streamKeysEqual = streamEqual; +export const streamKeysEqual: { + (right: StreamKey): (left: StreamKey) => boolean; + (left: StreamKey, right: StreamKey): boolean; +} = dual(2, streamEqual); diff --git a/packages/domain/src/admission-period/effects.ts b/packages/domain/src/admission-period/effects.ts index 4fbe1eab3..9f06af7a1 100644 --- a/packages/domain/src/admission-period/effects.ts +++ b/packages/domain/src/admission-period/effects.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import { dual } from "effect/Function"; import { admissionPeriodCommandDigest } from "./digest.js"; import { AdmissionPeriodCommandId, @@ -21,16 +22,27 @@ export const AdmissionPeriodOutboxRequestSchema = Schema.TaggedUnion({ export type AdmissionPeriodOutboxRequest = typeof AdmissionPeriodOutboxRequestSchema.Type; -export const admissionPeriodOutboxRequest = ( - commandId: AdmissionPeriodCommandId, - period: typeof AdmissionPeriodSchema.Type, -): AdmissionPeriodOutboxRequest => - AdmissionPeriodOutboxRequestSchema.cases.PublishAdmissionPeriodChanged.make({ - effectId: AdmissionPeriodEffectId.make( - `admission-period:${admissionPeriodCommandDigest({ commandId, periodId: period.id, revision: period.revision })}`, - ), - commandId, - admissionPeriodId: period.id, - revision: period.revision, - period, - }); +export const admissionPeriodOutboxRequest: { + ( + period: typeof AdmissionPeriodSchema.Type, + ): (commandId: AdmissionPeriodCommandId) => AdmissionPeriodOutboxRequest; + ( + commandId: AdmissionPeriodCommandId, + period: typeof AdmissionPeriodSchema.Type, + ): AdmissionPeriodOutboxRequest; +} = dual( + 2, + ( + commandId: AdmissionPeriodCommandId, + period: typeof AdmissionPeriodSchema.Type, + ): AdmissionPeriodOutboxRequest => + AdmissionPeriodOutboxRequestSchema.cases.PublishAdmissionPeriodChanged.make({ + effectId: AdmissionPeriodEffectId.make( + `admission-period:${admissionPeriodCommandDigest({ commandId, periodId: period.id, revision: period.revision })}`, + ), + commandId, + admissionPeriodId: period.id, + revision: period.revision, + period, + }), +); diff --git a/packages/domain/src/admission-period/proof.ts b/packages/domain/src/admission-period/proof.ts index b46c87752..a1b53ecf2 100644 --- a/packages/domain/src/admission-period/proof.ts +++ b/packages/domain/src/admission-period/proof.ts @@ -1,4 +1,5 @@ import { Predicate, Effect } from "effect"; +import { dual } from "effect/Function"; import { DepartmentId, PersonId, SemesterId } from "../organization/schema.js"; import { admissionPeriodCommandDigest } from "./digest.js"; import { decideAdmissionPeriod } from "./update.js"; @@ -31,21 +32,31 @@ export interface AdmissionPeriodProofEvidence { }; } -export const admissionPeriodIsEligible = ( - period: AdmissionPeriod, - semesterStartAt: string, - semesterEndAt: string, - now: string, -): boolean => { - const instant = Date.parse(now); +export const admissionPeriodIsEligible: { + ( + semesterStartAt: string, + semesterEndAt: string, + now: string, + ): (period: AdmissionPeriod) => boolean; + (period: AdmissionPeriod, semesterStartAt: string, semesterEndAt: string, now: string): boolean; +} = dual( + 4, + ( + period: AdmissionPeriod, + semesterStartAt: string, + semesterEndAt: string, + now: string, + ): boolean => { + const instant = Date.parse(now); - return ( - Date.parse(semesterStartAt) <= instant && - instant < Date.parse(semesterEndAt) && - Date.parse(period.startAt) <= instant && - instant < Date.parse(period.endAt) - ); -}; + return ( + Date.parse(semesterStartAt) <= instant && + instant < Date.parse(semesterEndAt) && + Date.parse(period.startAt) <= instant && + instant < Date.parse(period.endAt) + ); + }, +); const proofActor: AdmissionPeriodActor = AdmissionPeriodActorSchema.cases.DepartmentAdministrator.make({ diff --git a/packages/domain/src/admission-period/schema.ts b/packages/domain/src/admission-period/schema.ts index ba0ef3743..458c9e754 100644 --- a/packages/domain/src/admission-period/schema.ts +++ b/packages/domain/src/admission-period/schema.ts @@ -240,24 +240,3 @@ export type AdmissionPeriodObservation = typeof AdmissionPeriodObservationSchema export const AdmissionPeriodListSchema = Schema.Array(AdmissionPeriodProjectionSchema); export type AdmissionPeriodList = typeof AdmissionPeriodListSchema.Type; - -export const decodeAdmissionPeriodCommand = Schema.decodeUnknownEffect( - AdmissionPeriodCommandSchema, - { - onExcessProperty: "error", - }, -); - -export const decodeCreateAdmissionPeriodInput = Schema.decodeUnknownEffect( - CreateAdmissionPeriodInputSchema, - { - onExcessProperty: "error", - }, -); - -export const decodeReviseAdmissionPeriodInput = Schema.decodeUnknownEffect( - ReviseAdmissionPeriodInputSchema, - { - onExcessProperty: "error", - }, -); diff --git a/packages/domain/src/admission-period/update.ts b/packages/domain/src/admission-period/update.ts index 21036e048..eef008007 100644 --- a/packages/domain/src/admission-period/update.ts +++ b/packages/domain/src/admission-period/update.ts @@ -1,4 +1,5 @@ import { Predicate, Effect, Struct } from "effect"; +import { dual } from "effect/Function"; import type { DepartmentId } from "../organization/schema.js"; import { compareRfc3339Instants, normalizeRfc3339Instant } from "../time.js"; import { @@ -44,7 +45,7 @@ export interface AdmissionPeriodDecision { } const activeActor = (actor: AdmissionPeriodActor): Effect.Effect => - actor.active ? Effect.void : Effect.fail(new InactiveActor({ personId: actor.personId })); + actor.active ? Effect.void : Effect.fail(InactiveActor.make({ personId: actor.personId })); const managementActor = ( actor: AdmissionPeriodActor, @@ -56,7 +57,7 @@ const managementActor = ( !Predicate.isTagged(actor, "DepartmentAdministrator") && !Predicate.isTagged(actor, "GlobalAdmin") ) { - return yield* new AdmissionRoleDenied({ personId: actor.personId }); + return yield* AdmissionRoleDenied.make({ personId: actor.personId }); } }); @@ -67,7 +68,7 @@ const departmentForCreate = ( if (Predicate.isTagged(actor, "DepartmentAdministrator")) { if (command.departmentId !== undefined && command.departmentId !== actor.departmentId) { return Effect.fail( - new AdmissionScopeDenied({ + AdmissionScopeDenied.make({ personId: actor.personId, departmentId: command.departmentId, }), @@ -77,7 +78,7 @@ const departmentForCreate = ( return Effect.succeed(actor.departmentId); } - if (command.departmentId === undefined) return Effect.fail(new DepartmentRequired()); + if (command.departmentId === undefined) return Effect.fail(DepartmentRequired.make({})); return Effect.succeed(command.departmentId); }; @@ -90,12 +91,14 @@ const checkWindow = ( const ordering = compareRfc3339Instants(startAt, endAt); if (ordering === 0) { - return Effect.fail(new InvalidAdmissionPeriodWindow({ startAt, endAt, reason: "EqualBounds" })); + return Effect.fail( + InvalidAdmissionPeriodWindow.make({ startAt, endAt, reason: "EqualBounds" }), + ); } if (ordering > 0) { return Effect.fail( - new InvalidAdmissionPeriodWindow({ startAt, endAt, reason: "ReversedBounds" }), + InvalidAdmissionPeriodWindow.make({ startAt, endAt, reason: "ReversedBounds" }), ); } @@ -104,7 +107,7 @@ const checkWindow = ( compareRfc3339Instants(endAt, semester.endAt) > 0 ) { return Effect.fail( - new AdmissionWindowOutsideSemester({ + AdmissionWindowOutsideSemester.make({ semesterId: semester.semesterId, startAt, endAt, @@ -138,100 +141,128 @@ const revisedObservation = ( ): AdmissionPeriodObservation => AdmissionPeriodObservationSchema.cases.Revised.make({ commandId, period }); -export const decideAdmissionPeriod = ( - existing: AdmissionPeriod | undefined, - command: AdmissionPeriodCommand, - context: AdmissionPeriodDecisionContext, -): Effect.Effect => - Effect.gen(function* () { - yield* managementActor(context.actor); +export const decideAdmissionPeriod: { + ( + command: AdmissionPeriodCommand, + context: AdmissionPeriodDecisionContext, + ): ( + existing: AdmissionPeriod | undefined, + ) => Effect.Effect; + ( + existing: AdmissionPeriod | undefined, + command: AdmissionPeriodCommand, + context: AdmissionPeriodDecisionContext, + ): Effect.Effect; +} = dual( + 3, + ( + existing: AdmissionPeriod | undefined, + command: AdmissionPeriodCommand, + context: AdmissionPeriodDecisionContext, + ): Effect.Effect => + Effect.gen(function* () { + yield* managementActor(context.actor); + + if (!isRfc3339Instant(context.now)) { + return yield* InvalidAdmissionPeriodWindow.make({ + startAt: context.now, + endAt: context.now, + reason: "EqualBounds", + }); + } - if (!isRfc3339Instant(context.now)) { - return yield* new InvalidAdmissionPeriodWindow({ - startAt: context.now, - endAt: context.now, - reason: "EqualBounds", - }); - } + if (Predicate.isTagged(command, "CreateAdmissionPeriod")) { + const departmentId = yield* departmentForCreate(command, context.actor); + yield* checkWindow(command.startAt, command.endAt, context.semester); - if (Predicate.isTagged(command, "CreateAdmissionPeriod")) { - const departmentId = yield* departmentForCreate(command, context.actor); - yield* checkWindow(command.startAt, command.endAt, context.semester); + if (existing !== undefined) { + return yield* AdmissionPeriodAlreadyExists.make({ + departmentId, + semesterId: command.semesterId, + }); + } - if (existing !== undefined) { - return yield* new AdmissionPeriodAlreadyExists({ + const period: AdmissionPeriod = { + id: periodIdForCreate(command, context), departmentId, semesterId: command.semesterId, + startAt: normalizedInstant(command.startAt), + endAt: normalizedInstant(command.endAt), + revision: 0, + lastCommandId: command.commandId, + }; + + return { + period, + observation: createdObservation(command.commandId, period), + outbox: [admissionPeriodOutboxRequest(command.commandId, period)], + auditAction: "AdmissionPeriodCreated" as const, + }; + } + + const current = existing; + + if (current === undefined) { + return yield* AdmissionPeriodNotFound.make({ + admissionPeriodId: command.admissionPeriodId, + }); + } + + const actorDepartment = Predicate.isTagged(context.actor, "DepartmentAdministrator") + ? context.actor.departmentId + : current.departmentId; + + if (actorDepartment !== current.departmentId) { + return yield* AdmissionScopeDenied.make({ + personId: context.actor.personId, + departmentId: current.departmentId, + admissionPeriodId: current.id, + }); + } + + if (current.revision !== command.expectedRevision) { + return yield* StaleAdmissionPeriodRevision.make({ + admissionPeriodId: current.id, + expected: command.expectedRevision, + actual: current.revision, }); } - const period: AdmissionPeriod = { - id: periodIdForCreate(command, context), - departmentId, - semesterId: command.semesterId, + yield* checkWindow(command.startAt, command.endAt, context.semester); + + const period: AdmissionPeriod = Struct.assign(current, { startAt: normalizedInstant(command.startAt), endAt: normalizedInstant(command.endAt), - revision: 0, + revision: current.revision + 1, lastCommandId: command.commandId, - }; + }); return { period, - observation: createdObservation(command.commandId, period), + observation: revisedObservation(command.commandId, period), outbox: [admissionPeriodOutboxRequest(command.commandId, period)], - auditAction: "AdmissionPeriodCreated" as const, + auditAction: "AdmissionPeriodRevised" as const, }; - } - - const current = existing; - - if (current === undefined) { - return yield* new AdmissionPeriodNotFound({ admissionPeriodId: command.admissionPeriodId }); - } - - const actorDepartment = Predicate.isTagged(context.actor, "DepartmentAdministrator") - ? context.actor.departmentId - : current.departmentId; - - if (actorDepartment !== current.departmentId) { - return yield* new AdmissionScopeDenied({ - personId: context.actor.personId, - departmentId: current.departmentId, - admissionPeriodId: current.id, - }); - } - - if (current.revision !== command.expectedRevision) { - return yield* new StaleAdmissionPeriodRevision({ - admissionPeriodId: current.id, - expected: command.expectedRevision, - actual: current.revision, - }); - } - - yield* checkWindow(command.startAt, command.endAt, context.semester); - - const period: AdmissionPeriod = Struct.assign(current, { - startAt: normalizedInstant(command.startAt), - endAt: normalizedInstant(command.endAt), - revision: current.revision + 1, - lastCommandId: command.commandId, - }); - - return { - period, - observation: revisedObservation(command.commandId, period), - outbox: [admissionPeriodOutboxRequest(command.commandId, period)], - auditAction: "AdmissionPeriodRevised" as const, - }; - }); - -export const contextForActor = ( - context: AdmissionPeriodCommandContext, - semester: AdmissionSemester, -): AdmissionPeriodDecisionContext => ({ - actor: context.actor, - now: context.now, - admissionPeriodId: context.admissionPeriodId, - semester, -}); + }), +); + +export const contextForActor: { + ( + semester: AdmissionSemester, + ): (context: AdmissionPeriodCommandContext) => AdmissionPeriodDecisionContext; + ( + context: AdmissionPeriodCommandContext, + semester: AdmissionSemester, + ): AdmissionPeriodDecisionContext; +} = dual( + 2, + ( + context: AdmissionPeriodCommandContext, + semester: AdmissionSemester, + ): AdmissionPeriodDecisionContext => ({ + actor: context.actor, + now: context.now, + admissionPeriodId: context.admissionPeriodId, + semester, + }), +); diff --git a/packages/domain/src/admissions/outcome.ts b/packages/domain/src/admissions/outcome.ts index 8b3b19402..6f197d1bb 100644 --- a/packages/domain/src/admissions/outcome.ts +++ b/packages/domain/src/admissions/outcome.ts @@ -1,4 +1,5 @@ import { Data, Predicate, Schema } from "effect"; +import { dual } from "effect/Function"; import { AdmissionPeriodId } from "../admission-period/schema.js"; import { PublicApplicationEmailSchema, @@ -100,22 +101,33 @@ export type AdmissionOutcomeOperationFailure = * read only the substitutes on call. Uses the canonical department mapping, so multi-membership * and ended-grant semantics match admission management. */ -export const admissionOutcomePermission = ( - authority: OrganizationPersonAuthority, - departmentId: DepartmentId, -): "Denied" | "ReadOnly" | "Decide" => { - const decision = mapOrganizationAuthorityToDepartmentActor( - authority, - "admissions.outcomes", - departmentId, - ); - - return Predicate.isTagged(decision, "Deny") - ? "Denied" - : Predicate.isTagged(decision.value, "Member") - ? "ReadOnly" - : "Decide"; -}; +export const admissionOutcomePermission: { + ( + departmentId: DepartmentId, + ): (authority: OrganizationPersonAuthority) => "Denied" | "ReadOnly" | "Decide"; + ( + authority: OrganizationPersonAuthority, + departmentId: DepartmentId, + ): "Denied" | "ReadOnly" | "Decide"; +} = dual( + 2, + ( + authority: OrganizationPersonAuthority, + departmentId: DepartmentId, + ): "Denied" | "ReadOnly" | "Decide" => { + const decision = mapOrganizationAuthorityToDepartmentActor( + authority, + "admissions.outcomes", + departmentId, + ); + + return Predicate.isTagged(decision, "Deny") + ? "Denied" + : Predicate.isTagged(decision.value, "Member") + ? "ReadOnly" + : "Decide"; + }, +); /** A department member sees only the name and contact of a substitute on call. */ export const onCallSubstitutes = ( diff --git a/packages/domain/src/admissions/service.ts b/packages/domain/src/admissions/service.ts index ade0f62d1..0834a84f3 100644 --- a/packages/domain/src/admissions/service.ts +++ b/packages/domain/src/admissions/service.ts @@ -10,7 +10,7 @@ import { type AdmissionPeriodCommand, } from "../admission-period/schema.js"; import type { OrganizationPersonAuthority } from "../organization/authority.js"; -import type { PersonId } from "../organization/schema.js"; +import type { DepartmentReach } from "../authz/reach.js"; import type { AdmissionOutcomeBoard, AdmissionOutcomeCommand, @@ -86,7 +86,8 @@ export interface AdmissionsOperations { input: { readonly applicationId: AdmissionOutcomeEntry["applicationId"]; readonly command: AdmissionOutcomeCommand; - readonly actor: PersonId; + /** Reach over the application's department; another department's evidence is denied. */ + readonly decider: DepartmentReach<"admissions.outcomes">; readonly now: string; }, checkPrecondition: (current: AdmissionOutcomeEntry) => Effect.Effect, @@ -94,5 +95,5 @@ export interface AdmissionsOperations { } export class Admissions extends Context.Service()( - "@vektorprogrammet/domain/Admissions", + "@vektorprogrammet/domain/admissions/service/Admissions", ) {} diff --git a/packages/domain/src/application/effects.ts b/packages/domain/src/application/effects.ts index e199aba5c..b0f59a04c 100644 --- a/packages/domain/src/application/effects.ts +++ b/packages/domain/src/application/effects.ts @@ -1,4 +1,5 @@ import { Predicate, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { publicApplicationCommandDigest } from "./digest.js"; import { ApplicantIdSchema, @@ -153,53 +154,73 @@ export interface PublicApplicationRecordingInterpreter extends PublicApplication const effectKindOf = (request: PublicApplicationOutboxRequest): PublicApplicationEffectKind => request._tag; -export const makePublicApplicationOutboxRequests = ( - command: PublicApplicationSubmitInput | SubmitPublicApplicationCommand, - application: PublicApplication, - applicant: Applicant, - email: string, - activationToken?: string, -): ReadonlyArray => { - const commandDigest = publicApplicationCommandDigest(command); - - const shared = { - commandId: command.commandId, - applicationId: application.id, - applicantId: applicant.id, - } as const; - - const activationFields = { - effectId: PublicApplicationEffectIdSchema.make( - `public-application:${commandDigest}:activation`, - ), - ...shared, - email, - }; - - const activation: PublicApplicationOutboxRequest = - PublicApplicationOutboxRequestSchemaInternal.cases.SendApplicantActivationOrConfirmation.make( - activationToken === undefined ? activationFields : { ...activationFields, activationToken }, - ); - - const subscription: PublicApplicationOutboxRequest = - PublicApplicationOutboxRequestSchemaInternal.cases.CreateAdmissionSubscription.make({ +export const makePublicApplicationOutboxRequests: { + ( + application: PublicApplication, + applicant: Applicant, + email: string, + activationToken?: string, + ): ( + command: PublicApplicationSubmitInput | SubmitPublicApplicationCommand, + ) => ReadonlyArray; + ( + command: PublicApplicationSubmitInput | SubmitPublicApplicationCommand, + application: PublicApplication, + applicant: Applicant, + email: string, + activationToken?: string, + ): ReadonlyArray; +} = dual( + // The applicant is the third argument of the data-first call; the data-last call has the email there. + (args) => Predicate.isObject(args[2]), + ( + command: PublicApplicationSubmitInput | SubmitPublicApplicationCommand, + application: PublicApplication, + applicant: Applicant, + email: string, + activationToken?: string, + ): ReadonlyArray => { + const commandDigest = publicApplicationCommandDigest(command); + + const shared = { + commandId: command.commandId, + applicationId: application.id, + applicantId: applicant.id, + } as const; + + const activationFields = { effectId: PublicApplicationEffectIdSchema.make( - `public-application:${commandDigest}:subscription`, + `public-application:${commandDigest}:activation`, ), ...shared, email, - departmentId: application.departmentId, - }); - - const audit: PublicApplicationOutboxRequest = - PublicApplicationOutboxRequestSchemaInternal.cases.WriteApplicationAudit.make({ - effectId: PublicApplicationEffectIdSchema.make(`public-application:${commandDigest}:audit`), - ...shared, - action: "PublicApplicationSubmitted", - }); + }; - return [activation, subscription, audit]; -}; + const activation: PublicApplicationOutboxRequest = + PublicApplicationOutboxRequestSchemaInternal.cases.SendApplicantActivationOrConfirmation.make( + activationToken === undefined ? activationFields : { ...activationFields, activationToken }, + ); + + const subscription: PublicApplicationOutboxRequest = + PublicApplicationOutboxRequestSchemaInternal.cases.CreateAdmissionSubscription.make({ + effectId: PublicApplicationEffectIdSchema.make( + `public-application:${commandDigest}:subscription`, + ), + ...shared, + email, + departmentId: application.departmentId, + }); + + const audit: PublicApplicationOutboxRequest = + PublicApplicationOutboxRequestSchemaInternal.cases.WriteApplicationAudit.make({ + effectId: PublicApplicationEffectIdSchema.make(`public-application:${commandDigest}:audit`), + ...shared, + action: "PublicApplicationSubmitted", + }); + + return [activation, subscription, audit]; + }, +); export interface ReturningAssistantOutboxInput { readonly commandId: PublicApplicationCommandId; @@ -268,7 +289,7 @@ export const makeRecordingPublicApplicationEffectInterpreter = attempts.set(request.effectId, nextAttempts); if (failedOnce.delete(request.effectId)) { - return yield* new PublicApplicationEffectDeliveryError({ effectId: request.effectId }); + return yield* PublicApplicationEffectDeliveryError.make({ effectId: request.effectId }); } const previous = delivered.get(request.effectId); @@ -294,13 +315,32 @@ export const makeRecordingPublicApplicationEffectInterpreter = }; }; -export const recordPublicApplicationEffects = ( - requests: ReadonlyArray, - interpreter = makeRecordingPublicApplicationEffectInterpreter(), -): Effect.Effect< - ReadonlyArray, - PublicApplicationEffectDeliveryError -> => Effect.forEach(requests, (request, ordinal) => interpreter.deliver(request, ordinal, 1)); +export const recordPublicApplicationEffects: { + ( + interpreter?: PublicApplicationRecordingInterpreter, + ): ( + requests: ReadonlyArray, + ) => Effect.Effect< + ReadonlyArray, + PublicApplicationEffectDeliveryError + >; + ( + requests: ReadonlyArray, + interpreter?: PublicApplicationRecordingInterpreter, + ): Effect.Effect< + ReadonlyArray, + PublicApplicationEffectDeliveryError + >; +} = dual( + (args) => Array.isArray(args[0]), + ( + requests: ReadonlyArray, + interpreter: PublicApplicationRecordingInterpreter = makeRecordingPublicApplicationEffectInterpreter(), + ): Effect.Effect< + ReadonlyArray, + PublicApplicationEffectDeliveryError + > => Effect.forEach(requests, (request, ordinal) => interpreter.deliver(request, ordinal, 1)), +); export interface PublicApplicationRecordingProof { readonly specId: "0039"; diff --git a/packages/domain/src/application/returning.ts b/packages/domain/src/application/returning.ts index 62b3adf93..b759c9ec6 100644 --- a/packages/domain/src/application/returning.ts +++ b/packages/domain/src/application/returning.ts @@ -202,4 +202,4 @@ export interface ReturningAssistantOperations { export class ReturningAssistants extends Context.Service< ReturningAssistants, ReturningAssistantOperations ->()("@vektorprogrammet/domain/ReturningAssistants") {} +>()("@vektorprogrammet/domain/application/returning/ReturningAssistants") {} diff --git a/packages/domain/src/application/schema.ts b/packages/domain/src/application/schema.ts index 449193e76..83a7223e0 100644 --- a/packages/domain/src/application/schema.ts +++ b/packages/domain/src/application/schema.ts @@ -366,12 +366,9 @@ export const ApplicantProgressResponseSchema = Schema.Struct({ export type ApplicantProgressResponse = typeof ApplicantProgressResponseSchema.Type; -export const decodeApplicantProgressResponse = Schema.decodeUnknownSync( - ApplicantProgressResponseSchema, - { - onExcessProperty: "error", - }, -); +/** Decodes JSON data into an applicant progress response, rejecting properties the schema does not name. */ +export const decodeApplicantProgressResponse = (input: Schema.Json): ApplicantProgressResponse => + Schema.decodeUnknownSync(ApplicantProgressResponseSchema)(input, { onExcessProperty: "error" }); export const PublicApplicationFieldOfStudySchema = Schema.Struct({ fieldOfStudyId: AdmissionFieldOfStudy.json.fields.fieldOfStudyId, @@ -423,10 +420,3 @@ export interface PublicApplicationSubmitResult { readonly replayed: boolean; readonly outboxCount: number; } - -export const decodePublicApplicationConfirmation = Schema.decodeUnknownEffect( - PublicApplicationConfirmationSchema, - { - onExcessProperty: "error", - }, -); diff --git a/packages/domain/src/application/validation.ts b/packages/domain/src/application/validation.ts index df1b6315e..509fafb27 100644 --- a/packages/domain/src/application/validation.ts +++ b/packages/domain/src/application/validation.ts @@ -13,7 +13,7 @@ import { import { DepartmentId } from "../organization/schema.js"; import { AdmissionFieldOfStudyId } from "../admission-period/schema.js"; -const invalidInput = new PublicApplicationDecodeError({ +const invalidInput = PublicApplicationDecodeError.make({ message: "invalid public application input", }); @@ -75,7 +75,7 @@ export const decodePublicApplicationNow = flow( Schema.decodeUnknownEffect( Schema.String.pipe(Schema.check(Schema.makeFilter(isPublicApplicationInstant))), ), - Effect.mapError( - () => new PublicApplicationDecodeError({ message: "invalid public application time" }), + Effect.mapError(() => + PublicApplicationDecodeError.make({ message: "invalid public application time" }), ), ); diff --git a/packages/domain/src/authz/AGENTS.md b/packages/domain/src/authz/AGENTS.md index 194588c1a..034297832 100644 --- a/packages/domain/src/authz/AGENTS.md +++ b/packages/domain/src/authz/AGENTS.md @@ -70,6 +70,12 @@ No upstream context. | -------------------------------- | -------------------- | | `@vektorprogrammet/domain/authz` | [index.ts](index.ts) | +## Constructs + +The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../../../docs/constructs.md) indexes them all. + +- [`requireDepartmentReach`](../../../../docs/constructs/authority-evidence.md#requiredepartmentreach) (authority-evidence): Checks that a resolved authority reaches one department with a capability, and returns the evidence that a department-scoped command requires. + Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. [//]: # "guide: end" diff --git a/packages/domain/src/authz/access.ts b/packages/domain/src/authz/access.ts index f35c43d21..d4ba4967b 100644 --- a/packages/domain/src/authz/access.ts +++ b/packages/domain/src/authz/access.ts @@ -1,5 +1,6 @@ import { isKnownSelfInterview } from "../recruitment/applicant-identity.js"; import { Record, flow, Result, Array, Match, Data, Effect, Predicate, Schema } from "effect"; +import { dual } from "effect/Function"; import { DepartmentId, PersonId } from "../organization/schema.js"; import { RecruitmentInterviewId, @@ -966,24 +967,15 @@ export const AccessSpecSchema = Schema.Struct({ export type AccessSpec = typeof AccessSpecSchema.Type; -const mechanismPrincipalKind = (mechanism: CredentialMechanism): PrincipalKind => { - return Match.value(mechanism).pipe( +const mechanismPrincipalKind = (mechanism: CredentialMechanism): PrincipalKind => + Match.value(mechanism).pipe( Match.withReturnType(), - Match.tag("None", () => { - return "Anonymous"; - }), - Match.tag("BetterAuthCookie", "OAuthUserBearer", () => { - return "Person"; - }), - Match.tag("OAuthServiceBearer", () => { - return "ServicePrincipal"; - }), - Match.tag("ObjectCapability", () => { - return "CapabilityHolder"; - }), + Match.tag("None", () => "Anonymous"), + Match.tag("BetterAuthCookie", "OAuthUserBearer", () => "Person"), + Match.tag("OAuthServiceBearer", () => "ServicePrincipal"), + Match.tag("ObjectCapability", () => "CapabilityHolder"), Match.exhaustive, ); -}; const sameCredentialMechanism = (left: CredentialMechanism, right: CredentialMechanism): boolean => Predicate.isTagged(left, right._tag) && @@ -996,30 +988,36 @@ const sameCredentialMechanism = (left: CredentialMechanism, right: CredentialMec * credential names the principal kind that mechanism resolves. This is the credential stage of * every access evaluation; a handler that authorizes in parts uses it for the same answer. */ -export const credentialMatchesAccessSpec = ( - spec: AccessSpec, - credential: Extract, -): boolean => - mechanismPrincipalKind(credential.mechanism) === credential.principal._tag && - spec.acceptedCredentials.some((accepted) => - sameCredentialMechanism(accepted, credential.mechanism), - ); +export const credentialMatchesAccessSpec: { + ( + credential: Extract, + ): (spec: AccessSpec) => boolean; + ( + spec: AccessSpec, + credential: Extract, + ): boolean; +} = dual( + 2, + ( + spec: AccessSpec, + credential: Extract, + ): boolean => + mechanismPrincipalKind(credential.mechanism) === credential.principal._tag && + spec.acceptedCredentials.some((accepted) => + sameCredentialMechanism(accepted, credential.mechanism), + ), +); -const capabilityTypesIn = (expression: CapabilityExpression): ReadonlyArray => { - return Match.value(expression).pipe( +const capabilityTypesIn = (expression: CapabilityExpression): ReadonlyArray => + Match.value(expression).pipe( Match.withReturnType>(), - Match.tag("None", () => { - return []; - }), - Match.tag("One", (expression) => { - return [expression.capability.type]; - }), - Match.tag("All", "Any", (expression) => { - return expression.capabilities.map((capability) => capability.type); - }), + Match.tag("None", () => []), + Match.tag("One", (expression) => [expression.capability.type]), + Match.tag("All", "Any", (expression) => + expression.capabilities.map((capability) => capability.type), + ), Match.exhaustive, ); -}; export const scopeResolverDeclaration = (resolverId: ScopeResolverId): ScopeResolverRegistration => SCOPE_RESOLVERS[scopeResolverRegistryKey(resolverId)]; @@ -1158,64 +1156,71 @@ export const normalizeScope = (scope: Scope): Scope => { export const decodeGrant = flow( Schema.decodeUnknownSync(GrantSchema, { onExcessProperty: "error" }), - (grant): Grant => { - return { ...grant, scope: normalizeScope(grant.scope) }; - }, + (grant): Grant => ({ ...grant, scope: normalizeScope(grant.scope) }), ); -export const expandAuthorityMacros = ( - directGrants: ReadonlyArray, - roles: ReadonlyArray, -): ReadonlyArray => [...directGrants, ...roles.flatMap((role) => role.grants)]; +export const expandAuthorityMacros: { + (roles: ReadonlyArray): (directGrants: ReadonlyArray) => ReadonlyArray; + (directGrants: ReadonlyArray, roles: ReadonlyArray): ReadonlyArray; +} = dual( + 2, + (directGrants: ReadonlyArray, roles: ReadonlyArray): ReadonlyArray => [ + ...directGrants, + ...roles.flatMap((role) => role.grants), + ], +); -export const scopeMatches = (scope: Scope, context: CanonicalResourceContext): boolean => { - return Match.value(scope).pipe( +export const scopeMatches: { + (context: CanonicalResourceContext): (scope: Scope) => boolean; + (scope: Scope, context: CanonicalResourceContext): boolean; +} = dual(2, (scope: Scope, context: CanonicalResourceContext): boolean => + Match.value(scope).pipe( Match.withReturnType(), - Match.tag("Global", () => { - return true; - }), - Match.tag("Domain", (scope) => { - return scope.domainId === context.domainId; - }), - Match.tag("Department", (scope) => { - return context.departmentId !== null && scope.departmentId === context.departmentId; - }), - Match.tag("Resource", (scope) => { - return ( + Match.tag("Global", () => true), + Match.tag("Domain", (scope) => scope.domainId === context.domainId), + Match.tag( + "Department", + (scope) => context.departmentId !== null && scope.departmentId === context.departmentId, + ), + Match.tag( + "Resource", + (scope) => context.resource !== null && scope.resource.kind === context.resource.kind && - scope.resource.id === context.resource.id - ); - }), - Match.tag("And", (scope) => { - return scopeMatches(scope.left, context) && scopeMatches(scope.right, context); - }), - Match.tag("Or", (scope) => { - return scopeMatches(scope.left, context) || scopeMatches(scope.right, context); - }), + scope.resource.id === context.resource.id, + ), + Match.tag( + "And", + (scope) => scopeMatches(scope.left, context) && scopeMatches(scope.right, context), + ), + Match.tag( + "Or", + (scope) => scopeMatches(scope.left, context) || scopeMatches(scope.right, context), + ), Match.exhaustive, - ); -}; + ), +); const samePrincipal = (left: NonAnonymousPrincipal, right: Principal): boolean => { if (!Predicate.isTagged(left, right._tag)) return false; return Match.value(left).pipe( Match.withReturnType(), - Match.tag("Person", (left) => { - return Predicate.isTagged(right, "Person") && left.personId === right.personId; - }), - Match.tag("ServicePrincipal", (left) => { - return ( + Match.tag( + "Person", + (left) => Predicate.isTagged(right, "Person") && left.personId === right.personId, + ), + Match.tag( + "ServicePrincipal", + (left) => Predicate.isTagged(right, "ServicePrincipal") && - left.servicePrincipalId === right.servicePrincipalId - ); - }), - Match.tag("CapabilityHolder", (left) => { - return ( - Predicate.isTagged(right, "CapabilityHolder") && left.capabilityId === right.capabilityId - ); - }), + left.servicePrincipalId === right.servicePrincipalId, + ), + Match.tag( + "CapabilityHolder", + (left) => + Predicate.isTagged(right, "CapabilityHolder") && left.capabilityId === right.capabilityId, + ), Match.exhaustive, ); }; @@ -1224,23 +1229,36 @@ const activeAt = (grant: Grant, instant: AuthorizationInstant): boolean => compareRfc3339Instants(grant.startAt, instant) <= 0 && (grant.endAt === null || compareRfc3339Instants(instant, grant.endAt) < 0); -export const evaluateRequirement = ( - requirement: TypedRequirement, - principal: Principal, - context: CanonicalResourceContext, -): RequirementResult => { - const registration = REQUIREMENT_TYPES[requirementRegistryKey(requirement.id)]; +export const evaluateRequirement: { + ( + principal: Principal, + context: CanonicalResourceContext, + ): (requirement: TypedRequirement) => RequirementResult; + ( + requirement: TypedRequirement, + principal: Principal, + context: CanonicalResourceContext, + ): RequirementResult; +} = dual( + 3, + ( + requirement: TypedRequirement, + principal: Principal, + context: CanonicalResourceContext, + ): RequirementResult => { + const registration = REQUIREMENT_TYPES[requirementRegistryKey(requirement.id)]; - if (!Schema.is(registration.parameterSchema)(requirement.parameters)) { - return RequirementResult.Failed({ id: requirement.id, reason: "InvalidParameters" }); - } + if (!Schema.is(registration.parameterSchema)(requirement.parameters)) { + return RequirementResult.Failed({ id: requirement.id, reason: "InvalidParameters" }); + } - const parameters = Schema.decodeSync(registration.parameterSchema)(requirement.parameters); + const parameters = Schema.decodeSync(registration.parameterSchema)(requirement.parameters); - const evaluation = registration.evaluate(parameters, principal, context); + const evaluation = registration.evaluate(parameters, principal, context); - return { id: requirement.id, ...evaluation }; -}; + return { id: requirement.id, ...evaluation }; + }, +); export type AccessDenialStage = "PrincipalKind" | "Capability" | "Scope" | "Requirement"; @@ -1327,15 +1345,13 @@ const expressionForContext = ( context: CanonicalResourceContext, grants: ReadonlyArray, instant: AuthorizationInstant, -): ContextCapabilityResult => { - return Match.value(expression).pipe( +): ContextCapabilityResult => + Match.value(expression).pipe( Match.withReturnType(), - Match.tag("None", () => { - return { allowed: true }; - }), - Match.tag("One", (expression) => { - return capabilityForContext(expression.capability, principal, context, grants, instant); - }), + Match.tag("None", () => ({ allowed: true })), + Match.tag("One", (expression) => + capabilityForContext(expression.capability, principal, context, grants, instant), + ), Match.tag("All", (expression) => { for (const capability of expression.capabilities) { const result = capabilityForContext(capability, principal, context, grants, instant); @@ -1368,7 +1384,6 @@ const expressionForContext = ( }), Match.exhaustive, ); -}; export const evaluateAccess = (input: { readonly spec: AccessSpec; @@ -1452,49 +1467,62 @@ export interface AccessJourneyServices { ) => Effect.Effect, E, R>; } -export const evaluateAccessJourney = ( - spec: AccessSpec, - input: I, - services: AccessJourneyServices, -): Effect.Effect, E, R> => - Effect.gen(function* () { - const authorizationInstant = yield* services.now; - const credential = yield* services.resolveCredential(authorizationInstant); - - if (Predicate.isTagged(credential, "Rejected")) { - return AccessEvaluation.CredentialRejected({ reason: credential.reason }); - } +export const evaluateAccessJourney: { + ( + input: I, + services: AccessJourneyServices, + ): (spec: AccessSpec) => Effect.Effect, E, R>; + ( + spec: AccessSpec, + input: I, + services: AccessJourneyServices, + ): Effect.Effect, E, R>; +} = dual( + 3, + ( + spec: AccessSpec, + input: I, + services: AccessJourneyServices, + ): Effect.Effect, E, R> => + Effect.gen(function* () { + const authorizationInstant = yield* services.now; + const credential = yield* services.resolveCredential(authorizationInstant); + + if (Predicate.isTagged(credential, "Rejected")) { + return AccessEvaluation.CredentialRejected({ reason: credential.reason }); + } - if (!credentialMatchesAccessSpec(spec, credential)) { - return AccessEvaluation.CredentialRejected({ reason: "WrongMechanism" }); - } + if (!credentialMatchesAccessSpec(spec, credential)) { + return AccessEvaluation.CredentialRejected({ reason: "WrongMechanism" }); + } - const resolution = yield* services.resolveScope( - input, - credential.principal, - authorizationInstant, - spec.decisionTime, - ); + const resolution = yield* services.resolveScope( + input, + credential.principal, + authorizationInstant, + spec.decisionTime, + ); - const grants = yield* services.resolveGrants( - credential.principal, - resolution, - authorizationInstant, - spec.decisionTime, - ); + const grants = yield* services.resolveGrants( + credential.principal, + resolution, + authorizationInstant, + spec.decisionTime, + ); - return evaluateAccess({ spec, credential, resolution, grants, authorizationInstant }); - }); + return evaluateAccess({ spec, credential, resolution, grants, authorizationInstant }); + }), +); const concealed = ( policy: ConcealmentPolicy, stage: AccessDenialStage | "CredentialFailure", ): boolean => Predicate.isTagged(policy, "NotFound") && policy.conceal.includes(stage); -export const accessHttpStatus = ( - evaluation: AccessEvaluation, - policy: ConcealmentPolicy, -): 200 | 401 | 403 | 404 => { +export const accessHttpStatus: { + (policy: ConcealmentPolicy): (evaluation: AccessEvaluation) => 200 | 401 | 403 | 404; + (evaluation: AccessEvaluation, policy: ConcealmentPolicy): 200 | 401 | 403 | 404; +} = dual(2, (evaluation: AccessEvaluation, policy: ConcealmentPolicy): 200 | 401 | 403 | 404 => { if (Predicate.isTagged(evaluation, "Allow")) return 200; if (Predicate.isTagged(evaluation, "CredentialRejected")) { @@ -1502,7 +1530,7 @@ export const accessHttpStatus = ( } return concealed(policy, evaluation.stage) ? 404 : 403; -}; +}); export interface AccessTrace { readonly declarationId: string; diff --git a/packages/domain/src/authz/delegation.ts b/packages/domain/src/authz/delegation.ts index 55efc6390..b2e4aed38 100644 --- a/packages/domain/src/authz/delegation.ts +++ b/packages/domain/src/authz/delegation.ts @@ -6,6 +6,7 @@ * time-bounded delegation of one capability in one area. */ import { Match, Record, Result, Schema } from "effect"; +import { dual } from "effect/Function"; import { DepartmentId, PersonId, TeamId } from "../organization/schema.js"; import { compareRfc3339Instants, Rfc3339InstantSchema } from "../time.js"; @@ -229,21 +230,28 @@ export const DelegationView = Schema.Struct({ export type DelegationView = typeof DelegationView.Type; -export const delegationStateAt = ( - delegation: Pick, - now: string, -): DelegationState => - delegation.endAt !== null && compareRfc3339Instants(delegation.endAt, now) <= 0 - ? "Ended" - : compareRfc3339Instants(delegation.startAt, now) > 0 - ? "Future" - : "Current"; +export const delegationStateAt: { + (now: string): (delegation: Pick) => DelegationState; + (delegation: Pick, now: string): DelegationState; +} = dual( + 2, + (delegation: Pick, now: string): DelegationState => + delegation.endAt !== null && compareRfc3339Instants(delegation.endAt, now) <= 0 + ? "Ended" + : compareRfc3339Instants(delegation.startAt, now) > 0 + ? "Future" + : "Current", +); /** Active from its start, inclusive, until its end, exclusive. */ -export const delegationActiveAt = ( - delegation: Pick, - instant: string, -): boolean => delegationStateAt(delegation, instant) === "Current"; +export const delegationActiveAt: { + (instant: string): (delegation: Pick) => boolean; + (delegation: Pick, instant: string): boolean; +} = dual( + 2, + (delegation: Pick, instant: string): boolean => + delegationStateAt(delegation, instant) === "Current", +); /** The facts of the delegated team that its delegations depend on. */ export interface DelegationTeam { @@ -255,12 +263,15 @@ export interface DelegationTeam { } /** A team's area: its home department, or the whole organization for a national team. */ -export const teamAreaCovers = ( - team: Pick, - area: DelegationArea, -): boolean => - team.teamScope === "National" || - (DelegationArea.guards.Department(area) && area.departmentId === team.departmentId); +export const teamAreaCovers: { + (area: DelegationArea): (team: Pick) => boolean; + (team: Pick, area: DelegationArea): boolean; +} = dual( + 2, + (team: Pick, area: DelegationArea): boolean => + team.teamScope === "National" || + (DelegationArea.guards.Department(area) && area.departmentId === team.departmentId), +); /** * Whether a delegation can confer its capability on the members of this team: the registry @@ -268,29 +279,40 @@ export const teamAreaCovers = ( * area lies inside the team's area. Evaluated again at every decision, so a reclassified team or a * changed registry fails closed. */ -export const delegationConforms = ( - delegation: Pick, - team: Pick, -): boolean => { - const rule = ORGANIZATION_CAPABILITIES[delegation.capability].delegation; - - return ( - team.unitKind === "Team" && - teamAreaCovers(team, delegation.area) && - Match.value(rule).pipe( - Match.when("TeamArea", () => true), - Match.when("National", () => DelegationArea.guards.Organization(delegation.area)), - Match.when( - "NationalLeaders", - () => - DelegationArea.guards.Organization(delegation.area) && - delegation.holders === "LeadersOnly", - ), - Match.when("Never", () => false), - Match.exhaustive, - ) - ); -}; +export const delegationConforms: { + ( + team: Pick, + ): (delegation: Pick) => boolean; + ( + delegation: Pick, + team: Pick, + ): boolean; +} = dual( + 2, + ( + delegation: Pick, + team: Pick, + ): boolean => { + const rule = ORGANIZATION_CAPABILITIES[delegation.capability].delegation; + + return ( + team.unitKind === "Team" && + teamAreaCovers(team, delegation.area) && + Match.value(rule).pipe( + Match.when("TeamArea", () => true), + Match.when("National", () => DelegationArea.guards.Organization(delegation.area)), + Match.when( + "NationalLeaders", + () => + DelegationArea.guards.Organization(delegation.area) && + delegation.holders === "LeadersOnly", + ), + Match.when("Never", () => false), + Match.exhaustive, + ) + ); + }, +); const command = { commandId: Text, reason: Text }; @@ -359,65 +381,86 @@ export class DelegationTransitionFailure extends Schema.TaggedError Result.fail(new DelegationTransitionFailure({ code: "Invalid" })); +const invalid = () => Result.fail(DelegationTransitionFailure.make({ code: "Invalid" })); /** * One exhaustive machine owns every delegation transition. Issuing needs an active ordinary team * whose area covers the requested area and a capability that the registry delegates there. * Ending never extends a delegation and never ends it before now. */ -export const transitionDelegation = ( - current: Delegation | undefined, - command: DelegationCommand, - input: { - readonly delegationId: DelegationId; - readonly team: DelegationTeam | undefined; - readonly now: string; - }, -): Result.Result => - Match.value(command).pipe( - Match.withReturnType>(), - Match.tag("IssueDelegation", (command) => { - if (current !== undefined) return invalid(); - - if (input.team === undefined) - return Result.fail(new DelegationTransitionFailure({ code: "NotFound" })); - - if ( - !input.team.active || - !delegationConforms(command, input.team) || - (command.endAt !== null && compareRfc3339Instants(command.startAt, command.endAt) >= 0) - ) - return invalid(); - - return Result.succeed({ - delegationId: input.delegationId, - name: command.name, - teamId: command.teamId, - capability: command.capability, - area: command.area, - holders: command.holders, - startAt: command.startAt, - endAt: command.endAt, - revision: 0, - }); - }), - Match.tag("EndDelegation", (command) => { - if (current === undefined) - return Result.fail(new DelegationTransitionFailure({ code: "NotFound" })); - - if (current.revision !== command.expectedRevision) - return Result.fail(new DelegationTransitionFailure({ code: "Stale" })); - - if ( - delegationStateAt(current, input.now) === "Ended" || - compareRfc3339Instants(command.endAt, input.now) < 0 || - compareRfc3339Instants(command.endAt, current.startAt) <= 0 || - (current.endAt !== null && compareRfc3339Instants(command.endAt, current.endAt) > 0) - ) - return invalid(); - - return Result.succeed({ ...current, endAt: command.endAt, revision: current.revision + 1 }); - }), - Match.exhaustive, - ); +export const transitionDelegation: { + ( + command: DelegationCommand, + input: { + readonly delegationId: DelegationId; + readonly team: DelegationTeam | undefined; + readonly now: string; + }, + ): (current: Delegation | undefined) => Result.Result; + ( + current: Delegation | undefined, + command: DelegationCommand, + input: { + readonly delegationId: DelegationId; + readonly team: DelegationTeam | undefined; + readonly now: string; + }, + ): Result.Result; +} = dual( + 3, + ( + current: Delegation | undefined, + command: DelegationCommand, + input: { + readonly delegationId: DelegationId; + readonly team: DelegationTeam | undefined; + readonly now: string; + }, + ): Result.Result => + Match.value(command).pipe( + Match.withReturnType>(), + Match.tag("IssueDelegation", (command) => { + if (current !== undefined) return invalid(); + + if (input.team === undefined) + return Result.fail(DelegationTransitionFailure.make({ code: "NotFound" })); + + if ( + !input.team.active || + !delegationConforms(command, input.team) || + (command.endAt !== null && compareRfc3339Instants(command.startAt, command.endAt) >= 0) + ) + return invalid(); + + return Result.succeed({ + delegationId: input.delegationId, + name: command.name, + teamId: command.teamId, + capability: command.capability, + area: command.area, + holders: command.holders, + startAt: command.startAt, + endAt: command.endAt, + revision: 0, + }); + }), + Match.tag("EndDelegation", (command) => { + if (current === undefined) + return Result.fail(DelegationTransitionFailure.make({ code: "NotFound" })); + + if (current.revision !== command.expectedRevision) + return Result.fail(DelegationTransitionFailure.make({ code: "Stale" })); + + if ( + delegationStateAt(current, input.now) === "Ended" || + compareRfc3339Instants(command.endAt, input.now) < 0 || + compareRfc3339Instants(command.endAt, current.startAt) <= 0 || + (current.endAt !== null && compareRfc3339Instants(command.endAt, current.endAt) > 0) + ) + return invalid(); + + return Result.succeed({ ...current, endAt: command.endAt, revision: current.revision + 1 }); + }), + Match.exhaustive, + ), +); diff --git a/packages/domain/src/authz/reach-evidence.test.ts b/packages/domain/src/authz/reach-evidence.test.ts new file mode 100644 index 000000000..53eea3f2a --- /dev/null +++ b/packages/domain/src/authz/reach-evidence.test.ts @@ -0,0 +1,334 @@ +import { expect, expectTypeOf, it } from "@effect/vitest"; +import { Effect, Predicate, Result, Schema } from "effect"; +import { Arbitrary } from "effect/unstable/arbitrary"; +import { admissionOutcomePermission } from "../admissions/outcome.js"; +import { + mapOrganizationAuthorityToDepartmentActor, + requireTeamInterestScope, + type TeamInterestReadScope, + OrganizationAuthorityBoardSeatSchema, + OrganizationAuthorityMembershipSchema, + OrganizationGlobalAdministratorStatusSchema, + OrganizationPersonAuthoritySchema, +} from "../organization/authority.js"; +import { spec0055OrganizationAuthorityFixtures } from "../organization/authority-fixtures.test-support.js"; +import { DepartmentId, TeamId } from "../organization/schema.js"; +import { canManagePlacements } from "../placements/policy.js"; +import type { OrganizationOperations } from "../organization/service.js"; +import type { PlacementExecution } from "../placements/service.js"; +import { + requireSocialEventCreation, + type SocialEventCreation, +} from "../social-events/authority.js"; +import type { SocialEventsOperations } from "../social-events/service.js"; +import type { AdmissionsOperations } from "../admissions/service.js"; +import { + Delegation, + DelegationArea, + DelegationId, + ORGANIZATION_CAPABILITIES, + ORGANIZATION_CAPABILITY_IDS, + OrganizationCapability, +} from "./delegation.js"; +import { + type DepartmentReach, + DepartmentReachDenied, + ReachedDepartments, + reachedDepartments, + reachedTeams, + reaches, + ReachTarget, + requireDepartmentReach, +} from "./reach.js"; + +const fixtures = spec0055OrganizationAuthorityFixtures({ + evaluatedAt: "2026-09-28T12:00:00.000Z", + departmentId: "reach-department-a", + teamId: "reach-team", + persons: { + administrator: "reach-administrator", + leader: "reach-leader", + inactiveLeader: "reach-inactive-leader", + member: "reach-member", + absent: "reach-absent", + }, + memberships: { + leader: "reach-leader-membership", + inactiveLeader: "reach-inactive-leader-membership", + member: "reach-member-membership", + }, +}); + +/** A small pool, so generated appointments land in the department under test. */ +const departments = ["reach-department-a", "reach-department-b", "reach-department-c"] as const; + +const department = Arbitrary.map(Arbitrary.schema(Schema.Literals(departments)), (id) => + DepartmentId.make(id), +); + +/** A small pool of teams, shared by appointments and delegations, so delegations can apply. */ +const teams = ["reach-team-a", "reach-team-b"] as const; + +const team = Arbitrary.map(Arbitrary.schema(Schema.Literals(teams)), (id) => TeamId.make(id)); + +const membership = Arbitrary.map( + Arbitrary.all({ + fields: Arbitrary.schema(OrganizationAuthorityMembershipSchema), + department, + team, + }), + ({ fields, department, team }) => ({ ...fields, departmentId: department, teamId: team }), +); + +/** + * Delegations of the pooled teams, current, ended, or future at the fixtures' instant. Their + * interval is chosen, not generated: the RFC 3339 filter exhausts the schema arbitrary. + */ +const delegation = Arbitrary.map( + Arbitrary.all({ + team, + capability: Arbitrary.schema(OrganizationCapability), + area: Arbitrary.schema(Schema.Literals(["Organization", ...departments])), + holders: Arbitrary.schema(Schema.Literals(["AllMembers", "LeadersOnly"])), + interval: Arbitrary.schema(Schema.Literals(["Current", "Ended", "Future"])), + }), + ({ team, capability, area, holders, interval }) => + Delegation.make({ + delegationId: DelegationId.make(`delegation-${"c".repeat(64)}`), + name: "Generated delegation", + teamId: team, + capability, + area: + area === "Organization" + ? DelegationArea.cases.Organization.make({}) + : DelegationArea.cases.Department.make({ departmentId: DepartmentId.make(area) }), + holders, + startAt: interval === "Future" ? "2026-10-01T00:00:00.000Z" : "2026-01-01T00:00:00.000Z", + endAt: interval === "Ended" ? "2026-06-01T00:00:00.000Z" : null, + revision: 0, + }), +); + +/** + * A fixture person and instant with every global-administrator status, generated team and board + * appointments in the pooled departments and teams, national board seats, and delegations. + * Instants are not generated: the RFC 3339 filter exhausts the schema arbitrary. + */ +const authority = Arbitrary.map( + Arbitrary.all({ + base: Arbitrary.schema( + Schema.Literals(["administrator", "leader", "inactiveLeader", "member", "absent"]), + ), + globalAdministrator: Arbitrary.schema(OrganizationGlobalAdministratorStatusSchema), + memberships: Arbitrary.array(membership, { maxLength: 4 }), + nationalBoardSeats: Arbitrary.schema(Schema.Array(OrganizationAuthorityBoardSeatSchema)), + delegations: Arbitrary.array(delegation, { maxLength: 2 }), + }), + ({ base, ...facts }) => OrganizationPersonAuthoritySchema.make({ ...fixtures[base], ...facts }), +); + +const capability = Arbitrary.schema(OrganizationCapability); + +const propertyOptions = { arbitrary: { seed: 28092026, runs: 300 } } as const; + +it.effect("requireDepartmentReach returns evidence for a board leader and denies a member", () => + Effect.gen(function* () { + const departmentId = DepartmentId.make("reach-department-a"); + + const leader = yield* Effect.fromResult( + requireDepartmentReach(fixtures.leader, "admissions.outcomes", departmentId), + ); + + expect(leader).toMatchObject({ + personId: fixtures.leader.personId, + capability: "admissions.outcomes", + departmentId, + }); + + const denied = yield* Effect.flip( + Effect.fromResult( + requireDepartmentReach(fixtures.member, "admissions.outcomes", departmentId), + ), + ); + + expect(denied).toEqual( + new DepartmentReachDenied({ + personId: fixtures.member.personId, + capability: "admissions.outcomes", + departmentId, + }), + ); + }), +); + +it.prop( + "requireDepartmentReach grants evidence exactly where reaches holds, naming the person, capability, and department", + { authority, capability, department }, + ({ authority, capability, department }) => { + const result = requireDepartmentReach(authority, capability, department); + + expect(Result.isSuccess(result)).toBe( + reaches(authority, capability, ReachTarget.Department({ departmentId: department })), + ); + + const facts = Result.isSuccess(result) ? result.success : result.failure; + expect(facts.personId).toBe(authority.personId); + expect(facts.capability).toBe(capability); + expect(facts.departmentId).toBe(department); + }, + propertyOptions, +); + +it.prop( + "requireDepartmentReach agrees with department administration where a global administrator holds the capability", + { authority, department }, + ({ authority, department }) => { + for (const capability of ORGANIZATION_CAPABILITY_IDS) { + if (!ORGANIZATION_CAPABILITIES[capability].globalAdministrator) continue; + + const decision = mapOrganizationAuthorityToDepartmentActor(authority, capability, department); + + expect(Result.isSuccess(requireDepartmentReach(authority, capability, department))).toBe( + Predicate.isTagged(decision, "Allow") && !Predicate.isTagged(decision.value, "Member"), + ); + } + }, + propertyOptions, +); + +it.prop( + "requireDepartmentReach replaces the placement coordinator and outcome decider checks", + { authority, department }, + ({ authority, department }) => { + expect( + Result.isSuccess(requireDepartmentReach(authority, "placements.coordinate", department)), + ).toBe(canManagePlacements(authority, department)); + + expect( + Result.isSuccess(requireDepartmentReach(authority, "admissions.outcomes", department)), + ).toBe(admissionOutcomePermission(authority, department) === "Decide"); + }, + propertyOptions, +); + +const requested = Arbitrary.map( + Arbitrary.schema(Schema.Literals(["none", ...departments])), + (id) => (id === "none" ? undefined : DepartmentId.make(id)), +); + +it.prop( + "requireTeamInterestScope reads only what team-interest.read reaches, inside the requested department", + { authority, requested }, + ({ authority, requested }) => { + const known = departments.map((id) => DepartmentId.make(id)); + const result = requireTeamInterestScope(authority, { requested, departments: known }); + const reached = reachedDepartments(authority, "team-interest.read"); + const readable = ReachedDepartments.$is("All")(reached) ? known : reached.departmentIds; + const teams = reachedTeams(authority, "team-interest.read"); + + // A reached team is read in the department of the person's appointment to it. + const teamDepartments = teams.flatMap((teamId) => { + const membership = authority.memberships.find((entry) => entry.teamId === teamId); + + return membership === undefined ? [] : [membership.departmentId]; + }); + + const organizationWide = ReachedDepartments.$is("All")(reached); + + const readableHere = + requested === undefined + ? organizationWide || readable.length > 0 || teamDepartments.length > 0 + : readable.includes(requested) || teamDepartments.includes(requested); + + expect(Result.isSuccess(result)).toBe(readableHere); + + if (Result.isFailure(result)) { + expect(result.failure.personId).toBe(authority.personId); + + return; + } + + const scope = result.success; + expect(scope.personId).toBe(authority.personId); + + for (const departmentId of scope.departmentIds) { + expect(readable).toContain(departmentId); + + if (requested !== undefined) expect(departmentId).toBe(requested); + } + + for (const team of scope.teams) { + expect(teams).toContain(team.teamId); + expect(scope.departmentIds).not.toContain(team.departmentId); + + if (requested !== undefined) expect(team.departmentId).toBe(requested); + } + }, + propertyOptions, +); + +it.prop( + "requireSocialEventCreation admits an active global administrator anywhere and an active appointment in the department", + { authority, department }, + ({ authority, department }) => { + const result = requireSocialEventCreation(authority, department); + + expect(Result.isSuccess(result)).toBe( + authority.globalAdministrator === "Active" || + authority.memberships.some( + (membership) => membership.active && membership.departmentId === department, + ), + ); + + const facts = Result.isSuccess(result) ? result.success : result.failure; + expect(facts.personId).toBe(authority.personId); + expect(facts.departmentId).toBe(department); + }, + propertyOptions, +); + +it("requireDepartmentReach evidence is the only way to satisfy a department-scoped command", () => { + type OutcomeDecider = Parameters[0]["decider"]; + + type Coordinator = Extract["coordinator"]; + + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); + + // Negative controls: a literal of the evidence's visible shape, or another capability's + // evidence, is not evidence for the command. + expectTypeOf<{ + readonly personId: OutcomeDecider["personId"]; + readonly capability: "admissions.outcomes"; + readonly departmentId: OutcomeDecider["departmentId"]; + }>().not.toExtend(); + expectTypeOf>().not.toExtend(); + expectTypeOf>().not.toExtend(); + + // A creator's person id is not creation evidence. + expectTypeOf< + Parameters[0] + >().toEqualTypeOf(); + expectTypeOf<{ + readonly personId: SocialEventCreation["personId"]; + readonly departmentId: SocialEventCreation["departmentId"]; + }>().not.toExtend(); + + // A handler-built filter is not a team-interest scope. + expectTypeOf<{ + readonly personId: TeamInterestReadScope["personId"]; + readonly departmentIds: TeamInterestReadScope["departmentIds"]; + readonly teams: TeamInterestReadScope["teams"]; + }>().not.toExtend(); + expectTypeOf< + Parameters[0] + >().toEqualTypeOf(); + + // A board or coverage-board change cannot run on a person's own actor id. + expectTypeOf<{ + readonly mutation: Extract["mutation"]; + readonly actor: OutcomeDecider["personId"]; + readonly now: string; + readonly commandId: string; + }>().not.toExtend(); +}); diff --git a/packages/domain/src/authz/reach.ts b/packages/domain/src/authz/reach.ts index a20d450c7..561ea3346 100644 --- a/packages/domain/src/authz/reach.ts +++ b/packages/domain/src/authz/reach.ts @@ -3,7 +3,8 @@ * `delegatedReach`). Every decision that depends on team or board leadership, or on a delegation, * asks this module; no other product code reads a leadership flag. */ -import { Data, Match, Option, Order } from "effect"; +import { Data, Match, Option, Order, Result } from "effect"; +import { dual } from "effect/Function"; import type { OrganizationPersonAuthority } from "../organization/authority.js"; import type { DepartmentId, MembershipId, PersonId, TeamId } from "../organization/schema.js"; import { @@ -38,7 +39,10 @@ export type ReachTarget = Data.TaggedEnum<{ export const ReachTarget = Data.taggedEnum(); /** Containment: the organization covers everything, a department its units, a team itself. */ -export const scopeCovers = (scope: ReachScope, target: ReachTarget): boolean => +export const scopeCovers: { + (target: ReachTarget): (scope: ReachScope) => boolean; + (scope: ReachScope, target: ReachTarget): boolean; +} = dual(2, (scope: ReachScope, target: ReachTarget): boolean => Match.value(scope).pipe( Match.tag("Organization", () => true), Match.tag( @@ -49,29 +53,41 @@ export const scopeCovers = (scope: ReachScope, target: ReachTarget): boolean => ), Match.tag("Team", ({ teamId }) => ReachTarget.$is("Team")(target) && target.teamId === teamId), Match.exhaustive, - ); + ), +); /** * The active delegations of a capability that reach this person: an active membership of the * delegated team, the leaders only for a leaders-only delegation, and a delegation that still * conforms to the registry and to the team's current area. */ -export const delegationsReaching = ( - authority: OrganizationPersonAuthority, - capability: OrganizationCapability, -): ReadonlyArray => - authority.delegations.filter( - (delegation) => - delegation.capability === capability && - delegationActiveAt(delegation, authority.evaluatedAt) && - authority.memberships.some( - (membership) => - membership.teamId === delegation.teamId && - membership.active && - (delegation.holders === "AllMembers" || membership.unitLeader) && - delegationConforms(delegation, membership), - ), - ); +export const delegationsReaching: { + ( + capability: OrganizationCapability, + ): (authority: OrganizationPersonAuthority) => ReadonlyArray; + ( + authority: OrganizationPersonAuthority, + capability: OrganizationCapability, + ): ReadonlyArray; +} = dual( + 2, + ( + authority: OrganizationPersonAuthority, + capability: OrganizationCapability, + ): ReadonlyArray => + authority.delegations.filter( + (delegation) => + delegation.capability === capability && + delegationActiveAt(delegation, authority.evaluatedAt) && + authority.memberships.some( + (membership) => + membership.teamId === delegation.teamId && + membership.active && + (delegation.holders === "AllMembers" || membership.unitLeader) && + delegationConforms(delegation, membership), + ), + ), +); /** Why a person holds a capability in a scope. */ export type ReachBasis = "GlobalAdministrator" | "TeamLeader" | "BoardLeader" | "Delegation"; @@ -133,16 +149,128 @@ const reachesOf = ( * independent; the national board's leader and a global administrator act everywhere; a * delegation acts in its area. */ -export const reachScopes = ( - authority: OrganizationPersonAuthority, - capability: OrganizationCapability, -): ReadonlyArray => reachesOf(authority, capability).map(({ scope }) => scope); +export const reachScopes: { + ( + capability: OrganizationCapability, + ): (authority: OrganizationPersonAuthority) => ReadonlyArray; + ( + authority: OrganizationPersonAuthority, + capability: OrganizationCapability, + ): ReadonlyArray; +} = dual( + 2, + ( + authority: OrganizationPersonAuthority, + capability: OrganizationCapability, + ): ReadonlyArray => reachesOf(authority, capability).map(({ scope }) => scope), +); -export const reaches = ( - authority: OrganizationPersonAuthority, - capability: OrganizationCapability, - target: ReachTarget, -): boolean => reachScopes(authority, capability).some((scope) => scopeCovers(scope, target)); +export const reaches: { + ( + capability: OrganizationCapability, + target: ReachTarget, + ): (authority: OrganizationPersonAuthority) => boolean; + ( + authority: OrganizationPersonAuthority, + capability: OrganizationCapability, + target: ReachTarget, + ): boolean; +} = dual( + 3, + ( + authority: OrganizationPersonAuthority, + capability: OrganizationCapability, + target: ReachTarget, + ): boolean => reachScopes(authority, capability).some((scope) => scopeCovers(scope, target)), +); + +/** Type-only brand. No module exports a value of it, so only {@link requireDepartmentReach} builds the evidence. */ +declare const DepartmentReachBrand: unique symbol; + +/** + * Proof that a person held `capability` over one department when their authority was resolved. + * A command takes the department from it, so the proof cannot be pointed at another department. + */ +export interface DepartmentReach { + readonly [DepartmentReachBrand]: C; + readonly personId: PersonId; + readonly capability: C; + readonly departmentId: DepartmentId; +} + +/** The person does not hold the capability over the department. */ +export class DepartmentReachDenied extends Data.TaggedError("DepartmentReachDenied")<{ + readonly personId: PersonId; + readonly capability: OrganizationCapability; + readonly departmentId: DepartmentId; +}> {} + +/** + * Checks that a resolved authority reaches one department with a capability, and returns the + * evidence that a department-scoped command requires. + * + * @remarks + * It is the only constructor of {@link DepartmentReach} and decides exactly as {@link reaches} + * with a department target: a board leadership of the independent department, a national board + * leadership, a delegation over the department or the organization, or an active global + * administrator where the capability admits one. A team leadership never reaches a department. + * + * @sideEffects none + * + * @example + * ```ts + * const coordinator = yield* Effect.fromResult( + * requireDepartmentReach(authority, "placements.coordinate", departmentId), + * ); + * yield* placements.execute({ mutation, coordinator, now, commandId }); + * ``` + * + * @avoid Checking `reaches` at the call site and passing a department and a person to the + * command: the command then trusts that some caller checked, for that department. Require the + * evidence, and take the department from it. + * + * @construct authority-evidence + */ +export const requireDepartmentReach: { + ( + /** The capability that the command needs. */ + capability: C, + /** The department that the command acts in. */ + departmentId: DepartmentId, + ): ( + /** The person's authority, resolved from current facts inside the command transaction. */ + authority: OrganizationPersonAuthority, + ) => /** Evidence for the department, or the typed denial. */ + Result.Result, DepartmentReachDenied>; + ( + /** The person's authority, resolved from current facts inside the command transaction. */ + authority: OrganizationPersonAuthority, + /** The capability that the command needs. */ + capability: C, + /** The department that the command acts in. */ + departmentId: DepartmentId, + ): /** Evidence for the department, or the typed denial. */ + Result.Result, DepartmentReachDenied>; +} = dual( + 3, + ( + /** The person's authority, resolved from current facts inside the command transaction. */ + authority: OrganizationPersonAuthority, + /** The capability that the command needs. */ + capability: C, + /** The department that the command acts in. */ + departmentId: DepartmentId, + ): /** Evidence for the department, or the typed denial. */ + Result.Result, DepartmentReachDenied> => + reaches(authority, capability, ReachTarget.Department({ departmentId })) + ? Result.succeed( + // SAFETY: the one constructor of the evidence brand; the reach check above is what it proves. + { personId: authority.personId, capability, departmentId } as DepartmentReach, + ) + : Result.fail( + new DepartmentReachDenied({ personId: authority.personId, capability, departmentId }), + ), +); /** The departments that the person reaches as a whole. */ export type ReachedDepartments = Data.TaggedEnum<{ @@ -152,38 +280,57 @@ export type ReachedDepartments = Data.TaggedEnum<{ export const ReachedDepartments = Data.taggedEnum(); -export const reachedDepartments = ( - authority: OrganizationPersonAuthority, - capability: OrganizationCapability, -): ReachedDepartments => { - const scopes = reachScopes(authority, capability); - - if (scopes.some(ReachScope.$is("Organization"))) return ReachedDepartments.All(); - - const departmentIds = new Set(); - - for (const scope of scopes) { - if (ReachScope.$is("Department")(scope)) departmentIds.add(scope.departmentId); - } - - return ReachedDepartments.Departments({ - departmentIds: [...departmentIds].sort((left, right) => left.localeCompare(right)), - }); -}; +export const reachedDepartments: { + ( + capability: OrganizationCapability, + ): (authority: OrganizationPersonAuthority) => ReachedDepartments; + (authority: OrganizationPersonAuthority, capability: OrganizationCapability): ReachedDepartments; +} = dual( + 2, + ( + authority: OrganizationPersonAuthority, + capability: OrganizationCapability, + ): ReachedDepartments => { + const scopes = reachScopes(authority, capability); + + if (scopes.some(ReachScope.$is("Organization"))) return ReachedDepartments.All(); + + const departmentIds = new Set(); + + for (const scope of scopes) { + if (ReachScope.$is("Department")(scope)) departmentIds.add(scope.departmentId); + } + + return ReachedDepartments.Departments({ + departmentIds: [...departmentIds].sort((left, right) => left.localeCompare(right)), + }); + }, +); /** The teams that the person reaches through a team scope (a team leader's own team). */ -export const reachedTeams = ( - authority: OrganizationPersonAuthority, - capability: OrganizationCapability, -): ReadonlyArray => { - const teamIds = new Set(); - - for (const scope of reachScopes(authority, capability)) { - if (ReachScope.$is("Team")(scope)) teamIds.add(scope.teamId); - } - - return [...teamIds].sort((left, right) => left.localeCompare(right)); -}; +export const reachedTeams: { + ( + capability: OrganizationCapability, + ): (authority: OrganizationPersonAuthority) => ReadonlyArray; + ( + authority: OrganizationPersonAuthority, + capability: OrganizationCapability, + ): ReadonlyArray; +} = dual( + 2, + ( + authority: OrganizationPersonAuthority, + capability: OrganizationCapability, + ): ReadonlyArray => { + const teamIds = new Set(); + + for (const scope of reachScopes(authority, capability)) { + if (ReachScope.$is("Team")(scope)) teamIds.add(scope.teamId); + } + + return [...teamIds].sort((left, right) => left.localeCompare(right)); + }, +); /** * Whether a board leadership or a delegation gives the person a capability beyond a single team. @@ -197,10 +344,14 @@ export const holdsDepartmentReach = (authority: OrganizationPersonAuthority): bo ); /** Whether the person currently leads this unit (a team, or a department board). */ -export const leadsUnit = (authority: OrganizationPersonAuthority, teamId: TeamId): boolean => +export const leadsUnit: { + (teamId: TeamId): (authority: OrganizationPersonAuthority) => boolean; + (authority: OrganizationPersonAuthority, teamId: TeamId): boolean; +} = dual(2, (authority: OrganizationPersonAuthority, teamId: TeamId): boolean => authority.memberships.some( (membership) => membership.teamId === teamId && membership.active && membership.unitLeader, - ); + ), +); /** Whether the person currently leads an ordinary team. */ export const leadsAnyTeam = (authority: OrganizationPersonAuthority): boolean => @@ -290,45 +441,56 @@ export interface GovernedDepartment { * An appointed seat comes before a derived seat, and a seat before the grant, so the certificate * names the seat. Anyone else, a team leader without such a seat included, has no basis. */ -export const certificateIssuerBasis = ( - authority: OrganizationPersonAuthority, - department: GovernedDepartment, -): Option.Option => { - const appointed = department.independent - ? authority.memberships - .filter( - (membership) => - membership.active && - membership.unitKind === "DepartmentBoard" && - membership.departmentId === department.departmentId, - ) - .toSorted(byMembership) - : authority.nationalBoardSeats.filter((seat) => seat.active).toSorted(byMembership); - - const seat = appointed[0]; - - if (seat !== undefined) - return Option.some(IssuerBasis.BoardSeat({ membershipId: seat.membershipId })); - - const derived = derivedBoardSeats( - authority.memberships.map((membership) => ({ ...membership, personId: authority.personId })), - ).find((candidate) => - SeatBoard.$match(candidate.board, { - DepartmentBoard: ({ departmentId }) => - department.independent && departmentId === department.departmentId, - NationalBoard: () => !department.independent, - }), - ); - - if (derived !== undefined) - return Option.some( - IssuerBasis.DerivedSeat({ - membershipId: derived.sourceMembershipId, - teamId: derived.sourceTeamId, +export const certificateIssuerBasis: { + ( + department: GovernedDepartment, + ): (authority: OrganizationPersonAuthority) => Option.Option; + ( + authority: OrganizationPersonAuthority, + department: GovernedDepartment, + ): Option.Option; +} = dual( + 2, + ( + authority: OrganizationPersonAuthority, + department: GovernedDepartment, + ): Option.Option => { + const appointed = department.independent + ? authority.memberships + .filter( + (membership) => + membership.active && + membership.unitKind === "DepartmentBoard" && + membership.departmentId === department.departmentId, + ) + .toSorted(byMembership) + : authority.nationalBoardSeats.filter((seat) => seat.active).toSorted(byMembership); + + const seat = appointed[0]; + + if (seat !== undefined) + return Option.some(IssuerBasis.BoardSeat({ membershipId: seat.membershipId })); + + const derived = derivedBoardSeats( + authority.memberships.map((membership) => ({ ...membership, personId: authority.personId })), + ).find((candidate) => + SeatBoard.$match(candidate.board, { + DepartmentBoard: ({ departmentId }) => + department.independent && departmentId === department.departmentId, + NationalBoard: () => !department.independent, }), ); - return authority.globalAdministrator === "Active" - ? Option.some(IssuerBasis.GlobalAdministrator()) - : Option.none(); -}; + if (derived !== undefined) + return Option.some( + IssuerBasis.DerivedSeat({ + membershipId: derived.sourceMembershipId, + teamId: derived.sourceTeamId, + }), + ); + + return authority.globalAdministrator === "Active" + ? Option.some(IssuerBasis.GlobalAdministrator()) + : Option.none(); + }, +); diff --git a/packages/domain/src/authz/rules.ts b/packages/domain/src/authz/rules.ts index 4b9e0bd3b..49ac7bfbd 100644 --- a/packages/domain/src/authz/rules.ts +++ b/packages/domain/src/authz/rules.ts @@ -1,4 +1,5 @@ import { Result, Array, Match, Data, Predicate } from "effect"; +import { dual } from "effect/Function"; import type { PersonId } from "../organization/schema.js"; import { ReceiptApprovalGrantId, @@ -33,93 +34,127 @@ export type AuthzApplicabilityFacts = { readonly tagAssignments: ReadonlyArray; }; -export const isAuthzIntervalActive = ( - interval: { readonly startAt: string; readonly endAt: string | null }, - authorizationInstant: string, -): boolean => - compareRfc3339Instants(interval.startAt, authorizationInstant) <= 0 && - (interval.endAt === null || compareRfc3339Instants(authorizationInstant, interval.endAt) < 0); - -export const isAuthzTagAssignmentActive = ( - assignment: AuthzTagAssignment, - personId: PersonId, - authorizationInstant: string, -): boolean => - assignment.personId === personId && isAuthzIntervalActive(assignment, authorizationInstant); - -export const authzRuleSubjectApplies = ( - rule: AuthzRule, - principal: Principal, - authorizationInstant: string, - tagAssignments: ReadonlyArray, -): boolean => { - const subject = rule.subject; - - return Match.value(subject).pipe( - Match.withReturnType(), - Match.tag("Person", (subject) => { - return Predicate.isTagged(principal, "Person") && subject.personId === principal.personId; - }), - Match.tag("ServicePrincipal", (subject) => { - return ( - Predicate.isTagged(principal, "ServicePrincipal") && - subject.servicePrincipalId === principal.servicePrincipalId - ); - }), - Match.tag("Tag", (subject) => { - return ( - Predicate.isTagged(principal, "Person") && - tagAssignments.some( - (assignment) => - assignment.tagId === subject.tagId && - isAuthzTagAssignmentActive(assignment, principal.personId, authorizationInstant), - ) - ); - }), - Match.exhaustive, - ); -}; - -export const authzRuleScopeApplies = ( - rule: AuthzRule, - context: CanonicalResourceContext, -): boolean => { - return Match.value(rule.scope).pipe( +export const isAuthzIntervalActive: { + ( + authorizationInstant: string, + ): (interval: { readonly startAt: string; readonly endAt: string | null }) => boolean; + ( + interval: { readonly startAt: string; readonly endAt: string | null }, + authorizationInstant: string, + ): boolean; +} = dual( + 2, + ( + interval: { readonly startAt: string; readonly endAt: string | null }, + authorizationInstant: string, + ): boolean => + compareRfc3339Instants(interval.startAt, authorizationInstant) <= 0 && + (interval.endAt === null || compareRfc3339Instants(authorizationInstant, interval.endAt) < 0), +); + +export const isAuthzTagAssignmentActive: { + (personId: PersonId, authorizationInstant: string): (assignment: AuthzTagAssignment) => boolean; + (assignment: AuthzTagAssignment, personId: PersonId, authorizationInstant: string): boolean; +} = dual( + 3, + (assignment: AuthzTagAssignment, personId: PersonId, authorizationInstant: string): boolean => + assignment.personId === personId && isAuthzIntervalActive(assignment, authorizationInstant), +); + +export const authzRuleSubjectApplies: { + ( + principal: Principal, + authorizationInstant: string, + tagAssignments: ReadonlyArray, + ): (rule: AuthzRule) => boolean; + ( + rule: AuthzRule, + principal: Principal, + authorizationInstant: string, + tagAssignments: ReadonlyArray, + ): boolean; +} = dual( + 4, + ( + rule: AuthzRule, + principal: Principal, + authorizationInstant: string, + tagAssignments: ReadonlyArray, + ): boolean => { + const subject = rule.subject; + + return Match.value(subject).pipe( + Match.withReturnType(), + Match.tag( + "Person", + (subject) => + Predicate.isTagged(principal, "Person") && subject.personId === principal.personId, + ), + Match.tag( + "ServicePrincipal", + (subject) => + Predicate.isTagged(principal, "ServicePrincipal") && + subject.servicePrincipalId === principal.servicePrincipalId, + ), + Match.tag( + "Tag", + (subject) => + Predicate.isTagged(principal, "Person") && + tagAssignments.some( + (assignment) => + assignment.tagId === subject.tagId && + isAuthzTagAssignmentActive(assignment, principal.personId, authorizationInstant), + ), + ), + Match.exhaustive, + ); + }, +); + +export const authzRuleScopeApplies: { + (context: CanonicalResourceContext): (rule: AuthzRule) => boolean; + (rule: AuthzRule, context: CanonicalResourceContext): boolean; +} = dual(2, (rule: AuthzRule, context: CanonicalResourceContext): boolean => + Match.value(rule.scope).pipe( Match.withReturnType(), - Match.tag("Global", () => { - return true; - }), - Match.tag("Domain", (matchedValue) => { - return context.domainId === matchedValue.domainId; - }), - Match.tag("Department", (matchedValue) => { - return context.departmentId === matchedValue.departmentId; - }), - Match.tag("Resource", (matchedValue) => { - return ( + Match.tag("Global", () => true), + Match.tag("Domain", (matchedValue) => context.domainId === matchedValue.domainId), + Match.tag("Department", (matchedValue) => context.departmentId === matchedValue.departmentId), + Match.tag( + "Resource", + (matchedValue) => context.resource !== null && context.resource.kind === matchedValue.resource.kind && - context.resource.id === matchedValue.resource.id - ); - }), + context.resource.id === matchedValue.resource.id, + ), Match.exhaustive, - ); -}; - -export const isAuthzRuleApplicable = (rule: AuthzRule, facts: AuthzApplicabilityFacts): boolean => - isAuthzIntervalActive(rule, facts.authorizationInstant) && - authzRuleSubjectApplies( - rule, - facts.principal, - facts.authorizationInstant, - facts.tagAssignments, - ) && - authzRuleScopeApplies(rule, facts.context); - -export const applicableAuthzRules = ( - rules: ReadonlyArray, - facts: AuthzApplicabilityFacts, -): ReadonlyArray => rules.filter((rule) => isAuthzRuleApplicable(rule, facts)); + ), +); + +export const isAuthzRuleApplicable: { + (facts: AuthzApplicabilityFacts): (rule: AuthzRule) => boolean; + (rule: AuthzRule, facts: AuthzApplicabilityFacts): boolean; +} = dual( + 2, + (rule: AuthzRule, facts: AuthzApplicabilityFacts): boolean => + isAuthzIntervalActive(rule, facts.authorizationInstant) && + authzRuleSubjectApplies( + rule, + facts.principal, + facts.authorizationInstant, + facts.tagAssignments, + ) && + authzRuleScopeApplies(rule, facts.context), +); + +export const applicableAuthzRules: { + (facts: AuthzApplicabilityFacts): (rules: ReadonlyArray) => ReadonlyArray; + (rules: ReadonlyArray, facts: AuthzApplicabilityFacts): ReadonlyArray; +} = dual( + 2, + (rules: ReadonlyArray, facts: AuthzApplicabilityFacts): ReadonlyArray => + rules.filter((rule) => isAuthzRuleApplicable(rule, facts)), +); const compareText = (left: string, right: string): -1 | 0 | 1 => left < right ? -1 : left > right ? 1 : 0; @@ -175,57 +210,72 @@ const stableParameters = (parameters: TypedRequirement["parameters"]): string => ), ); -export const evaluateCapabilityRequirements = ( - capabilityId: AuthzCapabilityId, - contributions: ReadonlyArray, - principal: Principal, - context: CanonicalResourceContext, -): CapabilityRequirementResult => { - const declaredOrder = CAPABILITY_IDS[capabilityId].requirementSlots; - - const orderedIds = [...new Set(contributions.map(({ requirement }) => requirement.id))].sort( - (left, right) => { - const leftIndex = declaredOrder.findIndex((id) => id === left); - const rightIndex = declaredOrder.findIndex((id) => id === right); - - return leftIndex - rightIndex || compareText(left, right); - }, - ); +export const evaluateCapabilityRequirements: { + ( + contributions: ReadonlyArray, + principal: Principal, + context: CanonicalResourceContext, + ): (capabilityId: AuthzCapabilityId) => CapabilityRequirementResult; + ( + capabilityId: AuthzCapabilityId, + contributions: ReadonlyArray, + principal: Principal, + context: CanonicalResourceContext, + ): CapabilityRequirementResult; +} = dual( + 4, + ( + capabilityId: AuthzCapabilityId, + contributions: ReadonlyArray, + principal: Principal, + context: CanonicalResourceContext, + ): CapabilityRequirementResult => { + const declaredOrder = CAPABILITY_IDS[capabilityId].requirementSlots; + + const orderedIds = [...new Set(contributions.map(({ requirement }) => requirement.id))].sort( + (left, right) => { + const leftIndex = declaredOrder.findIndex((id) => id === left); + const rightIndex = declaredOrder.findIndex((id) => id === right); + + return leftIndex - rightIndex || compareText(left, right); + }, + ); - const requirements: EvaluatedRuleRequirement[] = []; + const requirements: EvaluatedRuleRequirement[] = []; - for (const requirementId of orderedIds) { - const matching = contributions.filter(({ requirement }) => requirement.id === requirementId); + for (const requirementId of orderedIds) { + const matching = contributions.filter(({ requirement }) => requirement.id === requirementId); - const parameterValues = [ - ...new Set(matching.map(({ requirement }) => stableParameters(requirement.parameters))), - ]; + const parameterValues = [ + ...new Set(matching.map(({ requirement }) => stableParameters(requirement.parameters))), + ]; - const sourceRuleIds = [...new Set(matching.map(({ sourceRuleId }) => sourceRuleId))].sort( - compareText, - ); + const sourceRuleIds = [...new Set(matching.map(({ sourceRuleId }) => sourceRuleId))].sort( + compareText, + ); - if (parameterValues.length !== 1) { - return CapabilityRequirementResult.Ambiguous({ requirementId, sourceRuleIds }); - } + if (parameterValues.length !== 1) { + return CapabilityRequirementResult.Ambiguous({ requirementId, sourceRuleIds }); + } - const requirement = matching[0]!.requirement; + const requirement = matching[0]!.requirement; - const evaluated = { - requirement, - result: evaluateRequirement(requirement, principal, context), - sourceRuleIds, - }; + const evaluated = { + requirement, + result: evaluateRequirement(requirement, principal, context), + sourceRuleIds, + }; - requirements.push(evaluated); + requirements.push(evaluated); - if (Predicate.isTagged(evaluated.result, "Failed")) { - return CapabilityRequirementResult.Failed({ requirements, failed: evaluated }); + if (Predicate.isTagged(evaluated.result, "Failed")) { + return CapabilityRequirementResult.Failed({ requirements, failed: evaluated }); + } } - } - return CapabilityRequirementResult.Satisfied({ requirements }); -}; + return CapabilityRequirementResult.Satisfied({ requirements }); + }, +); export type ComposedCapabilityEvidence = { readonly evidence: RuleReceptiveEvidence; @@ -241,159 +291,175 @@ const ruleFactId = (ruleId: AuthzRuleId): string => `authz-rule:${ruleId}`; * direct evidence only through frozen receptive slots. With no contributing * rule, `evidence` and the allowed Decision value are the original object. */ -export const composeCapabilityEvidence = ( - capabilityId: AuthzCapabilityId, - directEvidence: RuleReceptiveEvidence, - rules: ReadonlyArray, - requestFacts: AuthzApplicabilityFacts, -): ComposedCapabilityEvidence => { - const applicableRules = [ - ...new Map( - Array.filterMap(applicableAuthzRules(rules, requestFacts), (rule) => - rule.capabilityId === capabilityId - ? Result.succeed([rule.ruleId, rule] as const) - : Result.failVoid, - ), - ).values(), - ].sort((left, right) => compareText(left.ruleId, right.ruleId)); +export const composeCapabilityEvidence: { + ( + directEvidence: RuleReceptiveEvidence, + rules: ReadonlyArray, + requestFacts: AuthzApplicabilityFacts, + ): (capabilityId: AuthzCapabilityId) => ComposedCapabilityEvidence; + ( + capabilityId: AuthzCapabilityId, + directEvidence: RuleReceptiveEvidence, + rules: ReadonlyArray, + requestFacts: AuthzApplicabilityFacts, + ): ComposedCapabilityEvidence; +} = dual( + 4, + ( + capabilityId: AuthzCapabilityId, + directEvidence: RuleReceptiveEvidence, + rules: ReadonlyArray, + requestFacts: AuthzApplicabilityFacts, + ): ComposedCapabilityEvidence => { + const applicableRules = [ + ...new Map( + Array.filterMap(applicableAuthzRules(rules, requestFacts), (rule) => + rule.capabilityId === capabilityId + ? Result.succeed([rule.ruleId, rule] as const) + : Result.failVoid, + ), + ).values(), + ].sort((left, right) => compareText(left.ruleId, right.ruleId)); + + const approvalContributions: Array = []; + const paymentContributions: Array = []; + const requirementContributions: Array = []; + + for (const rule of applicableRules) { + if (Predicate.isTagged(rule.subject, "ServicePrincipal") && rule.effectKind !== "requirement") + continue; - const approvalContributions: Array = []; - const paymentContributions: Array = []; - const requirementContributions: Array = []; + if (rule.effectKind === "requirement") { + if ( + rule.capabilityId !== "approveReceipt" || + !CAPABILITY_IDS.approveReceipt.requirementSlots.includes(rule.params.requirementId) + ) { + continue; + } + + requirementContributions.push({ + requirement: { + id: + rule.params.requirementId === "receipts.pending" + ? RECEIPT_PENDING_REQUIREMENT + : RECEIPT_APPROVER_REQUIREMENT, + parameters: rule.params.parameters, + }, + sourceRuleId: rule.ruleId, + }); + continue; + } - for (const rule of applicableRules) { - if (Predicate.isTagged(rule.subject, "ServicePrincipal") && rule.effectKind !== "requirement") - continue; + if (rule.effectKind !== "delegate" || !Predicate.isTagged(requestFacts.principal, "Person")) + continue; - if (rule.effectKind === "requirement") { if ( - rule.capabilityId !== "approveReceipt" || - !CAPABILITY_IDS.approveReceipt.requirementSlots.includes(rule.params.requirementId) + rule.capabilityId === "approveReceipt" && + CAPABILITY_IDS.approveReceipt.receptiveEvidenceSlots.includes(rule.params.slot) ) { + if (rule.params.slot === "EconomyDepartmentApprovalGrant") { + if (requestFacts.context.departmentId === null) continue; + approvalContributions.push({ + ruleId: rule.ruleId, + fact: { + approvalGrantId: ReceiptApprovalGrantId.make(ruleFactId(rule.ruleId)), + personId: requestFacts.principal.personId, + scope: Scope.Department({ departmentId: requestFacts.context.departmentId }), + startAt: rule.startAt, + endAt: rule.endAt, + revision: rule.revision, + }, + }); + } else { + approvalContributions.push({ + ruleId: rule.ruleId, + fact: { + approvalGrantId: ReceiptApprovalGrantId.make(ruleFactId(rule.ruleId)), + personId: requestFacts.principal.personId, + scope: Scope.Global(), + startAt: rule.startAt, + endAt: rule.endAt, + revision: rule.revision, + }, + }); + } + continue; } - requirementContributions.push({ - requirement: { - id: - rule.params.requirementId === "receipts.pending" - ? RECEIPT_PENDING_REQUIREMENT - : RECEIPT_APPROVER_REQUIREMENT, - parameters: rule.params.parameters, - }, - sourceRuleId: rule.ruleId, - }); - continue; - } - - if (rule.effectKind !== "delegate" || !Predicate.isTagged(requestFacts.principal, "Person")) - continue; - - if ( - rule.capabilityId === "approveReceipt" && - CAPABILITY_IDS.approveReceipt.receptiveEvidenceSlots.includes(rule.params.slot) - ) { - if (rule.params.slot === "EconomyDepartmentApprovalGrant") { - if (requestFacts.context.departmentId === null) continue; - approvalContributions.push({ - ruleId: rule.ruleId, - fact: { - approvalGrantId: ReceiptApprovalGrantId.make(ruleFactId(rule.ruleId)), - personId: requestFacts.principal.personId, - scope: Scope.Department({ departmentId: requestFacts.context.departmentId }), - startAt: rule.startAt, - endAt: rule.endAt, - revision: rule.revision, - }, - }); - } else { - approvalContributions.push({ + if ( + rule.capabilityId === "submitReceipt" && + rule.params.slot === "EconomyPaymentAuthority" && + CAPABILITY_IDS.submitReceipt.receptiveEvidenceSlots.includes(rule.params.slot) && + requestFacts.context.departmentId !== null + ) { + paymentContributions.push({ ruleId: rule.ruleId, fact: { - approvalGrantId: ReceiptApprovalGrantId.make(ruleFactId(rule.ruleId)), + paymentAuthorityId: ReceiptPaymentAuthorityId.make(ruleFactId(rule.ruleId)), personId: requestFacts.principal.personId, - scope: Scope.Global(), + departmentId: requestFacts.context.departmentId, + paymentAccountCiphertext: rule.params.paymentAccountCiphertext, startAt: rule.startAt, endAt: rule.endAt, revision: rule.revision, }, }); } - - continue; - } - - if ( - rule.capabilityId === "submitReceipt" && - rule.params.slot === "EconomyPaymentAuthority" && - CAPABILITY_IDS.submitReceipt.receptiveEvidenceSlots.includes(rule.params.slot) && - requestFacts.context.departmentId !== null - ) { - paymentContributions.push({ - ruleId: rule.ruleId, - fact: { - paymentAuthorityId: ReceiptPaymentAuthorityId.make(ruleFactId(rule.ruleId)), - personId: requestFacts.principal.personId, - departmentId: requestFacts.context.departmentId, - paymentAccountCiphertext: rule.params.paymentAccountCiphertext, - startAt: rule.startAt, - endAt: rule.endAt, - revision: rule.revision, - }, - }); } - } - const hasGeneratedEvidence = approvalContributions.length > 0 || paymentContributions.length > 0; + const hasGeneratedEvidence = + approvalContributions.length > 0 || paymentContributions.length > 0; - let evidence: RuleReceptiveEvidence = directEvidence; + let evidence: RuleReceptiveEvidence = directEvidence; - if (hasGeneratedEvidence) { - evidence = {}; + if (hasGeneratedEvidence) { + evidence = {}; - if (directEvidence.approvalGrants !== undefined || approvalContributions.length > 0) { - evidence = { - ...evidence, - approvalGrants: [ - ...(directEvidence.approvalGrants ?? []), - ...approvalContributions.map(({ fact }) => fact), - ], - }; - } + if (directEvidence.approvalGrants !== undefined || approvalContributions.length > 0) { + evidence = { + ...evidence, + approvalGrants: [ + ...(directEvidence.approvalGrants ?? []), + ...approvalContributions.map(({ fact }) => fact), + ], + }; + } - if (directEvidence.paymentAuthorities !== undefined || paymentContributions.length > 0) { - evidence = { - ...evidence, - paymentAuthorities: [ - ...(directEvidence.paymentAuthorities ?? []), - ...paymentContributions.map(({ fact }) => fact), - ], - }; + if (directEvidence.paymentAuthorities !== undefined || paymentContributions.length > 0) { + evidence = { + ...evidence, + paymentAuthorities: [ + ...(directEvidence.paymentAuthorities ?? []), + ...paymentContributions.map(({ fact }) => fact), + ], + }; + } } - } - const requirements = evaluateCapabilityRequirements( - capabilityId, - requirementContributions, - requestFacts.principal, - requestFacts.context, - ); + const requirements = evaluateCapabilityRequirements( + capabilityId, + requirementContributions, + requestFacts.principal, + requestFacts.context, + ); - const decision = Predicate.isTagged(requirements, "Ambiguous") - ? deny("Ambiguous") - : Predicate.isTagged(requirements, "Failed") - ? deny("RequirementFailed") - : allow(evidence); - - const contributingRuleIds = [ - ...new Set( - [ - ...approvalContributions.map(({ ruleId }) => ruleId), - ...paymentContributions.map(({ ruleId }) => ruleId), - ...requirementContributions.map(({ sourceRuleId }) => sourceRuleId), - ].sort(compareText), - ), - ]; + const decision = Predicate.isTagged(requirements, "Ambiguous") + ? deny("Ambiguous") + : Predicate.isTagged(requirements, "Failed") + ? deny("RequirementFailed") + : allow(evidence); + + const contributingRuleIds = [ + ...new Set( + [ + ...approvalContributions.map(({ ruleId }) => ruleId), + ...paymentContributions.map(({ ruleId }) => ruleId), + ...requirementContributions.map(({ sourceRuleId }) => sourceRuleId), + ].sort(compareText), + ), + ]; - return { evidence, requirements, decision, contributingRuleIds }; -}; + return { evidence, requirements, decision, contributingRuleIds }; + }, +); diff --git a/packages/domain/src/authz/service-principal-grants.ts b/packages/domain/src/authz/service-principal-grants.ts index 16660d671..85733ff95 100644 --- a/packages/domain/src/authz/service-principal-grants.ts +++ b/packages/domain/src/authz/service-principal-grants.ts @@ -1,4 +1,5 @@ import { Result, Array, Predicate, Context, Data, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { DepartmentId, PersonId } from "../organization/schema.js"; import { ReceiptId, @@ -29,7 +30,7 @@ import { ResourceId, ServicePrincipalId, } from "./access.js"; -import { composeCapabilityEvidence } from "./rules.js"; +import { composeCapabilityEvidence, type ComposedCapabilityEvidence } from "./rules.js"; import type { AuthzRule } from "./schema.js"; const TrimmedNonEmpty = Schema.String.pipe( @@ -182,149 +183,179 @@ export interface ServicePrincipalGrantAuthorityOperations { export class ServicePrincipalGrantAuthority extends Context.Service< ServicePrincipalGrantAuthority, ServicePrincipalGrantAuthorityOperations ->()("@vektorprogrammet/domain/ServicePrincipalGrantAuthority") {} +>()("@vektorprogrammet/domain/authz/service-principal-grants/ServicePrincipalGrantAuthority") {} -export const makeServicePrincipalReceiptGrant = Schema.decodeUnknownSync( - ServicePrincipalReceiptGrantSchema, - { +/** Decodes JSON data into a service principal receipt grant, rejecting properties the schema does not name. */ +export const makeServicePrincipalReceiptGrant = ( + input: Schema.Json, +): ServicePrincipalReceiptGrant => + Schema.decodeUnknownSync(ServicePrincipalReceiptGrantSchema)(input, { onExcessProperty: "error", - }, -); - -export const composeServicePrincipalReceiptRuleRequirements = ( - authority: ServicePrincipalReceiptGrantAuthority, - context: CanonicalResourceContext, - authorizationInstant: AuthorizationInstant, -) => - composeCapabilityEvidence("approveReceipt", {}, authority.rules, { - principal: PrincipalSchema.cases.ServicePrincipal.make({ - servicePrincipalId: authority.servicePrincipalId, - }), - authorizationInstant, - context, - tagAssignments: [], }); -export const evaluateServicePrincipalReceiptApprovalAccess = ( - credential: AcceptedOAuthServiceCredential, - authority: ServicePrincipalReceiptGrantAuthority, - authorizationInstant: AuthorizationInstant, -): AccessEvaluation => { - if ( - credential.principal.servicePrincipalId !== authority.servicePrincipalId || - authority.protectedResource !== NATIVE_API_PROTECTED_RESOURCE - ) { - return AccessEvaluation.Deny({ stage: "PrincipalKind", reason: "PrincipalKindNotAccepted" }); - } - - const candidateByReceipt = new Map(); - const activeGrants: Array = []; - - for (const candidate of authority.candidates) { - const grant = candidate.grant; +export const composeServicePrincipalReceiptRuleRequirements: { + ( + context: CanonicalResourceContext, + authorizationInstant: AuthorizationInstant, + ): (authority: ServicePrincipalReceiptGrantAuthority) => ComposedCapabilityEvidence; + ( + authority: ServicePrincipalReceiptGrantAuthority, + context: CanonicalResourceContext, + authorizationInstant: AuthorizationInstant, + ): ComposedCapabilityEvidence; +} = dual( + 3, + ( + authority: ServicePrincipalReceiptGrantAuthority, + context: CanonicalResourceContext, + authorizationInstant: AuthorizationInstant, + ): ComposedCapabilityEvidence => + composeCapabilityEvidence("approveReceipt", {}, authority.rules, { + principal: PrincipalSchema.cases.ServicePrincipal.make({ + servicePrincipalId: authority.servicePrincipalId, + }), + authorizationInstant, + context, + tagAssignments: [], + }), +); +export const evaluateServicePrincipalReceiptApprovalAccess: { + ( + authority: ServicePrincipalReceiptGrantAuthority, + authorizationInstant: AuthorizationInstant, + ): (credential: AcceptedOAuthServiceCredential) => AccessEvaluation; + ( + credential: AcceptedOAuthServiceCredential, + authority: ServicePrincipalReceiptGrantAuthority, + authorizationInstant: AuthorizationInstant, + ): AccessEvaluation; +} = dual( + 3, + ( + credential: AcceptedOAuthServiceCredential, + authority: ServicePrincipalReceiptGrantAuthority, + authorizationInstant: AuthorizationInstant, + ): AccessEvaluation => { if ( - grant.servicePrincipalId !== authority.servicePrincipalId || - grant.clientId !== authority.clientId || - grant.protectedResource !== authority.protectedResource || - grant.operationId !== RECEIPT_APPROVAL_QUEUE_OPERATION || - grant.capabilityId !== "approveReceipt" || - grant.resourceKind !== RECEIPT_RESOURCE_KIND || - grant.receiptId !== candidate.receipt.receiptId || - !servicePrincipalReceiptGrantActiveAt(grant, authorizationInstant) + credential.principal.servicePrincipalId !== authority.servicePrincipalId || + authority.protectedResource !== NATIVE_API_PROTECTED_RESOURCE ) { - continue; + return AccessEvaluation.Deny({ stage: "PrincipalKind", reason: "PrincipalKindNotAccepted" }); } - activeGrants.push(grant); - - if (!candidateByReceipt.has(candidate.receipt.receiptId)) { - candidateByReceipt.set(candidate.receipt.receiptId, candidate); + const candidateByReceipt = new Map(); + const activeGrants: Array = []; + + for (const candidate of authority.candidates) { + const grant = candidate.grant; + + if ( + grant.servicePrincipalId !== authority.servicePrincipalId || + grant.clientId !== authority.clientId || + grant.protectedResource !== authority.protectedResource || + grant.operationId !== RECEIPT_APPROVAL_QUEUE_OPERATION || + grant.capabilityId !== "approveReceipt" || + grant.resourceKind !== RECEIPT_RESOURCE_KIND || + grant.receiptId !== candidate.receipt.receiptId || + !servicePrincipalReceiptGrantActiveAt(grant, authorizationInstant) + ) { + continue; + } + + activeGrants.push(grant); + + if (!candidateByReceipt.has(candidate.receipt.receiptId)) { + candidateByReceipt.set(candidate.receipt.receiptId, candidate); + } } - } - if (activeGrants.length === 0) { - return AccessEvaluation.Deny({ stage: "Capability", reason: "CapabilityMissing" }); - } + if (activeGrants.length === 0) { + return AccessEvaluation.Deny({ stage: "Capability", reason: "CapabilityMissing" }); + } - const contexts = [...candidateByReceipt.values()] - .sort((left, right) => compareText(left.receipt.receiptId, right.receipt.receiptId)) - .map(({ receipt }) => ({ - domainId: RECEIPT_DOMAIN_ID, - departmentId: receipt.departmentId, - resource: { - kind: RECEIPT_RESOURCE_KIND, - id: ResourceId.make(receipt.receiptId), - }, - facts: { - ownerPersonId: receipt.ownerPersonId, - state: receipt.status, - approverPersonIds: [], - approverServicePrincipalIds: [authority.servicePrincipalId], - internalEvidenceEnabled: false, + const contexts = [...candidateByReceipt.values()] + .sort((left, right) => compareText(left.receipt.receiptId, right.receipt.receiptId)) + .map(({ receipt }) => ({ + domainId: RECEIPT_DOMAIN_ID, + departmentId: receipt.departmentId, + resource: { + kind: RECEIPT_RESOURCE_KIND, + id: ResourceId.make(receipt.receiptId), + }, + facts: { + ownerPersonId: receipt.ownerPersonId, + state: receipt.status, + approverPersonIds: [], + approverServicePrincipalIds: [authority.servicePrincipalId], + internalEvidenceEnabled: false, + }, + authorityVersion: AuthorityVersion.make( + [ + `service-principal:${authority.servicePrincipalId}`, + `client:${authority.clientId}`, + `receipt:${receipt.receiptId}:${receipt.revision}`, + ...Array.filterMap(activeGrants, (grant) => + grant.receiptId === receipt.receiptId + ? Result.succeed(`grant:${grant.grantId}:${grant.revision}`) + : Result.failVoid, + ).sort(compareText), + ...authority.rules + .map((rule) => `rule:${rule.ruleId}:${rule.revision}`) + .sort(compareText), + ].join("|"), + ), + })); + + const baseEvaluation = evaluateAccess({ + spec: RECEIPT_APPROVAL_QUEUE_ACCESS, + credential, + resolution: { + selection: "AllMatching", + contexts, }, - authorityVersion: AuthorityVersion.make( - [ - `service-principal:${authority.servicePrincipalId}`, - `client:${authority.clientId}`, - `receipt:${receipt.receiptId}:${receipt.revision}`, - ...Array.filterMap(activeGrants, (grant) => - grant.receiptId === receipt.receiptId - ? Result.succeed(`grant:${grant.grantId}:${grant.revision}`) - : Result.failVoid, - ).sort(compareText), - ...authority.rules - .map((rule) => `rule:${rule.ruleId}:${rule.revision}`) - .sort(compareText), - ].join("|"), - ), - })); - - const baseEvaluation = evaluateAccess({ - spec: RECEIPT_APPROVAL_QUEUE_ACCESS, - credential, - resolution: { - selection: "AllMatching", - contexts, - }, - grants: activeGrants - .sort((left, right) => compareText(left.grantId, right.grantId)) - .map(servicePrincipalReceiptGrantToAccessGrant), - authorizationInstant, - }); + grants: activeGrants + .sort((left, right) => compareText(left.grantId, right.grantId)) + .map(servicePrincipalReceiptGrantToAccessGrant), + authorizationInstant, + }); - if (!Predicate.isTagged(baseEvaluation, "Allow")) return baseEvaluation; + if (!Predicate.isTagged(baseEvaluation, "Allow")) return baseEvaluation; - const allowedContexts = baseEvaluation.resolution.contexts.filter((context) => { - const composition = composeServicePrincipalReceiptRuleRequirements( - authority, - context, - authorizationInstant, - ); + const allowedContexts = baseEvaluation.resolution.contexts.filter((context) => { + const composition = composeServicePrincipalReceiptRuleRequirements( + authority, + context, + authorizationInstant, + ); - return Predicate.isTagged(composition.decision, "Allow"); - }); + return Predicate.isTagged(composition.decision, "Allow"); + }); - if (allowedContexts.length === 0) { - return AccessEvaluation.Deny({ stage: "Requirement", reason: "RequirementFailed" }); - } - - return { - ...baseEvaluation, - resolution: { - ...baseEvaluation.resolution, - contexts: allowedContexts, - }, - }; -}; + if (allowedContexts.length === 0) { + return AccessEvaluation.Deny({ stage: "Requirement", reason: "RequirementFailed" }); + } -export const servicePrincipalReceiptGrantActiveAt = ( - grant: ServicePrincipalReceiptGrant, - authorizationInstant: AuthorizationInstant, -): boolean => - grant.revokedAt === null && - compareRfc3339Instants(grant.startAt, authorizationInstant) <= 0 && - (grant.endAt === null || compareRfc3339Instants(authorizationInstant, grant.endAt) < 0); + return { + ...baseEvaluation, + resolution: { + ...baseEvaluation.resolution, + contexts: allowedContexts, + }, + }; + }, +); + +export const servicePrincipalReceiptGrantActiveAt: { + (authorizationInstant: AuthorizationInstant): (grant: ServicePrincipalReceiptGrant) => boolean; + (grant: ServicePrincipalReceiptGrant, authorizationInstant: AuthorizationInstant): boolean; +} = dual( + 2, + (grant: ServicePrincipalReceiptGrant, authorizationInstant: AuthorizationInstant): boolean => + grant.revokedAt === null && + compareRfc3339Instants(grant.startAt, authorizationInstant) <= 0 && + (grant.endAt === null || compareRfc3339Instants(authorizationInstant, grant.endAt) < 0), +); export const servicePrincipalReceiptGrantToAccessGrant = ( grant: ServicePrincipalReceiptGrant, @@ -351,37 +382,65 @@ export const servicePrincipalReceiptGrantToAccessGrant = ( const compareText = (left: string, right: string): -1 | 0 | 1 => left < right ? -1 : left > right ? 1 : 0; -export const activeServicePrincipalReceiptGrants = ( - grants: ReadonlyArray, - servicePrincipalId: ServicePrincipalId, - authorizationInstant: AuthorizationInstant, -): ReadonlyArray => - [ - ...new Map( - Array.filterMap(grants, (grant) => - grant.servicePrincipalId === servicePrincipalId && - servicePrincipalReceiptGrantActiveAt(grant, authorizationInstant) - ? Result.succeed([grant.grantId, grant] as const) - : Result.failVoid, - ), - ).values(), - ].sort((left, right) => compareText(left.grantId, right.grantId)); - -export const servicePrincipalApproverIdsForContext = ( - grants: ReadonlyArray, - context: CanonicalResourceContext, - authorizationInstant: AuthorizationInstant, -): ReadonlyArray => { - if (context.resource === null || context.resource.kind !== RECEIPT_RESOURCE_KIND) return []; - - return [ - ...new Set( - Array.filterMap(grants, (grant) => - servicePrincipalReceiptGrantActiveAt(grant, authorizationInstant) && - ResourceId.make(grant.receiptId) === context.resource?.id - ? Result.succeed(grant.servicePrincipalId) - : Result.failVoid, +export const activeServicePrincipalReceiptGrants: { + ( + servicePrincipalId: ServicePrincipalId, + authorizationInstant: AuthorizationInstant, + ): ( + grants: ReadonlyArray, + ) => ReadonlyArray; + ( + grants: ReadonlyArray, + servicePrincipalId: ServicePrincipalId, + authorizationInstant: AuthorizationInstant, + ): ReadonlyArray; +} = dual( + 3, + ( + grants: ReadonlyArray, + servicePrincipalId: ServicePrincipalId, + authorizationInstant: AuthorizationInstant, + ): ReadonlyArray => + [ + ...new Map( + Array.filterMap(grants, (grant) => + grant.servicePrincipalId === servicePrincipalId && + servicePrincipalReceiptGrantActiveAt(grant, authorizationInstant) + ? Result.succeed([grant.grantId, grant] as const) + : Result.failVoid, + ), + ).values(), + ].sort((left, right) => compareText(left.grantId, right.grantId)), +); + +export const servicePrincipalApproverIdsForContext: { + ( + context: CanonicalResourceContext, + authorizationInstant: AuthorizationInstant, + ): (grants: ReadonlyArray) => ReadonlyArray; + ( + grants: ReadonlyArray, + context: CanonicalResourceContext, + authorizationInstant: AuthorizationInstant, + ): ReadonlyArray; +} = dual( + 3, + ( + grants: ReadonlyArray, + context: CanonicalResourceContext, + authorizationInstant: AuthorizationInstant, + ): ReadonlyArray => { + if (context.resource === null || context.resource.kind !== RECEIPT_RESOURCE_KIND) return []; + + return [ + ...new Set( + Array.filterMap(grants, (grant) => + servicePrincipalReceiptGrantActiveAt(grant, authorizationInstant) && + ResourceId.make(grant.receiptId) === context.resource?.id + ? Result.succeed(grant.servicePrincipalId) + : Result.failVoid, + ), ), - ), - ].sort(compareText); -}; + ].sort(compareText); + }, +); diff --git a/packages/domain/src/contact/schema.ts b/packages/domain/src/contact/schema.ts index 17f0ed0c2..82de9288a 100644 --- a/packages/domain/src/contact/schema.ts +++ b/packages/domain/src/contact/schema.ts @@ -46,7 +46,8 @@ export type ContactMessage = typeof ContactMessage.Type; /** Normalize address identity; networks, zones, lists and ports are not visitor addresses. */ export const canonicalContactIp = (raw: string): Option.Option => { - if (!raw || /[\s,/%]/u.test(raw) || raw.includes("[") || raw.includes("]")) return Option.none(); + if (raw === "" || /[\s,/%]/u.test(raw) || raw.includes("[") || raw.includes("]")) + return Option.none(); if (Address4.isValid(raw)) return Option.some(new Address4(raw).correctForm()); diff --git a/packages/domain/src/contact/service.ts b/packages/domain/src/contact/service.ts index 0f73532af..648f3f000 100644 --- a/packages/domain/src/contact/service.ts +++ b/packages/domain/src/contact/service.ts @@ -1,6 +1,7 @@ import { Predicate, Context, Data, Effect, Schema } from "effect"; import { Organization } from "../organization/service.js"; import { ContactEmail, type ContactMessage, type ContactVisitorIp } from "./schema.js"; +import { dual } from "effect/Function"; export class ContactFailure extends Data.TaggedError("ContactFailure")<{ readonly reason: "InvalidRecipient" | "RateLimited" | "Unavailable"; @@ -19,43 +20,59 @@ export class ContactDelivery extends Context.Service< { readonly send: (envelope: ContactEnvelope) => Effect.Effect; } ->()("@vektorprogrammet/ContactDelivery") {} +>()("@vektorprogrammet/domain/contact/service/ContactDelivery") {} export class ContactQuota extends Context.Service< ContactQuota, { readonly consume: (ip: ContactVisitorIp) => Effect.Effect; } ->()("@vektorprogrammet/ContactQuota") {} +>()("@vektorprogrammet/domain/contact/service/ContactQuota") {} /** Quota commits before recipient lookup or delivery, so neither failure refunds an attempt. */ -export const submitContact = (message: ContactMessage, ip: ContactVisitorIp) => - Effect.gen(function* () { - const quota = yield* ContactQuota; - yield* quota.consume(ip); - const organization = yield* Organization; - - const department = yield* organization.readDepartment(message.departmentId).pipe( - Effect.mapError( - (error) => - new ContactFailure({ - reason: Predicate.isTagged(error, "DepartmentNotFound") - ? "InvalidRecipient" - : "Unavailable", - }), - ), - ); - - if (!department.active || !Schema.is(ContactEmail)(department.email)) { - return yield* new ContactFailure({ reason: "InvalidRecipient" }); - } - - const delivery = yield* ContactDelivery; - yield* delivery.send({ - to: department.email, - replyTo: message.email, - name: message.name, - subject: message.subject, - message: message.message, - }); - }); +export const submitContact: { + ( + ip: ContactVisitorIp, + ): ( + message: ContactMessage, + ) => Effect.Effect; + ( + message: ContactMessage, + ip: ContactVisitorIp, + ): Effect.Effect; +} = dual( + 2, + ( + message: ContactMessage, + ip: ContactVisitorIp, + ): Effect.Effect => + Effect.gen(function* () { + const quota = yield* ContactQuota; + yield* quota.consume(ip); + const organization = yield* Organization; + + const department = yield* organization.readDepartment(message.departmentId).pipe( + Effect.mapError( + (error) => + new ContactFailure({ + reason: Predicate.isTagged(error, "DepartmentNotFound") + ? "InvalidRecipient" + : "Unavailable", + }), + ), + ); + + if (!department.active || !Schema.is(ContactEmail)(department.email)) { + return yield* new ContactFailure({ reason: "InvalidRecipient" }); + } + + const delivery = yield* ContactDelivery; + yield* delivery.send({ + to: department.email, + replyTo: message.email, + name: message.name, + subject: message.subject, + message: message.message, + }); + }), +); diff --git a/packages/domain/src/content/actor.ts b/packages/domain/src/content/actor.ts index 5a468de11..f752dacd5 100644 --- a/packages/domain/src/content/actor.ts +++ b/packages/domain/src/content/actor.ts @@ -1,4 +1,5 @@ import { Match, Data, Predicate } from "effect"; +import { dual } from "effect/Function"; import { allow, deny, type Decision } from "../authz/decision.js"; import { reachedDepartments, ReachedDepartments } from "../authz/reach.js"; import type { OrganizationPersonAuthority } from "../organization/authority.js"; @@ -81,27 +82,24 @@ export type ContentScope = export const ContentScope = Data.taggedEnum(); -export const contentScopeFor = (actor: ContentActor): ContentScope => { - return Match.value(actor).pipe( +export const contentScopeFor = (actor: ContentActor): ContentScope => + Match.value(actor).pipe( Match.withReturnType(), - Match.tag("ContentAdministrator", () => { - return ContentScope.All(); - }), - Match.tag("ContentPublisher", (actor) => { - return ContentScope.DepartmentIds({ departmentIds: actor.departmentIds }); - }), - Match.tag("ContentEditor", (actor) => { - return ContentScope.DepartmentIds({ departmentIds: actor.departmentIds }); - }), + Match.tag("ContentAdministrator", () => ContentScope.All()), + Match.tag("ContentPublisher", (actor) => + ContentScope.DepartmentIds({ departmentIds: actor.departmentIds }), + ), + Match.tag("ContentEditor", (actor) => + ContentScope.DepartmentIds({ departmentIds: actor.departmentIds }), + ), Match.exhaustive, ); -}; /** Whether the actor may set sticky or run publish/unpublish transitions. */ -export const canPublishContent = ( - actor: ContentActor, - articleDepartmentIds: ReadonlyArray, -): boolean => { +export const canPublishContent: { + (articleDepartmentIds: ReadonlyArray): (actor: ContentActor) => boolean; + (actor: ContentActor, articleDepartmentIds: ReadonlyArray): boolean; +} = dual(2, (actor: ContentActor, articleDepartmentIds: ReadonlyArray): boolean => { if (Predicate.isTagged(actor, "ContentAdministrator")) return true; if (!Predicate.isTagged(actor, "ContentPublisher")) return false; @@ -110,41 +108,58 @@ export const canPublishContent = ( if (articleDepartmentIds.length === 0) return false; return articleDepartmentIds.some((departmentId) => actor.departmentIds.includes(departmentId)); -}; +}); /** * Draft ownership boundary (spec 0062): editors revise only their own drafts * carrying at least one department within their active memberships; leaders * revise any draft scoped to their departments; administrators span all. */ -export const canReviseDraft = ( - actor: ContentActor, - draft: { +export const canReviseDraft: { + (draft: { readonly createdByPersonId: PersonId; readonly currentVersionNumber: number | null; readonly departmentIds: ReadonlyArray; - }, -): boolean => { - if (Predicate.isTagged(actor, "ContentAdministrator")) return true; - - if (Predicate.isTagged(actor, "ContentEditor")) { - const editor = actor; - - return ( - draft.currentVersionNumber === null && - draft.createdByPersonId === editor.personId && - draft.departmentIds.length > 0 && - draft.departmentIds.every((departmentId) => - authorityDepartmentIds(actor).includes(departmentId), - ) - ); - } + }): (actor: ContentActor) => boolean; + ( + actor: ContentActor, + draft: { + readonly createdByPersonId: PersonId; + readonly currentVersionNumber: number | null; + readonly departmentIds: ReadonlyArray; + }, + ): boolean; +} = dual( + 2, + ( + actor: ContentActor, + draft: { + readonly createdByPersonId: PersonId; + readonly currentVersionNumber: number | null; + readonly departmentIds: ReadonlyArray; + }, + ): boolean => { + if (Predicate.isTagged(actor, "ContentAdministrator")) return true; + + if (Predicate.isTagged(actor, "ContentEditor")) { + const editor = actor; + + return ( + draft.currentVersionNumber === null && + draft.createdByPersonId === editor.personId && + draft.departmentIds.length > 0 && + draft.departmentIds.every((departmentId) => + authorityDepartmentIds(actor).includes(departmentId), + ) + ); + } - // Publisher: any non-org-wide draft intersecting the leader's departments. - if (draft.departmentIds.length === 0) return false; + // Publisher: any non-org-wide draft intersecting the leader's departments. + if (draft.departmentIds.length === 0) return false; - return draft.departmentIds.some((departmentId) => actor.departmentIds.includes(departmentId)); -}; + return draft.departmentIds.some((departmentId) => actor.departmentIds.includes(departmentId)); + }, +); const authorityDepartmentIds = (actor: ContentActor): ReadonlyArray => Predicate.isTagged(actor, "ContentAdministrator") ? [] : actor.departmentIds; diff --git a/packages/domain/src/content/content-service.ts b/packages/domain/src/content/content-service.ts index edbd459aa..17354fc9c 100644 --- a/packages/domain/src/content/content-service.ts +++ b/packages/domain/src/content/content-service.ts @@ -37,5 +37,5 @@ export interface ContentOperations { } export class Content extends Context.Service()( - "@vektorprogrammet/domain/Content", + "@vektorprogrammet/domain/content/content-service/Content", ) {} diff --git a/packages/domain/src/content/journeys.ts b/packages/domain/src/content/journeys.ts index d30772321..1765c5e43 100644 --- a/packages/domain/src/content/journeys.ts +++ b/packages/domain/src/content/journeys.ts @@ -13,8 +13,9 @@ import type { UnpublishArticleInput, } from "./schema.js"; import { ContentManagement } from "./service.js"; +import { dual } from "effect/Function"; -export const runContentWorkspace = ( +const runContentWorkspaceImpl = ( personId: PersonId, authorizationInstant: OrganizationAuthorityInstant, query: ContentWorkspaceQuery, @@ -27,7 +28,19 @@ export const runContentWorkspace = ( return yield* content.readWorkspace({ personId, authorizationInstant }, query); }); -export const runContentArticleDetail = ( +export const runContentWorkspace: { + ( + authorizationInstant: OrganizationAuthorityInstant, + query: ContentWorkspaceQuery, + ): (personId: PersonId) => ReturnType; + ( + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + query: ContentWorkspaceQuery, + ): ReturnType; +} = dual(3, runContentWorkspaceImpl); + +const runContentArticleDetailImpl = ( personId: PersonId, authorizationInstant: OrganizationAuthorityInstant, articleId: ArticleId, @@ -40,13 +53,25 @@ export const runContentArticleDetail = ( return yield* content.readArticleDetail(articleId, { personId, authorizationInstant }); }); +export const runContentArticleDetail: { + ( + authorizationInstant: OrganizationAuthorityInstant, + articleId: ArticleId, + ): (personId: PersonId) => ReturnType; + ( + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + articleId: ArticleId, + ): ReturnType; +} = dual(3, runContentArticleDetailImpl); + export type ContentManagementCommand = | { readonly _tag: "CreateDraft"; readonly command: CreateArticleDraftInput } | { readonly _tag: "ReviseDraft"; readonly command: ReviseArticleDraftInput } | { readonly _tag: "Publish"; readonly command: PublishArticleInput } | { readonly _tag: "Unpublish"; readonly command: UnpublishArticleInput }; -export const runPublicationTransition = ( +const runPublicationTransitionImpl = ( personId: PersonId, authorizationInstant: OrganizationAuthorityInstant, input: ContentManagementCommand, @@ -57,22 +82,26 @@ export const runPublicationTransition = ( const context = { personId, authorizationInstant }; return yield* Match.value(input).pipe( - Match.tag("CreateDraft", (input) => { - return content.createDraft(input.command, context); - }), - Match.tag("ReviseDraft", (input) => { - return content.reviseDraft(input.command, context); - }), - Match.tag("Publish", (input) => { - return content.publish(input.command, context); - }), - Match.tag("Unpublish", (input) => { - return content.unpublish(input.command, context); - }), + Match.tag("CreateDraft", (input) => content.createDraft(input.command, context)), + Match.tag("ReviseDraft", (input) => content.reviseDraft(input.command, context)), + Match.tag("Publish", (input) => content.publish(input.command, context)), + Match.tag("Unpublish", (input) => content.unpublish(input.command, context)), Match.exhaustive, ); }); +export const runPublicationTransition: { + ( + authorizationInstant: OrganizationAuthorityInstant, + input: ContentManagementCommand, + ): (personId: PersonId) => ReturnType; + ( + personId: PersonId, + authorizationInstant: OrganizationAuthorityInstant, + input: ContentManagementCommand, + ): ReturnType; +} = dual(3, runPublicationTransitionImpl); + export type PublicNewsRead = | { readonly _tag: "Listing"; diff --git a/packages/domain/src/content/projection.ts b/packages/domain/src/content/projection.ts index d1bdf3567..17c70a259 100644 --- a/packages/domain/src/content/projection.ts +++ b/packages/domain/src/content/projection.ts @@ -13,6 +13,7 @@ import { type PublishedNewsListing, type PublishedNewsSummary, } from "./schema.js"; +import { dual } from "effect/Function"; /** Pure pagination slice over the fully loaded listing (page size 10). */ export const NEWS_PAGE_SIZE = 10; @@ -68,15 +69,18 @@ export const projectContentWorkspace = (input: { }; /** Narrows the authorized set; it can never create authority. */ -export const filterWorkspaceByDepartment = ( - workspace: ContentWorkspace, - departmentId: DepartmentId | undefined, -): ContentWorkspace => - departmentId === undefined - ? workspace - : { - entries: workspace.entries.filter((entry) => entry.departmentIds.includes(departmentId)), - }; +export const filterWorkspaceByDepartment: { + (departmentId: DepartmentId | undefined): (workspace: ContentWorkspace) => ContentWorkspace; + (workspace: ContentWorkspace, departmentId: DepartmentId | undefined): ContentWorkspace; +} = dual( + 2, + (workspace: ContentWorkspace, departmentId: DepartmentId | undefined): ContentWorkspace => + departmentId === undefined + ? workspace + : { + entries: workspace.entries.filter((entry) => entry.departmentIds.includes(departmentId)), + }, +); export const projectNewsSummaries = (input: { readonly versions: ReadonlyArray<{ @@ -123,47 +127,74 @@ export const orderNewsSummaries = ( return { articles: ordered } satisfies typeof PublishedNewsListingSchema.Type; }; -export const paginateNewsListing = ( - listing: PublishedNewsListing, - page: number, -): PublishedNewsListing => ({ - articles: listing.articles.slice((page - 1) * NEWS_PAGE_SIZE, page * NEWS_PAGE_SIZE), -}); +export const paginateNewsListing: { + (page: number): (listing: PublishedNewsListing) => PublishedNewsListing; + (listing: PublishedNewsListing, page: number): PublishedNewsListing; +} = dual( + 2, + (listing: PublishedNewsListing, page: number): PublishedNewsListing => ({ + articles: listing.articles.slice((page - 1) * NEWS_PAGE_SIZE, page * NEWS_PAGE_SIZE), + }), +); /** Filters by department without disturbing the frozen ordering. */ -export const filterNewsListingByDepartment = ( - listing: PublishedNewsListing, - departmentId: DepartmentId | undefined, -): PublishedNewsListing => - departmentId === undefined - ? listing - : { - articles: listing.articles.filter( - (article) => - article.departmentIds.length === 0 || article.departmentIds.includes(departmentId), - ), - }; +export const filterNewsListingByDepartment: { + (departmentId: DepartmentId | undefined): (listing: PublishedNewsListing) => PublishedNewsListing; + (listing: PublishedNewsListing, departmentId: DepartmentId | undefined): PublishedNewsListing; +} = dual( + 2, + (listing: PublishedNewsListing, departmentId: DepartmentId | undefined): PublishedNewsListing => + departmentId === undefined + ? listing + : { + articles: listing.articles.filter( + (article) => + article.departmentIds.length === 0 || article.departmentIds.includes(departmentId), + ), + }, +); /** Builds the detail projection with descending previous-version references. */ -export const projectPublishedNewsArticle = ( - summary: PublishedNewsSummary, - bodyHtml: string, - previousVersions: ReadonlyArray<{ - readonly versionNumber: number; - readonly publishedAt: string; - readonly slug: string; - }>, -): PublishedNewsArticle => ({ - ...summary, - bodyHtml, - previousVersions: [...previousVersions] - .sort((left, right) => right.versionNumber - left.versionNumber) - .map((version): PublishedNewsArticle["previousVersions"][number] => ({ - versionNumber: ArticleVersionNumber.make(version.versionNumber), - publishedAt: version.publishedAt, - urlPath: `/nyhet/${version.slug}?versjon=${version.versionNumber}`, - })), -}); +export const projectPublishedNewsArticle: { + ( + bodyHtml: string, + previousVersions: ReadonlyArray<{ + readonly versionNumber: number; + readonly publishedAt: string; + readonly slug: string; + }>, + ): (summary: PublishedNewsSummary) => PublishedNewsArticle; + ( + summary: PublishedNewsSummary, + bodyHtml: string, + previousVersions: ReadonlyArray<{ + readonly versionNumber: number; + readonly publishedAt: string; + readonly slug: string; + }>, + ): PublishedNewsArticle; +} = dual( + 3, + ( + summary: PublishedNewsSummary, + bodyHtml: string, + previousVersions: ReadonlyArray<{ + readonly versionNumber: number; + readonly publishedAt: string; + readonly slug: string; + }>, + ): PublishedNewsArticle => ({ + ...summary, + bodyHtml, + previousVersions: [...previousVersions] + .sort((left, right) => right.versionNumber - left.versionNumber) + .map((version): PublishedNewsArticle["previousVersions"][number] => ({ + versionNumber: ArticleVersionNumber.make(version.versionNumber), + publishedAt: version.publishedAt, + urlPath: `/nyhet/${version.slug}?versjon=${version.versionNumber}`, + })), + }), +); // --- Slug generation (legacy transliteration law) --- @@ -185,11 +216,14 @@ export const slugifyTitle = (title: string): string => * Deterministic deduplication against every existing slug: append `-2`, * `-3`, … until free. Pure over the taken-slug set. */ -export const dedupeSlug = (base: string, takenSlugs: ReadonlySet): string => { +export const dedupeSlug: { + (takenSlugs: ReadonlySet): (base: string) => string; + (base: string, takenSlugs: ReadonlySet): string; +} = dual(2, (base: string, takenSlugs: ReadonlySet): string => { if (!takenSlugs.has(base)) return base; let counter = 2; while (takenSlugs.has(`${base}-${counter}`)) counter += 1; return `${base}-${counter}`; -}; +}); diff --git a/packages/domain/src/content/sanitize.ts b/packages/domain/src/content/sanitize.ts index c977c3175..5f99106e0 100644 --- a/packages/domain/src/content/sanitize.ts +++ b/packages/domain/src/content/sanitize.ts @@ -1,4 +1,5 @@ import { Effect } from "effect"; +import { dual } from "effect/Function"; import { defaultTreeAdapter, parseFragment, serialize, type DefaultTreeAdapterTypes } from "parse5"; import { ContentDecodeError } from "./errors.js"; @@ -139,37 +140,40 @@ const sanitize = (bodyHtml: string) => { }; /** Sanitizes and validates one editorial body write before persistence. */ -export const sanitizeArticleBodyHtml = ( - operation: string, - bodyHtml: string, -): Effect.Effect => - Effect.try({ - try: () => sanitize(bodyHtml), - catch: () => new ContentDecodeError({ operation, message: "sanitizer failure" }), - }).pipe( - Effect.flatMap((result) => { - if (result.rejection !== undefined) { - return Effect.fail(new ContentDecodeError({ operation, message: result.rejection })); - } +export const sanitizeArticleBodyHtml: { + (bodyHtml: string): (operation: string) => Effect.Effect; + (operation: string, bodyHtml: string): Effect.Effect; +} = dual( + 2, + (operation: string, bodyHtml: string): Effect.Effect => + Effect.try({ + try: () => sanitize(bodyHtml), + catch: () => ContentDecodeError.make({ operation, message: "sanitizer failure" }), + }).pipe( + Effect.flatMap((result) => { + if (result.rejection !== undefined) { + return Effect.fail(ContentDecodeError.make({ operation, message: result.rejection })); + } - if (result.html.trim().length === 0) { - return Effect.fail( - new ContentDecodeError({ - operation, - message: "sanitized article body must contain non-empty content", - }), - ); - } + if (result.html.trim().length === 0) { + return Effect.fail( + ContentDecodeError.make({ + operation, + message: "sanitized article body must contain non-empty content", + }), + ); + } - if (new TextEncoder().encode(result.html).byteLength > 100000) { - return Effect.fail( - new ContentDecodeError({ - operation, - message: "sanitized article body exceeds the 100000-byte limit", - }), - ); - } + if (new TextEncoder().encode(result.html).byteLength > 100000) { + return Effect.fail( + ContentDecodeError.make({ + operation, + message: "sanitized article body exceeds the 100000-byte limit", + }), + ); + } - return Effect.succeed(result.html); - }), - ); + return Effect.succeed(result.html); + }), + ), +); diff --git a/packages/domain/src/content/service.ts b/packages/domain/src/content/service.ts index deb5a7c29..a7d0e9a92 100644 --- a/packages/domain/src/content/service.ts +++ b/packages/domain/src/content/service.ts @@ -68,4 +68,4 @@ export interface ContentManagementOperations { export class ContentManagement extends Context.Service< ContentManagement, ContentManagementOperations ->()("@vektorprogrammet/domain/ContentManagement") {} +>()("@vektorprogrammet/domain/content/service/ContentManagement") {} diff --git a/packages/domain/src/identity.test.ts b/packages/domain/src/identity.test.ts index d9bbb563f..3ec634d2e 100644 --- a/packages/domain/src/identity.test.ts +++ b/packages/domain/src/identity.test.ts @@ -1,13 +1,10 @@ import { expect, it } from "@effect/vitest"; import { DateTime, Effect, Schema } from "effect"; -import { - IdentityActor, - IdentitySecurityEvent, - IdentitySession, - decodeIdentityActor, -} from "./identity/index.js"; +import { IdentityActor, IdentitySecurityEvent, IdentitySession } from "./identity/index.js"; import { PersonId } from "./organization/schema.js"; +const decodeIdentityActor = Schema.decodeUnknownEffect(IdentityActor); + const instant = (text: string): Date => DateTime.toDateUtc(DateTime.makeUnsafe(text)); it.effect("decodes session actors to the canonical Organization PersonId", () => diff --git a/packages/domain/src/identity/access.ts b/packages/domain/src/identity/access.ts index 647a0cd75..8afa3c356 100644 --- a/packages/domain/src/identity/access.ts +++ b/packages/domain/src/identity/access.ts @@ -1,4 +1,5 @@ import { Result, Schema } from "effect"; +import { dual } from "effect/Function"; import { PersonId } from "../organization/schema.js"; export const AccountAccess = Schema.Struct({ @@ -17,22 +18,35 @@ export class AccountAccessFailure extends Schema.TaggedError Disabled. A repeated state needs replay, not a new transition. */ -export const transitionAccountAccess = ( - current: AccountAccess, - input: { readonly disabled: boolean; readonly expectedRevision: number }, - context: { readonly actorPersonId: PersonId; readonly anotherUsableAdministrator: boolean }, -): Result.Result => { - if (current.revision !== input.expectedRevision) - return Result.fail(new AccountAccessFailure({ code: "Stale" })); - - if (input.disabled && current.personId === context.actorPersonId) - return Result.fail(new AccountAccessFailure({ code: "SelfDisable" })); - - if (input.disabled && !context.anotherUsableAdministrator) - return Result.fail(new AccountAccessFailure({ code: "LastAdministrator" })); - - if (input.disabled === current.disabled) - return Result.fail(new AccountAccessFailure({ code: "Invalid" })); - - return Result.succeed({ ...current, disabled: input.disabled, revision: current.revision + 1 }); -}; +export const transitionAccountAccess: { + ( + input: { readonly disabled: boolean; readonly expectedRevision: number }, + context: { readonly actorPersonId: PersonId; readonly anotherUsableAdministrator: boolean }, + ): (current: AccountAccess) => Result.Result; + ( + current: AccountAccess, + input: { readonly disabled: boolean; readonly expectedRevision: number }, + context: { readonly actorPersonId: PersonId; readonly anotherUsableAdministrator: boolean }, + ): Result.Result; +} = dual( + 3, + ( + current: AccountAccess, + input: { readonly disabled: boolean; readonly expectedRevision: number }, + context: { readonly actorPersonId: PersonId; readonly anotherUsableAdministrator: boolean }, + ): Result.Result => { + if (current.revision !== input.expectedRevision) + return Result.fail(AccountAccessFailure.make({ code: "Stale" })); + + if (input.disabled && current.personId === context.actorPersonId) + return Result.fail(AccountAccessFailure.make({ code: "SelfDisable" })); + + if (input.disabled && !context.anotherUsableAdministrator) + return Result.fail(AccountAccessFailure.make({ code: "LastAdministrator" })); + + if (input.disabled === current.disabled) + return Result.fail(AccountAccessFailure.make({ code: "Invalid" })); + + return Result.succeed({ ...current, disabled: input.disabled, revision: current.revision + 1 }); + }, +); diff --git a/packages/domain/src/identity/index.ts b/packages/domain/src/identity/index.ts index 5313779d6..37ef515ea 100644 --- a/packages/domain/src/identity/index.ts +++ b/packages/domain/src/identity/index.ts @@ -14,8 +14,6 @@ export { } from "./errors.js"; export { - decodeIdentityActor, - decodeIdentitySession, IdentityActor, IdentityRequestContext, IdentitySecurityEvent, diff --git a/packages/domain/src/identity/schema.ts b/packages/domain/src/identity/schema.ts index 323e618c4..fb9e35d98 100644 --- a/packages/domain/src/identity/schema.ts +++ b/packages/domain/src/identity/schema.ts @@ -70,9 +70,6 @@ export class IdentityActor extends Schema.Class("IdentityActor")( expiresAt: Schema.DateTimeUtcFromDate, }) {} -/** Decodes an engine-native actor row into the canonical domain shape. */ -export const decodeIdentityActor = Schema.decodeUnknownEffect(IdentityActor); - /** Credential-free metadata exposed by the native session resources. */ export class IdentitySession extends Schema.Class("IdentitySession")({ sessionId: IdentitySessionId, @@ -85,8 +82,6 @@ export class IdentitySession extends Schema.Class("IdentitySess current: Schema.Boolean, }) {} -export const decodeIdentitySession = Schema.decodeUnknownEffect(IdentitySession); - export const IdentitySecurityEventKind = Schema.Literals([ "sign-in-success", "password-reset-request-accepted", diff --git a/packages/domain/src/identity/service.ts b/packages/domain/src/identity/service.ts index 1418ea06a..80a0656e5 100644 --- a/packages/domain/src/identity/service.ts +++ b/packages/domain/src/identity/service.ts @@ -89,7 +89,7 @@ export interface IdentityOperations { } export class Identity extends Context.Service()( - "@vektorprogrammet/domain/Identity", + "@vektorprogrammet/domain/identity/service/Identity", ) {} export type { IdentityFailure }; diff --git a/packages/domain/src/laws.ts b/packages/domain/src/laws.ts index b9e7435e2..f7af1914b 100644 --- a/packages/domain/src/laws.ts +++ b/packages/domain/src/laws.ts @@ -1,4 +1,5 @@ -import { Array, Record } from "effect"; +import { Array, Record, Predicate } from "effect"; +import { dual } from "effect/Function"; import type { Dataset, PersonAuthorityProjection } from "./data.js"; export const LAW_ID = "S-DEP-2-TEAM" as const; @@ -120,229 +121,235 @@ const sourceFiles = [ "executive_board_membership.json→executive_board.json", ] as const; -export const runSDep2Team = (dataset: Dataset, options: SDep2TeamOptions = {}): SDep2TeamResult => { - const reasonCounts = createReasonCounts(); - const reasonSamples = createReasonSamples(); - const allSamples: TechnicalSample[] = []; - const localDepartmentsByUser = new Map>(); - const globalUsers = new Set(); - let localAcceptedEdges = 0; - let globalAcceptedEdges = 0; - let violations = 0; - let personMissing = 0; - let personMismatches = 0; - const checkedUsers = new Set(); - - dataset.decodeFailures.forEach(() => { - increment(reasonCounts, "DECODE_FAILURE"); - }); - - const recordRelationFailure = (code: ReasonCode, sample: TechnicalSample): void => { - increment(reasonCounts, code); - addSample(reasonSamples, allSamples, code, sample); - violations += 1; - }; - - const recordRelationSuccess = ( - code: "ACCEPT_LOCAL" | "ACCEPT_GLOBAL", - sample: TechnicalSample, - ): void => { - increment(reasonCounts, code); - addSample(reasonSamples, allSamples, code, sample); - }; - - for (const membership of dataset.teamMemberships) { - const sample: TechnicalSample = { source: "team_membership", id: membership.id }; - const teamId = membership.teamId; - - if (teamId === null) { - recordRelationFailure("TEAM_UNRESOLVED", sample); - continue; - } - - if (dataset.duplicateIds.teams.includes(teamId)) { - increment(reasonCounts, "DUPLICATE_TEAM_ID"); - addSample(reasonSamples, allSamples, "DUPLICATE_TEAM_ID", sample); - recordRelationFailure("TEAM_UNRESOLVED", sample); - continue; - } - - const team = dataset.teamById.get(teamId); - - if (team === undefined) { - recordRelationFailure("TEAM_UNRESOLVED", sample); - continue; - } - - const departmentId = team.departmentId; - - if (departmentId === null) { - recordRelationFailure("LOCAL_DEPARTMENT_NULL", sample); - continue; - } - - if (dataset.duplicateIds.departments.includes(departmentId)) { - increment(reasonCounts, "DUPLICATE_DEPARTMENT_ID"); - addSample(reasonSamples, allSamples, "DUPLICATE_DEPARTMENT_ID", sample); - recordRelationFailure("LOCAL_DEPARTMENT_UNRESOLVED", sample); - continue; - } - - if (!dataset.departmentById.has(departmentId)) { - recordRelationFailure("LOCAL_DEPARTMENT_UNRESOLVED", sample); - continue; - } - - recordRelationSuccess("ACCEPT_LOCAL", sample); - localAcceptedEdges += 1; - const departmentSet = localDepartmentsByUser.get(membership.userId) ?? new Set(); - departmentSet.add(departmentId); - localDepartmentsByUser.set(membership.userId, departmentSet); - checkedUsers.add(membership.userId); - - if (options.personAuthority === undefined) { - continue; - } - - const authorized = options.personAuthority.departmentIdsByUser.get(membership.userId); - - if (authorized === undefined) { - personMissing += 1; - increment(reasonCounts, "PERSON_AUTHORITY_MISSING"); - addSample(reasonSamples, allSamples, "PERSON_AUTHORITY_MISSING", sample); - continue; - } - - if (!authorized.has(departmentId)) { - personMismatches += 1; - increment(reasonCounts, "LOCAL_DEPARTMENT_MISMATCH"); - addSample(reasonSamples, allSamples, "LOCAL_DEPARTMENT_MISMATCH", sample); +export const runSDep2Team: { + (options?: SDep2TeamOptions): (dataset: Dataset) => SDep2TeamResult; + (dataset: Dataset, options?: SDep2TeamOptions): SDep2TeamResult; +} = dual( + (args) => args.length >= 2 || Predicate.hasProperty(args[0], "departments"), + (dataset: Dataset, options: SDep2TeamOptions = {}): SDep2TeamResult => { + const reasonCounts = createReasonCounts(); + const reasonSamples = createReasonSamples(); + const allSamples: TechnicalSample[] = []; + const localDepartmentsByUser = new Map>(); + const globalUsers = new Set(); + let localAcceptedEdges = 0; + let globalAcceptedEdges = 0; + let violations = 0; + let personMissing = 0; + let personMismatches = 0; + const checkedUsers = new Set(); + + dataset.decodeFailures.forEach(() => { + increment(reasonCounts, "DECODE_FAILURE"); + }); + + const recordRelationFailure = (code: ReasonCode, sample: TechnicalSample): void => { + increment(reasonCounts, code); + addSample(reasonSamples, allSamples, code, sample); violations += 1; - } - } - - for (const membership of dataset.globalMemberships) { - const sample: TechnicalSample = { source: "executive_board_membership", id: membership.id }; - const boardId = membership.boardId; - - if (boardId === null) { - recordRelationFailure("GLOBAL_UNRESOLVED", sample); - continue; + }; + + const recordRelationSuccess = ( + code: "ACCEPT_LOCAL" | "ACCEPT_GLOBAL", + sample: TechnicalSample, + ): void => { + increment(reasonCounts, code); + addSample(reasonSamples, allSamples, code, sample); + }; + + for (const membership of dataset.teamMemberships) { + const sample: TechnicalSample = { source: "team_membership", id: membership.id }; + const teamId = membership.teamId; + + if (teamId === null) { + recordRelationFailure("TEAM_UNRESOLVED", sample); + continue; + } + + if (dataset.duplicateIds.teams.includes(teamId)) { + increment(reasonCounts, "DUPLICATE_TEAM_ID"); + addSample(reasonSamples, allSamples, "DUPLICATE_TEAM_ID", sample); + recordRelationFailure("TEAM_UNRESOLVED", sample); + continue; + } + + const team = dataset.teamById.get(teamId); + + if (team === undefined) { + recordRelationFailure("TEAM_UNRESOLVED", sample); + continue; + } + + const departmentId = team.departmentId; + + if (departmentId === null) { + recordRelationFailure("LOCAL_DEPARTMENT_NULL", sample); + continue; + } + + if (dataset.duplicateIds.departments.includes(departmentId)) { + increment(reasonCounts, "DUPLICATE_DEPARTMENT_ID"); + addSample(reasonSamples, allSamples, "DUPLICATE_DEPARTMENT_ID", sample); + recordRelationFailure("LOCAL_DEPARTMENT_UNRESOLVED", sample); + continue; + } + + if (!dataset.departmentById.has(departmentId)) { + recordRelationFailure("LOCAL_DEPARTMENT_UNRESOLVED", sample); + continue; + } + + recordRelationSuccess("ACCEPT_LOCAL", sample); + localAcceptedEdges += 1; + const departmentSet = localDepartmentsByUser.get(membership.userId) ?? new Set(); + departmentSet.add(departmentId); + localDepartmentsByUser.set(membership.userId, departmentSet); + checkedUsers.add(membership.userId); + + if (options.personAuthority === undefined) { + continue; + } + + const authorized = options.personAuthority.departmentIdsByUser.get(membership.userId); + + if (authorized === undefined) { + personMissing += 1; + increment(reasonCounts, "PERSON_AUTHORITY_MISSING"); + addSample(reasonSamples, allSamples, "PERSON_AUTHORITY_MISSING", sample); + continue; + } + + if (!authorized.has(departmentId)) { + personMismatches += 1; + increment(reasonCounts, "LOCAL_DEPARTMENT_MISMATCH"); + addSample(reasonSamples, allSamples, "LOCAL_DEPARTMENT_MISMATCH", sample); + violations += 1; + } } - if (dataset.duplicateIds.executiveBoards.includes(boardId)) { - increment(reasonCounts, "DUPLICATE_BOARD_ID"); - addSample(reasonSamples, allSamples, "DUPLICATE_BOARD_ID", sample); - recordRelationFailure("GLOBAL_UNRESOLVED", sample); - continue; + for (const membership of dataset.globalMemberships) { + const sample: TechnicalSample = { source: "executive_board_membership", id: membership.id }; + const boardId = membership.boardId; + + if (boardId === null) { + recordRelationFailure("GLOBAL_UNRESOLVED", sample); + continue; + } + + if (dataset.duplicateIds.executiveBoards.includes(boardId)) { + increment(reasonCounts, "DUPLICATE_BOARD_ID"); + addSample(reasonSamples, allSamples, "DUPLICATE_BOARD_ID", sample); + recordRelationFailure("GLOBAL_UNRESOLVED", sample); + continue; + } + + if (!dataset.executiveBoardById.has(boardId)) { + recordRelationFailure("GLOBAL_UNRESOLVED", sample); + continue; + } + + recordRelationSuccess("ACCEPT_GLOBAL", sample); + globalAcceptedEdges += 1; + globalUsers.add(membership.userId); } - if (!dataset.executiveBoardById.has(boardId)) { - recordRelationFailure("GLOBAL_UNRESOLVED", sample); - continue; + if (options.personAuthority === undefined) { + increment(reasonCounts, "PERSON_AUTHORITY_UNAVAILABLE"); } - recordRelationSuccess("ACCEPT_GLOBAL", sample); - globalAcceptedEdges += 1; - globalUsers.add(membership.userId); - } - - if (options.personAuthority === undefined) { - increment(reasonCounts, "PERSON_AUTHORITY_UNAVAILABLE"); - } - - const localUsers = new Set(localDepartmentsByUser.keys()); - - const localMultiDepartmentUsers = [...localDepartmentsByUser.values()].filter( - (departments) => departments.size > 1, - ).length; - - const globalUsersWithLocalMembership = [...globalUsers].filter((userId) => - localUsers.has(userId), - ).length; - - const globalUsersWithoutLocalMembership = globalUsers.size - globalUsersWithLocalMembership; - const checked = dataset.teamMemberships.length + dataset.globalMemberships.length; - - const unresolvedCoverage = - reasonCounts.TEAM_UNRESOLVED > 0 || - reasonCounts.LOCAL_DEPARTMENT_NULL > 0 || - reasonCounts.LOCAL_DEPARTMENT_UNRESOLVED > 0 || - reasonCounts.GLOBAL_UNRESOLVED > 0; - - const relationCompleteness: Completeness = - dataset.decodeFailures.length === 0 && - dataset.duplicateIds.departments.length === 0 && - dataset.duplicateIds.teams.length === 0 && - dataset.duplicateIds.executiveBoards.length === 0 && - !unresolvedCoverage && - checked > 0 - ? "FULL" - : "PARTIAL"; - - const personCompleteness: Completeness = - options.personAuthority === undefined || personMissing > 0 ? "PARTIAL" : "FULL"; - - const personStatus: PersonComparison["status"] = - options.personAuthority === undefined ? "UNAVAILABLE" : personCompleteness; - - const status: LawStatus = - violations > 0 - ? "FAIL" - : relationCompleteness === "FULL" && personCompleteness === "FULL" - ? "PASS" - : "INFO"; - - const drift = status !== "PASS"; - - let provenance: SDep2TeamResult["provenance"] = { - snapshot: options.snapshotId ?? "unspecified", - files: sourceFiles, - tables: [ - "team_membership.team_id→team.department_id→department.id", - "executive_board_membership.board_id→executive_board.id", - ], - scope: { team: "Local", executiveBoard: "Global" }, - pii: "none", - }; - - if (options.snapshotHash !== undefined) - provenance = { ...provenance, hash: options.snapshotHash }; - - return { - lawId: LAW_ID, - statement: LAW_STATEMENT, - status, - relationCompleteness, - personCompleteness, - personComparison: { - status: personStatus, - checkedUsers: checkedUsers.size, - missingUsers: personMissing, - mismatches: personMismatches, - }, - checked, - violations, - drift, - reasonCounts, - reasonSamples: reasonSamples, - samples: allSamples, - relation: { - departments: dataset.departments.length, - teams: dataset.teams.length, - teamMemberships: dataset.teamMemberships.length, - executiveBoards: dataset.executiveBoards.length, - globalMemberships: dataset.globalMemberships.length, - consideredEdges: checked, - localAcceptedEdges, - globalAcceptedEdges, - localUsers: localUsers.size, - localMultiDepartmentUsers, - globalUsers: globalUsers.size, - globalUsersWithLocalMembership, - globalUsersWithoutLocalMembership, - }, - provenance, - input: dataset.input, - }; -}; + const localUsers = new Set(localDepartmentsByUser.keys()); + + const localMultiDepartmentUsers = [...localDepartmentsByUser.values()].filter( + (departments) => departments.size > 1, + ).length; + + const globalUsersWithLocalMembership = [...globalUsers].filter((userId) => + localUsers.has(userId), + ).length; + + const globalUsersWithoutLocalMembership = globalUsers.size - globalUsersWithLocalMembership; + const checked = dataset.teamMemberships.length + dataset.globalMemberships.length; + + const unresolvedCoverage = + reasonCounts.TEAM_UNRESOLVED > 0 || + reasonCounts.LOCAL_DEPARTMENT_NULL > 0 || + reasonCounts.LOCAL_DEPARTMENT_UNRESOLVED > 0 || + reasonCounts.GLOBAL_UNRESOLVED > 0; + + const relationCompleteness: Completeness = + dataset.decodeFailures.length === 0 && + dataset.duplicateIds.departments.length === 0 && + dataset.duplicateIds.teams.length === 0 && + dataset.duplicateIds.executiveBoards.length === 0 && + !unresolvedCoverage && + checked > 0 + ? "FULL" + : "PARTIAL"; + + const personCompleteness: Completeness = + options.personAuthority === undefined || personMissing > 0 ? "PARTIAL" : "FULL"; + + const personStatus: PersonComparison["status"] = + options.personAuthority === undefined ? "UNAVAILABLE" : personCompleteness; + + const status: LawStatus = + violations > 0 + ? "FAIL" + : relationCompleteness === "FULL" && personCompleteness === "FULL" + ? "PASS" + : "INFO"; + + const drift = status !== "PASS"; + + let provenance: SDep2TeamResult["provenance"] = { + snapshot: options.snapshotId ?? "unspecified", + files: sourceFiles, + tables: [ + "team_membership.team_id→team.department_id→department.id", + "executive_board_membership.board_id→executive_board.id", + ], + scope: { team: "Local", executiveBoard: "Global" }, + pii: "none", + }; + + if (options.snapshotHash !== undefined) + provenance = { ...provenance, hash: options.snapshotHash }; + + return { + lawId: LAW_ID, + statement: LAW_STATEMENT, + status, + relationCompleteness, + personCompleteness, + personComparison: { + status: personStatus, + checkedUsers: checkedUsers.size, + missingUsers: personMissing, + mismatches: personMismatches, + }, + checked, + violations, + drift, + reasonCounts, + reasonSamples: reasonSamples, + samples: allSamples, + relation: { + departments: dataset.departments.length, + teams: dataset.teams.length, + teamMemberships: dataset.teamMemberships.length, + executiveBoards: dataset.executiveBoards.length, + globalMemberships: dataset.globalMemberships.length, + consideredEdges: checked, + localAcceptedEdges, + globalAcceptedEdges, + localUsers: localUsers.size, + localMultiDepartmentUsers, + globalUsers: globalUsers.size, + globalUsersWithLocalMembership, + globalUsersWithoutLocalMembership, + }, + provenance, + input: dataset.input, + }; + }, +); diff --git a/packages/domain/src/mail.ts b/packages/domain/src/mail.ts index 8d4e79cda..3dcae3631 100644 --- a/packages/domain/src/mail.ts +++ b/packages/domain/src/mail.ts @@ -45,5 +45,5 @@ export interface MailOperations { /** Provider-neutral authority for delivering one complete mail message. */ export class Mail extends Context.Service()( - "@vektorprogrammet/domain/Mail", + "@vektorprogrammet/domain/mail", ) {} diff --git a/packages/domain/src/notification/service.ts b/packages/domain/src/notification/service.ts index f37e9df13..20094d6cf 100644 --- a/packages/domain/src/notification/service.ts +++ b/packages/domain/src/notification/service.ts @@ -22,4 +22,4 @@ export interface NotificationGatewayOperations { export class NotificationGateway extends Context.Service< NotificationGateway, NotificationGatewayOperations ->()("@vektorprogrammet/domain/NotificationGateway") {} +>()("@vektorprogrammet/domain/notification/service/NotificationGateway") {} diff --git a/packages/domain/src/organization/AGENTS.md b/packages/domain/src/organization/AGENTS.md index 176064da3..12a2b9bba 100644 --- a/packages/domain/src/organization/AGENTS.md +++ b/packages/domain/src/organization/AGENTS.md @@ -80,6 +80,13 @@ Responsibilities: | `@vektorprogrammet/domain/organization` | [index.ts](index.ts) | | `@vektorprogrammet/domain/organization/authority-fixtures` | [authority-fixtures.test-support.ts](authority-fixtures.test-support.ts) | +## Constructs + +The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../../../docs/constructs.md) indexes them all. + +- [`requireOrganizationAdministrator`](../../../../docs/constructs/authority-evidence.md#requireorganizationadministrator) (authority-evidence): Checks that a resolved authority holds active global administration, and returns the evidence that the Organization administration commands require. +- [`requireTeamInterestScope`](../../../../docs/constructs/authority-evidence.md#requireteaminterestscope) (authority-evidence): Checks what team interest a resolved authority may read, narrowed to one requested department, and returns the scope that the listing requires. + Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. [//]: # "guide: end" diff --git a/packages/domain/src/organization/administration.test.ts b/packages/domain/src/organization/administration.test.ts index b74fb6689..861a1cc58 100644 --- a/packages/domain/src/organization/administration.test.ts +++ b/packages/domain/src/organization/administration.test.ts @@ -2,14 +2,11 @@ import { expect, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; import { CreateDepartmentCommandSchema, - OrganizationMemberSchema, - OrganizationAdministratorSchema, CreateFieldOfStudyCommandSchema, CreateTeamCommandSchema, OrganizationCommandId, } from "./administration-schema.js"; import { - authorizeOrganizationActor, decodeCreateDepartmentCommand, departmentIdForCommand, fieldOfStudyIdForCommand, @@ -17,7 +14,7 @@ import { organizationEntityDigest, teamIdForCommand, } from "./administration.js"; -import { DepartmentId, PersonId } from "./schema.js"; +import { DepartmentId } from "./schema.js"; const departmentCommand = CreateDepartmentCommandSchema.make({ commandId: OrganizationCommandId.make("organization-domain-department-command"), @@ -121,18 +118,3 @@ it("derives stable, kind-separated IDs from the complete SHA-256 digest", () => organizationCommandDigest(departmentCommand), ); }); - -it.effect("allows administrators and returns a typed denial for members", () => - Effect.gen(function* () { - const personId = PersonId.make("organization-domain-actor"); - yield* authorizeOrganizationActor(OrganizationAdministratorSchema.make({ personId })); - - const denied = yield* Effect.flip( - authorizeOrganizationActor(OrganizationMemberSchema.make({ personId })), - ); - - expect(denied._tag).toBe("OrganizationRoleDenied"); - expect(denied.actorPersonId).toBe(personId); - expect(denied.requiredRole).toBe("OrganizationAdministrator"); - }), -); diff --git a/packages/domain/src/organization/administration.ts b/packages/domain/src/organization/administration.ts index 165e0ca86..2f0b6ae12 100644 --- a/packages/domain/src/organization/administration.ts +++ b/packages/domain/src/organization/administration.ts @@ -1,13 +1,12 @@ -import { flow, Predicate, Effect, Schema } from "effect"; +import { flow, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { canonicalJsonBytes, sha256Hex } from "../shared-kernel/index.js"; -import { OrganizationDecodeError, OrganizationRoleDenied } from "./errors.js"; +import { OrganizationDecodeError } from "./errors.js"; import { CreateDepartmentCommandSchema, CreateFieldOfStudyCommandSchema, CreateTeamCommandSchema, - OrganizationActorSchema, OrganizationCreateCommandSchema, - type OrganizationActor, type OrganizationCommandId, type OrganizationCreateCommand, type OrganizationEntityKind, @@ -22,14 +21,7 @@ import { } from "./schema.js"; const decodeError = (operation: string, cause: unknown) => - new OrganizationDecodeError({ operation, message: String(cause) }); - -export const decodeOrganizationActor = flow( - Schema.decodeUnknownEffect(OrganizationActorSchema, { - onExcessProperty: "error", - }), - Effect.mapError((cause) => decodeError("decode organization actor", cause)), -); + OrganizationDecodeError.make({ operation, message: String(cause) }); export const decodeCreateDepartmentCommand = flow( Schema.decodeUnknownEffect(CreateDepartmentCommandSchema, { @@ -65,56 +57,18 @@ export const organizationCommandBytes = (command: OrganizationCreateCommand): Ui export const organizationCommandDigest = (command: OrganizationCreateCommand): string => sha256Hex(organizationCommandBytes(command)); -export const organizationEntityDigest = ( - entityKind: OrganizationEntityKind, - commandId: OrganizationCommandId, -): string => sha256Hex(canonicalJsonBytes({ entityKind, commandId })); - -export function organizationEntityIdForCommand( - entityKind: "Department", - commandId: OrganizationCommandId, -): DepartmentIdType; -export function organizationEntityIdForCommand( - entityKind: "Team", - commandId: OrganizationCommandId, -): TeamIdType; -export function organizationEntityIdForCommand( - entityKind: "FieldOfStudy", - commandId: OrganizationCommandId, -): FieldOfStudyIdType; -export function organizationEntityIdForCommand( - entityKind: OrganizationEntityKind, - commandId: OrganizationCommandId, -): DepartmentIdType | TeamIdType | FieldOfStudyIdType { - const digest = organizationEntityDigest(entityKind, commandId); - - switch (entityKind) { - case "Department": - return DepartmentId.make(`department-${digest}`); - case "Team": - return TeamId.make(`team-${digest}`); - case "FieldOfStudy": - return FieldOfStudyId.make(`field-of-study-${digest}`); - } -} +export const organizationEntityDigest: { + (commandId: OrganizationCommandId): (entityKind: OrganizationEntityKind) => string; + (entityKind: OrganizationEntityKind, commandId: OrganizationCommandId): string; +} = dual(2, (entityKind: OrganizationEntityKind, commandId: OrganizationCommandId): string => + sha256Hex(canonicalJsonBytes({ entityKind, commandId })), +); export const departmentIdForCommand = (commandId: OrganizationCommandId): DepartmentIdType => - organizationEntityIdForCommand("Department", commandId); + DepartmentId.make(`department-${organizationEntityDigest("Department", commandId)}`); export const teamIdForCommand = (commandId: OrganizationCommandId): TeamIdType => - organizationEntityIdForCommand("Team", commandId); + TeamId.make(`team-${organizationEntityDigest("Team", commandId)}`); export const fieldOfStudyIdForCommand = (commandId: OrganizationCommandId): FieldOfStudyIdType => - organizationEntityIdForCommand("FieldOfStudy", commandId); - -export const authorizeOrganizationActor = ( - actor: OrganizationActor, -): Effect.Effect => - Predicate.isTagged(actor, "OrganizationAdministrator") - ? Effect.void - : Effect.fail( - new OrganizationRoleDenied({ - actorPersonId: actor.personId, - requiredRole: "OrganizationAdministrator", - }), - ); + FieldOfStudyId.make(`field-of-study-${organizationEntityDigest("FieldOfStudy", commandId)}`); diff --git a/packages/domain/src/organization/authority-evidence.test.ts b/packages/domain/src/organization/authority-evidence.test.ts new file mode 100644 index 000000000..1d8c024b0 --- /dev/null +++ b/packages/domain/src/organization/authority-evidence.test.ts @@ -0,0 +1,130 @@ +import { expect, expectTypeOf, it } from "@effect/vitest"; +import { Effect, Predicate, Result, Schema } from "effect"; +import { Arbitrary } from "effect/unstable/arbitrary"; +import { + type OrganizationActor, + type OrganizationAdministrator, + OrganizationAdministratorSchema, + type OrganizationMember, +} from "./administration-schema.js"; +import { + mapOrganizationAuthorityToOrganizationActor, + type OrganizationAdministratorEvidence, + OrganizationAuthorityBoardSeatSchema, + OrganizationAuthorityMembershipSchema, + OrganizationGlobalAdministratorStatusSchema, + OrganizationPersonAuthoritySchema, + requireOrganizationAdministrator, +} from "./authority.js"; +import { spec0055OrganizationAuthorityFixtures } from "./authority-fixtures.test-support.js"; +import type { OrganizationOperations } from "./service.js"; + +const fixtures = spec0055OrganizationAuthorityFixtures({ + evaluatedAt: "2026-09-28T12:00:00.000Z", + departmentId: "evidence-department", + teamId: "evidence-team", + persons: { + administrator: "evidence-administrator", + leader: "evidence-leader", + inactiveLeader: "evidence-inactive-leader", + member: "evidence-member", + absent: "evidence-absent", + }, + memberships: { + leader: "evidence-leader-membership", + inactiveLeader: "evidence-inactive-leader-membership", + member: "evidence-member-membership", + }, +}); + +/** + * A fixture person and instant with every global-administrator status and generated memberships + * and board seats, leaders and board leaders included. They vary independently of the grant, so a + * check that read them instead of the grant would disagree on some case. Instants are not + * generated: the RFC 3339 filter exhausts the schema arbitrary. + */ +const authority = Arbitrary.map( + Arbitrary.all({ + base: Arbitrary.schema( + Schema.Literals(["administrator", "leader", "inactiveLeader", "member", "absent"]), + ), + globalAdministrator: Arbitrary.schema(OrganizationGlobalAdministratorStatusSchema), + memberships: Arbitrary.schema(Schema.Array(OrganizationAuthorityMembershipSchema)), + nationalBoardSeats: Arbitrary.schema(Schema.Array(OrganizationAuthorityBoardSeatSchema)), + }), + ({ base, globalAdministrator, memberships, nationalBoardSeats }) => + OrganizationPersonAuthoritySchema.make({ + ...fixtures[base], + globalAdministrator, + memberships, + nationalBoardSeats, + }), +); + +const propertyOptions = { arbitrary: { seed: 28092026, runs: 200 } } as const; + +it.effect( + "requireOrganizationAdministrator returns evidence for an active global administrator and a denial for the others", + () => + Effect.gen(function* () { + const administrator = yield* Effect.fromResult( + requireOrganizationAdministrator(fixtures.administrator), + ); + + expect(administrator.actor).toEqual( + OrganizationAdministratorSchema.make({ personId: fixtures.administrator.personId }), + ); + + for (const denied of [ + fixtures.leader, + fixtures.inactiveLeader, + fixtures.member, + fixtures.absent, + ]) { + const failure = yield* Effect.flip( + Effect.fromResult(requireOrganizationAdministrator(denied)), + ); + + expect(failure._tag).toBe("OrganizationRoleDenied"); + expect(failure.actorPersonId).toBe(denied.personId); + expect(failure.requiredRole).toBe("OrganizationAdministrator"); + } + }), +); + +it.prop( + "requireOrganizationAdministrator grants evidence exactly for an active grant, for the same person, in agreement with the actor mapping", + { authority }, + ({ authority }) => { + const result = requireOrganizationAdministrator(authority); + const actor = mapOrganizationAuthorityToOrganizationActor(authority); + + expect(Result.isSuccess(result)).toBe(authority.globalAdministrator === "Active"); + expect(Result.isSuccess(result)).toBe(Predicate.isTagged(actor, "OrganizationAdministrator")); + + if (Result.isSuccess(result)) { + expect(result.success.actor).toEqual(actor); + } else { + expect(result.failure.actorPersonId).toBe(authority.personId); + } + }, + propertyOptions, +); + +it("requireOrganizationAdministrator evidence is the only way to satisfy an administration command", () => { + type Administrator = Parameters[1]; + + expectTypeOf< + Parameters[1] + >().toEqualTypeOf(); + expectTypeOf< + Parameters[1] + >().toEqualTypeOf(); + expectTypeOf().toExtend(); + + // Negative controls: an actor, a member, or a literal of the evidence's visible shape is not evidence. + expectTypeOf().not.toExtend(); + expectTypeOf().not.toExtend(); + expectTypeOf().not.toExtend(); + expectTypeOf<{ readonly actor: OrganizationAdministrator }>().not.toExtend(); +}); diff --git a/packages/domain/src/organization/authority-fixtures.test-support.ts b/packages/domain/src/organization/authority-fixtures.test-support.ts index 8c3c763f8..e23dcca66 100644 --- a/packages/domain/src/organization/authority-fixtures.test-support.ts +++ b/packages/domain/src/organization/authority-fixtures.test-support.ts @@ -1,5 +1,9 @@ +import { Result } from "effect"; +import type { OrganizationCapability } from "../authz/delegation.js"; +import { requireDepartmentReach, type DepartmentReach } from "../authz/reach.js"; import type { OrganizationPersonAuthority } from "./authority.js"; import { DepartmentId, MembershipId, PersonId, TeamId } from "./schema.js"; +import { dual } from "effect/Function"; export interface Spec0055OrganizationAuthorityFixtureIds { readonly evaluatedAt: string; @@ -113,3 +117,46 @@ export const spec0055OrganizationAuthorityFixtures = ( }, }; }; + +/** + * The resolved authority of an active global administrator with no appointments, at a fixed + * instant. Tests and proofs mint evidence from it; production resolves authority from facts. + */ +export const activeAdministratorAuthority = (personId: string): OrganizationPersonAuthority => ({ + personId: PersonId.make(personId), + evaluatedAt: "2026-09-28T12:00:00.000Z", + globalAdministrator: "Active", + memberships: [], + nationalBoardSeats: [], + delegations: [], +}); + +/** + * Department reach evidence for an active global administrator, for tests and proofs that call a + * department-scoped command directly. A capability that no global administrator holds fails. + */ +export const administratorDepartmentReach: { + ( + capability: C, + departmentId: string, + ): (personId: string) => DepartmentReach; + ( + personId: string, + capability: C, + departmentId: string, + ): DepartmentReach; +} = dual( + 3, + ( + personId: string, + capability: C, + departmentId: string, + ): DepartmentReach => + Result.getOrThrow( + requireDepartmentReach( + activeAdministratorAuthority(personId), + capability, + DepartmentId.make(departmentId), + ), + ), +); diff --git a/packages/domain/src/organization/authority.ts b/packages/domain/src/organization/authority.ts index 55b77d7ac..27263f65c 100644 --- a/packages/domain/src/organization/authority.ts +++ b/packages/domain/src/organization/authority.ts @@ -1,4 +1,5 @@ -import { Schema } from "effect"; +import { Data, Result, Schema } from "effect"; +import { dual } from "effect/Function"; import { type AdmissionPeriodActor, AdmissionPeriodActorSchema, @@ -10,13 +11,23 @@ import { TeamScope, UnitKind, } from "../authz/delegation.js"; -import { holdsDepartmentReach, leadsAnyTeam, reaches, ReachTarget } from "../authz/reach.js"; +import { + holdsDepartmentReach, + leadsAnyTeam, + ReachedDepartments, + reachedDepartments, + reachedTeams, + reaches, + ReachTarget, +} from "../authz/reach.js"; import { compareRfc3339Instants, Rfc3339InstantSchema } from "../time.js"; import { OrganizationMemberSchema, type OrganizationActor, + type OrganizationAdministrator, OrganizationAdministratorSchema, } from "./administration-schema.js"; +import { OrganizationRoleDenied } from "./errors.js"; import { DepartmentId, MembershipId, PersonId, TeamId } from "./schema.js"; const NonEmpty = Schema.String.pipe( @@ -170,50 +181,63 @@ export type ProfileRole = typeof ProfileRoleSchema.Type; * independent: an ended global-administrator grant removes no role authority, and only names the * denial where nothing reaches the department. */ -export const mapOrganizationAuthorityToDepartmentActor = ( - authority: OrganizationPersonAuthority, - capability: OrganizationCapability, - departmentId: DepartmentId, -): Decision => { - if (authority.globalAdministrator === "Active") { - return allow( - AdmissionPeriodActorSchema.cases.GlobalAdmin.make({ - personId: authority.personId, - active: true, - }), +export const mapOrganizationAuthorityToDepartmentActor: { + ( + capability: OrganizationCapability, + departmentId: DepartmentId, + ): (authority: OrganizationPersonAuthority) => Decision; + ( + authority: OrganizationPersonAuthority, + capability: OrganizationCapability, + departmentId: DepartmentId, + ): Decision; +} = dual( + 3, + ( + authority: OrganizationPersonAuthority, + capability: OrganizationCapability, + departmentId: DepartmentId, + ): Decision => { + if (authority.globalAdministrator === "Active") { + return allow( + AdmissionPeriodActorSchema.cases.GlobalAdmin.make({ + personId: authority.personId, + active: true, + }), + ); + } + + if (reaches(authority, capability, ReachTarget.Department({ departmentId }))) { + return allow( + AdmissionPeriodActorSchema.cases.DepartmentAdministrator.make({ + personId: authority.personId, + departmentId, + active: true, + }), + ); + } + + const memberships = authority.memberships.filter( + (membership) => membership.departmentId === departmentId, ); - } - if (reaches(authority, capability, ReachTarget.Department({ departmentId }))) { - return allow( - AdmissionPeriodActorSchema.cases.DepartmentAdministrator.make({ - personId: authority.personId, - departmentId, - active: true, - }), + if (memberships.some((membership) => membership.active)) { + return allow( + AdmissionPeriodActorSchema.cases.Member.make({ + personId: authority.personId, + departmentId, + active: true, + }), + ); + } + + return deny( + memberships.length > 0 || authority.globalAdministrator === "Inactive" + ? "AuthorityInactive" + : "NotInScope", ); - } - - const memberships = authority.memberships.filter( - (membership) => membership.departmentId === departmentId, - ); - - if (memberships.some((membership) => membership.active)) { - return allow( - AdmissionPeriodActorSchema.cases.Member.make({ - personId: authority.personId, - departmentId, - active: true, - }), - ); - } - - return deny( - memberships.length > 0 || authority.globalAdministrator === "Inactive" - ? "AuthorityInactive" - : "NotInScope", - ); -}; + }, +); export const mapOrganizationAuthorityToOrganizationActor = ( authority: OrganizationPersonAuthority, @@ -222,6 +246,61 @@ export const mapOrganizationAuthorityToOrganizationActor = ( ? OrganizationAdministratorSchema.make({ personId: authority.personId }) : OrganizationMemberSchema.make({ personId: authority.personId }); +/** Type-only brand. No module exports a value of it, so no module outside this one can build evidence. */ +declare const OrganizationAdministratorEvidenceBrand: unique symbol; + +/** + * Proof that a person held active global administration when their authority was resolved. + * `actor` is the audit identity that the command records. + */ +export interface OrganizationAdministratorEvidence { + readonly [OrganizationAdministratorEvidenceBrand]: "OrganizationAdministratorEvidence"; + readonly actor: OrganizationAdministrator; +} + +/** + * Checks that a resolved authority holds active global administration, and returns the evidence + * that the Organization administration commands require. + * + * @remarks + * It is the only constructor of {@link OrganizationAdministratorEvidence}. An active global + * administrator yields evidence whose actor names the same person; an inactive or absent grant + * yields `OrganizationRoleDenied` for that person. It reads only `globalAdministrator` and + * `personId`: memberships, board seats, and delegations confer no organization administration. + * + * @sideEffects none + * + * @example + * ```ts + * const administrator = yield* Effect.fromResult(requireOrganizationAdministrator(authority)); + * yield* organization.createDepartment(command, administrator); + * ``` + * + * @avoid Passing an `OrganizationActor` to a create command, or checking its tag at the call site: + * any module can build an actor, so the command could not trust it. Resolve the authority and + * require the evidence here. + * + * @construct authority-evidence + */ +export const requireOrganizationAdministrator = ( + /** The person's authority, resolved from current facts inside the command transaction. */ + authority: OrganizationPersonAuthority, +): /** Evidence for an active global administrator, or the typed denial. */ +Result.Result => + authority.globalAdministrator === "Active" + ? Result.succeed( + // SAFETY: the one constructor of the evidence brand; the branch above is the check it proves. + { + actor: OrganizationAdministratorSchema.make({ personId: authority.personId }), + } as OrganizationAdministratorEvidence, + ) + : Result.fail( + OrganizationRoleDenied.make({ + actorPersonId: authority.personId, + requiredRole: "OrganizationAdministrator", + }), + ); + export const mapOrganizationAuthorityToProfileRole = ( authority: OrganizationPersonAuthority, ): Decision => { @@ -252,3 +331,125 @@ export const mapOrganizationAuthorityToProfileRole = ( : "AuthorityInactive", ); }; + +/** Type-only brand. Only {@link requireTeamInterestScope} builds this evidence. */ +declare const TeamInterestReadScopeBrand: unique symbol; + +/** + * The registrations that a person may read now: whole departments that `team-interest.read` + * reaches, and the teams that it reaches outside them. The listing reads nothing else. + */ +export interface TeamInterestReadScope { + readonly [TeamInterestReadScopeBrand]: "TeamInterestReadScope"; + readonly personId: PersonId; + readonly departmentIds: ReadonlyArray; + readonly teams: ReadonlyArray<{ readonly teamId: TeamId; readonly departmentId: DepartmentId }>; +} + +/** The person reads no team interest, or none in the requested department. */ +export class TeamInterestScopeDenied extends Data.TaggedError("TeamInterestScopeDenied")<{ + readonly personId: PersonId; + readonly departmentId: DepartmentId | undefined; +}> {} + +/** + * Checks what team interest a resolved authority may read, narrowed to one requested department, + * and returns the scope that the listing requires. + * + * @remarks + * It is the only constructor of {@link TeamInterestReadScope}. A reach over the organization + * reads every department in `departments`, also while there is none; a department reach reads + * that department; a team leader's reach reads the team where no department reach covers it. A + * person with no reach, or with none in the requested department or its teams, is denied. + * + * @sideEffects none + * + * @example + * ```ts + * const scope = yield* Effect.fromResult( + * requireTeamInterestScope(authority, { requested, departments }), + * ); + * yield* organization.listTeamInterestRegistrations(scope, semesterId); + * ``` + * + * @avoid Computing the departments and teams in a handler and passing them to the listing as a + * filter: the listing then reads whatever scope a caller names. Require the scope here. + * + * @construct authority-evidence + */ +export const requireTeamInterestScope: { + (input: { + /** The department the request names, if any. */ + readonly requested: DepartmentId | undefined; + /** Every current department, which an organization-wide reach reads. */ + readonly departments: ReadonlyArray; + }): ( + /** The person's authority, resolved from current facts at the request's instant. */ + authority: OrganizationPersonAuthority, + ) => /** The readable scope, or the typed denial. */ + Result.Result; + ( + /** The person's authority, resolved from current facts at the request's instant. */ + authority: OrganizationPersonAuthority, + input: { + /** The department the request names, if any. */ + readonly requested: DepartmentId | undefined; + /** Every current department, which an organization-wide reach reads. */ + readonly departments: ReadonlyArray; + }, + ): /** The readable scope, or the typed denial. */ + Result.Result; +} = dual( + 2, + ( + /** The person's authority, resolved from current facts at the request's instant. */ + authority: OrganizationPersonAuthority, + input: { + /** The department the request names, if any. */ + readonly requested: DepartmentId | undefined; + /** Every current department, which an organization-wide reach reads. */ + readonly departments: ReadonlyArray; + }, + ): /** The readable scope, or the typed denial. */ + Result.Result => { + const reached = reachedDepartments(authority, "team-interest.read"); + const organizationWide = ReachedDepartments.$is("All")(reached); + const departmentScope = organizationWide ? input.departments : reached.departmentIds; + + const teamScope = reachedTeams(authority, "team-interest.read").flatMap((teamId) => { + const membership = authority.memberships.find((entry) => entry.teamId === teamId); + + return membership === undefined ? [] : [{ teamId, departmentId: membership.departmentId }]; + }); + + const denied = new TeamInterestScopeDenied({ + personId: authority.personId, + departmentId: input.requested, + }); + + if (!organizationWide && departmentScope.length === 0 && teamScope.length === 0) + return Result.fail(denied); + + const teams: TeamInterestReadScope["teams"] = teamScope.filter( + (team) => + (input.requested === undefined || team.departmentId === input.requested) && + !departmentScope.includes(team.departmentId), + ); + + const departmentIds = + input.requested === undefined + ? departmentScope + : departmentScope.includes(input.requested) + ? [input.requested] + : teams.length > 0 + ? [] + : undefined; + + return departmentIds === undefined + ? Result.fail(denied) + : Result.succeed( + // SAFETY: the one constructor of the evidence brand; the scope above is what it proves. + { personId: authority.personId, departmentIds, teams } as TeamInterestReadScope, + ); + }, +); diff --git a/packages/domain/src/organization/directory.ts b/packages/domain/src/organization/directory.ts index 95c83eb71..62dc9ced7 100644 --- a/packages/domain/src/organization/directory.ts +++ b/packages/domain/src/organization/directory.ts @@ -1,4 +1,5 @@ import { Data } from "effect"; +import { dual } from "effect/Function"; import type { OrganizationAuthorityInstant } from "./authority.js"; import { type OrganizationGlobalAdministratorStatus, @@ -152,11 +153,11 @@ export const resolveDirectoryGateScope = ( * Whether one row's derived departments intersect the authorized scope. An * empty intersection is a legitimate empty view, never a denial. */ -export const directoryRowInScope = ( - scope: DirectoryGateScope, - departments: ReadonlyArray, -): boolean => { +export const directoryRowInScope: { + (departments: ReadonlyArray): (scope: DirectoryGateScope) => boolean; + (scope: DirectoryGateScope, departments: ReadonlyArray): boolean; +} = dual(2, (scope: DirectoryGateScope, departments: ReadonlyArray): boolean => { if (DirectoryGateScope.$is("AllDepartments")(scope)) return true; return departments.some((departmentId) => scope.departmentIds.includes(departmentId)); -}; +}); diff --git a/packages/domain/src/organization/import.ts b/packages/domain/src/organization/import.ts index 979687f07..6d10f039d 100644 --- a/packages/domain/src/organization/import.ts +++ b/packages/domain/src/organization/import.ts @@ -377,16 +377,22 @@ const teamFromLegacy = ( return decoded.ok ? { team: decoded.value } : { reason: "MISSING_TEAM_FIELD" as const }; }; +/** A missing or empty identity leaves its source row unresolved. */ +const isUnresolved = (identity: string | undefined): boolean => + identity === undefined || identity === ""; + const membershipFromLegacy = ( row: LegacyMembershipRow, teams: ReadonlySet, identities: OrganizationImportIdentities, ) => { - if (!identities.persons[sourceId(row.userId)]) return { reason: "PERSON_UNRESOLVED" as const }; + if (isUnresolved(identities.persons[sourceId(row.userId)])) + return { reason: "PERSON_UNRESOLVED" as const }; - if (!identities.memberships[sourceId(row.id)]) return { reason: "IDENTITY_UNRESOLVED" as const }; + if (isUnresolved(identities.memberships[sourceId(row.id)])) + return { reason: "IDENTITY_UNRESOLVED" as const }; - if (row.positionId != null && !identities.positions[sourceId(row.positionId)]) + if (row.positionId != null && isUnresolved(identities.positions[sourceId(row.positionId)])) return { reason: "POSITION_UNRESOLVED" as const }; if ( @@ -410,7 +416,7 @@ const membershipFromLegacy = ( const legacyTeamId = row.teamId; const teamId = legacyTeamId === null ? null : (identities.teams[sourceId(legacyTeamId)] ?? null); - if (legacyTeamId !== null && (!teams.has(legacyTeamId) || !teamId)) + if (legacyTeamId !== null && (!teams.has(legacyTeamId) || teamId === null || teamId === "")) return { reason: "TEAM_UNRESOLVED" as const }; const deletedTeamName = row.deletedTeamName ?? null; @@ -509,7 +515,7 @@ export const importLegacyOrganization = ( continue; } - if (!snapshot.identities.departments[sourcePrimaryKey]) { + if (isUnresolved(snapshot.identities.departments[sourcePrimaryKey])) { quarantine( output, snapshot, @@ -611,7 +617,7 @@ export const importLegacyOrganization = ( continue; } - if (!snapshot.identities.teams[sourcePrimaryKey]) { + if (isUnresolved(snapshot.identities.teams[sourcePrimaryKey])) { quarantine(output, snapshot, "team", sourcePrimaryKey, target, "IDENTITY_UNRESOLVED", raw); continue; } @@ -766,7 +772,7 @@ export const importLegacyOrganizationEffect = ( Effect.try({ try: () => importLegacyOrganization(snapshot), catch: (cause) => - new OrganizationImportError({ + OrganizationImportError.make({ operation: "import legacy organization", message: String(cause), }), diff --git a/packages/domain/src/organization/lifecycle.ts b/packages/domain/src/organization/lifecycle.ts index 0c513f6ec..7fdfda683 100644 --- a/packages/domain/src/organization/lifecycle.ts +++ b/packages/domain/src/organization/lifecycle.ts @@ -1,4 +1,5 @@ import { Match, Result, Schema } from "effect"; +import { dual } from "effect/Function"; import { TeamScope, UnitKind } from "../authz/delegation.js"; import { AccountAccess } from "../identity/access.js"; import { compareRfc3339Instants, Rfc3339InstantSchema } from "../time.js"; @@ -181,15 +182,26 @@ export class OrganizationLifecycleFailure extends Schema.TaggedError, - now: string, -): Appointment["state"] => - appointment.endAt !== null && compareRfc3339Instants(appointment.endAt, now) <= 0 - ? "Ended" - : compareRfc3339Instants(appointment.startAt, now) > 0 - ? "Future" - : "Current"; +export const appointmentStateAt: { + ( + now: string, + ): (appointment: Pick) => Appointment["state"]; + ( + appointment: Pick, + now: string, + ): Appointment["state"]; +} = dual( + 2, + ( + appointment: Pick, + now: string, + ): Appointment["state"] => + appointment.endAt !== null && compareRfc3339Instants(appointment.endAt, now) <= 0 + ? "Ended" + : compareRfc3339Instants(appointment.startAt, now) > 0 + ? "Future" + : "Current", +); export type AppointmentCommand = Exclude< OrganizationLifecycleCommand, @@ -210,72 +222,92 @@ export class AppointmentTransitionFailure extends Schema.TaggedError => - Match.value(command).pipe( - Match.withReturnType>(), - Match.tag("Appoint", (command) => - current - ? Result.fail(new AppointmentTransitionFailure({ code: "Invalid" })) - : Result.succeed({ - appointmentId, - personId: command.personId, - target: command.target, - position: command.position, - leadership: command.leadership, - startAt: command.startAt, - endAt: command.endAt, - suspended: false, - revision: 0, - state: "Future", - }), - ), - Match.orElse((command) => { - if (!current) return Result.fail(new AppointmentTransitionFailure({ code: "NotFound" })); - - if (current.revision !== command.expectedRevision) - return Result.fail(new AppointmentTransitionFailure({ code: "Stale" })); - - return Match.value(command).pipe( - Match.withReturnType>(), - Match.tag("ReviseAppointment", (command) => - Result.succeed({ - ...current, - position: command.position, - leadership: command.leadership, - startAt: command.startAt, - endAt: command.endAt, - revision: current.revision + 1, - }), - ), - Match.tag("EndAppointment", (command) => - current.endAt !== null && compareRfc3339Instants(current.endAt, now) <= 0 - ? Result.fail(new AppointmentTransitionFailure({ code: "Invalid" })) - : Result.succeed({ ...current, endAt: command.endAt, revision: current.revision + 1 }), - ), - Match.tag("SuspendAppointment", () => - current.suspended - ? Result.fail(new AppointmentTransitionFailure({ code: "Invalid" })) - : Result.succeed({ ...current, suspended: true, revision: current.revision + 1 }), - ), - Match.tag("ReinstateAppointment", () => - !current.suspended - ? Result.fail(new AppointmentTransitionFailure({ code: "Invalid" })) - : Result.succeed({ ...current, suspended: false, revision: current.revision + 1 }), - ), - Match.exhaustive, - ); - }), - Result.flatMap((next) => - next.endAt !== null && compareRfc3339Instants(next.endAt, next.startAt) <= 0 - ? Result.fail(new AppointmentTransitionFailure({ code: "Invalid" })) - : Result.succeed({ ...next, state: appointmentStateAt(next, now) }), +export const transitionAppointment: { + ( + command: AppointmentCommand, + appointmentId: string, + now: string, + ): (current: Appointment | undefined) => Result.Result; + ( + current: Appointment | undefined, + command: AppointmentCommand, + appointmentId: string, + now: string, + ): Result.Result; +} = dual( + 4, + ( + current: Appointment | undefined, + command: AppointmentCommand, + appointmentId: string, + now: string, + ): Result.Result => + Match.value(command).pipe( + Match.withReturnType>(), + Match.tag("Appoint", (command) => + current !== undefined + ? Result.fail(AppointmentTransitionFailure.make({ code: "Invalid" })) + : Result.succeed({ + appointmentId, + personId: command.personId, + target: command.target, + position: command.position, + leadership: command.leadership, + startAt: command.startAt, + endAt: command.endAt, + suspended: false, + revision: 0, + state: "Future", + }), + ), + Match.orElse((command) => { + if (current === undefined) + return Result.fail(AppointmentTransitionFailure.make({ code: "NotFound" })); + + if (current.revision !== command.expectedRevision) + return Result.fail(AppointmentTransitionFailure.make({ code: "Stale" })); + + return Match.value(command).pipe( + Match.withReturnType>(), + Match.tag("ReviseAppointment", (command) => + Result.succeed({ + ...current, + position: command.position, + leadership: command.leadership, + startAt: command.startAt, + endAt: command.endAt, + revision: current.revision + 1, + }), + ), + Match.tag("EndAppointment", (command) => + current.endAt !== null && compareRfc3339Instants(current.endAt, now) <= 0 + ? Result.fail(AppointmentTransitionFailure.make({ code: "Invalid" })) + : Result.succeed({ + ...current, + endAt: command.endAt, + revision: current.revision + 1, + }), + ), + Match.tag("SuspendAppointment", () => + current.suspended + ? Result.fail(AppointmentTransitionFailure.make({ code: "Invalid" })) + : Result.succeed({ ...current, suspended: true, revision: current.revision + 1 }), + ), + Match.tag("ReinstateAppointment", () => + !current.suspended + ? Result.fail(AppointmentTransitionFailure.make({ code: "Invalid" })) + : Result.succeed({ ...current, suspended: false, revision: current.revision + 1 }), + ), + Match.exhaustive, + ); + }), + Result.flatMap((next) => + next.endAt !== null && compareRfc3339Instants(next.endAt, next.startAt) <= 0 + ? Result.fail(AppointmentTransitionFailure.make({ code: "Invalid" })) + : Result.succeed({ ...next, state: appointmentStateAt(next, now) }), + ), ), - ); +); type ClassifyTeamCommand = Extract; @@ -288,43 +320,71 @@ type RecogniseDepartmentCommand = Extract< * A department has at most one board, a board works for its own department, and a repeated * classification needs replay, not a new transition. */ -export const transitionTeamClassification = ( - current: TeamClassification, - command: ClassifyTeamCommand, - anotherBoardInDepartment: boolean, -): Result.Result => { - if (current.revision !== command.expectedRevision) - return Result.fail(new AppointmentTransitionFailure({ code: "Stale" })); - - if ( - (command.unitKind === "DepartmentBoard" && - (command.teamScope === "National" || anotherBoardInDepartment)) || - (command.unitKind === current.unitKind && command.teamScope === current.teamScope) - ) - return Result.fail(new AppointmentTransitionFailure({ code: "Invalid" })); - - return Result.succeed({ - ...current, - unitKind: command.unitKind, - teamScope: command.teamScope, - revision: current.revision + 1, - }); -}; +export const transitionTeamClassification: { + ( + command: ClassifyTeamCommand, + anotherBoardInDepartment: boolean, + ): ( + current: TeamClassification, + ) => Result.Result; + ( + current: TeamClassification, + command: ClassifyTeamCommand, + anotherBoardInDepartment: boolean, + ): Result.Result; +} = dual( + 3, + ( + current: TeamClassification, + command: ClassifyTeamCommand, + anotherBoardInDepartment: boolean, + ): Result.Result => { + if (current.revision !== command.expectedRevision) + return Result.fail(AppointmentTransitionFailure.make({ code: "Stale" })); + + if ( + (command.unitKind === "DepartmentBoard" && + (command.teamScope === "National" || anotherBoardInDepartment)) || + (command.unitKind === current.unitKind && command.teamScope === current.teamScope) + ) + return Result.fail(AppointmentTransitionFailure.make({ code: "Invalid" })); + + return Result.succeed({ + ...current, + unitKind: command.unitKind, + teamScope: command.teamScope, + revision: current.revision + 1, + }); + }, +); /** Recognising or withdrawing independence; a repeated state needs replay. */ -export const transitionDepartmentRecognition = ( - current: DepartmentRecognition, - command: RecogniseDepartmentCommand, -): Result.Result => { - if (current.revision !== command.expectedRevision) - return Result.fail(new AppointmentTransitionFailure({ code: "Stale" })); - - if (command.independent === current.independent) - return Result.fail(new AppointmentTransitionFailure({ code: "Invalid" })); - - return Result.succeed({ - ...current, - independent: command.independent, - revision: current.revision + 1, - }); -}; +export const transitionDepartmentRecognition: { + ( + command: RecogniseDepartmentCommand, + ): ( + current: DepartmentRecognition, + ) => Result.Result; + ( + current: DepartmentRecognition, + command: RecogniseDepartmentCommand, + ): Result.Result; +} = dual( + 2, + ( + current: DepartmentRecognition, + command: RecogniseDepartmentCommand, + ): Result.Result => { + if (current.revision !== command.expectedRevision) + return Result.fail(AppointmentTransitionFailure.make({ code: "Stale" })); + + if (command.independent === current.independent) + return Result.fail(AppointmentTransitionFailure.make({ code: "Invalid" })); + + return Result.succeed({ + ...current, + independent: command.independent, + revision: current.revision + 1, + }); + }, +); diff --git a/packages/domain/src/organization/mailing-lists.ts b/packages/domain/src/organization/mailing-lists.ts index f6e9ddff1..c48259377 100644 --- a/packages/domain/src/organization/mailing-lists.ts +++ b/packages/domain/src/organization/mailing-lists.ts @@ -1,5 +1,6 @@ import type { DepartmentId, PersonId } from "./schema.js"; import { compareRfc3339Instants } from "../time.js"; +import { dual } from "effect/Function"; /** Pure rendering of scoped recipient facts and canonical Profile contacts. */ @@ -28,12 +29,24 @@ export interface MailingList { } /** Half-open intervals overlap only when both starts precede the other end. */ -export const membershipCoversSemester = ( - membership: { readonly startAt: string; readonly endAt: string | null }, - semester: { readonly startAt: string; readonly endAt: string }, -): boolean => - compareRfc3339Instants(membership.startAt, semester.endAt) < 0 && - (membership.endAt === null || compareRfc3339Instants(semester.startAt, membership.endAt) < 0); +export const membershipCoversSemester: { + (semester: { + readonly startAt: string; + readonly endAt: string; + }): (membership: { readonly startAt: string; readonly endAt: string | null }) => boolean; + ( + membership: { readonly startAt: string; readonly endAt: string | null }, + semester: { readonly startAt: string; readonly endAt: string }, + ): boolean; +} = dual( + 2, + ( + membership: { readonly startAt: string; readonly endAt: string | null }, + semester: { readonly startAt: string; readonly endAt: string }, + ): boolean => + compareRfc3339Instants(membership.startAt, semester.endAt) < 0 && + (membership.endAt === null || compareRfc3339Instants(semester.startAt, membership.endAt) < 0), +); const mergeFirstSeen = ( assistants: ReadonlyArray, diff --git a/packages/domain/src/organization/review-classification.ts b/packages/domain/src/organization/review-classification.ts index 28c91ad93..e8585d8ce 100644 --- a/packages/domain/src/organization/review-classification.ts +++ b/packages/domain/src/organization/review-classification.ts @@ -1,4 +1,5 @@ import { flow, Option, Predicate, Result, Schema } from "effect"; +import { dual } from "effect/Function"; import { normalizeRfc3339Instant } from "../time.js"; import { canonicalJson } from "../shared-kernel/index.js"; import { @@ -110,59 +111,62 @@ const uniqueNamedRows = (rows: ReadonlyArray) => { return valid.filter((row) => counts.get(row.id) === 1); }; -export const organizationOccurrenceSourceDigest = ( - snapshot: ReviewedOrganizationSnapshot, - occurrence: OrganizationSourceOccurrence, -): string => { - const row = rawObject(occurrence.row); - - const unit = - (occurrence.sourceKind === "TeamMembership" ? snapshot.teams : snapshot.boards).find( - (raw) => - idText(rawObject(raw).id) === - idText(occurrence.sourceKind === "TeamMembership" ? row.teamId : row.boardId), - ) ?? null; - - const position = - occurrence.sourceKind === "TeamMembership" && row.positionId != null - ? (snapshot.positions.find((raw) => idText(rawObject(raw).id) === idText(row.positionId)) ?? - null) - : null; - - const personMapping = - snapshot.mappings.persons.find( - (mapping) => mapping.sourceUserId === `legacy-user:${idText(row.userId)}`, - ) ?? null; - - const departmentMapping = - snapshot.mappings.departments.find( - (mapping) => - mapping.sourceDepartmentId === `legacy-department:${idText(rawObject(unit).departmentId)}`, - ) ?? null; - - return organizationEvidenceDigest({ - sourceKind: occurrence.sourceKind, - sourceId: occurrence.sourceId, - row: occurrence.row, - sourceRowDigest: occurrence.sourceRowDigest, - review: - snapshot.review.memberships.find( - (review) => - review.sourceKind === occurrence.sourceKind && review.sourceId === occurrence.sourceId, - ) ?? null, - asOf: snapshot.review.asOf, - attestedBy: snapshot.review.attestedBy, - evidenceRef: snapshot.review.evidenceRef, - transformationRevision: snapshot.transformationRevision, - personMapping, - departmentMapping, - unit, - position, - }); -}; +export const organizationOccurrenceSourceDigest: { + (occurrence: OrganizationSourceOccurrence): (snapshot: ReviewedOrganizationSnapshot) => string; + (snapshot: ReviewedOrganizationSnapshot, occurrence: OrganizationSourceOccurrence): string; +} = dual( + 2, + (snapshot: ReviewedOrganizationSnapshot, occurrence: OrganizationSourceOccurrence): string => { + const row = rawObject(occurrence.row); + + const unit = + (occurrence.sourceKind === "TeamMembership" ? snapshot.teams : snapshot.boards).find( + (raw) => + idText(rawObject(raw).id) === + idText(occurrence.sourceKind === "TeamMembership" ? row.teamId : row.boardId), + ) ?? null; + + const position = + occurrence.sourceKind === "TeamMembership" && row.positionId != null + ? (snapshot.positions.find((raw) => idText(rawObject(raw).id) === idText(row.positionId)) ?? + null) + : null; + + const personMapping = + snapshot.mappings.persons.find( + (mapping) => mapping.sourceUserId === `legacy-user:${idText(row.userId)}`, + ) ?? null; + + const departmentMapping = + snapshot.mappings.departments.find( + (mapping) => + mapping.sourceDepartmentId === + `legacy-department:${idText(rawObject(unit).departmentId)}`, + ) ?? null; + + return organizationEvidenceDigest({ + sourceKind: occurrence.sourceKind, + sourceId: occurrence.sourceId, + row: occurrence.row, + sourceRowDigest: occurrence.sourceRowDigest, + review: + snapshot.review.memberships.find( + (review) => + review.sourceKind === occurrence.sourceKind && review.sourceId === occurrence.sourceId, + ) ?? null, + asOf: snapshot.review.asOf, + attestedBy: snapshot.review.attestedBy, + evidenceRef: snapshot.review.evidenceRef, + transformationRevision: snapshot.transformationRevision, + personMapping, + departmentMapping, + unit, + position, + }); + }, +); -/** Accepted identity evidence is resolved before the shared classifier sees any membership. */ -export const classifyReviewedOrganization = ( +const classifyReviewedOrganizationImpl = ( snapshot: ReviewedOrganizationSnapshot, acceptedPersons: Readonly>, departments: ReadonlyArray, @@ -285,7 +289,9 @@ export const classifyReviewedOrganization = ( continue; } - if (!acceptedPersons[String(row.userId)]) { + const acceptedPerson = acceptedPersons[String(row.userId)]; + + if (acceptedPerson === undefined || acceptedPerson === "") { reject("PersonReconciliationMissing"); continue; } @@ -297,7 +303,7 @@ export const classifyReviewedOrganization = ( if (occurrence.sourceKind === "TeamMembership") { membership = memberships.get(targetId); - if (!membership) { + if (membership === undefined) { const reason = classified.ledger.find( (entry) => entry.sourceKind === "membership" && entry.sourcePrimaryKey === occurrence.sourceId, @@ -349,7 +355,7 @@ export const classifyReviewedOrganization = ( const board = boards.find((board) => board.id === decoded.value.boardId); - if (!board) { + if (board === undefined) { reject("ReferenceMissing"); continue; } @@ -439,3 +445,18 @@ export const classifyReviewedOrganization = ( ), }; }; + +/** Accepted identity evidence is resolved before the shared classifier sees any membership. */ +export const classifyReviewedOrganization: { + ( + acceptedPersons: Readonly>, + departments: ReadonlyArray, + ): ( + snapshot: ReviewedOrganizationSnapshot, + ) => ReturnType; + ( + snapshot: ReviewedOrganizationSnapshot, + acceptedPersons: Readonly>, + departments: ReadonlyArray, + ): ReturnType; +} = dual(3, classifyReviewedOrganizationImpl); diff --git a/packages/domain/src/organization/review.ts b/packages/domain/src/organization/review.ts index 20df3fb3e..de525cac1 100644 --- a/packages/domain/src/organization/review.ts +++ b/packages/domain/src/organization/review.ts @@ -1,4 +1,5 @@ import { Data, Result, Schema } from "effect"; +import { dual } from "effect/Function"; import { canonicalJsonBytes, sha256Hex } from "../shared-kernel/index.js"; import { compareRfc3339Instants, Rfc3339InstantSchema } from "../time.js"; import { appointmentStateAt } from "./lifecycle.js"; @@ -102,72 +103,96 @@ export const organizationSnapshotDigest = (snapshot: Schema.JsonObject): string Object.fromEntries(Object.entries(snapshot).filter(([key]) => key !== "snapshotDigest")), ); -export const reviewedOrganizationTargetId = ( - kind: "Team" | "Board" | "Position" | "TeamMembership" | "BoardMembership", - sourceRepository: string, - sourceId: string, -): string => `org-${organizationEvidenceDigest([kind, sourceRepository, sourceId])}`; +export const reviewedOrganizationTargetId: { + ( + sourceRepository: string, + sourceId: string, + ): (kind: "Team" | "Board" | "Position" | "TeamMembership" | "BoardMembership") => string; + ( + kind: "Team" | "Board" | "Position" | "TeamMembership" | "BoardMembership", + sourceRepository: string, + sourceId: string, + ): string; +} = dual( + 3, + ( + kind: "Team" | "Board" | "Position" | "TeamMembership" | "BoardMembership", + sourceRepository: string, + sourceId: string, + ): string => `org-${organizationEvidenceDigest([kind, sourceRepository, sourceId])}`, +); const invalidReview = () => new OrganizationCohortFailure({ code: "InvalidReview" }); const invalidSnapshot = () => new OrganizationCohortFailure({ code: "InvalidSnapshot" }); -export const validateOrganizationReview = ( - input: Schema.Json, - occurrences: ReadonlyArray, -): Result.Result => - Result.gen(function* () { - const review = yield* Schema.decodeUnknownResult(OrganizationReview)(input, { - onExcessProperty: "error", - }).pipe(Result.mapError(invalidReview)); - - const keys = new Set(); - - const entries = new Map( - review.memberships.map((entry) => [ - JSON.stringify([entry.sourceKind, entry.sourceId]), - entry, - ]), - ); - - if (entries.size !== review.memberships.length || entries.size !== occurrences.length) - return yield* Result.fail(invalidReview()); - - for (const occurrence of occurrences) { - const key = JSON.stringify([occurrence.sourceKind, occurrence.sourceId]); - const entry = entries.get(key); +export const validateOrganizationReview: { + ( + occurrences: ReadonlyArray, + ): (input: Schema.Json) => Result.Result; + ( + input: Schema.Json, + occurrences: ReadonlyArray, + ): Result.Result; +} = dual( + 2, + ( + input: Schema.Json, + occurrences: ReadonlyArray, + ): Result.Result => + Result.gen(function* () { + const review = yield* Schema.decodeUnknownResult(OrganizationReview)(input, { + onExcessProperty: "error", + }).pipe(Result.mapError(invalidReview)); + + const keys = new Set(); + + const entries = new Map( + review.memberships.map((entry) => [ + JSON.stringify([entry.sourceKind, entry.sourceId]), + entry, + ]), + ); - if ( - keys.has(key) || - !entry || - entry.sourceRowDigest !== occurrence.sourceRowDigest || - occurrence.sourceRowDigest !== organizationEvidenceDigest(occurrence.row) - ) + if (entries.size !== review.memberships.length || entries.size !== occurrences.length) return yield* Result.fail(invalidReview()); - keys.add(key); - if (entry.decision === "Excluded") { - continue; + for (const occurrence of occurrences) { + const key = JSON.stringify([occurrence.sourceKind, occurrence.sourceId]); + const entry = entries.get(key); + + if ( + keys.has(key) || + entry === undefined || + entry.sourceRowDigest !== occurrence.sourceRowDigest || + occurrence.sourceRowDigest !== organizationEvidenceDigest(occurrence.row) + ) + return yield* Result.fail(invalidReview()); + keys.add(key); + + if (entry.decision === "Excluded") { + continue; + } + + if ( + entry.startAt === undefined || + entry.endAt === undefined || + (entry.endAt !== null && compareRfc3339Instants(entry.endAt, entry.startAt) <= 0) + ) + return yield* Result.fail(invalidReview()); + + const state = appointmentStateAt( + { startAt: entry.startAt, endAt: entry.endAt, suspended: false }, + review.asOf, + ); + + if (state !== (entry.decision === "Historical" ? "Ended" : entry.decision)) + return yield* Result.fail(invalidReview()); } - if ( - entry.startAt === undefined || - entry.endAt === undefined || - (entry.endAt !== null && compareRfc3339Instants(entry.endAt, entry.startAt) <= 0) - ) - return yield* Result.fail(invalidReview()); - - const state = appointmentStateAt( - { startAt: entry.startAt, endAt: entry.endAt, suspended: false }, - review.asOf, - ); - - if (state !== (entry.decision === "Historical" ? "Ended" : entry.decision)) - return yield* Result.fail(invalidReview()); - } - - return review; - }); + return review; + }), +); export const decodeReviewedOrganizationSnapshot = ( input: Schema.Json, diff --git a/packages/domain/src/organization/schema.ts b/packages/domain/src/organization/schema.ts index ab2185280..7e90d1e4c 100644 --- a/packages/domain/src/organization/schema.ts +++ b/packages/domain/src/organization/schema.ts @@ -27,7 +27,7 @@ const Revision = Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0))) const BooleanValue = Schema.Boolean; -/** Numeric identity demanded by the frozen SDK TeamInterest schema (spec 0059). */ +/** Numeric identity demanded by the frozen TeamInterest schema (spec 0059). */ const RegistrationNumber = Schema.Int.pipe( Schema.check( Schema.makeFilter(Number.isSafeInteger, { message: "a safe integer" }), diff --git a/packages/domain/src/organization/service.ts b/packages/domain/src/organization/service.ts index b51ef0e25..dde9ef428 100644 --- a/packages/domain/src/organization/service.ts +++ b/packages/domain/src/organization/service.ts @@ -23,9 +23,13 @@ import type { CreateFieldOfStudyResult, CreateTeamCommand, CreateTeamResult, - OrganizationActor, } from "./administration-schema.js"; -import type { OrganizationAuthorityInstant, OrganizationPersonAuthority } from "./authority.js"; +import type { + OrganizationAdministratorEvidence, + TeamInterestReadScope, + OrganizationAuthorityInstant, + OrganizationPersonAuthority, +} from "./authority.js"; import type { OrganizationDirectoryFacts } from "./directory.js"; import type { SemesterId, TeamInterestRegistration } from "./schema.js"; import type { @@ -61,13 +65,6 @@ import type { Profile } from "../profile/service.js"; * Spec 0059 read filter: the authorized scope is explicit input (0055). A registration is in * scope when its department or its team is authorized. */ -export interface TeamInterestFilter { - readonly authorizedDepartmentIds: ReadonlyArray; - readonly authorizedTeamIds: ReadonlyArray; - readonly departmentId?: DepartmentId; - readonly semesterId?: SemesterId; -} - export type OrganizationListFailure = OrganizationDecodeError | OrganizationPersistenceError; export type OrganizationReadError = @@ -126,8 +123,10 @@ export interface OrganizationOperations { * registration_id ASC. No authorization happens here; no narrowing may * exceed the authorized set. */ + /** The registrations inside the reader's scope, optionally of one semester. */ readonly listTeamInterestRegistrations: ( - filter: TeamInterestFilter, + scope: TeamInterestReadScope, + semesterId?: SemesterId, ) => Effect.Effect< ReadonlyArray, OrganizationDecodeError | OrganizationPersistenceError @@ -152,15 +151,15 @@ export interface OrganizationOperations { readonly createDepartment: ( command: CreateDepartmentCommand, - actor: OrganizationActor, + administrator: OrganizationAdministratorEvidence, ) => Effect.Effect; readonly createTeam: ( command: CreateTeamCommand, - actor: OrganizationActor, + administrator: OrganizationAdministratorEvidence, ) => Effect.Effect; readonly createFieldOfStudy: ( command: CreateFieldOfStudyCommand, - actor: OrganizationActor, + administrator: OrganizationAdministratorEvidence, ) => Effect.Effect; readonly readMembership: ( membershipId: MembershipId, @@ -211,5 +210,5 @@ export interface OrganizationOperations { } export class Organization extends Context.Service()( - "@vektorprogrammet/domain/Organization", + "@vektorprogrammet/domain/organization/service/Organization", ) {} diff --git a/packages/domain/src/organization/transitions.ts b/packages/domain/src/organization/transitions.ts index 18f798262..5237c3e48 100644 --- a/packages/domain/src/organization/transitions.ts +++ b/packages/domain/src/organization/transitions.ts @@ -1,7 +1,11 @@ import { compareRfc3339Instants } from "../time.js"; import { isMembershipInterval, isRfc3339, type Membership } from "./schema.js"; +import { dual } from "effect/Function"; -export const membershipIsActiveAt = (membership: Membership, at: string): boolean => { +export const membershipIsActiveAt: { + (at: string): (membership: Membership) => boolean; + (membership: Membership, at: string): boolean; +} = dual(2, (membership: Membership, at: string): boolean => { if (!isRfc3339(at) || !isMembershipInterval(membership)) return false; return ( @@ -9,4 +13,4 @@ export const membershipIsActiveAt = (membership: Membership, at: string): boolea (membership.endAt === null || compareRfc3339Instants(at, membership.endAt) < 0) && !membership.isSuspended ); -}; +}); diff --git a/packages/domain/src/placements/README.md b/packages/domain/src/placements/README.md index 66c27c02a..65396b050 100644 --- a/packages/domain/src/placements/README.md +++ b/packages/domain/src/placements/README.md @@ -109,15 +109,15 @@ An Effect requirement expresses composition, not permission to act for a person. | Caller concern | Contract and response | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | Authority | The caller authenticates and authorizes each operation. The service key is not an authority token. | -| Command identity | The transport derives `commandId` from its receipt identity. An arbitrary identifier does not provide HTTP replay protection. | +| Command identity | The transport derives `commandId` from its receipt identity. An arbitrary identifier does not provide replay protection. | | Precondition | The callback compares the locked snapshot with the caller's transport precondition. It does not grant authority or perform business writes. | | Domain rejection | `PlacementFailure` supplies a tagged failure, code, and status. The caller corrects the relevant scope or command before another attempt. | | Persistence failure | `PlacementPersistenceError` distinguishes transaction conflicts from internal errors. Its cause stays internal. | | Success | `execute` returns a snapshot within the caller's transaction. This is not yet a commit or a provider acknowledgement. | -The [HTTP adapter](../../../../apps/backend/src/placements/http.ts) shows the complete production call boundary. +The [RPC handlers](../../../../apps/backend/src/placements/rpc.ts) show the complete production call boundary. It resolves current authority and receipt identity before the service executes. -It evaluates the ETag precondition under the department lock. +It evaluates the `ifMatch` entity-tag precondition under the department lock. A read preflight or a successful pure policy calculation does not replace this boundary. ## Compose it @@ -133,7 +133,7 @@ Production composition differs from this fixture: - [Database Layers](../../../database/src/layers.ts) supply the adapter and run migrations. - [Backend composition](../../../../apps/backend/src/main.ts) shares the database Layer and supervises workers. -- [HTTP command transactions](../../../../apps/backend/src/http-api/receipt-transaction.ts) own authority preparation, receipt lookup, replay, and transaction completion. +- [Command transactions](../../../../apps/backend/src/rpc/receipt-transaction.ts) own authority preparation, receipt lookup, replay, and transaction completion. - [Notification worker](../../../../apps/backend/src/placements/notification.ts) supplies the roster interpreter and recovery schedule. A new server caller must preserve those responsibilities rather than call `execute` without an authorized transaction. @@ -181,7 +181,7 @@ A coordinator applies the chosen rows as ordinary `Create` commands against the ### Retry and interruption The Placements service does not install a retry policy. -The HTTP adapter selects `serialization-once` in the [receipt transaction owner](../../../../apps/backend/src/http-api/receipt-transaction.ts). +The RPC handler selects `serialization-once` in the [receipt transaction owner](../../../../apps/backend/src/rpc/receipt-transaction.ts). That owner repeats preparation and execution after a retryable rollback, so authority must remain inside preparation. Provider I/O must remain outside the business transaction. @@ -297,7 +297,7 @@ TypeDoc generates the local reference, renders this guide, and includes the exac The [Placements documentation command](../../../../tools/placements-docs/placements.ts) derives entry points from the `./placements` export of each manifest and compares fresh output. It does not parse TypeScript itself. The [documentation site](../../../../apps/docs/site.ts) renders this guide beside the other repository documents and links the generated reference. -The [HTTP generator](../../../http-api/scripts/generate-openapi.ts) remains the owner of HTTP reference artifacts. +The [RPC contract](../../../rpc/src/placements.ts) is the reference of the Placements RPCs; RPC produces no OpenAPI document. ## Cleanup and evidence diff --git a/packages/domain/src/placements/certificate.ts b/packages/domain/src/placements/certificate.ts index 89e5caa9d..7a0417722 100644 --- a/packages/domain/src/placements/certificate.ts +++ b/packages/domain/src/placements/certificate.ts @@ -3,7 +3,7 @@ * of the department whose confirmed total is above zero, with the confirmed schools and days, and * it names the issuer and the seat that authorizes them (docs/system.md#certificates). */ -import { Data, Option, Order, Schema } from "effect"; +import { Option, Order, Schema } from "effect"; import { CertificateIssuer } from "../organization/board-roster.js"; import { DepartmentId, PersonId, SemesterId } from "../organization/schema.js"; import { canonicalJsonBytes, sha256Hex } from "../shared-kernel/canonical-json.js"; @@ -204,13 +204,36 @@ export const CertificateAssistant = Schema.Struct({ export type CertificateAssistant = typeof CertificateAssistant.Type; -/** The command whose authority a request resolves before a stored response can replay. */ -export type CertificateCommandTarget = Data.TaggedEnum<{ - ConfirmDaysServed: { readonly departmentId: DepartmentId; readonly semesterId: SemesterId }; - IssueCertificate: { readonly departmentId: DepartmentId; readonly personId: PersonId }; -}>; +/** Type-only brands. Only the Placements adapter that resolves current authority builds these. */ +declare const DaysServedConfirmationAuthorizationBrand: unique symbol; -export const CertificateCommandTarget = Data.taggedEnum(); +declare const CertificateIssueAuthorizationBrand: unique symbol; + +/** + * Proof that the principal holds `placements.days-served` in one department and semester, resolved + * under the department lock inside the command's transaction. `confirmDaysServed` confirms only in + * this scope and resolves no authority again. Valid only in the transaction that resolved it. + */ +export interface DaysServedConfirmationAuthorization { + readonly [DaysServedConfirmationAuthorizationBrand]: "DaysServedConfirmationAuthorization"; + readonly principal: CertificatePrincipal; + readonly departmentId: DepartmentId; + readonly semesterId: SemesterId; +} + +/** + * Proof that the principal currently issues the department's certificates, under the seat named by + * `issuer`, for one other person, resolved under the department lock inside the command's + * transaction. `issueCertificate` issues only this certificate and resolves no authority again. + * Valid only in the transaction that resolved it. + */ +export interface CertificateIssueAuthorization { + readonly [CertificateIssueAuthorizationBrand]: "CertificateIssueAuthorization"; + readonly principal: CertificatePrincipal; + readonly department: { readonly departmentId: DepartmentId; readonly name: string }; + readonly personId: PersonId; + readonly issuer: CertificateIssuer; +} /** A semester with its bounds, as the scope choice and the days-served list name it. */ export const CertificateSemesterScope = Schema.Struct({ diff --git a/packages/domain/src/placements/days-served.ts b/packages/domain/src/placements/days-served.ts index 8d9ae5599..35a3d7575 100644 --- a/packages/domain/src/placements/days-served.ts +++ b/packages/domain/src/placements/days-served.ts @@ -8,6 +8,7 @@ * legacy workday totals, which count as totals and never become dates. */ import { Effect, Encoding, Order, Result, Schema } from "effect"; +import { dual } from "effect/Function"; import { DepartmentId, PersonId, SemesterId } from "../organization/schema.js"; import { SchoolId } from "../schools/schema.js"; import { canonicalJsonBytes, sha256Hex } from "../shared-kernel/canonical-json.js"; @@ -77,31 +78,42 @@ const uniqueSchools = (schools: Iterable): ReadonlyArray, - legacyTotals: ReadonlyArray, -): DaysServedEvidence => { - const byDate = new Map>(); - - for (const { serviceDate, school } of attendance) { - const schools = byDate.get(serviceDate); - - if (schools === undefined) byDate.set(serviceDate, [school]); - else schools.push(school); - } - - return { - dates: [...byDate.entries()] - .map(([serviceDate, schools]) => ({ serviceDate, schools: uniqueSchools(schools) })) - .toSorted(Order.mapInput(Order.String, (date) => date.serviceDate)), - legacyTotals: legacyTotals.toSorted( - Order.combine( - Order.mapInput(bySchool, (total: LegacyWorkdayTotal) => total.school), - Order.mapInput(Order.Number, (total: LegacyWorkdayTotal) => total.workdays), +export const daysServedEvidence: { + ( + legacyTotals: ReadonlyArray, + ): (attendance: ReadonlyArray) => DaysServedEvidence; + ( + attendance: ReadonlyArray, + legacyTotals: ReadonlyArray, + ): DaysServedEvidence; +} = dual( + 2, + ( + attendance: ReadonlyArray, + legacyTotals: ReadonlyArray, + ): DaysServedEvidence => { + const byDate = new Map>(); + + for (const { serviceDate, school } of attendance) { + const schools = byDate.get(serviceDate); + + if (schools === undefined) byDate.set(serviceDate, [school]); + else schools.push(school); + } + + return { + dates: [...byDate.entries()] + .map(([serviceDate, schools]) => ({ serviceDate, schools: uniqueSchools(schools) })) + .toSorted(Order.mapInput(Order.String, (date) => date.serviceDate)), + legacyTotals: legacyTotals.toSorted( + Order.combine( + Order.mapInput(bySchool, (total: LegacyWorkdayTotal) => total.school), + Order.mapInput(Order.Number, (total: LegacyWorkdayTotal) => total.workdays), + ), ), - ), - }; -}; + }; + }, +); /** The calculated count: the distinct service dates plus the accepted legacy totals. */ export const calculatedDaysServed = (evidence: DaysServedEvidence): number => @@ -216,7 +228,7 @@ export const decodeAssistantCursor = ( const [, lastName, firstName, personId] = yield* Schema.decodeEffect(CursorTuple)(text); return { lastName, firstName, personId }; - }).pipe(Effect.mapError(() => new CertificateInvalidCursor())); + }).pipe(Effect.mapError(() => CertificateInvalidCursor.make({}))); export interface AssistantPage { readonly items: ReadonlyArray; @@ -224,20 +236,23 @@ export interface AssistantPage { } /** Keeps one page of rows read one past the page and names the continuation of the next. */ -export const assistantPage = ( - rows: ReadonlyArray, - position: (row: A) => AssistantCursorPosition, -): AssistantPage => { - const last = rows[DAYS_SERVED_PAGE_SIZE - 1]; - - if (rows.length <= DAYS_SERVED_PAGE_SIZE || last === undefined) return { items: rows }; - - const { lastName, firstName, personId } = position(last); - - return { - items: rows.slice(0, DAYS_SERVED_PAGE_SIZE), - nextCursor: Encoding.encodeBase64( - encodeCursorTuple(["assistant-v1", lastName, firstName, personId]), - ), - }; -}; +export const assistantPage: { + (position: (row: A) => AssistantCursorPosition): (rows: ReadonlyArray) => AssistantPage; + (rows: ReadonlyArray, position: (row: A) => AssistantCursorPosition): AssistantPage; +} = dual( + 2, + (rows: ReadonlyArray, position: (row: A) => AssistantCursorPosition): AssistantPage => { + const last = rows[DAYS_SERVED_PAGE_SIZE - 1]; + + if (rows.length <= DAYS_SERVED_PAGE_SIZE || last === undefined) return { items: rows }; + + const { lastName, firstName, personId } = position(last); + + return { + items: rows.slice(0, DAYS_SERVED_PAGE_SIZE), + nextCursor: Encoding.encodeBase64( + encodeCursorTuple(["assistant-v1", lastName, firstName, personId]), + ), + }; + }, +); diff --git a/packages/domain/src/placements/policy.ts b/packages/domain/src/placements/policy.ts index 64e760900..996f3b5ca 100644 --- a/packages/domain/src/placements/policy.ts +++ b/packages/domain/src/placements/policy.ts @@ -1,4 +1,5 @@ import { Predicate, Data } from "effect"; +import { dual } from "effect/Function"; import { mapOrganizationAuthorityToDepartmentActor, type OrganizationPersonAuthority, @@ -57,10 +58,10 @@ export class SchoolServiceNotificationDeliveryError extends Data.TaggedError( }> {} /** A coordinator holds `placements.coordinate` in the department, or is an active global administrator. */ -export const canManagePlacements = ( - authority: OrganizationPersonAuthority, - departmentId: DepartmentId, -): boolean => { +export const canManagePlacements: { + (departmentId: DepartmentId): (authority: OrganizationPersonAuthority) => boolean; + (authority: OrganizationPersonAuthority, departmentId: DepartmentId): boolean; +} = dual(2, (authority: OrganizationPersonAuthority, departmentId: DepartmentId): boolean => { const decision = mapOrganizationAuthorityToDepartmentActor( authority, "placements.coordinate", @@ -68,23 +69,34 @@ export const canManagePlacements = ( ); return Predicate.isTagged(decision, "Allow") && !Predicate.isTagged(decision.value, "Member"); -}; +}); /** Returns the next status, or null for a rejected transition. Does not authorize or persist a change. */ -export const nextAffiliationStatus = ( - status: Affiliation["status"], - action: OwnAffiliationCommand["action"] | "Establish" | "Reject" | "Revoke", -): Affiliation["status"] | null => { - if (action === "Request" && (status === "Absent" || status === "Inactive")) return "Pending"; +export const nextAffiliationStatus: { + ( + action: OwnAffiliationCommand["action"] | "Establish" | "Reject" | "Revoke", + ): (status: Affiliation["status"]) => Affiliation["status"] | null; + ( + status: Affiliation["status"], + action: OwnAffiliationCommand["action"] | "Establish" | "Reject" | "Revoke", + ): Affiliation["status"] | null; +} = dual( + 2, + ( + status: Affiliation["status"], + action: OwnAffiliationCommand["action"] | "Establish" | "Reject" | "Revoke", + ): Affiliation["status"] | null => { + if (action === "Request" && (status === "Absent" || status === "Inactive")) return "Pending"; - if (action === "Establish" && status === "Pending") return "Active"; + if (action === "Establish" && status === "Pending") return "Active"; - if ((action === "Withdraw" || action === "Reject") && status === "Pending") return "Inactive"; + if ((action === "Withdraw" || action === "Reject") && status === "Pending") return "Inactive"; - if (action === "Revoke" && status === "Active") return "Inactive"; + if (action === "Revoke" && status === "Active") return "Inactive"; - return null; -}; + return null; + }, +); const compareText = (left: string, right: string): number => left === right ? 0 : left < right ? -1 : 1; @@ -210,36 +222,54 @@ const exactUniqueValues = ( ); }; -export const hasExactSchoolServiceExceptionReview = ( - proposal: SchoolServiceProposal, - reviewedExceptionIds: ReadonlyArray, -): boolean => - exactUniqueValues( - proposal.exceptions.map((exception) => exception.exceptionId), - reviewedExceptionIds, - ); +export const hasExactSchoolServiceExceptionReview: { + (reviewedExceptionIds: ReadonlyArray): (proposal: SchoolServiceProposal) => boolean; + (proposal: SchoolServiceProposal, reviewedExceptionIds: ReadonlyArray): boolean; +} = dual( + 2, + (proposal: SchoolServiceProposal, reviewedExceptionIds: ReadonlyArray): boolean => + exactUniqueValues( + proposal.exceptions.map((exception) => exception.exceptionId), + reviewedExceptionIds, + ), +); /** * Derives the actual attendance of one dated commitment: its confirmed roster minus absent * assistants plus the covering person of each current coverage record. Sorted and unique. */ -export const schoolServiceAttendance = ( - commitment: Pick, - absences: ReadonlyArray>, - coverage: ReadonlyArray>, -): ReadonlyArray => { - const own = absences.filter((absence) => absence.commitmentId === commitment.commitmentId); - const absent = new Set(own.map((absence) => absence.personId)); - const absenceIds = new Set(own.map((absence) => absence.absenceId)); - - return [ - ...new Set([ - ...commitment.assignments - .map((assignment) => assignment.personId) - .filter((personId) => !absent.has(personId)), - ...coverage.flatMap((record) => - absenceIds.has(record.absenceId) ? [record.coveringPersonId] : [], - ), - ]), - ].sort(compareText); -}; +export const schoolServiceAttendance: { + ( + absences: ReadonlyArray>, + coverage: ReadonlyArray>, + ): ( + commitment: Pick, + ) => ReadonlyArray; + ( + commitment: Pick, + absences: ReadonlyArray>, + coverage: ReadonlyArray>, + ): ReadonlyArray; +} = dual( + 3, + ( + commitment: Pick, + absences: ReadonlyArray>, + coverage: ReadonlyArray>, + ): ReadonlyArray => { + const own = absences.filter((absence) => absence.commitmentId === commitment.commitmentId); + const absent = new Set(own.map((absence) => absence.personId)); + const absenceIds = new Set(own.map((absence) => absence.absenceId)); + + return [ + ...new Set([ + ...commitment.assignments + .map((assignment) => assignment.personId) + .filter((personId) => !absent.has(personId)), + ...coverage.flatMap((record) => + absenceIds.has(record.absenceId) ? [record.coveringPersonId] : [], + ), + ]), + ].sort(compareText); + }, +); diff --git a/packages/domain/src/placements/scheduler.ts b/packages/domain/src/placements/scheduler.ts index 9d442fe63..508f6e70c 100644 --- a/packages/domain/src/placements/scheduler.ts +++ b/packages/domain/src/placements/scheduler.ts @@ -11,7 +11,8 @@ * assistants to schools. The injected `Random` is the only source of randomness, so the * same input and seed give the same draft. */ -import { Schema, type Random } from "effect"; +import { Schema, type Random, Predicate } from "effect"; +import { dual } from "effect/Function"; import { AssistantLanguageSchema, ReturningAssistantPreferencesSchema, @@ -624,47 +625,53 @@ const distribute = ( * of one weekday at one school. Duplicate slots add up; a repeated person counts once. * The result depends on the input content and `random`, not on input order. */ -export const draftPlacements = ( - input: PlacementDraftInput, - random: Random.Random, - search: DraftSearch = defaultDraftSearch, -): PlacementDraftPlan => { - const schools = schoolDays(input.slots); - const capacity = capacityOf(schools); - const everyone = candidatesOf(input.assistants); - const empty = emptySchedule(0); - - // A candidate with no weekday that could ever take it stays out of the search. - const candidates = everyone.filter((candidate) => - candidate.days.some((day) => firstFit(capacity, empty, candidate, day) !== none), - ); - - const bound = upperBound(capacity, candidates); - let best: Schedule | undefined; +export const draftPlacements: { + (random: Random.Random, search?: DraftSearch): (input: PlacementDraftInput) => PlacementDraftPlan; + (input: PlacementDraftInput, random: Random.Random, search?: DraftSearch): PlacementDraftPlan; +} = dual( + (args) => Predicate.hasProperty(args[0], "slots"), + ( + input: PlacementDraftInput, + random: Random.Random, + search: DraftSearch = defaultDraftSearch, + ): PlacementDraftPlan => { + const schools = schoolDays(input.slots); + const capacity = capacityOf(schools); + const everyone = candidatesOf(input.assistants); + const empty = emptySchedule(0); + + // A candidate with no weekday that could ever take it stays out of the search. + const candidates = everyone.filter((candidate) => + candidate.days.some((day) => firstFit(capacity, empty, candidate, day) !== none), + ); - for (let run = 0; run < Math.max(1, search.restarts); run += 1) { - const start = emptySchedule(candidates.length); - fill(capacity, candidates, start, run === 0 ? canonicalOrder : shuffledOrder(random)); - const result = anneal(capacity, candidates, bound, start, random, search); + const bound = upperBound(capacity, candidates); + let best: Schedule | undefined; - if (best === undefined || result.filled > best.filled) best = result; + for (let run = 0; run < Math.max(1, search.restarts); run += 1) { + const start = emptySchedule(candidates.length); + fill(capacity, candidates, start, run === 0 ? canonicalOrder : shuffledOrder(random)); + const result = anneal(capacity, candidates, bound, start, random, search); - if (best.filled >= bound) break; - } + if (best === undefined || result.filled > best.filled) best = result; - const schedule = best ?? emptySchedule(candidates.length); - const assignments = distribute(schools, candidates, schedule); - const assigned = new Set(assignments.map((assignment) => assignment.personId)); + if (best.filled >= bound) break; + } - return { - assignments, - unplaced: everyone.flatMap((candidate) => - assigned.has(candidate.personId) ? [] : [candidate.personId], - ), - openPlaces: capacity.total, - filledPlaces: schedule.filled, - }; -}; + const schedule = best ?? emptySchedule(candidates.length); + const assignments = distribute(schools, candidates, schedule); + const assigned = new Set(assignments.map((assignment) => assignment.personId)); + + return { + assignments, + unplaced: everyone.flatMap((candidate) => + assigned.has(candidate.personId) ? [] : [candidate.personId], + ), + openPlaces: capacity.total, + filledPlaces: schedule.filled, + }; + }, +); export interface PlacementDraftSource { readonly board: Pick< @@ -689,25 +696,38 @@ const blockByGroup: Record = { * unavailable, and, as in the legacy scheduler, both blocks for an eight-week position, * whatever block a four-week position would take. */ -export const placementSupplyOf = ( - personId: PersonId, - availability: AssistantAvailability, - preferredSchool: SchoolWishes["preferredSchool"], -): PlacementSupply => ({ - personId, - days: days.filter( - (day) => - !{ - Monday: availability.mondayUnavailable, - Tuesday: availability.tuesdayUnavailable, - Wednesday: availability.wednesdayUnavailable, - Thursday: availability.thursdayUnavailable, - Friday: availability.fridayUnavailable, - }[day], - ), - block: availability.positionWeeks === 8 ? "Both" : blockByGroup[availability.preferredGroup], - wishes: { language: availability.language, preferredSchool }, -}); +export const placementSupplyOf: { + ( + availability: AssistantAvailability, + preferredSchool: SchoolWishes["preferredSchool"], + ): (personId: PersonId) => PlacementSupply; + ( + personId: PersonId, + availability: AssistantAvailability, + preferredSchool: SchoolWishes["preferredSchool"], + ): PlacementSupply; +} = dual( + 3, + ( + personId: PersonId, + availability: AssistantAvailability, + preferredSchool: SchoolWishes["preferredSchool"], + ): PlacementSupply => ({ + personId, + days: days.filter( + (day) => + !{ + Monday: availability.mondayUnavailable, + Tuesday: availability.tuesdayUnavailable, + Wednesday: availability.wednesdayUnavailable, + Thursday: availability.thursdayUnavailable, + Friday: availability.fridayUnavailable, + }[day], + ), + block: availability.positionWeeks === 8 ? "Both" : blockByGroup[availability.preferredGroup], + wishes: { language: availability.language, preferredSchool }, + }), +); const workdaysPerBlock = 4; @@ -727,114 +747,125 @@ const halves = (block: DraftAssignment["block"]): ReadonlyArray { - const { board } = source; - const occupied = new Map(); - const placed = new Set(); - - for (const placement of board.placements) { - if (!placement.active) continue; - placed.add(placement.personId); - - for (const block of halves(placement.block)) { - const key = slotKey(placement.schoolId, placement.day, block); - occupied.set(key, (occupied.get(key) ?? 0) + 1); +export const buildPlacementDraft: { + (random: Random.Random, search?: DraftSearch): (source: PlacementDraftSource) => PlacementDraft; + (source: PlacementDraftSource, random: Random.Random, search?: DraftSearch): PlacementDraft; +} = dual( + (args) => Predicate.hasProperty(args[0], "board"), + ( + source: PlacementDraftSource, + random: Random.Random, + search: DraftSearch = defaultDraftSearch, + ): PlacementDraft => { + const { board } = source; + const occupied = new Map(); + const placed = new Set(); + + for (const placement of board.placements) { + if (!placement.active) continue; + placed.add(placement.personId); + + for (const block of halves(placement.block)) { + const key = slotKey(placement.schoolId, placement.day, block); + occupied.set(key, (occupied.get(key) ?? 0) + 1); + } } - } - const bounds = new Map( - source.capacities.map((capacity) => [`${capacity.schoolId}:${capacity.day}`, capacity.places]), - ); + const bounds = new Map( + source.capacities.map((capacity) => [ + `${capacity.schoolId}:${capacity.day}`, + capacity.places, + ]), + ); - const schoolNames = new Map(board.schools.map((school) => [school.schoolId, school.name])); + const schoolNames = new Map(board.schools.map((school) => [school.schoolId, school.name])); - const slots = board.demands.flatMap((demand): ReadonlyArray => { - if (!schoolNames.has(demand.schoolId)) return []; + const slots = board.demands.flatMap((demand): ReadonlyArray => { + if (!schoolNames.has(demand.schoolId)) return []; - const bound = Math.min( - demand.requiredVolunteers, - bounds.get(`${demand.schoolId}:${demand.day}`) ?? demand.requiredVolunteers, - ); + const bound = Math.min( + demand.requiredVolunteers, + bounds.get(`${demand.schoolId}:${demand.day}`) ?? demand.requiredVolunteers, + ); - const places = bound - (occupied.get(slotKey(demand.schoolId, demand.day, demand.block)) ?? 0); + const places = + bound - (occupied.get(slotKey(demand.schoolId, demand.day, demand.block)) ?? 0); - return places > 0 - ? [{ schoolId: demand.schoolId, day: demand.day, block: demand.block, places }] - : []; - }); + return places > 0 + ? [{ schoolId: demand.schoolId, day: demand.day, block: demand.block, places }] + : []; + }); - const people = board.affiliations.filter( - (affiliation) => affiliation.status === "Active" && !placed.has(affiliation.personId), - ); + const people = board.affiliations.filter( + (affiliation) => affiliation.status === "Active" && !placed.has(affiliation.personId), + ); - const supply = new Map(source.availability.map((entry) => [entry.personId, entry])); + const supply = new Map(source.availability.map((entry) => [entry.personId, entry])); - const assistants = people.flatMap((person) => { - const entry = supply.get(person.personId); + const assistants = people.flatMap((person) => { + const entry = supply.get(person.personId); - return entry === undefined || entry.days.length === 0 ? [] : [entry]; - }); + return entry === undefined || entry.days.length === 0 ? [] : [entry]; + }); - const plan = draftPlacements({ slots, assistants }, random, search); - const byPerson = new Map(people.map((person) => [person.personId, person])); - const withoutPlace = new Set(plan.unplaced); - const drafted = new Set(plan.assignments.map((assignment) => assignment.personId)); - const filled = new Map(); + const plan = draftPlacements({ slots, assistants }, random, search); + const byPerson = new Map(people.map((person) => [person.personId, person])); + const withoutPlace = new Set(plan.unplaced); + const drafted = new Set(plan.assignments.map((assignment) => assignment.personId)); + const filled = new Map(); - for (const assignment of plan.assignments) { - for (const block of halves(assignment.block)) { - const key = slotKey(assignment.schoolId, assignment.day, block); - filled.set(key, (filled.get(key) ?? 0) + 1); + for (const assignment of plan.assignments) { + for (const block of halves(assignment.block)) { + const key = slotKey(assignment.schoolId, assignment.day, block); + filled.set(key, (filled.get(key) ?? 0) + 1); + } } - } - return { - departmentId: board.departmentId, - semesterId: board.semesterId, - placements: plan.assignments.flatMap((assignment) => { - const entry = supply.get(assignment.personId); - - // The scheduler places only the assistants that the supply lists. - if (entry === undefined) return []; - - return [ - { - ...assignment, - firstName: byPerson.get(assignment.personId)?.firstName ?? "", - lastName: byPerson.get(assignment.personId)?.lastName ?? "", - schoolName: schoolNames.get(assignment.schoolId) ?? "", - workdays: halves(assignment.block).length * workdaysPerBlock, - wishes: entry.wishes, - }, - ]; - }), - unplaced: people.flatMap((person) => - drafted.has(person.personId) - ? [] - : [ - { - personId: person.personId, - firstName: person.firstName, - lastName: person.lastName, - reason: withoutPlace.has(person.personId) - ? ("NoOpenPlace" as const) - : ("NoAvailability" as const), - wishes: supply.get(person.personId)?.wishes ?? null, - }, - ], - ), - openSlots: slots.flatMap((slot) => { - const places = slot.places - (filled.get(slotKey(slot.schoolId, slot.day, slot.block)) ?? 0); - - return places > 0 - ? [{ ...slot, schoolName: schoolNames.get(slot.schoolId) ?? "", places }] - : []; - }), - openPlaces: plan.openPlaces, - filledPlaces: plan.filledPlaces, - }; -}; + return { + departmentId: board.departmentId, + semesterId: board.semesterId, + placements: plan.assignments.flatMap((assignment) => { + const entry = supply.get(assignment.personId); + + // The scheduler places only the assistants that the supply lists. + if (entry === undefined) return []; + + return [ + { + ...assignment, + firstName: byPerson.get(assignment.personId)?.firstName ?? "", + lastName: byPerson.get(assignment.personId)?.lastName ?? "", + schoolName: schoolNames.get(assignment.schoolId) ?? "", + workdays: halves(assignment.block).length * workdaysPerBlock, + wishes: entry.wishes, + }, + ]; + }), + unplaced: people.flatMap((person) => + drafted.has(person.personId) + ? [] + : [ + { + personId: person.personId, + firstName: person.firstName, + lastName: person.lastName, + reason: withoutPlace.has(person.personId) + ? ("NoOpenPlace" as const) + : ("NoAvailability" as const), + wishes: supply.get(person.personId)?.wishes ?? null, + }, + ], + ), + openSlots: slots.flatMap((slot) => { + const places = + slot.places - (filled.get(slotKey(slot.schoolId, slot.day, slot.block)) ?? 0); + + return places > 0 + ? [{ ...slot, schoolName: schoolNames.get(slot.schoolId) ?? "", places }] + : []; + }), + openPlaces: plan.openPlaces, + filledPlaces: plan.filledPlaces, + }; + }, +); diff --git a/packages/domain/src/placements/service.ts b/packages/domain/src/placements/service.ts index d0d13eca9..b9a519c7c 100644 --- a/packages/domain/src/placements/service.ts +++ b/packages/domain/src/placements/service.ts @@ -1,13 +1,15 @@ import { Context, Data, type Effect } from "effect"; import type { DepartmentId, OrganizationPersonAuthority, PersonId } from "../organization/index.js"; +import type { DepartmentReach } from "../authz/reach.js"; import type { - CertificateCommandTarget, CertificateAssistant, + CertificateIssueAuthorization, CertificateIssue, CertificatePreview, CertificatePrincipal, CertificateScopes, CertificateSemesterScope, + DaysServedConfirmationAuthorization, IssueCertificateCommand, } from "./certificate.js"; import type { @@ -61,12 +63,26 @@ export type PlacementMutation = export type PlacementSnapshot = Affiliation | PlacementBoard | OwnCoverageView | CoverageBoard; -export interface PlacementExecution { - readonly mutation: PlacementMutation; - readonly actor: PersonId; - readonly now: string; - readonly commandId: string; -} +/** + * One placement mutation. A person changes their own affiliation or coverage as themselves; a + * board or coverage-board change needs a coordinator's reach over the mutation's department. + */ +export type PlacementExecution = + | { + readonly mutation: Extract< + PlacementMutation, + { readonly mode: "affiliation" | "ownCoverage" } + >; + readonly actor: PersonId; + readonly now: string; + readonly commandId: string; + } + | { + readonly mutation: Extract; + readonly coordinator: DepartmentReach<"placements.coordinate">; + readonly now: string; + readonly commandId: string; + }; /** One page of the assistants of a department and semester, with the scope's labels. */ export interface DaysServedPage extends AssistantPage { @@ -153,21 +169,22 @@ export interface PlacementsOperations { cursor?: string, ) => Effect.Effect; /** - * Resolves the principal's current authority for one days-served or certificate command on the - * caller's transaction, with the command's locks, before a stored response can replay. + * Resolves the principal's current `placements.days-served` authority in one department and + * semester on the caller's transaction, under the department lock, before a stored response can + * replay. */ - readonly authorizeCertificateCommand: ( + readonly authorizeDaysServedConfirmation: ( principal: CertificatePrincipal, - target: CertificateCommandTarget, - ) => Effect.Effect; + scope: PlacementScope, + ) => Effect.Effect; /** - * Appends the next confirmation of one assistant's total under the department lock, after the + * Appends the next confirmation of one assistant's total in the authorized scope, after the * transport precondition on the fresh entry. Never changes an earlier confirmation or a service * fact. The callback grants no authority and must not write business state. */ readonly confirmDaysServed: ( - principal: CertificatePrincipal, - command: ConfirmDaysServedCommand, + authorization: DaysServedConfirmationAuthorization, + command: Omit, checkPrecondition: (current: DaysServedEntry) => Effect.Effect, ) => Effect.Effect; /** One bounded page of the assistants with service facts in a department; issuers only. */ @@ -183,17 +200,27 @@ export interface PlacementsOperations { personId: PersonId, ) => Effect.Effect; /** - * Records one issue of the current certificate under the department lock, after the transport - * precondition on the fresh preview: who, when, the authorizing seat, and the content hash. + * Resolves whether the principal currently issues the department's certificates for another + * person, on the caller's transaction and under the department lock, before a stored response + * can replay. */ - readonly issueCertificate: ( + readonly authorizeCertificateIssue: ( principal: CertificatePrincipal, - command: IssueCertificateCommand, + departmentId: DepartmentId, + personId: PersonId, + ) => Effect.Effect; + /** + * Records one issue of the authorized certificate, after the transport precondition on the + * fresh preview: who, when, the authorizing seat, and the content hash. + */ + readonly issueCertificate: ( + authorization: CertificateIssueAuthorization, + command: Pick, checkPrecondition: (current: CertificatePreview) => Effect.Effect, ) => Effect.Effect; } /** The portable service key. The server entry point supplies its database-backed Layer. */ export class Placements extends Context.Service()( - "@vektorprogrammet/domain/Placements", + "@vektorprogrammet/domain/placements/service/Placements", ) {} diff --git a/packages/domain/src/profile/directory-cursor.ts b/packages/domain/src/profile/directory-cursor.ts index f82330609..a3e17295c 100644 --- a/packages/domain/src/profile/directory-cursor.ts +++ b/packages/domain/src/profile/directory-cursor.ts @@ -45,7 +45,7 @@ export const decodeDirectoryCursor = ( return { lastName, firstName, personId }; }).pipe( - Effect.mapError( - () => new ProfileDecodeError({ message: "malformed Profile directory cursor" }), + Effect.mapError(() => + ProfileDecodeError.make({ message: "malformed Profile directory cursor" }), ), ); diff --git a/packages/domain/src/profile/service.ts b/packages/domain/src/profile/service.ts index 677a66ffd..9893b474a 100644 --- a/packages/domain/src/profile/service.ts +++ b/packages/domain/src/profile/service.ts @@ -67,5 +67,5 @@ export interface DirectoryPage { } export class Profile extends Context.Service()( - "@vektorprogrammet/domain/Profile", + "@vektorprogrammet/domain/profile/service/Profile", ) {} diff --git a/packages/domain/src/receipt/README.md b/packages/domain/src/receipt/README.md index de1e0b1a2..e5b5b194f 100644 --- a/packages/domain/src/receipt/README.md +++ b/packages/domain/src/receipt/README.md @@ -35,9 +35,9 @@ Consumers must not import private PostgreSQL modules or write receipt tables dir The [service declaration](service.ts) owns operation signatures, Effect requirements, and transaction-specific witnesses. The [schemas](schema.ts) own amounts, dates, identities, revisions, file metadata, and settlement evidence. -The [HTTP contract](../../../http-api/src/receipts.ts) owns transport requests and responses. -Browser consumers use `@vektorprogrammet/sdk` or `@vektorprogrammet/sdk/effect`, not the database Layer. -The [SDK export map](../../../sdk/package.json) owns these generated consumer entry points. +The [RPC contract](../../../rpc/src/receipts.ts) owns transport requests and responses. +Browser consumers call these RPCs through the dashboard's RPC clients, `callNative` and `callBrowserNative`, not the database Layer. +The [RPC export map](../../../rpc/package.json) owns the client entry points; none is generated. ### Run the executable example @@ -109,7 +109,7 @@ Do not expose storage identities, payment ciphertext, provider secrets, or persi | Existing settlement or duplicate external reference | Reconcile the existing evidence. Do not create a second record. | | Persistence or file failure | Preserve the cause privately and inspect the owned recovery boundary. | -The [failure unions](errors.ts), [file failures](file-errors.ts), and [HTTP mapping](../../../../apps/backend/src/receipt/http-problem.ts) own exact tags and statuses. +The [failure unions](errors.ts), [file failures](file-errors.ts), and [RPC problem mapping](../../../../apps/backend/src/receipt/problem.ts) own exact tags and statuses. Settlement also rejects a settlement time after its recorded time. Its immutable evidence binds the approved amount, destination fingerprint, external reference, actor, and revision. The [settlement implementation](../../../database/src/receipt/settlement.ts) owns those checks and uniqueness rules. @@ -154,8 +154,8 @@ post-commit delivery fenced claim -> private-file or notification interpreter -> completion ``` -The [HTTP transaction owner](../../../../apps/backend/src/http-api/receipt-transaction.ts) retains response receipts and preconditions. -The [receipt HTTP commands](../../../../apps/backend/src/receipt/http-commands.ts) supply receipt-specific authority and execution. +The [command receipt owner](../../../../apps/backend/src/rpc/receipt-transaction.ts) retains response receipts and preconditions. +The [receipt RPC commands](../../../../apps/backend/src/receipt/commands.ts) supply receipt-specific authority and execution. The [database command implementation](../../../database/src/receipt/postgres.ts) preserves state, revision, command receipt, audit, and outbox in one transaction. The [decision function](update.ts) owns legal lifecycle transitions. HTTP responses do not replace domain command receipts. @@ -213,15 +213,15 @@ The [receipt recovery rules](../../../../docs/delivery-recovery.md#receipt-work) The following sources own the limits. This guide does not duplicate their numeric values. -| Boundary | Canonical source and behavior | -| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| File intake | [`RECEIPT_FILE_MAX_BYTES`](schema.ts) owns the maximum file size; [receipt configuration](../../../../apps/backend/src/receipt/config.ts) defaults to it. The [bounded multipart reader](../../../http-api/src/receipt-upload.ts) limits actual transfer bytes before parsing, and the dashboard's owner form answers a larger file with 413 and commits nothing. | -| Private files | The [filesystem adapter](../../../../apps/backend/src/receipt/filesystem.ts) bounds staging and committed reads. Native downloads materialize bounded bytes and check their digest. | -| Collections | [Pagination declarations](pagination.ts) own the page size and cursor types. The [collection methods](service.ts) return bounded pages. | -| Delivery ownership | The [outbox adapter](../../../database/src/receipt/outbox.ts) bounds concurrent interpreters within the runtime and fences each durable claim. | -| Provider calls | The [delivery parser](../../../../apps/backend/src/receipt/delivery.ts) bounds deadlines. The [HTTP transport](../../../../apps/backend/src/delivery/http.ts) propagates cancellation without an internal retry loop. | -| Recovery | The [worker](../../../../apps/backend/src/receipt/worker.ts) owns stale-claim recovery and retry cadence. The [request drain](../../../../apps/backend/src/receipt/outbox-drain.ts) bounds work per invocation. | -| Processes | The [native root](../../../../apps/backend/src/main.ts) owns the worker lifetime. The [shared pool](../../../database/src/pg-pool.ts) owns database acquisition and release. | +| Boundary | Canonical source and behavior | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| File intake | [`RECEIPT_FILE_MAX_BYTES`](schema.ts) owns the maximum file size; [receipt configuration](../../../../apps/backend/src/receipt/config.ts) defaults to it. The dashboard's [bounded multipart reader](../../../../apps/dashboard/app/lib/receipt-upload.server.ts) limits the transfer bytes of the owner's form before parsing and answers a larger file with 413, committing nothing; the backend checks the byte bound and media type of the file that the RPC payload carries. | +| Private files | The [filesystem adapter](../../../../apps/backend/src/receipt/filesystem.ts) bounds staging and committed reads. Native downloads materialize bounded bytes and check their digest. | +| Collections | [Pagination declarations](pagination.ts) own the page size and cursor types. The [collection methods](service.ts) return bounded pages. | +| Delivery ownership | The [outbox adapter](../../../database/src/receipt/outbox.ts) bounds concurrent interpreters within the runtime and fences each durable claim. | +| Provider calls | The [delivery parser](../../../../apps/backend/src/receipt/delivery.ts) bounds deadlines. The [HTTP transport](../../../../apps/backend/src/delivery/http.ts) propagates cancellation without an internal retry loop. | +| Recovery | The [worker](../../../../apps/backend/src/receipt/worker.ts) owns stale-claim recovery and retry cadence. The [request drain](../../../../apps/backend/src/receipt/outbox-drain.ts) bounds work per invocation. | +| Processes | The [native root](../../../../apps/backend/src/main.ts) owns the worker lifetime. The [shared pool](../../../database/src/pg-pool.ts) owns database acquisition and release. | The shared delivery permit applies within one JavaScript process, before a durable claim. Database claim fences still apply across processes. @@ -244,19 +244,19 @@ These queries bound candidate-row materialization and result size. They do not g Current authority, rules, and grant projections remain complete. Their memory use scales with configured authority records. The [OAuth list boundary](../authz/service-principal-grants.ts) retains all applicable exact-grant evidence, not only the first grant. -To continue the collection, pass the returned cursor through the same generated operation until `nextCursor` is absent. +To continue the collection, pass the returned cursor through the same RPC until `nextCursor` is absent. Keep only the current result page in the dashboard Model. Do not replace pagination with a hidden result cap or a misleading total count. File-effect markers and durable failed work retain history on storage. A bound on transient memory does not limit retained business history. -Bounded native transfer is not allocation-free JavaScript. Multipart parsing, file reads, hashing, and SDK downloads still allocate or materialize data. +Bounded native transfer is not allocation-free JavaScript. Multipart parsing, file reads, hashing, and RPC downloads (base64 in JSON) still allocate or materialize data. No claim here bounds third-party internals, provider memory, or the R2 runtime. The real journey must record process and memory observations during operation and after cleanup. ## Change and check it -For a lifecycle change, update the schema, decision, transaction, projections, HTTP contract, SDK, and dashboard callers together. +For a lifecycle change, update the schema, decision, transaction, projections, RPC contract and handlers, and dashboard callers together. For a settlement change, preserve separate authority and immutable external evidence. For a storage change, preserve digest checks, private access, ordered promotion, and replay after restart. For a delivery change, preserve claim fences, predecessor order, immutable envelopes, and provider idempotency. @@ -264,16 +264,15 @@ For a delivery change, preserve claim fences, predecessor order, immutable envel Keep numeric limits in their canonical declarations rather than this guide. Do not use a schema change, a recording adapter, or a successful compile as proof of database or provider behavior. -Run the focused decision and HTTP transaction checks from the repository root: +Run the focused decision and RPC transaction checks from the repository root: ```bash bun run --cwd packages/domain vitest run src/receipt/update.test.ts src/receipt/update.property.test.ts --no-file-parallelism --maxWorkers=1 -bun run --cwd apps/backend vitest run src/receipt/http.test.ts src/http-api/receipt-transaction.test.ts --no-file-parallelism --maxWorkers=1 -bun run --cwd packages/http-api generate +bun run --cwd apps/backend vitest run src/receipt/rpc.test.ts src/rpc/receipt-transaction.test.ts --no-file-parallelism --maxWorkers=1 ``` The decision checks defend legal transitions and invariants. They do not resolve live authority. -The HTTP checks cover the adapter contract with their declared test dependencies, not a continuous browser journey. +The RPC checks cover the adapter contract with their declared test dependencies, not a continuous browser journey. The generation step derives the transport artifacts and checks the contract release invariants. It does not exercise delivery. The [functional testing guide](../../../../docs/web-system-functional-testing.md) owns local journey commands and their evidence requirements. diff --git a/packages/domain/src/receipt/approval-list.ts b/packages/domain/src/receipt/approval-list.ts index a2d8c9048..36ce9a662 100644 --- a/packages/domain/src/receipt/approval-list.ts +++ b/packages/domain/src/receipt/approval-list.ts @@ -1,4 +1,5 @@ import { Predicate } from "effect"; +import { dual } from "effect/Function"; import { PrincipalSchema, AuthorityVersion, @@ -64,29 +65,44 @@ const receiptAuthorityVersion = ( ].join("|"), ); -export const makeReceiptApprovalContext = ( - receipt: ReceiptApprovalCandidate, - organization: OrganizationPersonAuthority, - directAuthority: ReceiptAuthority, - rules: ReadonlyArray, -): CanonicalResourceContext => ({ - domainId: RECEIPT_DOMAIN_ID, - departmentId: receipt.departmentId, - resource: { - kind: RECEIPT_RESOURCE_KIND, - id: ResourceId.make(receipt.receiptId), - }, - facts: { - ownerPersonId: receipt.ownerPersonId, - state: receipt.status, - approverPersonIds: isCanonicalApproverRelationship(organization, receipt.departmentId) - ? [directAuthority.personId] - : [], - approverServicePrincipalIds: [], - internalEvidenceEnabled: false, - }, - authorityVersion: receiptAuthorityVersion(receipt, organization, directAuthority, rules), -}); +export const makeReceiptApprovalContext: { + ( + organization: OrganizationPersonAuthority, + directAuthority: ReceiptAuthority, + rules: ReadonlyArray, + ): (receipt: ReceiptApprovalCandidate) => CanonicalResourceContext; + ( + receipt: ReceiptApprovalCandidate, + organization: OrganizationPersonAuthority, + directAuthority: ReceiptAuthority, + rules: ReadonlyArray, + ): CanonicalResourceContext; +} = dual( + 4, + ( + receipt: ReceiptApprovalCandidate, + organization: OrganizationPersonAuthority, + directAuthority: ReceiptAuthority, + rules: ReadonlyArray, + ): CanonicalResourceContext => ({ + domainId: RECEIPT_DOMAIN_ID, + departmentId: receipt.departmentId, + resource: { + kind: RECEIPT_RESOURCE_KIND, + id: ResourceId.make(receipt.receiptId), + }, + facts: { + ownerPersonId: receipt.ownerPersonId, + state: receipt.status, + approverPersonIds: isCanonicalApproverRelationship(organization, receipt.departmentId) + ? [directAuthority.personId] + : [], + approverServicePrincipalIds: [], + internalEvidenceEnabled: false, + }, + authorityVersion: receiptAuthorityVersion(receipt, organization, directAuthority, rules), + }), +); /** * Evaluates every collection row against its canonical receipt context. The @@ -99,87 +115,132 @@ export interface ReceiptApprovalSelectionEvidence { readonly inactiveGrantSeen: boolean; } -export const evaluateReceiptApprovalCandidates = ( - organization: OrganizationPersonAuthority, - directAuthority: ReceiptAuthority, - candidates: ReadonlyArray, - rules: ReadonlyArray, - tagAssignments: ReadonlyArray, -): ReceiptApprovalSelectionEvidence => { - const directEvidence = { approvalGrants: directAuthority.approvalGrants }; - const receiptIds: string[] = []; - let denialReason: DecisionReason | undefined; - let inactiveGrantSeen = directAuthority.approvalGrants.length > 0; - - for (const receipt of candidates) { - const context = makeReceiptApprovalContext(receipt, organization, directAuthority, rules); - - const composition = composeCapabilityEvidence("approveReceipt", directEvidence, rules, { - principal: PrincipalSchema.cases.Person.make({ personId: directAuthority.personId }), - authorizationInstant: directAuthority.evaluatedAt, - context, - tagAssignments, - }); +export const evaluateReceiptApprovalCandidates: { + ( + directAuthority: ReceiptAuthority, + candidates: ReadonlyArray, + rules: ReadonlyArray, + tagAssignments: ReadonlyArray, + ): (organization: OrganizationPersonAuthority) => ReceiptApprovalSelectionEvidence; + ( + organization: OrganizationPersonAuthority, + directAuthority: ReceiptAuthority, + candidates: ReadonlyArray, + rules: ReadonlyArray, + tagAssignments: ReadonlyArray, + ): ReceiptApprovalSelectionEvidence; +} = dual( + 5, + ( + organization: OrganizationPersonAuthority, + directAuthority: ReceiptAuthority, + candidates: ReadonlyArray, + rules: ReadonlyArray, + tagAssignments: ReadonlyArray, + ): ReceiptApprovalSelectionEvidence => { + const directEvidence = { approvalGrants: directAuthority.approvalGrants }; + const receiptIds: string[] = []; + let denialReason: DecisionReason | undefined; + let inactiveGrantSeen = directAuthority.approvalGrants.length > 0; - if (Predicate.isTagged(composition.decision, "Deny")) { - denialReason ??= composition.decision.reason; - continue; - } + for (const receipt of candidates) { + const context = makeReceiptApprovalContext(receipt, organization, directAuthority, rules); - const authority = projectReceiptAuthority( - organization, - [], - composition.decision.value.approvalGrants ?? [], - ); + const composition = composeCapabilityEvidence("approveReceipt", directEvidence, rules, { + principal: PrincipalSchema.cases.Person.make({ personId: directAuthority.personId }), + authorizationInstant: directAuthority.evaluatedAt, + context, + tagAssignments, + }); + + if (Predicate.isTagged(composition.decision, "Deny")) { + denialReason ??= composition.decision.reason; + continue; + } - const selected = selectReceiptApprovalGrant(authority, receipt.departmentId); + const authority = projectReceiptAuthority( + organization, + [], + composition.decision.value.approvalGrants ?? [], + ); - if (selected?.active === true) { - receiptIds.push(receipt.receiptId); - } else { - inactiveGrantSeen ||= selected !== undefined; + const selected = selectReceiptApprovalGrant(authority, receipt.departmentId); + + if (selected?.active === true) { + receiptIds.push(receipt.receiptId); + } else { + inactiveGrantSeen ||= selected !== undefined; + } } - } - return { receiptIds, candidateSeen: candidates.length > 0, denialReason, inactiveGrantSeen }; -}; + return { receiptIds, candidateSeen: candidates.length > 0, denialReason, inactiveGrantSeen }; + }, +); -export const receiptApprovalSelectionDecision = ( - directAuthority: ReceiptAuthority, - evidence: ReceiptApprovalSelectionEvidence, -): Decision => { - if (directAuthority.organizationAuthority !== "Active") return deny("AuthorityInactive"); +export const receiptApprovalSelectionDecision: { + ( + evidence: ReceiptApprovalSelectionEvidence, + ): (directAuthority: ReceiptAuthority) => Decision; + ( + directAuthority: ReceiptAuthority, + evidence: ReceiptApprovalSelectionEvidence, + ): Decision; +} = dual( + 2, + ( + directAuthority: ReceiptAuthority, + evidence: ReceiptApprovalSelectionEvidence, + ): Decision => { + if (directAuthority.organizationAuthority !== "Active") return deny("AuthorityInactive"); - if (!evidence.candidateSeen) { - return directAuthority.approvalGrants.some(({ active }) => active) - ? allow({ receiptIds: [] }) - : deny(directAuthority.approvalGrants.length > 0 ? "AuthorityInactive" : "NotInScope"); - } + if (!evidence.candidateSeen) { + return directAuthority.approvalGrants.some(({ active }) => active) + ? allow({ receiptIds: [] }) + : deny(directAuthority.approvalGrants.length > 0 ? "AuthorityInactive" : "NotInScope"); + } - if (evidence.receiptIds.length > 0) return allow({ receiptIds: evidence.receiptIds }); + if (evidence.receiptIds.length > 0) return allow({ receiptIds: evidence.receiptIds }); - if (evidence.denialReason !== undefined) return deny(evidence.denialReason); + if (evidence.denialReason !== undefined) return deny(evidence.denialReason); - return deny(evidence.inactiveGrantSeen ? "AuthorityInactive" : "NotInScope"); -}; + return deny(evidence.inactiveGrantSeen ? "AuthorityInactive" : "NotInScope"); + }, +); -export const selectAuthorizedReceiptApprovals = ( - organization: OrganizationPersonAuthority, - directAuthority: ReceiptAuthority, - candidates: ReadonlyArray, - rules: ReadonlyArray, - tagAssignments: ReadonlyArray, -): Decision => - receiptApprovalSelectionDecision( - directAuthority, - evaluateReceiptApprovalCandidates( - organization, +export const selectAuthorizedReceiptApprovals: { + ( + directAuthority: ReceiptAuthority, + candidates: ReadonlyArray, + rules: ReadonlyArray, + tagAssignments: ReadonlyArray, + ): (organization: OrganizationPersonAuthority) => Decision; + ( + organization: OrganizationPersonAuthority, + directAuthority: ReceiptAuthority, + candidates: ReadonlyArray, + rules: ReadonlyArray, + tagAssignments: ReadonlyArray, + ): Decision; +} = dual( + 5, + ( + organization: OrganizationPersonAuthority, + directAuthority: ReceiptAuthority, + candidates: ReadonlyArray, + rules: ReadonlyArray, + tagAssignments: ReadonlyArray, + ): Decision => + receiptApprovalSelectionDecision( directAuthority, - candidates, - rules, - tagAssignments, + evaluateReceiptApprovalCandidates( + organization, + directAuthority, + candidates, + rules, + tagAssignments, + ), ), - ); +); const isPendingReceiptRequirement = (rule: AuthzRule): boolean => rule.effectKind === "requirement" && rule.params.requirementId === "receipts.pending"; @@ -188,17 +249,34 @@ const isPendingReceiptRequirement = (rule: AuthzRule): boolean => * Applies the queue's rule-aware approver relationship to one canonical * receipt while deliberately excluding the decision-only pending requirement. */ -export const selectAuthorizedReceiptFileForApproval = ( - organization: OrganizationPersonAuthority, - directAuthority: ReceiptAuthority, - candidate: ReceiptApprovalCandidate, - rules: ReadonlyArray, - tagAssignments: ReadonlyArray, -): Decision => - selectAuthorizedReceiptApprovals( - organization, - directAuthority, - [candidate], - rules.filter((rule) => !isPendingReceiptRequirement(rule)), - tagAssignments, - ); +export const selectAuthorizedReceiptFileForApproval: { + ( + directAuthority: ReceiptAuthority, + candidate: ReceiptApprovalCandidate, + rules: ReadonlyArray, + tagAssignments: ReadonlyArray, + ): (organization: OrganizationPersonAuthority) => Decision; + ( + organization: OrganizationPersonAuthority, + directAuthority: ReceiptAuthority, + candidate: ReceiptApprovalCandidate, + rules: ReadonlyArray, + tagAssignments: ReadonlyArray, + ): Decision; +} = dual( + 5, + ( + organization: OrganizationPersonAuthority, + directAuthority: ReceiptAuthority, + candidate: ReceiptApprovalCandidate, + rules: ReadonlyArray, + tagAssignments: ReadonlyArray, + ): Decision => + selectAuthorizedReceiptApprovals( + organization, + directAuthority, + [candidate], + rules.filter((rule) => !isPendingReceiptRequirement(rule)), + tagAssignments, + ), +); diff --git a/packages/domain/src/receipt/authority.ts b/packages/domain/src/receipt/authority.ts index a4a2779a1..c9cde0924 100644 --- a/packages/domain/src/receipt/authority.ts +++ b/packages/domain/src/receipt/authority.ts @@ -1,7 +1,8 @@ import { Scope } from "../authz/access.js"; import { DelegationArea, type Delegation } from "../authz/delegation.js"; import { delegationsReaching } from "../authz/reach.js"; -import { Effect, Schema } from "effect"; +import { Effect, Schema, Predicate } from "effect"; +import { dual } from "effect/Function"; import type { OrganizationPersonAuthority } from "../organization/authority.js"; import { DepartmentId, PersonId } from "../organization/schema.js"; import { compareRfc3339Instants, Rfc3339InstantSchema } from "../time.js"; @@ -305,127 +306,142 @@ const delegatedFact = (delegation: Delegation) => ({ * through the leaders-only `receipts.settle`. Each reaching delegation becomes one approval or * settlement fact keyed `delegation:`, beside the person's own Economy grants. */ -export const projectReceiptAuthority = ( - organization: OrganizationPersonAuthority, - paymentAuthorities: ReadonlyArray, - directApprovalGrants: ReadonlyArray, - directSettlementGrants: ReadonlyArray = [], -): ReceiptAuthority => { - const approvalGrants: Array = [...directApprovalGrants]; - - for (const delegation of delegationsReaching(organization, "receipts.approve")) { - const approvalGrantId = ReceiptApprovalGrantId.make(`delegation:${delegation.delegationId}`); - - if (approvalGrants.some((grant) => grant.approvalGrantId === approvalGrantId)) continue; - approvalGrants.push({ - ...delegatedFact(delegation), - approvalGrantId, - personId: organization.personId, - }); - } +export const projectReceiptAuthority: { + ( + paymentAuthorities: ReadonlyArray, + directApprovalGrants: ReadonlyArray, + directSettlementGrants?: ReadonlyArray, + ): (organization: OrganizationPersonAuthority) => ReceiptAuthority; + ( + organization: OrganizationPersonAuthority, + paymentAuthorities: ReadonlyArray, + directApprovalGrants: ReadonlyArray, + directSettlementGrants?: ReadonlyArray, + ): ReceiptAuthority; +} = dual( + (args) => !Array.isArray(args[0]), + ( + organization: OrganizationPersonAuthority, + paymentAuthorities: ReadonlyArray, + directApprovalGrants: ReadonlyArray, + directSettlementGrants: ReadonlyArray = [], + ): ReceiptAuthority => { + const approvalGrants: Array = [...directApprovalGrants]; + + for (const delegation of delegationsReaching(organization, "receipts.approve")) { + const approvalGrantId = ReceiptApprovalGrantId.make(`delegation:${delegation.delegationId}`); + + if (approvalGrants.some((grant) => grant.approvalGrantId === approvalGrantId)) continue; + approvalGrants.push({ + ...delegatedFact(delegation), + approvalGrantId, + personId: organization.personId, + }); + } - const settlementGrants: Array = [...directSettlementGrants]; + const settlementGrants: Array = [...directSettlementGrants]; - for (const delegation of delegationsReaching(organization, "receipts.settle")) { - const settlementGrantId = ReceiptSettlementGrantId.make( - `delegation:${delegation.delegationId}`, - ); + for (const delegation of delegationsReaching(organization, "receipts.settle")) { + const settlementGrantId = ReceiptSettlementGrantId.make( + `delegation:${delegation.delegationId}`, + ); - if (settlementGrants.some((grant) => grant.settlementGrantId === settlementGrantId)) continue; - settlementGrants.push({ - ...delegatedFact(delegation), - settlementGrantId, - personId: organization.personId, - }); - } + if (settlementGrants.some((grant) => grant.settlementGrantId === settlementGrantId)) continue; + settlementGrants.push({ + ...delegatedFact(delegation), + settlementGrantId, + personId: organization.personId, + }); + } - const projectedPayments: Array = []; + const projectedPayments: Array = []; - for (const authority of paymentAuthorities) { - if (authority.personId !== organization.personId) continue; - projectedPayments.push({ - ...authority, - active: - intervalContains(authority, organization.evaluatedAt) && - activeOrganizationAuthorityInDepartment(organization, authority.departmentId), - }); - } + for (const authority of paymentAuthorities) { + if (authority.personId !== organization.personId) continue; + projectedPayments.push({ + ...authority, + active: + intervalContains(authority, organization.evaluatedAt) && + activeOrganizationAuthorityInDepartment(organization, authority.departmentId), + }); + } - projectedPayments.sort( - (left, right) => - compareText(left.departmentId, right.departmentId) || - compareRfc3339Instants(left.startAt, right.startAt) || - compareText(left.paymentAuthorityId, right.paymentAuthorityId), - ); + projectedPayments.sort( + (left, right) => + compareText(left.departmentId, right.departmentId) || + compareRfc3339Instants(left.startAt, right.startAt) || + compareText(left.paymentAuthorityId, right.paymentAuthorityId), + ); - const resolvedOrganizationStatus = organizationStatus(organization); - const projectedGrants: Array = []; - - for (const grant of approvalGrants) { - if (grant.personId !== organization.personId) continue; - projectedGrants.push({ - ...grant, - active: - intervalContains(grant, organization.evaluatedAt) && - (ReceiptApprovalGrantScopeSchema.guards.Global(grant.scope) - ? resolvedOrganizationStatus === "Active" - : activeOrganizationAuthorityInDepartment(organization, grant.scope.departmentId)), - }); - } + const resolvedOrganizationStatus = organizationStatus(organization); + const projectedGrants: Array = []; + + for (const grant of approvalGrants) { + if (grant.personId !== organization.personId) continue; + projectedGrants.push({ + ...grant, + active: + intervalContains(grant, organization.evaluatedAt) && + (ReceiptApprovalGrantScopeSchema.guards.Global(grant.scope) + ? resolvedOrganizationStatus === "Active" + : activeOrganizationAuthorityInDepartment(organization, grant.scope.departmentId)), + }); + } - projectedGrants.sort( - (left, right) => - compareText(left.scope._tag, right.scope._tag) || - compareText( - ReceiptApprovalGrantScopeSchema.guards.Department(left.scope) - ? left.scope.departmentId - : "", - ReceiptApprovalGrantScopeSchema.guards.Department(right.scope) - ? right.scope.departmentId - : "", - ) || - compareRfc3339Instants(left.startAt, right.startAt) || - compareText(left.approvalGrantId, right.approvalGrantId), - ); + projectedGrants.sort( + (left, right) => + compareText(left.scope._tag, right.scope._tag) || + compareText( + ReceiptApprovalGrantScopeSchema.guards.Department(left.scope) + ? left.scope.departmentId + : "", + ReceiptApprovalGrantScopeSchema.guards.Department(right.scope) + ? right.scope.departmentId + : "", + ) || + compareRfc3339Instants(left.startAt, right.startAt) || + compareText(left.approvalGrantId, right.approvalGrantId), + ); - const projectedSettlementGrants: Array = []; - - for (const grant of settlementGrants) { - if (grant.personId !== organization.personId) continue; - projectedSettlementGrants.push({ - ...grant, - active: - intervalContains(grant, organization.evaluatedAt) && - (ReceiptApprovalGrantScopeSchema.guards.Global(grant.scope) - ? resolvedOrganizationStatus === "Active" - : activeOrganizationAuthorityInDepartment(organization, grant.scope.departmentId)), - }); - } + const projectedSettlementGrants: Array = []; + + for (const grant of settlementGrants) { + if (grant.personId !== organization.personId) continue; + projectedSettlementGrants.push({ + ...grant, + active: + intervalContains(grant, organization.evaluatedAt) && + (ReceiptApprovalGrantScopeSchema.guards.Global(grant.scope) + ? resolvedOrganizationStatus === "Active" + : activeOrganizationAuthorityInDepartment(organization, grant.scope.departmentId)), + }); + } - projectedSettlementGrants.sort( - (left, right) => - compareText(left.scope._tag, right.scope._tag) || - compareText( - ReceiptApprovalGrantScopeSchema.guards.Department(left.scope) - ? left.scope.departmentId - : "", - ReceiptApprovalGrantScopeSchema.guards.Department(right.scope) - ? right.scope.departmentId - : "", - ) || - compareRfc3339Instants(left.startAt, right.startAt) || - compareText(left.settlementGrantId, right.settlementGrantId), - ); + projectedSettlementGrants.sort( + (left, right) => + compareText(left.scope._tag, right.scope._tag) || + compareText( + ReceiptApprovalGrantScopeSchema.guards.Department(left.scope) + ? left.scope.departmentId + : "", + ReceiptApprovalGrantScopeSchema.guards.Department(right.scope) + ? right.scope.departmentId + : "", + ) || + compareRfc3339Instants(left.startAt, right.startAt) || + compareText(left.settlementGrantId, right.settlementGrantId), + ); - return { - personId: organization.personId, - evaluatedAt: organization.evaluatedAt, - organizationAuthority: resolvedOrganizationStatus, - paymentAuthorities: projectedPayments, - approvalGrants: projectedGrants, - settlementGrants: projectedSettlementGrants, - }; -}; + return { + personId: organization.personId, + evaluatedAt: organization.evaluatedAt, + organizationAuthority: resolvedOrganizationStatus, + paymentAuthorities: projectedPayments, + approvalGrants: projectedGrants, + settlementGrants: projectedSettlementGrants, + }; + }, +); type TemporalAuthority = { readonly startAt: string; @@ -466,94 +482,118 @@ const deny = ( operation: "Submission" | "DepartmentApproval" | "GlobalApproval" | "Owner", departmentId: DepartmentId | null, ): ReceiptAuthorityDenied => - new ReceiptAuthorityDenied({ personId: authority.personId, operation, departmentId }); + ReceiptAuthorityDenied.make({ personId: authority.personId, operation, departmentId }); const ambiguousPayment = ( authority: ReceiptAuthority, payments: ReadonlyArray, ): AmbiguousReceiptPaymentAuthority => - new AmbiguousReceiptPaymentAuthority({ + AmbiguousReceiptPaymentAuthority.make({ personId: authority.personId, departmentIds: payments .map((payment) => payment.departmentId) .sort((left, right) => compareText(left, right)), }); -export const mapReceiptSubmissionPrincipal = ( - authority: ReceiptAuthority, - departmentId?: DepartmentId, -): Effect.Effect => { - const candidatesByDepartment = new Map(); - - for (const payment of authority.paymentAuthorities) { - candidatesByDepartment.set( - payment.departmentId, - preferTemporalCandidate( - candidatesByDepartment.get(payment.departmentId), - payment, - authority.evaluatedAt, - ), - ); - } +export const mapReceiptSubmissionPrincipal: { + ( + departmentId?: DepartmentId, + ): ( + authority: ReceiptAuthority, + ) => Effect.Effect; + ( + authority: ReceiptAuthority, + departmentId?: DepartmentId, + ): Effect.Effect; +} = dual( + (args) => Predicate.isObject(args[0]), + ( + authority: ReceiptAuthority, + departmentId?: DepartmentId, + ): Effect.Effect => { + const candidatesByDepartment = new Map(); + + for (const payment of authority.paymentAuthorities) { + candidatesByDepartment.set( + payment.departmentId, + preferTemporalCandidate( + candidatesByDepartment.get(payment.departmentId), + payment, + authority.evaluatedAt, + ), + ); + } - let selected: ResolvedReceiptPaymentAuthority | undefined; + let selected: ResolvedReceiptPaymentAuthority | undefined; - if (departmentId !== undefined) { - selected = candidatesByDepartment.get(departmentId); - } else { - const candidates = Array.from(candidatesByDepartment.values()); - const active = candidates.filter((payment) => payment.active); + if (departmentId !== undefined) { + selected = candidatesByDepartment.get(departmentId); + } else { + const candidates = Array.from(candidatesByDepartment.values()); + const active = candidates.filter((payment) => payment.active); - if (active.length > 1) return Effect.fail(ambiguousPayment(authority, active)); + if (active.length > 1) return Effect.fail(ambiguousPayment(authority, active)); - if (active.length === 1) { - selected = active[0]; - } else { - if (candidates.length > 1) return Effect.fail(ambiguousPayment(authority, candidates)); - selected = candidates[0]; + if (active.length === 1) { + selected = active[0]; + } else { + if (candidates.length > 1) return Effect.fail(ambiguousPayment(authority, candidates)); + selected = candidates[0]; + } } - } - if (selected === undefined) { - return Effect.fail(deny(authority, "Submission", departmentId ?? null)); - } - - return Effect.succeed({ - actor: { - personId: authority.personId, - departmentId: selected.departmentId, - active: selected.active, - approvalScope: ApprovalScopeSchema.cases.None.make({}), - }, - paymentAccountCiphertext: selected.paymentAccountCiphertext, - }); -}; + if (selected === undefined) { + return Effect.fail(deny(authority, "Submission", departmentId ?? null)); + } -export const mapReceiptDepartmentApprovalActor = ( - authority: ReceiptAuthority, - departmentId: DepartmentId, -): Effect.Effect => { - let selected: ResolvedReceiptApprovalGrant | undefined; + return Effect.succeed({ + actor: { + personId: authority.personId, + departmentId: selected.departmentId, + active: selected.active, + approvalScope: ApprovalScopeSchema.cases.None.make({}), + }, + paymentAccountCiphertext: selected.paymentAccountCiphertext, + }); + }, +); - for (const grant of authority.approvalGrants) { - if ( - !ReceiptApprovalGrantScopeSchema.guards.Department(grant.scope) || - grant.scope.departmentId !== departmentId - ) - continue; - selected = preferTemporalCandidate(selected, grant, authority.evaluatedAt); - } +export const mapReceiptDepartmentApprovalActor: { + ( + departmentId: DepartmentId, + ): (authority: ReceiptAuthority) => Effect.Effect; + ( + authority: ReceiptAuthority, + departmentId: DepartmentId, + ): Effect.Effect; +} = dual( + 2, + ( + authority: ReceiptAuthority, + departmentId: DepartmentId, + ): Effect.Effect => { + let selected: ResolvedReceiptApprovalGrant | undefined; + + for (const grant of authority.approvalGrants) { + if ( + !ReceiptApprovalGrantScopeSchema.guards.Department(grant.scope) || + grant.scope.departmentId !== departmentId + ) + continue; + selected = preferTemporalCandidate(selected, grant, authority.evaluatedAt); + } - if (selected === undefined) - return Effect.fail(deny(authority, "DepartmentApproval", departmentId)); + if (selected === undefined) + return Effect.fail(deny(authority, "DepartmentApproval", departmentId)); - return Effect.succeed({ - personId: authority.personId, - departmentId, - active: selected.active, - approvalScope: Scope.Department({ departmentId }), - }); -}; + return Effect.succeed({ + personId: authority.personId, + departmentId, + active: selected.active, + approvalScope: Scope.Department({ departmentId }), + }); + }, +); export interface ReceiptGlobalApprovalPrincipal { readonly personId: PersonId; @@ -577,133 +617,202 @@ export const mapReceiptGlobalApprovalPrincipal = ( const principalPersonId = (authority: ReceiptAuthority): PersonId => authority.personId; -export const mapReceiptGlobalApprovalActor = ( - authority: ReceiptAuthority, - receiptDepartmentId: DepartmentId, -): Effect.Effect => - mapReceiptGlobalApprovalPrincipal(authority).pipe( - Effect.map((principal) => ({ - personId: principal.personId, - departmentId: receiptDepartmentId, - active: principal.active, - approvalScope: Scope.Global(), - })), - ); +export const mapReceiptGlobalApprovalActor: { + ( + receiptDepartmentId: DepartmentId, + ): (authority: ReceiptAuthority) => Effect.Effect; + ( + authority: ReceiptAuthority, + receiptDepartmentId: DepartmentId, + ): Effect.Effect; +} = dual( + 2, + ( + authority: ReceiptAuthority, + receiptDepartmentId: DepartmentId, + ): Effect.Effect => + mapReceiptGlobalApprovalPrincipal(authority).pipe( + Effect.map((principal) => ({ + personId: principal.personId, + departmentId: receiptDepartmentId, + active: principal.active, + approvalScope: Scope.Global(), + })), + ), +); /** * Selects the same grant used by a protected approval command for one * canonical receipt department. */ -export const selectReceiptApprovalGrant = ( - authority: ReceiptAuthority, - receiptDepartmentId: DepartmentId, -): ResolvedReceiptApprovalGrant | undefined => { - let global: ResolvedReceiptApprovalGrant | undefined; - let department: ResolvedReceiptApprovalGrant | undefined; - - for (const grant of authority.approvalGrants) { - if (ReceiptApprovalGrantScopeSchema.guards.Global(grant.scope)) { - global = preferTemporalCandidate(global, grant, authority.evaluatedAt); - } else if (grant.scope.departmentId === receiptDepartmentId) { - department = preferTemporalCandidate(department, grant, authority.evaluatedAt); +export const selectReceiptApprovalGrant: { + ( + receiptDepartmentId: DepartmentId, + ): (authority: ReceiptAuthority) => ResolvedReceiptApprovalGrant | undefined; + ( + authority: ReceiptAuthority, + receiptDepartmentId: DepartmentId, + ): ResolvedReceiptApprovalGrant | undefined; +} = dual( + 2, + ( + authority: ReceiptAuthority, + receiptDepartmentId: DepartmentId, + ): ResolvedReceiptApprovalGrant | undefined => { + let global: ResolvedReceiptApprovalGrant | undefined; + let department: ResolvedReceiptApprovalGrant | undefined; + + for (const grant of authority.approvalGrants) { + if (ReceiptApprovalGrantScopeSchema.guards.Global(grant.scope)) { + global = preferTemporalCandidate(global, grant, authority.evaluatedAt); + } else if (grant.scope.departmentId === receiptDepartmentId) { + department = preferTemporalCandidate(department, grant, authority.evaluatedAt); + } } - } - return global?.active === true - ? global - : department?.active === true - ? department - : (global ?? department); -}; + return global?.active === true + ? global + : department?.active === true + ? department + : (global ?? department); + }, +); /** * Existing receipt approval derives its only department scope from the locked * receipt. Active global authority wins, then active authority for that * department; known inactive authority remains an inactive actor. */ -export const mapReceiptApprovalActor = ( - authority: ReceiptAuthority, - receiptDepartmentId: DepartmentId, -): Effect.Effect => { - const selected = selectReceiptApprovalGrant(authority, receiptDepartmentId); - - if (selected === undefined) - return Effect.fail(deny(authority, "DepartmentApproval", receiptDepartmentId)); - - return Effect.succeed({ - personId: authority.personId, - departmentId: receiptDepartmentId, - active: selected.active, - approvalScope: ReceiptApprovalGrantScopeSchema.guards.Global(selected.scope) - ? Scope.Global() - : Scope.Department({ departmentId: receiptDepartmentId }), - }); -}; +export const mapReceiptApprovalActor: { + ( + receiptDepartmentId: DepartmentId, + ): (authority: ReceiptAuthority) => Effect.Effect; + ( + authority: ReceiptAuthority, + receiptDepartmentId: DepartmentId, + ): Effect.Effect; +} = dual( + 2, + ( + authority: ReceiptAuthority, + receiptDepartmentId: DepartmentId, + ): Effect.Effect => { + const selected = selectReceiptApprovalGrant(authority, receiptDepartmentId); + + if (selected === undefined) + return Effect.fail(deny(authority, "DepartmentApproval", receiptDepartmentId)); + + return Effect.succeed({ + personId: authority.personId, + departmentId: receiptDepartmentId, + active: selected.active, + approvalScope: ReceiptApprovalGrantScopeSchema.guards.Global(selected.scope) + ? Scope.Global() + : Scope.Department({ departmentId: receiptDepartmentId }), + }); + }, +); /** * An existing Receipt owns the department that selects approval authority. * Absence of an applicable grant is therefore a scope denial, not an * authority-projection denial. */ -export const mapExistingReceiptApprovalActor = ( - authority: ReceiptAuthority, - receiptId: string, - receiptDepartmentId: DepartmentId, -): Effect.Effect => - mapReceiptApprovalActor(authority, receiptDepartmentId).pipe( - Effect.mapError( - () => - new ReceiptScopeDenied({ +export const mapExistingReceiptApprovalActor: { + ( + receiptId: string, + receiptDepartmentId: DepartmentId, + ): (authority: ReceiptAuthority) => Effect.Effect; + ( + authority: ReceiptAuthority, + receiptId: string, + receiptDepartmentId: DepartmentId, + ): Effect.Effect; +} = dual( + 3, + ( + authority: ReceiptAuthority, + receiptId: string, + receiptDepartmentId: DepartmentId, + ): Effect.Effect => + mapReceiptApprovalActor(authority, receiptDepartmentId).pipe( + Effect.mapError(() => + ReceiptScopeDenied.make({ receiptId, departmentId: receiptDepartmentId, }), + ), ), - ); +); /** Selects an active global grant first, then the receipt's department grant. */ -export const selectReceiptSettlementGrant = ( - authority: ReceiptAuthority, - receiptDepartmentId: DepartmentId, -): ResolvedReceiptSettlementGrant | undefined => { - let global: ResolvedReceiptSettlementGrant | undefined; - let department: ResolvedReceiptSettlementGrant | undefined; - - for (const grant of authority.settlementGrants) { - if (ReceiptApprovalGrantScopeSchema.guards.Global(grant.scope)) { - global = preferTemporalCandidate(global, grant, authority.evaluatedAt); - } else if (grant.scope.departmentId === receiptDepartmentId) { - department = preferTemporalCandidate(department, grant, authority.evaluatedAt); +export const selectReceiptSettlementGrant: { + ( + receiptDepartmentId: DepartmentId, + ): (authority: ReceiptAuthority) => ResolvedReceiptSettlementGrant | undefined; + ( + authority: ReceiptAuthority, + receiptDepartmentId: DepartmentId, + ): ResolvedReceiptSettlementGrant | undefined; +} = dual( + 2, + ( + authority: ReceiptAuthority, + receiptDepartmentId: DepartmentId, + ): ResolvedReceiptSettlementGrant | undefined => { + let global: ResolvedReceiptSettlementGrant | undefined; + let department: ResolvedReceiptSettlementGrant | undefined; + + for (const grant of authority.settlementGrants) { + if (ReceiptApprovalGrantScopeSchema.guards.Global(grant.scope)) { + global = preferTemporalCandidate(global, grant, authority.evaluatedAt); + } else if (grant.scope.departmentId === receiptDepartmentId) { + department = preferTemporalCandidate(department, grant, authority.evaluatedAt); + } } - } - return global?.active === true - ? global - : department?.active === true - ? department - : (global ?? department); -}; + return global?.active === true + ? global + : department?.active === true + ? department + : (global ?? department); + }, +); /** * Settlement scope is intentionally concealed. An absent, inactive, or * out-of-scope grant is indistinguishable from an unknown receipt. */ -export const mapExistingReceiptSettlementActor = ( - authority: ReceiptAuthority, - receiptId: string, - receiptDepartmentId: DepartmentId, -): Effect.Effect => { - const selected = selectReceiptSettlementGrant(authority, receiptDepartmentId); - - if (selected?.active !== true) return Effect.fail(new ReceiptNotFound({ receiptId })); - - return Effect.succeed({ - personId: authority.personId, - active: true, - settlementScope: ReceiptApprovalGrantScopeSchema.guards.Global(selected.scope) - ? Scope.Global() - : Scope.Department({ departmentId: receiptDepartmentId }), - }); -}; +export const mapExistingReceiptSettlementActor: { + ( + receiptId: string, + receiptDepartmentId: DepartmentId, + ): (authority: ReceiptAuthority) => Effect.Effect; + ( + authority: ReceiptAuthority, + receiptId: string, + receiptDepartmentId: DepartmentId, + ): Effect.Effect; +} = dual( + 3, + ( + authority: ReceiptAuthority, + receiptId: string, + receiptDepartmentId: DepartmentId, + ): Effect.Effect => { + const selected = selectReceiptSettlementGrant(authority, receiptDepartmentId); + + if (selected?.active !== true) return Effect.fail(ReceiptNotFound.make({ receiptId })); + + return Effect.succeed({ + personId: authority.personId, + active: true, + settlementScope: ReceiptApprovalGrantScopeSchema.guards.Global(selected.scope) + ? Scope.Global() + : Scope.Department({ departmentId: receiptDepartmentId }), + }); + }, +); /** Owner lists use this person-keyed result and never select one department. */ export const mapReceiptOwnerPrincipal = ( @@ -717,15 +826,26 @@ export const mapReceiptOwnerPrincipal = ( }); /** Existing receipt transitions supply the receipt's immutable department. */ -export const mapReceiptOwnerActor = ( - authority: ReceiptAuthority, - receiptDepartmentId: DepartmentId, -): Effect.Effect => - mapReceiptOwnerPrincipal(authority).pipe( - Effect.map((principal) => ({ - personId: principal.personId, - departmentId: receiptDepartmentId, - active: principal.active, - approvalScope: ApprovalScopeSchema.cases.None.make({}), - })), - ); +export const mapReceiptOwnerActor: { + ( + receiptDepartmentId: DepartmentId, + ): (authority: ReceiptAuthority) => Effect.Effect; + ( + authority: ReceiptAuthority, + receiptDepartmentId: DepartmentId, + ): Effect.Effect; +} = dual( + 2, + ( + authority: ReceiptAuthority, + receiptDepartmentId: DepartmentId, + ): Effect.Effect => + mapReceiptOwnerPrincipal(authority).pipe( + Effect.map((principal) => ({ + personId: principal.personId, + departmentId: receiptDepartmentId, + active: principal.active, + approvalScope: ApprovalScopeSchema.cases.None.make({}), + })), + ), +); diff --git a/packages/domain/src/receipt/auxiliary-service.ts b/packages/domain/src/receipt/auxiliary-service.ts index d04c51287..87e8edf80 100644 --- a/packages/domain/src/receipt/auxiliary-service.ts +++ b/packages/domain/src/receipt/auxiliary-service.ts @@ -27,7 +27,7 @@ export interface ReceiptAuxiliaryEffectsOperations { export class ReceiptAuxiliaryEffects extends Context.Service< ReceiptAuxiliaryEffects, ReceiptAuxiliaryEffectsOperations ->()("@vektorprogrammet/domain/ReceiptAuxiliaryEffects") {} +>()("@vektorprogrammet/domain/receipt/auxiliary-service/ReceiptAuxiliaryEffects") {} export interface ReceiptAuxiliaryRecordingControl { readonly layer: Layer.Layer; @@ -44,7 +44,7 @@ export const makeReceiptAuxiliaryRecording = (): ReceiptAuxiliaryRecordingContro const previous = applied.get(request.effectId); if (previous !== undefined && previous !== digest) { - return Effect.fail(new ReceiptAuxiliaryEffectConflict({ effectId: request.effectId })); + return Effect.fail(ReceiptAuxiliaryEffectConflict.make({ effectId: request.effectId })); } return Effect.sync(() => void applied.set(request.effectId, digest)); diff --git a/packages/domain/src/receipt/effects.ts b/packages/domain/src/receipt/effects.ts index f21e7b875..00bb06422 100644 --- a/packages/domain/src/receipt/effects.ts +++ b/packages/domain/src/receipt/effects.ts @@ -1,4 +1,5 @@ -import { Schema } from "effect"; +import { Predicate, Schema } from "effect"; +import { dual } from "effect/Function"; import { ReceiptFileSchema, type ReceiptFile } from "./schema.js"; const NonEmpty = Schema.String.pipe(Schema.check(Schema.isMinLength(1))); @@ -34,32 +35,58 @@ type ReceiptFileEffectType = "PromoteReceiptFile" | "DeleteReceiptFile"; type ReceiptNoticeEffectType = Exclude; /** A file effect names its file; every other effect names none. */ -export function receiptOutboxRequest( - commandId: string, - receiptId: string, - effectType: Tag, - file: ReceiptFile, -): Extract; -export function receiptOutboxRequest( - commandId: string, - receiptId: string, - effectType: Tag, -): Extract; -export function receiptOutboxRequest( - commandId: string, - receiptId: string, - ...request: [effectType: ReceiptFileEffectType, file: ReceiptFile] | [ReceiptNoticeEffectType] -): ReceiptOutboxRequest { - const base = { effectId: `${commandId}:${request[0]}`, receiptId, commandId }; +/** The outbox request of one effect type. */ +type ReceiptOutboxRequestFor = Extract< + ReceiptOutboxRequest, + { readonly _tag: Tag } +>; - return request.length === 2 - ? ReceiptOutboxRequestSchema.cases[request[0]].make({ ...base, file: request[1] }) - : ReceiptOutboxRequestSchema.cases[request[0]].make(base); -} +export const receiptOutboxRequest: { + ( + commandId: string, + receiptId: string, + effectType: Tag, + file: ReceiptFile, + ): ReceiptOutboxRequestFor; + ( + commandId: string, + receiptId: string, + effectType: Tag, + ): ReceiptOutboxRequestFor; + ( + receiptId: string, + effectType: Tag, + file: ReceiptFile, + ): (commandId: string) => ReceiptOutboxRequestFor; + ( + receiptId: string, + effectType: Tag, + ): (commandId: string) => ReceiptOutboxRequestFor; +} = dual( + // A data-first call names the command, the receipt, and the effect; its third argument is a string. + (args) => args.length >= 4 || (args.length === 3 && Predicate.isString(args[2])), + ( + commandId: string, + receiptId: string, + ...request: [effectType: ReceiptFileEffectType, file: ReceiptFile] | [ReceiptNoticeEffectType] + ): ReceiptOutboxRequest => { + const base = { effectId: `${commandId}:${request[0]}`, receiptId, commandId }; -export const sameReceiptFile = (left: ReceiptFile, right: ReceiptFile): boolean => - left.fileRef === right.fileRef && - left.objectKey === right.objectKey && - left.contentType === right.contentType && - left.byteLength === right.byteLength && - left.sha256 === right.sha256; + return request.length === 2 + ? ReceiptOutboxRequestSchema.cases[request[0]].make({ ...base, file: request[1] }) + : ReceiptOutboxRequestSchema.cases[request[0]].make(base); + }, +); + +export const sameReceiptFile: { + (right: ReceiptFile): (left: ReceiptFile) => boolean; + (left: ReceiptFile, right: ReceiptFile): boolean; +} = dual( + 2, + (left: ReceiptFile, right: ReceiptFile): boolean => + left.fileRef === right.fileRef && + left.objectKey === right.objectKey && + left.contentType === right.contentType && + left.byteLength === right.byteLength && + left.sha256 === right.sha256, +); diff --git a/packages/domain/src/receipt/errors.ts b/packages/domain/src/receipt/errors.ts index ab51be0a7..935bdd4fa 100644 --- a/packages/domain/src/receipt/errors.ts +++ b/packages/domain/src/receipt/errors.ts @@ -1,4 +1,5 @@ import { Match, Schema } from "effect"; +import { dual } from "effect/Function"; import type { DecisionReason } from "../authz/decision.js"; import { DepartmentId, PersonId } from "../organization/schema.js"; import { Rfc3339InstantSchema } from "../time.js"; @@ -114,19 +115,31 @@ export class FailedComposedRequirement extends Schema.TaggedError - Match.value(reason).pipe( - Match.when("Ambiguous", () => new AmbiguousParameterFill({ personId, capabilityId })), - Match.when( - "RequirementFailed", - () => new FailedComposedRequirement({ personId, capabilityId }), +export const receiptCompositionFailure: { + ( + personId: PersonId, + capabilityId: ReceiptComposedCapability, + ): (reason: DecisionReason) => AmbiguousParameterFill | FailedComposedRequirement | undefined; + ( + reason: DecisionReason, + personId: PersonId, + capabilityId: ReceiptComposedCapability, + ): AmbiguousParameterFill | FailedComposedRequirement | undefined; +} = dual( + 3, + ( + reason: DecisionReason, + personId: PersonId, + capabilityId: ReceiptComposedCapability, + ): AmbiguousParameterFill | FailedComposedRequirement | undefined => + Match.value(reason).pipe( + Match.when("Ambiguous", () => AmbiguousParameterFill.make({ personId, capabilityId })), + Match.when("RequirementFailed", () => + FailedComposedRequirement.make({ personId, capabilityId }), + ), + Match.orElse(() => undefined), ), - Match.orElse(() => undefined), - ); +); export const ReceiptAuthorityOperationSchema = Schema.Literals([ "Submission", diff --git a/packages/domain/src/receipt/file-service.ts b/packages/domain/src/receipt/file-service.ts index 04092022a..257b9a977 100644 --- a/packages/domain/src/receipt/file-service.ts +++ b/packages/domain/src/receipt/file-service.ts @@ -23,7 +23,7 @@ export interface ReceiptFileServiceOperations { export class ReceiptFileService extends Context.Service< ReceiptFileService, ReceiptFileServiceOperations ->()("@vektorprogrammet/domain/ReceiptFileService") {} +>()("@vektorprogrammet/domain/receipt/file-service/ReceiptFileService") {} export interface ReceiptFileEvent { readonly effectId: string; @@ -97,7 +97,7 @@ export const makeReceiptFileRecording = (): ReceiptFileRecordingControl => { ) { state.conflicts.push(`stage:${file.fileRef}:${file.objectKey}`); - return yield* new ReceiptFileIdentityConflict({ + return yield* ReceiptFileIdentityConflict.make({ effectId: "stage", objectKey: file.objectKey, }); @@ -114,14 +114,14 @@ export const makeReceiptFileRecording = (): ReceiptFileRecordingControl => { if (appliedDigest !== digest) { state.conflicts.push(`effect:${request.effectId}`); - return yield* new ReceiptFileEffectConflict({ effectId: request.effectId }); + return yield* ReceiptFileEffectConflict.make({ effectId: request.effectId }); } return; } if (state.failOnce.delete(request.effectId)) { - return yield* new ReceiptFileInjectedFailure({ effectId: request.effectId }); + return yield* ReceiptFileInjectedFailure.make({ effectId: request.effectId }); } if (Predicate.isTagged(request, "PromoteReceiptFile")) { @@ -130,7 +130,7 @@ export const makeReceiptFileRecording = (): ReceiptFileRecordingControl => { ); if (staged === undefined || !sameIdentity(staged, request.file)) { - return yield* new ReceiptFileNotStaged({ + return yield* ReceiptFileNotStaged.make({ effectId: request.effectId, fileRef: request.file.fileRef, }); @@ -143,7 +143,7 @@ export const makeReceiptFileRecording = (): ReceiptFileRecordingControl => { if (current !== undefined && !sameIdentity(current, request.file)) { state.conflicts.push(`identity:${request.effectId}:${request.file.objectKey}`); - return yield* new ReceiptFileIdentityConflict({ + return yield* ReceiptFileIdentityConflict.make({ effectId: request.effectId, objectKey: request.file.objectKey, }); @@ -168,7 +168,7 @@ export const makeReceiptFileRecording = (): ReceiptFileRecordingControl => { if (current !== undefined && !sameIdentity(current, request.file)) { state.conflicts.push(`identity:${request.effectId}:${request.file.objectKey}`); - return yield* new ReceiptFileIdentityConflict({ + return yield* ReceiptFileIdentityConflict.make({ effectId: request.effectId, objectKey: request.file.objectKey, }); diff --git a/packages/domain/src/receipt/import.ts b/packages/domain/src/receipt/import.ts index 13ee265cf..20836dcdd 100644 --- a/packages/domain/src/receipt/import.ts +++ b/packages/domain/src/receipt/import.ts @@ -1,4 +1,5 @@ import { Data, Predicate, Schema } from "effect"; +import { dual } from "effect/Function"; import { isIsoDate, isIsoInstant, @@ -224,11 +225,24 @@ const importReceiptOccurrence = ( }; /** Imports one legacy row as the first occurrence of its source identity. */ -export const importLegacyReceipt = ( - row: LegacyReceiptRow, - receiptId: string, - provenance: ReceiptImportProvenance, -): ReceiptImportResult => importReceiptOccurrence(row, receiptId, provenance, 0); +export const importLegacyReceipt: { + ( + receiptId: string, + provenance: ReceiptImportProvenance, + ): (row: LegacyReceiptRow) => ReceiptImportResult; + ( + row: LegacyReceiptRow, + receiptId: string, + provenance: ReceiptImportProvenance, + ): ReceiptImportResult; +} = dual( + 3, + ( + row: LegacyReceiptRow, + receiptId: string, + provenance: ReceiptImportProvenance, + ): ReceiptImportResult => importReceiptOccurrence(row, receiptId, provenance, 0), +); export interface LegacyReceiptImportInput { readonly row: LegacyReceiptRow; diff --git a/packages/domain/src/receipt/pagination.ts b/packages/domain/src/receipt/pagination.ts index 738b8b998..904667204 100644 --- a/packages/domain/src/receipt/pagination.ts +++ b/packages/domain/src/receipt/pagination.ts @@ -2,6 +2,7 @@ import { Effect, Encoding, Result, Schema } from "effect"; import { ReceiptDecodeError } from "./errors.js"; import { ReceiptId } from "./schema.js"; import { isRfc3339Instant } from "../time.js"; +import { dual } from "effect/Function"; export const RECEIPT_PAGE_SIZE = 50; @@ -56,15 +57,18 @@ export const decodeReceiptCursor = ( const [, timestamp, receiptId] = yield* Schema.decodeEffect(CursorTuple)(text); return { timestamp, receiptId }; - }).pipe(Effect.mapError(() => new ReceiptDecodeError({ message: "invalid receipt cursor" }))); + }).pipe(Effect.mapError(() => ReceiptDecodeError.make({ message: "invalid receipt cursor" }))); -export const receiptPage = ( - rows: ReadonlyArray, - position: (row: A) => ReceiptCursorPosition, -): ReceiptPage => - rows.length <= RECEIPT_PAGE_SIZE - ? { items: rows } - : { - items: rows.slice(0, RECEIPT_PAGE_SIZE), - nextCursor: encodeReceiptCursor(position(rows[RECEIPT_PAGE_SIZE - 1]!)), - }; +export const receiptPage: { + (position: (row: A) => ReceiptCursorPosition): (rows: ReadonlyArray) => ReceiptPage; + (rows: ReadonlyArray, position: (row: A) => ReceiptCursorPosition): ReceiptPage; +} = dual( + 2, + (rows: ReadonlyArray, position: (row: A) => ReceiptCursorPosition): ReceiptPage => + rows.length <= RECEIPT_PAGE_SIZE + ? { items: rows } + : { + items: rows.slice(0, RECEIPT_PAGE_SIZE), + nextCursor: encodeReceiptCursor(position(rows[RECEIPT_PAGE_SIZE - 1]!)), + }, +); diff --git a/packages/domain/src/receipt/review.ts b/packages/domain/src/receipt/review.ts index ef72a346e..db6e728fb 100644 --- a/packages/domain/src/receipt/review.ts +++ b/packages/domain/src/receipt/review.ts @@ -141,7 +141,7 @@ export const decodeReviewedReceiptSnapshot = ( for (const row of snapshot.rows) { const entry = entries.get(row.sourcePrimaryKey); - if (!entry || entry.sourceRowDigest !== receiptSourceRowDigest(row)) + if (entry === undefined || entry.sourceRowDigest !== receiptSourceRowDigest(row)) return yield* Result.fail(invalidReview()); if (Predicate.isTagged(entry, "Excluded")) continue; diff --git a/packages/domain/src/receipt/service.ts b/packages/domain/src/receipt/service.ts index 37a126073..c508c464d 100644 --- a/packages/domain/src/receipt/service.ts +++ b/packages/domain/src/receipt/service.ts @@ -181,5 +181,5 @@ export interface EconomyOperations { } export class Economy extends Context.Service()( - "@vektorprogrammet/domain/Economy", + "@vektorprogrammet/domain/receipt/service/Economy", ) {} diff --git a/packages/domain/src/receipt/update.ts b/packages/domain/src/receipt/update.ts index 6c8961ae5..cbb61116d 100644 --- a/packages/domain/src/receipt/update.ts +++ b/packages/domain/src/receipt/update.ts @@ -1,4 +1,5 @@ import { Data, Match, flow, Predicate, Effect, Schema, Struct } from "effect"; +import { dual } from "effect/Function"; import { DepartmentId } from "../organization/schema.js"; import { InactiveActor, @@ -71,13 +72,15 @@ export interface ReceiptDecision { } const activeActor = (actor: ReceiptActor): Effect.Effect => - actor.active ? Effect.void : Effect.fail(new InactiveActor({ personId: actor.personId })); + actor.active ? Effect.void : Effect.fail(InactiveActor.make({ personId: actor.personId })); const requireReceipt = ( receipt: Receipt | undefined, receiptId: string, ): Effect.Effect => - receipt === undefined ? Effect.fail(new ReceiptNotFound({ receiptId })) : Effect.succeed(receipt); + receipt === undefined + ? Effect.fail(ReceiptNotFound.make({ receiptId })) + : Effect.succeed(receipt); const currentRevision = ( receipt: Receipt, @@ -86,7 +89,7 @@ const currentRevision = ( receipt.revision === expected ? Effect.void : Effect.fail( - new StaleReceiptRevision({ + StaleReceiptRevision.make({ receiptId: receipt.receiptId, expected, actual: receipt.revision, @@ -100,7 +103,7 @@ const pending = ( receipt.status === "Pending" ? Effect.void : Effect.fail( - new InvalidReceiptTransition({ + InvalidReceiptTransition.make({ receiptId: receipt.receiptId, status: receipt.status, command, @@ -111,7 +114,7 @@ const owner = (receipt: Receipt, actor: ReceiptActor): Effect.Effect { - return Effect.void; - }), - Match.tag("RevisePendingReceipt", "WithdrawPendingReceipt", (authorization) => { - return owner(authorization.current, authorization.actor); - }), - Match.tag("ApproveReceipt", "RejectReceipt", "ReopenRejectedReceipt", (authorization) => { - return approver(authorization.current, authorization.actor); - }), + Match.tag("SubmitReceipt", () => Effect.void), + Match.tag("RevisePendingReceipt", "WithdrawPendingReceipt", (authorization) => + owner(authorization.current, authorization.actor), + ), + Match.tag("ApproveReceipt", "RejectReceipt", "ReopenRejectedReceipt", (authorization) => + approver(authorization.current, authorization.actor), + ), Match.exhaustive, ); }); @@ -207,7 +208,7 @@ const decideCommand = ( SubmitReceipt: (input) => Effect.gen(function* () { if (existing !== undefined) { - return yield* new ReceiptAlreadyExists({ receiptId: context.receiptId }); + return yield* ReceiptAlreadyExists.make({ receiptId: context.receiptId }); } const receipt: Receipt = { @@ -322,7 +323,7 @@ const decideCommand = ( yield* currentRevision(current, input.expectedRevision); if (current.status !== "Rejected") { - return yield* new InvalidReceiptTransition({ + return yield* InvalidReceiptTransition.make({ receiptId: current.receiptId, status: current.status, command: input._tag, @@ -370,25 +371,38 @@ const decodeReceiptCommand = flow( Schema.decodeUnknownEffect(AuthorizedReceiptCommandSchema, { onExcessProperty: "error", }), - Effect.mapError((cause) => new ReceiptDecodeError({ message: String(cause) })), + Effect.mapError((cause) => ReceiptDecodeError.make({ message: String(cause) })), ); const decodeReceiptDecisionContext = flow( Schema.decodeUnknownEffect(ReceiptDecisionContextSchema, { onExcessProperty: "error", }), - Effect.mapError((cause) => new ReceiptDecodeError({ message: String(cause) })), + Effect.mapError((cause) => ReceiptDecodeError.make({ message: String(cause) })), ); -export const decideReceipt = ( - existing: Receipt | undefined, - input: Schema.Json, - context: ReceiptDecisionContext, -): Effect.Effect => - decodeReceiptCommand(input).pipe( - Effect.flatMap((command) => - decodeReceiptDecisionContext(context).pipe( - Effect.flatMap((decodedContext) => decideCommand(existing, command, decodedContext)), +export const decideReceipt: { + ( + input: Schema.Json, + context: ReceiptDecisionContext, + ): (existing: Receipt | undefined) => Effect.Effect; + ( + existing: Receipt | undefined, + input: Schema.Json, + context: ReceiptDecisionContext, + ): Effect.Effect; +} = dual( + 3, + ( + existing: Receipt | undefined, + input: Schema.Json, + context: ReceiptDecisionContext, + ): Effect.Effect => + decodeReceiptCommand(input).pipe( + Effect.flatMap((command) => + decodeReceiptDecisionContext(context).pipe( + Effect.flatMap((decodedContext) => decideCommand(existing, command, decodedContext)), + ), ), ), - ); +); diff --git a/packages/domain/src/recruitment/applicant-identity.ts b/packages/domain/src/recruitment/applicant-identity.ts index 8d8f2533c..c9ac9681c 100644 --- a/packages/domain/src/recruitment/applicant-identity.ts +++ b/packages/domain/src/recruitment/applicant-identity.ts @@ -1,5 +1,11 @@ +import { dual } from "effect/Function"; + /** Only an established Applicant-to-Person association proves a self-interview. */ -export const isKnownSelfInterview = ( - linkedApplicantPersonId: string | null, - actorPersonId: string, -): boolean => linkedApplicantPersonId !== null && linkedApplicantPersonId === actorPersonId; +export const isKnownSelfInterview: { + (actorPersonId: string): (linkedApplicantPersonId: string | null) => boolean; + (linkedApplicantPersonId: string | null, actorPersonId: string): boolean; +} = dual( + 2, + (linkedApplicantPersonId: string | null, actorPersonId: string): boolean => + linkedApplicantPersonId !== null && linkedApplicantPersonId === actorPersonId, +); diff --git a/packages/domain/src/recruitment/conduct.ts b/packages/domain/src/recruitment/conduct.ts index 51a23b915..57829fc84 100644 --- a/packages/domain/src/recruitment/conduct.ts +++ b/packages/domain/src/recruitment/conduct.ts @@ -1,4 +1,5 @@ import { Predicate, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { isRfc3339Instant } from "../time.js"; import { RecruitmentConductValidationError, @@ -55,7 +56,7 @@ type ConductFailure = const invalid = (state: RecruitmentConductState, message: string) => Effect.fail( - new RecruitmentConductValidationError({ + RecruitmentConductValidationError.make({ interviewId: state.interview.interviewId, message, }), @@ -66,7 +67,7 @@ const checkActor = ( actor: RecruitmentConductActor, ): Effect.Effect => { if (!actor.active || !actor.membershipActive || !actor.teamActive || !actor.departmentActive) { - return Effect.fail(new RecruitmentInactiveActor({ personId: actor.personId })); + return Effect.fail(RecruitmentInactiveActor.make({ personId: actor.personId })); } if ( @@ -74,7 +75,7 @@ const checkActor = ( actor.departmentId !== state.interview.departmentId ) { return Effect.fail( - new RecruitmentScopeDenied({ + RecruitmentScopeDenied.make({ personId: actor.personId, departmentId: state.interview.departmentId, }), @@ -91,13 +92,13 @@ const checkBase = ( ): Effect.Effect => { if (state.schedule === null) { return Effect.fail( - new RecruitmentInterviewNotScheduled({ interviewId: state.interview.interviewId }), + RecruitmentInterviewNotScheduled.make({ interviewId: state.interview.interviewId }), ); } if (state.revision !== expectedRevision) { return Effect.fail( - new RecruitmentInterviewStaleRevision({ + RecruitmentInterviewStaleRevision.make({ interviewId: state.interview.interviewId, expectedRevision, actualRevision: state.revision, @@ -225,132 +226,159 @@ const validateScore = ( onExcessProperty: "error", }).pipe( Effect.asVoid, - Effect.mapError( - () => - new RecruitmentConductValidationError({ - interviewId: state.interview.interviewId, - message: "score values must be integers from 0 to 10", - }), + Effect.mapError(() => + RecruitmentConductValidationError.make({ + interviewId: state.interview.interviewId, + message: "score values must be integers from 0 to 10", + }), ), ); -export const finalizeInterview = ( - state: RecruitmentConductState, - command: FinalizeInterviewCommand, - actor: RecruitmentConductActor, - now: string, -): Effect.Effect => - Effect.gen(function* () { - if (!isRfc3339Instant(now)) return yield* invalid(state, "invalid finalization instant"); - yield* checkBase(state, actor, command.expectedRevision); - - if (state.invitationResponse !== "Accepted") { - return yield* new RecruitmentInvitationNotAccepted({ - interviewId: state.interview.interviewId, - responseState: state.invitationResponse ?? "Absent", - }); - } +export const finalizeInterview: { + ( + command: FinalizeInterviewCommand, + actor: RecruitmentConductActor, + now: string, + ): (state: RecruitmentConductState) => Effect.Effect; + ( + state: RecruitmentConductState, + command: FinalizeInterviewCommand, + actor: RecruitmentConductActor, + now: string, + ): Effect.Effect; +} = dual( + 4, + ( + state: RecruitmentConductState, + command: FinalizeInterviewCommand, + actor: RecruitmentConductActor, + now: string, + ): Effect.Effect => + Effect.gen(function* () { + if (!isRfc3339Instant(now)) return yield* invalid(state, "invalid finalization instant"); + yield* checkBase(state, actor, command.expectedRevision); + + if (state.invitationResponse !== "Accepted") { + return yield* RecruitmentInvitationNotAccepted.make({ + interviewId: state.interview.interviewId, + responseState: state.invitationResponse ?? "Absent", + }); + } - if (state.conduct !== null) - return yield* new RecruitmentInterviewAlreadyFinalized({ - interviewId: state.interview.interviewId, - }); + if (state.conduct !== null) + return yield* RecruitmentInterviewAlreadyFinalized.make({ + interviewId: state.interview.interviewId, + }); - if (state.cancellation !== null) - return yield* new RecruitmentInterviewAlreadyCancelled({ - interviewId: state.interview.interviewId, - }); - const answers = yield* validateAnswers(state, command.answers); - yield* validateScore(state, command.score); + if (state.cancellation !== null) + return yield* RecruitmentInterviewAlreadyCancelled.make({ + interviewId: state.interview.interviewId, + }); + const answers = yield* validateAnswers(state, command.answers); + yield* validateScore(state, command.score); - if (!interviewRecommendations.includes(command.recommendation)) - return yield* invalid(state, "an explicit interviewer recommendation is required"); - const revision = state.revision + 1; + if (!interviewRecommendations.includes(command.recommendation)) + return yield* invalid(state, "an explicit interviewer recommendation is required"); + const revision = state.revision + 1; - const conduct = new RecruitmentInterviewConduct({ - interviewId: state.interview.interviewId, - answers, - score: command.score, - recommendation: command.recommendation, - finalizedByPersonId: actor.personId, - finalizedAt: now, - interviewRevision: revision, - }); - - const observation = yield* Schema.decodeEffect(FinalizeInterviewObservationSchema)( - FinalizeInterviewObservationSchema.make({ - commandId: command.commandId, + const conduct = RecruitmentInterviewConduct.make({ interviewId: state.interview.interviewId, - interviewRevision: revision, + answers, + score: command.score, + recommendation: command.recommendation, + finalizedByPersonId: actor.personId, finalizedAt: now, - completionState: "Completed", - cancellationState: "NotCancelled", - notificationState: "Pending", - }), - { onExcessProperty: "error" }, - ).pipe( - Effect.mapError( - (cause) => - new RecruitmentConductValidationError({ + interviewRevision: revision, + }); + + const observation = yield* Schema.decodeEffect(FinalizeInterviewObservationSchema)( + FinalizeInterviewObservationSchema.make({ + commandId: command.commandId, + interviewId: state.interview.interviewId, + interviewRevision: revision, + finalizedAt: now, + completionState: "Completed", + cancellationState: "NotCancelled", + notificationState: "Pending", + }), + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => + RecruitmentConductValidationError.make({ interviewId: state.interview.interviewId, message: String(cause), }), - ), - ); + ), + ); - return { observation, state: { ...state, conduct, revision } }; - }); + return { observation, state: { ...state, conduct, revision } }; + }), +); + +export const cancelInterview: { + ( + command: CancelInterviewCommand, + actor: RecruitmentConductActor, + now: string, + ): (state: RecruitmentConductState) => Effect.Effect; + ( + state: RecruitmentConductState, + command: CancelInterviewCommand, + actor: RecruitmentConductActor, + now: string, + ): Effect.Effect; +} = dual( + 4, + ( + state: RecruitmentConductState, + command: CancelInterviewCommand, + actor: RecruitmentConductActor, + now: string, + ): Effect.Effect => + Effect.gen(function* () { + if (!isRfc3339Instant(now)) return yield* invalid(state, "invalid cancellation instant"); + yield* checkBase(state, actor, command.expectedRevision); + + if (state.conduct !== null) + return yield* RecruitmentInterviewAlreadyFinalized.make({ + interviewId: state.interview.interviewId, + }); -export const cancelInterview = ( - state: RecruitmentConductState, - command: CancelInterviewCommand, - actor: RecruitmentConductActor, - now: string, -): Effect.Effect => - Effect.gen(function* () { - if (!isRfc3339Instant(now)) return yield* invalid(state, "invalid cancellation instant"); - yield* checkBase(state, actor, command.expectedRevision); - - if (state.conduct !== null) - return yield* new RecruitmentInterviewAlreadyFinalized({ - interviewId: state.interview.interviewId, - }); + if (state.cancellation !== null) + return yield* RecruitmentInterviewAlreadyCancelled.make({ + interviewId: state.interview.interviewId, + }); + const revision = state.revision + 1; - if (state.cancellation !== null) - return yield* new RecruitmentInterviewAlreadyCancelled({ + const cancellation = RecruitmentInterviewCancellation.make({ interviewId: state.interview.interviewId, + cancelledByPersonId: actor.personId, + cancelledAt: now, + interviewRevision: revision, }); - const revision = state.revision + 1; - const cancellation = new RecruitmentInterviewCancellation({ - interviewId: state.interview.interviewId, - cancelledByPersonId: actor.personId, - cancelledAt: now, - interviewRevision: revision, - }); - - const observation = yield* Schema.decodeEffect(CancelInterviewObservationSchema)( - CancelInterviewObservationSchema.make({ - commandId: command.commandId, - interviewId: state.interview.interviewId, - interviewRevision: revision, - cancelledAt: now, - completionState: "NotCompleted", - cancellationState: "Cancelled", - }), - { onExcessProperty: "error" }, - ).pipe( - Effect.mapError( - (cause) => - new RecruitmentConductValidationError({ + const observation = yield* Schema.decodeEffect(CancelInterviewObservationSchema)( + CancelInterviewObservationSchema.make({ + commandId: command.commandId, + interviewId: state.interview.interviewId, + interviewRevision: revision, + cancelledAt: now, + completionState: "NotCompleted", + cancellationState: "Cancelled", + }), + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => + RecruitmentConductValidationError.make({ interviewId: state.interview.interviewId, message: String(cause), }), - ), - ); + ), + ); - return { observation, state: { ...state, cancellation, revision } }; - }); + return { observation, state: { ...state, cancellation, revision } }; + }), +); export interface CorrectionTransition { readonly observation: CorrectInterviewAssessmentObservation; @@ -358,76 +386,89 @@ export interface CorrectionTransition { readonly state: RecruitmentConductState; } -export const correctInterviewAssessment = ( - state: RecruitmentConductState, - command: CorrectInterviewAssessmentCommand, - actor: RecruitmentConductActor, - now: string, -): Effect.Effect => - Effect.gen(function* () { - if (!isRfc3339Instant(now)) return yield* invalid(state, "invalid correction instant"); - yield* checkBase(state, actor, command.expectedRevision); - - if (state.cancellation !== null) - return yield* new RecruitmentInterviewAlreadyCancelled({ - interviewId: state.interview.interviewId, - }); +export const correctInterviewAssessment: { + ( + command: CorrectInterviewAssessmentCommand, + actor: RecruitmentConductActor, + now: string, + ): (state: RecruitmentConductState) => Effect.Effect; + ( + state: RecruitmentConductState, + command: CorrectInterviewAssessmentCommand, + actor: RecruitmentConductActor, + now: string, + ): Effect.Effect; +} = dual( + 4, + ( + state: RecruitmentConductState, + command: CorrectInterviewAssessmentCommand, + actor: RecruitmentConductActor, + now: string, + ): Effect.Effect => + Effect.gen(function* () { + if (!isRfc3339Instant(now)) return yield* invalid(state, "invalid correction instant"); + yield* checkBase(state, actor, command.expectedRevision); + + if (state.cancellation !== null) + return yield* RecruitmentInterviewAlreadyCancelled.make({ + interviewId: state.interview.interviewId, + }); - if (state.conduct === null) - return yield* new RecruitmentConductValidationError({ - interviewId: state.interview.interviewId, - message: "the interview is not completed", - }); - const answers = yield* validateAnswers(state, command.answers); - yield* validateScore(state, command.score); + if (state.conduct === null) + return yield* RecruitmentConductValidationError.make({ + interviewId: state.interview.interviewId, + message: "the interview is not completed", + }); + const answers = yield* validateAnswers(state, command.answers); + yield* validateScore(state, command.score); - if (!interviewRecommendations.includes(command.recommendation)) - return yield* invalid(state, "an explicit interviewer recommendation is required"); - const resultingRevision = state.revision + 1; + if (!interviewRecommendations.includes(command.recommendation)) + return yield* invalid(state, "an explicit interviewer recommendation is required"); + const resultingRevision = state.revision + 1; - const correction = yield* Schema.decodeEffect(RecruitmentInterviewCorrectionSchema)( - { - interviewId: state.interview.interviewId, - predecessorRevision: state.revision, - resultingRevision, - answers, - score: command.score, - recommendation: command.recommendation, - correctedByPersonId: actor.personId, - correctedAt: now, - commandId: command.commandId, - }, - { onExcessProperty: "error" }, - ).pipe( - Effect.mapError( - (cause) => - new RecruitmentConductValidationError({ + const correction = yield* Schema.decodeEffect(RecruitmentInterviewCorrectionSchema)( + { + interviewId: state.interview.interviewId, + predecessorRevision: state.revision, + resultingRevision, + answers, + score: command.score, + recommendation: command.recommendation, + correctedByPersonId: actor.personId, + correctedAt: now, + commandId: command.commandId, + }, + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => + RecruitmentConductValidationError.make({ interviewId: state.interview.interviewId, message: String(cause), }), - ), - ); + ), + ); - const observation = yield* Schema.decodeEffect(CorrectInterviewAssessmentObservationSchema)( - CorrectInterviewAssessmentObservationSchema.make({ - commandId: command.commandId, - interviewId: state.interview.interviewId, - predecessorRevision: state.revision, - resultingRevision, - }), - { onExcessProperty: "error" }, - ).pipe( - Effect.mapError( - (cause) => - new RecruitmentConductValidationError({ + const observation = yield* Schema.decodeEffect(CorrectInterviewAssessmentObservationSchema)( + CorrectInterviewAssessmentObservationSchema.make({ + commandId: command.commandId, + interviewId: state.interview.interviewId, + predecessorRevision: state.revision, + resultingRevision, + }), + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => + RecruitmentConductValidationError.make({ interviewId: state.interview.interviewId, message: String(cause), }), - ), - ); + ), + ); - return { observation, correction, state: { ...state, revision: resultingRevision } }; - }); + return { observation, correction, state: { ...state, revision: resultingRevision } }; + }), +); export const recruitmentInterviewAnswerIsValid = ( value: unknown, diff --git a/packages/domain/src/recruitment/maintenance.ts b/packages/domain/src/recruitment/maintenance.ts index 0478847fd..f784dd881 100644 --- a/packages/domain/src/recruitment/maintenance.ts +++ b/packages/domain/src/recruitment/maintenance.ts @@ -45,6 +45,21 @@ export const RecruitmentMaintenanceCommand = Schema.TaggedUnion({ export type RecruitmentMaintenanceCommand = typeof RecruitmentMaintenanceCommand.Type; +/** Type-only brand. Only the Recruitment adapter that resolves current authority builds this evidence. */ +declare const RecruitmentMaintenanceAuthorizationBrand: unique symbol; + +/** + * Proof that a person may run one maintenance command, resolved with its locks inside the + * command's transaction: a questionnaire change needs an active global administrator, a staffing + * change `recruitment.interviews` over the interview's department. The command runs from it, and + * `maintainRecruitment` resolves no authority again. Valid only in the transaction that resolved it. + */ +export interface RecruitmentMaintenanceAuthorization { + readonly [RecruitmentMaintenanceAuthorizationBrand]: "RecruitmentMaintenanceAuthorization"; + readonly personId: PersonId; + readonly command: RecruitmentMaintenanceCommand; +} + export const RecruitmentMaintenanceResult = Schema.TaggedUnion({ QuestionnaireSaved: { interviewSchemaId: InterviewSchemaId, revision: Revision }, InterviewStaffingChanged: { interviewId: RecruitmentInterviewId, revision: Revision }, diff --git a/packages/domain/src/recruitment/report.ts b/packages/domain/src/recruitment/report.ts index 992877b6e..790d9a81a 100644 --- a/packages/domain/src/recruitment/report.ts +++ b/packages/domain/src/recruitment/report.ts @@ -7,6 +7,7 @@ import { RecruitmentInterviewId, RecruitmentInterviewScoreSchema, } from "./schema.js"; +import { dual } from "effect/Function"; export const InterviewReportFilter = Schema.Literals([ "all", @@ -65,7 +66,7 @@ export const interviewScoreTotal = ( row: Pick, ) => row.explanatoryPower + row.roleModel + row.suitability; -export const orderInterviewReport = ( +const orderInterviewReportImpl = ( rows: ReadonlyArray, query: InterviewReportQuery, ) => { @@ -96,3 +97,13 @@ export const orderInterviewReport = ( return primary * direction || compareText(left.interviewId, right.interviewId); }); }; + +export const orderInterviewReport: { + ( + query: InterviewReportQuery, + ): (rows: ReadonlyArray) => ReturnType; + ( + rows: ReadonlyArray, + query: InterviewReportQuery, + ): ReturnType; +} = dual(2, orderInterviewReportImpl); diff --git a/packages/domain/src/recruitment/scheduling.ts b/packages/domain/src/recruitment/scheduling.ts index e51ccac8f..16bbf0dc0 100644 --- a/packages/domain/src/recruitment/scheduling.ts +++ b/packages/domain/src/recruitment/scheduling.ts @@ -1,32 +1,58 @@ import { Effect } from "effect"; +import { dual } from "effect/Function"; import { compareRfc3339Instants } from "../time.js"; import { RecruitmentInterviewAlreadyScheduled, RecruitmentScheduleInPast } from "./errors.js"; import type { RecruitmentInvitationResponseState, RecruitmentScheduleCommand } from "./schema.js"; /** Initial scheduling and applicant-requested replacement share one command. */ -export const validateInterviewScheduling = ( - command: RecruitmentScheduleCommand, - current: { - readonly scheduled: boolean; - readonly responseState: RecruitmentInvitationResponseState | null; - readonly completed: boolean; - readonly cancelled: boolean; - }, - now: string, -): Effect.Effect => { - if ( - current.completed || - current.cancelled || - (current.scheduled && current.responseState !== "RequestedNewTime") - ) { - return Effect.fail( - new RecruitmentInterviewAlreadyScheduled({ interviewId: command.interviewId }), - ); - } +export const validateInterviewScheduling: { + ( + current: { + readonly scheduled: boolean; + readonly responseState: RecruitmentInvitationResponseState | null; + readonly completed: boolean; + readonly cancelled: boolean; + }, + now: string, + ): ( + command: RecruitmentScheduleCommand, + ) => Effect.Effect; + ( + command: RecruitmentScheduleCommand, + current: { + readonly scheduled: boolean; + readonly responseState: RecruitmentInvitationResponseState | null; + readonly completed: boolean; + readonly cancelled: boolean; + }, + now: string, + ): Effect.Effect; +} = dual( + 3, + ( + command: RecruitmentScheduleCommand, + current: { + readonly scheduled: boolean; + readonly responseState: RecruitmentInvitationResponseState | null; + readonly completed: boolean; + readonly cancelled: boolean; + }, + now: string, + ): Effect.Effect => { + if ( + current.completed || + current.cancelled || + (current.scheduled && current.responseState !== "RequestedNewTime") + ) { + return Effect.fail( + RecruitmentInterviewAlreadyScheduled.make({ interviewId: command.interviewId }), + ); + } - if (compareRfc3339Instants(command.scheduledAt, now) <= 0) { - return Effect.fail(new RecruitmentScheduleInPast({ interviewId: command.interviewId })); - } + if (compareRfc3339Instants(command.scheduledAt, now) <= 0) { + return Effect.fail(RecruitmentScheduleInPast.make({ interviewId: command.interviewId })); + } - return Effect.void; -}; + return Effect.void; + }, +); diff --git a/packages/domain/src/recruitment/service.ts b/packages/domain/src/recruitment/service.ts index 86f28beb6..c4db6cb1b 100644 --- a/packages/domain/src/recruitment/service.ts +++ b/packages/domain/src/recruitment/service.ts @@ -16,6 +16,7 @@ import type { RecruitmentInvitationTransition, } from "./access.js"; import type { + RecruitmentMaintenanceAuthorization, RecruitmentMaintenanceCommand, RecruitmentMaintenanceResult, QuestionnaireManagement, @@ -179,13 +180,14 @@ export interface RecruitmentOperations { readonly readInterviewStaffing: ( personId: PersonId, ) => Effect.Effect; + /** Resolves current authority for one command, with its locks, on the caller's transaction. */ readonly authorizeMaintenance: ( command: RecruitmentMaintenanceCommand, personId: PersonId, - ) => Effect.Effect; + ) => Effect.Effect; + /** Runs the authorized command in the transaction that authorized it. */ readonly maintainRecruitment: ( - command: RecruitmentMaintenanceCommand, - personId: PersonId, + authorization: RecruitmentMaintenanceAuthorization, ) => Effect.Effect; readonly readAssignmentBoard: ( query: RecruitmentAssignmentBoardQuery, @@ -238,5 +240,5 @@ export interface RecruitmentOperations { } export class Recruitment extends Context.Service()( - "@vektorprogrammet/domain/Recruitment", + "@vektorprogrammet/domain/recruitment/service/Recruitment", ) {} diff --git a/packages/domain/src/report.ts b/packages/domain/src/report.ts index 971d76db7..2d37f6a96 100644 --- a/packages/domain/src/report.ts +++ b/packages/domain/src/report.ts @@ -1,4 +1,5 @@ import { Record, Result, Array } from "effect"; +import { dual } from "effect/Function"; import type { Dataset, DatasetInputSummary } from "./data.js"; import { REASON_CODES, @@ -157,5 +158,11 @@ export const renderMarkdown = (report: MachineReport): string => { return lines.join("\n"); }; -export const reportForDataset = (dataset: Dataset, result: SDep2TeamResult): MachineReport => - createMachineReport({ ...result, input: dataset.input }); +export const reportForDataset: { + (result: SDep2TeamResult): (dataset: Dataset) => MachineReport; + (dataset: Dataset, result: SDep2TeamResult): MachineReport; +} = dual( + 2, + (dataset: Dataset, result: SDep2TeamResult): MachineReport => + createMachineReport({ ...result, input: dataset.input }), +); diff --git a/packages/domain/src/runtime-services.ts b/packages/domain/src/runtime-services.ts index 3de82ad0b..22b61e423 100644 --- a/packages/domain/src/runtime-services.ts +++ b/packages/domain/src/runtime-services.ts @@ -4,6 +4,7 @@ * @since 0.1.0 */ import { Context, Effect, type PlatformError } from "effect"; +import { dual } from "effect/Function"; export interface DomainFileSystemOperations { readonly readTextFile: (path: string) => Effect.Effect; @@ -21,7 +22,7 @@ export interface DomainFileSystemOperations { export class DomainFileSystem extends Context.Service< DomainFileSystem, DomainFileSystemOperations ->()("@vektorprogrammet/domain/DomainFileSystem") {} +>()("@vektorprogrammet/domain/runtime-services/DomainFileSystem") {} export interface DomainProcessOperations { readonly writeStandardOutput: (text: string) => Effect.Effect; @@ -29,7 +30,7 @@ export interface DomainProcessOperations { } export class DomainProcess extends Context.Service()( - "@vektorprogrammet/domain/DomainProcess", + "@vektorprogrammet/domain/runtime-services/DomainProcess", ) {} export const readTextFile = ( @@ -37,17 +38,31 @@ export const readTextFile = ( ): Effect.Effect => DomainFileSystem.use((fileSystem) => fileSystem.readTextFile(path)); -export const joinPath = ( - directory: string, - file: string, -): Effect.Effect => - DomainFileSystem.use((fileSystem) => Effect.succeed(fileSystem.joinPath(directory, file))); +export const joinPath: { + (file: string): (directory: string) => Effect.Effect; + (directory: string, file: string): Effect.Effect; +} = dual( + 2, + (directory: string, file: string): Effect.Effect => + DomainFileSystem.use((fileSystem) => Effect.succeed(fileSystem.joinPath(directory, file))), +); -export const writeTextFile = ( - path: string, - contents: string, -): Effect.Effect => - DomainFileSystem.use((fileSystem) => fileSystem.writeTextFile(path, contents)); +export const writeTextFile: { + ( + contents: string, + ): (path: string) => Effect.Effect; + ( + path: string, + contents: string, + ): Effect.Effect; +} = dual( + 2, + ( + path: string, + contents: string, + ): Effect.Effect => + DomainFileSystem.use((fileSystem) => fileSystem.writeTextFile(path, contents)), +); export const makeTempDirectory = ( prefix: string, diff --git a/packages/domain/src/schema.ts b/packages/domain/src/schema.ts index d542e0992..0ae86d29a 100644 --- a/packages/domain/src/schema.ts +++ b/packages/domain/src/schema.ts @@ -7,6 +7,7 @@ */ import { Data, flow, Match, Predicate, Result, Schema, SchemaAST, SchemaIssue } from "effect"; +import { dual } from "effect/Function"; export type SchemaFailureCode = | "ROW_NOT_OBJECT" @@ -75,8 +76,8 @@ type IssueNode = { const astForIssue = ( issue: SchemaIssue.Issue, fallback: SchemaAST.AST | undefined, -): SchemaAST.AST | undefined => { - return Match.value(issue).pipe( +): SchemaAST.AST | undefined => + Match.value(issue).pipe( Match.withReturnType(), Match.tag( "Composite", @@ -85,15 +86,10 @@ const astForIssue = ( "InvalidType", "UnexpectedKey", "OneOf", - (issue) => { - return issue.ast; - }, + (issue) => issue.ast, ), - Match.orElse(() => { - return fallback; - }), + Match.orElse(() => fallback), ); -}; const collectIssueNodes = ( issue: SchemaIssue.Issue, @@ -137,7 +133,7 @@ const inspectAstPrimitives = ( Match.value(ast).pipe( Match.tag("Number", (ast) => { - if (ast.checks?.some((check) => Predicate.isTagged(check, "Filter"))) + if (ast.checks?.some((check) => Predicate.isTagged(check, "Filter")) === true) primitives.hasIntegerNumber = true; }), Match.tag("Null", () => { @@ -303,21 +299,22 @@ const decodeWith = (schema: Schema.ConstraintDecoder) => export const decodeDepartment = flow( decodeWith(DepartmentRowSchema), - (decoded): DecodeResult => { - return decoded.ok ? { ok: true, value: { id: decoded.value.id } } : decoded; - }, + (decoded): DecodeResult => + decoded.ok ? { ok: true, value: { id: decoded.value.id } } : decoded, ); -export const decodeTeam = flow(decodeWith(TeamRowSchema), (decoded): DecodeResult => { - return decoded.ok - ? { ok: true, value: { id: decoded.value.id, departmentId: decoded.value.departmentId } } - : decoded; -}); +export const decodeTeam = flow( + decodeWith(TeamRowSchema), + (decoded): DecodeResult => + decoded.ok + ? { ok: true, value: { id: decoded.value.id, departmentId: decoded.value.departmentId } } + : decoded, +); export const decodeTeamMembership = flow( decodeWith(TeamMembershipRowSchema), - (decoded): DecodeResult => { - return decoded.ok + (decoded): DecodeResult => + decoded.ok ? { ok: true, value: { @@ -326,21 +323,19 @@ export const decodeTeamMembership = flow( teamId: decoded.value.teamId, }, } - : decoded; - }, + : decoded, ); export const decodeGlobalContainer = flow( decodeWith(GlobalContainerRowSchema), - (decoded): DecodeResult => { - return decoded.ok ? { ok: true, value: { id: decoded.value.id } } : decoded; - }, + (decoded): DecodeResult => + decoded.ok ? { ok: true, value: { id: decoded.value.id } } : decoded, ); export const decodeGlobalMembership = flow( decodeWith(GlobalMembershipRowSchema), - (decoded): DecodeResult => { - return decoded.ok + (decoded): DecodeResult => + decoded.ok ? { ok: true, value: { @@ -349,33 +344,53 @@ export const decodeGlobalMembership = flow( boardId: decoded.value.boardId, }, } - : decoded; - }, + : decoded, ); -export const decodeRows = ( - value: Schema.Json, - file: string, - decoder: (value: Schema.Json) => DecodeResult, -): Result.Result< - { readonly rows: ReadonlyArray; readonly failures: ReadonlyArray }, - SchemaInputError -> => { - if (!Array.isArray(value)) return Result.fail(new SchemaInputError({ file })); - const rowsInput = value; - - const rows: A[] = []; - const failures: DecodeFailure[] = []; - - for (let index = 0; index < rowsInput.length; index += 1) { - const decoded = decoder(rowsInput[index]); - - if (decoded.ok) { - rows.push(decoded.value); - } else { - failures.push({ file, index, ...decoded.failure }); +export const decodeRows: { + ( + file: string, + decoder: (value: Schema.Json) => DecodeResult, + ): ( + value: Schema.Json, + ) => Result.Result< + { readonly rows: ReadonlyArray; readonly failures: ReadonlyArray }, + SchemaInputError + >; + ( + value: Schema.Json, + file: string, + decoder: (value: Schema.Json) => DecodeResult, + ): Result.Result< + { readonly rows: ReadonlyArray; readonly failures: ReadonlyArray }, + SchemaInputError + >; +} = dual( + 3, + ( + value: Schema.Json, + file: string, + decoder: (value: Schema.Json) => DecodeResult, + ): Result.Result< + { readonly rows: ReadonlyArray; readonly failures: ReadonlyArray }, + SchemaInputError + > => { + if (!Array.isArray(value)) return Result.fail(new SchemaInputError({ file })); + const rowsInput = value; + + const rows: A[] = []; + const failures: DecodeFailure[] = []; + + for (let index = 0; index < rowsInput.length; index += 1) { + const decoded = decoder(rowsInput[index]); + + if (decoded.ok) { + rows.push(decoded.value); + } else { + failures.push({ file, index, ...decoded.failure }); + } } - } - return Result.succeed({ rows, failures }); -}; + return Result.succeed({ rows, failures }); + }, +); diff --git a/packages/domain/src/schools/AGENTS.md b/packages/domain/src/schools/AGENTS.md index cc2f7bdd6..e75ec822e 100644 --- a/packages/domain/src/schools/AGENTS.md +++ b/packages/domain/src/schools/AGENTS.md @@ -7,7 +7,6 @@ The domain layer holds business values, state transitions, failures, capability The Schools context in other folders: -- [apps/backend/src/schools](../../../../apps/backend/src/schools/AGENTS.md) - [apps/dashboard/app/foldkit/schools](../../../../apps/dashboard/app/foldkit/schools/AGENTS.md) - [packages/database/src/schools](../../../database/src/schools/AGENTS.md) diff --git a/packages/domain/src/schools/administration.ts b/packages/domain/src/schools/administration.ts index 7f767421d..c8240cf68 100644 --- a/packages/domain/src/schools/administration.ts +++ b/packages/domain/src/schools/administration.ts @@ -1,8 +1,15 @@ import { Schema } from "effect"; +import { dual } from "effect/Function"; import { reachedDepartments, ReachedDepartments } from "../authz/reach.js"; import { DepartmentId, PersonId, SemesterId } from "../organization/schema.js"; import type { OrganizationPersonAuthority } from "../organization/authority.js"; -import { School, SchoolCapacityPlan, SchoolId, SchoolDirectoryDepartmentSchema } from "./schema.js"; +import { + School, + SchoolCapacityPlan, + SchoolId, + SchoolDirectoryDepartmentSchema, + type SchoolJson, +} from "./schema.js"; const Reason = Schema.String.check( Schema.makeFilter((value) => value.trim().length > 0), @@ -64,6 +71,24 @@ export const SchoolCommand = Schema.TaggedUnion({ export type SchoolCommand = typeof SchoolCommand.Type; +/** Type-only brand. Only the Schools adapter that resolves current authority builds this evidence. */ +declare const SchoolCommandAuthorizationBrand: unique symbol; + +/** + * Proof that a person may run one school command, resolved with its locks inside the command's + * transaction. The command runs from it: `executeCommand` takes no other command or person, and + * resolves no authority again. Valid only in the transaction that resolved it. + */ +export interface SchoolCommandAuthorization { + readonly [SchoolCommandAuthorizationBrand]: "SchoolCommandAuthorization"; + readonly personId: PersonId; + readonly command: SchoolCommand; + /** The locked school that the command changes; null for `CreateSchool`. */ + readonly school: SchoolJson | null; + /** The departments whose administration authorized the command. */ + readonly departments: ReadonlyArray; +} + export const SchoolCommandResult = Schema.Struct({ schoolId: SchoolId, capacityId: Schema.NullOr(SchoolCapacityPlan.fields.capacityId), @@ -128,15 +153,18 @@ export class SchoolCommandFailure extends Schema.TaggedError, -): boolean => { - const reached = reachedDepartments(authority, "schools.administer"); - - return ( - ReachedDepartments.$is("All")(reached) || - (departments.length > 0 && - departments.every((department) => reached.departmentIds.includes(department))) - ); -}; +export const canManageSchoolDepartments: { + (departments: ReadonlyArray): (authority: OrganizationPersonAuthority) => boolean; + (authority: OrganizationPersonAuthority, departments: ReadonlyArray): boolean; +} = dual( + 2, + (authority: OrganizationPersonAuthority, departments: ReadonlyArray): boolean => { + const reached = reachedDepartments(authority, "schools.administer"); + + return ( + ReachedDepartments.$is("All")(reached) || + (departments.length > 0 && + departments.every((department) => reached.departmentIds.includes(department))) + ); + }, +); diff --git a/packages/domain/src/schools/service.ts b/packages/domain/src/schools/service.ts index 6d7923fc8..7380642bb 100644 --- a/packages/domain/src/schools/service.ts +++ b/packages/domain/src/schools/service.ts @@ -4,6 +4,7 @@ import type { SchoolDirectory, SchoolDirectoryListInput } from "./schema.js"; import type { PersonId } from "../organization/schema.js"; import type { SchoolCommand, + SchoolCommandAuthorization, SchoolCommandResult, SchoolManagement, SchoolCommandFailure, @@ -13,13 +14,14 @@ export interface SchoolsOperations { readonly readManagement: ( personId: PersonId, ) => Effect.Effect; + /** Resolves current authority for one command, with its locks, on the caller's transaction. */ readonly authorizeCommand: ( command: SchoolCommand, personId: PersonId, - ) => Effect.Effect; + ) => Effect.Effect; + /** Runs the authorized command in the transaction that authorized it. */ readonly executeCommand: ( - command: SchoolCommand, - personId: PersonId, + authorization: SchoolCommandAuthorization, ) => Effect.Effect; readonly listDirectory: ( input: SchoolDirectoryListInput, @@ -27,5 +29,5 @@ export interface SchoolsOperations { } export class Schools extends Context.Service()( - "@vektorprogrammet/domain/Schools", + "@vektorprogrammet/domain/schools/service/Schools", ) {} diff --git a/packages/domain/src/social-events/authority.ts b/packages/domain/src/social-events/authority.ts index 7f33dc48e..9ea566fa8 100644 --- a/packages/domain/src/social-events/authority.ts +++ b/packages/domain/src/social-events/authority.ts @@ -1,4 +1,5 @@ -import { Result, Array } from "effect"; +import { Array, Data, Predicate, Result } from "effect"; +import { dual } from "effect/Function"; import { PrincipalSchema, AuthorityRef, @@ -15,7 +16,7 @@ import { decodeGrant, } from "../authz/access.js"; import type { OrganizationPersonAuthority } from "../organization/authority.js"; -import type { DepartmentId } from "../organization/schema.js"; +import type { DepartmentId, PersonId } from "../organization/schema.js"; const authorityVersion = (authority: OrganizationPersonAuthority) => AuthorityVersion.make(`organization:${authority.evaluatedAt}`); @@ -32,16 +33,27 @@ export const socialEventScopeAccessContext = ( }); /** Canonical resolver context shared by `social-events.list` and `.create`. */ -export const socialEventDepartmentAccessContext = ( - authority: OrganizationPersonAuthority, - departmentId: DepartmentId, -): CanonicalResourceContext> => ({ - domainId: SOCIAL_EVENTS_DOMAIN_ID, - departmentId, - resource: null, - facts: {}, - authorityVersion: authorityVersion(authority), -}); +export const socialEventDepartmentAccessContext: { + ( + departmentId: DepartmentId, + ): (authority: OrganizationPersonAuthority) => CanonicalResourceContext>; + ( + authority: OrganizationPersonAuthority, + departmentId: DepartmentId, + ): CanonicalResourceContext>; +} = dual( + 2, + ( + authority: OrganizationPersonAuthority, + departmentId: DepartmentId, + ): CanonicalResourceContext> => ({ + domainId: SOCIAL_EVENTS_DOMAIN_ID, + departmentId, + resource: null, + facts: {}, + authorityVersion: authorityVersion(authority), + }), +); const scopeIdentity = (scope: Scope): string => JSON.stringify(scope); @@ -119,10 +131,86 @@ export const deriveSocialEventCandidateGrants = ( * evaluator. Keeping capability selection here prevents a department fact * from becoming a grant for an unrelated social-event operation. */ -export const socialEventCandidateGrantScopes = ( - authority: OrganizationPersonAuthority, - capability: CapabilityTypeId, -): ReadonlyArray => - Array.filterMap(deriveSocialEventCandidateGrants(authority), (grant) => - grant.capability.type === capability ? Result.succeed(grant.scope) : Result.failVoid, - ); +export const socialEventCandidateGrantScopes: { + (capability: CapabilityTypeId): (authority: OrganizationPersonAuthority) => ReadonlyArray; + (authority: OrganizationPersonAuthority, capability: CapabilityTypeId): ReadonlyArray; +} = dual( + 2, + (authority: OrganizationPersonAuthority, capability: CapabilityTypeId): ReadonlyArray => + Array.filterMap(deriveSocialEventCandidateGrants(authority), (grant) => + grant.capability.type === capability ? Result.succeed(grant.scope) : Result.failVoid, + ), +); + +/** Type-only brand. Only {@link requireSocialEventCreation} builds this evidence. */ +declare const SocialEventCreationBrand: unique symbol; + +/** Proof that a person may create a social event in one department at the authority's instant. */ +export interface SocialEventCreation { + readonly [SocialEventCreationBrand]: "SocialEventCreation"; + readonly personId: PersonId; + readonly departmentId: DepartmentId; +} + +/** The person holds no create grant that covers the department. */ +export class SocialEventCreationDenied extends Data.TaggedError("SocialEventCreationDenied")<{ + readonly personId: PersonId; + readonly departmentId: DepartmentId; +}> {} + +/** + * Checks that a resolved authority may create a social event in one department, and returns the + * evidence that creation requires. + * + * @remarks + * It is the only constructor of {@link SocialEventCreation} and decides with the candidate grants + * that the create AccessSpec evaluates: an active global administrator creates everywhere, and an + * active appointment in the department creates there. + * + * @sideEffects none + * + * @example + * ```ts + * const creator = yield* Effect.fromResult(requireSocialEventCreation(authority, departmentId)); + * yield* events.create(creator, { commandId, occurredAt, eventId, request }); + * ``` + * + * @avoid Passing the creator's person id to `create`: the command then trusts that some caller + * checked the department. Require the evidence, which names the department it covers. + */ +export const requireSocialEventCreation: { + ( + /** The department of the event. */ + departmentId: DepartmentId, + ): ( + /** The person's authority, resolved from current facts inside the command transaction. */ + authority: OrganizationPersonAuthority, + ) => /** Evidence for the department, or the typed denial. */ + Result.Result; + ( + /** The person's authority, resolved from current facts inside the command transaction. */ + authority: OrganizationPersonAuthority, + /** The department of the event. */ + departmentId: DepartmentId, + ): /** Evidence for the department, or the typed denial. */ + Result.Result; +} = dual( + 2, + ( + /** The person's authority, resolved from current facts inside the command transaction. */ + authority: OrganizationPersonAuthority, + /** The department of the event. */ + departmentId: DepartmentId, + ): /** Evidence for the department, or the typed denial. */ + Result.Result => + socialEventCandidateGrantScopes(authority, SOCIAL_EVENTS_CREATE_CAPABILITY).some( + (scope) => + Predicate.isTagged(scope, "Global") || + (Predicate.isTagged(scope, "Department") && scope.departmentId === departmentId), + ) + ? Result.succeed( + // SAFETY: the one constructor of the evidence brand; the grant check above is what it proves. + { personId: authority.personId, departmentId } as SocialEventCreation, + ) + : Result.fail(new SocialEventCreationDenied({ personId: authority.personId, departmentId })), +); diff --git a/packages/domain/src/social-events/service.ts b/packages/domain/src/social-events/service.ts index fdad88e62..7d32b8e4d 100644 --- a/packages/domain/src/social-events/service.ts +++ b/packages/domain/src/social-events/service.ts @@ -2,6 +2,7 @@ import { Context, Effect } from "effect"; import type { OrganizationPersonAuthority } from "../organization/authority.js"; import type { DepartmentId, SemesterId } from "../organization/schema.js"; import type { SocialEventFailure } from "./errors.js"; +import type { SocialEventCreation } from "./authority.js"; import type { CreateSocialEventCommand, SocialEventListResource, @@ -36,11 +37,13 @@ export interface SocialEventsOperations { readonly validateScope: ( scope: SocialEventScope, ) => Effect.Effect; + /** Creates the event in the creator's department; a request for another department is a defect. */ readonly create: ( - command: CreateSocialEventCommand, + creator: SocialEventCreation, + command: Omit, ) => Effect.Effect; } export class SocialEvents extends Context.Service()( - "@vektorprogrammet/domain/SocialEvents", + "@vektorprogrammet/domain/social-events/service/SocialEvents", ) {} diff --git a/packages/domain/src/team-application/notification.ts b/packages/domain/src/team-application/notification.ts index 02445a701..a83b719a4 100644 --- a/packages/domain/src/team-application/notification.ts +++ b/packages/domain/src/team-application/notification.ts @@ -1,4 +1,5 @@ import { Data, Schema } from "effect"; +import { dual } from "effect/Function"; import { ContactEmail } from "../contact/schema.js"; import { TeamId } from "../organization/schema.js"; import { TeamApplicationCommandId, TeamApplicationId, type TeamApplication } from "./schema.js"; @@ -42,74 +43,91 @@ export interface TeamApplicationNotification { * Renders the applicant receipt and the team notification. The receipt replies to * the team mailbox; the team notification replies to the applicant. */ -export const teamApplicationNotifications = ( - commandId: TeamApplicationCommandId, - application: TeamApplication, - teamName: string, - teamMailbox: typeof ContactEmail.Type, -): readonly [TeamApplicationNotification, TeamApplicationNotification] => { - const team = teamName.replace(/\p{Cc}+/gu, " "); +export const teamApplicationNotifications: { + ( + application: TeamApplication, + teamName: string, + teamMailbox: typeof ContactEmail.Type, + ): ( + commandId: TeamApplicationCommandId, + ) => readonly [TeamApplicationNotification, TeamApplicationNotification]; + ( + commandId: TeamApplicationCommandId, + application: TeamApplication, + teamName: string, + teamMailbox: typeof ContactEmail.Type, + ): readonly [TeamApplicationNotification, TeamApplicationNotification]; +} = dual( + 4, + ( + commandId: TeamApplicationCommandId, + application: TeamApplication, + teamName: string, + teamMailbox: typeof ContactEmail.Type, + ): readonly [TeamApplicationNotification, TeamApplicationNotification] => { + const team = teamName.replace(/\p{Cc}+/gu, " "); - const identity = { - commandId, - teamId: application.teamId, - applicationId: application.applicationId, - }; + const identity = { + commandId, + teamId: application.teamId, + applicationId: application.applicationId, + }; - const receiptId = `${commandId}:SendTeamApplicationReceipt`; - const notificationId = `${commandId}:NotifyTeamOfApplication`; + const receiptId = `${commandId}:SendTeamApplicationReceipt`; + const notificationId = `${commandId}:NotifyTeamOfApplication`; - return [ - { - request: TeamApplicationOutboxRequest.cases.SendTeamApplicationReceipt.make({ - ...identity, - effectId: receiptId, - }), - envelope: { - deliveryId: receiptId, - recipient: application.email, - replyTo: teamMailbox, - subject: `Søknad til ${team} mottatt`, - text: [ - "Vi har mottatt søknaden din på vektorprogrammet.no.", - "Leder i teamet vil ta kontakt med deg snart.", - "", - "Vi gleder oss til å møte deg!", - `Vennlig hilsen ${team}, Vektorprogrammet`, - ].join("\n"), + return [ + { + request: TeamApplicationOutboxRequest.cases.SendTeamApplicationReceipt.make({ + ...identity, + effectId: receiptId, + }), + envelope: { + deliveryId: receiptId, + recipient: application.email, + replyTo: teamMailbox, + subject: `Søknad til ${team} mottatt`, + text: [ + "Vi har mottatt søknaden din på vektorprogrammet.no.", + "Leder i teamet vil ta kontakt med deg snart.", + "", + "Vi gleder oss til å møte deg!", + `Vennlig hilsen ${team}, Vektorprogrammet`, + ].join("\n"), + }, }, - }, - { - request: TeamApplicationOutboxRequest.cases.NotifyTeamOfApplication.make({ - ...identity, - effectId: notificationId, - }), - envelope: { - deliveryId: notificationId, - recipient: teamMailbox, - replyTo: application.email, - subject: `Ny søker til ${team}`, - text: [ - "Dere har fått en ny søker.", - "", - `Navn: ${application.name}`, - `E-post: ${application.email}`, - `Telefon: ${application.phone}`, - `Studieår: ${application.yearOfStudy}`, - `Linje: ${application.fieldOfStudy}`, - "", - "Om søkeren:", - application.biography, - "", - "Motivasjon:", - application.motivation, - "", - "Se søknaden i kontrollpanelet.", - ].join("\n"), + { + request: TeamApplicationOutboxRequest.cases.NotifyTeamOfApplication.make({ + ...identity, + effectId: notificationId, + }), + envelope: { + deliveryId: notificationId, + recipient: teamMailbox, + replyTo: application.email, + subject: `Ny søker til ${team}`, + text: [ + "Dere har fått en ny søker.", + "", + `Navn: ${application.name}`, + `E-post: ${application.email}`, + `Telefon: ${application.phone}`, + `Studieår: ${application.yearOfStudy}`, + `Linje: ${application.fieldOfStudy}`, + "", + "Om søkeren:", + application.biography, + "", + "Motivasjon:", + application.motivation, + "", + "Se søknaden i kontrollpanelet.", + ].join("\n"), + }, }, - }, - ]; -}; + ]; + }, +); export type TeamApplicationOutboxDelivery = Data.TaggedEnum<{ /** No notification was taken within the wait. */ diff --git a/packages/domain/src/team-application/pagination.ts b/packages/domain/src/team-application/pagination.ts index 694455020..303cb7bdd 100644 --- a/packages/domain/src/team-application/pagination.ts +++ b/packages/domain/src/team-application/pagination.ts @@ -2,6 +2,7 @@ import { Effect, Encoding, Result, Schema } from "effect"; import { isRfc3339Instant } from "../time.js"; import { TeamApplicationInvalidCursor } from "./errors.js"; import { TeamApplicationId } from "./schema.js"; +import { dual } from "effect/Function"; /** Fixed staff page size. */ export const TEAM_APPLICATION_PAGE_SIZE = 50; @@ -56,21 +57,32 @@ export const decodeTeamApplicationCursor = ( const [, timestamp, applicationId] = yield* Schema.decodeEffect(CursorTuple)(text); return { timestamp, applicationId }; - }).pipe(Effect.mapError(() => new TeamApplicationInvalidCursor())); + }).pipe(Effect.mapError(() => TeamApplicationInvalidCursor.make({}))); /** Keeps one page and encodes the last kept row when a further row was read. */ -export const teamApplicationPage = ( - rows: ReadonlyArray, - position: (row: A) => TeamApplicationCursorPosition, -): TeamApplicationPage => { - if (rows.length <= TEAM_APPLICATION_PAGE_SIZE) return { items: rows }; +export const teamApplicationPage: { + ( + position: (row: A) => TeamApplicationCursorPosition, + ): (rows: ReadonlyArray) => TeamApplicationPage; + ( + rows: ReadonlyArray, + position: (row: A) => TeamApplicationCursorPosition, + ): TeamApplicationPage; +} = dual( + 2, + ( + rows: ReadonlyArray, + position: (row: A) => TeamApplicationCursorPosition, + ): TeamApplicationPage => { + if (rows.length <= TEAM_APPLICATION_PAGE_SIZE) return { items: rows }; - const last = position(rows[TEAM_APPLICATION_PAGE_SIZE - 1]!); + const last = position(rows[TEAM_APPLICATION_PAGE_SIZE - 1]!); - return { - items: rows.slice(0, TEAM_APPLICATION_PAGE_SIZE), - nextCursor: Encoding.encodeBase64( - JSON.stringify(["team-application-v1", last.timestamp, last.applicationId]), - ), - }; -}; + return { + items: rows.slice(0, TEAM_APPLICATION_PAGE_SIZE), + nextCursor: Encoding.encodeBase64( + JSON.stringify(["team-application-v1", last.timestamp, last.applicationId]), + ), + }; + }, +); diff --git a/packages/domain/src/team-application/policy.ts b/packages/domain/src/team-application/policy.ts index 93f9b026a..3196c84fb 100644 --- a/packages/domain/src/team-application/policy.ts +++ b/packages/domain/src/team-application/policy.ts @@ -1,4 +1,5 @@ import { Data, DateTime, Predicate, Schema } from "effect"; +import { dual } from "effect/Function"; import { allow, deny, type Decision } from "../authz/decision.js"; import { leadsUnit } from "../authz/reach.js"; import { ContactEmail } from "../contact/schema.js"; @@ -20,10 +21,10 @@ export const TeamApplicationIntakeState = Data.taggedEnum { +export const evaluateTeamApplicationIntake: { + (now: DateTime.Utc): (facts: TeamApplicationIntakeFacts) => TeamApplicationIntakeState; + (facts: TeamApplicationIntakeFacts, now: DateTime.Utc): TeamApplicationIntakeState; +} = dual(2, (facts: TeamApplicationIntakeFacts, now: DateTime.Utc): TeamApplicationIntakeState => { const mailbox = facts.teamEmail ?? facts.departmentEmail; return facts.teamActive && @@ -33,13 +34,15 @@ export const evaluateTeamApplicationIntake = ( Schema.is(ContactEmail)(mailbox) ? TeamApplicationIntakeState.Open({ mailbox }) : TeamApplicationIntakeState.Closed(); -}; +}); /** Public reads, staff reads, and submission all derive `open` from one evaluation. */ -export const isTeamApplicationIntakeOpen = ( - facts: TeamApplicationIntakeFacts, - now: DateTime.Utc, -): boolean => Predicate.isTagged(evaluateTeamApplicationIntake(facts, now), "Open"); +export const isTeamApplicationIntakeOpen: { + (now: DateTime.Utc): (facts: TeamApplicationIntakeFacts) => boolean; + (facts: TeamApplicationIntakeFacts, now: DateTime.Utc): boolean; +} = dual(2, (facts: TeamApplicationIntakeFacts, now: DateTime.Utc): boolean => + Predicate.isTagged(evaluateTeamApplicationIntake(facts, now), "Open"), +); /** * Maps the Organization projection to one team's staff actor. The unit's current leader @@ -47,23 +50,26 @@ export const isTeamApplicationIntakeOpen = ( * already excludes ended, suspended, and inactive-team or inactive-department memberships. * Global administration grants no implicit access. */ -export const mapOrganizationAuthorityToTeamApplicationActor = ( - authority: OrganizationPersonAuthority, - teamId: TeamId, -): Decision => { - if (leadsUnit(authority, teamId)) { - return allow( - TeamApplicationActor.cases.TeamLeader.make({ personId: authority.personId, teamId }), - ); - } +export const mapOrganizationAuthorityToTeamApplicationActor: { + (teamId: TeamId): (authority: OrganizationPersonAuthority) => Decision; + (authority: OrganizationPersonAuthority, teamId: TeamId): Decision; +} = dual( + 2, + (authority: OrganizationPersonAuthority, teamId: TeamId): Decision => { + if (leadsUnit(authority, teamId)) { + return allow( + TeamApplicationActor.cases.TeamLeader.make({ personId: authority.personId, teamId }), + ); + } - const memberships = authority.memberships.filter((membership) => membership.teamId === teamId); + const memberships = authority.memberships.filter((membership) => membership.teamId === teamId); - if (memberships.some((membership) => membership.active)) { - return allow( - TeamApplicationActor.cases.TeamMember.make({ personId: authority.personId, teamId }), - ); - } + if (memberships.some((membership) => membership.active)) { + return allow( + TeamApplicationActor.cases.TeamMember.make({ personId: authority.personId, teamId }), + ); + } - return deny(memberships.length > 0 ? "AuthorityInactive" : "NotInScope"); -}; + return deny(memberships.length > 0 ? "AuthorityInactive" : "NotInScope"); + }, +); diff --git a/packages/domain/src/team-application/service.ts b/packages/domain/src/team-application/service.ts index 644d72475..89068daef 100644 --- a/packages/domain/src/team-application/service.ts +++ b/packages/domain/src/team-application/service.ts @@ -43,6 +43,31 @@ export type TeamApplicationAction = Data.TaggedEnum<{ export const TeamApplicationAction = Data.taggedEnum(); +/** Type-only brand. Only the TeamApplications adapter that resolves current authority builds this. */ +declare const TeamApplicationAuthorizationBrand: unique symbol; + +/** + * Proof that a principal may take one staff action, resolved with its row locks inside the + * action's transaction. A change command runs from it: it takes its application or team from + * `action` and resolves no authority again. Valid only in the transaction that resolved it. + */ +export interface TeamApplicationAuthorization { + readonly [TeamApplicationAuthorizationBrand]: A["_tag"]; + readonly principal: TeamApplicationPrincipal; + readonly action: A; + readonly actor: TeamApplicationActor; +} + +type DeleteTeamApplicationAction = Extract< + TeamApplicationAction, + { readonly _tag: "DeleteTeamApplication" } +>; + +type ReviseTeamApplicationIntakeAction = Extract< + TeamApplicationAction, + { readonly _tag: "ReviseTeamApplicationIntake" } +>; + export interface TeamApplicationSubmission { readonly confirmation: TeamApplicationConfirmation; readonly replayed: boolean; @@ -95,11 +120,11 @@ export interface TeamApplicationsOperations { * Change actions lock the target row first. The result grants nothing beyond * the caller's transaction. */ - readonly authorize: ( + readonly authorize: ( principal: TeamApplicationPrincipal, - action: TeamApplicationAction, + action: A, ) => Effect.Effect< - TeamApplicationActor, + TeamApplicationAuthorization, TeamApplicationAccessDenied | TeamApplicationNotFound | TeamApplicationPersistenceError >; readonly listApplications: ( @@ -116,8 +141,8 @@ export interface TeamApplicationsOperations { * clears every undelivered notification envelope for it. */ readonly deleteApplication: ( - command: DeleteTeamApplicationCommand, - principal: TeamApplicationPrincipal, + authorization: TeamApplicationAuthorization, + command: Pick, ) => Effect.Effect; /** * Leader-only. Locks the team, supplies the fresh intake to the transport @@ -125,8 +150,8 @@ export interface TeamApplicationsOperations { * authority and must not write business state. */ readonly reviseIntake: ( - command: ReviseTeamApplicationIntakeCommand, - principal: TeamApplicationPrincipal, + authorization: TeamApplicationAuthorization, + command: Omit, checkPrecondition: (current: TeamApplicationIntake) => Effect.Effect, ) => Effect.Effect; /** @@ -145,4 +170,4 @@ export interface TeamApplicationsOperations { export class TeamApplications extends Context.Service< TeamApplications, TeamApplicationsOperations ->()("@vektorprogrammet/domain/team-application/TeamApplications") {} +>()("@vektorprogrammet/domain/team-application/service/TeamApplications") {} diff --git a/packages/domain/src/time.ts b/packages/domain/src/time.ts index cf0e1609c..d48279763 100644 --- a/packages/domain/src/time.ts +++ b/packages/domain/src/time.ts @@ -1,4 +1,5 @@ import { DateTime, Option, Schema, SchemaTransformation } from "effect"; +import { dual } from "effect/Function"; const Rfc3339InstantPattern = /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(?:\.\d{1,3})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/; @@ -36,12 +37,15 @@ export const isRfc3339Instant = (value: string): boolean => { ); }; -export const compareRfc3339Instants = (left: string, right: string): -1 | 0 | 1 => { +export const compareRfc3339Instants: { + (right: string): (left: string) => -1 | 0 | 1; + (left: string, right: string): -1 | 0 | 1; +} = dual(2, (left: string, right: string): -1 | 0 | 1 => { const leftMilliseconds = DateTime.toEpochMillis(DateTime.makeUnsafe(left)); const rightMilliseconds = DateTime.toEpochMillis(DateTime.makeUnsafe(right)); return leftMilliseconds < rightMilliseconds ? -1 : leftMilliseconds > rightMilliseconds ? 1 : 0; -}; +}); export const normalizeRfc3339Instant = (value: string): string => DateTime.formatIso(DateTime.makeUnsafe(value)); diff --git a/packages/domain/src/tutor/d1-proof.ts b/packages/domain/src/tutor/d1-proof.ts index a3ca022da..05a7ce30b 100644 --- a/packages/domain/src/tutor/d1-proof.ts +++ b/packages/domain/src/tutor/d1-proof.ts @@ -20,7 +20,7 @@ export interface TutorD1ProofOperations { } export class TutorD1Proof extends Context.Service()( - "@vektorprogrammet/domain/TutorD1Proof", + "@vektorprogrammet/domain/tutor/d1-proof/TutorD1Proof", ) {} const errorTag = Match.type().pipe( diff --git a/packages/domain/src/tutor/tracer.ts b/packages/domain/src/tutor/tracer.ts index 83c584452..aa3adcf01 100644 --- a/packages/domain/src/tutor/tracer.ts +++ b/packages/domain/src/tutor/tracer.ts @@ -1,4 +1,5 @@ import { Array, Schema, Data, Effect } from "effect"; +import { dual } from "effect/Function"; import { type ConductInterviewV1, decodeConductInterviewV1, @@ -294,93 +295,99 @@ const observationFor = ( descriptor, }); -export const conductInterview = ( - state: TutorState, - input: Schema.Json, -): Effect.Effect => - Effect.gen(function* () { - const command = yield* decodeConductInterviewV1(input); - const commandBytes = canonicalJson(command); - const receipt = state.receipts.find((candidate) => candidate.commandId === command.commandId); - - if (receipt !== undefined) { - if (receipt.commandBytes === commandBytes) { - return ConductInterviewResult.DuplicateResult({ - state, - observation: receipt.observation, - observationBytes: receipt.observationBytes, - }); +export const conductInterview: { + (input: Schema.Json): (state: TutorState) => Effect.Effect; + (state: TutorState, input: Schema.Json): Effect.Effect; +} = dual( + 2, + (state: TutorState, input: Schema.Json): Effect.Effect => + Effect.gen(function* () { + const command = yield* decodeConductInterviewV1(input); + const commandBytes = canonicalJson(command); + const receipt = state.receipts.find((candidate) => candidate.commandId === command.commandId); + + if (receipt !== undefined) { + if (receipt.commandBytes === commandBytes) { + return ConductInterviewResult.DuplicateResult({ + state, + observation: receipt.observation, + observationBytes: receipt.observationBytes, + }); + } + + return yield* new DuplicateCommandConflict({ commandId: command.commandId }); } - return yield* new DuplicateCommandConflict({ commandId: command.commandId }); - } + const folded = yield* foldEvents(state.events); - const folded = yield* foldEvents(state.events); + if (!streamEqual(state.stream, folded.stream)) { + return yield* new StreamMismatch({ detail: "STATE_STREAM" }); + } - if (!streamEqual(state.stream, folded.stream)) { - return yield* new StreamMismatch({ detail: "STATE_STREAM" }); - } + if (!streamEqual(command.stream, folded.stream)) { + return yield* new StreamMismatch({ detail: "COMMAND_STREAM" }); + } - if (!streamEqual(command.stream, folded.stream)) { - return yield* new StreamMismatch({ detail: "COMMAND_STREAM" }); - } + if (command.correlationId !== folded.correlationId) { + return yield* new StreamMismatch({ detail: "COMMAND_CORRELATION" }); + } - if (command.correlationId !== folded.correlationId) { - return yield* new StreamMismatch({ detail: "COMMAND_CORRELATION" }); - } + if (command.expectedVersion !== folded.events.length) { + return yield* new StaleState({ + expectedVersion: command.expectedVersion, + currentVersion: folded.events.length, + }); + } - if (command.expectedVersion !== folded.events.length) { - return yield* new StaleState({ - expectedVersion: command.expectedVersion, - currentVersion: folded.events.length, - }); - } + if (folded.nextEventType === "Terminal") { + return yield* new InvalidTransition({ + reasonCode: "TERMINAL_CONDUCTED", + lawRef: "T-INT-2", + }); + } - if (folded.nextEventType === "Terminal") { - return yield* new InvalidTransition({ reasonCode: "TERMINAL_CONDUCTED", lawRef: "T-INT-2" }); - } + if (folded.nextEventType !== "InterviewConducted") { + return yield* new InvalidTransition({ + reasonCode: "CONDUCT_REQUIRES_ACCEPTED", + lawRef: "T-INT-1", + }); + } - if (folded.nextEventType !== "InterviewConducted") { - return yield* new InvalidTransition({ - reasonCode: "CONDUCT_REQUIRES_ACCEPTED", - lawRef: "T-INT-1", + const event = conductEvent(command, folded); + const nextEvents = [...folded.events, event]; + const nextFolded = yield* foldEvents(nextEvents); + const projection = projectFoldedState(nextFolded); + + const descriptor: Descriptor = { + descriptorVersion: 1, + kind: "InterviewConductedDescriptor", + sourceEventId: event.eventId, + causationId: command.commandId, + correlationId: command.correlationId, + idempotencyKey: `post-commit:${event.eventId}`, + }; + + const observation = observationFor(command, event, projection, descriptor); + const observationBytes = canonicalJson(observation); + + const nextState: TutorState = { + stream: nextFolded.stream, + events: nextFolded.events, + receipts: [ + ...state.receipts, + { + commandId: command.commandId, + commandBytes, + observationBytes, + observation, + }, + ], + }; + + return ConductInterviewResult.AcceptedResult({ + state: nextState, + observation, + observationBytes, }); - } - - const event = conductEvent(command, folded); - const nextEvents = [...folded.events, event]; - const nextFolded = yield* foldEvents(nextEvents); - const projection = projectFoldedState(nextFolded); - - const descriptor: Descriptor = { - descriptorVersion: 1, - kind: "InterviewConductedDescriptor", - sourceEventId: event.eventId, - causationId: command.commandId, - correlationId: command.correlationId, - idempotencyKey: `post-commit:${event.eventId}`, - }; - - const observation = observationFor(command, event, projection, descriptor); - const observationBytes = canonicalJson(observation); - - const nextState: TutorState = { - stream: nextFolded.stream, - events: nextFolded.events, - receipts: [ - ...state.receipts, - { - commandId: command.commandId, - commandBytes, - observationBytes, - observation, - }, - ], - }; - - return ConductInterviewResult.AcceptedResult({ - state: nextState, - observation, - observationBytes, - }); - }); + }), +); diff --git a/packages/http-api/AGENTS.md b/packages/http-api/AGENTS.md deleted file mode 100644 index 00bbdb10b..000000000 --- a/packages/http-api/AGENTS.md +++ /dev/null @@ -1,27 +0,0 @@ -[//]: # "guide: generated from docs/model/contexts.cml, the @construct tags, and package.json exports by just guides write; do not edit" - -# packages/http-api - -HTTP contracts, middleware declarations, and OpenAPI. -Package `@vektorprogrammet/http-api`. - -## Entry points - -| Import | Module | -| ------------------------------------------- | ---------------------------------------------- | -| `@vektorprogrammet/http-api` | [src/index.ts](src/index.ts) | -| `@vektorprogrammet/http-api/http-semantics` | [src/http-semantics.ts](src/http-semantics.ts) | - -## Constructs - -The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../docs/constructs.md) indexes them all. - -- [`problemUnion`](../../docs/constructs/http-problem.md#problemunion) (http-problem): Creates a closed endpoint-specific Problem Details union. -- [`Problem`](../../docs/constructs/http-problem.md#problem) (http-problem): One RFC 9457 failure in an Effect error channel. -- [`isProblem`](../../docs/constructs/http-problem.md#isproblem) (http-problem): Narrows a caught value to a `Problem`, also one that another copy of this module created. -- [`problemBody`](../../docs/constructs/http-problem.md#problembody) (http-problem): The frozen RFC 9457 body: the registry entry, then code, instance, and validation. -- [`makeNativeProblem`](../../docs/constructs/http-problem.md#makenativeproblem) (http-problem): Builds one safe fixed public problem value. - -Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. - -[//]: # "guide: end" diff --git a/packages/http-api/CLAUDE.md b/packages/http-api/CLAUDE.md deleted file mode 100644 index 43c994c2d..000000000 --- a/packages/http-api/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/packages/http-api/scripts/generate-openapi.ts b/packages/http-api/scripts/generate-openapi.ts deleted file mode 100644 index 1e6623326..000000000 --- a/packages/http-api/scripts/generate-openapi.ts +++ /dev/null @@ -1,98 +0,0 @@ -import * as BunFileSystem from "@effect/platform-bun/BunFileSystem"; -import * as BunPath from "@effect/platform-bun/BunPath"; -import { ExternalNativeApi, InternalNativeApi } from "../src/api.js"; -import { NativeApiReleaseVersion } from "../src/release.js"; -import { OpenApi } from "effect/unstable/httpapi"; -import { Array as Arr, Effect, FileSystem, Layer, Path, Predicate, Schema } from "effect"; -import assert from "node:assert/strict"; - -// Writes the ignored OpenAPI projection of ExternalNativeApi and checks its release invariants. -// Turbo runs this `generate` task before type checks and builds; never commit its output. -const methods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const; - -const unexpectedArguments = process.argv.slice(2); - -if (unexpectedArguments.length > 0) { - throw new Error(`unknown arguments: ${unexpectedArguments.join(", ")}`); -} - -const stable = (value: Schema.Json): Schema.Json => { - if (value === null || Predicate.isBoolean(value) || Predicate.isString(value)) return value; - - if (Predicate.isNumber(value)) { - if (!Number.isFinite(value)) throw new Error("OpenAPI contains a non-finite number"); - - return value; - } - - if (Arr.isArray(value)) return value.map(stable); - - return Object.fromEntries( - Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, child]) => [key, stable(child)]), - ); -}; - -const encode = (value: Schema.Json): string => `${JSON.stringify(stable(value), null, 2)}\n`; - -const document = OpenApi.fromApi(ExternalNativeApi); - -assert( - document.openapi === "3.1.0", - `expected OpenAPI 3.1.0, received ${String(document.openapi)}`, -); - -assert( - document.info.version === NativeApiReleaseVersion, - "OpenAPI and NativeApi release versions differ", -); - -const externalOperationIds = Object.entries(document.paths).flatMap(([path, pathItem]) => - methods.flatMap((method) => { - const operation = pathItem[method]; - - if (operation === undefined) return []; - assert(operation.operationId.length > 0, `${method} ${path} has no operationId`); - - return [operation.operationId]; - }), -); - -const internalOperationIds = Object.values(InternalNativeApi.groups).flatMap((group) => - Object.values(group.endpoints).map((endpoint) => `${group.identifier}.${endpoint.identifier}`), -); - -const operationIds = [...externalOperationIds, ...internalOperationIds]; - -assert(new Set(operationIds).size === operationIds.length, "operationId values must be unique"); - -assert( - externalOperationIds.length === - Object.values(ExternalNativeApi.groups).reduce( - (count, group) => count + Object.keys(group.endpoints).length, - 0, - ), - "OpenAPI operation count differs from its source contract", -); - -assert( - internalOperationIds.length === 1, - `expected 1 internal operation, received ${internalOperationIds.length}`, -); - -await Effect.runPromise( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const target = yield* path.fromFileUrl(new URL("../openapi.json", import.meta.url)); - - const json = yield* Schema.decodeUnknownEffect(Schema.Json)(document); - - yield* fileSystem.writeFileString(target, encode(json)); - }).pipe(Effect.provide(Layer.merge(BunFileSystem.layer, BunPath.layer))), -); - -process.stdout.write( - `generated NativeApi ${NativeApiReleaseVersion} OpenAPI: ${externalOperationIds.length} external operations; ${internalOperationIds.length} internal operation excluded\n`, -); diff --git a/packages/http-api/src/admission-outcomes.ts b/packages/http-api/src/admission-outcomes.ts deleted file mode 100644 index eced61a91..000000000 --- a/packages/http-api/src/admission-outcomes.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * HTTP contract for admission outcomes: the recorded outcome of each application, and the - * substitutes on call whom department members contact to arrange cover. - */ -import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; -import { AdmissionPeriodId } from "@vektorprogrammet/domain/admission-period"; -import { - AdmissionOutcomeCommand, - AdmissionOutcomeEntry, - AdmissionOutcomeScope, - AdmissionOutcomeScopes, - OnCallSubstitute, -} from "@vektorprogrammet/domain/admissions"; -import { Schema } from "effect"; -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; -import { annotateAccessSpec, personNativeAccess } from "./access.js"; -import { operationAnnotations, PersonSecurity } from "./common.js"; -import { - ConditionalReadHeaders, - StrongETag, - IdempotencyIfMatchHeaders, - privateReadResponse, - privateConditionalResponses, - entityMutationResponse, - endpointProblemResponses, - problemUnion, -} from "./http-semantics.js"; - -export { AdmissionOutcomeCommand, AdmissionOutcomeScope }; - -export const AdmissionOutcomeResource = Schema.Struct({ - ...AdmissionOutcomeEntry.fields, - etag: StrongETag, -}).annotate({ identifier: "AdmissionOutcomeResource" }); - -const BoardFields = { - ...AdmissionOutcomeScope.fields, - admissionPeriodId: Schema.NullOr(AdmissionPeriodId), -}; - -/** Admission management sees every application; other department members see substitutes on call. */ -export const AdmissionOutcomeBoardResource = Schema.TaggedUnion({ - ReadOnly: { ...BoardFields, substitutes: Schema.Array(OnCallSubstitute) }, - Decide: { ...BoardFields, entries: Schema.Array(AdmissionOutcomeResource) }, -}).annotate({ identifier: "AdmissionOutcomeBoardResource" }); - -const admissionOutcomeProblems = [ - "request.malformed", - "request.too-large", - "precondition.invalid", - "idempotency-key.invalid", - "header.malformed", - "validation.failed", - "scope.invalid", - "authority.denied", - "resource.not-found", - "precondition.required", - "precondition.failed", - "idempotency.in-flight", - "idempotency.digest-conflict", - "idempotency.response-expired", - "transaction.conflict", - "internal.error", - "media-type.unsupported", -] as const; - -export const AdmissionOutcomeProblem = problemUnion( - "AdmissionOutcomeProblem", - admissionOutcomeProblems, -); - -/** A command also answers an unavailable receipt store. */ -export const AdmissionOutcomeCommandProblem = problemUnion("AdmissionOutcomeCommandProblem", [ - ...admissionOutcomeProblems, - "idempotency.unavailable", -]); - -const access = (decide: boolean) => - personNativeAccess({ - capability: decide ? "admissions.outcomes.decide" : "admissions.outcomes.read", - canonicalScopeResolver: "admissions.application-outcomes", - decisionTime: decide ? "Transaction" : "SnapshotRead", - }); - -export const ListAdmissionOutcomeScopesEndpoint = HttpApiEndpoint.get( - "listScopes", - "/api/admission-outcomes/scopes", - { - success: privateReadResponse(AdmissionOutcomeScopes), - error: endpointProblemResponses(AdmissionOutcomeProblem), - }, -) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access(false))) - .annotateMerge( - operationAnnotations( - "Select admission outcome scope", - "Authorized departments and canonical historical semesters.", - ), - ); - -export const ReadAdmissionOutcomesEndpoint = HttpApiEndpoint.get( - "readOutcomes", - "/api/admission-outcomes", - { - query: AdmissionOutcomeScope.fields, - success: privateReadResponse(AdmissionOutcomeBoardResource), - error: endpointProblemResponses(AdmissionOutcomeProblem), - }, -) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access(false))) - .annotateMerge( - operationAnnotations( - "Read admission outcomes", - "Admission management reads every application of the period; other department members read the name and contact of each substitute on call.", - ), - ); - -export const ReadAdmissionOutcomeEndpoint = HttpApiEndpoint.get( - "readOutcome", - "/api/admission-outcomes/:applicationId", - { - params: { applicationId: PublicApplicationIdSchema }, - headers: ConditionalReadHeaders, - success: privateConditionalResponses(AdmissionOutcomeResource), - error: endpointProblemResponses(AdmissionOutcomeProblem), - }, -) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access(false))) - .annotateMerge( - operationAnnotations( - "Select admission outcome", - "Only admission management reads one application's outcome entry.", - ), - ); - -export const RecordAdmissionOutcomeEndpoint = HttpApiEndpoint.post( - "recordOutcome", - "/api/admission-outcomes/:applicationId:record", - { - params: { applicationId: PublicApplicationIdSchema }, - headers: IdempotencyIfMatchHeaders, - payload: AdmissionOutcomeCommand, - success: entityMutationResponse(AdmissionOutcomeResource), - error: endpointProblemResponses(AdmissionOutcomeCommandProblem), - }, -) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access(true))) - .annotateMerge( - operationAnnotations( - "Record admission outcome", - "Records Admitted, Substitute, or Rejected. Recording the current outcome again changes nothing.", - ), - ); - -export class AdmissionOutcomesApi extends HttpApiGroup.make("admissionOutcomes") - .add(ListAdmissionOutcomeScopesEndpoint) - .add(ReadAdmissionOutcomesEndpoint) - .add(ReadAdmissionOutcomeEndpoint) - .add(RecordAdmissionOutcomeEndpoint) - .annotateMerge( - OpenApi.annotations({ - title: "Admission outcomes", - description: - "Recorded admission outcomes and the substitutes on call. People arrange cover outside the system.", - }), - ) {} diff --git a/packages/http-api/src/admissions.ts b/packages/http-api/src/admissions.ts deleted file mode 100644 index 4565ff7e1..000000000 --- a/packages/http-api/src/admissions.ts +++ /dev/null @@ -1,350 +0,0 @@ -/** - * Public HTTP contracts for admission periods and applications. - * - * @since 0.1.0 - */ -import { AdmissionPeriodId } from "@vektorprogrammet/domain/admission-period"; -import { - ApplicantProgressItemSchema, - ApplicantProgressResponseSchema, - ApplicantProgressStateSchema, - PublicApplicationCatalogSchema, - PublicApplicationIdSchema, - ReturningAssistantOptionsSchema, - ReturningAssistantRegistrationInputSchema, - ReturningAssistantRegistrationResponseSchema, - type ApplicantProgressItem, - type ApplicantProgressResponse, - type ApplicantProgressState, -} from "@vektorprogrammet/domain/application"; -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"; -import { annotateAccessSpec, anonymousNativeAccess, personNativeAccess } from "./access.js"; -import { operationAnnotations, PersonSecurity } from "./common.js"; -import { - AdmissionsCreateAdmissionPeriodProblem, - AdmissionsListAdmissionPeriodsProblem, - AdmissionsListApplicationOptionsProblem, - AdmissionsListOpenAdmissionPeriodsProblem, - AdmissionsReadApplicantProgressProblem, - AdmissionsReadApplicationConfirmationProblem, - AdmissionsReadReturningAssistantOptionsProblem, - AdmissionsRegisterReturningAssistantProblem, - AdmissionsReviseAdmissionPeriodProblem, - AdmissionsSubmitApplicationProblem, -} from "./endpoint-problems.js"; -import { - ConditionalReadHeaders, - createdMutationResponse, - dynamicAdmissionConditionalResponses, - endpointProblemResponses, - entityMutationResponse, - IdempotencyHeaders, - IdempotencyIfMatchHeaders, - noStoreReadResponse, - privateConditionalResponses, - privateReadResponse, -} from "./http-semantics.js"; -import { - AdmissionPeriodManagementItem, - AdmissionPeriodManagementListResponse, - AdmissionPeriodMergePatch, - CreateAdmissionPeriodRequest, - OpenAdmissionPeriodListResponse, - PublicApplicationConfirmationSchema, - SubmitApplicationRequest, -} from "./v2-schemas.js"; - -export { - AdmissionPeriodId, - ApplicantProgressItemSchema, - ApplicantProgressResponseSchema, - ApplicantProgressStateSchema, - PublicApplicationIdSchema, - ReturningAssistantRegistrationInputSchema, -}; - -export type { ApplicantProgressItem, ApplicantProgressResponse, ApplicantProgressState }; - -/** @since 0.1.0 @category Endpoints */ -export const ListOpenAdmissionPeriodsEndpoint = HttpApiEndpoint.get( - "listOpenAdmissionPeriods", - "/api/open-admission-periods", - { - headers: ConditionalReadHeaders, - success: dynamicAdmissionConditionalResponses(OpenAdmissionPeriodListResponse), - error: endpointProblemResponses(AdmissionsListOpenAdmissionPeriodsProblem), - }, -) - .pipe((endpoint) => - annotateAccessSpec(endpoint, anonymousNativeAccess("admissions.public-open-periods")), - ) - .annotateMerge( - operationAnnotations( - "List open admission periods", - "Returns currently open native admission periods.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ReadApplicationCatalogEndpoint = HttpApiEndpoint.get( - "listApplicationOptions", - "/api/application-options", - { - headers: ConditionalReadHeaders, - success: dynamicAdmissionConditionalResponses(PublicApplicationCatalogSchema), - error: endpointProblemResponses(AdmissionsListApplicationOptionsProblem), - }, -) - .pipe((endpoint) => - annotateAccessSpec(endpoint, anonymousNativeAccess("admissions.public-application-options")), - ) - .annotateMerge( - operationAnnotations( - "List application options", - "Returns departments and fields open to applicants.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const SubmitApplicationEndpoint = HttpApiEndpoint.post( - "submitApplication", - "/api/applications", - { - headers: IdempotencyHeaders, - payload: SubmitApplicationRequest, - success: createdMutationResponse( - PublicApplicationConfirmationSchema.pipe(HttpApiSchema.status(201)), - ), - error: endpointProblemResponses(AdmissionsSubmitApplicationProblem), - }, -) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - anonymousNativeAccess("admissions.application-create", "Transaction"), - ), - ) - .annotateMerge( - operationAnnotations("Submit application", "Submits or replays a public applicant command."), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ReadApplicationConfirmationEndpoint = HttpApiEndpoint.get( - "readApplicationConfirmation", - "/api/applications/:applicationId", - { - params: { applicationId: PublicApplicationIdSchema }, - success: noStoreReadResponse(PublicApplicationConfirmationSchema), - error: endpointProblemResponses(AdmissionsReadApplicationConfirmationProblem), - }, -) - .pipe((endpoint) => - annotateAccessSpec(endpoint, anonymousNativeAccess("admissions.public-application-by-id")), - ) - .annotateMerge( - operationAnnotations( - "Read application confirmation", - "Returns the public confirmation projection.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ReadApplicantProgressEndpoint = HttpApiEndpoint.get( - "readApplicantProgress", - "/api/applicant-progress", - { - success: privateReadResponse(ApplicantProgressResponseSchema), - error: endpointProblemResponses(AdmissionsReadApplicantProgressProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "admissions.read-applicant-progress", - canonicalScopeResolver: "profile.current-person", - requirements: ["profile.owner"], - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read applicant progress", - "Returns current-semester recruitment progress for the signed-in applicant.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ListAdmissionPeriodsEndpoint = HttpApiEndpoint.get( - "listAdmissionPeriods", - "/api/admission-periods", - { - headers: ConditionalReadHeaders, - success: privateConditionalResponses(AdmissionPeriodManagementListResponse), - error: endpointProblemResponses(AdmissionsListAdmissionPeriodsProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "admissions.read-periods", - canonicalScopeResolver: "admissions.management-periods", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "List admission periods", - "Returns the management admission-period projection.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const CreateAdmissionPeriodEndpoint = HttpApiEndpoint.post( - "createAdmissionPeriod", - "/api/admission-periods", - { - headers: IdempotencyHeaders, - payload: CreateAdmissionPeriodRequest, - success: createdMutationResponse(AdmissionPeriodManagementItem.pipe(HttpApiSchema.status(201))), - error: endpointProblemResponses(AdmissionsCreateAdmissionPeriodProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "admissions.create-period", - canonicalScopeResolver: "admissions.period-create", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Create admission period", - "Creates or replays an admission-period command.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ReviseAdmissionPeriodEndpoint = HttpApiEndpoint.patch( - "reviseAdmissionPeriod", - "/api/admission-periods/:admissionPeriodId", - { - params: { admissionPeriodId: AdmissionPeriodId }, - headers: IdempotencyIfMatchHeaders, - payload: AdmissionPeriodMergePatch.pipe( - HttpApiSchema.asJson({ contentType: "application/merge-patch+json" }), - ), - success: entityMutationResponse(AdmissionPeriodManagementItem), - error: endpointProblemResponses(AdmissionsReviseAdmissionPeriodProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "admissions.revise-period", - canonicalScopeResolver: "admissions.period-by-id", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Revise admission period", - "Revises or replays an admission-period command.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ReadReturningAssistantOptionsEndpoint = HttpApiEndpoint.get( - "readReturningAssistantOptions", - "/api/returning-assistant/options", - { - success: noStoreReadResponse(ReturningAssistantOptionsSchema), - error: endpointProblemResponses(AdmissionsReadReturningAssistantOptionsProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "placements.self", - canonicalScopeResolver: "profile.current-person", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read returning-assistant registration options", - "Returns current identity, placement, department and admission-period options.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const RegisterReturningAssistantEndpoint = HttpApiEndpoint.post( - "registerReturningAssistant", - "/api/returning-assistant/registrations", - { - headers: IdempotencyHeaders, - payload: ReturningAssistantRegistrationInputSchema, - success: createdMutationResponse( - ReturningAssistantRegistrationResponseSchema.pipe(HttpApiSchema.status(201)), - ), - error: endpointProblemResponses(AdmissionsRegisterReturningAssistantProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "placements.self", - canonicalScopeResolver: "profile.current-person", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Register returning assistant", - "Registers a returning assistant for an admission period with immutable provenance.", - ), - ); - -/** - * Public applicant and administrative admission endpoints. - * - * @since 0.1.0 - * @category Groups - */ -export class AdmissionsApi extends HttpApiGroup.make("admissions") - .add( - ListOpenAdmissionPeriodsEndpoint, - ReadApplicationCatalogEndpoint, - SubmitApplicationEndpoint, - ReadApplicationConfirmationEndpoint, - ReadApplicantProgressEndpoint, - ListAdmissionPeriodsEndpoint, - CreateAdmissionPeriodEndpoint, - ReviseAdmissionPeriodEndpoint, - ReadReturningAssistantOptionsEndpoint, - RegisterReturningAssistantEndpoint, - ) - .annotateMerge( - OpenApi.annotations({ - title: "Admissions", - description: "Public applicant and administrative admission-period API.", - }), - ) {} diff --git a/packages/http-api/src/api.ts b/packages/http-api/src/api.ts deleted file mode 100644 index 166dab42b..000000000 --- a/packages/http-api/src/api.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { OnboardingApi } from "./onboarding.js"; -import { PlacementsApi } from "./placements.js"; -/** - * Composition of all Vektor-owned native HTTP groups. - * - * @since 0.1.0 - */ -import { HttpApi, OpenApi } from "effect/unstable/httpapi"; -import { ContactApi } from "./contact.js"; -import { AdmissionsApi } from "./admissions.js"; -import { AdmissionOutcomesApi } from "./admission-outcomes.js"; -import { ContentApi } from "./content.js"; -import { DirectoryApi } from "./directory.js"; -import { OrganizationApi } from "./organization.js"; -import { ProfileApi } from "./profile.js"; -import { InternalReceiptsApi, ReceiptsApi } from "./receipts.js"; -import { RecruitmentApi } from "./recruitment.js"; -import { SocialEventsApi } from "./social-events.js"; -import { TeamApplicationsApi } from "./team-application.js"; -import { CertificatesApi } from "./certificates.js"; -import { RequestSchemaErrorMiddleware } from "./common.js"; -import { NativeApiReleaseVersion } from "./release.js"; -import { SystemApi } from "./system.js"; - -/** - * Complete externally reachable Vektor-owned native HTTP contract. - * - * Better Auth `/api/auth/*` remains an independent external credential-engine - * surface. Internal operations are not members of this root. - * - * @since 0.1.0 - * @category APIs - */ -export class ExternalNativeApi extends HttpApi.make("external-native-api") - .add(PlacementsApi) - .add(OnboardingApi) - .add(ContactApi) - .add(SystemApi) - .add(ProfileApi) - .add(OrganizationApi) - .add(DirectoryApi) - .add(AdmissionsApi) - .add(AdmissionOutcomesApi) - .add(RecruitmentApi) - .add(ReceiptsApi) - .add(ContentApi) - .add(SocialEventsApi) - .add(TeamApplicationsApi) - .add(CertificatesApi) - .middleware(RequestSchemaErrorMiddleware) - .annotateMerge( - OpenApi.annotations({ - title: "Vektorprogrammet native preview API", - version: NativeApiReleaseVersion, - description: "The complete Vektor-owned native backend contract for preview environments.", - servers: [], - override: { - "x-vektorprogrammet-provenance": { - contract: "@vektorprogrammet/http-api/ExternalNativeApi", - generator: "effect/unstable/httpapi/OpenApi.fromApi", - schemas: "Effect.Schema", - statuses: "HttpApiSchema.status", - security: "HttpApiMiddleware.security", - exclusions: ["Better Auth /api/auth/*", "all internal operations"], - }, - "x-tagGroups": [ - { name: "Platform", tags: ["System", "Profile"] }, - { name: "Directories", tags: ["Organization", "Directories"] }, - { name: "Admissions", tags: ["Admissions", "Admission outcomes"] }, - { name: "Recruitment", tags: ["Recruitment", "Team applications"] }, - { name: "Economy", tags: ["Receipts"] }, - { name: "Content", tags: ["Content and news"] }, - { name: "Social", tags: ["Social events"] }, - ], - }, - }), - ) {} - -/** - * Internal native operations. A caller must mount this root on an explicit - * internal ingress. It is never part of public OpenAPI generation. - * - * @since 0.1.0 - * @category APIs - */ -export class InternalNativeApi extends HttpApi.make("internal-native-api") - .add(InternalReceiptsApi) - .middleware(RequestSchemaErrorMiddleware) {} diff --git a/packages/http-api/src/certificates.ts b/packages/http-api/src/certificates.ts deleted file mode 100644 index 2e7f0d82f..000000000 --- a/packages/http-api/src/certificates.ts +++ /dev/null @@ -1,304 +0,0 @@ -/** - * Days served and certificates: school coordination confirms each assistant's days served per - * department and semester, and an issuer downloads the cumulative certificate of an assistant. - * - * @since 0.2.0 - */ -import { DepartmentId, PersonId, SemesterId } from "@vektorprogrammet/domain/organization"; -import { - AssistantCursor, - CertificateAssistant, - CertificatePreview, - CertificateScopes, - CertificateSemesterScope, - DAYS_SERVED_PAGE_SIZE, - DaysServedEntry, - DaysServedTotal, -} from "@vektorprogrammet/domain/placements"; -import { Schema } from "effect"; -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; -import { annotateAccessSpec, personNativeAccess } from "./access.js"; -import { operationAnnotations, PersonSecurity } from "./common.js"; -import { - documentMutationResponse, - endpointProblemResponses, - entityMutationResponse, - IdempotencyIfMatchHeaders, - privateReadResponse, - problemUnion, - StrongETag, -} from "./http-semantics.js"; - -export { AssistantCursor, CertificateScopes, DaysServedTotal }; - -/** One assistant's days served, with the entity tag that a confirmation's `If-Match` repeats. */ -export const DaysServedEntryResource = Schema.Struct({ - ...DaysServedEntry.fields, - etag: StrongETag, -}).annotate({ - identifier: "DaysServedEntryResource", - description: - "One assistant in one department and semester: the dated evidence, the calculated count, and the current confirmation.", -}); - -export const DaysServedListResponse = Schema.Struct({ - departmentId: DepartmentId, - departmentName: Schema.String, - semester: CertificateSemesterScope, - items: Schema.Array(DaysServedEntryResource).pipe( - Schema.check(Schema.isMaxLength(DAYS_SERVED_PAGE_SIZE)), - ), - nextCursor: Schema.optional(AssistantCursor), -}).annotate({ - identifier: "DaysServedListResponse", - description: `One page of at most ${DAYS_SERVED_PAGE_SIZE} assistants, ordered by name.`, -}); - -/** Confirms the calculated count, or adjusts it; zero is a confirmed total too. */ -export const ConfirmDaysServedInput = Schema.Struct({ total: DaysServedTotal }).annotate({ - identifier: "ConfirmDaysServedInput", -}); - -export const CertificateListResponse = Schema.Struct({ - departmentId: DepartmentId, - departmentName: Schema.String, - items: Schema.Array(CertificateAssistant).pipe( - Schema.check(Schema.isMaxLength(DAYS_SERVED_PAGE_SIZE)), - ), - nextCursor: Schema.optional(AssistantCursor), -}).annotate({ - identifier: "CertificateListResponse", - description: `One page of at most ${DAYS_SERVED_PAGE_SIZE} assistants with service in the department, ordered by name.`, -}); - -/** What the issuer sees before download, with the entity tag of the content that would be issued. */ -export const CertificatePreviewResource = Schema.Struct({ - ...CertificatePreview.fields, - etag: StrongETag, -}).annotate({ - identifier: "CertificatePreviewResource", - description: - "Every semester of the assistant in the department with its status, the certificate that an issue would record, and its content entity tag.", -}); - -export const CertificatesReadProblem = problemUnion("CertificatesReadProblem", [ - "request.malformed", - "header.malformed", - "authority.denied", - "origin.denied", - "resource.not-found", - "transaction.conflict", - "internal.error", -]); - -export const CertificatesConfirmProblem = problemUnion("CertificatesConfirmProblem", [ - "request.malformed", - "header.malformed", - "idempotency-key.invalid", - "precondition.invalid", - "authority.denied", - "origin.denied", - "resource.not-found", - "idempotency.in-flight", - "idempotency.digest-conflict", - "idempotency.response-expired", - "transaction.conflict", - "precondition.failed", - "request.too-large", - "media-type.unsupported", - "validation.failed", - "precondition.required", - "internal.error", - "idempotency.unavailable", -]); - -export const CertificatesIssueProblem = problemUnion("CertificatesIssueProblem", [ - "request.malformed", - "header.malformed", - "idempotency-key.invalid", - "precondition.invalid", - "authority.denied", - "origin.denied", - "resource.not-found", - "idempotency.in-flight", - "idempotency.digest-conflict", - "idempotency.response-expired", - "transaction.conflict", - "precondition.failed", - "certificate.empty", - "certificate.unprintable", - "precondition.required", - "internal.error", - "idempotency.unavailable", -]); - -const access = ( - capability: "placements.days-served" | "certificates.issue", - decisionTime: "SnapshotRead" | "Transaction", -) => - personNativeAccess({ - capability, - canonicalScopeResolver: "placements.explicit-department", - decisionTime, - }); - -const SemesterParams = { departmentId: DepartmentId, semesterId: SemesterId }; - -const EntryParams = { ...SemesterParams, personId: PersonId }; - -const DepartmentParams = { departmentId: DepartmentId }; - -const CertificateParams = { departmentId: DepartmentId, personId: PersonId }; - -/** @since 0.2.0 @category Endpoints */ -export const ReadCertificateScopesEndpoint = HttpApiEndpoint.get( - "readCertificateScopes", - "/api/certificate-scopes", - { - success: privateReadResponse(CertificateScopes), - error: endpointProblemResponses(CertificatesReadProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "placements.days-served", - alternatives: ["certificates.issue"], - canonicalScopeResolver: "placements.explicit-department", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read certificate scopes", - "Returns the departments where the reader confirms days served or issues certificates, and the semesters. Either capability in some department grants the read.", - ), - ); - -/** @since 0.2.0 @category Endpoints */ -export const ListDaysServedEndpoint = HttpApiEndpoint.get( - "listDaysServed", - "/api/departments/:departmentId/semesters/:semesterId/days-served", - { - params: SemesterParams, - query: { cursor: Schema.optional(AssistantCursor) }, - success: privateReadResponse(DaysServedListResponse), - error: endpointProblemResponses(CertificatesReadProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec(endpoint, access("placements.days-served", "SnapshotRead")), - ) - .annotateMerge( - operationAnnotations( - "List days served", - "Returns a bounded page of the assistants of a department and semester with the dated evidence, the calculated count, and the current confirmation. Requires the days-served capability in the department.", - ), - ); - -/** @since 0.2.0 @category Endpoints */ -export const ConfirmDaysServedEndpoint = HttpApiEndpoint.post( - "confirmDaysServed", - "/api/departments/:departmentId/semesters/:semesterId/days-served/:personId", - { - params: EntryParams, - headers: IdempotencyIfMatchHeaders, - payload: ConfirmDaysServedInput, - success: entityMutationResponse(DaysServedEntryResource), - error: endpointProblemResponses(CertificatesConfirmProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => annotateAccessSpec(endpoint, access("placements.days-served", "Transaction"))) - .annotateMerge( - operationAnnotations( - "Confirm days served", - "Confirms or adjusts one assistant's total for the semester as the next revision. The earlier confirmation stays as history. Requires the observed entry entity tag.", - ), - ); - -/** @since 0.2.0 @category Endpoints */ -export const ListCertificatesEndpoint = HttpApiEndpoint.get( - "listCertificates", - "/api/departments/:departmentId/certificates", - { - params: DepartmentParams, - query: { cursor: Schema.optional(AssistantCursor) }, - success: privateReadResponse(CertificateListResponse), - error: endpointProblemResponses(CertificatesReadProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => annotateAccessSpec(endpoint, access("certificates.issue", "SnapshotRead"))) - .annotateMerge( - operationAnnotations( - "List certificates", - "Returns a bounded page of the assistants with service in the department and how many semesters their certificate lists. Issuers of the department only.", - ), - ); - -/** @since 0.2.0 @category Endpoints */ -export const ReadCertificateEndpoint = HttpApiEndpoint.get( - "readCertificate", - "/api/departments/:departmentId/certificates/:personId", - { - params: CertificateParams, - success: privateReadResponse(CertificatePreviewResource), - error: endpointProblemResponses(CertificatesReadProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => annotateAccessSpec(endpoint, access("certificates.issue", "SnapshotRead"))) - .annotateMerge( - operationAnnotations( - "Read certificate", - "Returns every semester of the assistant in the department with its status and the certificate that an issue would record. Issuers of the department only, never the assistant.", - ), - ); - -/** @since 0.2.0 @category Endpoints */ -export const IssueCertificateEndpoint = HttpApiEndpoint.post( - "issueCertificate", - "/api/departments/:departmentId/certificates/:personId/issues", - { - params: CertificateParams, - headers: IdempotencyIfMatchHeaders, - success: documentMutationResponse("application/pdf"), - error: endpointProblemResponses(CertificatesIssueProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => annotateAccessSpec(endpoint, access("certificates.issue", "Transaction"))) - .annotateMerge( - operationAnnotations( - "Issue certificate", - "Records one issue of the certificate with the issuer, the authorizing seat, the instant, and the content hash, and returns the PDF. Requires the observed certificate entity tag. An idempotency-key replay returns the same PDF.", - ), - ); - -/** - * Days served and certificates. - * - * @since 0.2.0 - * @category Groups - */ -export class CertificatesApi extends HttpApiGroup.make("certificates") - .add( - ReadCertificateScopesEndpoint, - ListDaysServedEndpoint, - ConfirmDaysServedEndpoint, - ListCertificatesEndpoint, - ReadCertificateEndpoint, - IssueCertificateEndpoint, - ) - .annotateMerge( - OpenApi.annotations({ - title: "Certificates", - description: - "Confirmation of days served per assistant, department, and semester, and the issue of cumulative certificates.", - }), - ) {} diff --git a/packages/http-api/src/common.ts b/packages/http-api/src/common.ts deleted file mode 100644 index a726ab983..000000000 --- a/packages/http-api/src/common.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * Shared security, middleware, annotation, and error contracts for the native API. - * - * @since 0.1.0 - */ -import { HttpApiMiddleware, HttpApiSecurity, OpenApi } from "effect/unstable/httpapi"; -import { problemStatusResponse, problemUnion } from "./http-semantics.js"; - -/** - * Request Cookie header marker for endpoints that perform authoritative session resolution. - * Both Better Auth's local and secure-prefixed cookie names travel through this one header. - * - * @since 0.1.0 - * @category Security - */ -export const RequestCookieHeader = HttpApiSecurity.apiKey({ - key: "Cookie", - in: "header", -}).pipe( - HttpApiSecurity.annotateMerge( - OpenApi.annotations({ description: "Cookie header resolved authoritatively by Identity." }), - ), -); - -/** - * Bearer security declaration for delegated person credentials. Service - * credentials are rejected by the backend credential authority. - * - * @since 0.1.0 - * @category Security - */ -export const OAuthUserBearer = HttpApiSecurity.bearer.pipe( - HttpApiSecurity.annotateMerge( - OpenApi.annotations({ - description: "Native OAuth delegated access token for a current Person principal.", - }), - ), -); - -/** Bearer security declaration for service-principal OAuth access tokens. */ -export const OAuthServiceBearer = HttpApiSecurity.bearer.pipe( - HttpApiSecurity.annotateMerge( - OpenApi.annotations({ - description: "Native OAuth service access token for a current ServicePrincipal.", - }), - ), -); - -/** - * The credential problems every security middleware declares; secured - * endpoint unions do not repeat them. - * - * @since 0.2.0 - * @category Schemas - */ -export const SessionUnauthorizedProblem = problemUnion("SessionUnauthorizedProblem", [ - "credential.missing", - "credential.invalid", -]); - -/** - * Standard missing or invalid session response. - * - * @since 0.1.0 - * @category Schemas - */ -export const SessionUnauthorizedResponse = problemStatusResponse(SessionUnauthorizedProblem); - -/** - * Contract security marker for session-authenticated native operations. - * The backend implementation resolves the full Cookie header through Identity. - * - * @since 0.1.0 - * @category Security - */ -export class SessionSecurity extends HttpApiMiddleware.Service()( - "@vektorprogrammet/http-api/SessionSecurity", - { - security: { - cookieHeader: RequestCookieHeader, - }, - error: SessionUnauthorizedResponse, - }, -) {} - -/** - * Contract security marker for person-authenticated operations. The two - * security entries are alternatives: a current Better Auth browser session or - * a delegated OAuth user bearer. The backend resolves either mechanism to the - * same canonical Person principal before current authorization is evaluated. - * - * The leaking expectation below is exception EX-0007 of docs/effect-exceptions.json. - * - * @since 0.1.0 - * @category Security - * @effect-expect-leaking HttpServerRequest ParsedSearchParams RouteContext - */ -export class PersonSecurity extends HttpApiMiddleware.Service()( - "@vektorprogrammet/http-api/PersonSecurity", - { - security: { - cookieHeader: RequestCookieHeader, - oauthUserBearer: OAuthUserBearer, - }, - error: SessionUnauthorizedResponse, - }, -) {} - -/** - * Contract marker for the one native operation that accepts either a person - * credential or a service-principal bearer. - * - * The leaking expectation below is exception EX-0007 of docs/effect-exceptions.json. - * - * @effect-expect-leaking HttpServerRequest ParsedSearchParams RouteContext - */ -export class PersonOrServiceSecurity extends HttpApiMiddleware.Service()( - "@vektorprogrammet/http-api/PersonOrServiceSecurity", - { - security: { - cookieHeader: RequestCookieHeader, - oauthUserBearer: OAuthUserBearer, - oauthServiceBearer: OAuthServiceBearer, - }, - error: SessionUnauthorizedResponse, - }, -) {} - -/** - * Header security declaration for public recruitment invitation responses. - * - * @since 0.1.0 - * @category Security - */ -export const RecruitmentInvitationCapability = HttpApiSecurity.apiKey({ - key: "X-Recruitment-Invitation-Capability", - in: "header", -}).pipe( - HttpApiSecurity.annotateMerge( - OpenApi.annotations({ - description: - "Opaque invitation response capability. It is the request's one credential: a session cookie or bearer beside it is rejected.", - }), - ), -); - -/** - * Missing, malformed, or unknown invitation capability response. - * - * @since 0.1.0 - * @category Schemas - */ -export const InvitationNotFoundResponse = problemStatusResponse( - problemUnion("InvitationNotFoundProblem", ["resource.not-found"]), -); - -/** - * A capability presented beside a session cookie or bearer. A request presents - * one credential, so neither acts. - * - * @since 0.2.0 - * @category Schemas - */ -export const InvitationSecondCredentialResponse = problemStatusResponse( - problemUnion("InvitationSecondCredentialProblem", ["credential.invalid"]), -); - -/** - * Contract security marker for invitation-capability operations. - * - * @since 0.1.0 - * @category Security - */ -export class InvitationCapabilitySecurity extends HttpApiMiddleware.Service()( - "@vektorprogrammet/http-api/InvitationCapabilitySecurity", - { - security: { invitationCapability: RecruitmentInvitationCapability }, - error: [InvitationNotFoundResponse, InvitationSecondCredentialResponse], - }, -) {} - -/** - * Contract marker that lets the backend translate automatic schema failures to - * each group's frozen error envelope. - * - * @since 0.1.0 - * @category Middleware - */ -export class RequestSchemaErrorMiddleware extends HttpApiMiddleware.Service()( - "@vektorprogrammet/http-api/RequestSchemaErrorMiddleware", -) {} - -/** - * Machine-readable lineage shared by every native OpenAPI operation. - * - * @since 0.1.0 - * @category Annotations - */ -export const NativeOperationProvenance = { - contract: "@vektorprogrammet/http-api/ExternalNativeApi", - operationId: "HttpApiGroup.identifier.HttpApiEndpoint.identifier", - tags: "HttpApiGroup.identifier", - security: "HttpApiMiddleware.security", - statuses: "HttpApiSchema.status", - schemas: "Effect.Schema", -} as const; - -/** - * Adds stable OpenAPI operation metadata. - * - * @since 0.1.0 - * @category Annotations - */ -export const operationAnnotations = (summary: string, description: string) => - OpenApi.annotations({ - summary, - description, - transform: (operation) => ({ - ...operation, - "x-vektorprogrammet-provenance": NativeOperationProvenance, - }), - }); diff --git a/packages/http-api/src/contact.ts b/packages/http-api/src/contact.ts deleted file mode 100644 index 0192c576b..000000000 --- a/packages/http-api/src/contact.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { - ContactMessage, - ContactVisitorIp, - CONTACT_BACKEND_HEADER, - CONTACT_IP_HEADER, -} from "@vektorprogrammet/domain/contact"; -import { - makeAccessSpec, - CredentialMechanismSchema, - CapabilityTypeId, - CapabilityExpressionSchema, - ConcealmentPolicySchema, -} from "@vektorprogrammet/domain/authz"; -import { Schema } from "effect"; -import { - HttpApiEndpoint, - HttpApiGroup, - HttpApiMiddleware, - HttpApiSchema, - HttpApiSecurity, - OpenApi, -} from "effect/unstable/httpapi"; -import { annotateAccessSpec } from "./access.js"; -import { operationAnnotations, SessionUnauthorizedResponse } from "./common.js"; -import { endpointProblemResponses, problemUnion } from "./http-semantics.js"; - -export { ContactMessage, ContactVisitorIp }; - -export class ContactSsrSecurity extends HttpApiMiddleware.Service()( - "@vektorprogrammet/http-api/ContactSsrSecurity", - { - security: { - contactBackend: HttpApiSecurity.apiKey({ key: CONTACT_BACKEND_HEADER, in: "header" }).pipe( - HttpApiSecurity.annotateMerge( - OpenApi.annotations({ - description: - "Server-only homepage-to-backend contact credential. Visitor needs no account; direct anonymous API requests are rejected.", - }), - ), - ), - }, - error: SessionUnauthorizedResponse, - }, -) {} - -export const ContactProblem = problemUnion("ContactProblem", [ - "request.malformed", - "header.malformed", - "request.too-large", - "validation.failed", - "rate-limit.exceeded", - "contact.unavailable", - "media-type.unsupported", - "internal.error", -]); - -export const SubmitContactMessageEndpoint = HttpApiEndpoint.post( - "submitContactMessage", - "/api/contact-messages", - { - payload: ContactMessage, - headers: Schema.Struct({ [CONTACT_IP_HEADER]: ContactVisitorIp }), - success: HttpApiSchema.WithHeaders(Schema.Void.pipe(HttpApiSchema.status(201)), { - "cache-control": Schema.Literal("no-store"), - vary: Schema.Literal("Origin"), - }), - error: endpointProblemResponses(ContactProblem), - }, -) - .middleware(ContactSsrSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - makeAccessSpec({ - exposure: "External", - acceptedCredentials: [ - CredentialMechanismSchema.cases.ObjectCapability.make({ - capabilityType: CapabilityTypeId.make("contact.submit"), - }), - ], - principalKinds: ["CapabilityHolder"], - capabilities: CapabilityExpressionSchema.cases.One.make({ - capability: { type: CapabilityTypeId.make("contact.submit") }, - }), - requirements: [], - canonicalScopeResolver: "contact.department-recipient", - concealment: ConcealmentPolicySchema.cases.Reveal.make({}), - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Send public contact message", - "Consumes one of five attempts per visitor fixed one-hour window and waits for delivery transport acceptance. No message is persisted; no automatic retry. HTTP 201 has no body and is not proof of inbox delivery.", - ), - ); - -export class ContactApi extends HttpApiGroup.make("contact") - .add(SubmitContactMessageEndpoint) - .annotateMerge( - OpenApi.annotations({ - title: "Public contact", - description: "Public visitor contact via authenticated homepage SSR ingress.", - }), - ) {} diff --git a/packages/http-api/src/content.ts b/packages/http-api/src/content.ts deleted file mode 100644 index 038eee00c..000000000 --- a/packages/http-api/src/content.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * Public HTTP contracts for staff content and published news. - * - * @since 0.1.0 - */ -import { - ArticleVersionNumber, - ArticleId, - ArticleSlug, - ContentArticleDetailSchema, - ContentWorkspaceSchema, - PublishedNewsArticleSchema, - PublishedNewsListingSchema, - type ContentWorkspace, -} from "@vektorprogrammet/domain/content"; -import { DepartmentId } from "@vektorprogrammet/domain/organization"; -import { Schema } from "effect"; -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"; -import { annotateAccessSpec, anonymousNativeAccess, personNativeAccess } from "./access.js"; -import { operationAnnotations, PersonSecurity } from "./common.js"; -import { - ContentCreateArticleProblem, - ContentListNewsProblem, - ContentPublishArticleProblem, - ContentReadArticleProblem, - ContentReadContentWorkspaceProblem, - ContentReadNewsArticleProblem, - ContentReviseArticleProblem, - ContentUnpublishArticleProblem, -} from "./endpoint-problems.js"; -import { - ConditionalReadHeaders, - createdMutationResponse, - endpointProblemResponses, - entityMutationResponse, - IdempotencyHeaders, - IdempotencyIfMatchHeaders, - privateConditionalResponses, - privateReadResponse, - publicConditionalResponses, -} from "./http-semantics.js"; -import { - ArticleMergePatch, - CreateArticleRequest, - PublishArticleRequest, - PublishArticleResponse, - UnpublishArticleRequest, - UnpublishArticleResponse, -} from "./v2-schemas.js"; - -export { ArticleVersionNumber, ArticleId, ArticleSlug, ContentWorkspaceSchema }; - -export type { ContentWorkspace }; - -/** - * Optional public/staff department filter using the transport key. - * - * @since 0.1.0 - * @category Schemas - */ -export const ContentDepartmentQuery = { - department: Schema.optional(DepartmentId), -}; - -/** - * Optional positive published-version selector. - * - * @since 0.1.0 - * @category Schemas - */ -export const NewsVersionQuery = { - version: Schema.optional(Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(1)))), -}; - -export const WorkspaceEntryExample = { - articleId: ArticleId.make(1), - title: "Penerimaan Anggota Baru 2026", - slug: ArticleSlug.make("penerimaan-anggota-2026"), - status: "Draft", - sticky: false, - updatedAt: "2026-08-24T09:00:00.000Z", - departmentIds: [DepartmentId.make("1")], - canRevise: true, - canPublish: true, - authorDisplayName: "Kari Penerbit", -} as const; - -export const ArticleDetailExample = { - articleId: ArticleId.make(1), - title: "Penerimaan Anggota Baru 2026", - slug: ArticleSlug.make("penerimaan-anggota-2026"), - status: "Draft", - bodyHtml: "

Informasi penerimaan anggota baru tahun 2026.

", - sticky: false, - createdAt: "2026-08-20T09:00:00.000Z", - updatedAt: "2026-08-24T09:00:00.000Z", - currentVersionNumber: null, - revision: 0, - departmentIds: [DepartmentId.make("1")], - canRevise: true, - canPublish: true, - authorDisplayName: "Kari Penerbit", -} as const; - -export const NewsListingExample = { - articles: [ - { - slug: ArticleSlug.make("penerimaan-anggota-2026"), - title: "Penerimaan Anggota Baru 2026", - sticky: true, - publishedAt: "2026-08-25T08:00:00.000Z", - authorDisplayName: "Kari Penerbit", - departmentIds: [DepartmentId.make("1")], - hasImage: false, - }, - ], -} as const; - -export const NewsArticleExample = { - slug: ArticleSlug.make("penerimaan-anggota-2026"), - title: "Penerimaan Anggota Baru 2026", - sticky: true, - publishedAt: "2026-08-25T08:00:00.000Z", - authorDisplayName: "Kari Penerbit", - departmentIds: [DepartmentId.make("1")], - hasImage: false, - bodyHtml: "

Informasi penerimaan anggota baru tahun 2026.

", - previousVersions: [], -} as const; - -/** @since 0.1.0 @category Endpoints */ -export const ReadContentWorkspaceEndpoint = HttpApiEndpoint.get( - "readContentWorkspace", - "/api/content/articles", - { - query: ContentDepartmentQuery, - success: privateReadResponse(ContentWorkspaceSchema), - error: endpointProblemResponses(ContentReadContentWorkspaceProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "content.read-workspace", - canonicalScopeResolver: "content.articles", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read content workspace", - "Returns article drafts visible to the current staff actor.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const CreateArticleEndpoint = HttpApiEndpoint.post( - "createArticle", - "/api/content/articles", - { - headers: IdempotencyHeaders, - payload: CreateArticleRequest, - success: createdMutationResponse(ContentArticleDetailSchema.pipe(HttpApiSchema.status(201))), - error: endpointProblemResponses(ContentCreateArticleProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "content.create-article", - canonicalScopeResolver: "content.article-create", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Create article draft", - "Creates or replays a native article draft command.", - ), - ); - -const ArticleParams = { articleId: ArticleId }; - -/** @since 0.1.0 @category Endpoints */ -export const ReadArticleEndpoint = HttpApiEndpoint.get( - "readArticle", - "/api/content/articles/:articleId", - { - params: ArticleParams, - headers: ConditionalReadHeaders, - success: privateConditionalResponses(ContentArticleDetailSchema), - error: endpointProblemResponses(ContentReadArticleProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "content.read-article", - canonicalScopeResolver: "content.article-by-id", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations("Read article detail", "Returns one staff article detail projection."), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ReviseArticleEndpoint = HttpApiEndpoint.patch( - "reviseArticle", - "/api/content/articles/:articleId", - { - params: ArticleParams, - headers: IdempotencyIfMatchHeaders, - payload: ArticleMergePatch.pipe( - HttpApiSchema.asJson({ contentType: "application/merge-patch+json" }), - ), - success: entityMutationResponse(ContentArticleDetailSchema), - error: endpointProblemResponses(ContentReviseArticleProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "content.revise-article", - canonicalScopeResolver: "content.article-by-id", - requirements: ["content.revisable"], - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Revise article draft", - "Revises a draft with optimistic revision control.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const PublishArticleEndpoint = HttpApiEndpoint.post( - "publishArticle", - "/api/content/articles/:articleId:publish", - { - params: ArticleParams, - headers: IdempotencyIfMatchHeaders, - payload: PublishArticleRequest, - success: entityMutationResponse(PublishArticleResponse), - error: endpointProblemResponses(ContentPublishArticleProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "content.publish-article", - canonicalScopeResolver: "content.article-by-id", - requirements: ["content.publishable"], - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations("Publish article", "Publishes a new immutable article version."), - ); - -/** @since 0.1.0 @category Endpoints */ -export const UnpublishArticleEndpoint = HttpApiEndpoint.post( - "unpublishArticle", - "/api/content/articles/:articleId:unpublish", - { - params: ArticleParams, - headers: IdempotencyIfMatchHeaders, - payload: UnpublishArticleRequest, - success: entityMutationResponse(UnpublishArticleResponse), - error: endpointProblemResponses(ContentUnpublishArticleProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "content.publish-article", - canonicalScopeResolver: "content.article-by-id", - requirements: ["content.unpublishable"], - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Unpublish article", - "Removes an article from the public current listing.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ListNewsEndpoint = HttpApiEndpoint.get("listNews", "/api/news", { - query: ContentDepartmentQuery, - headers: ConditionalReadHeaders, - success: publicConditionalResponses( - PublishedNewsListingSchema.annotate({ - identifier: "PublishedNewsListing", - description: "Current public news listing.", - examples: [NewsListingExample], - }), - ), - error: endpointProblemResponses(ContentListNewsProblem), -}) - .pipe((endpoint) => annotateAccessSpec(endpoint, anonymousNativeAccess("content.public-news"))) - .annotateMerge( - operationAnnotations("List published news", "Returns the current public native news listing."), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ReadNewsArticleEndpoint = HttpApiEndpoint.get("readNewsArticle", "/api/news/:slug", { - params: { slug: ArticleSlug }, - query: NewsVersionQuery, - headers: ConditionalReadHeaders, - success: publicConditionalResponses( - PublishedNewsArticleSchema.annotate({ - identifier: "PublishedNewsArticle", - description: "One published news article.", - examples: [NewsArticleExample], - }), - ), - error: endpointProblemResponses(ContentReadNewsArticleProblem), -}) - .pipe((endpoint) => - annotateAccessSpec(endpoint, anonymousNativeAccess("content.public-news-by-slug")), - ) - .annotateMerge( - operationAnnotations( - "Read published news article", - "Returns the current or selected published version.", - ), - ); - -/** - * Staff content management and public news API. - * - * @since 0.1.0 - * @category Groups - */ -export class ContentApi extends HttpApiGroup.make("content") - .add( - ReadContentWorkspaceEndpoint, - CreateArticleEndpoint, - ReadArticleEndpoint, - ReviseArticleEndpoint, - PublishArticleEndpoint, - UnpublishArticleEndpoint, - ListNewsEndpoint, - ReadNewsArticleEndpoint, - ) - .annotateMerge( - OpenApi.annotations({ - title: "Content and news", - description: "Native staff publication and public news operations.", - }), - ) {} diff --git a/packages/http-api/src/directory.ts b/packages/http-api/src/directory.ts deleted file mode 100644 index 2f1627185..000000000 --- a/packages/http-api/src/directory.ts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * Public HTTP contracts for the people and school directories. - * - */ -import { DepartmentId } from "@vektorprogrammet/domain/organization"; -import { - SchoolCommand, - SchoolCommandResult, - SchoolManagement, - SchoolDirectoryDepartmentSchema, - SchoolDirectorySchema, - SchoolId, - type SchoolDirectory, - type SchoolDirectoryDepartment, - type SchoolDirectoryEntry, -} from "@vektorprogrammet/domain/schools"; -import { Schema } from "effect"; -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; -import { annotateAccessSpec, personNativeAccess } from "./access.js"; -import { operationAnnotations, PersonSecurity } from "./common.js"; -import { DirectoryListPeopleProblem, DirectoryListSchoolsProblem } from "./endpoint-problems.js"; -import { - endpointProblemResponses, - privateReadResponse, - IdempotencyHeaders, - entityMutationResponse, - problemUnion, -} from "./http-semantics.js"; - -export { SchoolDirectoryDepartmentSchema, SchoolDirectorySchema, SchoolId }; - -export type { SchoolDirectory, SchoolDirectoryDepartment, SchoolDirectoryEntry }; - -/** - * One profile/organization directory row. - * - * @since 0.1.0 - * @category Schemas - */ -export const PeopleDirectoryEntry = Schema.Struct({ - personId: Schema.String, - firstName: Schema.String, - lastName: Schema.String, - email: Schema.String, - phone: Schema.String, - studyProgramme: Schema.Null, - departments: Schema.Array(Schema.String), - isActive: Schema.Boolean, -}).annotate({ - identifier: "PeopleDirectoryEntry", - description: "Person profile enriched with Organization-owned department facts.", - examples: [ - { - personId: "7202", - firstName: "Ming", - lastName: "Medlem", - email: "ming.medlem@example.org", - phone: "+47 900 00 000", - studyProgramme: null, - departments: ["1"], - isActive: true, - }, - ], -}); - -/** - * Complete scoped people directory response. - * - * @since 0.1.0 - * @category Schemas - */ -export const PeopleDirectoryResponse = Schema.Struct({ - activePeople: Schema.Array(PeopleDirectoryEntry), - inactivePeople: Schema.Array(PeopleDirectoryEntry), - nextCursor: Schema.NullOr(Schema.String), -}).annotate({ - identifier: "PeopleDirectoryResponse", - description: "Active and inactive people visible in the caller's authority scope.", - examples: [ - { - activePeople: [ - { - personId: "7202", - firstName: "Ming", - lastName: "Medlem", - email: "ming.medlem@example.org", - phone: "+47 900 00 000", - studyProgramme: null, - departments: ["1"], - isActive: true, - }, - ], - inactivePeople: [], - nextCursor: null, - }, - ], -}); - -/** @since 0.1.0 @category Endpoints */ -export const ListPeopleEndpoint = HttpApiEndpoint.get("listPeople", "/api/people", { - success: privateReadResponse(PeopleDirectoryResponse), - error: endpointProblemResponses(DirectoryListPeopleProblem), -}) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "profile.read-directory", - canonicalScopeResolver: "profile.people-directory", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations("List people", "Returns the people directory within the caller's scope."), - ); - -export const SchoolDirectoryExample = { - activeSchools: [ - { - schoolId: SchoolId.make(1), - name: "Trondheim katedral videregående skole", - contactPerson: "Heidi Holm", - email: "post@tks.example.org", - phone: "+47 900 00 001", - language: "Norwegian", - departments: [{ departmentId: DepartmentId.make("1"), name: "Trondheim" }], - isActive: true, - }, - ], - inactiveSchools: [], -} as const; - -/** @since 0.1.0 @category Endpoints */ -export const ListSchoolsEndpoint = HttpApiEndpoint.get("listSchools", "/api/schools", { - query: { department: Schema.optional(DepartmentId) }, - success: privateReadResponse( - SchoolDirectorySchema.annotate({ - identifier: "SchoolDirectory", - description: "Active and inactive school directory entries.", - examples: [SchoolDirectoryExample], - }), - ), - error: endpointProblemResponses(DirectoryListSchoolsProblem), -}) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "schools.read-directory", - canonicalScopeResolver: "schools.directory", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations("List schools", "Returns the native school directory in authority scope."), - ); - -/** - * Scoped people and school directory endpoints. - * - * @since 0.1.0 - * @category Groups - */ -export { SchoolCommand, SchoolCommandResult, SchoolManagement }; - -const SchoolAdministrationProblem = problemUnion("SchoolAdministrationProblem", [ - "authority.denied", - "origin.denied", - "request.malformed", - "request.too-large", - "media-type.unsupported", - "header.malformed", - "idempotency-key.invalid", - "idempotency.in-flight", - "idempotency.digest-conflict", - "idempotency.response-expired", - "transaction.conflict", - "resource.not-found", - "precondition.failed", - "schools.invalid-command", - "schools.association-in-use", - "schools.inactive", - "schools.capacity-exists", - "schools.unavailable", - "idempotency.unavailable", - "internal.error", -]); - -export const ReadSchoolManagementEndpoint = HttpApiEndpoint.get( - "readSchoolManagement", - "/api/schools/management", - { - success: privateReadResponse(SchoolManagement), - error: endpointProblemResponses(SchoolAdministrationProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "schools.manage", - canonicalScopeResolver: "schools.management", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Manage schools", - "Returns authorized school facts, capacity plans, options, and history.", - ), - ); - -export const ExecuteSchoolCommandEndpoint = HttpApiEndpoint.post( - "executeSchoolCommand", - "/api/schools/commands", - { - headers: IdempotencyHeaders, - payload: SchoolCommand, - success: entityMutationResponse(SchoolCommandResult), - error: endpointProblemResponses(SchoolAdministrationProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "schools.manage", - canonicalScopeResolver: "schools.management", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Maintain schools", - "Applies one scoped school or capacity command with an explicit observed revision and atomic history.", - ), - ); - -export class DirectoryApi extends HttpApiGroup.make("directory") - .add( - ListPeopleEndpoint, - ListSchoolsEndpoint, - ReadSchoolManagementEndpoint, - ExecuteSchoolCommandEndpoint, - ) - .annotateMerge( - OpenApi.annotations({ - title: "Directories", - description: "Scoped people and school directories.", - override: { "x-displayName": "Directories" }, - }), - ) {} diff --git a/packages/http-api/src/onboarding.ts b/packages/http-api/src/onboarding.ts deleted file mode 100644 index 7d6e1c235..000000000 --- a/packages/http-api/src/onboarding.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { - OnboardingScope, - OnboardingBoard, - OnboardingCommand, - OnboardingClaim, - OnboardingClaimResult, -} from "@vektorprogrammet/domain/onboarding"; -import { - makeAccessSpec, - CapabilityTypeId, - CredentialMechanismSchema, - CapabilityExpressionSchema, - ConcealmentPolicySchema, - RequirementId, -} from "@vektorprogrammet/domain/authz"; -import { Schema } from "effect"; -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; -import { annotateAccessSpec, personNativeAccess } from "./access.js"; -import { PersonSecurity, operationAnnotations } from "./common.js"; -import { - StrongETag, - IdempotencyIfMatchHeaders, - privateReadResponse, - entityMutationResponse, - endpointProblemResponses, - problemUnion, -} from "./http-semantics.js"; - -export { OnboardingClaim, OnboardingCommand, OnboardingScope }; - -export const OnboardingResource = Schema.Struct({ - ...OnboardingBoard.fields, - etag: StrongETag, -}).annotate({ identifier: "OnboardingResource" }); - -const onboardingProblems = [ - "request.malformed", - "request.too-large", - "validation.failed", - "authority.denied", - "resource.not-found", - "precondition.required", - "precondition.invalid", - "precondition.failed", - "idempotency-key.invalid", - "idempotency.in-flight", - "idempotency.digest-conflict", - "idempotency.response-expired", - "transaction.conflict", - "internal.error", - "media-type.unsupported", - "onboarding.claim-invalid", - "onboarding.sign-in-required", - "onboarding.already-linked", -] as const; - -/** Problems of the person-secured operations; PersonSecurity declares their credential problems. */ -export const OnboardingProblem = problemUnion("OnboardingProblem", onboardingProblems); - -/** The command's receipt store can also be unavailable. */ -export const OnboardingCommandProblem = problemUnion("OnboardingCommandProblem", [ - ...onboardingProblems, - "idempotency.unavailable", -]); - -/** - * The capability claim has no security middleware, so it declares its own credential problems: - * existing-account mode answers a missing or rejected person credential. - */ -export const OnboardingClaimProblem = problemUnion("OnboardingClaimProblem", [ - ...onboardingProblems, - "credential.missing", - "credential.invalid", -]); - -const access = (write = false) => - personNativeAccess({ - capability: "onboarding.manage", - canonicalScopeResolver: "onboarding.application-department", - decisionTime: write ? "Transaction" : "SnapshotRead", - }); - -export const ReadOnboardingEndpoint = HttpApiEndpoint.get("readBoard", "/api/onboarding", { - query: OnboardingScope.fields, - success: privateReadResponse(OnboardingResource), - error: endpointProblemResponses(OnboardingProblem), -}) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access())) - .annotateMerge( - operationAnnotations( - "Read applicant onboarding", - "Scoped applicant names and invitation states.", - ), - ); - -export const CommandOnboardingEndpoint = HttpApiEndpoint.post("command", "/api/onboarding", { - query: OnboardingScope.fields, - payload: OnboardingCommand, - headers: IdempotencyIfMatchHeaders, - success: entityMutationResponse(OnboardingResource), - error: endpointProblemResponses(OnboardingCommandProblem), -}) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access(true))) - .annotateMerge( - operationAnnotations( - "Invite applicant or revoke invitation", - "No arbitrary recipient or target Person selector.", - ), - ); - -export const ClaimOnboardingEndpoint = HttpApiEndpoint.post("claim", "/api/onboarding/claim", { - payload: OnboardingClaim, - success: privateReadResponse(OnboardingClaimResult), - error: endpointProblemResponses(OnboardingClaimProblem), -}) - .pipe((e) => - annotateAccessSpec( - e, - // New-account mode: the token holder is the principal. Existing-account mode: the - // browser session names the principal, and the token is its requirement. A delegated - // bearer cannot make the claim, so the AccessSpec does not accept one. - makeAccessSpec({ - exposure: "External", - acceptedCredentials: [ - CredentialMechanismSchema.cases.ObjectCapability.make({ - capabilityType: CapabilityTypeId.make("onboarding.claim"), - }), - CredentialMechanismSchema.cases.BetterAuthCookie.make({}), - ], - principalKinds: ["CapabilityHolder", "Person"], - capabilities: CapabilityExpressionSchema.cases.One.make({ - capability: { type: CapabilityTypeId.make("onboarding.claim") }, - }), - requirements: [{ id: RequirementId.make("onboarding.claim-token"), parameters: {} }], - canonicalScopeResolver: "onboarding.claim", - concealment: ConcealmentPolicySchema.cases.Reveal.make({}), - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Claim applicant account invitation", - "The body token is required in both modes and works once. In new-account mode the token is the one credential, so a session cookie or bearer beside it is rejected. In existing-account mode the browser session names the one principal, and the token is its requirement onboarding.claim-token: it must name an open invitation. A delegated bearer cannot make the claim.", - ), - ); - -export class OnboardingApi extends HttpApiGroup.make("onboarding") - .add(ReadOnboardingEndpoint) - .add(CommandOnboardingEndpoint) - .add(ClaimOnboardingEndpoint) - .annotateMerge( - OpenApi.annotations({ - title: "Applicant account onboarding", - description: "Explicit applicant identity proof and account linking.", - }), - ) {} diff --git a/packages/http-api/src/organization.ts b/packages/http-api/src/organization.ts deleted file mode 100644 index 4c4226ea5..000000000 --- a/packages/http-api/src/organization.ts +++ /dev/null @@ -1,596 +0,0 @@ -/** - * Public HTTP contracts for organization queries and commands. - * - * @since 0.1.0 - */ -import { - DepartmentId, - DepartmentJsonSchema, - FieldOfStudyJsonSchema, - FieldOfStudyId, - PersonId, - SemesterId, - TeamId, - TeamJsonSchema, - type DepartmentJson, - type FieldOfStudyJson, - type TeamJson, -} from "@vektorprogrammet/domain/organization"; -import { Schema } from "effect"; -import { - AppointmentManagement, - BoardRosters, - OrganizationLifecycleCommand, - OrganizationLifecycleResult, -} from "@vektorprogrammet/domain/organization"; -import { - DelegableCapability, - DelegationArea, - DelegationCommand, - DelegationManagement, - DelegationResult, - OrganizationCapability, -} from "@vektorprogrammet/domain/authz"; -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"; -import { annotateAccessSpec, anonymousNativeAccess, personNativeAccess } from "./access.js"; -import { operationAnnotations, PersonSecurity } from "./common.js"; -import { - OrganizationCreateDepartmentProblem, - OrganizationCreateFieldOfStudyProblem, - OrganizationCreateTeamProblem, - OrganizationListDepartmentsProblem, - OrganizationListFieldOfStudiesProblem, - OrganizationListMailingListsProblem, - OrganizationListTeamInterestProblem, - OrganizationListTeamsProblem, -} from "./endpoint-problems.js"; -import { - ConditionalReadHeaders, - createdMutationResponse, - endpointProblemResponses, - IdempotencyHeaders, - privateReadResponse, - publicConditionalResponses, - entityMutationResponse, - problemUnion, -} from "./http-semantics.js"; -import { - CreateDepartmentRequest, - CreateFieldOfStudyRequest, - CreateTeamRequest, -} from "./v2-schemas.js"; - -export { - DepartmentId, - DepartmentJsonSchema, - FieldOfStudyJsonSchema, - PersonId, - SemesterId, - TeamJsonSchema, -}; - -export type { DepartmentJson, FieldOfStudyJson, TeamJson }; - -/** - * Reach-scoped organization query. Repeated values remain representable because - * the current transport selects the first value. - * - * @since 0.1.0 - * @category Schemas - */ -export const OrganizationScopeQuery = { - department: Schema.optional(Schema.Union([Schema.String, Schema.Array(Schema.String)])), - semester: Schema.optional(Schema.Union([Schema.String, Schema.Array(Schema.String)])), -}; - -/** - * Mailing-list projection selector. - * - * @since 0.1.0 - * @category Schemas - */ -export const MailingListQuery = { - ...OrganizationScopeQuery, - type: Schema.optional( - Schema.Union([ - Schema.Literals(["assistants", "team", "all"]), - Schema.Array(Schema.Literals(["assistants", "team", "all"])), - ]), - ), -}; - -/** - * Strict team-interest response compatible with the existing Hydra envelope. - * - * @since 0.1.0 - * @category Schemas - */ -export const TeamInterestResponse = Schema.Struct({ - "hydra:member": Schema.Array( - Schema.Struct({ id: Schema.Int, userName: Schema.String, teamName: Schema.String }), - ), - "hydra:totalItems": Schema.Int, -}).annotate({ - identifier: "TeamInterestResponse", - description: "Scoped team-interest rows.", - examples: [ - { - "hydra:member": [{ id: 1, userName: "ming.medlem", teamName: "Rekruttering" }], - "hydra:totalItems": 1, - }, - ], -}); - -/** - * Projected mailing list. - * - * @since 0.1.0 - * @category Schemas - */ -export const MailingListResponse = Schema.Array( - Schema.Struct({ name: Schema.String, emails: Schema.Array(Schema.String) }), -).annotate({ - identifier: "MailingListResponse", - description: "Scoped mailing-list projections.", - examples: [[{ name: "leder-trondheim", emails: ["lina.leder@example.org"] }]], -}); - -/** - * Representative department JSON projection. - * - * @since 0.1.0 - * @category Schemas - */ -export const DepartmentExample = { - departmentId: DepartmentId.make("1"), - name: "Datateknologi", - shortName: "DAT", - email: "datateknologi@example.org", - address: "Sem Sælands vei 1", - city: "Trondheim", - latitude: null, - longitude: null, - slackChannel: null, - logoPath: null, - active: true, - revision: 0, -} as const; - -/** - * Representative team JSON projection. - * - * @since 0.1.0 - * @category Schemas - */ -export const TeamExample = { - teamId: TeamId.make("rekruttering"), - departmentId: DepartmentId.make("1"), - name: "Rekruttering", - shortName: "REK", - shortDescription: "Interviews applicants", - description: "Interviews applicants and runs the admission process.", - email: "rekruttering@example.org", - deadline: "2026-09-15T23:59:59.999Z", - acceptApplication: true, - active: true, - revision: 0, -} as const; - -/** - * Representative field-of-study JSON projection. - * - * @since 0.1.0 - * @category Schemas - */ -export const FieldOfStudyExample = { - fieldOfStudyId: FieldOfStudyId.make("datateknologi"), - departmentId: DepartmentId.make("1"), - name: "Datateknologi", - shortName: "DAT", - active: true, - revision: 0, -} as const; - -/** @since 0.1.0 @category Endpoints */ -export const ListDepartmentsEndpoint = HttpApiEndpoint.get("listDepartments", "/api/departments", { - headers: ConditionalReadHeaders, - success: publicConditionalResponses( - Schema.Array( - DepartmentJsonSchema.annotate({ - identifier: "DepartmentJson", - description: "One native department directory row.", - examples: [DepartmentExample], - }), - ), - ), - error: endpointProblemResponses(OrganizationListDepartmentsProblem), -}) - .pipe((endpoint) => - annotateAccessSpec(endpoint, anonymousNativeAccess("organization.public-departments")), - ) - .annotateMerge( - operationAnnotations("List departments", "Returns the public native department directory."), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ListTeamsEndpoint = HttpApiEndpoint.get("listTeams", "/api/teams", { - headers: ConditionalReadHeaders, - success: publicConditionalResponses( - Schema.Array( - TeamJsonSchema.annotate({ - identifier: "TeamJson", - description: "One native team directory row.", - examples: [TeamExample], - }), - ), - ), - error: endpointProblemResponses(OrganizationListTeamsProblem), -}) - .pipe((endpoint) => - annotateAccessSpec(endpoint, anonymousNativeAccess("organization.public-teams")), - ) - .annotateMerge(operationAnnotations("List teams", "Returns the public native team directory.")); - -export const ListFieldOfStudiesEndpoint = HttpApiEndpoint.get( - "listFieldOfStudies", - "/api/field-of-studies", - { - headers: ConditionalReadHeaders, - success: publicConditionalResponses( - Schema.Array( - FieldOfStudyJsonSchema.annotate({ - identifier: "FieldOfStudyJson", - description: "One native field-of-study directory row.", - examples: [FieldOfStudyExample], - }), - ), - ), - error: endpointProblemResponses(OrganizationListFieldOfStudiesProblem), - }, -) - .pipe((endpoint) => - annotateAccessSpec(endpoint, anonymousNativeAccess("organization.public-field-of-studies")), - ) - .annotateMerge( - operationAnnotations("List fields of study", "Returns the public native study directory."), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ListTeamInterestEndpoint = HttpApiEndpoint.get( - "listTeamInterest", - "/api/team-interest-registrations", - { - query: OrganizationScopeQuery, - success: privateReadResponse(TeamInterestResponse), - error: endpointProblemResponses(OrganizationListTeamInterestProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "organization.read-team-interest", - canonicalScopeResolver: "organization.team-interest-registrations", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "List team interest", - "Returns registrations within the caller's team-interest reach: the own team for its leader, whole departments for department reach.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ListMailingListsEndpoint = HttpApiEndpoint.get( - "listMailingLists", - "/api/mailing-lists", - { - query: MailingListQuery, - success: privateReadResponse(MailingListResponse), - error: endpointProblemResponses(OrganizationListMailingListsProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "organization.read-mailing-lists", - canonicalScopeResolver: "organization.mailing-lists", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Project mailing lists", - "Projects addresses within the departments where the caller reads people.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const CreateDepartmentEndpoint = HttpApiEndpoint.post( - "createDepartment", - "/api/departments", - { - headers: IdempotencyHeaders, - payload: CreateDepartmentRequest, - success: createdMutationResponse(DepartmentJsonSchema.pipe(HttpApiSchema.status(201))), - error: endpointProblemResponses(OrganizationCreateDepartmentProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "organization.create-department", - canonicalScopeResolver: "organization.department-create", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Create department", - "Creates or replays an idempotent department command.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const CreateTeamEndpoint = HttpApiEndpoint.post("createTeam", "/api/teams", { - headers: IdempotencyHeaders, - payload: CreateTeamRequest, - success: createdMutationResponse(TeamJsonSchema.pipe(HttpApiSchema.status(201))), - error: endpointProblemResponses(OrganizationCreateTeamProblem), -}) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "organization.create-team", - canonicalScopeResolver: "organization.team-create", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations("Create team", "Creates or replays an idempotent team command."), - ); - -/** @since 0.1.0 @category Endpoints */ -export const CreateFieldOfStudyEndpoint = HttpApiEndpoint.post( - "createFieldOfStudy", - "/api/field-of-studies", - { - headers: IdempotencyHeaders, - payload: CreateFieldOfStudyRequest, - success: createdMutationResponse(FieldOfStudyJsonSchema.pipe(HttpApiSchema.status(201))), - error: endpointProblemResponses(OrganizationCreateFieldOfStudyProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "organization.create-field-of-study", - canonicalScopeResolver: "organization.field-of-study-create", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Create field of study", - "Creates or replays an idempotent field-of-study command.", - ), - ); - -/** - * Organization directory and administration API. - * - * @since 0.1.0 - * @category Groups - */ -const OrganizationLifecycleProblem = problemUnion("OrganizationLifecycleProblem", [ - "authority.denied", - "origin.denied", - "resource.not-found", - "precondition.failed", - "validation.failed", - "request.malformed", - "request.too-large", - "idempotency-key.invalid", - "idempotency.digest-conflict", - "idempotency.in-flight", - "idempotency.response-expired", - "transaction.conflict", - "internal.error", - "idempotency.unavailable", - "organization.unavailable", -]); - -export const ReadAppointmentManagementEndpoint = HttpApiEndpoint.get( - "readAppointmentManagement", - "/api/organization/appointments", - { - success: privateReadResponse(AppointmentManagement), - error: endpointProblemResponses(OrganizationLifecycleProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "organization.manage-appointments", - canonicalScopeResolver: "organization.appointment-management", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read appointment management", - "Returns authorized people, units, appointments, account access and history.", - ), - ); - -/** - * The rosters of Styret of an independent department and of Hovedstyret: the appointed seats and - * the seats that current team leadership derives, for the boards whose appointments the reader - * manages. - */ -export const ReadBoardRostersEndpoint = HttpApiEndpoint.get( - "readBoardRosters", - "/api/organization/board-rosters", - { - success: privateReadResponse(BoardRosters), - error: endpointProblemResponses(OrganizationLifecycleProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "organization.manage-appointments", - canonicalScopeResolver: "organization.appointment-management", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read board rosters", - "Returns the appointed and derived seats of the certificate-issuing boards whose appointments the reader manages, as of the request instant.", - ), - ); - -export const ExecuteOrganizationLifecycleEndpoint = HttpApiEndpoint.post( - "executeLifecycle", - "/api/organization/appointments/commands", - { - headers: IdempotencyHeaders, - payload: OrganizationLifecycleCommand, - success: entityMutationResponse(OrganizationLifecycleResult), - error: endpointProblemResponses(OrganizationLifecycleProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "organization.manage-appointments", - canonicalScopeResolver: "organization.appointment-management", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Manage appointments and account access", - "Applies one authorized, revision-checked lifecycle command with atomic history.", - ), - ); - -/** - * The delegations that one person manages: a Styret leader those of its department's teams, - * Hovedstyret or a global administrator those of national teams. - */ -export const ReadDelegationManagementEndpoint = HttpApiEndpoint.get( - "readDelegationManagement", - "/api/organization/delegations", - { - success: privateReadResponse(DelegationManagement), - error: endpointProblemResponses(OrganizationLifecycleProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "organization.manage-delegations", - canonicalScopeResolver: "organization.delegation-management", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read delegation management", - "Returns the teams, delegations and history within the caller's delegation management reach.", - ), - ); - -export const ExecuteDelegationEndpoint = HttpApiEndpoint.post( - "executeDelegation", - "/api/organization/delegations/commands", - { - headers: IdempotencyHeaders, - payload: DelegationCommand, - success: entityMutationResponse(DelegationResult), - error: endpointProblemResponses(OrganizationLifecycleProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "organization.manage-delegations", - canonicalScopeResolver: "organization.delegation-management", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Issue or end a delegation", - "Issues or ends one named, time-bounded delegation with atomic history under current authority.", - ), - ); - -export { - AppointmentManagement, - BoardRosters, - DelegableCapability, - DelegationArea, - DelegationCommand, - DelegationManagement, - DelegationResult, - OrganizationCapability, - OrganizationLifecycleCommand, - OrganizationLifecycleResult, -}; - -export class OrganizationApi extends HttpApiGroup.make("organization") - .add( - ReadAppointmentManagementEndpoint, - ReadBoardRostersEndpoint, - ExecuteOrganizationLifecycleEndpoint, - ReadDelegationManagementEndpoint, - ExecuteDelegationEndpoint, - ListDepartmentsEndpoint, - ListTeamsEndpoint, - ListFieldOfStudiesEndpoint, - ListTeamInterestEndpoint, - ListMailingListsEndpoint, - CreateDepartmentEndpoint, - CreateTeamEndpoint, - CreateFieldOfStudyEndpoint, - ) - .annotateMerge( - OpenApi.annotations({ - title: "Organization", - description: "Public organization directories and scoped administration.", - override: { "x-displayName": "Organization" }, - }), - ) {} diff --git a/packages/http-api/src/placements.ts b/packages/http-api/src/placements.ts deleted file mode 100644 index 692ffc459..000000000 --- a/packages/http-api/src/placements.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { - Affiliation, - AffiliationScope, - CoverageBoard, - CoverageCommand, - OwnAffiliationCommand, - OwnCoverageCommand, - OwnCoverageView, - PlacementBoard, - PlacementDraft, - PlacementCommand, - PlacementScope, - PlacementScopes, -} from "@vektorprogrammet/domain/placements"; -import { Schema } from "effect"; -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; -import { annotateAccessSpec, personNativeAccess } from "./access.js"; -import { operationAnnotations, PersonSecurity } from "./common.js"; -import { - StrongETag, - IdempotencyIfMatchHeaders, - privateReadResponse, - entityMutationResponse, - endpointProblemResponses, - problemUnion, -} from "./http-semantics.js"; - -export { - AffiliationScope, - CoverageCommand, - OwnAffiliationCommand, - OwnCoverageCommand, - PlacementCommand, - PlacementScope, - PlacementScopes, -}; - -export const OwnAffiliationResource = Schema.Struct({ - ...Affiliation.fields, - etag: StrongETag, -}).annotate({ identifier: "OwnAffiliationResource" }); - -export const PlacementBoardResource = Schema.Struct({ - ...PlacementBoard.fields, - etag: StrongETag, -}).annotate({ identifier: "PlacementBoardResource" }); - -export const OwnCoverageResource = Schema.Struct({ - ...OwnCoverageView.fields, - etag: StrongETag, -}).annotate({ identifier: "OwnCoverageResource" }); - -export const CoverageBoardResource = Schema.Struct({ - ...CoverageBoard.fields, - etag: StrongETag, -}).annotate({ identifier: "CoverageBoardResource" }); - -/** - * A draft is a read. `boardEtag` names the board version it was drafted from, for the - * `If-Match` of the placement commands that apply it. - */ -export const PlacementDraftResource = Schema.Struct({ - ...PlacementDraft.fields, - boardEtag: StrongETag, -}).annotate({ identifier: "PlacementDraftResource" }); - -export const PlacementProblem = problemUnion("PlacementProblem", [ - "request.malformed", - "request.too-large", - "precondition.invalid", - "idempotency-key.invalid", - "header.malformed", - "validation.failed", - "scope.invalid", - "authority.denied", - "resource.not-found", - "affiliation.transition-invalid", - "affiliation.inactive", - "placement.overlap", - "placement.inactive", - "school-service.proposal-empty", - "school-service.proposal-inactive", - "school-service.exception-review-invalid", - "commitment.target-invalid", - "commitment.duplicate", - "commitment.closed", - "commitment.outcome-invalid", - "commitment.interval-invalid", - "absence.target-invalid", - "absence.duplicate", - "absence.closed", - "coverage.owner-invalid", - "coverage.coverer-ineligible", - "coverage.coverer-unavailable", - "coverage.not-recorded", - - "precondition.required", - "precondition.failed", - "idempotency.in-flight", - "idempotency.digest-conflict", - "idempotency.response-expired", - "idempotency.unavailable", - "transaction.conflict", - "internal.error", - "media-type.unsupported", -]); - -const access = (capability: "placements.self" | "placements.manage", write = false) => - personNativeAccess({ - capability, - canonicalScopeResolver: "placements.explicit-department", - decisionTime: write ? "Transaction" : "SnapshotRead", - }); - -export const ListPlacementScopesEndpoint = HttpApiEndpoint.get( - "listScopes", - "/api/placements/scopes", - { - success: privateReadResponse(PlacementScopes), - error: endpointProblemResponses(PlacementProblem), - }, -) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access("placements.self"))) - .annotateMerge( - operationAnnotations( - "Select placement department and semester", - "Public department labels and canonical semesters; private candidate names require coordinator authority.", - ), - ); - -export const ReadOwnAffiliationEndpoint = HttpApiEndpoint.get( - "readOwnAffiliation", - "/api/placements/affiliation", - { - query: AffiliationScope.fields, - success: privateReadResponse(OwnAffiliationResource), - error: endpointProblemResponses(PlacementProblem), - }, -) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access("placements.self"))) - .annotateMerge( - operationAnnotations("Read own affiliation", "Authenticated Person only; no person selector."), - ); - -export const CommandOwnAffiliationEndpoint = HttpApiEndpoint.post( - "commandOwnAffiliation", - "/api/placements/affiliation", - { - query: AffiliationScope.fields, - headers: IdempotencyIfMatchHeaders, - payload: OwnAffiliationCommand, - success: entityMutationResponse(OwnAffiliationResource), - error: endpointProblemResponses(PlacementProblem), - }, -) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access("placements.self", true))) - .annotateMerge( - operationAnnotations( - "Request or withdraw own affiliation", - "Self nomination gives scoped coordinators permission to discover this person.", - ), - ); - -export const ReadPlacementBoardEndpoint = HttpApiEndpoint.get("readBoard", "/api/placements", { - query: PlacementScope.fields, - success: privateReadResponse(PlacementBoardResource), - error: endpointProblemResponses(PlacementProblem), -}) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access("placements.manage"))) - .annotateMerge( - operationAnnotations( - "Read placement board", - "Only holders of placement coordination in the department (a board leadership or a delegation) or global administrators.", - ), - ); - -export const CommandPlacementBoardEndpoint = HttpApiEndpoint.post( - "commandBoard", - "/api/placements", - { - query: PlacementScope.fields, - headers: IdempotencyIfMatchHeaders, - payload: PlacementCommand, - success: entityMutationResponse(PlacementBoardResource), - error: endpointProblemResponses(PlacementProblem), - }, -) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access("placements.manage", true))) - .annotateMerge( - operationAnnotations( - "Manage placement and dated school service", - "Commands keep demand, proposals, commitments, and placement changes inside one transaction.", - ), - ); - -export const ReadPlacementDraftEndpoint = HttpApiEndpoint.get( - "readDraft", - "/api/placements/draft", - { - query: PlacementScope.fields, - success: privateReadResponse(PlacementDraftResource), - error: endpointProblemResponses(PlacementProblem), - }, -) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access("placements.manage"))) - .annotateMerge( - operationAnnotations( - "Draft placements", - "Drafts open school demand for unplaced active assistants from their weekday availability and teaching blocks. Nothing is stored; coordinators apply the draft with placement commands.", - ), - ); - -export const ReadOwnCoverageEndpoint = HttpApiEndpoint.get( - "readOwnCoverage", - "/api/placements/coverage/own", - { - query: PlacementScope.fields, - success: privateReadResponse(OwnCoverageResource), - error: endpointProblemResponses(PlacementProblem), - }, -) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access("placements.self"))) - .annotateMerge( - operationAnnotations( - "Read own coverage and scheduled service", - "A person sees only commitments where they are scheduled or cover a recorded absence, and names of possible coverers only while they have an open absence.", - ), - ); - -export const CommandOwnCoverageEndpoint = HttpApiEndpoint.post( - "commandOwnCoverage", - "/api/placements/coverage/own", - { - query: PlacementScope.fields, - headers: IdempotencyIfMatchHeaders, - payload: OwnCoverageCommand, - success: entityMutationResponse(OwnCoverageResource), - error: endpointProblemResponses(PlacementProblem), - }, -) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access("placements.self", true))) - .annotateMerge( - operationAnnotations( - "Report own absence or record who covers it", - "The current person must own a scheduled assignment in an open commitment; coverage commands name only that person's own absence.", - ), - ); - -export const ReadCoverageBoardEndpoint = HttpApiEndpoint.get( - "readCoverageBoard", - "/api/placements/coverage", - { - query: PlacementScope.fields, - success: privateReadResponse(CoverageBoardResource), - error: endpointProblemResponses(PlacementProblem), - }, -) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access("placements.manage"))) - .annotateMerge( - operationAnnotations( - "Read coordinator coverage board", - "Only a scoped coordinator can read absences, coverage records, possible coverers, per-absence closures, and commitment outcomes.", - ), - ); - -export const CommandCoverageBoardEndpoint = HttpApiEndpoint.post( - "commandCoverageBoard", - "/api/placements/coverage", - { - query: PlacementScope.fields, - headers: IdempotencyIfMatchHeaders, - payload: CoverageCommand, - success: entityMutationResponse(CoverageBoardResource), - error: endpointProblemResponses(PlacementProblem), - }, -) - .middleware(PersonSecurity) - .pipe((e) => annotateAccessSpec(e, access("placements.manage", true))) - .annotateMerge( - operationAnnotations( - "Record coverage and decide dated service", - "Commands check coordinator scope, record absences and coverage, and derive actual attendance for immutable outcome evidence inside one transaction.", - ), - ); - -export class PlacementsApi extends HttpApiGroup.make("placements") - .add(ListPlacementScopesEndpoint) - .add(ReadOwnAffiliationEndpoint) - .add(CommandOwnAffiliationEndpoint) - .add(ReadPlacementBoardEndpoint) - .add(CommandPlacementBoardEndpoint) - .add(ReadPlacementDraftEndpoint) - .add(ReadOwnCoverageEndpoint) - .add(CommandOwnCoverageEndpoint) - .add(ReadCoverageBoardEndpoint) - .add(CommandCoverageBoardEndpoint) - .annotateMerge( - OpenApi.annotations({ - title: "Volunteer placement", - description: - "Explicit affiliation, placement, confirmed roster, dated school commitment, absence, coverage records, and immutable outcomes.", - }), - ) {} diff --git a/packages/http-api/src/profile.ts b/packages/http-api/src/profile.ts deleted file mode 100644 index b30b47e15..000000000 --- a/packages/http-api/src/profile.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Public HTTP contracts for the current user's profile. - * - * @since 0.1.0 - */ -import { PersonId, ProfileRoleSchema } from "@vektorprogrammet/domain/organization"; -import { OwnProfile } from "@vektorprogrammet/domain/profile"; -import { Schema } from "effect"; -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"; -import { annotateAccessSpec, personNativeAccess } from "./access.js"; -import { - ProfileReadOwnProfileProblem, - ProfileUpdateOwnProfileProblem, -} from "./endpoint-problems.js"; -import { - ConditionalReadHeaders, - endpointProblemResponses, - entityMutationResponse, - IdempotencyIfMatchHeaders, - privateConditionalResponses, -} from "./http-semantics.js"; -import { operationAnnotations, PersonSecurity } from "./common.js"; -import { ProfileMergePatch } from "./v2-schemas.js"; - -/** - * Dashboard role projection for navigation. A department administrator holds a capability beyond - * one team through a board leadership or a delegation; a team leader acts within the team. - * - * @since 0.1.0 - * @category Schemas - */ -export const UserRoleSchema = ProfileRoleSchema; - -/** - * Strict self-profile response exposed to the dashboard. - * - * @since 0.1.0 - * @category Schemas - */ -export const UserProfileResponse = Schema.Struct({ - personId: OwnProfile.fields.personId, - firstName: OwnProfile.fields.firstName, - lastName: OwnProfile.fields.lastName, - email: OwnProfile.fields.email, - phone: OwnProfile.fields.phone, - role: UserRoleSchema, - nameRevision: OwnProfile.fields.nameRevision, - contactRevision: OwnProfile.fields.contactRevision, -}).annotate({ - identifier: "UserProfileResponse", - description: "The current person's editable profile and authorization role projection.", - examples: [ - { - personId: PersonId.make("7202"), - firstName: "Ming", - lastName: "Medlem", - email: "ming.medlem@example.org", - phone: "+47 900 00 000", - role: "ROLE_TEAM_MEMBER", - nameRevision: 0, - contactRevision: 1, - }, - ], -}); - -/** - * Reads the current person's profile. - * - * @since 0.1.0 - * @category Endpoints - */ -export const ReadOwnProfileEndpoint = HttpApiEndpoint.get("readOwnProfile", "/api/profile", { - headers: ConditionalReadHeaders, - success: privateConditionalResponses(UserProfileResponse), - error: endpointProblemResponses(ProfileReadOwnProfileProblem), -}) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "profile.read-self", - canonicalScopeResolver: "profile.current-person", - requirements: ["profile.owner"], - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read own profile", - "Returns the profile selected by the current session.", - ), - ); - -/** - * Updates the current person's profile with optimistic revisions. - * - * @since 0.1.0 - * @category Endpoints - */ -export const UpdateOwnProfileEndpoint = HttpApiEndpoint.patch("updateOwnProfile", "/api/profile", { - headers: IdempotencyIfMatchHeaders, - payload: ProfileMergePatch.pipe( - HttpApiSchema.asJson({ contentType: "application/merge-patch+json" }), - ), - success: entityMutationResponse(UserProfileResponse), - error: endpointProblemResponses(ProfileUpdateOwnProfileProblem), -}) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "profile.update-self", - canonicalScopeResolver: "profile.current-person", - requirements: ["profile.owner"], - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Update own profile", - "Updates only the profile selected by the current session and returns the fresh projection.", - ), - ); - -/** - * Current-person profile endpoints. - * - * @since 0.1.0 - * @category Groups - */ -export class ProfileApi extends HttpApiGroup.make("profile") - .add(ReadOwnProfileEndpoint, UpdateOwnProfileEndpoint) - .annotateMerge( - OpenApi.annotations({ - title: "Profile", - description: "Authenticated self-service profile API.", - override: { "x-displayName": "Profile" }, - }), - ) {} diff --git a/packages/http-api/src/receipts.ts b/packages/http-api/src/receipts.ts deleted file mode 100644 index 2dd5efbaf..000000000 --- a/packages/http-api/src/receipts.ts +++ /dev/null @@ -1,724 +0,0 @@ -/** - * Public and internal HTTP contracts for receipt lifecycle operations. - * - * @since 0.1.0 - */ -import { - INTERNAL_RECEIPT_EVIDENCE_ACCESS, - RECEIPT_APPROVAL_QUEUE_ACCESS, - makeAccessSpec, - CredentialMechanismSchema, - CapabilityExpressionSchema, - CapabilityTypeId, - ConcealmentPolicySchema, -} from "@vektorprogrammet/domain/authz"; -import { - ReceiptCursor, - RECEIPT_PAGE_SIZE, - Receipt, - ReceiptId, - ReceiptSettlementEvidenceSchema, - ReceiptStatusSchema, -} from "@vektorprogrammet/domain/receipt"; -import { Schema } from "effect"; -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"; -import { annotateAccessSpec, personNativeAccess } from "./access.js"; -import { - operationAnnotations, - PersonOrServiceSecurity, - PersonSecurity, - SessionSecurity, -} from "./common.js"; -import { - InternalReadReceiptEvidenceProblem, - ReceiptsApproveReceiptProblem, - ReceiptsListReceiptsForApprovalProblem, - ReceiptsListReceiptsForSettlementProblem, - ReceiptsListReceiptsProblem, - ReceiptsReadReceiptFileProblem, - ReceiptsReadReceiptSettlementForFinanceProblem, - ReceiptsRejectReceiptProblem, - ReceiptsReopenReceiptProblem, - ReceiptsReviseReceiptProblem, - ReceiptsSettleReceiptProblem, - ReceiptsSubmitReceiptProblem, - ReceiptsWithdrawReceiptProblem, -} from "./endpoint-problems.js"; -import { - createdMutationResponse, - endpointProblemResponses, - entityMutationResponse, - IdempotencyHeaders, - IdempotencyIfMatchHeaders, - internalNoStoreResponse, - privateReadResponse, - StrongETag, -} from "./http-semantics.js"; -import { - ApproveReceiptRequest, - ReceiptResource, - ReceiptSettlementEvidenceResource, - RecordReceiptSettlementRequest, - RejectReceiptRequest, - ReopenReceiptRequest, - ReviseReceiptMultipartV2, - SubmitReceiptMultipartV2, - WithdrawReceiptRequest, -} from "./v2-schemas.js"; - -export { ReceiptId }; - -const privateEntityMutationResponse = (success: S) => - HttpApiSchema.WithHeaders(success, { - "cache-control": Schema.Literal("private, no-store"), - vary: Schema.Literal("Origin"), - etag: StrongETag, - }); - -const ReceiptSettlementQueueAccess = makeAccessSpec({ - exposure: "External", - acceptedCredentials: [ - CredentialMechanismSchema.cases.BetterAuthCookie.make({}), - CredentialMechanismSchema.cases.OAuthUserBearer.make({}), - ], - principalKinds: ["Person"], - capabilities: CapabilityExpressionSchema.cases.One.make({ - capability: { type: CapabilityTypeId.make("settleReceipt") }, - }), - requirements: [], - canonicalScopeResolver: "receipts.settlement-queue", - concealment: ConcealmentPolicySchema.cases.NotFound.make({ conceal: ["Capability", "Scope"] }), - decisionTime: "SnapshotRead", -}); - -const ReceiptSettlementReadAccess = makeAccessSpec({ - exposure: "External", - acceptedCredentials: [ - CredentialMechanismSchema.cases.BetterAuthCookie.make({}), - CredentialMechanismSchema.cases.OAuthUserBearer.make({}), - ], - principalKinds: ["Person"], - capabilities: CapabilityExpressionSchema.cases.One.make({ - capability: { type: CapabilityTypeId.make("settleReceipt") }, - }), - requirements: [], - canonicalScopeResolver: "receipts.by-id", - concealment: ConcealmentPolicySchema.cases.NotFound.make({ conceal: ["Capability", "Scope"] }), - decisionTime: "SnapshotRead", -}); - -const ReceiptSettlementMutationAccess = makeAccessSpec({ - exposure: "External", - acceptedCredentials: [ - CredentialMechanismSchema.cases.BetterAuthCookie.make({}), - CredentialMechanismSchema.cases.OAuthUserBearer.make({}), - ], - principalKinds: ["Person"], - capabilities: CapabilityExpressionSchema.cases.One.make({ - capability: { type: CapabilityTypeId.make("settleReceipt") }, - }), - requirements: [], - canonicalScopeResolver: "receipts.by-id", - concealment: ConcealmentPolicySchema.cases.NotFound.make({ conceal: ["Capability", "Scope"] }), - decisionTime: "Transaction", -}); - -/** - * Receipt list projection item. - * - * @since 0.1.0 - * @category Schemas - */ -export const ReceiptListItemExample = { - receiptId: "receipt-list", - visualId: "LIST-1", - ownerPersonId: "owner-list", - departmentId: "1", - amountOre: 1250, - currency: "NOK", - description: "list row", - receiptDate: "2026-08-24", - status: "Pending", - approvedAt: null, - settlement: null, - revision: 2, - etag: StrongETag.make('"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"'), -} as const; - -export const ReceiptListItem = Schema.Struct({ - receiptId: Schema.String, - visualId: Schema.String, - ownerPersonId: Schema.String, - departmentId: Schema.String, - amountOre: Schema.Int, - currency: Schema.Literals(["NOK"]), - description: Schema.String, - receiptDate: Schema.String, - status: ReceiptStatusSchema, - revision: Schema.Int, - approvedAt: Receipt.json.fields.approvedAt, - settlement: Schema.NullOr(ReceiptSettlementEvidenceSchema), - etag: StrongETag, -}).annotate({ - identifier: "ReceiptListItem", - description: "Owner or approver receipt projection.", - examples: [ - { - receiptId: "receipt-list", - visualId: "LIST-1", - ownerPersonId: "owner-list", - departmentId: "1", - amountOre: 1250, - currency: "NOK", - description: "list row", - receiptDate: "2026-08-24", - status: "Pending", - approvedAt: null, - settlement: null, - revision: 2, - etag: StrongETag.make('"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"'), - }, - ], -}); - -/** - * Receipt list envelope. - * - * @since 0.1.0 - * @category Schemas - */ -export const ReceiptListResponse = Schema.Struct({ - items: Schema.Array(ReceiptListItem).pipe(Schema.check(Schema.isMaxLength(RECEIPT_PAGE_SIZE))), - nextCursor: Schema.optional(ReceiptCursor), -}).annotate({ - identifier: "ReceiptListResponse", - description: "One bounded receipt page and optional continuation.", - examples: [ - { - items: [ReceiptListItemExample], - }, - ], -}); - -export const ReceiptApprovalQueueItem = Schema.Struct({ - receiptId: Schema.String, - visualId: Schema.String, - ownerPersonId: Schema.String, - departmentId: Schema.String, - amountOre: Schema.Int, - currency: Schema.Literals(["NOK"]), - description: Schema.String, - receiptDate: Schema.String, - status: ReceiptStatusSchema, - revision: Schema.Int, - etag: StrongETag, - approvedAt: Receipt.json.fields.approvedAt, -}).annotate({ - identifier: "ReceiptApprovalQueueItem", - description: "One receipt visible in the current approver queue.", -}); - -export const ReceiptApprovalQueueResponse = Schema.Struct({ - items: Schema.Array(ReceiptApprovalQueueItem).pipe( - Schema.check(Schema.isMaxLength(RECEIPT_PAGE_SIZE)), - ), - nextCursor: Schema.optional(ReceiptCursor), -}).annotate({ - identifier: "ReceiptApprovalQueueResponse", - description: "One bounded page of receipts in the current approver queue.", -}); - -export const ReceiptSettlementQueueItem = Schema.Struct({ - receiptId: Schema.String, - visualId: Schema.String, - ownerPersonId: Schema.String, - departmentId: Schema.String, - amountOre: Schema.Int, - currency: Schema.Literal("NOK"), - description: Schema.String, - receiptDate: Schema.String, - status: Schema.Literal("Approved"), - approvedAt: Schema.String, - revision: Schema.Int, - etag: StrongETag, -}).annotate({ - identifier: "ReceiptSettlementQueueItem", - description: "One approved, unsettled receipt visible in the current settlement scope.", -}); - -export const ReceiptSettlementQueueResponse = Schema.Struct({ - items: Schema.Array(ReceiptSettlementQueueItem).pipe( - Schema.check(Schema.isMaxLength(RECEIPT_PAGE_SIZE)), - ), - nextCursor: Schema.optional(ReceiptCursor), -}).annotate({ - identifier: "ReceiptSettlementQueueResponse", - description: "One bounded page of approved, unsettled receipts in the current settlement scope.", -}); - -/** - * Internal receipt file/outbox/audit lifecycle evidence. - * - * @since 0.1.0 - * @category Schemas - */ -export const ReceiptLifecycleEvidenceResponse = Schema.Struct({ - receiptId: Schema.String, - file: Schema.Struct({ - fileRef: Schema.String, - objectKey: Schema.String, - contentType: Schema.String, - byteLength: Schema.Int, - sha256: Schema.String, - }), - settlement: Schema.NullOr(ReceiptSettlementEvidenceSchema), - outbox: Schema.Array( - Schema.Struct({ - effectId: Schema.String, - effectType: Schema.String, - commandId: Schema.String, - receiptId: Schema.String, - ordinal: Schema.Int, - status: Schema.String, - attempts: Schema.Int, - lastFailureTag: Schema.NullOr(Schema.String), - }), - ), - audit: Schema.Array( - Schema.Struct({ - commandId: Schema.String, - receiptId: Schema.String, - action: Schema.String, - receiptRevision: Schema.Int, - }), - ), -}).annotate({ - identifier: "ReceiptLifecycleEvidenceResponse", - description: "E2E-only receipt file, outbox, and audit evidence.", -}); - -const ReceiptStatusQuery = { - cursor: Schema.optional(ReceiptCursor), - status: Schema.optional(ReceiptStatusSchema), -}; - -const OwnerReceiptStatusQuery = { - cursor: Schema.optional(ReceiptCursor), - status: Schema.optional(Schema.Union([ReceiptStatusSchema, Schema.Array(ReceiptStatusSchema)])), -}; - -const ReceiptParams = { receiptId: ReceiptId }; - -const ReceiptFileContentType = Schema.Literals(["image/jpeg", "image/png", "application/pdf"]); - -const ReceiptFileContentLength = Schema.String.pipe( - Schema.check( - Schema.makeFilter((value) => /^[1-9]\d*$/u.test(value), { - message: "a positive decimal byte length", - }), - ), -); - -const ReceiptFileContentDisposition = Schema.Literals([ - 'inline; filename="receipt.jpg"', - 'inline; filename="receipt.png"', - 'inline; filename="receipt.pdf"', -]); - -const ReceiptPrivateFileResponse = HttpApiSchema.WithHeaders( - Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), - { - "cache-control": Schema.Literal("private, no-store"), - vary: Schema.Literal("Origin"), - "content-type": ReceiptFileContentType, - "content-length": ReceiptFileContentLength, - "content-disposition": ReceiptFileContentDisposition, - "x-content-type-options": Schema.Literal("nosniff"), - }, -); - -/** @since 0.1.0 @category Endpoints */ -export const SubmitReceiptEndpoint = HttpApiEndpoint.post("submitReceipt", "/api/receipts", { - query: { departmentId: Schema.optional(Schema.String) }, - headers: IdempotencyHeaders, - payload: SubmitReceiptMultipartV2, - success: createdMutationResponse(ReceiptResource.pipe(HttpApiSchema.status(201))), - error: endpointProblemResponses(ReceiptsSubmitReceiptProblem), -}) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "submitReceipt", - canonicalScopeResolver: "receipts.create", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Submit receipt", - "Stages a receipt file and submits an idempotent command.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ReviseReceiptEndpoint = HttpApiEndpoint.patch( - "reviseReceipt", - "/api/receipts/:receiptId", - { - params: ReceiptParams, - headers: IdempotencyIfMatchHeaders, - payload: ReviseReceiptMultipartV2, - success: entityMutationResponse(ReceiptResource), - error: endpointProblemResponses(ReceiptsReviseReceiptProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "receipts.manage-owned", - canonicalScopeResolver: "receipts.by-id", - requirements: ["receipts.owner", "receipts.pending"], - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations("Revise receipt", "Revises a pending owned receipt and optional file."), - ); - -/** @since 0.1.0 @category Endpoints */ -export const WithdrawReceiptEndpoint = HttpApiEndpoint.post( - "withdrawReceipt", - "/api/receipts/:receiptId:withdraw", - { - params: ReceiptParams, - headers: IdempotencyIfMatchHeaders, - payload: WithdrawReceiptRequest, - success: entityMutationResponse(ReceiptResource), - error: endpointProblemResponses(ReceiptsWithdrawReceiptProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "receipts.manage-owned", - canonicalScopeResolver: "receipts.by-id", - requirements: ["receipts.owner", "receipts.pending"], - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Withdraw receipt", - "Withdraws a pending receipt owned by the current person.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ListReceiptsEndpoint = HttpApiEndpoint.get("listReceipts", "/api/receipts", { - query: OwnerReceiptStatusQuery, - success: privateReadResponse(ReceiptListResponse), - error: endpointProblemResponses(ReceiptsListReceiptsProblem), -}) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "receipts.read-owned", - canonicalScopeResolver: "receipts.owned", - requirements: ["receipts.owner"], - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations("List owned receipts", "Lists receipts owned by the current person."), - ); - -/** Owner-only, verified private bytes; storage identities are never public. */ -export const ReadReceiptFileEndpoint = HttpApiEndpoint.get( - "readReceiptFile", - "/api/receipts/:receiptId/file", - { - params: ReceiptParams, - success: ReceiptPrivateFileResponse, - error: endpointProblemResponses(ReceiptsReadReceiptFileProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "receipts.read-owned", - canonicalScopeResolver: "receipts.by-id", - requirements: ["receipts.owner"], - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read owned receipt file", - "Downloads verified private bytes for the canonical owner.", - ), - ); - -/** - * Scoped approver-only receipt bytes. Terminal receipt files remain readable - * while the current rule-aware approver relationship remains active. - */ -export const ReadReceiptFileForApprovalEndpoint = HttpApiEndpoint.get( - "readReceiptFileForApproval", - "/api/receipt-approval-queue/:receiptId/file", - { - params: ReceiptParams, - success: ReceiptPrivateFileResponse, - error: endpointProblemResponses(ReceiptsReadReceiptFileProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "approveReceipt", - canonicalScopeResolver: "receipts.by-id", - requirements: ["receipts.approver-relationship"], - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read receipt file for approval", - "Downloads verified private bytes for the current scoped approver.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ListReceiptsForApprovalEndpoint = HttpApiEndpoint.get( - "listReceiptsForApproval", - "/api/receipt-approval-queue", - { - query: ReceiptStatusQuery, - success: privateReadResponse(ReceiptApprovalQueueResponse), - error: endpointProblemResponses(ReceiptsListReceiptsForApprovalProblem), - }, -) - .middleware(PersonOrServiceSecurity) - .pipe((endpoint) => annotateAccessSpec(endpoint, RECEIPT_APPROVAL_QUEUE_ACCESS)) - .annotateMerge( - operationAnnotations( - "List receipts for approval", - "Lists receipts in the caller's approval scope.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ListReceiptsForSettlementEndpoint = HttpApiEndpoint.get( - "listReceiptsForSettlement", - "/api/receipt-settlement-queue", - { - query: { cursor: Schema.optional(ReceiptCursor) }, - success: privateReadResponse(ReceiptSettlementQueueResponse), - error: endpointProblemResponses(ReceiptsListReceiptsForSettlementProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => annotateAccessSpec(endpoint, ReceiptSettlementQueueAccess)) - .annotateMerge( - operationAnnotations( - "List receipts for settlement", - "Lists approved, unsettled receipts visible to the current settlement grant.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ReadReceiptSettlementForFinanceEndpoint = HttpApiEndpoint.get( - "readReceiptSettlementForFinance", - "/api/receipt-settlement-queue/:receiptId", - { - params: ReceiptParams, - success: privateReadResponse(ReceiptSettlementEvidenceResource), - error: endpointProblemResponses(ReceiptsReadReceiptSettlementForFinanceProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => annotateAccessSpec(endpoint, ReceiptSettlementReadAccess)) - .annotateMerge( - operationAnnotations( - "Read receipt settlement for finance", - "Reads immutable settlement evidence visible to the current settlement grant.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const SettleReceiptEndpoint = HttpApiEndpoint.post( - "settleReceipt", - "/api/receipts/:receiptId:settle", - { - params: ReceiptParams, - headers: IdempotencyIfMatchHeaders, - payload: RecordReceiptSettlementRequest, - success: privateEntityMutationResponse(ReceiptSettlementEvidenceResource), - error: endpointProblemResponses(ReceiptsSettleReceiptProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => annotateAccessSpec(endpoint, ReceiptSettlementMutationAccess)) - .annotateMerge( - operationAnnotations( - "Record receipt settlement", - "Records immutable evidence for an externally settled approved receipt.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ApproveReceiptEndpoint = HttpApiEndpoint.post( - "approveReceipt", - "/api/receipts/:receiptId:approve", - { - params: ReceiptParams, - headers: IdempotencyIfMatchHeaders, - payload: ApproveReceiptRequest, - success: entityMutationResponse(ReceiptResource), - error: endpointProblemResponses(ReceiptsApproveReceiptProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "approveReceipt", - canonicalScopeResolver: "receipts.by-id", - requirements: ["receipts.pending", "receipts.approver-relationship"], - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge(operationAnnotations("Approve receipt", "Approves a pending receipt.")); - -/** @since 0.1.0 @category Endpoints */ -export const RejectReceiptEndpoint = HttpApiEndpoint.post( - "rejectReceipt", - "/api/receipts/:receiptId:reject", - { - params: ReceiptParams, - headers: IdempotencyIfMatchHeaders, - payload: RejectReceiptRequest, - success: entityMutationResponse(ReceiptResource), - error: endpointProblemResponses(ReceiptsRejectReceiptProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "approveReceipt", - canonicalScopeResolver: "receipts.by-id", - requirements: ["receipts.pending", "receipts.approver-relationship"], - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge(operationAnnotations("Reject receipt", "Rejects a pending receipt.")); - -export const ReopenReceiptEndpoint = HttpApiEndpoint.post( - "reopenReceipt", - "/api/receipts/:receiptId:reopen", - { - params: ReceiptParams, - headers: IdempotencyIfMatchHeaders, - payload: ReopenReceiptRequest, - success: entityMutationResponse(ReceiptResource), - error: endpointProblemResponses(ReceiptsReopenReceiptProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "approveReceipt", - canonicalScopeResolver: "receipts.by-id", - requirements: ["receipts.rejected", "receipts.approver-relationship"], - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Reopen rejected receipt", - "Reopens a rejected receipt for owner correction.", - ), - ); - -/** - * Owner and approver receipt API. - * - * @since 0.1.0 - * @category Groups - */ -export class ReceiptsApi extends HttpApiGroup.make("receipts") - .add( - ReadReceiptFileEndpoint, - ReadReceiptFileForApprovalEndpoint, - SubmitReceiptEndpoint, - ReviseReceiptEndpoint, - WithdrawReceiptEndpoint, - ListReceiptsEndpoint, - ListReceiptsForApprovalEndpoint, - ApproveReceiptEndpoint, - ListReceiptsForSettlementEndpoint, - ReadReceiptSettlementForFinanceEndpoint, - SettleReceiptEndpoint, - RejectReceiptEndpoint, - ReopenReceiptEndpoint, - ) - .annotateMerge( - OpenApi.annotations({ - title: "Receipts", - description: "Receipt owner lifecycle and scoped approval operations.", - }), - ) {} - -/** @since 0.1.0 @category Endpoints */ -export const ReadReceiptEvidenceEndpoint = annotateAccessSpec( - HttpApiEndpoint.get("readReceiptEvidence", "/api/receipt-lifecycle-evidence-records/:receiptId", { - params: ReceiptParams, - success: internalNoStoreResponse(ReceiptLifecycleEvidenceResponse), - error: endpointProblemResponses(InternalReadReceiptEvidenceProblem, { cors: false }), - }) - .middleware(SessionSecurity) - .annotateMerge( - operationAnnotations("Read receipt evidence", "Reads E2E-only receipt lifecycle evidence."), - ), - INTERNAL_RECEIPT_EVIDENCE_ACCESS, -); - -/** - * Internal evidence routes exist only in `InternalNativeApi`. - * @since 0.1.0 - * @category Groups - */ -export class InternalReceiptsApi extends HttpApiGroup.make("internal") - .add(ReadReceiptEvidenceEndpoint) - .annotateMerge( - OpenApi.annotations({ - title: "Internal evidence", - description: "E2E-only evidence operations.", - exclude: true, - }), - ) {} diff --git a/packages/http-api/src/recruitment.ts b/packages/http-api/src/recruitment.ts deleted file mode 100644 index 7acd68093..000000000 --- a/packages/http-api/src/recruitment.ts +++ /dev/null @@ -1,797 +0,0 @@ -/** - * Public HTTP contracts for recruitment assignment, scheduling, and interviews. - * - * @since 0.1.0 - */ -import { - RecruitmentMaintenanceCommand, - RecruitmentMaintenanceResult, - QuestionnaireManagement, - InterviewStaffingManagement, -} from "@vektorprogrammet/domain/recruitment"; -import { AdmissionPeriodId } from "@vektorprogrammet/domain/admission-period"; -import { ApplicantIdSchema, PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; -import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; -import { - CancelInterviewObservationSchema, - containsRecruitmentInvitationCapabilitySequence, - FinalizeInterviewObservationSchema, - InterviewRecommendationSchema, - InterviewReport, - InterviewReportQuery, - InterviewSchemaId, - interviewRecommendations, - interviewScoreTotal, - RecruitmentAssignmentBoardQuerySchema, - RecruitmentAssignmentBoardSchema, - RecruitmentInterviewConductObservationSchema, - RecruitmentInterviewId, - RecruitmentInterviewerOptionSchema, - RecruitmentInvitationCapabilitySchema, - RecruitmentInvitationRejectInputSchema, - RecruitmentInvitationRequestNewTimeInputSchema, - RecruitmentInvitationResponseMessageSchema, - RecruitmentInvitationResponseObservationSchema, - RecruitmentInterviewQuestionSnapshot, - RecruitmentSchedulingInterviewSchema, - RecruitmentSchedulingBoardSchema, - type RecruitmentAssignmentBoard, - type RecruitmentAssignmentBoardQuery, - type RecruitmentInterviewConductObservation, - type RecruitmentSchedulingInterview, -} from "@vektorprogrammet/domain/recruitment"; - -const InvitationResponseObservationExample: any = { - scheduledAt: "2026-09-10T14:00:00.000Z", - room: "Realfagbygget, R90", - campus: "Gløshaugen", - responseState: "Pending", - responseMessage: null, -} as const; - -const AssignmentBoardExample: any = { - admissionPeriodId: AdmissionPeriodId.make("period-1"), - departmentId: DepartmentId.make("1"), - candidates: [ - { - applicationId: PublicApplicationIdSchema.make("app-0080"), - applicantId: ApplicantIdSchema.make("applicant-0080"), - firstName: "Ming", - lastName: "Medlem", - email: "ming.medlem@example.org", - submittedAt: "2026-08-15T12:00:00.000Z", - applicationState: "Received", - interviewState: "Unassigned", - interviewer: null, - interviewSchema: null, - scheduledAt: null, - }, - ], - interviewers: [{ personId: PersonId.make("1001"), displayName: "Kari Leder" }], - interviewSchemas: [ - { - interviewSchemaId: InterviewSchemaId.make("interview-schema-1"), - name: "Standardintervju", - questionCount: 5, - active: true, - revision: 2, - }, - ], -}; - -const SchedulingBoardExample: any = { - departmentId: DepartmentId.make("1"), - interviews: [ - { - interviewId: RecruitmentInterviewId.make("interview-1"), - applicationId: PublicApplicationIdSchema.make("app-0080"), - departmentId: DepartmentId.make("1"), - interviewer: { - personId: PersonId.make("1001"), - displayName: "Kari Leder", - email: "kari.leder@example.org", - phone: "+47 900 00 000", - }, - coInterviewer: { - personId: PersonId.make("1003"), - displayName: "Ola Medintervjuer", - }, - applicant: { - applicationId: PublicApplicationIdSchema.make("app-0080"), - applicantId: ApplicantIdSchema.make("applicant-0080"), - firstName: "Ming", - lastName: "Medlem", - email: "ming.medlem@example.org", - phone: "+47 900 00 000", - }, - revision: 2, - etag: StrongETag.make('"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"'), - schedule: { - interviewId: RecruitmentInterviewId.make("interview-1"), - scheduledAt: "2026-09-10T14:00:00.000Z", - room: "Realfagbygget, R90", - campus: "Gløshaugen", - mapLink: "https://maps.example.org/r90", - message: "Interview invitation with room details.", - scheduledByPersonId: PersonId.make("1002"), - committedAt: "2026-08-20T10:05:00.000Z", - scheduleRevision: 1, - }, - notificationState: "Pending", - responseState: "Pending", - responseMessage: null, - }, - ], -}; - -const ConductObservationExample: any = { - interviewId: RecruitmentInterviewId.make("interview-1"), - applicationId: PublicApplicationIdSchema.make("app-0080"), - applicant: { - applicantId: ApplicantIdSchema.make("applicant-0080"), - firstName: "Ming", - lastName: "Medlem", - }, - schedule: { - interviewId: RecruitmentInterviewId.make("interview-1"), - scheduledAt: "2026-09-10T14:00:00.000Z", - room: "Realfagbygget, R90", - campus: "Gløshaugen", - mapLink: "https://maps.example.org/r90", - message: "Interview invitation with room details.", - scheduledByPersonId: PersonId.make("1002"), - committedAt: "2026-08-20T10:05:00.000Z", - scheduleRevision: 1, - }, - invitationResponse: "Accepted", - questions: [ - { - interviewId: RecruitmentInterviewId.make("interview-1"), - questionId: "q-1", - ordinal: 1, - prompt: "Why did you apply to this programme?", - helpText: null, - kind: "text", - alternatives: [], - }, - ], - answers: [{ questionId: "q-1", answer: "I am drawn to the study programme's breadth." }], - score: { explanatoryPower: 7, roleModel: 8, suitability: 6 }, - recommendation: "Ja", - completionState: "Completed", - cancellationState: "NotCancelled", - finalizedAt: "2026-09-12T15:00:00.000Z", - cancelledAt: null, - revision: 3, - canFinalize: false, - canCancel: false, -}; - -import * as Schema from "effect/Schema"; -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"; -import { annotateAccessSpec, invitationNativeAccess, personNativeAccess } from "./access.js"; -import { InvitationCapabilitySecurity, operationAnnotations, PersonSecurity } from "./common.js"; -import { - RecruitmentCancelInterviewProblem, - RecruitmentCorrectInterviewAssessmentProblem, - RecruitmentConfirmInvitationProblem, - RecruitmentCreateApplicationInterviewProblem, - RecruitmentFinalizeInterviewProblem, - RecruitmentReadAssignmentBoardProblem, - RecruitmentReadInterviewConductProblem, - RecruitmentReadInvitationResponseProblem, - RecruitmentReadSchedulingBoardProblem, - RecruitmentReadInterviewReportProblem, - RecruitmentRejectInvitationProblem, - RecruitmentRequestNewInvitationTimeProblem, - RecruitmentScheduleInterviewProblem, -} from "./endpoint-problems.js"; -import { - problemUnion, - ConditionalReadHeaders, - createdMutationResponse, - endpointProblemResponses, - entityMutationResponse, - IdempotencyHeaders, - IdempotencyIfMatchHeaders, - IfMatchHeaders, - noContentMutationResponse, - privateConditionalResponses, - privateReadResponse, - StrongETag, -} from "./http-semantics.js"; -import { - CancelInterviewRequest, - CancelInterviewResponse, - CorrectInterviewAssessmentRequest, - CorrectInterviewAssessmentResponse, - CreateApplicationInterviewRequest, - EmptyJsonRequest, - FinalizeInterviewRequest, - FinalizeInterviewResponse, - RecruitmentInterviewResource, - ScheduleInterviewRequest, - ScheduleInterviewResponse, -} from "./v2-schemas.js"; - -export { - CancelInterviewObservationSchema, - containsRecruitmentInvitationCapabilitySequence, - FinalizeInterviewObservationSchema, - InterviewRecommendationSchema, - InterviewReport, - InterviewReportQuery, - InterviewSchemaId, - interviewRecommendations, - interviewScoreTotal, - RecruitmentAssignmentBoardQuerySchema, - RecruitmentAssignmentBoardSchema, - RecruitmentInterviewConductObservationSchema, - RecruitmentInterviewId, - RecruitmentInterviewerOptionSchema, - RecruitmentInvitationCapabilitySchema, - RecruitmentInvitationResponseMessageSchema, - RecruitmentInvitationResponseObservationSchema, - RecruitmentInterviewQuestionSnapshot, - RecruitmentSchedulingInterviewSchema, -}; - -export type { - RecruitmentAssignmentBoard, - RecruitmentAssignmentBoardQuery, - RecruitmentInterviewConductObservation, - RecruitmentSchedulingInterview, -}; - -/** - * Exact empty JSON object required when accepting an invitation. - * - * @since 0.1.0 - * @category Schemas - */ -export const ConfirmInvitationPayload = EmptyJsonRequest.annotate({ - identifier: "ConfirmInvitationRequest", - description: "An exact empty JSON object.", - examples: [{}], -}); - -export const InvitationRejectInput = RecruitmentInvitationRejectInputSchema.annotate({ - identifier: "InvitationRejectInput", - description: "Optional short rejection message.", - examples: [{ message: "Cannot attend that day." }, {}], -}); - -export const InvitationRequestNewTimeInput = - RecruitmentInvitationRequestNewTimeInputSchema.annotate({ - identifier: "InvitationRequestNewTimeInput", - description: "Short message proposing another time.", - examples: [{ message: "Could we do Thursday instead?" }], - }); - -export const InvitationResponseObservation = - RecruitmentInvitationResponseObservationSchema.annotate({ - identifier: "InvitationResponseObservation", - description: "Interview invitation state visible to the invitee.", - examples: [InvitationResponseObservationExample], - }); - -export const AssignmentBoard = RecruitmentAssignmentBoardSchema.annotate({ - identifier: "AssignmentBoard", - description: - "Candidates and interviewers in the departments where the caller manages interviews.", - examples: [AssignmentBoardExample], -}); - -export const SchedulingInterview = RecruitmentSchedulingInterviewSchema.mapMembers((members) => [ - members[0].mapFields((fields) => ({ ...fields, etag: StrongETag })), - members[1].mapFields((fields) => ({ ...fields, etag: StrongETag })), - members[2].mapFields((fields) => ({ ...fields, etag: StrongETag })), - members[3].mapFields((fields) => ({ ...fields, etag: StrongETag })), -]).annotate({ - identifier: "SchedulingInterview", - description: "One interview with the strong validator for its mutation.", -}); - -export const SchedulingBoard = Schema.Struct({ - departmentId: RecruitmentSchedulingBoardSchema.fields.departmentId, - interviews: Schema.Array(SchedulingInterview), -}).annotate({ - identifier: "SchedulingBoard", - description: "Interviews visible to the current member.", - examples: [SchedulingBoardExample], -}); - -export const ConductObservation = RecruitmentInterviewConductObservationSchema.annotate({ - identifier: "ConductObservation", - description: "Interview questions, answers, and conduct state.", - examples: [ConductObservationExample], -}); - -/** @since 0.1.0 @category Endpoints */ -export const ReadInvitationResponseEndpoint = HttpApiEndpoint.get( - "readInvitationResponse", - "/api/recruitment/invitation-response", - { - headers: ConditionalReadHeaders, - success: privateConditionalResponses(InvitationResponseObservation), - error: endpointProblemResponses(RecruitmentReadInvitationResponseProblem), - }, -) - .middleware(InvitationCapabilitySecurity) - .pipe((endpoint) => annotateAccessSpec(endpoint, invitationNativeAccess([], "SnapshotRead"))) - .annotateMerge( - operationAnnotations( - "Read invitation response", - "Reads an interview invitation by capability.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ConfirmInvitationEndpoint = HttpApiEndpoint.post( - "confirmInvitation", - "/api/recruitment/invitation-response:confirm", - { - params: {}, - headers: IfMatchHeaders, - payload: ConfirmInvitationPayload, - success: noContentMutationResponse({ etag: true }), - error: endpointProblemResponses(RecruitmentConfirmInvitationProblem), - }, -) - .middleware(InvitationCapabilitySecurity) - .pipe((endpoint) => annotateAccessSpec(endpoint, invitationNativeAccess([], "Transaction"))) - .annotateMerge( - operationAnnotations("Confirm invitation", "Accepts an interview invitation by capability."), - ); - -/** @since 0.1.0 @category Endpoints */ -export const RejectInvitationEndpoint = HttpApiEndpoint.post( - "rejectInvitation", - "/api/recruitment/invitation-response:reject", - { - params: {}, - headers: IfMatchHeaders, - payload: InvitationRejectInput, - success: noContentMutationResponse({ etag: true }), - error: endpointProblemResponses(RecruitmentRejectInvitationProblem), - }, -) - .middleware(InvitationCapabilitySecurity) - .pipe((endpoint) => annotateAccessSpec(endpoint, invitationNativeAccess([], "Transaction"))) - .annotateMerge( - operationAnnotations("Reject invitation", "Rejects an interview invitation by capability."), - ); - -/** @since 0.1.0 @category Endpoints */ -export const RequestNewInvitationTimeEndpoint = HttpApiEndpoint.post( - "requestNewInvitationTime", - "/api/recruitment/invitation-response:request-new-time", - { - params: {}, - headers: IfMatchHeaders, - payload: InvitationRequestNewTimeInput, - success: noContentMutationResponse({ etag: true }), - error: endpointProblemResponses(RecruitmentRequestNewInvitationTimeProblem), - }, -) - .middleware(InvitationCapabilitySecurity) - .pipe((endpoint) => annotateAccessSpec(endpoint, invitationNativeAccess([], "Transaction"))) - .annotateMerge( - operationAnnotations( - "Request a new invitation time", - "Requests another interview time by capability.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ReadAssignmentBoardEndpoint = HttpApiEndpoint.get( - "readAssignmentBoard", - "/api/recruitment/application-assignments", - { - query: RecruitmentAssignmentBoardQuerySchema.fields, - success: privateReadResponse(AssignmentBoard), - error: endpointProblemResponses(RecruitmentReadAssignmentBoardProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "reviewApplicants", - canonicalScopeResolver: "recruitment.application-assignments", - requirements: ["organization.single-department-administrator"], - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read assignment board", - "Returns applicants and interviewers where the caller manages interviews.", - ), - ); - -/** Scoped completed conduct projection; raw conduct access remains separate. */ -export const ReadInterviewReportEndpoint = HttpApiEndpoint.get( - "readInterviewReport", - "/api/recruitment/interview-report", - { - query: InterviewReportQuery.fields, - success: privateReadResponse(InterviewReport), - error: endpointProblemResponses(RecruitmentReadInterviewReportProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "recruitment.read-interview-report", - canonicalScopeResolver: "recruitment.interview-report", - requirements: ["organization.single-department-administrator"], - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read completed interview report", - "Returns limited completed interview observations for an explicitly selected department admission period; proven self assessments are excluded.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ReadSchedulingBoardEndpoint = HttpApiEndpoint.get( - "readSchedulingBoard", - "/api/recruitment/interviews", - { - success: privateReadResponse(SchedulingBoard), - error: endpointProblemResponses(RecruitmentReadSchedulingBoardProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "recruitment.read-interviews", - canonicalScopeResolver: "recruitment.interviews", - requirements: ["organization.single-department-member"], - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read scheduling board", - "Returns interviews visible to the current member.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const AssignApplicantEndpoint = HttpApiEndpoint.post( - "createApplicationInterview", - "/api/recruitment/applications/:applicationId/interviews", - { - params: { applicationId: PublicApplicationIdSchema }, - headers: IdempotencyHeaders, - payload: CreateApplicationInterviewRequest, - success: createdMutationResponse(RecruitmentInterviewResource.pipe(HttpApiSchema.status(201))), - error: endpointProblemResponses(RecruitmentCreateApplicationInterviewProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "reviewApplicants", - canonicalScopeResolver: "recruitment.application-by-id", - requirements: [ - "organization.single-department-administrator", - "recruitment.interviewer-eligible", - ], - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations("Assign applicant", "Assigns an applicant to an eligible interviewer."), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ScheduleInterviewEndpoint = HttpApiEndpoint.post( - "scheduleInterview", - "/api/recruitment/interviews/:interviewId:schedule", - { - params: { interviewId: RecruitmentInterviewId }, - headers: IdempotencyIfMatchHeaders, - payload: ScheduleInterviewRequest, - success: entityMutationResponse(ScheduleInterviewResponse), - error: endpointProblemResponses(RecruitmentScheduleInterviewProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "recruitment.schedule-interview", - canonicalScopeResolver: "recruitment.interview-by-id", - requirements: ["recruitment.assigned-interviewer-or-administrator"], - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations("Schedule interview", "Schedules an assigned interview and invitation."), - ); - -/** @since 0.1.0 @category Endpoints */ -export const ReadInterviewConductEndpoint = HttpApiEndpoint.get( - "readInterviewConduct", - "/api/recruitment/interviews/:interviewId", - { - params: { interviewId: RecruitmentInterviewId }, - headers: ConditionalReadHeaders, - success: privateConditionalResponses(ConductObservation), - error: endpointProblemResponses(RecruitmentReadInterviewConductProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "recruitment.conduct-interview", - canonicalScopeResolver: "recruitment.interview-by-id", - requirements: [ - "recruitment.assigned-interviewer-or-co-interviewer", - "recruitment.not-known-self", - ], - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read interview conduct", - "Returns interview questions and conduct state.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const FinalizeInterviewEndpoint = HttpApiEndpoint.post( - "finalizeInterview", - "/api/recruitment/interviews/:interviewId:finalize", - { - params: { interviewId: RecruitmentInterviewId }, - headers: IdempotencyIfMatchHeaders, - payload: FinalizeInterviewRequest, - success: entityMutationResponse(FinalizeInterviewResponse), - error: endpointProblemResponses(RecruitmentFinalizeInterviewProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "recruitment.conduct-interview", - canonicalScopeResolver: "recruitment.interview-by-id", - requirements: ["recruitment.assigned-interviewer", "recruitment.not-known-self"], - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations("Finalize interview", "Records answers and scores for an interview."), - ); - -/** @since 0.1.0 @category Endpoints */ -export const CorrectInterviewAssessmentEndpoint = HttpApiEndpoint.post( - "correctInterviewAssessment", - "/api/recruitment/interviews/:interviewId:correct", - { - params: { interviewId: RecruitmentInterviewId }, - headers: IdempotencyIfMatchHeaders, - payload: CorrectInterviewAssessmentRequest, - success: entityMutationResponse(CorrectInterviewAssessmentResponse), - error: endpointProblemResponses(RecruitmentCorrectInterviewAssessmentProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "recruitment.conduct-interview", - canonicalScopeResolver: "recruitment.interview-by-id", - requirements: [ - "recruitment.assigned-interviewer-or-co-interviewer", - "recruitment.not-known-self", - ], - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Correct interview assessment", - "Appends a replacement assessment to a completed interview.", - ), - ); - -/** @since 0.1.0 @category Endpoints */ -export const CancelInterviewEndpoint = HttpApiEndpoint.post( - "cancelInterview", - "/api/recruitment/interviews/:interviewId:cancel", - { - params: { interviewId: RecruitmentInterviewId }, - headers: IdempotencyIfMatchHeaders, - payload: CancelInterviewRequest, - success: entityMutationResponse(CancelInterviewResponse), - error: endpointProblemResponses(RecruitmentCancelInterviewProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "recruitment.conduct-interview", - canonicalScopeResolver: "recruitment.interview-by-id", - requirements: ["recruitment.assigned-interviewer", "recruitment.not-known-self"], - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations("Cancel interview", "Cancels an interview before finalization."), - ); - -/** - * Recruitment assignment, scheduling, conduct, and invitation API. - * - * @since 0.1.0 - * @category Groups - */ - -const RecruitmentMaintenanceProblem = problemUnion("RecruitmentMaintenanceProblem", [ - "authority.denied", - "origin.denied", - "request.malformed", - "request.too-large", - "media-type.unsupported", - "header.malformed", - "idempotency-key.invalid", - "idempotency.in-flight", - "idempotency.digest-conflict", - "idempotency.response-expired", - "transaction.conflict", - "resource.not-found", - "precondition.failed", - "recruitment.invalid-command", - "recruitment.ineligible", - "recruitment.empty-active-questionnaire", - "recruitment.terminal", - "recruitment.unavailable", - "idempotency.unavailable", - "internal.error", -]); - -export const ReadQuestionnairesEndpoint = HttpApiEndpoint.get( - "readQuestionnaires", - "/api/recruitment/questionnaires", - { - success: privateReadResponse(QuestionnaireManagement), - error: endpointProblemResponses(RecruitmentMaintenanceProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "recruitment.maintain", - canonicalScopeResolver: "recruitment.maintenance", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read questionnaires", - "Returns global questionnaire definitions and immutable maintenance history for a current global administrator.", - ), - ); - -export const ReadInterviewStaffingEndpoint = HttpApiEndpoint.get( - "readInterviewStaffing", - "/api/recruitment/interview-staffing", - { - success: privateReadResponse(InterviewStaffingManagement), - error: endpointProblemResponses(RecruitmentMaintenanceProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "recruitment.maintain", - canonicalScopeResolver: "recruitment.maintenance", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read interview staffing", - "Returns interviews, eligible candidates, and staffing history in the current coordinator scope.", - ), - ); - -export const MaintainRecruitmentEndpoint = HttpApiEndpoint.post( - "maintainRecruitment", - "/api/recruitment/maintenance/commands", - { - headers: IdempotencyHeaders, - payload: RecruitmentMaintenanceCommand, - success: entityMutationResponse(RecruitmentMaintenanceResult), - error: endpointProblemResponses(RecruitmentMaintenanceProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "recruitment.maintain", - canonicalScopeResolver: "recruitment.maintenance", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Maintain recruitment", - "Creates or revises a questionnaire, or replaces a nonterminal interview staffing pair with an observed revision and reason.", - ), - ); - -export { - RecruitmentMaintenanceCommand, - RecruitmentMaintenanceResult, - QuestionnaireManagement, - InterviewStaffingManagement, -}; - -export { RecruitmentInterviewQuestionKindSchema } from "@vektorprogrammet/domain/recruitment"; - -export class RecruitmentApi extends HttpApiGroup.make("recruitment") - .add( - ReadInvitationResponseEndpoint, - ConfirmInvitationEndpoint, - RejectInvitationEndpoint, - RequestNewInvitationTimeEndpoint, - ReadAssignmentBoardEndpoint, - ReadSchedulingBoardEndpoint, - ReadInterviewReportEndpoint, - AssignApplicantEndpoint, - ScheduleInterviewEndpoint, - ReadInterviewConductEndpoint, - FinalizeInterviewEndpoint, - CorrectInterviewAssessmentEndpoint, - CancelInterviewEndpoint, - ReadQuestionnairesEndpoint, - ReadInterviewStaffingEndpoint, - MaintainRecruitmentEndpoint, - ) - .annotateMerge( - OpenApi.annotations({ - title: "Recruitment", - description: "Applicant assignment, interview scheduling, conduct, and invitation response.", - }), - ) {} diff --git a/packages/http-api/src/release.ts b/packages/http-api/src/release.ts deleted file mode 100644 index 81ddad96a..000000000 --- a/packages/http-api/src/release.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Release identity shared by every generated NativeApi projection. - * - * @since 0.2.0 - */ -import manifest from "../package.json" with { type: "json" }; - -/** - * Current synchronized NativeApi contract release: the version of this package. - * Changesets versions it and the SDK as one fixed group. - */ -export const NativeApiReleaseVersion: string = manifest.version; - -/** Stable release name used by the manifest and generated metadata. */ -export const NativeApiReleaseName = "@vektorprogrammet/native-api" as const; diff --git a/packages/http-api/src/social-events.ts b/packages/http-api/src/social-events.ts deleted file mode 100644 index 9f07baf55..000000000 --- a/packages/http-api/src/social-events.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { - CreateSocialEventRequest, - SocialEventAudience, - SocialEventId, - SocialEventListResource, - SocialEventObservedAt, - SocialEventResource, - SocialEventScope, - SocialEventScopeResource, -} from "@vektorprogrammet/domain"; -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"; -import { annotateAccessSpec, personNativeAccess } from "./access.js"; -import { operationAnnotations, PersonSecurity } from "./common.js"; -import { - createdMutationResponse, - endpointProblemResponses, - IdempotencyHeaders, - privateReadResponse, - problemUnion, -} from "./http-semantics.js"; - -export { - CreateSocialEventRequest, - SocialEventAudience, - SocialEventId, - SocialEventListResource, - SocialEventObservedAt, - SocialEventResource, - SocialEventScope, - SocialEventScopeResource, -}; - -export const SocialEventsReadScopeProblem = problemUnion("SocialEventsReadScopeProblem", [ - "request.malformed", - "header.malformed", - "authority.denied", - "origin.denied", - "internal.error", - "dependency.unavailable", - "organization.unavailable", -]); - -export const SocialEventsListProblem = problemUnion("SocialEventsListProblem", [ - "request.malformed", - "header.malformed", - "authority.denied", - "origin.denied", - "scope.invalid", - "internal.error", - "dependency.unavailable", - "organization.unavailable", -]); - -export const SocialEventsCreateProblem = problemUnion("SocialEventsCreateProblem", [ - "request.malformed", - "header.malformed", - "authority.denied", - "origin.denied", - "idempotency-key.invalid", - "idempotency.in-flight", - "idempotency.digest-conflict", - "idempotency.response-expired", - "request.too-large", - "media-type.unsupported", - "validation.failed", - "scope.invalid", - "internal.error", - "dependency.unavailable", - "organization.unavailable", - "idempotency.unavailable", - "transaction.conflict", -]); - -/** @since 0.2.0 @category Endpoints */ -export const ReadSocialEventScopeEndpoint = HttpApiEndpoint.get( - "readScope", - "/api/social-events/scope", - { - success: privateReadResponse(SocialEventScopeResource), - error: endpointProblemResponses(SocialEventsReadScopeProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "social-events.read-scope", - canonicalScopeResolver: "social-events.scope", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read social event scope", - "Returns the caller's authorized departments and canonical semesters.", - ), - ); - -/** @since 0.2.0 @category Endpoints */ -export const ListSocialEventsEndpoint = HttpApiEndpoint.get("list", "/api/social-events", { - query: SocialEventScope.fields, - success: privateReadResponse(SocialEventListResource), - error: endpointProblemResponses(SocialEventsListProblem), -}) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "social-events.read", - canonicalScopeResolver: "social-events.list", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "List social events", - "Returns social events in one authorized department and semester scope.", - ), - ); - -/** @since 0.2.0 @category Endpoints */ -export const CreateSocialEventEndpoint = HttpApiEndpoint.post("create", "/api/social-events", { - headers: IdempotencyHeaders, - payload: CreateSocialEventRequest, - success: createdMutationResponse(SocialEventResource.pipe(HttpApiSchema.status(201))), - error: endpointProblemResponses(SocialEventsCreateProblem), -}) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "social-events.create", - canonicalScopeResolver: "social-events.create", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations("Create social event", "Creates or replays one social event command."), - ); - -export class SocialEventsApi extends HttpApiGroup.make("social-events") - .add(ReadSocialEventScopeEndpoint) - .add(ListSocialEventsEndpoint) - .add(CreateSocialEventEndpoint) - .annotateMerge( - OpenApi.annotations({ - title: "Social events", - description: "Department and semester scoped team social events.", - }), - ) {} diff --git a/packages/http-api/src/system.ts b/packages/http-api/src/system.ts deleted file mode 100644 index 0985b8735..000000000 --- a/packages/http-api/src/system.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Public HTTP contracts for health and first-party session resources. - * - * @since 0.1.0 - */ -import { PersonId } from "@vektorprogrammet/domain/organization"; -import { PUBLIC_SYSTEM_ACCESS } from "@vektorprogrammet/domain/authz"; -import { Schema } from "effect"; -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"; -import { annotateAccessSpec, browserSessionNativeAccess } from "./access.js"; -import { operationAnnotations, SessionSecurity } from "./common.js"; -import { - SystemDeleteOwnedSessionProblem, - SystemDeleteSessionProblem, - SystemHealthProblem, - SystemListSessionsProblem, - SystemReadSessionProblem, - SystemRevokeAllSessionsProblem, - SystemRevokeOtherSessionsProblem, -} from "./endpoint-problems.js"; -import { - endpointProblemResponses, - IdempotencyHeaders, - noContentMutationResponse, - noStoreReadResponse, - privateReadResponse, -} from "./http-semantics.js"; - -export const HealthOkResponse = Schema.Struct({ status: Schema.Literals(["ok"]) }) - .pipe(HttpApiSchema.status(200)) - .annotate({ identifier: "HealthOkResponse", description: "Database is available." }); - -export const HealthUnavailableResponse = Schema.Struct({ status: Schema.Literals(["unavailable"]) }) - .pipe(HttpApiSchema.status(503)) - .annotate({ - identifier: "HealthUnavailableResponse", - description: "Database is unavailable.", - }); - -export const HealthEndpoint = HttpApiEndpoint.get("health", "/health", { - success: noStoreReadResponse(HealthOkResponse), - error: endpointProblemResponses(SystemHealthProblem), -}) - .pipe((endpoint) => annotateAccessSpec(endpoint, PUBLIC_SYSTEM_ACCESS)) - .annotateMerge( - operationAnnotations("Read native health", "Checks the native PostgreSQL boundary."), - ); - -const SessionId = Schema.String.pipe( - Schema.check( - Schema.makeFilter((value) => value.length > 0 && value.length <= 128, { - message: "a bounded opaque session identifier", - }), - ), -); - -/** Credential-free owner-only session metadata. */ -export const SessionResponse = Schema.Struct({ - sessionId: SessionId, - personId: PersonId, - createdAt: Schema.String, - updatedAt: Schema.String, - expiresAt: Schema.String, - ipAddress: Schema.NullOr(Schema.String), - userAgent: Schema.NullOr(Schema.String), - current: Schema.Boolean, -}).annotate({ - identifier: "SessionResponse", - description: "Safe first-party session metadata without credential material.", -}); - -export const SessionListResponse = Schema.Array(SessionResponse).annotate({ - identifier: "SessionListResponse", - description: "Sessions owned by the authenticated person.", -}); - -export const ReadSessionEndpoint = HttpApiEndpoint.get("readSession", "/api/session", { - success: privateReadResponse(SessionResponse), - error: endpointProblemResponses(SystemReadSessionProblem), -}) - .middleware(SessionSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - browserSessionNativeAccess({ - canonicalScopeResolver: "identity.current-session", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read current session", - "Reads safe metadata for the authoritative current Better Auth session.", - ), - ); - -export const DeleteSessionEndpoint = HttpApiEndpoint.delete("deleteSession", "/api/session", { - headers: IdempotencyHeaders, - success: noContentMutationResponse(), - error: endpointProblemResponses(SystemDeleteSessionProblem), -}) - .middleware(SessionSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - browserSessionNativeAccess({ - canonicalScopeResolver: "identity.current-session", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations("End current session", "Revokes the authoritative current session."), - ); - -export const ListSessionsEndpoint = HttpApiEndpoint.get("listSessions", "/api/sessions", { - success: privateReadResponse(SessionListResponse), - error: endpointProblemResponses(SystemListSessionsProblem), -}) - .middleware(SessionSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - browserSessionNativeAccess({ - canonicalScopeResolver: "identity.owned-sessions", - requirements: ["sessions.owner"], - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "List current person's sessions", - "Lists only safe metadata for sessions owned by the authenticated person.", - ), - ); - -export const DeleteOwnedSessionEndpoint = HttpApiEndpoint.delete( - "deleteOwnedSession", - "/api/sessions/:sessionId", - { - params: { sessionId: SessionId }, - success: noContentMutationResponse(), - headers: IdempotencyHeaders, - error: endpointProblemResponses(SystemDeleteOwnedSessionProblem), - }, -) - .middleware(SessionSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - browserSessionNativeAccess({ - canonicalScopeResolver: "identity.session-by-id", - requirements: ["sessions.owner"], - concealRequirement: true, - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Revoke one session", - "Revokes one owned session; missing and non-owned identifiers are concealed.", - ), - ); - -export const RevokeOtherSessionsEndpoint = HttpApiEndpoint.post( - "revokeOtherSessions", - "/api/sessions:revoke-others", - { - params: {}, - headers: IdempotencyHeaders, - success: noContentMutationResponse(), - error: endpointProblemResponses(SystemRevokeOtherSessionsProblem), - }, -) - .middleware(SessionSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - browserSessionNativeAccess({ - canonicalScopeResolver: "identity.current-session", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Revoke other sessions", - "Revokes every owned session except the authoritative current session.", - ), - ); - -export const RevokeAllSessionsEndpoint = HttpApiEndpoint.post( - "revokeAllSessions", - "/api/sessions:revoke-all", - { - params: {}, - headers: IdempotencyHeaders, - success: noContentMutationResponse(), - error: endpointProblemResponses(SystemRevokeAllSessionsProblem), - }, -) - .middleware(SessionSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - browserSessionNativeAccess({ - canonicalScopeResolver: "identity.current-session", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Revoke all sessions", - "Revokes every owned session, including the authoritative current session.", - ), - ); - -export class SystemApi extends HttpApiGroup.make("system") - .add( - HealthEndpoint, - ReadSessionEndpoint, - DeleteSessionEndpoint, - ListSessionsEndpoint, - DeleteOwnedSessionEndpoint, - RevokeOtherSessionsEndpoint, - RevokeAllSessionsEndpoint, - ) - .annotateMerge( - OpenApi.annotations({ - title: "System", - description: "Native health and first-party session resources.", - }), - ) {} diff --git a/packages/http-api/src/team-application.ts b/packages/http-api/src/team-application.ts deleted file mode 100644 index f6d56f659..000000000 --- a/packages/http-api/src/team-application.ts +++ /dev/null @@ -1,382 +0,0 @@ -/** - * Public intake and staff review contracts for standalone team applications. - * - * @since 0.2.0 - */ -import { Team, TeamId } from "@vektorprogrammet/domain/organization"; -import { - PublicTeamApplicationIntake, - TEAM_APPLICATION_INTAKE_LIST_LIMIT, - TEAM_APPLICATION_PAGE_SIZE, - TeamApplication, - TeamApplicationConfirmation, - TeamApplicationCursor, - TeamApplicationId, - TeamApplicationInput, - TeamApplicationIntake, - TeamApplicationIntakeListItem, - TeamApplicationSummary, -} from "@vektorprogrammet/domain/team-application"; -import { Rfc3339InstantSchema } from "@vektorprogrammet/domain/time"; -import { Schema } from "effect"; -import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"; -import { annotateAccessSpec, anonymousNativeAccess, personNativeAccess } from "./access.js"; -import { operationAnnotations, PersonSecurity } from "./common.js"; -import { - createdMutationResponse, - endpointProblemResponses, - entityMutationResponse, - IdempotencyHeaders, - IdempotencyIfMatchHeaders, - noContentMutationResponse, - noStoreReadResponse, - privateReadResponse, - problemUnion, - StrongETag, -} from "./http-semantics.js"; - -export { - PublicTeamApplicationIntake, - TeamApplicationConfirmation, - TeamApplicationCursor, - TeamApplicationId, - TeamApplicationInput, - TeamApplicationIntakeListItem, - TeamApplicationSummary, -}; - -/** Staff intake settings with the team entity tag that `If-Match` must repeat. */ -export const TeamApplicationIntakeResource = Schema.Struct({ - ...TeamApplicationIntake.fields, - etag: StrongETag, -}).annotate({ - identifier: "TeamApplicationIntakeResource", - description: "Team intake settings, their open state now, and the team revision entity tag.", -}); - -export const TeamApplicationListResponse = Schema.Struct({ - teamId: TeamId, - teamName: Team.fields.name, - items: Schema.Array(TeamApplicationSummary).pipe( - Schema.check(Schema.isMaxLength(TEAM_APPLICATION_PAGE_SIZE)), - ), - nextCursor: Schema.optional(TeamApplicationCursor), - intake: TeamApplicationIntakeResource, - canManage: Schema.Boolean, -}).annotate({ - identifier: "TeamApplicationListResponse", - description: - "One bounded newest-first page of a team's applications. canManage is a presentation projection.", -}); - -export const TeamApplicationResource = Schema.Struct({ - ...TeamApplication.fields, - teamName: Team.fields.name, - canManage: Schema.Boolean, -}).annotate({ - identifier: "TeamApplicationResource", - description: "One application with its private applicant fields.", -}); - -export const TeamApplicationIntakeListResponse = Schema.Array(TeamApplicationIntakeListItem) - .pipe(Schema.check(Schema.isMaxLength(TEAM_APPLICATION_INTAKE_LIST_LIMIT))) - .annotate({ - identifier: "TeamApplicationIntakeListResponse", - description: `Intake state for at most ${TEAM_APPLICATION_INTAKE_LIST_LIMIT} active teams, ordered by team identifier.`, - }); - -/** Absence keeps a setting; a null deadline clears it; acceptApplication cannot be deleted. */ -export const TeamApplicationIntakeMergePatch = Schema.Struct({ - acceptApplication: Schema.optional(Schema.NullOr(Schema.Boolean)), - deadline: Schema.optional(Schema.NullOr(Rfc3339InstantSchema)), -}).annotate({ identifier: "TeamApplicationIntakeMergePatch" }); - -export type TeamApplicationIntakeMergePatch = typeof TeamApplicationIntakeMergePatch.Type; - -export const TeamApplicationsReadIntakeProblem = problemUnion("TeamApplicationsReadIntakeProblem", [ - "request.malformed", - "header.malformed", - "resource.not-found", - "internal.error", -]); - -export const TeamApplicationsListIntakesProblem = problemUnion( - "TeamApplicationsListIntakesProblem", - ["request.malformed", "header.malformed", "internal.error"], -); - -export const TeamApplicationsSubmitProblem = problemUnion("TeamApplicationsSubmitProblem", [ - "request.malformed", - "header.malformed", - "idempotency-key.invalid", - "resource.not-found", - "idempotency.in-flight", - "idempotency.digest-conflict", - "idempotency.response-expired", - "team-application.intake-closed", - "transaction.conflict", - "request.too-large", - "media-type.unsupported", - "validation.failed", - "rate-limit.exceeded", - "internal.error", - "idempotency.unavailable", -]); - -export const TeamApplicationsStaffReadProblem = problemUnion("TeamApplicationsStaffReadProblem", [ - "request.malformed", - "header.malformed", - "authority.denied", - "origin.denied", - "resource.not-found", - "internal.error", -]); - -export const TeamApplicationsDeleteProblem = problemUnion("TeamApplicationsDeleteProblem", [ - "request.malformed", - "header.malformed", - "idempotency-key.invalid", - "authority.denied", - "origin.denied", - "resource.not-found", - "idempotency.in-flight", - "idempotency.digest-conflict", - "idempotency.response-expired", - "transaction.conflict", - "internal.error", - "idempotency.unavailable", -]); - -export const TeamApplicationsReviseIntakeProblem = problemUnion( - "TeamApplicationsReviseIntakeProblem", - [ - "request.malformed", - "header.malformed", - "idempotency-key.invalid", - "precondition.invalid", - "authority.denied", - "origin.denied", - "idempotency.in-flight", - "idempotency.digest-conflict", - "idempotency.response-expired", - "transaction.conflict", - "precondition.failed", - "request.too-large", - "media-type.unsupported", - "validation.failed", - "validation.no-change", - "validation.field-not-deletable", - "precondition.required", - "internal.error", - "idempotency.unavailable", - ], -); - -const TeamParams = { teamId: TeamId }; - -const ApplicationParams = { applicationId: TeamApplicationId }; - -/** @since 0.2.0 @category Endpoints */ -export const ReadTeamApplicationIntakeEndpoint = HttpApiEndpoint.get( - "readTeamApplicationIntake", - "/api/teams/:teamId/application-intake", - { - params: TeamParams, - success: noStoreReadResponse(PublicTeamApplicationIntake), - error: endpointProblemResponses(TeamApplicationsReadIntakeProblem), - }, -) - .pipe((endpoint) => - annotateAccessSpec(endpoint, anonymousNativeAccess("team-applications.public-intake")), - ) - .annotateMerge( - operationAnnotations( - "Read team application intake", - "Returns whether one active team accepts applications now. Unknown and inactive teams are not found.", - ), - ); - -/** @since 0.2.0 @category Endpoints */ -export const ListTeamApplicationIntakesEndpoint = HttpApiEndpoint.get( - "listTeamApplicationIntakes", - "/api/team-application-intakes", - { - success: noStoreReadResponse(TeamApplicationIntakeListResponse), - error: endpointProblemResponses(TeamApplicationsListIntakesProblem), - }, -) - .pipe((endpoint) => - annotateAccessSpec(endpoint, anonymousNativeAccess("team-applications.public-intakes")), - ) - .annotateMerge( - operationAnnotations( - "List team application intakes", - `Returns the intake state of at most ${TEAM_APPLICATION_INTAKE_LIST_LIMIT} active teams.`, - ), - ); - -/** @since 0.2.0 @category Endpoints */ -export const SubmitTeamApplicationEndpoint = HttpApiEndpoint.post( - "submitTeamApplication", - "/api/teams/:teamId/applications", - { - params: TeamParams, - headers: IdempotencyHeaders, - payload: TeamApplicationInput, - success: createdMutationResponse(TeamApplicationConfirmation.pipe(HttpApiSchema.status(201))), - error: endpointProblemResponses(TeamApplicationsSubmitProblem), - }, -) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - anonymousNativeAccess("team-applications.application-create", "Transaction"), - ), - ) - .annotateMerge( - operationAnnotations( - "Submit team application", - "Stores one application to an open team and queues its receipt and team notification. An idempotency-key replay returns the original result. Every submission, including a replay, counts against one public rate limit.", - ), - ); - -/** @since 0.2.0 @category Endpoints */ -export const ListTeamApplicationsEndpoint = HttpApiEndpoint.get( - "listTeamApplications", - "/api/teams/:teamId/applications", - { - params: TeamParams, - query: { cursor: Schema.optional(TeamApplicationCursor) }, - success: privateReadResponse(TeamApplicationListResponse), - error: endpointProblemResponses(TeamApplicationsStaffReadProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "team-applications.read", - canonicalScopeResolver: "team-applications.team-applications", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "List team applications", - "Returns a bounded page of the team's applications to a current, nonsuspended member of that team.", - ), - ); - -/** @since 0.2.0 @category Endpoints */ -export const ReadTeamApplicationEndpoint = HttpApiEndpoint.get( - "readTeamApplication", - "/api/team-applications/:applicationId", - { - params: ApplicationParams, - success: privateReadResponse(TeamApplicationResource), - error: endpointProblemResponses(TeamApplicationsStaffReadProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "team-applications.read", - canonicalScopeResolver: "team-applications.application-by-id", - decisionTime: "SnapshotRead", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Read team application", - "Returns one application to a current, nonsuspended member of its team.", - ), - ); - -/** @since 0.2.0 @category Endpoints */ -export const DeleteTeamApplicationEndpoint = HttpApiEndpoint.delete( - "deleteTeamApplication", - "/api/team-applications/:applicationId", - { - params: ApplicationParams, - headers: IdempotencyHeaders, - success: noContentMutationResponse(), - error: endpointProblemResponses(TeamApplicationsDeleteProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "team-applications.manage", - canonicalScopeResolver: "team-applications.application-by-id", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Delete team application", - "Removes one application and its undelivered notifications. Only the current team leader can delete.", - ), - ); - -/** @since 0.2.0 @category Endpoints */ -export const ReviseTeamApplicationIntakeEndpoint = HttpApiEndpoint.patch( - "reviseTeamApplicationIntake", - "/api/teams/:teamId/application-intake", - { - params: TeamParams, - headers: IdempotencyIfMatchHeaders, - payload: TeamApplicationIntakeMergePatch.pipe( - HttpApiSchema.asJson({ contentType: "application/merge-patch+json" }), - ), - success: entityMutationResponse(TeamApplicationIntakeResource), - error: endpointProblemResponses(TeamApplicationsReviseIntakeProblem), - }, -) - .middleware(PersonSecurity) - .pipe((endpoint) => - annotateAccessSpec( - endpoint, - personNativeAccess({ - capability: "team-applications.manage", - canonicalScopeResolver: "team-applications.intake-by-team", - decisionTime: "Transaction", - }), - ), - ) - .annotateMerge( - operationAnnotations( - "Revise team application intake", - "Opens or closes intake and sets or clears the deadline. Requires the current team leader and the observed team entity tag.", - ), - ); - -/** - * Standalone team application intake and staff review. - * - * @since 0.2.0 - * @category Groups - */ -export class TeamApplicationsApi extends HttpApiGroup.make("team-applications") - .add( - ReadTeamApplicationIntakeEndpoint, - ListTeamApplicationIntakesEndpoint, - SubmitTeamApplicationEndpoint, - ListTeamApplicationsEndpoint, - ReadTeamApplicationEndpoint, - DeleteTeamApplicationEndpoint, - ReviseTeamApplicationIntakeEndpoint, - ) - .annotateMerge( - OpenApi.annotations({ - title: "Team applications", - description: "Public team intake, staff review, deletion, and intake settings.", - }), - ) {} diff --git a/packages/http-api/test/native-api.test.ts b/packages/http-api/test/native-api.test.ts deleted file mode 100644 index 2c0052b4f..000000000 --- a/packages/http-api/test/native-api.test.ts +++ /dev/null @@ -1,1563 +0,0 @@ -import { PUBLIC_SYSTEM_ACCESS, CapabilityTypeId, RequirementId } from "@vektorprogrammet/domain/authz"; -import { ReceiptId } from "@vektorprogrammet/domain/receipt"; -import { Predicate, Context, Schema } from "effect"; -import { HttpApiClient, OpenApi } from "effect/unstable/httpapi"; -import { describe, expect, it } from "vitest"; -import { ExternalNativeApi, InternalNativeApi } from "../src/api.js"; -import { - annotateAccessSpec, - projectVektorAccess, - reflectAccessSpec, - type VektorAccessProjection, -} from "../src/access.js"; -import { HealthEndpoint } from "../src/system.js"; -import { - NativeProblemRegistry, - PeopleDirectoryResponse, - ProfileMergePatch, - ProfileUpdateOwnProfileProblem, - SystemHealthProblem, -} from "../src/index.js"; - -const endpointInventory = () => - Object.values(ExternalNativeApi.groups).flatMap((group) => - Object.values(group.endpoints).map((endpoint) => ({ - group: group.identifier, - identifier: endpoint.identifier, - method: endpoint.method, - path: endpoint.path, - })), - ); - -const internalEndpointInventory = () => - Object.values(InternalNativeApi.groups).flatMap((group) => - Object.values(group.endpoints).map((endpoint) => ({ - group: group.identifier, - identifier: endpoint.identifier, - method: endpoint.method, - path: endpoint.path, - })), - ); - -const documentedOperations = () => { - const spec = OpenApi.fromApi(ExternalNativeApi); - const methods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const; - - return Object.entries(spec.paths).flatMap(([path, item]) => - methods.flatMap((method) => { - const operation = item[method]; - - return operation === undefined ? [] : [{ method, path, operation }]; - }), - ); -}; - -type ExpectedOperation = readonly [ - method: string, - path: string, - operationId: string, - access: VektorAccessProjection, -]; - -const expectedAccess = (input: { - readonly credentials: ReadonlyArray; - readonly principals: ReadonlyArray; - readonly capability?: typeof CapabilityTypeId.Encoded; - readonly resolver: string; - readonly requirements?: ReadonlyArray; - readonly concealment?: ReadonlyArray; - readonly decisionTime: "SnapshotRead" | "Transaction"; - readonly exposure?: "External" | "Internal"; -}): VektorAccessProjection => ({ - exposure: input.exposure ?? "External", - acceptedCredentials: input.credentials, - principalKinds: input.principals, - capabilities: input.capability === undefined ? { none: true } : { one: CapabilityTypeId.make(input.capability) }, - requirements: (input.requirements ?? []).map((id) => ({ id: RequirementId.make(id) })), - canonicalScopeResolver: input.resolver, - concealment: - input.concealment === undefined - ? { mode: "Reveal", stages: [] } - : { mode: "NotFound", stages: [...input.concealment].sort() }, - decisionTime: input.decisionTime, -}); - -const anonymous = ( - resolver: string, - decisionTime: "SnapshotRead" | "Transaction" = "SnapshotRead", -) => - expectedAccess({ - credentials: ["None"], - principals: ["Anonymous"], - resolver, - decisionTime, - }); - -const cookie = ( - resolver: string, - requirements: ReadonlyArray, - decisionTime: "SnapshotRead" | "Transaction", - concealment?: ReadonlyArray, -) => - expectedAccess({ - credentials: ["BetterAuthCookie"], - principals: ["Person"], - resolver, - requirements, - concealment, - decisionTime, - }); - -const person = ( - capability: typeof CapabilityTypeId.Encoded, - resolver: string, - requirements: ReadonlyArray, - decisionTime: "SnapshotRead" | "Transaction", -) => - expectedAccess({ - credentials: ["BetterAuthCookie", "OAuthUserBearer"], - principals: ["Person"], - capability, - resolver, - requirements, - decisionTime, - }); - -const receiptSettlement = ( - resolver: "receipts.settlement-queue" | "receipts.by-id", - decisionTime: "SnapshotRead" | "Transaction", -) => - expectedAccess({ - credentials: ["BetterAuthCookie", "OAuthUserBearer"], - principals: ["Person"], - capability: "settleReceipt", - resolver, - concealment: ["Capability", "Scope"], - decisionTime, - }); - -const invitation = ( - requirements: ReadonlyArray, - decisionTime: "SnapshotRead" | "Transaction", -) => - expectedAccess({ - credentials: ["ObjectCapability"], - principals: ["CapabilityHolder"], - capability: "recruitment.invitation-response", - resolver: "recruitment.invitation-response-by-capability", - requirements, - concealment: ["CredentialFailure", "PrincipalKind", "Capability", "Scope", "Requirement"], - decisionTime, - }); - -const expectedOperations: ReadonlyArray = [ - [ - "GET", - "/api/organization/appointments", - "organization.readAppointmentManagement", - person("organization.manage-appointments", "organization.appointment-management", [], "SnapshotRead"), - ], - [ - "GET", - "/api/organization/board-rosters", - "organization.readBoardRosters", - person("organization.manage-appointments", "organization.appointment-management", [], "SnapshotRead"), - ], - [ - "POST", - "/api/organization/appointments/commands", - "organization.executeLifecycle", - person("organization.manage-appointments", "organization.appointment-management", [], "Transaction"), - ], - [ - "GET", - "/api/organization/delegations", - "organization.readDelegationManagement", - person("organization.manage-delegations", "organization.delegation-management", [], "SnapshotRead"), - ], - [ - "POST", - "/api/organization/delegations/commands", - "organization.executeDelegation", - person("organization.manage-delegations", "organization.delegation-management", [], "Transaction"), - ], - [ - "GET", - "/api/admission-outcomes/scopes", - "admissionOutcomes.listScopes", - person("admissions.outcomes.read", "admissions.application-outcomes", [], "SnapshotRead"), - ], - [ - "GET", - "/api/admission-outcomes", - "admissionOutcomes.readOutcomes", - person("admissions.outcomes.read", "admissions.application-outcomes", [], "SnapshotRead"), - ], - [ - "GET", - "/api/admission-outcomes/:applicationId", - "admissionOutcomes.readOutcome", - person("admissions.outcomes.read", "admissions.application-outcomes", [], "SnapshotRead"), - ], - [ - "POST", - "/api/admission-outcomes/:applicationId:record", - "admissionOutcomes.recordOutcome", - person("admissions.outcomes.decide", "admissions.application-outcomes", [], "Transaction"), - ], - - [ - "GET", - "/api/placements/scopes", - "placements.listScopes", - person("placements.self", "placements.explicit-department", [], "SnapshotRead"), - ], - [ - "GET", - "/api/placements/affiliation", - "placements.readOwnAffiliation", - person("placements.self", "placements.explicit-department", [], "SnapshotRead"), - ], - [ - "POST", - "/api/placements/affiliation", - "placements.commandOwnAffiliation", - person("placements.self", "placements.explicit-department", [], "Transaction"), - ], - [ - "GET", - "/api/placements", - "placements.readBoard", - person("placements.manage", "placements.explicit-department", [], "SnapshotRead"), - ], - [ - "POST", - "/api/placements", - "placements.commandBoard", - person("placements.manage", "placements.explicit-department", [], "Transaction"), - ], - [ - "GET", - "/api/placements/draft", - "placements.readDraft", - person("placements.manage", "placements.explicit-department", [], "SnapshotRead"), - ], - [ - "GET", - "/api/placements/coverage/own", - "placements.readOwnCoverage", - person("placements.self", "placements.explicit-department", [], "SnapshotRead"), - ], - [ - "POST", - "/api/placements/coverage/own", - "placements.commandOwnCoverage", - person("placements.self", "placements.explicit-department", [], "Transaction"), - ], - [ - "GET", - "/api/placements/coverage", - "placements.readCoverageBoard", - person("placements.manage", "placements.explicit-department", [], "SnapshotRead"), - ], - [ - "POST", - "/api/placements/coverage", - "placements.commandCoverageBoard", - person("placements.manage", "placements.explicit-department", [], "Transaction"), - ], - [ - "GET", - "/api/onboarding", - "onboarding.readBoard", - person("onboarding.manage", "onboarding.application-department", [], "SnapshotRead"), - ], - [ - "POST", - "/api/onboarding", - "onboarding.command", - person("onboarding.manage", "onboarding.application-department", [], "Transaction"), - ], - [ - "POST", - "/api/onboarding/claim", - "onboarding.claim", - expectedAccess({ - credentials: ["BetterAuthCookie", "ObjectCapability"], - principals: ["Person", "CapabilityHolder"], - capability: "onboarding.claim", - resolver: "onboarding.claim", - requirements: ["onboarding.claim-token"], - decisionTime: "Transaction", - }), - ], - [ - "POST", - "/api/contact-messages", - "contact.submitContactMessage", - expectedAccess({ - credentials: ["ObjectCapability"], - principals: ["CapabilityHolder"], - capability: "contact.submit", - resolver: "contact.department-recipient", - decisionTime: "SnapshotRead", - }), - ], - ["GET", "/health", "system.health", anonymous("system.health")], - [ - "GET", - "/api/session", - "system.readSession", - cookie("identity.current-session", [], "SnapshotRead"), - ], - [ - "DELETE", - "/api/session", - "system.deleteSession", - cookie("identity.current-session", [], "Transaction"), - ], - [ - "GET", - "/api/sessions", - "system.listSessions", - cookie("identity.owned-sessions", ["sessions.owner"], "SnapshotRead"), - ], - [ - "DELETE", - "/api/sessions/:sessionId", - "system.deleteOwnedSession", - cookie("identity.session-by-id", ["sessions.owner"], "Transaction", ["Requirement"]), - ], - [ - "POST", - "/api/sessions:revoke-others", - "system.revokeOtherSessions", - cookie("identity.current-session", [], "Transaction"), - ], - [ - "POST", - "/api/sessions:revoke-all", - "system.revokeAllSessions", - cookie("identity.current-session", [], "Transaction"), - ], - [ - "GET", - "/api/profile", - "profile.readOwnProfile", - person("profile.read-self", "profile.current-person", ["profile.owner"], "SnapshotRead"), - ], - [ - "PATCH", - "/api/profile", - "profile.updateOwnProfile", - person("profile.update-self", "profile.current-person", ["profile.owner"], "Transaction"), - ], - [ - "GET", - "/api/departments", - "organization.listDepartments", - anonymous("organization.public-departments"), - ], - ["GET", "/api/teams", "organization.listTeams", anonymous("organization.public-teams")], - [ - "GET", - "/api/field-of-studies", - "organization.listFieldOfStudies", - anonymous("organization.public-field-of-studies"), - ], - [ - "GET", - "/api/team-interest-registrations", - "organization.listTeamInterest", - person( - "organization.read-team-interest", - "organization.team-interest-registrations", - [], - "SnapshotRead", - ), - ], - [ - "GET", - "/api/mailing-lists", - "organization.listMailingLists", - person("organization.read-mailing-lists", "organization.mailing-lists", [], "SnapshotRead"), - ], - [ - "POST", - "/api/departments", - "organization.createDepartment", - person("organization.create-department", "organization.department-create", [], "Transaction"), - ], - [ - "POST", - "/api/teams", - "organization.createTeam", - person("organization.create-team", "organization.team-create", [], "Transaction"), - ], - [ - "POST", - "/api/field-of-studies", - "organization.createFieldOfStudy", - person( - "organization.create-field-of-study", - "organization.field-of-study-create", - [], - "Transaction", - ), - ], - [ - "GET", - "/api/people", - "directory.listPeople", - person("profile.read-directory", "profile.people-directory", [], "SnapshotRead"), - ], - [ - "GET", - "/api/schools", - "directory.listSchools", - person("schools.read-directory", "schools.directory", [], "SnapshotRead"), - ], - [ - "GET", - "/api/schools/management", - "directory.readSchoolManagement", - person("schools.manage", "schools.management", [], "SnapshotRead"), - ], - [ - "POST", - "/api/schools/commands", - "directory.executeSchoolCommand", - person("schools.manage", "schools.management", [], "Transaction"), - ], - [ - "GET", - "/api/open-admission-periods", - "admissions.listOpenAdmissionPeriods", - anonymous("admissions.public-open-periods"), - ], - [ - "GET", - "/api/application-options", - "admissions.listApplicationOptions", - anonymous("admissions.public-application-options"), - ], - [ - "POST", - "/api/applications", - "admissions.submitApplication", - anonymous("admissions.application-create", "Transaction"), - ], - [ - "GET", - "/api/applications/:applicationId", - "admissions.readApplicationConfirmation", - anonymous("admissions.public-application-by-id"), - ], - [ - "GET", - "/api/applicant-progress", - "admissions.readApplicantProgress", - person( - "admissions.read-applicant-progress", - "profile.current-person", - ["profile.owner"], - "SnapshotRead", - ), - ], - [ - "GET", - "/api/admission-periods", - "admissions.listAdmissionPeriods", - person("admissions.read-periods", "admissions.management-periods", [], "SnapshotRead"), - ], - [ - "POST", - "/api/admission-periods", - "admissions.createAdmissionPeriod", - person("admissions.create-period", "admissions.period-create", [], "Transaction"), - ], - [ - "PATCH", - "/api/admission-periods/:admissionPeriodId", - "admissions.reviseAdmissionPeriod", - person("admissions.revise-period", "admissions.period-by-id", [], "Transaction"), - ], - [ - "GET", - "/api/returning-assistant/options", - "admissions.readReturningAssistantOptions", - person("placements.self", "profile.current-person", [], "SnapshotRead"), - ], - [ - "POST", - "/api/returning-assistant/registrations", - "admissions.registerReturningAssistant", - person("placements.self", "profile.current-person", [], "Transaction"), - ], - [ - "GET", - "/api/recruitment/invitation-response", - "recruitment.readInvitationResponse", - invitation([], "SnapshotRead"), - ], - [ - "POST", - "/api/recruitment/invitation-response:confirm", - "recruitment.confirmInvitation", - invitation([], "Transaction"), - ], - [ - "POST", - "/api/recruitment/invitation-response:reject", - "recruitment.rejectInvitation", - invitation([], "Transaction"), - ], - [ - "POST", - "/api/recruitment/invitation-response:request-new-time", - "recruitment.requestNewInvitationTime", - invitation([], "Transaction"), - ], - [ - "GET", - "/api/recruitment/application-assignments", - "recruitment.readAssignmentBoard", - person( - "reviewApplicants", - "recruitment.application-assignments", - ["organization.single-department-administrator"], - "SnapshotRead", - ), - ], - [ - "GET", - "/api/recruitment/interviews", - "recruitment.readSchedulingBoard", - person( - "recruitment.read-interviews", - "recruitment.interviews", - ["organization.single-department-member"], - "SnapshotRead", - ), - ], - [ - "GET", - "/api/recruitment/interview-report", - "recruitment.readInterviewReport", - person( - "recruitment.read-interview-report", - "recruitment.interview-report", - ["organization.single-department-administrator"], - "SnapshotRead", - ), - ], - [ - "POST", - "/api/recruitment/applications/:applicationId/interviews", - "recruitment.createApplicationInterview", - person( - "reviewApplicants", - "recruitment.application-by-id", - ["organization.single-department-administrator", "recruitment.interviewer-eligible"], - "Transaction", - ), - ], - [ - "POST", - "/api/recruitment/interviews/:interviewId:schedule", - "recruitment.scheduleInterview", - person( - "recruitment.schedule-interview", - "recruitment.interview-by-id", - ["recruitment.assigned-interviewer-or-administrator"], - "Transaction", - ), - ], - [ - "GET", - "/api/recruitment/interviews/:interviewId", - "recruitment.readInterviewConduct", - person( - "recruitment.conduct-interview", - "recruitment.interview-by-id", - ["recruitment.assigned-interviewer-or-co-interviewer", "recruitment.not-known-self"], - "SnapshotRead", - ), - ], - [ - "POST", - "/api/recruitment/interviews/:interviewId:finalize", - "recruitment.finalizeInterview", - person( - "recruitment.conduct-interview", - "recruitment.interview-by-id", - ["recruitment.assigned-interviewer", "recruitment.not-known-self"], - "Transaction", - ), - ], - [ - "POST", - "/api/recruitment/interviews/:interviewId:correct", - "recruitment.correctInterviewAssessment", - person( - "recruitment.conduct-interview", - "recruitment.interview-by-id", - ["recruitment.assigned-interviewer-or-co-interviewer", "recruitment.not-known-self"], - "Transaction", - ), - ], - [ - "GET", - "/api/recruitment/questionnaires", - "recruitment.readQuestionnaires", - person("recruitment.maintain", "recruitment.maintenance", [], "SnapshotRead"), - ], - [ - "GET", - "/api/recruitment/interview-staffing", - "recruitment.readInterviewStaffing", - person("recruitment.maintain", "recruitment.maintenance", [], "SnapshotRead"), - ], - [ - "POST", - "/api/recruitment/maintenance/commands", - "recruitment.maintainRecruitment", - person("recruitment.maintain", "recruitment.maintenance", [], "Transaction"), - ], - [ - "POST", - "/api/recruitment/interviews/:interviewId:cancel", - "recruitment.cancelInterview", - person( - "recruitment.conduct-interview", - "recruitment.interview-by-id", - ["recruitment.assigned-interviewer", "recruitment.not-known-self"], - "Transaction", - ), - ], - [ - "GET", - "/api/receipts/:receiptId/file", - "receipts.readReceiptFile", - person("receipts.read-owned", "receipts.by-id", ["receipts.owner"], "SnapshotRead"), - ], - [ - "GET", - "/api/receipt-approval-queue/:receiptId/file", - "receipts.readReceiptFileForApproval", - person("approveReceipt", "receipts.by-id", ["receipts.approver-relationship"], "SnapshotRead"), - ], - [ - "POST", - "/api/receipts", - "receipts.submitReceipt", - person("submitReceipt", "receipts.create", [], "Transaction"), - ], - [ - "PATCH", - "/api/receipts/:receiptId", - "receipts.reviseReceipt", - person( - "receipts.manage-owned", - "receipts.by-id", - ["receipts.owner", "receipts.pending"], - "Transaction", - ), - ], - [ - "POST", - "/api/receipts/:receiptId:withdraw", - "receipts.withdrawReceipt", - person( - "receipts.manage-owned", - "receipts.by-id", - ["receipts.owner", "receipts.pending"], - "Transaction", - ), - ], - [ - "GET", - "/api/receipts", - "receipts.listReceipts", - person("receipts.read-owned", "receipts.owned", ["receipts.owner"], "SnapshotRead"), - ], - [ - "GET", - "/api/receipt-approval-queue", - "receipts.listReceiptsForApproval", - expectedAccess({ - credentials: ["BetterAuthCookie", "OAuthUserBearer", "OAuthServiceBearer"], - principals: ["Person", "ServicePrincipal"], - capability: "approveReceipt", - resolver: "receipts.approval-queue", - requirements: ["receipts.approver-relationship"], - decisionTime: "SnapshotRead", - }), - ], - [ - "POST", - "/api/receipts/:receiptId:approve", - "receipts.approveReceipt", - person( - "approveReceipt", - "receipts.by-id", - ["receipts.pending", "receipts.approver-relationship"], - "Transaction", - ), - ], - [ - "GET", - "/api/receipt-settlement-queue", - "receipts.listReceiptsForSettlement", - receiptSettlement("receipts.settlement-queue", "SnapshotRead"), - ], - [ - "GET", - "/api/receipt-settlement-queue/:receiptId", - "receipts.readReceiptSettlementForFinance", - receiptSettlement("receipts.by-id", "SnapshotRead"), - ], - [ - "POST", - "/api/receipts/:receiptId:settle", - "receipts.settleReceipt", - receiptSettlement("receipts.by-id", "Transaction"), - ], - [ - "POST", - "/api/receipts/:receiptId:reject", - "receipts.rejectReceipt", - person( - "approveReceipt", - "receipts.by-id", - ["receipts.pending", "receipts.approver-relationship"], - "Transaction", - ), - ], - [ - "POST", - "/api/receipts/:receiptId:reopen", - "receipts.reopenReceipt", - person( - "approveReceipt", - "receipts.by-id", - ["receipts.rejected", "receipts.approver-relationship"], - "Transaction", - ), - ], - [ - "GET", - "/api/content/articles", - "content.readContentWorkspace", - person("content.read-workspace", "content.articles", [], "SnapshotRead"), - ], - [ - "POST", - "/api/content/articles", - "content.createArticle", - person("content.create-article", "content.article-create", [], "Transaction"), - ], - [ - "GET", - "/api/content/articles/:articleId", - "content.readArticle", - person("content.read-article", "content.article-by-id", [], "SnapshotRead"), - ], - [ - "PATCH", - "/api/content/articles/:articleId", - "content.reviseArticle", - person("content.revise-article", "content.article-by-id", ["content.revisable"], "Transaction"), - ], - [ - "POST", - "/api/content/articles/:articleId:publish", - "content.publishArticle", - person( - "content.publish-article", - "content.article-by-id", - ["content.publishable"], - "Transaction", - ), - ], - [ - "POST", - "/api/content/articles/:articleId:unpublish", - "content.unpublishArticle", - person( - "content.publish-article", - "content.article-by-id", - ["content.unpublishable"], - "Transaction", - ), - ], - ["GET", "/api/news", "content.listNews", anonymous("content.public-news")], - ["GET", "/api/news/:slug", "content.readNewsArticle", anonymous("content.public-news-by-slug")], - [ - "GET", - "/api/social-events/scope", - "social-events.readScope", - person("social-events.read-scope", "social-events.scope", [], "SnapshotRead"), - ], - [ - "GET", - "/api/social-events", - "social-events.list", - person("social-events.read", "social-events.list", [], "SnapshotRead"), - ], - [ - "POST", - "/api/social-events", - "social-events.create", - person("social-events.create", "social-events.create", [], "Transaction"), - ], - [ - "GET", - "/api/teams/:teamId/application-intake", - "team-applications.readTeamApplicationIntake", - anonymous("team-applications.public-intake"), - ], - [ - "GET", - "/api/team-application-intakes", - "team-applications.listTeamApplicationIntakes", - anonymous("team-applications.public-intakes"), - ], - [ - "POST", - "/api/teams/:teamId/applications", - "team-applications.submitTeamApplication", - anonymous("team-applications.application-create", "Transaction"), - ], - [ - "GET", - "/api/teams/:teamId/applications", - "team-applications.listTeamApplications", - person("team-applications.read", "team-applications.team-applications", [], "SnapshotRead"), - ], - [ - "GET", - "/api/team-applications/:applicationId", - "team-applications.readTeamApplication", - person("team-applications.read", "team-applications.application-by-id", [], "SnapshotRead"), - ], - [ - "DELETE", - "/api/team-applications/:applicationId", - "team-applications.deleteTeamApplication", - person("team-applications.manage", "team-applications.application-by-id", [], "Transaction"), - ], - [ - "PATCH", - "/api/teams/:teamId/application-intake", - "team-applications.reviseTeamApplicationIntake", - person("team-applications.manage", "team-applications.intake-by-team", [], "Transaction"), - ], - [ - "GET", - "/api/certificate-scopes", - "certificates.readCertificateScopes", - { - ...person("certificates.issue", "placements.explicit-department", [], "SnapshotRead"), - // Either capability grants the read, in registry order. - capabilities: { - any: [ - CapabilityTypeId.make("placements.days-served"), - CapabilityTypeId.make("certificates.issue"), - ], - }, - }, - ], - [ - "GET", - "/api/departments/:departmentId/semesters/:semesterId/days-served", - "certificates.listDaysServed", - person("placements.days-served", "placements.explicit-department", [], "SnapshotRead"), - ], - [ - "POST", - "/api/departments/:departmentId/semesters/:semesterId/days-served/:personId", - "certificates.confirmDaysServed", - person("placements.days-served", "placements.explicit-department", [], "Transaction"), - ], - [ - "GET", - "/api/departments/:departmentId/certificates", - "certificates.listCertificates", - person("certificates.issue", "placements.explicit-department", [], "SnapshotRead"), - ], - [ - "GET", - "/api/departments/:departmentId/certificates/:personId", - "certificates.readCertificate", - person("certificates.issue", "placements.explicit-department", [], "SnapshotRead"), - ], - [ - "POST", - "/api/departments/:departmentId/certificates/:personId/issues", - "certificates.issueCertificate", - person("certificates.issue", "placements.explicit-department", [], "Transaction"), - ], - - [ - "GET", - "/api/receipt-lifecycle-evidence-records/:receiptId", - "internal.readReceiptEvidence", - expectedAccess({ - exposure: "Internal", - credentials: ["BetterAuthCookie"], - principals: ["Person"], - capability: "receipts.read-internal-evidence", - resolver: "receipts.by-id", - requirements: ["internal-evidence.enabled", "receipts.owner"], - decisionTime: "SnapshotRead", - }), - ], -]; - -const expectedExternalCount = expectedOperations.filter( - ([, , , access]) => access.exposure === "External", -).length; - -const publicConditionalOperations = [ - "organization.listDepartments", - "organization.listTeams", - "organization.listFieldOfStudies", - "admissions.listOpenAdmissionPeriods", - "admissions.listApplicationOptions", - "content.listNews", - "content.readNewsArticle", -] as const; - -const privateConditionalOperations = [ - "admissionOutcomes.readOutcome", - "profile.readOwnProfile", - "admissions.listAdmissionPeriods", - "recruitment.readInvitationResponse", - "recruitment.readInterviewConduct", - "content.readArticle", -] as const; - -const createdMutationOperations = [ - "organization.createDepartment", - "organization.createTeam", - "organization.createFieldOfStudy", - "admissions.submitApplication", - "admissions.createAdmissionPeriod", - "admissions.registerReturningAssistant", - "recruitment.createApplicationInterview", - "receipts.submitReceipt", - "content.createArticle", - "social-events.create", - "team-applications.submitTeamApplication", -]; - -const entityMutationOperations = [ - "onboarding.command", - "placements.commandOwnAffiliation", - "placements.commandBoard", - "placements.commandOwnCoverage", - "placements.commandCoverageBoard", - "admissionOutcomes.recordOutcome", - "profile.updateOwnProfile", - "admissions.reviseAdmissionPeriod", - "recruitment.scheduleInterview", - "recruitment.finalizeInterview", - "recruitment.cancelInterview", - "recruitment.correctInterviewAssessment", - "receipts.reviseReceipt", - "receipts.withdrawReceipt", - "receipts.approveReceipt", - "receipts.settleReceipt", - "receipts.rejectReceipt", - "receipts.reopenReceipt", - "content.reviseArticle", - "content.publishArticle", - "content.unpublishArticle", - "team-applications.reviseTeamApplicationIntake", - "certificates.confirmDaysServed", -] as const; - -/** Mutations that answer the bytes of a generated document with the entity tag of its content. */ -const documentMutationOperations = ["certificates.issueCertificate"] as const; - -const bodyPreconditionMutationOperations = [ - "organization.executeLifecycle", - "organization.executeDelegation", - "directory.executeSchoolCommand", - "recruitment.maintainRecruitment", -] as const; - -const taggedNoContentMutationOperations = [ - "recruitment.confirmInvitation", - "recruitment.rejectInvitation", - "recruitment.requestNewInvitationTime", -] as const; - -const plainNoContentMutationOperations = [ - "system.deleteSession", - "system.deleteOwnedSession", - "system.revokeOtherSessions", - "system.revokeAllSessions", - "team-applications.deleteTeamApplication", -] as const; - -const privateBinaryReadOperations = [ - "receipts.readReceiptFile", - "receipts.readReceiptFileForApproval", -] as const; - -const privateReadOperations = [ - "organization.readAppointmentManagement", - "organization.readBoardRosters", - "organization.readDelegationManagement", - "onboarding.readBoard", - "onboarding.claim", - "placements.listScopes", - "placements.readOwnAffiliation", - "placements.readBoard", - "placements.readDraft", - "placements.readOwnCoverage", - "placements.readCoverageBoard", - "admissionOutcomes.listScopes", - "admissionOutcomes.readOutcomes", - "system.readSession", - "system.listSessions", - "organization.listTeamInterest", - "organization.listMailingLists", - "directory.listPeople", - "directory.listSchools", - "recruitment.readAssignmentBoard", - "recruitment.readInterviewReport", - "recruitment.readSchedulingBoard", - "directory.readSchoolManagement", - "recruitment.readQuestionnaires", - "recruitment.readInterviewStaffing", - "receipts.listReceipts", - "receipts.listReceiptsForApproval", - "receipts.listReceiptsForSettlement", - "receipts.readReceiptSettlementForFinance", - "content.readContentWorkspace", - "admissions.readApplicantProgress", - "social-events.readScope", - "social-events.list", - "team-applications.listTeamApplications", - "team-applications.readTeamApplication", - "certificates.readCertificateScopes", - "certificates.listDaysServed", - "certificates.listCertificates", - "certificates.readCertificate", -] as const; - -const noStoreReadOperations = [ - "system.health", - "admissions.readApplicationConfirmation", - "admissions.readReturningAssistantOptions", - "team-applications.readTeamApplicationIntake", - "team-applications.listTeamApplicationIntakes", -]; - -const existingResourceMutationOperations = new Set([ - ...entityMutationOperations, - ...documentMutationOperations, -]); - -const reflectedOperations = () => { - const externalPaths = new Map( - documentedOperations().map(({ path, operation }) => [operation.operationId, path] as const), - ); - - return [ - ...Object.values(ExternalNativeApi.groups).flatMap((group) => - Object.values(group.endpoints).map((endpoint) => ({ group, endpoint })), - ), - ...Object.values(InternalNativeApi.groups).flatMap((group) => - Object.values(group.endpoints).map((endpoint) => ({ group, endpoint })), - ), - ].map(({ group, endpoint }): ExpectedOperation => { - const operationId = `${group.identifier}.${endpoint.identifier}`; - const publicPath = externalPaths.get(operationId); - const reflected = reflectAccessSpec(endpoint); - - if (Predicate.isTagged(reflected, "None")) { - throw new TypeError(`${operationId} has no AccessSpec`); - } - - return [ - endpoint.method, - publicPath?.replace(/\{(\w+)\}/g, ":$1") ?? endpoint.path, - operationId, - projectVektorAccess(reflected.value), - ]; - }); -}; - -describe("native API reflection", () => { - it("equals the explicit operation matrix without a gap or legacy authority", () => { - const actual = reflectedOperations(); - const authorities = actual.map(([method, path]) => `${method} ${path}`); - const operationIds = actual.map(([, , operationId]) => operationId); - - expect(new Map(actual.map((row) => [row[2], row] as const))).toEqual( - new Map(expectedOperations.map((row) => [row[2], row] as const)), - ); - expect(actual).toHaveLength(expectedOperations.length); - expect(new Set(authorities).size).toBe(expectedOperations.length); - expect(new Set(operationIds).size).toBe(expectedOperations.length); - expect(authorities.some((authority) => /\/api\/admin(?:\/|$)/u.test(authority))).toBe(false); - expect( - authorities.some((authority) => - /\/api\/(?:me|field_of_studies|receipts\/submit|e2e)(?:\/|$)/u.test(authority), - ), - ).toBe(false); - expect( - authorities.some((authority) => /::|\/(?:revise|publish|unpublish)$/u.test(authority)), - ).toBe(false); - }); - it("keeps external authorities and one internal authority on separate roots", () => { - const external = endpointInventory(); - const internal = internalEndpointInventory(); - const externalAuthorities = external.map(({ method, path }) => `${method} ${path}`); - - expect(external).toHaveLength(expectedExternalCount); - expect(new Set(externalAuthorities).size).toBe(expectedExternalCount); - expect(external.map(({ group }) => group)).not.toContain("internal"); - expect(internal).toEqual([ - { - group: "internal", - identifier: "readReceiptEvidence", - method: "GET", - path: "/api/receipt-lifecycle-evidence-records/:receiptId", - }, - ]); - }); - - it("generates public OpenAPI from only the external root", () => { - const operations = documentedOperations(); - - expect(Context.get(InternalNativeApi.groups.internal.annotations, OpenApi.Exclude)).toBe(true); - expect(operations).toHaveLength(expectedExternalCount); - expect(operations.some(({ path }) => path.startsWith("/api/e2e"))).toBe(false); - expect(operations.some(({ path }) => path.startsWith("/api/auth"))).toBe(false); - }); - - it("preserves literal action suffixes in OpenAPI and encoded client URLs", () => { - const spec = OpenApi.fromApi(ExternalNativeApi); - const urls = HttpApiClient.urlBuilder(ExternalNativeApi); - - expect(spec.paths["/api/sessions:revoke-others"]?.post).toBeDefined(); - expect(spec.paths["/api/receipts/{receiptId}:withdraw"]?.post).toBeDefined(); - expect(spec.paths["/api/content/articles/{articleId}:publish"]?.post).toBeDefined(); - expect( - Object.keys(spec.paths).some((path) => path.includes("([^:]+)") || path.includes("::")), - ).toBe(false); - expect(urls.system.revokeOtherSessions({ params: {} })).toBe("/api/sessions:revoke-others"); - expect( - urls.receipts.withdrawReceipt({ - params: { receiptId: ReceiptId.make("receipt/with-colon:segment") }, - }), - ).toBe("/api/receipts/receipt%2Fwith-colon%3Asegment:withdraw"); - }); - - it("projects one declared AccessSpec without credential leakage or a second registry", () => { - const spec = OpenApi.fromApi(ExternalNativeApi); - const health = spec.paths["/health"]?.get; - const reflected = reflectAccessSpec(HealthEndpoint); - - expect(health).toHaveProperty("x-vektor-access",{ - exposure: "External", - acceptedCredentials: ["None"], - principalKinds: ["Anonymous"], - capabilities: { none: true }, - requirements: [], - canonicalScopeResolver: "system.health", - concealment: { mode: "Reveal", stages: [] }, - decisionTime: "SnapshotRead", - }); - expect(health?.security).toEqual([]); - expect(reflected._tag).toBe("Some"); - - if (Predicate.isTagged(reflected, "Some")) { - expect(reflected.value).toEqual(PUBLIC_SYSTEM_ACCESS); - } - - expect(() => annotateAccessSpec(HealthEndpoint, PUBLIC_SYSTEM_ACCESS)).toThrow( - /multiple AccessSpec annotations/u, - ); - }); - - it("declares the body token and the existing-account Person credential without inventing a header scheme", () => { - const spec = OpenApi.fromApi(ExternalNativeApi); - const claim = spec.paths["/api/onboarding/claim"]!.post!; - // New-account mode needs no HTTP scheme; existing-account mode needs the browser session. - expect(claim.security).toEqual([{}, { cookieHeader: [] }]); - expect(claim).toHaveProperty("x-vektor-body-capability",{ - type: "onboarding.claim", - pointer: "/token", - required: true, - }); - expect(claim).toHaveProperty("x-vektor-conditional-credential",{ - when: { pointer: "/mode", equals: "ExistingAccount" }, - principalKind: "Person", - mechanisms: ["BetterAuthCookie"], - }); - expect(claim).toMatchObject({ "x-vektor-access": { - acceptedCredentials: ["BetterAuthCookie", "ObjectCapability"], - principalKinds: ["Person", "CapabilityHolder"], - capabilities: { one: "onboarding.claim" }, - requirements: [{ id: "onboarding.claim-token" }], - } }); - - const body = Schema.decodeUnknownSync(Schema.Struct({ content: Schema.Struct({ "application/json": Schema.Struct({ schema: Schema.Struct({ anyOf: Schema.Array(Schema.Struct({ required: Schema.Array(Schema.String) })) }) }) }) }))(claim.requestBody); - - expect( - body.content["application/json"]!.schema.anyOf.every((option) => - option.required.includes("token"), - ), - ).toBe(true); - }); - - it("derives stable fully-qualified group.endpoint operation ids", () => { - const spec = OpenApi.fromApi(ExternalNativeApi); - - const actual = documentedOperations() - .map(({ operation }) => operation.operationId) - .sort(); - - const expected = endpointInventory() - .map(({ group, identifier }) => `${group}.${identifier}`) - .sort(); - - const internal = internalEndpointInventory().map( - ({ group, identifier }) => `${group}.${identifier}`, - ); - - expect(actual).toEqual(expected); - expect(actual).toEqual( - expect.arrayContaining([ - "system.health", - "organization.listDepartments", - "profile.readOwnProfile", - "organization.createDepartment", - "admissions.createAdmissionPeriod", - "recruitment.readInterviewReport", - "recruitment.readSchedulingBoard", - "receipts.submitReceipt", - "content.listNews", - ]), - ); - expect(internal).toEqual(["internal.readReceiptEvidence"]); - expect(actual).not.toContain(internal[0]); - expect(spec.paths["/api/departments"]?.get?.operationId).toBe("organization.listDepartments"); - }); - - it("derives unique operation ids and representative request, response, and error schemas", () => { - const spec = OpenApi.fromApi(ExternalNativeApi); - const operations = documentedOperations(); - const operationIds = operations.map(({ operation }) => operation.operationId); - - - expect(new Set(operationIds).size).toBe(operations.length); - expect(spec.openapi).toBe("3.1.0"); - expect(spec.info.title).toBe("Vektorprogrammet native preview API"); - expect(spec.servers ?? []).toEqual([]); - expect(spec).toHaveProperty("x-vektorprogrammet-provenance"); - - for (const { operation } of operations) expect(operation).toHaveProperty("x-vektorprogrammet-provenance"); - expect(operations.every(({ operation }) => operation.tags.length > 0)).toBe(true); - expect(spec.paths["/api/session"]?.get?.security[0]?.cookieHeader).toEqual([]); - expect(spec.paths["/api/departments"]?.get?.responses["200"]).toBeDefined(); - expect(spec.paths["/api/session"]?.get?.responses["401"]).toBeDefined(); - expect(spec.paths["/api/departments"]?.post?.responses["201"]).toBeDefined(); - expect( - spec.paths["/api/receipts"]?.post?.requestBody?.content["multipart/form-data"], - ).toBeDefined(); - expect(spec.paths["/api/receipts"]?.post?.responses["422"]).toBeDefined(); - expect( - spec.paths["/api/recruitment/interviews/{interviewId}:finalize"]?.post?.responses["409"], - ).toBeDefined(); - expect(spec.paths["/api/news/{slug}"]?.get?.responses["200"]).toBeDefined(); - }); - - it("freezes endpoint metadata, schemas, headers, and access projections programmatically", () => { - const spec = OpenApi.fromApi(ExternalNativeApi); - const operations = documentedOperations(); - - const byId = new Map( - operations.map(({ operation }) => [operation.operationId, operation] as const), - ); - - const categories = [ - "contact.submitContactMessage", - ...privateBinaryReadOperations, - - ...publicConditionalOperations, - ...privateConditionalOperations, - ...createdMutationOperations, - ...entityMutationOperations, - ...documentMutationOperations, - ...bodyPreconditionMutationOperations, - ...taggedNoContentMutationOperations, - ...plainNoContentMutationOperations, - ...privateReadOperations, - ...noStoreReadOperations, - ]; - - expect(categories).toHaveLength(expectedExternalCount); - expect(new Set(categories).size).toBe(expectedExternalCount); - expect([...byId.keys()].sort()).toEqual([...categories].sort()); - - const operation = (operationId: string) => { - const value = byId.get(operationId); - - if (value === undefined) throw new TypeError(`missing ${operationId}`); - - return value; - }; - - const assertSuccess = ( - operationId: string, - status: "200" | "201" | "204" | "304", - headers: ReadonlyArray, - body: boolean, - ) => { - const response = operation(operationId).responses[status]; - - if (response === undefined) throw new TypeError(`${operationId} has no ${status}`); - expect(Object.keys(response.headers ?? {}).sort()).toEqual([...headers].sort()); - expect(Object.keys(response.content ?? {})).toEqual(body ? ["application/json"] : []); - }; - - for (const operationId of [...publicConditionalOperations, ...privateConditionalOperations]) { - assertSuccess(operationId, "200", ["cache-control", "etag", "vary"], true); - assertSuccess(operationId, "304", ["cache-control", "etag", "vary"], false); - } - - for (const operationId of createdMutationOperations) { - assertSuccess(operationId, "201", ["cache-control", "etag", "location", "vary"], true); - } - - for (const operationId of entityMutationOperations) { - assertSuccess(operationId, "200", ["cache-control", "etag", "vary"], true); - } - - for (const operationId of bodyPreconditionMutationOperations) { - assertSuccess(operationId, "200", ["cache-control", "etag", "vary"], true); - } - - for (const operationId of taggedNoContentMutationOperations) { - assertSuccess(operationId, "204", ["cache-control", "etag", "vary"], false); - } - - for (const operationId of plainNoContentMutationOperations) { - assertSuccess(operationId, "204", ["cache-control", "vary"], false); - } - - for (const operationId of [...privateReadOperations, ...noStoreReadOperations]) { - assertSuccess(operationId, "200", ["cache-control", "vary"], true); - } - - for (const operationId of privateBinaryReadOperations) { - const binary = operation(operationId).responses["200"]!; - expect(Object.keys(binary.content ?? {})).toEqual(["application/octet-stream"]); - expect(Object.keys(binary.headers ?? {}).sort()).toEqual([ - "cache-control", - "content-disposition", - "content-length", - "vary", - "x-content-type-options", - ]); - } - - for (const operationId of documentMutationOperations) { - const document = operation(operationId).responses["200"]!; - expect(Object.keys(document.content ?? {})).toEqual(["application/pdf"]); - expect(Object.keys(document.headers ?? {}).sort()).toEqual(["cache-control", "etag", "vary"]); - } - - assertSuccess("contact.submitContactMessage", "201", ["cache-control", "vary"], false); - - const tags = new Map([ - ["contact", "Public contact"], - ["admissionOutcomes", "Admission outcomes"], - ["placements", "Volunteer placement"], - ["onboarding", "Applicant account onboarding"], - ["admissions", "Admissions"], - ["content", "Content and news"], - ["directory", "Directories"], - ["organization", "Organization"], - ["profile", "Profile"], - ["receipts", "Receipts"], - ["recruitment", "Recruitment"], - ["social-events", "Social events"], - ["system", "System"], - ["team-applications", "Team applications"], - ["certificates", "Certificates"], - ]); - - for (const [operationId, documented] of byId) { - const group = operationId.slice(0, operationId.indexOf(".")); - const tag = tags.get(group); - - if (tag === undefined) throw new TypeError(`unknown group ${group}`); - expect(documented.tags).toEqual([tag]); - expect(documented.summary?.trim().length).toBeGreaterThan(0); - expect(documented.description?.trim().length).toBeGreaterThan(0); - - for (const [status, response] of Object.entries(documented.responses)) { - if (Number(status) < 400) { - if (status !== "201") expect(response.headers).not.toHaveProperty("location"); - continue; - } - - expect(Object.keys(response.content ?? {})).toEqual(["application/problem+json"]); - const headers = Object.keys(response.headers ?? {}); - expect(headers).toEqual(expect.arrayContaining(["cache-control", "vary"])); - - if (status === "401") expect(headers).toContain("www-authenticate"); - - if (status === "429" || status === "503") expect(headers).toContain("retry-after"); - - if (status === "500") expect(headers).not.toContain("retry-after"); - } - } - - const conditional = new Set([ - ...publicConditionalOperations, - ...privateConditionalOperations, - ]); - - const mutations = new Set([ - ...createdMutationOperations, - ...entityMutationOperations, - ...documentMutationOperations, - ...taggedNoContentMutationOperations, - ...plainNoContentMutationOperations, - ...bodyPreconditionMutationOperations, - ]); - - for (const operationId of categories) { - const headerParameters = (operation(operationId).parameters ?? []) - .filter((parameter) => "in" in parameter && parameter.in === "header") - .map((parameter) => ("name" in parameter ? parameter.name.toLowerCase() : "")) - .sort(); - - if (operationId === "contact.submitContactMessage") { - expect(headerParameters).toEqual(["x-vektor-contact-ip"]); - } else if (conditional.has(operationId)) { - expect(headerParameters).toEqual(["if-match", "if-none-match"]); - } else if (mutations.has(operationId)) { - expect(headerParameters).toEqual( - taggedNoContentMutationOperations.some((id) => id === operationId) - ? ["if-match"] - : existingResourceMutationOperations.has(operationId) - ? ["idempotency-key", "if-match"] - : ["idempotency-key"], - ); - } else { - expect(headerParameters).toEqual([]); - } - } - - for (const operationId of [ - "profile.updateOwnProfile", - "admissions.reviseAdmissionPeriod", - "content.reviseArticle", - "team-applications.reviseTeamApplicationIntake", - ]) { - expect(Object.keys(operation(operationId).requestBody?.content ?? {})).toEqual([ - "application/merge-patch+json", - ]); - } - - for (const operationId of ["receipts.submitReceipt", "receipts.reviseReceipt"]) { - const multipart = operation(operationId).requestBody?.content["multipart/form-data"]; - - if (multipart === undefined) throw new TypeError(`${operationId} has no multipart body`); - const requestSchema = multipart.schema; - - if ( - requestSchema === undefined || - !("$ref" in requestSchema) || - !Predicate.isString(requestSchema.$ref) - ) { - throw new TypeError(`${operationId} multipart schema is not a component reference`); - } - - const componentName = requestSchema.$ref.slice(requestSchema.$ref.lastIndexOf("/") + 1); - expect(JSON.stringify(spec.components.schemas[componentName])).toContain('"format":"binary"'); - } - - expect(operation("receipts.listReceiptsForApproval").security).toEqual([ - { cookieHeader: [] }, - { oauthUserBearer: [] }, - { oauthServiceBearer: [] }, - ]); - }); -}); - -describe("frozen v0.2 boundary schemas", () => { - const strict = { onExcessProperty: "error" } as const; - - it("keeps merge-patch absence distinct from null and unknown fields", () => { - expect(Schema.decodeSync(ProfileMergePatch)({ firstName: "Ada" }, strict)).toEqual({ - firstName: "Ada", - }); - expect(Schema.decodeSync(ProfileMergePatch)({}, strict)).toEqual({}); - expect(Schema.decodeSync(ProfileMergePatch)({ email: null }, strict)).toEqual({ - email: null, - }); - expect(() => - Schema.decodeUnknownSync(ProfileMergePatch)({ personId: "person-1" }, strict), - ).toThrow(); - }); - - it("cuts the directory response over to people names without an alias", () => { - const response = { activePeople: [], inactivePeople: [], nextCursor: null }; - expect(Schema.decodeSync(PeopleDirectoryResponse)(response, strict)).toEqual(response); - expect(() => - Schema.decodeUnknownSync(PeopleDirectoryResponse)( - { activeUsers: [], inactiveUsers: [], nextCursor: null }, - strict, - ), - ).toThrow(); - }); - - it("keeps endpoint problem variants correlated and validation extensions mandatory", () => { - const problem = { - ...NativeProblemRegistry["internal.error"], - code: "internal.error", - } as const; - - expect(Schema.decodeSync(SystemHealthProblem)(problem, strict)).toEqual(problem); - expect(() => - Schema.decodeUnknownSync(SystemHealthProblem)( - { ...problem, detail: "database password leaked" }, - strict, - ), - ).toThrow(); - - const validationProblem = { - ...NativeProblemRegistry["validation.failed"], - code: "validation.failed", - validation: { - errors: [ - { - pointer: "/firstName", - code: "invalid", - message: "The value is invalid.", - }, - ], - truncated: false, - }, - } as const; - - expect( - Schema.decodeSync(ProfileUpdateOwnProfileProblem)(validationProblem, strict), - ).toEqual(validationProblem); - const { validation: _, ...validationCore } = validationProblem; - expect(() => - Schema.decodeUnknownSync(ProfileUpdateOwnProfileProblem)(validationCore, strict), - ).toThrow(); - }); -}); diff --git a/packages/http-api/test/receipts.test.ts b/packages/http-api/test/receipts.test.ts deleted file mode 100644 index aab31cdbe..000000000 --- a/packages/http-api/test/receipts.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { HttpApi, OpenApi } from "effect/unstable/httpapi"; -import { describe, expect, it } from "vitest"; -import { ReceiptsApi } from "../src/receipts.js"; - -const ReceiptContractApi = HttpApi.make("receipt-contract").add(ReceiptsApi); - -describe("frozen receipt route contract", () => { - it("publishes the thirteen outward receipt operations without internal evidence routes", () => { - const document = OpenApi.fromApi(ReceiptContractApi); - - const operations = Object.entries(document.paths).flatMap(([path, item]) => - Object.keys(item).map((method) => `${method.toUpperCase()} ${path}`), - ); - - expect(operations.sort()).toEqual( - [ - "GET /api/receipts/{receiptId}/file", - "GET /api/receipt-approval-queue/{receiptId}/file", - "POST /api/receipts", - "PATCH /api/receipts/{receiptId}", - "POST /api/receipts/{receiptId}:withdraw", - "GET /api/receipts", - "GET /api/receipt-approval-queue", - "POST /api/receipts/{receiptId}:approve", - "POST /api/receipts/{receiptId}:reject", - "POST /api/receipts/{receiptId}:reopen", - "GET /api/receipt-settlement-queue", - "GET /api/receipt-settlement-queue/{receiptId}", - "POST /api/receipts/{receiptId}:settle", - ].sort(), - ); - }); -}); diff --git a/packages/rpc/AGENTS.md b/packages/rpc/AGENTS.md new file mode 100644 index 000000000..5a7c48180 --- /dev/null +++ b/packages/rpc/AGENTS.md @@ -0,0 +1,30 @@ +[//]: # "guide: generated from docs/model/contexts.cml, the @construct tags, and package.json exports by just guides write; do not edit" + +# packages/rpc + +The native RPC contract, its credential middlewares, problems, and client. +Package `@vektorprogrammet/rpc`. + +## Entry points + +| Import | Module | +| ------------------------------- | -------------------------------------------- | +| `@vektorprogrammet/rpc` | [src/index.ts](src/index.ts) | +| `@vektorprogrammet/rpc/problem` | [src/problem.ts](src/problem.ts) | +| `@vektorprogrammet/rpc/client` | [src/client.ts](src/client.ts) | +| `@vektorprogrammet/rpc/script` | [src/script-client.ts](src/script-client.ts) | + +## Constructs + +The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../docs/constructs.md) indexes them all. + +- [`isNativeRpcPath`](../../docs/constructs/rpc-transport.md#isnativerpcpath) (rpc-transport): Whether a request path addresses the native RPC endpoint. +- [`problemUnion`](../../docs/constructs/rpc-problem.md#problemunion) (rpc-problem): Creates the closed Problem Details union of one RPC. +- [`Problem`](../../docs/constructs/rpc-problem.md#problem) (rpc-problem): One RFC 9457 failure in an Effect error channel. +- [`isProblem`](../../docs/constructs/rpc-problem.md#isproblem) (rpc-problem): Narrows a caught value to a `Problem`, also one that another copy of this module created. +- [`problemBody`](../../docs/constructs/rpc-problem.md#problembody) (rpc-problem): The frozen RFC 9457 body: the registry entry, then code, instance, and validation. +- [`makeNativeProblem`](../../docs/constructs/rpc-problem.md#makenativeproblem) (rpc-problem): Builds one safe fixed public problem value. + +Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. + +[//]: # "guide: end" diff --git a/apps/backend/src/schools/CLAUDE.md b/packages/rpc/CLAUDE.md similarity index 100% rename from apps/backend/src/schools/CLAUDE.md rename to packages/rpc/CLAUDE.md diff --git a/packages/http-api/package.json b/packages/rpc/package.json similarity index 77% rename from packages/http-api/package.json rename to packages/rpc/package.json index 42d81d902..4a4d8abe7 100644 --- a/packages/http-api/package.json +++ b/packages/rpc/package.json @@ -1,15 +1,16 @@ { - "name": "@vektorprogrammet/http-api", - "version": "0.2.0", + "name": "@vektorprogrammet/rpc", + "version": "0.3.0", "private": true, "homepage": "https://github.com/vektorprogrammet/mono-web", "type": "module", "exports": { ".": "./src/index.ts", - "./http-semantics": "./src/http-semantics.ts" + "./problem": "./src/problem.ts", + "./client": "./src/client.ts", + "./script": "./src/script-client.ts" }, "scripts": { - "generate": "bun run scripts/generate-openapi.ts", "check-types": "tsc --noEmit", "build": "tsc --noEmit", "lint": "oxlint", diff --git a/packages/http-api/src/access.ts b/packages/rpc/src/access.ts similarity index 54% rename from packages/http-api/src/access.ts rename to packages/rpc/src/access.ts index 5dfd0574c..da7de898e 100644 --- a/packages/http-api/src/access.ts +++ b/packages/rpc/src/access.ts @@ -12,7 +12,6 @@ import { type AuthorizationMode, type CapabilityExpression, CapabilityTypeId, - type CredentialMechanism, RequirementId, ScopeResolverId, type TypedRequirement, @@ -32,20 +31,26 @@ const typedRequirements = ( requirements.map((id) => ({ id: RequirementId.make(id), parameters: {} })); /** Colocated AccessSpec constructor for an anonymous native operation. */ -export const anonymousNativeAccess = ( - canonicalScopeResolver: ScopeResolverValue, - decisionTime: AuthorizationMode = "SnapshotRead", -): AccessSpec => - makeAccessSpec({ - exposure: "External", - acceptedCredentials: [CredentialMechanismSchema.cases.None.make({})], - principalKinds: ["Anonymous"], - capabilities: CapabilityExpressionSchema.cases.None.make({}), - requirements: [], - canonicalScopeResolver, - concealment: ConcealmentPolicySchema.cases.Reveal.make({}), - decisionTime, - }); +export const anonymousNativeAccess: { + (canonicalScopeResolver: ScopeResolverValue, decisionTime?: AuthorizationMode): AccessSpec; + (decisionTime?: AuthorizationMode): (canonicalScopeResolver: ScopeResolverValue) => AccessSpec; +} = dual( + (args) => Schema.is(ScopeResolverId)(args[0]), + ( + canonicalScopeResolver: ScopeResolverValue, + decisionTime: AuthorizationMode = "SnapshotRead", + ): AccessSpec => + makeAccessSpec({ + exposure: "External", + acceptedCredentials: [CredentialMechanismSchema.cases.None.make({})], + principalKinds: ["Anonymous"], + capabilities: CapabilityExpressionSchema.cases.None.make({}), + requirements: [], + canonicalScopeResolver, + concealment: ConcealmentPolicySchema.cases.Reveal.make({}), + decisionTime, + }), +); /** Colocated AccessSpec constructor for a first-party cookie-only session operation. */ export const browserSessionNativeAccess = (input: { @@ -104,28 +109,39 @@ export const personNativeAccess = (input: { }); /** Exact object-capability access contract for invitation response operations. */ -export const invitationNativeAccess = ( - requirements: ReadonlyArray<"recruitment.invitation-pending">, - decisionTime: AuthorizationMode, -): AccessSpec => - makeAccessSpec({ - exposure: "External", - acceptedCredentials: [ - CredentialMechanismSchema.cases.ObjectCapability.make({ - capabilityType: INVITATION_RESPONSE_CAPABILITY, +export const invitationNativeAccess: { + ( + decisionTime: AuthorizationMode, + ): (requirements: ReadonlyArray<"recruitment.invitation-pending">) => AccessSpec; + ( + requirements: ReadonlyArray<"recruitment.invitation-pending">, + decisionTime: AuthorizationMode, + ): AccessSpec; +} = dual( + 2, + ( + requirements: ReadonlyArray<"recruitment.invitation-pending">, + decisionTime: AuthorizationMode, + ): AccessSpec => + makeAccessSpec({ + exposure: "External", + acceptedCredentials: [ + CredentialMechanismSchema.cases.ObjectCapability.make({ + capabilityType: INVITATION_RESPONSE_CAPABILITY, + }), + ], + principalKinds: ["CapabilityHolder"], + capabilities: CapabilityExpressionSchema.cases.One.make({ + capability: { type: INVITATION_RESPONSE_CAPABILITY }, }), - ], - principalKinds: ["CapabilityHolder"], - capabilities: CapabilityExpressionSchema.cases.One.make({ - capability: { type: INVITATION_RESPONSE_CAPABILITY }, - }), - requirements: typedRequirements(requirements), - canonicalScopeResolver: "recruitment.invitation-response-by-capability", - concealment: ConcealmentPolicySchema.cases.NotFound.make({ - conceal: ["CredentialFailure", "PrincipalKind", "Capability", "Scope", "Requirement"], + requirements: typedRequirements(requirements), + canonicalScopeResolver: "recruitment.invitation-response-by-capability", + concealment: ConcealmentPolicySchema.cases.NotFound.make({ + conceal: ["CredentialFailure", "PrincipalKind", "Capability", "Scope", "Requirement"], + }), + decisionTime, }), - decisionTime, - }); +); /** Exact isolated internal receipt evidence access contract. */ export const internalReceiptEvidenceAccess = (): AccessSpec => @@ -143,11 +159,13 @@ export const internalReceiptEvidenceAccess = (): AccessSpec => }); import { Match, Predicate, Context, Option, Schema } from "effect"; -import { HttpApiEndpoint, OpenApi } from "effect/unstable/httpapi"; +import type { Rpc, RpcMiddleware } from "effect/unstable/rpc"; +import { dual } from "effect/Function"; -export const AccessSpecAnnotation = Context.Service( - "@vektorprogrammet/http-api/AccessSpec", -); +/** The AccessSpec that annotates one RPC. */ +export class AccessSpecAnnotation extends Context.Service()( + "@vektorprogrammet/rpc/access/AccessSpecAnnotation", +) {} const rank = (order: ReadonlyArray, value: string): number => { const index = order.indexOf(value); @@ -158,31 +176,22 @@ const rank = (order: ReadonlyArray, value: string): number => { const compareByRegistry = (order: ReadonlyArray) => (left: string, right: string) => rank(order, left) - rank(order, right); -const projectedCapabilities = (expression: CapabilityExpression) => { - return Match.value(expression).pipe( - Match.tag("None", () => { - return { none: true }; - }), - Match.tag("One", (expression) => { - return { one: expression.capability.type }; - }), - Match.tag("All", (expression) => { - return { - all: expression.capabilities - .map((capability) => capability.type) - .sort(compareByRegistry(CAPABILITY_TYPE_IDS)), - }; - }), - Match.tag("Any", (expression) => { - return { - any: expression.capabilities - .map((capability) => capability.type) - .sort(compareByRegistry(CAPABILITY_TYPE_IDS)), - }; - }), +const projectedCapabilities = (expression: CapabilityExpression) => + Match.value(expression).pipe( + Match.tag("None", () => ({ none: true })), + Match.tag("One", (expression) => ({ one: expression.capability.type })), + Match.tag("All", (expression) => ({ + all: expression.capabilities + .map((capability) => capability.type) + .sort(compareByRegistry(CAPABILITY_TYPE_IDS)), + })), + Match.tag("Any", (expression) => ({ + any: expression.capabilities + .map((capability) => capability.type) + .sort(compareByRegistry(CAPABILITY_TYPE_IDS)), + })), Match.exhaustive, ); -}; const projectedRequirement = (requirement: TypedRequirement) => { const parameters = Object.keys(requirement.parameters); @@ -259,159 +268,67 @@ export const assertAccessProjectionRegistryParity = (): void => { expected.some((capabilityType, index) => capabilityType !== actual[index]) ) { throw new TypeError( - `ObjectCapability OpenAPI security mapping does not match the domain registry: expected ${expected.join(",")}; received ${actual.join(",")}`, + `ObjectCapability security mapping does not match the domain registry: expected ${expected.join(",")}; received ${actual.join(",")}`, ); } }; assertAccessProjectionRegistryParity(); -const securitySchemeFor = (mechanism: CredentialMechanism): string | null | undefined => { - return Match.value(mechanism).pipe( - Match.tag("None", () => { - return undefined; - }), - Match.tag("BetterAuthCookie", () => { - return "cookieHeader"; - }), - Match.tag("OAuthUserBearer", () => { - return "oauthUserBearer"; - }), - Match.tag("OAuthServiceBearer", () => { - return "oauthServiceBearer"; - }), - Match.tag("ObjectCapability", (mechanism) => { - const scheme = objectCapabilitySecurityScheme[capabilityTypeValue(mechanism.capabilityType)]; - - if (scheme === undefined) { - throw new TypeError( - `object capability ${mechanism.capabilityType} has no OpenAPI security scheme`, - ); - } +/** + * Attaches the AccessSpec of one RPC. Every RPC carries exactly one; the handler evaluates it with + * `authorizePerson` or `authorizeAnonymous`, and `reflectAccessSpec` reads it back. + */ +export const accessSpecAnnotations = (input: AccessSpec): Context.Context => { + const spec = makeAccessSpec(input); - return Predicate.isString(scheme) ? scheme : null; - }), - Match.exhaustive, + const acceptsPerson = spec.acceptedCredentials.some( + (mechanism) => + Predicate.isTagged(mechanism, "BetterAuthCookie") || + Predicate.isTagged(mechanism, "OAuthUserBearer"), ); -}; - -export type OpenApiSecurityProjection = ReadonlyArray< - Readonly>> ->; -export const projectCredentialSecurity = (spec: AccessSpec): OpenApiSecurityProjection => { - if ( - spec.acceptedCredentials.length === 1 && - Predicate.isTagged(spec.acceptedCredentials[0], "None") - ) { - return []; - } - - return spec.acceptedCredentials.flatMap((mechanism) => { - const scheme = securitySchemeFor(mechanism); - - if (scheme === undefined) { - throw new TypeError("None cannot be combined with another credential mechanism"); + for (const mechanism of spec.acceptedCredentials) { + if ( + Predicate.isTagged(mechanism, "ObjectCapability") && + Predicate.isObjectOrArray( + objectCapabilitySecurityScheme[capabilityTypeValue(mechanism.capabilityType)], + ) && + !acceptsPerson + ) { + throw new TypeError( + `body capability ${mechanism.capabilityType} requires a Person credential its AccessSpec does not accept`, + ); } + } - // A body capability is the alternative that needs no HTTP security scheme. - return scheme === null ? [{}] : [{ [scheme]: [] }]; - }); -}; - -export const accessSpecAnnotations = (input: AccessSpec): Context.Context => { - const spec = makeAccessSpec(input); - - const personMechanisms = spec.acceptedCredentials - .filter( - (mechanism) => - Predicate.isTagged(mechanism, "BetterAuthCookie") || - Predicate.isTagged(mechanism, "OAuthUserBearer"), - ) - .map((mechanism) => mechanism._tag) - .sort(compareByRegistry(CREDENTIAL_MECHANISM_KINDS)); - - return Context.merge( - Context.make(AccessSpecAnnotation, spec), - OpenApi.annotations({ - override: { - "x-vektor-access": projectVektorAccess(spec), - security: projectCredentialSecurity(spec), - ...Object.fromEntries( - spec.acceptedCredentials.flatMap((mechanism) => { - if (!Predicate.isTagged(mechanism, "ObjectCapability")) return []; - - const projection = - objectCapabilitySecurityScheme[capabilityTypeValue(mechanism.capabilityType)]; - - if (!Predicate.isObjectOrArray(projection)) return []; - - if (personMechanisms.length === 0) { - throw new TypeError( - `body capability ${mechanism.capabilityType} requires a Person credential its AccessSpec does not accept`, - ); - } - - return [ - [ - "x-vektor-body-capability", - { - type: mechanism.capabilityType, - pointer: projection.bodyPointer, - required: projection.required, - }, - ], - [ - "x-vektor-conditional-credential", - { - when: projection.personCredentialWhen, - principalKind: "Person", - mechanisms: personMechanisms, - }, - ], - ]; - }), - ), - }, - }), - ); + return Context.make(AccessSpecAnnotation, spec); }; +/** The AccessSpec that `withAccessSpec` attached to an RPC. */ export const reflectAccessSpec = ( - endpoint: Pick, -): Option.Option => Context.getOption(endpoint.annotations, AccessSpecAnnotation); - -export const annotateAccessSpec = < - Identifier extends string, - Method extends HttpApiEndpoint.Top["method"], - Path extends string, - Params extends Schema.Top, - Query extends Schema.Top, - Payload extends Schema.Top, - Headers extends Schema.Top, - Success extends Schema.Top, - Error extends Schema.Top, - Middleware, - MiddlewareServices, ->( - endpoint: HttpApiEndpoint.HttpApiEndpoint< - Identifier, - Method, - Path, - Params, - Query, - Payload, - Headers, - Success, - Error, - Middleware, - MiddlewareServices - >, - input: AccessSpec, -) => { - if (Option.isSome(reflectAccessSpec(endpoint))) { - throw new TypeError(`endpoint ${endpoint.identifier} has multiple AccessSpec annotations`); - } + rpc: Pick, +): Option.Option => Context.getOption(rpc.annotations, AccessSpecAnnotation); + +/** Attaches `input` to `rpc`; a second AccessSpec on one RPC is a defect. */ +export const withAccessSpec = + (input: AccessSpec) => + < + Tag extends string, + Payload extends Schema.Top, + Success extends Schema.Top, + Error extends Schema.Top, + Middleware extends RpcMiddleware.AnyService, + Requires, + >( + rpc: Rpc.Rpc, + ): Rpc.Rpc => { + if (Option.isSome(reflectAccessSpec(rpc))) { + throw new TypeError(`RPC ${rpc._tag} has multiple AccessSpec annotations`); + } - return endpoint.annotateMerge(accessSpecAnnotations(input)); -}; + return rpc.annotate( + AccessSpecAnnotation, + Context.get(accessSpecAnnotations(input), AccessSpecAnnotation), + ); + }; diff --git a/packages/rpc/src/admission-outcomes.ts b/packages/rpc/src/admission-outcomes.ts new file mode 100644 index 000000000..69a5cbb58 --- /dev/null +++ b/packages/rpc/src/admission-outcomes.ts @@ -0,0 +1,136 @@ +/** + * Admission outcomes: the recorded outcome of each application, and the substitutes on call whom + * department members contact to arrange cover. + * + * @since 0.3.0 + */ +import { AdmissionPeriodId } from "@vektorprogrammet/domain/admission-period"; +import { + AdmissionOutcomeCommand, + AdmissionOutcomeEntry, + AdmissionOutcomeScope, + AdmissionOutcomeScopes, + OnCallSubstitute, +} from "@vektorprogrammet/domain/admissions"; +import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { personNativeAccess, withAccessSpec } from "./access.js"; +import { PersonCredential } from "./credential.js"; +import { IdempotencyKey, problemUnion, rpcProblems, StrongETag } from "./problem.js"; + +export { + AdmissionOutcomeCommand, + AdmissionOutcomeEntry, + AdmissionOutcomeScope, + AdmissionOutcomeScopes, + OnCallSubstitute, +}; + +/** One application's outcome entry, with the tag that a later record names as `ifMatch`. */ +export const AdmissionOutcomeResource = Schema.Struct({ + ...AdmissionOutcomeEntry.fields, + etag: StrongETag, +}).annotate({ identifier: "AdmissionOutcomeResource" }); + +export type AdmissionOutcomeResource = typeof AdmissionOutcomeResource.Type; + +const BoardFields = { + ...AdmissionOutcomeScope.fields, + admissionPeriodId: Schema.NullOr(AdmissionPeriodId), +}; + +/** Admission management sees every application; other department members see substitutes on call. */ +export const AdmissionOutcomeBoardResource = Schema.TaggedUnion({ + ReadOnly: { ...BoardFields, substitutes: Schema.Array(OnCallSubstitute) }, + Decide: { ...BoardFields, entries: Schema.Array(AdmissionOutcomeResource) }, +}).annotate({ identifier: "AdmissionOutcomeBoardResource" }); + +export type AdmissionOutcomeBoardResource = typeof AdmissionOutcomeBoardResource.Type; + +const admissionOutcomeProblems = [ + "credential.missing", + "credential.invalid", + "validation.failed", + "scope.invalid", + "authority.denied", + "resource.not-found", + "precondition.failed", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "internal.error", +] as const; + +/** Problems of the admission outcome reads. */ +export const AdmissionOutcomeProblem = problemUnion( + "AdmissionOutcomeProblem", + admissionOutcomeProblems, +); + +/** A record also answers an unavailable receipt store. */ +export const AdmissionOutcomeCommandProblem = problemUnion("AdmissionOutcomeCommandProblem", [ + ...admissionOutcomeProblems, + "idempotency.unavailable", +]); + +const access = (decide: boolean) => + personNativeAccess({ + capability: decide ? "admissions.outcomes.decide" : "admissions.outcomes.read", + canonicalScopeResolver: "admissions.application-outcomes", + decisionTime: decide ? "Transaction" : "SnapshotRead", + }); + +/** The authorized departments and canonical historical semesters. */ +export const ListAdmissionOutcomeScopes = Rpc.make("admissionOutcomes.listScopes", { + success: AdmissionOutcomeScopes, + error: rpcProblems(AdmissionOutcomeProblem), +}) + .middleware(PersonCredential) + .pipe(withAccessSpec(access(false))); + +/** + * Admission management reads every application of the period; other department members read the + * name and contact of each substitute on call. + */ +export const ReadAdmissionOutcomes = Rpc.make("admissionOutcomes.readOutcomes", { + payload: AdmissionOutcomeScope, + success: AdmissionOutcomeBoardResource, + error: rpcProblems(AdmissionOutcomeProblem), +}) + .middleware(PersonCredential) + .pipe(withAccessSpec(access(false))); + +/** Only admission management reads one application's outcome entry. */ +export const ReadAdmissionOutcome = Rpc.make("admissionOutcomes.readOutcome", { + payload: Schema.Struct({ applicationId: PublicApplicationIdSchema }), + success: AdmissionOutcomeResource, + error: rpcProblems(AdmissionOutcomeProblem), +}) + .middleware(PersonCredential) + .pipe(withAccessSpec(access(false))); + +/** + * Records Admitted, Substitute, or Rejected, or replays by its idempotency key. Recording the + * current outcome again changes nothing. + */ +export const RecordAdmissionOutcome = Rpc.make("admissionOutcomes.recordOutcome", { + payload: Schema.Struct({ + applicationId: PublicApplicationIdSchema, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: AdmissionOutcomeCommand, + }), + success: AdmissionOutcomeResource, + error: rpcProblems(AdmissionOutcomeCommandProblem), +}) + .middleware(PersonCredential) + .pipe(withAccessSpec(access(true))); + +export class AdmissionOutcomesRpcs extends RpcGroup.make( + ListAdmissionOutcomeScopes, + ReadAdmissionOutcomes, + ReadAdmissionOutcome, + RecordAdmissionOutcome, +) {} diff --git a/packages/rpc/src/admissions.ts b/packages/rpc/src/admissions.ts new file mode 100644 index 000000000..bf8a639f4 --- /dev/null +++ b/packages/rpc/src/admissions.ts @@ -0,0 +1,330 @@ +/** + * Admissions: admission periods, public applications, applicant progress, and returning + * assistants. + * + * @since 0.3.0 + */ +import { AdmissionPeriodId } from "@vektorprogrammet/domain/admission-period"; +import { + ApplicantProgressItemSchema, + ApplicantProgressResponseSchema, + ApplicantProgressStateSchema, + PublicApplicationCatalogSchema, + PublicApplicationIdSchema, + ReturningAssistantOptionsSchema, + ReturningAssistantRegistrationInputSchema, + ReturningAssistantRegistrationResponseSchema, + type ApplicantProgressItem, + type ApplicantProgressResponse, + type ApplicantProgressState, +} from "@vektorprogrammet/domain/application"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { anonymousNativeAccess, personNativeAccess, withAccessSpec } from "./access.js"; +import { PersonCredential } from "./credential.js"; +import { IdempotencyKey, problemUnion, rpcProblems, StrongETag } from "./problem.js"; +import { + AdmissionPeriodManagementItem, + AdmissionPeriodManagementListResponse, + AdmissionPeriodMergePatch, + CreateAdmissionPeriodRequest, + OpenAdmissionPeriodListResponse, + PublicApplicationConfirmationSchema, + SubmitApplicationRequest, +} from "./v2-schemas.js"; + +export { + AdmissionPeriodId, + ApplicantProgressItemSchema, + ApplicantProgressResponseSchema, + ApplicantProgressStateSchema, + PublicApplicationCatalogSchema, + PublicApplicationIdSchema, + ReturningAssistantOptionsSchema, + ReturningAssistantRegistrationInputSchema, + ReturningAssistantRegistrationResponseSchema, +}; + +export type { ApplicantProgressItem, ApplicantProgressResponse, ApplicantProgressState }; + +/** Problems of `admissions.listOpenAdmissionPeriods`. */ +export const ListOpenAdmissionPeriodsProblem = problemUnion("ListOpenAdmissionPeriodsProblem", [ + "internal.error", + "admissions.unavailable", +]); + +/** Problems of `admissions.listApplicationOptions`. */ +export const ListApplicationOptionsProblem = problemUnion("ListApplicationOptionsProblem", [ + "internal.error", + "admissions.unavailable", +]); + +/** Problems of `admissions.submitApplication`. */ +export const SubmitApplicationProblem = problemUnion("SubmitApplicationProblem", [ + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "validation.failed", + "internal.error", + "dependency.unavailable", + "idempotency.unavailable", + "application.no-eligible-period", + "application.ambiguous-period", + "application.duplicate", + "application.invalid-field-of-study", + "rate-limit.exceeded", +]); + +/** Problems of `admissions.readApplicationConfirmation`. */ +export const ReadApplicationConfirmationProblem = problemUnion( + "ReadApplicationConfirmationProblem", + ["internal.error", "application.not-found", "admissions.unavailable"], +); + +/** Problems of `admissions.readApplicantProgress`. */ +export const ReadApplicantProgressProblem = problemUnion("ReadApplicantProgressProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "internal.error", + "admissions.unavailable", +]); + +/** Problems of `admissions.listAdmissionPeriods`. */ +export const ListAdmissionPeriodsProblem = problemUnion("ListAdmissionPeriodsProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "internal.error", + "admissions.unavailable", +]); + +/** Problems of `admissions.createAdmissionPeriod`. */ +export const CreateAdmissionPeriodProblem = problemUnion("CreateAdmissionPeriodProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "validation.failed", + "internal.error", + "dependency.unavailable", + "idempotency.unavailable", + "admission-period.already-exists", + "admission-period.invalid-window", +]); + +/** Problems of `admissions.reviseAdmissionPeriod`. */ +export const ReviseAdmissionPeriodProblem = problemUnion("ReviseAdmissionPeriodProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "validation.failed", + "validation.no-change", + "validation.field-not-deletable", + "precondition.failed", + "internal.error", + "dependency.unavailable", + "idempotency.unavailable", + "admission-period.not-found", + "admission-period.invalid-window", +]); + +/** Problems of `admissions.readReturningAssistantOptions`. */ +export const ReadReturningAssistantOptionsProblem = problemUnion( + "ReadReturningAssistantOptionsProblem", + [ + "credential.missing", + "credential.invalid", + "authority.denied", + "validation.failed", + "returning.identity-missing", + "returning.identity-ambiguous", + "returning.history-missing", + "returning.study-invalid", + "returning.period-unavailable", + "internal.error", + "returning.unavailable", + ], +); + +/** Problems of `admissions.registerReturningAssistant`. */ +export const RegisterReturningAssistantProblem = problemUnion("RegisterReturningAssistantProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "validation.failed", + "returning.identity-missing", + "returning.identity-ambiguous", + "returning.history-missing", + "returning.study-invalid", + "returning.period-unavailable", + "returning.team-scope-denied", + "returning.revision-conflict", + "internal.error", + "dependency.unavailable", + "idempotency.unavailable", + "returning.unavailable", +]); + +/** The currently open admission periods, for the public application form. */ +export const ListOpenAdmissionPeriods = Rpc.make("admissions.listOpenAdmissionPeriods", { + success: OpenAdmissionPeriodListResponse, + error: rpcProblems(ListOpenAdmissionPeriodsProblem), +}).pipe(withAccessSpec(anonymousNativeAccess("admissions.public-open-periods"))); + +/** The departments and fields of study open to applicants. */ +export const ListApplicationOptions = Rpc.make("admissions.listApplicationOptions", { + success: PublicApplicationCatalogSchema, + error: rpcProblems(ListApplicationOptionsProblem), +}).pipe(withAccessSpec(anonymousNativeAccess("admissions.public-application-options"))); + +/** Submits, or replays by its idempotency key, one public application. */ +export const SubmitApplication = Rpc.make("admissions.submitApplication", { + payload: Schema.Struct({ idempotencyKey: IdempotencyKey, request: SubmitApplicationRequest }), + success: PublicApplicationConfirmationSchema, + error: rpcProblems(SubmitApplicationProblem), +}).pipe(withAccessSpec(anonymousNativeAccess("admissions.application-create", "Transaction"))); + +/** The public confirmation of one submitted application. */ +export const ReadApplicationConfirmation = Rpc.make("admissions.readApplicationConfirmation", { + payload: Schema.Struct({ applicationId: PublicApplicationIdSchema }), + success: PublicApplicationConfirmationSchema, + error: rpcProblems(ReadApplicationConfirmationProblem), +}).pipe(withAccessSpec(anonymousNativeAccess("admissions.public-application-by-id"))); + +/** The current-semester recruitment progress of the signed-in applicant. */ +export const ReadApplicantProgress = Rpc.make("admissions.readApplicantProgress", { + success: ApplicantProgressResponseSchema, + error: rpcProblems(ReadApplicantProgressProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "admissions.read-applicant-progress", + canonicalScopeResolver: "profile.current-person", + requirements: ["profile.owner"], + decisionTime: "SnapshotRead", + }), + ), + ); + +/** The admission periods the caller manages; each item carries the tag that a revision names. */ +export const ListAdmissionPeriods = Rpc.make("admissions.listAdmissionPeriods", { + success: AdmissionPeriodManagementListResponse, + error: rpcProblems(ListAdmissionPeriodsProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "admissions.read-periods", + canonicalScopeResolver: "admissions.management-periods", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** Creates, or replays by its idempotency key, one admission period. */ +export const CreateAdmissionPeriod = Rpc.make("admissions.createAdmissionPeriod", { + payload: Schema.Struct({ idempotencyKey: IdempotencyKey, request: CreateAdmissionPeriodRequest }), + success: AdmissionPeriodManagementItem, + error: rpcProblems(CreateAdmissionPeriodProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "admissions.create-period", + canonicalScopeResolver: "admissions.period-create", + decisionTime: "Transaction", + }), + ), + ); + +/** + * Revises, or replays by its idempotency key, the window of one admission period. `request` keeps + * merge-patch semantics: an absent member keeps its value, an empty patch changes nothing, and a + * null member would delete a field, which an admission period refuses. + */ +export const ReviseAdmissionPeriod = Rpc.make("admissions.reviseAdmissionPeriod", { + payload: Schema.Struct({ + admissionPeriodId: AdmissionPeriodId, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: AdmissionPeriodMergePatch, + }), + success: AdmissionPeriodManagementItem, + error: rpcProblems(ReviseAdmissionPeriodProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "admissions.revise-period", + canonicalScopeResolver: "admissions.period-by-id", + decisionTime: "Transaction", + }), + ), + ); + +/** The identity, placement, department, and admission-period options of a returning assistant. */ +export const ReadReturningAssistantOptions = Rpc.make("admissions.readReturningAssistantOptions", { + success: ReturningAssistantOptionsSchema, + error: rpcProblems(ReadReturningAssistantOptionsProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "placements.self", + canonicalScopeResolver: "profile.current-person", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** Registers, or replays by its idempotency key, a returning assistant for an admission period. */ +export const RegisterReturningAssistant = Rpc.make("admissions.registerReturningAssistant", { + payload: Schema.Struct({ + idempotencyKey: IdempotencyKey, + request: ReturningAssistantRegistrationInputSchema, + }), + success: ReturningAssistantRegistrationResponseSchema, + error: rpcProblems(RegisterReturningAssistantProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "placements.self", + canonicalScopeResolver: "profile.current-person", + decisionTime: "Transaction", + }), + ), + ); + +export class AdmissionsRpcs extends RpcGroup.make( + ListOpenAdmissionPeriods, + ListApplicationOptions, + SubmitApplication, + ReadApplicationConfirmation, + ReadApplicantProgress, + ListAdmissionPeriods, + CreateAdmissionPeriod, + ReviseAdmissionPeriod, + ReadReturningAssistantOptions, + RegisterReturningAssistant, +) {} diff --git a/packages/rpc/src/api.ts b/packages/rpc/src/api.ts new file mode 100644 index 000000000..83d88d94c --- /dev/null +++ b/packages/rpc/src/api.ts @@ -0,0 +1,90 @@ +/** + * The native RPC contract: every operation a client of the native backend can call. + * + * Better Auth `/api/auth/*`, the OAuth2 routes, and `/health` stay plain HTTP: they are HTTP by + * protocol or probed by infrastructure. Internal operations form their own group, which only the + * internal ingress serves. + * + * @since 0.3.0 + */ +import { RpcGroup } from "effect/unstable/rpc"; +import { AdmissionOutcomesRpcs } from "./admission-outcomes.js"; +import { ProblemBoundary } from "./boundary.js"; +import { AdmissionsRpcs } from "./admissions.js"; +import { CertificatesRpcs } from "./certificates.js"; +import { ContactRpcs } from "./contact.js"; +import { ContentRpcs } from "./content.js"; +import { DirectoryRpcs } from "./directory.js"; +import { OnboardingRpcs } from "./onboarding.js"; +import { OrganizationRpcs } from "./organization.js"; +import { PlacementsRpcs } from "./placements.js"; +import { ProfileRpcs } from "./profile.js"; +import { InternalReceiptsRpcs, ReceiptsRpcs } from "./receipts.js"; +import { RecruitmentRpcs } from "./recruitment.js"; +import { SocialEventsRpcs } from "./social-events.js"; +import { SystemRpcs } from "./system.js"; +import { TeamApplicationsRpcs } from "./team-application.js"; + +/** The path that serves `NativeRpcs` over HTTP. */ +export const nativeRpcPath = "/api/rpc"; + +/** The path that serves `InternalNativeRpcs`, on the internal ingress only. */ +export const internalNativeRpcPath = "/internal/rpc"; + +/** + * Whether a request path addresses the native RPC endpoint. + * + * @remarks + * The RPC client joins the endpoint URL with an empty request URL, so it posts to the path with one + * trailing slash (`/api/rpc/`); a probe that builds its own request posts to `/api/rpc`. The + * ingress serves both, and every recorder and filter asks this predicate, so no check matches one + * spelling and silently misses the other. + * + * @sideEffects none + * + * @example + * ```ts + * if (request.method === "POST" && isNativeRpcPath(new URL(request.url).pathname)) record(body); + * ``` + * + * @avoid `pathname === nativeRpcPath` or `pathname === "/api/rpc"`: every call of the RPC client + * misses it. `anti-slop/no-rpc-path-comparison` rejects both. + * + * @param pathname - The path of a request URL, without its query. + * @returns `true` for `/api/rpc` and `/api/rpc/`. + * + * @construct rpc-transport + */ +export const isNativeRpcPath = (pathname: string): boolean => + pathname === nativeRpcPath || pathname === `${nativeRpcPath}/`; + +/** + * Whether a request path addresses the internal RPC endpoint, as `isNativeRpcPath` does for the + * external one. + */ +export const isInternalNativeRpcPath = (pathname: string): boolean => + pathname === internalNativeRpcPath || pathname === `${internalNativeRpcPath}/`; + +export class NativeRpcs extends RpcGroup.make() + .merge( + AdmissionOutcomesRpcs, + AdmissionsRpcs, + CertificatesRpcs, + ContactRpcs, + ContentRpcs, + DirectoryRpcs, + OnboardingRpcs, + OrganizationRpcs, + PlacementsRpcs, + ProfileRpcs, + ReceiptsRpcs, + RecruitmentRpcs, + SocialEventsRpcs, + SystemRpcs, + TeamApplicationsRpcs, + ) + .middleware(ProblemBoundary) {} + +export class InternalNativeRpcs extends RpcGroup.make() + .merge(InternalReceiptsRpcs) + .middleware(ProblemBoundary) {} diff --git a/packages/rpc/src/boundary.ts b/packages/rpc/src/boundary.ts new file mode 100644 index 000000000..f0cec1685 --- /dev/null +++ b/packages/rpc/src/boundary.ts @@ -0,0 +1,17 @@ +/** + * The defect boundary of the native RPC contract. + * + * @since 0.3.0 + */ +import { RpcMiddleware } from "effect/unstable/rpc"; +import { problemUnion, rpcProblems } from "./problem.js"; + +/** + * Every native RPC runs inside this middleware. The backend reports a defect through + * `ErrorReporter` and answers internal.error, so no defect, stack, or cause reaches a client. Typed + * failures and interrupts pass through unchanged. + */ +export class ProblemBoundary extends RpcMiddleware.Service()( + "@vektorprogrammet/rpc/ProblemBoundary", + { error: rpcProblems(problemUnion("InternalProblem", ["internal.error"])) }, +) {} diff --git a/packages/rpc/src/certificates.ts b/packages/rpc/src/certificates.ts new file mode 100644 index 000000000..08ff56505 --- /dev/null +++ b/packages/rpc/src/certificates.ts @@ -0,0 +1,256 @@ +/** + * Days served and certificates: school coordination confirms each assistant's days served per + * department and semester, and an issuer downloads the cumulative certificate of an assistant. + * + * Each RPC keeps the operation ID of the HTTP operation it replaces as its tag. A read takes the + * old path and query members as its payload; a command adds its `idempotencyKey` and the `ifMatch` + * of the entry or certificate it acts on. The issued certificate is the PDF's bytes. + * + * @since 0.3.0 + */ +import { DepartmentId, PersonId, SemesterId } from "@vektorprogrammet/domain/organization"; +import { + AssistantCursor, + CertificateAssistant, + CertificatePreview, + CertificateScopes, + CertificateSemesterScope, + DAYS_SERVED_PAGE_SIZE, + DaysServedEntry, + DaysServedTotal, +} from "@vektorprogrammet/domain/placements"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { personNativeAccess, withAccessSpec } from "./access.js"; +import { PersonCredential } from "./credential.js"; +import { IdempotencyKey, problemUnion, rpcProblems, StrongETag } from "./problem.js"; + +export { AssistantCursor, CertificateScopes, DaysServedTotal }; + +/** One assistant's days served, with the entity tag that a confirmation's `ifMatch` repeats. */ +export const DaysServedEntryResource = Schema.Struct({ + ...DaysServedEntry.fields, + etag: StrongETag, +}).annotate({ + identifier: "DaysServedEntryResource", + description: + "One assistant in one department and semester: the dated evidence, the calculated count, and the current confirmation.", +}); + +export const DaysServedListResponse = Schema.Struct({ + departmentId: DepartmentId, + departmentName: Schema.String, + semester: CertificateSemesterScope, + items: Schema.Array(DaysServedEntryResource).pipe( + Schema.check(Schema.isMaxLength(DAYS_SERVED_PAGE_SIZE)), + ), + nextCursor: Schema.optional(AssistantCursor), +}).annotate({ + identifier: "DaysServedListResponse", + description: `One page of at most ${DAYS_SERVED_PAGE_SIZE} assistants, ordered by name.`, +}); + +/** Confirms the calculated count, or adjusts it; zero is a confirmed total too. */ +export const ConfirmDaysServedInput = Schema.Struct({ total: DaysServedTotal }).annotate({ + identifier: "ConfirmDaysServedInput", +}); + +export const CertificateListResponse = Schema.Struct({ + departmentId: DepartmentId, + departmentName: Schema.String, + items: Schema.Array(CertificateAssistant).pipe( + Schema.check(Schema.isMaxLength(DAYS_SERVED_PAGE_SIZE)), + ), + nextCursor: Schema.optional(AssistantCursor), +}).annotate({ + identifier: "CertificateListResponse", + description: `One page of at most ${DAYS_SERVED_PAGE_SIZE} assistants with service in the department, ordered by name.`, +}); + +/** What the issuer sees before download, with the entity tag of the content that would be issued. */ +export const CertificatePreviewResource = Schema.Struct({ + ...CertificatePreview.fields, + etag: StrongETag, +}).annotate({ + identifier: "CertificatePreviewResource", + description: + "Every semester of the assistant in the department with its status, the certificate that an issue would record, and its content entity tag.", +}); + +/** The bytes of one issued certificate, an `application/pdf` document. */ +export const CertificatePdf = Schema.Uint8Array.annotate({ + identifier: "CertificatePdf", + description: "The PDF of the recorded certificate issue.", +}); + +export const CertificatesReadProblem = problemUnion("CertificatesReadProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "resource.not-found", + "transaction.conflict", + "internal.error", +]); + +/** A list also answers a cursor that the page it names cannot resolve. */ +export const CertificatesListProblem = problemUnion("CertificatesListProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "resource.not-found", + "request.malformed", + "transaction.conflict", + "internal.error", +]); + +export const CertificatesConfirmProblem = problemUnion("CertificatesConfirmProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "resource.not-found", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "precondition.failed", + "internal.error", + "idempotency.unavailable", +]); + +export const CertificatesIssueProblem = problemUnion("CertificatesIssueProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "resource.not-found", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "precondition.failed", + "certificate.empty", + "certificate.unprintable", + "internal.error", + "idempotency.unavailable", +]); + +const access = ( + capability: "placements.days-served" | "certificates.issue", + decisionTime: "SnapshotRead" | "Transaction", +) => + withAccessSpec( + personNativeAccess({ + capability, + canonicalScopeResolver: "placements.explicit-department", + decisionTime, + }), + ); + +/** + * The departments where the reader confirms days served or issues certificates, and the + * semesters. Either capability in some department grants the read. + */ +export const ReadCertificateScopes = Rpc.make("certificates.readCertificateScopes", { + success: CertificateScopes, + error: rpcProblems(CertificatesReadProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "placements.days-served", + alternatives: ["certificates.issue"], + canonicalScopeResolver: "placements.explicit-department", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** + * A bounded page of the assistants of a department and semester with the dated evidence, the + * calculated count, and the current confirmation. Requires the days-served capability in the + * department. + */ +export const ListDaysServed = Rpc.make("certificates.listDaysServed", { + payload: Schema.Struct({ + departmentId: DepartmentId, + semesterId: SemesterId, + cursor: Schema.optional(AssistantCursor), + }), + success: DaysServedListResponse, + error: rpcProblems(CertificatesListProblem), +}) + .middleware(PersonCredential) + .pipe(access("placements.days-served", "SnapshotRead")); + +/** + * Confirms or adjusts one assistant's total for the semester as the next revision. The earlier + * confirmation stays as history. Requires the observed entry entity tag. + */ +export const ConfirmDaysServed = Rpc.make("certificates.confirmDaysServed", { + payload: Schema.Struct({ + departmentId: DepartmentId, + semesterId: SemesterId, + personId: PersonId, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: ConfirmDaysServedInput, + }), + success: DaysServedEntryResource, + error: rpcProblems(CertificatesConfirmProblem), +}) + .middleware(PersonCredential) + .pipe(access("placements.days-served", "Transaction")); + +/** + * A bounded page of the assistants with service in the department and how many semesters their + * certificate lists. Issuers of the department only. + */ +export const ListCertificates = Rpc.make("certificates.listCertificates", { + payload: Schema.Struct({ + departmentId: DepartmentId, + cursor: Schema.optional(AssistantCursor), + }), + success: CertificateListResponse, + error: rpcProblems(CertificatesListProblem), +}) + .middleware(PersonCredential) + .pipe(access("certificates.issue", "SnapshotRead")); + +/** + * Every semester of the assistant in the department with its status and the certificate that an + * issue would record. Issuers of the department only, never the assistant. + */ +export const ReadCertificate = Rpc.make("certificates.readCertificate", { + payload: Schema.Struct({ departmentId: DepartmentId, personId: PersonId }), + success: CertificatePreviewResource, + error: rpcProblems(CertificatesReadProblem), +}) + .middleware(PersonCredential) + .pipe(access("certificates.issue", "SnapshotRead")); + +/** + * Records one issue of the certificate with the issuer, the authorizing seat, the instant, and + * the content hash, and answers the PDF. Requires the observed certificate entity tag. A replay + * of the idempotency key answers the same bytes. + */ +export const IssueCertificate = Rpc.make("certificates.issueCertificate", { + payload: Schema.Struct({ + departmentId: DepartmentId, + personId: PersonId, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + }), + success: CertificatePdf, + error: rpcProblems(CertificatesIssueProblem), +}) + .middleware(PersonCredential) + .pipe(access("certificates.issue", "Transaction")); + +export class CertificatesRpcs extends RpcGroup.make( + ReadCertificateScopes, + ListDaysServed, + ConfirmDaysServed, + ListCertificates, + ReadCertificate, + IssueCertificate, +) {} diff --git a/packages/rpc/src/client.ts b/packages/rpc/src/client.ts new file mode 100644 index 000000000..dac5f7e83 --- /dev/null +++ b/packages/rpc/src/client.ts @@ -0,0 +1,55 @@ +/** + * The native RPC client, derived from the contract. There is no generated SDK: `NativeRpcClient` + * is `RpcClient.make(NativeRpcs)` over HTTP. + * + * @since 0.3.0 + */ +import { Context, Effect, Layer } from "effect"; +import { FetchHttpClient, Headers } from "effect/unstable/http"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import { NativeRpcs, nativeRpcPath } from "./api.js"; + +const makeClient = RpcClient.make(NativeRpcs); + +/** A client of every native RPC. */ +export class NativeRpcClient extends Context.Service< + NativeRpcClient, + Effect.Success +>()("@vektorprogrammet/rpc/client/NativeRpcClient") {} + +/** + * The client of the backend at `origin`. + * + * @remarks + * A browser sends its session cookie itself (`credentials: "include"`). A server that calls on a + * person's behalf, such as a dashboard loader, forwards that person's `Cookie` or `Authorization` + * header per call with `RpcClient.withHeaders`, never through this layer, so one client never + * carries one person's credential into another person's call. Those headers travel inside the RPC + * request message, so the client follows no redirect: the RPC endpoint never redirects, and a + * followed 307 or 308 would resend the message, credential or contact secret included, to the + * redirect target. A redirect answer fails the call. + */ +export const nativeRpcClientLayer = (origin: string): Layer.Layer => + Layer.effect(NativeRpcClient)(makeClient).pipe( + Layer.provide( + RpcClient.layerProtocolHttp({ url: new URL(nativeRpcPath, origin).href }).pipe( + Layer.provide(RpcSerialization.layerJson), + Layer.provide( + FetchHttpClient.layer.pipe( + Layer.provide( + Layer.succeed(FetchHttpClient.RequestInit)({ + credentials: "include", + redirect: "manual", + }), + ), + ), + ), + ), + ), + ); + +/** Runs `effect` with `headers` added to every RPC it sends. */ +export const withForwardedHeaders = + (headers: Headers.Input) => + (effect: Effect.Effect): Effect.Effect => + RpcClient.withHeaders(effect, headers); diff --git a/packages/rpc/src/contact.ts b/packages/rpc/src/contact.ts new file mode 100644 index 000000000..5c331a4b4 --- /dev/null +++ b/packages/rpc/src/contact.ts @@ -0,0 +1,71 @@ +/** + * Contact: anonymous contact messages from the homepage server. + * + * @since 0.3.0 + */ +import { + ContactMessage, + ContactVisitorIp, + CONTACT_BACKEND_HEADER, + CONTACT_IP_HEADER, +} from "@vektorprogrammet/domain/contact"; +import { + CapabilityExpressionSchema, + CapabilityTypeId, + ConcealmentPolicySchema, + CredentialMechanismSchema, + makeAccessSpec, +} from "@vektorprogrammet/domain/authz"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { withAccessSpec } from "./access.js"; +import { ContactBackendCredential } from "./credential.js"; +import { problemUnion, rpcProblems } from "./problem.js"; + +export { ContactMessage, ContactVisitorIp, CONTACT_BACKEND_HEADER, CONTACT_IP_HEADER }; + +/** + * A visitor address that is not one canonical IP answers header.malformed: the homepage server + * sends it in the `x-vektor-contact-ip` header beside its deployment secret. + */ +export const ContactProblem = problemUnion("ContactProblem", [ + "header.malformed", + "validation.failed", + "rate-limit.exceeded", + "contact.unavailable", + "internal.error", +]); + +/** + * Sends one public contact message. It consumes one of five attempts per visitor fixed one-hour + * window and waits for delivery transport acceptance. No message is persisted, and nothing retries + * it; success is not proof of inbox delivery. + */ +export const SubmitContactMessage = Rpc.make("contact.submitContactMessage", { + payload: ContactMessage, + success: Schema.Void, + error: rpcProblems(ContactProblem), +}) + .middleware(ContactBackendCredential) + .pipe( + withAccessSpec( + makeAccessSpec({ + exposure: "External", + acceptedCredentials: [ + CredentialMechanismSchema.cases.ObjectCapability.make({ + capabilityType: CapabilityTypeId.make("contact.submit"), + }), + ], + principalKinds: ["CapabilityHolder"], + capabilities: CapabilityExpressionSchema.cases.One.make({ + capability: { type: CapabilityTypeId.make("contact.submit") }, + }), + requirements: [], + canonicalScopeResolver: "contact.department-recipient", + concealment: ConcealmentPolicySchema.cases.Reveal.make({}), + decisionTime: "SnapshotRead", + }), + ), + ); + +export class ContactRpcs extends RpcGroup.make(SubmitContactMessage) {} diff --git a/packages/rpc/src/content.ts b/packages/rpc/src/content.ts new file mode 100644 index 000000000..7fb9526c8 --- /dev/null +++ b/packages/rpc/src/content.ts @@ -0,0 +1,324 @@ +/** + * Content: staff article drafts and publication, and the public news they publish. + * + * @since 0.3.0 + */ +import { + ArticleId, + ArticleSlug, + ArticleVersionNumber, + ContentWorkspaceQuerySchema, + ContentWorkspaceSchema, + PublishedNewsArticleSchema, + PublishedNewsListingSchema, + type ContentArticleDetail, + type ContentWorkspace, + type PublishedNewsArticle, + type PublishedNewsListing, +} from "@vektorprogrammet/domain/content"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { anonymousNativeAccess, personNativeAccess, withAccessSpec } from "./access.js"; +import { PersonCredential } from "./credential.js"; +import { IdempotencyKey, problemUnion, rpcProblems, StrongETag } from "./problem.js"; +import { + ArticleMergePatch, + ContentArticleDetailSchema, + CreateArticleRequest, + PublishArticleResponse, + UnpublishArticleResponse, +} from "./v2-schemas.js"; + +export { + ArticleId, + ArticleSlug, + ArticleVersionNumber, + ContentWorkspaceQuerySchema, + ContentWorkspaceSchema, + PublishedNewsArticleSchema, + PublishedNewsListingSchema, +}; + +export type { ContentArticleDetail, ContentWorkspace, PublishedNewsArticle, PublishedNewsListing }; + +/** + * One staff article beside the strong entity tag that `content.reviseArticle`, + * `content.publishArticle`, and `content.unpublishArticle` take as `ifMatch`. The tag covers the + * article revision, its author's profile revision, and the caller's content authority sources. + */ +export const ContentArticleResource = Schema.Struct({ + article: ContentArticleDetailSchema, + etag: StrongETag, +}).annotate({ identifier: "ContentArticleResource" }); + +export type ContentArticleResource = typeof ContentArticleResource.Type; + +/** A committed publication beside the article's new entity tag. */ +export const PublishArticleResult = Schema.Struct({ + result: PublishArticleResponse, + etag: StrongETag, +}).annotate({ identifier: "PublishArticleResult" }); + +export type PublishArticleResult = typeof PublishArticleResult.Type; + +/** A committed unpublication beside the article's new entity tag. */ +export const UnpublishArticleResult = Schema.Struct({ + result: UnpublishArticleResponse, + etag: StrongETag, +}).annotate({ identifier: "UnpublishArticleResult" }); + +export type UnpublishArticleResult = typeof UnpublishArticleResult.Type; + +/** Selects one published article by slug, and optionally one of its published versions. */ +export const NewsArticleQuery = Schema.Struct({ + slug: ArticleSlug, + version: Schema.optional(Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(1)))), +}).annotate({ identifier: "NewsArticleQuery" }); + +export type NewsArticleQuery = typeof NewsArticleQuery.Type; + +/** Problems of `content.readContentWorkspace`. */ +export const ReadContentWorkspaceProblem = problemUnion("ReadContentWorkspaceProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "internal.error", + "content.department-not-found", + "content.integrity-error", + "content.unavailable", +]); + +/** Problems of `content.createArticle`. */ +export const CreateArticleProblem = problemUnion("CreateArticleProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "internal.error", + "idempotency.unavailable", + "content.slug-conflict", + "content.department-not-found", + "content.integrity-error", + "content.unavailable", +]); + +/** Problems of `content.readArticle`. */ +export const ReadArticleProblem = problemUnion("ReadArticleProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "internal.error", + "content.article-not-found", + "content.integrity-error", + "content.unavailable", +]); + +/** Problems of `content.reviseArticle`. */ +export const ReviseArticleProblem = problemUnion("ReviseArticleProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "validation.no-change", + "validation.field-not-deletable", + "precondition.failed", + "internal.error", + "idempotency.unavailable", + "content.article-not-found", + "content.slug-conflict", + "content.department-not-found", + "content.integrity-error", + "content.unavailable", +]); + +/** Problems of `content.publishArticle` and `content.unpublishArticle`. */ +const lifecycleProblems = [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "precondition.failed", + "internal.error", + "idempotency.unavailable", + "content.article-not-found", + "content.lifecycle-conflict", + "content.integrity-error", + "content.unavailable", +] as const; + +/** Problems of `content.publishArticle`. */ +export const PublishArticleProblem = problemUnion("PublishArticleProblem", lifecycleProblems); + +/** Problems of `content.unpublishArticle`. */ +export const UnpublishArticleProblem = problemUnion("UnpublishArticleProblem", lifecycleProblems); + +/** Problems of `content.listNews`. */ +export const ListNewsProblem = problemUnion("ListNewsProblem", [ + "internal.error", + "content.department-not-found", + "content.integrity-error", + "content.unavailable", +]); + +/** Problems of `content.readNewsArticle`. */ +export const ReadNewsArticleProblem = problemUnion("ReadNewsArticleProblem", [ + "internal.error", + "content.article-not-found", + "content.integrity-error", + "content.unavailable", +]); + +/** Returns the article drafts visible to the current staff person, optionally of one department. */ +export const ReadContentWorkspace = Rpc.make("content.readContentWorkspace", { + payload: ContentWorkspaceQuerySchema, + success: ContentWorkspaceSchema, + error: rpcProblems(ReadContentWorkspaceProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "content.read-workspace", + canonicalScopeResolver: "content.articles", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** Creates, or replays by its idempotency key, one native article draft. */ +export const CreateArticle = Rpc.make("content.createArticle", { + payload: Schema.Struct({ idempotencyKey: IdempotencyKey, request: CreateArticleRequest }), + success: ContentArticleResource, + error: rpcProblems(CreateArticleProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "content.create-article", + canonicalScopeResolver: "content.article-create", + decisionTime: "Transaction", + }), + ), + ); + +/** Returns one staff article detail beside its entity tag. */ +export const ReadArticle = Rpc.make("content.readArticle", { + payload: Schema.Struct({ articleId: ArticleId }), + success: ContentArticleResource, + error: rpcProblems(ReadArticleProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "content.read-article", + canonicalScopeResolver: "content.article-by-id", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** + * Applies a JSON merge patch (RFC 7396) to a draft under its entity tag. An absent member keeps + * its value; a patch with no member answers validation.no-change, and a `null` member + * validation.field-not-deletable. + */ +export const ReviseArticle = Rpc.make("content.reviseArticle", { + payload: Schema.Struct({ + articleId: ArticleId, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: ArticleMergePatch, + }), + success: ContentArticleResource, + error: rpcProblems(ReviseArticleProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "content.revise-article", + canonicalScopeResolver: "content.article-by-id", + requirements: ["content.revisable"], + decisionTime: "Transaction", + }), + ), + ); + +/** The payload of a publication transition: the article, the key, and its entity tag. */ +const ArticleTransitionPayload = Schema.Struct({ + articleId: ArticleId, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, +}); + +/** Publishes a new immutable version of an article under its entity tag. */ +export const PublishArticle = Rpc.make("content.publishArticle", { + payload: ArticleTransitionPayload, + success: PublishArticleResult, + error: rpcProblems(PublishArticleProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "content.publish-article", + canonicalScopeResolver: "content.article-by-id", + requirements: ["content.publishable"], + decisionTime: "Transaction", + }), + ), + ); + +/** Removes an article from the public current listing under its entity tag. */ +export const UnpublishArticle = Rpc.make("content.unpublishArticle", { + payload: ArticleTransitionPayload, + success: UnpublishArticleResult, + error: rpcProblems(UnpublishArticleProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "content.publish-article", + canonicalScopeResolver: "content.article-by-id", + requirements: ["content.unpublishable"], + decisionTime: "Transaction", + }), + ), + ); + +/** Returns the current public news listing, optionally of one department. */ +export const ListNews = Rpc.make("content.listNews", { + payload: ContentWorkspaceQuerySchema, + success: PublishedNewsListingSchema, + error: rpcProblems(ListNewsProblem), +}).pipe(withAccessSpec(anonymousNativeAccess("content.public-news"))); + +/** Returns the current, or the selected, published version of one news article. */ +export const ReadNewsArticle = Rpc.make("content.readNewsArticle", { + payload: NewsArticleQuery, + success: PublishedNewsArticleSchema, + error: rpcProblems(ReadNewsArticleProblem), +}).pipe(withAccessSpec(anonymousNativeAccess("content.public-news-by-slug"))); + +export class ContentRpcs extends RpcGroup.make( + ReadContentWorkspace, + CreateArticle, + ReadArticle, + ReviseArticle, + PublishArticle, + UnpublishArticle, + ListNews, + ReadNewsArticle, +) {} diff --git a/packages/rpc/src/credential.ts b/packages/rpc/src/credential.ts new file mode 100644 index 000000000..60fe9c396 --- /dev/null +++ b/packages/rpc/src/credential.ts @@ -0,0 +1,54 @@ +/** + * Credential middlewares of the native RPC contract. + * + * Each middleware authenticates the credential that its RPCs accept, before the handler runs, and + * answers a rejected credential with a declared problem. It grants no authority: a handler still + * resolves the credential and the authority inside the transaction that commits its command, and + * evaluates the RPC's AccessSpec there. The backend implements each middleware from the request + * headers that RPC over HTTP carries. + * + * @since 0.3.0 + */ +import { RpcMiddleware } from "effect/unstable/rpc"; +import { problemUnion, rpcProblems } from "./problem.js"; + +const CredentialProblem = rpcProblems( + problemUnion("CredentialProblem", ["credential.missing", "credential.invalid"]), +); + +/** A Better Auth session cookie, and nothing else: session management. */ +export class SessionCredential extends RpcMiddleware.Service()( + "@vektorprogrammet/rpc/SessionCredential", + { error: CredentialProblem }, +) {} + +/** A Better Auth session cookie or an OAuth user bearer that names one Person. */ +export class PersonCredential extends RpcMiddleware.Service()( + "@vektorprogrammet/rpc/PersonCredential", + { error: CredentialProblem }, +) {} + +/** A Person credential, or an OAuth service bearer that names one service principal. */ +export class PersonOrServiceCredential extends RpcMiddleware.Service()( + "@vektorprogrammet/rpc/PersonOrServiceCredential", + { error: CredentialProblem }, +) {} + +/** + * A recruitment invitation capability in the payload, with no cookie or bearer beside it. A missing + * capability answers resource.not-found, so the RPC conceals whether the invitation exists. + */ +export class InvitationCapabilityCredential extends RpcMiddleware.Service()( + "@vektorprogrammet/rpc/InvitationCapabilityCredential", + { + error: rpcProblems( + problemUnion("InvitationCapabilityProblem", ["resource.not-found", "credential.invalid"]), + ), + }, +) {} + +/** The homepage server's deployment secret. It names no principal. */ +export class ContactBackendCredential extends RpcMiddleware.Service()( + "@vektorprogrammet/rpc/ContactBackendCredential", + { error: CredentialProblem }, +) {} diff --git a/packages/rpc/src/directory.ts b/packages/rpc/src/directory.ts new file mode 100644 index 000000000..0deeba1d9 --- /dev/null +++ b/packages/rpc/src/directory.ts @@ -0,0 +1,187 @@ +/** + * Directory: people and schools. + * + * @since 0.3.0 + */ +import { + SchoolCommand, + SchoolCommandResult, + SchoolDirectoryQuerySchema, + SchoolDirectorySchema, + SchoolManagement, + type SchoolDirectory, + type SchoolDirectoryDepartment, + type SchoolDirectoryEntry, +} from "@vektorprogrammet/domain/schools"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { personNativeAccess, withAccessSpec } from "./access.js"; +import { PersonCredential } from "./credential.js"; +import { IdempotencyKey, problemUnion, rpcProblems } from "./problem.js"; + +export { + SchoolCommand, + SchoolCommandResult, + SchoolDirectoryQuerySchema, + SchoolDirectorySchema, + SchoolManagement, +}; + +export type { SchoolDirectory, SchoolDirectoryDepartment, SchoolDirectoryEntry }; + +/** One profile/organization directory row. */ +export const PeopleDirectoryEntry = Schema.Struct({ + personId: Schema.String, + firstName: Schema.String, + lastName: Schema.String, + email: Schema.String, + phone: Schema.String, + /** Null until the Organization-owned association exists. */ + studyProgramme: Schema.Null, + departments: Schema.Array(Schema.String), + isActive: Schema.Boolean, +}).annotate({ + identifier: "PeopleDirectoryEntry", + description: "Person profile enriched with Organization-owned department facts.", +}); + +export type PeopleDirectoryEntry = typeof PeopleDirectoryEntry.Type; + +/** Complete scoped people directory response. */ +export const PeopleDirectoryResponse = Schema.Struct({ + activePeople: Schema.Array(PeopleDirectoryEntry), + inactivePeople: Schema.Array(PeopleDirectoryEntry), + nextCursor: Schema.NullOr(Schema.String), +}).annotate({ + identifier: "PeopleDirectoryResponse", + description: "Active and inactive people visible in the caller's authority scope.", +}); + +export type PeopleDirectoryResponse = typeof PeopleDirectoryResponse.Type; + +export const ListPeopleProblem = problemUnion("ListPeopleProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "internal.error", + "directory.unavailable", +]); + +export const ListSchoolsProblem = problemUnion("ListSchoolsProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "internal.error", + "schools.invalid-department", + "schools.unavailable", +]); + +export const SchoolManagementReadProblem = problemUnion("SchoolManagementReadProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "resource.not-found", + "precondition.failed", + "idempotency.digest-conflict", + "schools.invalid-command", + "schools.association-in-use", + "schools.inactive", + "schools.capacity-exists", + "schools.unavailable", + "internal.error", +]); + +export const SchoolCommandProblem = problemUnion("SchoolCommandProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "resource.not-found", + "precondition.failed", + "schools.invalid-command", + "schools.association-in-use", + "schools.inactive", + "schools.capacity-exists", + "schools.unavailable", + "idempotency.unavailable", + "internal.error", +]); + +/** The people directory within the caller's scope. */ +export const ListPeople = Rpc.make("directory.listPeople", { + success: PeopleDirectoryResponse, + error: rpcProblems(ListPeopleProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "profile.read-directory", + canonicalScopeResolver: "profile.people-directory", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** The native school directory in authority scope, optionally of one department. */ +export const ListSchools = Rpc.make("directory.listSchools", { + payload: SchoolDirectoryQuerySchema, + success: SchoolDirectorySchema, + error: rpcProblems(ListSchoolsProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "schools.read-directory", + canonicalScopeResolver: "schools.directory", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** Authorized school facts, capacity plans, options, and history. */ +export const ReadSchoolManagement = Rpc.make("directory.readSchoolManagement", { + success: SchoolManagement, + error: rpcProblems(SchoolManagementReadProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "schools.manage", + canonicalScopeResolver: "schools.management", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** + * Applies one scoped school or capacity command with an explicit observed revision and atomic + * history. The command's `commandId` must equal the idempotency key. + */ +export const ExecuteSchoolCommand = Rpc.make("directory.executeSchoolCommand", { + payload: Schema.Struct({ idempotencyKey: IdempotencyKey, request: SchoolCommand }), + success: SchoolCommandResult, + error: rpcProblems(SchoolCommandProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "schools.manage", + canonicalScopeResolver: "schools.management", + decisionTime: "Transaction", + }), + ), + ); + +export class DirectoryRpcs extends RpcGroup.make( + ListPeople, + ListSchools, + ReadSchoolManagement, + ExecuteSchoolCommand, +) {} diff --git a/packages/http-api/src/endpoint-problems.ts b/packages/rpc/src/endpoint-problems.ts similarity index 99% rename from packages/http-api/src/endpoint-problems.ts rename to packages/rpc/src/endpoint-problems.ts index 73cec7db6..f5462c103 100644 --- a/packages/http-api/src/endpoint-problems.ts +++ b/packages/rpc/src/endpoint-problems.ts @@ -1,5 +1,5 @@ /** Generated from the frozen 0080.1 endpoint-specific Problem Details table. */ -import { problemUnion } from "./http-semantics.js"; +import { problemUnion } from "./problem.js"; /** Problems for `system.health`. */ export const SystemHealthProblem = problemUnion("SystemHealthProblem", [ diff --git a/packages/http-api/src/index.ts b/packages/rpc/src/index.ts similarity index 74% rename from packages/http-api/src/index.ts rename to packages/rpc/src/index.ts index f815f5cb8..a5627e961 100644 --- a/packages/http-api/src/index.ts +++ b/packages/rpc/src/index.ts @@ -1,50 +1,50 @@ /** - * Canonical Effect HttpApi contract for the Vektorprogrammet native backend. + * The native RPC contract of the Vektorprogrammet backend, and its client. * - * @since 0.1.0 + * @since 0.3.0 */ -export * from "./admissions.js"; +export * from "./access.js"; export * from "./admission-outcomes.js"; -export * from "./api.js"; +export * from "./admissions.js"; -export * from "./access.js"; +export * from "./api.js"; -export * from "./common.js"; +export * from "./boundary.js"; -export * from "./content.js"; +export * from "./certificates.js"; -export * from "./endpoint-problems.js"; +export * from "./client.js"; -export * from "./http-semantics.js"; +export * from "./contact.js"; -export * from "./directory.js"; +export * from "./content.js"; -export * from "./organization.js"; +export * from "./credential.js"; -export * from "./profile.js"; +export * from "./directory.js"; -export * from "./receipts.js"; +export * from "./endpoint-problems.js"; -export * from "./receipt-upload.js"; +export * from "./onboarding.js"; -export * from "./release.js"; +export * from "./organization.js"; -export * from "./recruitment.js"; +export * from "./placements.js"; -export * from "./system.js"; +export * from "./problem.js"; -export * from "./v2-schemas.js"; +export * from "./profile.js"; -export * from "./contact.js"; +export * from "./receipts.js"; -export * from "./placements.js"; +export * from "./recruitment.js"; export * from "./social-events.js"; -export * from "./onboarding.js"; +export * from "./system.js"; export * from "./team-application.js"; -export * from "./certificates.js"; +export * from "./v2-schemas.js"; diff --git a/packages/rpc/src/onboarding.ts b/packages/rpc/src/onboarding.ts new file mode 100644 index 000000000..d66ffeaab --- /dev/null +++ b/packages/rpc/src/onboarding.ts @@ -0,0 +1,164 @@ +/** + * Onboarding: coordinators invite admitted applicants to an account, and an applicant claims the + * invitation. + * + * @since 0.3.0 + */ +import { + CapabilityExpressionSchema, + CapabilityTypeId, + ConcealmentPolicySchema, + CredentialMechanismSchema, + makeAccessSpec, + RequirementId, +} from "@vektorprogrammet/domain/authz"; +import { + OnboardingBoard, + OnboardingClaim, + OnboardingClaimResult, + OnboardingCommand, + OnboardingScope, +} from "@vektorprogrammet/domain/onboarding"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { personNativeAccess, withAccessSpec } from "./access.js"; +import { PersonCredential } from "./credential.js"; +import { IdempotencyKey, problemUnion, rpcProblems, StrongETag } from "./problem.js"; + +export { OnboardingClaim, OnboardingClaimResult, OnboardingCommand, OnboardingScope }; + +/** A department's applicant names and invitation states, with the tag that a command names. */ +export const OnboardingResource = Schema.Struct({ + ...OnboardingBoard.fields, + etag: StrongETag, +}).annotate({ identifier: "OnboardingResource" }); + +export type OnboardingResource = typeof OnboardingResource.Type; + +/** Problems of `onboarding.readBoard`. */ +export const OnboardingReadBoardProblem = problemUnion("OnboardingReadBoardProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "resource.not-found", + "transaction.conflict", + "internal.error", + "onboarding.claim-invalid", + "onboarding.sign-in-required", + "onboarding.already-linked", +]); + +/** Problems of `onboarding.command`; its receipt store can also be unavailable. */ +export const OnboardingCommandProblem = problemUnion("OnboardingCommandProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "resource.not-found", + "precondition.failed", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "internal.error", + "idempotency.unavailable", + "onboarding.claim-invalid", + "onboarding.sign-in-required", + "onboarding.already-linked", +]); + +/** + * Problems of `onboarding.claim`. The claim has no credential middleware, so it answers its own + * credential problems: an existing-account claim without the browser session, and a new-account + * claim with a cookie or bearer beside its token. + */ +export const OnboardingClaimProblem = problemUnion("OnboardingClaimProblem", [ + "credential.missing", + "credential.invalid", + "resource.not-found", + "transaction.conflict", + "internal.error", + "onboarding.claim-invalid", + "onboarding.sign-in-required", + "onboarding.already-linked", +]); + +/** Applicant names and invitation states of one department. */ +export const ReadOnboardingBoard = Rpc.make("onboarding.readBoard", { + payload: OnboardingScope, + success: OnboardingResource, + error: rpcProblems(OnboardingReadBoardProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "onboarding.manage", + canonicalScopeResolver: "onboarding.application-department", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** + * Invites an admitted applicant or revokes an invitation, or replays the command by its + * idempotency key. It names no arbitrary recipient or target Person. + */ +export const CommandOnboarding = Rpc.make("onboarding.command", { + payload: Schema.Struct({ + departmentId: OnboardingScope.fields.departmentId, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: OnboardingCommand, + }), + success: OnboardingResource, + error: rpcProblems(OnboardingCommandProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "onboarding.manage", + canonicalScopeResolver: "onboarding.application-department", + decisionTime: "Transaction", + }), + ), + ); + +/** + * Claims an applicant account invitation. The payload token is required in both modes and works + * once. In new-account mode the token is the one credential, so a session cookie or bearer beside + * it is rejected. In existing-account mode the browser session names the one principal, and the + * token is its requirement `onboarding.claim-token`: it must name an open invitation. A delegated + * bearer cannot make the claim. + */ +export const ClaimOnboarding = Rpc.make("onboarding.claim", { + payload: OnboardingClaim, + success: OnboardingClaimResult, + error: rpcProblems(OnboardingClaimProblem), +}).pipe( + withAccessSpec( + makeAccessSpec({ + exposure: "External", + acceptedCredentials: [ + CredentialMechanismSchema.cases.ObjectCapability.make({ + capabilityType: CapabilityTypeId.make("onboarding.claim"), + }), + CredentialMechanismSchema.cases.BetterAuthCookie.make({}), + ], + principalKinds: ["CapabilityHolder", "Person"], + capabilities: CapabilityExpressionSchema.cases.One.make({ + capability: { type: CapabilityTypeId.make("onboarding.claim") }, + }), + requirements: [{ id: RequirementId.make("onboarding.claim-token"), parameters: {} }], + canonicalScopeResolver: "onboarding.claim", + concealment: ConcealmentPolicySchema.cases.Reveal.make({}), + decisionTime: "Transaction", + }), + ), +); + +export class OnboardingRpcs extends RpcGroup.make( + ReadOnboardingBoard, + CommandOnboarding, + ClaimOnboarding, +) {} diff --git a/packages/rpc/src/organization.ts b/packages/rpc/src/organization.ts new file mode 100644 index 000000000..c3bfb0c02 --- /dev/null +++ b/packages/rpc/src/organization.ts @@ -0,0 +1,403 @@ +/** + * Organization: departments, teams, fields of study, team interest, mailing lists, delegations, lifecycle. + * + * @since 0.3.0 + */ +import { + DelegationCommand, + DelegationManagement, + DelegationResult, +} from "@vektorprogrammet/domain/authz"; +import { + AppointmentManagement, + BoardRosters, + DepartmentId, + DepartmentJsonSchema, + FieldOfStudyJsonSchema, + OrganizationLifecycleCommand, + OrganizationLifecycleResult, + SemesterId, + TeamJsonSchema, + type DepartmentJson, + type FieldOfStudyJson, + type TeamJson, +} from "@vektorprogrammet/domain/organization"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { anonymousNativeAccess, personNativeAccess, withAccessSpec } from "./access.js"; +import { PersonCredential } from "./credential.js"; +import { IdempotencyKey, problemUnion, rpcProblems } from "./problem.js"; +import { + CreateDepartmentRequest, + CreateFieldOfStudyRequest, + CreateTeamRequest, +} from "./v2-schemas.js"; + +export { + AppointmentManagement, + BoardRosters, + DelegationCommand, + DelegationManagement, + DelegationResult, + DepartmentJsonSchema, + FieldOfStudyJsonSchema, + OrganizationLifecycleCommand, + OrganizationLifecycleResult, + TeamJsonSchema, +}; + +export type { DepartmentJson, FieldOfStudyJson, TeamJson }; + +/** The department and semester that a team-interest read narrows to; both are optional. */ +export const TeamInterestQuery = Schema.Struct({ + departmentId: Schema.optional(DepartmentId), + semesterId: Schema.optional(SemesterId), +}).annotate({ identifier: "TeamInterestQuery" }); + +export type TeamInterestQuery = typeof TeamInterestQuery.Type; + +/** The mailing lists that a projection selects. */ +export const MailingListType = Schema.Literals(["assistants", "team", "all"]); + +export type MailingListType = typeof MailingListType.Type; + +/** + * The department, semester, and list type that a mailing-list projection selects. An absent + * `type` selects the assistants. + */ +export const MailingListQuery = Schema.Struct({ + departmentId: Schema.optional(DepartmentId), + semesterId: Schema.optional(SemesterId), + type: Schema.optional(MailingListType), +}).annotate({ identifier: "MailingListQuery" }); + +export type MailingListQuery = typeof MailingListQuery.Type; + +/** Strict team-interest response compatible with the existing Hydra envelope. */ +export const TeamInterestResponse = Schema.Struct({ + "hydra:member": Schema.Array( + Schema.Struct({ id: Schema.Int, userName: Schema.String, teamName: Schema.String }), + ), + "hydra:totalItems": Schema.Int, +}).annotate({ identifier: "TeamInterestResponse", description: "Scoped team-interest rows." }); + +export type TeamInterestResponse = typeof TeamInterestResponse.Type; + +/** Projected mailing lists. */ +export const MailingListResponse = Schema.Array( + Schema.Struct({ name: Schema.String, emails: Schema.Array(Schema.String) }), +).annotate({ + identifier: "MailingListResponse", + description: "Scoped mailing-list projections.", +}); + +export type MailingListResponse = typeof MailingListResponse.Type; + +export const ListDepartmentsProblem = problemUnion("ListDepartmentsProblem", [ + "internal.error", + "organization.unavailable", +]); + +export const ListTeamsProblem = problemUnion("ListTeamsProblem", [ + "internal.error", + "organization.unavailable", +]); + +export const ListFieldOfStudiesProblem = problemUnion("ListFieldOfStudiesProblem", [ + "internal.error", + "organization.unavailable", +]); + +export const ListTeamInterestProblem = problemUnion("ListTeamInterestProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "internal.error", + "organization.invalid-reference", + "organization.unavailable", +]); + +export const ListMailingListsProblem = problemUnion("ListMailingListsProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "internal.error", + "organization.invalid-reference", + "organization.unavailable", +]); + +export const CreateDepartmentProblem = problemUnion("CreateDepartmentProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "internal.error", + "idempotency.unavailable", + "organization.invalid-reference", + "organization.unavailable", +]); + +export const CreateTeamProblem = problemUnion("CreateTeamProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "internal.error", + "idempotency.unavailable", + "organization.invalid-reference", + "organization.unavailable", +]); + +export const CreateFieldOfStudyProblem = problemUnion("CreateFieldOfStudyProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "internal.error", + "idempotency.unavailable", + "organization.invalid-reference", + "organization.unavailable", +]); + +/** Appointment and delegation management: their reads and their commands. */ +export const OrganizationLifecycleProblem = problemUnion("OrganizationLifecycleProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "resource.not-found", + "precondition.failed", + "validation.failed", + "idempotency.digest-conflict", + "idempotency.in-flight", + "idempotency.response-expired", + "transaction.conflict", + "internal.error", + "idempotency.unavailable", + "organization.unavailable", +]); + +/** The public native department directory. */ +export const ListDepartments = Rpc.make("organization.listDepartments", { + success: Schema.Array(DepartmentJsonSchema), + error: rpcProblems(ListDepartmentsProblem), +}).pipe(withAccessSpec(anonymousNativeAccess("organization.public-departments"))); + +/** The public native team directory. */ +export const ListTeams = Rpc.make("organization.listTeams", { + success: Schema.Array(TeamJsonSchema), + error: rpcProblems(ListTeamsProblem), +}).pipe(withAccessSpec(anonymousNativeAccess("organization.public-teams"))); + +/** The public native study directory. */ +export const ListFieldOfStudies = Rpc.make("organization.listFieldOfStudies", { + success: Schema.Array(FieldOfStudyJsonSchema), + error: rpcProblems(ListFieldOfStudiesProblem), +}).pipe(withAccessSpec(anonymousNativeAccess("organization.public-field-of-studies"))); + +/** + * Registrations within the caller's team-interest reach: the own team for its leader, whole + * departments for department reach. + */ +export const ListTeamInterest = Rpc.make("organization.listTeamInterest", { + payload: TeamInterestQuery, + success: TeamInterestResponse, + error: rpcProblems(ListTeamInterestProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "organization.read-team-interest", + canonicalScopeResolver: "organization.team-interest-registrations", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** Projects addresses within the departments where the caller reads people. */ +export const ListMailingLists = Rpc.make("organization.listMailingLists", { + payload: MailingListQuery, + success: MailingListResponse, + error: rpcProblems(ListMailingListsProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "organization.read-mailing-lists", + canonicalScopeResolver: "organization.mailing-lists", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** Creates, or replays by its idempotency key, one department. */ +export const CreateDepartment = Rpc.make("organization.createDepartment", { + payload: Schema.Struct({ idempotencyKey: IdempotencyKey, request: CreateDepartmentRequest }), + success: DepartmentJsonSchema, + error: rpcProblems(CreateDepartmentProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "organization.create-department", + canonicalScopeResolver: "organization.department-create", + decisionTime: "Transaction", + }), + ), + ); + +/** Creates, or replays by its idempotency key, one team. */ +export const CreateTeam = Rpc.make("organization.createTeam", { + payload: Schema.Struct({ idempotencyKey: IdempotencyKey, request: CreateTeamRequest }), + success: TeamJsonSchema, + error: rpcProblems(CreateTeamProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "organization.create-team", + canonicalScopeResolver: "organization.team-create", + decisionTime: "Transaction", + }), + ), + ); + +/** Creates, or replays by its idempotency key, one field of study. */ +export const CreateFieldOfStudy = Rpc.make("organization.createFieldOfStudy", { + payload: Schema.Struct({ idempotencyKey: IdempotencyKey, request: CreateFieldOfStudyRequest }), + success: FieldOfStudyJsonSchema, + error: rpcProblems(CreateFieldOfStudyProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "organization.create-field-of-study", + canonicalScopeResolver: "organization.field-of-study-create", + decisionTime: "Transaction", + }), + ), + ); + +/** Authorized people, units, appointments, account access and history. */ +export const ReadAppointmentManagement = Rpc.make("organization.readAppointmentManagement", { + success: AppointmentManagement, + error: rpcProblems(OrganizationLifecycleProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "organization.manage-appointments", + canonicalScopeResolver: "organization.appointment-management", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** + * The rosters of Styret of an independent department and of Hovedstyret: the appointed seats and + * the seats that current team leadership derives, for the boards whose appointments the reader + * manages, as of the request instant. + */ +export const ReadBoardRosters = Rpc.make("organization.readBoardRosters", { + success: BoardRosters, + error: rpcProblems(OrganizationLifecycleProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "organization.manage-appointments", + canonicalScopeResolver: "organization.appointment-management", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** + * Applies one authorized, revision-checked lifecycle command with atomic history. The command's + * `commandId` must equal the idempotency key. + */ +export const ExecuteLifecycle = Rpc.make("organization.executeLifecycle", { + payload: Schema.Struct({ idempotencyKey: IdempotencyKey, request: OrganizationLifecycleCommand }), + success: OrganizationLifecycleResult, + error: rpcProblems(OrganizationLifecycleProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "organization.manage-appointments", + canonicalScopeResolver: "organization.appointment-management", + decisionTime: "Transaction", + }), + ), + ); + +/** + * The delegations that one person manages: a Styret leader those of its department's teams, + * Hovedstyret or a global administrator those of national teams. + */ +export const ReadDelegationManagement = Rpc.make("organization.readDelegationManagement", { + success: DelegationManagement, + error: rpcProblems(OrganizationLifecycleProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "organization.manage-delegations", + canonicalScopeResolver: "organization.delegation-management", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** + * Issues or ends one named, time-bounded delegation with atomic history under current authority. + * The command's `commandId` must equal the idempotency key. + */ +export const ExecuteDelegation = Rpc.make("organization.executeDelegation", { + payload: Schema.Struct({ idempotencyKey: IdempotencyKey, request: DelegationCommand }), + success: DelegationResult, + error: rpcProblems(OrganizationLifecycleProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "organization.manage-delegations", + canonicalScopeResolver: "organization.delegation-management", + decisionTime: "Transaction", + }), + ), + ); + +export class OrganizationRpcs extends RpcGroup.make( + ListDepartments, + ListTeams, + ListFieldOfStudies, + ListTeamInterest, + ListMailingLists, + CreateDepartment, + CreateTeam, + CreateFieldOfStudy, + ReadAppointmentManagement, + ReadBoardRosters, + ExecuteLifecycle, + ReadDelegationManagement, + ExecuteDelegation, +) {} diff --git a/packages/rpc/src/placements.ts b/packages/rpc/src/placements.ts new file mode 100644 index 000000000..8db878e06 --- /dev/null +++ b/packages/rpc/src/placements.ts @@ -0,0 +1,267 @@ +/** + * Placements: affiliation, placement boards, drafts, and coverage. + * + * Each RPC keeps the operation ID of the HTTP operation it replaces as its tag. A read takes the + * old query as its payload. A command takes that scope, its `idempotencyKey`, the `ifMatch` of the + * snapshot it changes, and the old body as its `request`, and answers the changed snapshot beside + * its new entity tag. + * + * @since 0.3.0 + */ +import { + Affiliation, + AffiliationScope, + CoverageBoard, + CoverageCommand, + OwnAffiliationCommand, + OwnCoverageCommand, + OwnCoverageView, + PlacementBoard, + PlacementCommand, + PlacementDraft, + PlacementScope, + PlacementScopes, +} from "@vektorprogrammet/domain/placements"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { personNativeAccess, withAccessSpec } from "./access.js"; +import { PersonCredential } from "./credential.js"; +import { IdempotencyKey, problemUnion, rpcProblems, StrongETag } from "./problem.js"; + +export { + AffiliationScope, + CoverageCommand, + OwnAffiliationCommand, + OwnCoverageCommand, + PlacementCommand, + PlacementScope, + PlacementScopes, +}; + +/** The caller's own affiliation, with the tag that `placements.commandOwnAffiliation` repeats. */ +export const OwnAffiliationResource = Schema.Struct({ + ...Affiliation.fields, + etag: StrongETag, +}).annotate({ identifier: "OwnAffiliationResource" }); + +/** The coordinator's board, with the tag that `placements.commandBoard` repeats. */ +export const PlacementBoardResource = Schema.Struct({ + ...PlacementBoard.fields, + etag: StrongETag, +}).annotate({ identifier: "PlacementBoardResource" }); + +/** The caller's own coverage, with the tag that `placements.commandOwnCoverage` repeats. */ +export const OwnCoverageResource = Schema.Struct({ + ...OwnCoverageView.fields, + etag: StrongETag, +}).annotate({ identifier: "OwnCoverageResource" }); + +/** The coordinator's coverage board, with the tag that `placements.commandCoverageBoard` repeats. */ +export const CoverageBoardResource = Schema.Struct({ + ...CoverageBoard.fields, + etag: StrongETag, +}).annotate({ identifier: "CoverageBoardResource" }); + +/** + * A draft is a read. `boardEtag` names the board version it was drafted from, for the `ifMatch` + * of the placement commands that apply it. + */ +export const PlacementDraftResource = Schema.Struct({ + ...PlacementDraft.fields, + boardEtag: StrongETag, +}).annotate({ identifier: "PlacementDraftResource" }); + +/** The failures that every placement operation can answer: credential, authority, and domain. */ +const placementFailureCodes = [ + "credential.missing", + "credential.invalid", + "authority.denied", + "resource.not-found", + "scope.invalid", + "affiliation.transition-invalid", + "affiliation.inactive", + "placement.overlap", + "placement.inactive", + "school-service.proposal-empty", + "school-service.proposal-inactive", + "school-service.exception-review-invalid", + "commitment.target-invalid", + "commitment.duplicate", + "commitment.closed", + "commitment.outcome-invalid", + "commitment.interval-invalid", + "absence.target-invalid", + "absence.duplicate", + "coverage.owner-invalid", + "coverage.coverer-ineligible", + "coverage.coverer-unavailable", + "coverage.not-recorded", + "transaction.conflict", + "internal.error", +] as const; + +export const PlacementsReadProblem = problemUnion("PlacementsReadProblem", placementFailureCodes); + +export const PlacementsCommandProblem = problemUnion("PlacementsCommandProblem", [ + ...placementFailureCodes, + "absence.closed", + "precondition.failed", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "idempotency.unavailable", +]); + +const access = (capability: "placements.self" | "placements.manage", write: boolean) => + withAccessSpec( + personNativeAccess({ + capability, + canonicalScopeResolver: "placements.explicit-department", + decisionTime: write ? "Transaction" : "SnapshotRead", + }), + ); + +/** The replay key and precondition of a command on one snapshot. */ +const commandFields = { idempotencyKey: IdempotencyKey, ifMatch: StrongETag }; + +/** + * Public department labels and canonical semesters; private candidate names require coordinator + * authority. + */ +export const ListPlacementScopes = Rpc.make("placements.listScopes", { + success: PlacementScopes, + error: rpcProblems(PlacementsReadProblem), +}) + .middleware(PersonCredential) + .pipe(access("placements.self", false)); + +/** The caller's own affiliation; there is no person selector. */ +export const ReadOwnAffiliation = Rpc.make("placements.readOwnAffiliation", { + payload: AffiliationScope, + success: OwnAffiliationResource, + error: rpcProblems(PlacementsReadProblem), +}) + .middleware(PersonCredential) + .pipe(access("placements.self", false)); + +/** Requests or withdraws the caller's own affiliation, which lets scoped coordinators find them. */ +export const CommandOwnAffiliation = Rpc.make("placements.commandOwnAffiliation", { + payload: Schema.Struct({ + ...AffiliationScope.fields, + ...commandFields, + request: OwnAffiliationCommand, + }), + success: OwnAffiliationResource, + error: rpcProblems(PlacementsCommandProblem), +}) + .middleware(PersonCredential) + .pipe(access("placements.self", true)); + +/** + * The placement board, for holders of placement coordination in the department (a board + * leadership or a delegation) and global administrators only. + */ +export const ReadPlacementBoard = Rpc.make("placements.readBoard", { + payload: PlacementScope, + success: PlacementBoardResource, + error: rpcProblems(PlacementsReadProblem), +}) + .middleware(PersonCredential) + .pipe(access("placements.manage", false)); + +/** + * Manages placements and dated school service: demand, proposals, commitments, and placement + * changes inside one transaction. + */ +export const CommandPlacementBoard = Rpc.make("placements.commandBoard", { + payload: Schema.Struct({ + ...PlacementScope.fields, + ...commandFields, + request: PlacementCommand, + }), + success: PlacementBoardResource, + error: rpcProblems(PlacementsCommandProblem), +}) + .middleware(PersonCredential) + .pipe(access("placements.manage", true)); + +/** + * Drafts open school demand for unplaced active assistants from their weekday availability and + * teaching blocks. Nothing is stored; coordinators apply the draft with placement commands. + */ +export const ReadPlacementDraft = Rpc.make("placements.readDraft", { + payload: PlacementScope, + success: PlacementDraftResource, + error: rpcProblems(PlacementsReadProblem), +}) + .middleware(PersonCredential) + .pipe(access("placements.manage", false)); + +/** + * The caller's own coverage and scheduled service: only commitments where they are scheduled or + * cover a recorded absence, and possible coverers only while they have an open absence. + */ +export const ReadOwnCoverage = Rpc.make("placements.readOwnCoverage", { + payload: PlacementScope, + success: OwnCoverageResource, + error: rpcProblems(PlacementsReadProblem), +}) + .middleware(PersonCredential) + .pipe(access("placements.self", false)); + +/** + * Reports the caller's own absence or records who covers it. The caller must own a scheduled + * assignment in an open commitment; coverage commands name only the caller's own absence. + */ +export const CommandOwnCoverage = Rpc.make("placements.commandOwnCoverage", { + payload: Schema.Struct({ + ...PlacementScope.fields, + ...commandFields, + request: OwnCoverageCommand, + }), + success: OwnCoverageResource, + error: rpcProblems(PlacementsCommandProblem), +}) + .middleware(PersonCredential) + .pipe(access("placements.self", true)); + +/** + * The coordinator coverage board: absences, coverage records, possible coverers, per-absence + * closures, and commitment outcomes, for a scoped coordinator only. + */ +export const ReadCoverageBoard = Rpc.make("placements.readCoverageBoard", { + payload: PlacementScope, + success: CoverageBoardResource, + error: rpcProblems(PlacementsReadProblem), +}) + .middleware(PersonCredential) + .pipe(access("placements.manage", false)); + +/** + * Records coverage and decides dated service: checks coordinator scope, records absences and + * coverage, and derives actual attendance for immutable outcome evidence in one transaction. + */ +export const CommandCoverageBoard = Rpc.make("placements.commandCoverageBoard", { + payload: Schema.Struct({ + ...PlacementScope.fields, + ...commandFields, + request: CoverageCommand, + }), + success: CoverageBoardResource, + error: rpcProblems(PlacementsCommandProblem), +}) + .middleware(PersonCredential) + .pipe(access("placements.manage", true)); + +export class PlacementsRpcs extends RpcGroup.make( + ListPlacementScopes, + ReadOwnAffiliation, + CommandOwnAffiliation, + ReadPlacementBoard, + CommandPlacementBoard, + ReadPlacementDraft, + ReadOwnCoverage, + CommandOwnCoverage, + ReadCoverageBoard, + CommandCoverageBoard, +) {} diff --git a/packages/http-api/src/http-semantics.ts b/packages/rpc/src/problem.ts similarity index 79% rename from packages/http-api/src/http-semantics.ts rename to packages/rpc/src/problem.ts index 41c244445..24f952d48 100644 --- a/packages/http-api/src/http-semantics.ts +++ b/packages/rpc/src/problem.ts @@ -1,10 +1,10 @@ /** - * Frozen v0.2 HTTP primitives shared by every native endpoint. + * The native problem vocabulary and the command primitives shared by every RPC. * * @since 0.2.0 */ -import { Data, ErrorReporter, Predicate, Struct, Schema, type Types } from "effect"; -import { HttpApiSchema } from "effect/unstable/httpapi"; +import { Data, ErrorReporter, Predicate, Struct, Schema, SchemaGetter, type Types } from "effect"; +import { dual } from "effect/Function"; export { parseJsonWithUniqueMembers } from "@vektorprogrammet/domain/http-semantics"; @@ -50,165 +50,6 @@ export const Sha256Hex = Schema.String.pipe( export type Sha256Hex = typeof Sha256Hex.Type; -/** Headers accepted by every replayable external native mutation. */ -export const IdempotencyHeaders = Schema.Struct({ - "idempotency-key": IdempotencyKey, -}).annotate({ identifier: "IdempotencyHeaders" }); - -export type IdempotencyHeaders = typeof IdempotencyHeaders.Type; - -/** Headers accepted by a replayable existing-resource native mutation. */ -export const IdempotencyIfMatchHeaders = Schema.Struct({ - "idempotency-key": IdempotencyKey, - "if-match": StrongETag, -}).annotate({ identifier: "IdempotencyIfMatchHeaders" }); - -export type IdempotencyIfMatchHeaders = typeof IdempotencyIfMatchHeaders.Type; - -/** Headers accepted by an existing-resource mutation that stores no replayable receipt. */ -export const IfMatchHeaders = Schema.Struct({ - "if-match": StrongETag, -}).annotate({ identifier: "IfMatchHeaders" }); - -export type IfMatchHeaders = typeof IfMatchHeaders.Type; - -const EntityTagConditionHeader = Schema.String.pipe( - Schema.check( - Schema.makeFilter((value) => value.trim().length > 0 && value.length <= 4096, { - message: "an entity-tag condition", - }), - ), -); - -/** Optional validators accepted by each frozen conditional read. */ -export const ConditionalReadHeaders = Schema.Struct({ - "if-match": Schema.optional(EntityTagConditionHeader), - "if-none-match": Schema.optional(EntityTagConditionHeader), -}).annotate({ identifier: "ConditionalReadHeaders" }); - -export type ConditionalReadHeaders = typeof ConditionalReadHeaders.Type; - -const OriginVary = Schema.Literal("Origin"); - -const NoStore = Schema.Literal("no-store"); - -const PrivateNoStore = Schema.Literal("private, no-store"); - -const PublicCache = Schema.Literal("public, max-age=60, s-maxage=300, must-revalidate"); - -const DynamicAdmissionCache = Schema.String.pipe( - Schema.check( - Schema.makeFilter( - (value) => { - const match = - /^public, max-age=([0-9]|[12][0-9]|30), s-maxage=([0-9]|[12][0-9]|30), must-revalidate$/u.exec( - value, - ); - - return match !== null && match[1] === match[2]; - }, - { message: "the frozen dynamic admission cache policy" }, - ), - ), -); - -const OriginRelativeLocation = Schema.String.pipe( - Schema.check( - Schema.makeFilter((value) => /^\/api\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$/u.test(value), { - message: "an origin-relative API resource location", - }), - ), -); - -const RetryAfterSeconds = Schema.String.pipe( - Schema.check( - Schema.makeFilter( - (value) => { - const seconds = Number(value); - - return Number.isSafeInteger(seconds) && seconds >= 1 && seconds <= 3_600; - }, - { message: "a retry delay from 1 through 3600 seconds" }, - ), - ), -); - -const externalHeaders = (cacheControl: CacheControl) => ({ - "cache-control": cacheControl, - vary: OriginVary, -}); - -const conditionalReadResponses = ( - success: S, - cacheControl: CacheControl, -) => { - const headers = { - ...externalHeaders(cacheControl), - etag: StrongETag, - }; - - return [ - HttpApiSchema.WithHeaders(success, headers), - HttpApiSchema.WithHeaders(HttpApiSchema.NoContent.pipe(HttpApiSchema.status(304)), headers), - ] as const; -}; - -/** Public conditional response with the fixed five-minute shared cache policy. */ -export const publicConditionalResponses = (success: S) => - conditionalReadResponses(success, PublicCache); - -/** Public conditional response whose TTL is bounded by the next admission boundary. */ -export const dynamicAdmissionConditionalResponses = (success: S) => - conditionalReadResponses(success, DynamicAdmissionCache); - -/** Credential-selected conditional response that is never stored. */ -export const privateConditionalResponses = (success: S) => - conditionalReadResponses(success, PrivateNoStore); - -/** Credential-selected non-conditional read response. */ -export const privateReadResponse = (success: S) => - HttpApiSchema.WithHeaders(success, externalHeaders(PrivateNoStore)); - -/** Anonymous or health response that is not cacheable. */ -export const noStoreReadResponse = (success: S) => - HttpApiSchema.WithHeaders(success, externalHeaders(NoStore)); - -/** Internal response with no browser CORS contract. */ -export const internalNoStoreResponse = (success: S) => - HttpApiSchema.WithHeaders(success, { "cache-control": NoStore }); - -/** Successful resource creation response. */ -export const createdMutationResponse = (success: S) => - HttpApiSchema.WithHeaders(success, { - ...externalHeaders(NoStore), - etag: StrongETag, - location: OriginRelativeLocation, - }); - -/** Successful mutation response carrying a current mutable representation. */ -export const entityMutationResponse = (success: S) => - HttpApiSchema.WithHeaders(success, { - ...externalHeaders(NoStore), - etag: StrongETag, - }); - -/** Successful mutation response carrying the bytes of a generated document and its entity tag. */ -export const documentMutationResponse = (contentType: string) => - HttpApiSchema.WithHeaders(Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array({ contentType })), { - ...externalHeaders(NoStore), - etag: StrongETag, - }); - -/** Successful no-content mutation response, optionally with a new entity tag. */ -export const noContentMutationResponse = (options?: { readonly etag?: boolean }) => { - const headers = externalHeaders(NoStore); - - if (options?.etag === true) - return HttpApiSchema.WithHeaders(HttpApiSchema.NoContent, { ...headers, etag: StrongETag }); - - return HttpApiSchema.WithHeaders(HttpApiSchema.NoContent, headers); -}; - const ValidationPointer = Schema.String.pipe( Schema.check( Schema.makeFilter( @@ -251,14 +92,24 @@ export const NativeValidationError = Schema.Union([ export type NativeValidationError = typeof NativeValidationError.Type; -export const makeNativeValidationError = ( - pointer: string, - code: Code, -) => ({ - pointer, - code, - message: NativeValidationMessage[code], -}); +/** One validation diagnostic, with the message that its code fixes. */ +type ValidationDiagnostic = { + pointer: string; + code: Code; + message: (typeof NativeValidationMessage)[Code]; +}; + +export const makeNativeValidationError: { + (code: Code): (pointer: string) => ValidationDiagnostic; + (pointer: string, code: Code): ValidationDiagnostic; +} = dual( + 2, + (pointer: string, code: Code): ValidationDiagnostic => ({ + pointer, + code, + message: NativeValidationMessage[code], + }), +); const InstanceUrn = Schema.String.pipe( Schema.check( @@ -1040,7 +891,7 @@ const nativeProblemCoreSchema = (code: Code) => detail: Schema.Literal(definition.detail), instance: Schema.optional(InstanceUrn), }), - ).pipe(HttpApiSchema.status(definition.status)); + ); }; const problemCodes = Struct.keys(NativeProblemRegistry); @@ -1077,7 +928,7 @@ export const validationProblemSchema = (code instance: Schema.optional(InstanceUrn), validation: validationBody, }), - ).pipe(HttpApiSchema.status(definition.status)); + ); }; /** The only public extension for semantic validation failures. */ @@ -1090,18 +941,31 @@ export const ValidationProblem = Schema.Union([ export type ValidationProblem = typeof ValidationProblem.Type; /** Creates one fixed RFC 9457 core variant from the frozen registry. */ -export const nativeProblemSchema = ( - code: Code, - expectedStatus?: number, -) => { - const definition = NativeProblemRegistry[code]; - - if (expectedStatus !== undefined && definition.status !== expectedStatus) { - throw new Error(`${code} is frozen at HTTP ${definition.status}, not ${expectedStatus}`); - } +export const nativeProblemSchema: { + ( + code: Code, + expectedStatus?: number, + ): ReturnType>; + ( + expectedStatus?: number, + ): ( + code: Code, + ) => ReturnType>; +} = dual( + (args) => Predicate.isString(args[0]), + ( + code: Code, + expectedStatus?: number, + ): ReturnType> => { + const definition = NativeProblemRegistry[code]; + + if (expectedStatus !== undefined && definition.status !== expectedStatus) { + throw new Error(`${code} is frozen at HTTP ${definition.status}, not ${expectedStatus}`); + } - return nativeProblemCoreSchema(code); -}; + return nativeProblemCoreSchema(code); + }, +); const isValidationProblemCode = (code: NativeProblemCode): code is ValidationProblemCode => code === "validation.failed" || @@ -1121,13 +985,13 @@ function problemVariant(code: NativeProblemCode) { } /** - * Creates a closed endpoint-specific Problem Details union. + * Creates the closed Problem Details union of one RPC. * * @remarks * It builds one wire-ordered variant per distinct code of `codes`, whose status, type, title, and * detail come from `NativeProblemRegistry`, and a validation code carries its validation - * extension. The union keeps each declared code as a literal, and `identifier` names it in the - * OpenAPI document. `endpointProblemResponses` splits it into one response per status. + * extension. The union keeps each declared code as a literal, and `identifier` names the schema. + * `rpcProblems` makes it the error of an RPC. * * @sideEffects none * @@ -1137,25 +1001,34 @@ function problemVariant(code: NativeProblemCode) { * ``` * * @avoid Writing a problem schema by hand, or giving a code its own status: the answer then - * differs from the registry that clients decode. Declare an endpoint's problems with this. + * differs from the registry that clients decode. Declare an RPC's problems with this. * - * @construct http-problem + * @construct rpc-problem */ -export const problemUnion = < - const Codes extends readonly [NativeProblemCode, ...ReadonlyArray], ->( - identifier: string, - codes: Codes, -): Schema.Union>> => { - // Array methods on `Codes` resolve through its constraint; the element view keeps the literals. - const declared: ReadonlyArray = codes; - - return Schema.Union([...new Set(declared)].map(problemVariant)).annotate({ - identifier, - title: identifier, - description: `Closed RFC 9457 error union for ${identifier}.`, - }); -}; +export const problemUnion: { + ]>( + codes: Codes, + ): (identifier: string) => Schema.Union>>; + ]>( + identifier: string, + codes: Codes, + ): Schema.Union>>; +} = dual( + 2, + ]>( + identifier: string, + codes: Codes, + ): Schema.Union>> => { + // Array methods on `Codes` resolve through its constraint; the element view keeps the literals. + const declared: ReadonlyArray = codes; + + return Schema.Union([...new Set(declared)].map(problemVariant)).annotate({ + identifier, + title: identifier, + description: `Closed RFC 9457 error union for ${identifier}.`, + }); + }, +); export type NativeValidationDetails = typeof validationBody.Type; @@ -1172,7 +1045,7 @@ export const normalizeValidationErrors = ( return { errors: sorted.slice(0, 32), truncated: sorted.length > 32 }; }; -const CredentialPresentationTypeId = "~@vektorprogrammet/http-api/CredentialPresentation"; +const CredentialPresentationTypeId = "~@vektorprogrammet/rpc/CredentialPresentation"; /** Ingress found no credential on the request. */ export interface CredentialAbsent { @@ -1210,7 +1083,7 @@ export function credentialPresentation(input: { : { [CredentialPresentationTypeId]: "Absent", challenge: input.challenge }; } -const ProblemTypeId = "~@vektorprogrammet/http-api/Problem"; +const ProblemTypeId = "~@vektorprogrammet/rpc/Problem"; interface ProblemMembers { readonly code: Code; @@ -1258,7 +1131,7 @@ export interface WireProblemHeaders { * longer decides the answer, and an endpoint cannot declare the failure. Fail with a `Problem` * that one of its constructors built. * - * @construct http-problem + * @construct rpc-problem */ export class Problem extends Data.Error< ProblemMembers @@ -1321,7 +1194,7 @@ export class Problem e : Problem.credentialInvalid(presentation); } - /** Rebuilds a problem decoded from the wire by a generated client. */ + /** Rebuilds a problem decoded from the wire by the RPC client. */ static fromWire( body: WireProblemBody, headers: WireProblemHeaders, @@ -1361,7 +1234,7 @@ export class Problem e * @avoid `cause instanceof Problem`: a problem from another copy of the module fails it and is * treated as a defect. Narrow with this. * - * @construct http-problem + * @construct rpc-problem */ export const isProblem = (u: unknown): u is Problem => Predicate.hasProperty(u, ProblemTypeId); @@ -1390,7 +1263,7 @@ export interface ProblemWireRecord extends FrozenProblemDefinition { * @avoid Spreading a problem into a body: its constructor members, such as the challenge, then * leak into the body, and the registry members go missing. Render the body with this. * - * @construct http-problem + * @construct rpc-problem */ export const problemBody = (problem: Problem): ProblemWireRecord => { const body: Types.Mutable = { @@ -1432,25 +1305,11 @@ export const problemHeaders = (problem: Problem): ProblemHeaderValues => { return headers; }; -/** Problem bodies and headers are built from service-free codecs. */ +/** Problem bodies are built from service-free codecs. */ type ProblemWireSchema = Schema.Codec; -interface ProblemHeaderFields { - readonly [header: PropertyKey]: ProblemWireSchema; -} - -/** The declared response for every problem of one status, typed by its codes. */ -export interface ProblemResponse< - Code extends NativeProblemCode, -> extends HttpApiSchema.encodeToWithHeaders< - Schema.declare>, - ProblemWireSchema, - ProblemHeaderFields -> {} - interface ProblemVariant extends ProblemWireSchema { readonly fields: { - readonly status: { readonly literal: number }; readonly code: { readonly literal: NativeProblemCode }; }; } @@ -1460,102 +1319,47 @@ interface ProblemUnionSchema extends ProblemWireSchema { readonly Type: { readonly code: NativeProblemCode }; } -/** Folds every problem of one status and its response headers into one encoder. */ -const problemResponse = ( - status: number, - body: ProblemWireSchema, - codes: ReadonlySet, - cors: boolean, -): ProblemResponse => { - const headers: Array = [["cache-control", NoStore]]; - - if (cors) headers.push(["vary", OriginVary]); - - if (status === 401) headers.push(["www-authenticate", Schema.String]); - - if (codes.has("idempotency.in-flight")) - headers.push(["retry-after", Schema.optional(Schema.Literal("1"))]); - - if (status === 429) headers.push(["retry-after", RetryAfterSeconds]); - - if (status === 503) headers.push(["retry-after", Schema.Literal("5")]); - - const wireBody: ProblemWireSchema = body.pipe( - HttpApiSchema.status(status), - HttpApiSchema.asJson({ contentType: "application/problem+json" }), - ); - - const wireHeaders: ProblemHeaderFields = Object.fromEntries(headers); - - return Schema.declare((u): u is Problem => isProblem(u) && codes.has(u.code)).pipe( - HttpApiSchema.encodeToWithHeaders( - { body: wireBody, headers: wireHeaders }, - { - decode: ({ body: decoded, headers: received }) => { - // SAFETY: the body schema above decoded one declared problem variant of this status. - const wire = decoded as WireProblemBody; - // SAFETY: the header schemas above decoded this status's problem headers. - const receivedHeaders = received as WireProblemHeaders; - - return Problem.fromWire(wire, receivedHeaders); - }, - encode: (problem) => ({ - body: problemBody(problem), - headers: cors ? { ...problemHeaders(problem), vary: "Origin" } : problemHeaders(problem), - }), - }, - ), - ); -}; - /** - * Splits an endpoint-specific Problem union into status-bearing response - * schemas. Effect resolves an HTTP status only from the outer schema, and - * permits one header-carrying response per status, so each status folds its - * problems and their headers into one encoder. + * The error schema of one RPC: its declared `problemUnion`, carried as `Problem` values. + * + * @remarks + * A handler fails with `Problem.make(code)`; the RPC encodes it as the RFC 9457 body of its code, + * and the client decodes it back to a `Problem` of the same code. A problem whose code the union + * does not declare fails to encode, which the RPC server answers as a defect, so the contract + * stays closed. + * + * @sideEffects none + * + * @example + * ```ts + * Rpc.make("SocialEvents.create", { payload, success, error: rpcProblems(SocialEventsCreateProblem) }); + * ``` + * + * @avoid Declaring `Problem` or `NativeProblem` as the error of an RPC: the client then learns + * nothing about which failures it must handle. Declare the operation's own union. */ -export const endpointProblemResponses = ( - problem: Union, - options?: { readonly cors?: boolean }, -): ReadonlyArray> => { - const grouped = new Map>(); - - for (const member of problem.members) { - const status = member.fields.status.literal; - const bucket = grouped.get(status); - - if (bucket === undefined) grouped.set(status, [member]); - else bucket.push(member); - } +export const rpcProblems = (problem: Union) => { + const codes = new Set( + problem.members.map((member) => member.fields.code.literal), + ); - return [...grouped].map(([status, variants]) => - problemResponse( - status, - Schema.Union(variants), - new Set(variants.map((variant) => variant.fields.code.literal)), - options?.cors !== false, - ), + return Schema.declare( + (u): u is Problem => isProblem(u) && codes.has(u.code), + ).pipe( + Schema.encodeTo(problem, { + // SAFETY: the union above decoded one declared problem variant. + decode: SchemaGetter.transform((wire) => + Problem.fromWire(wire as WireProblemBody, {}), + ), + encode: SchemaGetter.transform((value) => problemBody(value)), + }), ); }; -/** One response for a union whose problems share a status; the union keeps its identity. */ -export const problemStatusResponse = ( - problem: Union, - options?: { readonly cors?: boolean }, -): ProblemResponse => { - const statuses = new Set(problem.members.map((member) => member.fields.status.literal)); - const [status] = statuses; - - if (status === undefined || statuses.size !== 1) { - throw new Error("problemStatusResponse requires problems of exactly one status"); - } - - return problemResponse( - status, - problem, - new Set(problem.members.map((member) => member.fields.code.literal)), - options?.cors !== false, - ); +/** The body of one registry problem, with its code and an optional instance. */ +type RegistryProblemBody = (typeof NativeProblemRegistry)[Code] & { + readonly code: Code; + readonly instance?: string; }; /** @@ -1579,21 +1383,34 @@ export const problemStatusResponse = ( * @avoid Writing a problem body by hand in a test or a fixture: its title or status then drifts * from the registry that clients decode. Build it with this. * - * @construct http-problem + * @construct rpc-problem */ -export const makeNativeProblem = ( - code: Code, - expectedStatus?: number, - instance?: string, -): (typeof NativeProblemRegistry)[Code] & { readonly code: Code; readonly instance?: string } => { - const definition = NativeProblemRegistry[code]; - - if (expectedStatus !== undefined && definition.status !== expectedStatus) { - throw new Error(`${code} is frozen at HTTP ${definition.status}, not ${expectedStatus}`); - } +export const makeNativeProblem: { + ( + code: Code, + expectedStatus?: number, + instance?: string, + ): RegistryProblemBody; + ( + expectedStatus?: number, + instance?: string, + ): (code: Code) => RegistryProblemBody; +} = dual( + (args) => Predicate.isString(args[0]), + ( + code: Code, + expectedStatus?: number, + instance?: string, + ): RegistryProblemBody => { + const definition = NativeProblemRegistry[code]; + + if (expectedStatus !== undefined && definition.status !== expectedStatus) { + throw new Error(`${code} is frozen at HTTP ${definition.status}, not ${expectedStatus}`); + } - return instance === undefined ? { ...definition, code } : { ...definition, code, instance }; -}; + return instance === undefined ? { ...definition, code } : { ...definition, code, instance }; + }, +); /** Path-level error for a method outside a resource's frozen method set. */ export const NativeMethodNotAllowedProblem = nativeProblemSchema("method.not-allowed").annotate({ diff --git a/packages/rpc/src/profile.ts b/packages/rpc/src/profile.ts new file mode 100644 index 000000000..d69584318 --- /dev/null +++ b/packages/rpc/src/profile.ts @@ -0,0 +1,129 @@ +/** + * Profile: the caller's own profile. + * + * @since 0.3.0 + */ +import { PersonId, ProfileRoleSchema } from "@vektorprogrammet/domain/organization"; +import { OwnProfile } from "@vektorprogrammet/domain/profile"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { personNativeAccess, withAccessSpec } from "./access.js"; +import { PersonCredential } from "./credential.js"; +import { IdempotencyKey, problemUnion, rpcProblems, StrongETag } from "./problem.js"; +import { ProfileMergePatch } from "./v2-schemas.js"; + +/** + * Dashboard role projection for navigation. A department administrator holds a capability beyond + * one team through a board leadership or a delegation; a team leader acts within the team. + */ +export const UserRoleSchema = ProfileRoleSchema; + +/** Strict self-profile representation exposed to the dashboard. */ +export const UserProfileResponse = Schema.Struct({ + personId: OwnProfile.fields.personId, + firstName: OwnProfile.fields.firstName, + lastName: OwnProfile.fields.lastName, + email: OwnProfile.fields.email, + phone: OwnProfile.fields.phone, + role: UserRoleSchema, + nameRevision: OwnProfile.fields.nameRevision, + contactRevision: OwnProfile.fields.contactRevision, +}).annotate({ + identifier: "UserProfileResponse", + description: "The current person's editable profile and authorization role projection.", + examples: [ + { + personId: PersonId.make("7202"), + firstName: "Ming", + lastName: "Medlem", + email: "ming.medlem@example.org", + phone: "+47 900 00 000", + role: "ROLE_TEAM_MEMBER", + nameRevision: 0, + contactRevision: 1, + }, + ], +}); + +/** + * The profile beside its strong entity tag, which `profile.updateOwnProfile` takes as `ifMatch`. + * The tag covers the stored profile revisions and the role representation revision. + */ +export const OwnProfileResource = Schema.Struct({ + profile: UserProfileResponse, + etag: StrongETag, +}).annotate({ identifier: "OwnProfileResource" }); + +export type OwnProfileResource = typeof OwnProfileResource.Type; + +export const OwnProfileReadProblem = problemUnion("OwnProfileReadProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "internal.error", + "profile.not-found", + "profile.unavailable", +]); + +export const OwnProfileUpdateProblem = problemUnion("OwnProfileUpdateProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "validation.failed", + "validation.no-change", + "validation.field-not-deletable", + "precondition.failed", + "transaction.conflict", + "internal.error", + "profile.not-found", + "profile.unavailable", + "idempotency.unavailable", +]); + +/** Returns the profile selected by the current credential. */ +export const ReadOwnProfile = Rpc.make("profile.readOwnProfile", { + success: OwnProfileResource, + error: rpcProblems(OwnProfileReadProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "profile.read-self", + canonicalScopeResolver: "profile.current-person", + requirements: ["profile.owner"], + decisionTime: "SnapshotRead", + }), + ), + ); + +/** + * Applies a JSON merge patch (RFC 7396) to the profile selected by the current credential, and + * returns the fresh projection. An absent member keeps its value; a patch with no member answers + * validation.no-change, and a `null` member validation.field-not-deletable. + */ +export const UpdateOwnProfile = Rpc.make("profile.updateOwnProfile", { + payload: Schema.Struct({ + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: ProfileMergePatch, + }), + success: OwnProfileResource, + error: rpcProblems(OwnProfileUpdateProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "profile.update-self", + canonicalScopeResolver: "profile.current-person", + requirements: ["profile.owner"], + decisionTime: "Transaction", + }), + ), + ); + +export class ProfileRpcs extends RpcGroup.make(ReadOwnProfile, UpdateOwnProfile) {} diff --git a/packages/rpc/src/receipts.ts b/packages/rpc/src/receipts.ts new file mode 100644 index 000000000..4a6c74ca9 --- /dev/null +++ b/packages/rpc/src/receipts.ts @@ -0,0 +1,586 @@ +/** + * Receipts: submission, revision, approval, settlement, and receipt files. + * + * The RPC tags keep the operation IDs of the HTTP contract they replace, and each command's + * receipt keeps the old route path as its normalized target. A receipt file travels as bytes in + * the payload or the success; the JSON serialization carries them as base64 text. + * + * @since 0.3.0 + */ +import { + CapabilityExpressionSchema, + CapabilityTypeId, + ConcealmentPolicySchema, + CredentialMechanismSchema, + INTERNAL_RECEIPT_EVIDENCE_ACCESS, + RECEIPT_APPROVAL_QUEUE_ACCESS, + makeAccessSpec, +} from "@vektorprogrammet/domain/authz"; +import { + RECEIPT_PAGE_SIZE, + Receipt, + ReceiptCursor, + ReceiptId, + ReceiptSettlementEvidenceSchema, + ReceiptStatusSchema, +} from "@vektorprogrammet/domain/receipt"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { personNativeAccess, withAccessSpec } from "./access.js"; +import { PersonCredential, PersonOrServiceCredential, SessionCredential } from "./credential.js"; +import { IdempotencyKey, problemUnion, rpcProblems, StrongETag } from "./problem.js"; +import { + ReceiptResource, + ReceiptSettlementEvidenceResource, + RecordReceiptSettlementRequest, +} from "./v2-schemas.js"; + +export { ReceiptCursor, ReceiptId }; + +const settlementAccess = ( + canonicalScopeResolver: "receipts.settlement-queue" | "receipts.by-id", + decisionTime: "SnapshotRead" | "Transaction", +) => + makeAccessSpec({ + exposure: "External", + acceptedCredentials: [ + CredentialMechanismSchema.cases.BetterAuthCookie.make({}), + CredentialMechanismSchema.cases.OAuthUserBearer.make({}), + ], + principalKinds: ["Person"], + capabilities: CapabilityExpressionSchema.cases.One.make({ + capability: { type: CapabilityTypeId.make("settleReceipt") }, + }), + requirements: [], + canonicalScopeResolver, + concealment: ConcealmentPolicySchema.cases.NotFound.make({ + conceal: ["Capability", "Scope"], + }), + decisionTime, + }); + +/** The media types of a receipt file. */ +export const ReceiptFileMediaType = Schema.Literals(["image/jpeg", "image/png", "application/pdf"]); + +export type ReceiptFileMediaType = typeof ReceiptFileMediaType.Type; + +/** + * One uploaded receipt file. The handler checks its media type and its byte bound, and answers + * validation.failed for a file outside them, as the multipart reader did. + */ +export const ReceiptFileUpload = Schema.Struct({ + contentType: Schema.String, + bytes: Schema.Uint8Array, +}).annotate({ identifier: "ReceiptFileUpload" }); + +export type ReceiptFileUpload = typeof ReceiptFileUpload.Type; + +/** + * A new receipt. The handler checks each field as the multipart reader did: a description of 1 to + * 5 000 characters, a positive safe integer amount in øre, and a calendar date `YYYY-MM-DD`. + */ +export const SubmitReceiptRequest = Schema.Struct({ + description: Schema.String, + amountOre: Schema.Finite, + receiptDate: Schema.String, + file: ReceiptFileUpload, +}).annotate({ identifier: "SubmitReceiptRequest" }); + +export type SubmitReceiptRequest = typeof SubmitReceiptRequest.Type; + +/** A revision of a pending receipt: the fields that it changes, at least one. */ +export const ReviseReceiptRequest = Schema.Struct({ + description: Schema.optional(Schema.String), + amountOre: Schema.optional(Schema.Finite), + receiptDate: Schema.optional(Schema.String), + file: Schema.optional(ReceiptFileUpload), +}).annotate({ identifier: "ReviseReceiptRequest" }); + +export type ReviseReceiptRequest = typeof ReviseReceiptRequest.Type; + +/** The verified private bytes of one receipt file, with their stored media type. */ +export const ReceiptFileContent = Schema.Struct({ + contentType: ReceiptFileMediaType, + bytes: Schema.Uint8Array, +}).annotate({ identifier: "ReceiptFileContent" }); + +export type ReceiptFileContent = typeof ReceiptFileContent.Type; + +/** Owner receipt projection. */ +export const ReceiptListItem = Schema.Struct({ + receiptId: Schema.String, + visualId: Schema.String, + ownerPersonId: Schema.String, + departmentId: Schema.String, + amountOre: Schema.Int, + currency: Schema.Literals(["NOK"]), + description: Schema.String, + receiptDate: Schema.String, + status: ReceiptStatusSchema, + revision: Schema.Int, + approvedAt: Receipt.json.fields.approvedAt, + settlement: Schema.NullOr(ReceiptSettlementEvidenceSchema), + etag: StrongETag, +}).annotate({ + identifier: "ReceiptListItem", + description: "Owner receipt projection.", +}); + +export type ReceiptListItem = typeof ReceiptListItem.Type; + +/** One bounded page of the caller's own receipts, and its continuation. */ +export const ReceiptListResponse = Schema.Struct({ + items: Schema.Array(ReceiptListItem).pipe(Schema.check(Schema.isMaxLength(RECEIPT_PAGE_SIZE))), + nextCursor: Schema.optional(ReceiptCursor), +}).annotate({ + identifier: "ReceiptListResponse", + description: "One bounded receipt page and optional continuation.", +}); + +export const ReceiptApprovalQueueItem = Schema.Struct({ + receiptId: Schema.String, + visualId: Schema.String, + ownerPersonId: Schema.String, + departmentId: Schema.String, + amountOre: Schema.Int, + currency: Schema.Literals(["NOK"]), + description: Schema.String, + receiptDate: Schema.String, + status: ReceiptStatusSchema, + revision: Schema.Int, + etag: StrongETag, + approvedAt: Receipt.json.fields.approvedAt, +}).annotate({ + identifier: "ReceiptApprovalQueueItem", + description: "One receipt visible in the current approver queue.", +}); + +export type ReceiptApprovalQueueItem = typeof ReceiptApprovalQueueItem.Type; + +export const ReceiptApprovalQueueResponse = Schema.Struct({ + items: Schema.Array(ReceiptApprovalQueueItem).pipe( + Schema.check(Schema.isMaxLength(RECEIPT_PAGE_SIZE)), + ), + nextCursor: Schema.optional(ReceiptCursor), +}).annotate({ + identifier: "ReceiptApprovalQueueResponse", + description: "One bounded page of receipts in the current approver queue.", +}); + +export const ReceiptSettlementQueueItem = Schema.Struct({ + receiptId: Schema.String, + visualId: Schema.String, + ownerPersonId: Schema.String, + departmentId: Schema.String, + amountOre: Schema.Int, + currency: Schema.Literal("NOK"), + description: Schema.String, + receiptDate: Schema.String, + status: Schema.Literal("Approved"), + approvedAt: Schema.String, + revision: Schema.Int, + etag: StrongETag, +}).annotate({ + identifier: "ReceiptSettlementQueueItem", + description: "One approved, unsettled receipt visible in the current settlement scope.", +}); + +export type ReceiptSettlementQueueItem = typeof ReceiptSettlementQueueItem.Type; + +export const ReceiptSettlementQueueResponse = Schema.Struct({ + items: Schema.Array(ReceiptSettlementQueueItem).pipe( + Schema.check(Schema.isMaxLength(RECEIPT_PAGE_SIZE)), + ), + nextCursor: Schema.optional(ReceiptCursor), +}).annotate({ + identifier: "ReceiptSettlementQueueResponse", + description: "One bounded page of approved, unsettled receipts in the current settlement scope.", +}); + +/** Internal receipt file, outbox, and audit lifecycle evidence. */ +export const ReceiptLifecycleEvidenceResponse = Schema.Struct({ + receiptId: Schema.String, + file: Schema.Struct({ + fileRef: Schema.String, + objectKey: Schema.String, + contentType: Schema.String, + byteLength: Schema.Int, + sha256: Schema.String, + }), + settlement: Schema.NullOr(ReceiptSettlementEvidenceSchema), + outbox: Schema.Array( + Schema.Struct({ + effectId: Schema.String, + effectType: Schema.String, + commandId: Schema.String, + receiptId: Schema.String, + ordinal: Schema.Int, + status: Schema.String, + attempts: Schema.Int, + lastFailureTag: Schema.NullOr(Schema.String), + }), + ), + audit: Schema.Array( + Schema.Struct({ + commandId: Schema.String, + receiptId: Schema.String, + action: Schema.String, + receiptRevision: Schema.Int, + }), + ), +}).annotate({ + identifier: "ReceiptLifecycleEvidenceResponse", + description: "E2E-only receipt file, outbox, and audit evidence.", +}); + +/** + * The payload of a command on one existing receipt whose HTTP body was the exact empty object. + * Its request digest still covers that empty body, so a replay across the cutover matches. + */ +export const ReceiptTransitionPayload = Schema.Struct({ + receiptId: ReceiptId, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, +}).annotate({ identifier: "ReceiptTransitionPayload" }); + +const ReceiptById = Schema.Struct({ receiptId: ReceiptId }); + +const receiptListQuery = { + cursor: Schema.optional(ReceiptCursor), + status: Schema.optional(ReceiptStatusSchema), +}; + +const transitionProblems = [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "precondition.failed", + "internal.error", + "dependency.unavailable", + "receipts.unavailable", + "idempotency.unavailable", + "transaction.conflict", + "receipt.not-found", + "receipt.invalid-transition", +] as const; + +const readProblems = [ + "credential.missing", + "credential.invalid", + "authority.denied", + "internal.error", + "receipts.unavailable", +] as const; + +export const SubmitReceiptProblem = problemUnion("SubmitReceiptProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "validation.failed", + "internal.error", + "dependency.unavailable", + "receipts.unavailable", + "idempotency.unavailable", + "transaction.conflict", + "receipt.already-exists", + "receipt.file-not-staged", +]); + +export const ReviseReceiptProblem = problemUnion("ReviseReceiptProblem", [ + ...transitionProblems, + "validation.failed", + "receipt.file-not-staged", +]); + +export const ReceiptTransitionProblem = problemUnion("ReceiptTransitionProblem", [ + ...transitionProblems, +]); + +export const SettleReceiptProblem = problemUnion("SettleReceiptProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "validation.failed", + "settlement.after-recorded-at", + "precondition.failed", + "receipt.already-settled", + "receipt.invalid-transition", + "settlement.external-reference-conflict", + "receipt.not-found", + "internal.error", + "dependency.unavailable", + "receipts.unavailable", + "idempotency.unavailable", + "transaction.conflict", +]); + +export const ListReceiptsProblem = problemUnion("ListReceiptsProblem", [...readProblems]); + +export const ReadReceiptSettlementProblem = problemUnion("ReadReceiptSettlementProblem", [ + ...readProblems, + "receipt.not-found", +]); + +export const ReadReceiptFileProblem = problemUnion("ReadReceiptFileProblem", [ + ...readProblems, + "resource.not-found", +]); + +export const ReadReceiptEvidenceProblem = problemUnion("ReadReceiptEvidenceProblem", [ + ...readProblems, + "receipt.not-found", +]); + +/** Stages a receipt file and submits the receipt, or replays it by its idempotency key. */ +export const SubmitReceipt = Rpc.make("receipts.submitReceipt", { + payload: Schema.Struct({ + idempotencyKey: IdempotencyKey, + departmentId: Schema.optional(Schema.String), + request: SubmitReceiptRequest, + }), + success: ReceiptResource, + error: rpcProblems(SubmitReceiptProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "submitReceipt", + canonicalScopeResolver: "receipts.create", + decisionTime: "Transaction", + }), + ), + ); + +/** Revises a pending owned receipt, and optionally replaces its file. */ +export const ReviseReceipt = Rpc.make("receipts.reviseReceipt", { + payload: Schema.Struct({ + receiptId: ReceiptId, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: ReviseReceiptRequest, + }), + success: ReceiptResource, + error: rpcProblems(ReviseReceiptProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "receipts.manage-owned", + canonicalScopeResolver: "receipts.by-id", + requirements: ["receipts.owner", "receipts.pending"], + decisionTime: "Transaction", + }), + ), + ); + +/** Withdraws a pending receipt that the caller owns. */ +export const WithdrawReceipt = Rpc.make("receipts.withdrawReceipt", { + payload: ReceiptTransitionPayload, + success: ReceiptResource, + error: rpcProblems(ReceiptTransitionProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "receipts.manage-owned", + canonicalScopeResolver: "receipts.by-id", + requirements: ["receipts.owner", "receipts.pending"], + decisionTime: "Transaction", + }), + ), + ); + +/** Lists the receipts that the caller owns. */ +export const ListReceipts = Rpc.make("receipts.listReceipts", { + payload: Schema.Struct(receiptListQuery), + success: ReceiptListResponse, + error: rpcProblems(ListReceiptsProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "receipts.read-owned", + canonicalScopeResolver: "receipts.owned", + requirements: ["receipts.owner"], + decisionTime: "SnapshotRead", + }), + ), + ); + +/** The verified private bytes of a receipt file, for its canonical owner only. */ +export const ReadReceiptFile = Rpc.make("receipts.readReceiptFile", { + payload: ReceiptById, + success: ReceiptFileContent, + error: rpcProblems(ReadReceiptFileProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "receipts.read-owned", + canonicalScopeResolver: "receipts.by-id", + requirements: ["receipts.owner"], + decisionTime: "SnapshotRead", + }), + ), + ); + +/** + * The verified private bytes of a receipt file, for the current scoped approver. A terminal + * receipt's file stays readable while the rule-aware approver relationship stays active. + */ +export const ReadReceiptFileForApproval = Rpc.make("receipts.readReceiptFileForApproval", { + payload: ReceiptById, + success: ReceiptFileContent, + error: rpcProblems(ReadReceiptFileProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "approveReceipt", + canonicalScopeResolver: "receipts.by-id", + requirements: ["receipts.approver-relationship"], + decisionTime: "SnapshotRead", + }), + ), + ); + +/** Lists the receipts in the caller's approval scope. */ +export const ListReceiptsForApproval = Rpc.make("receipts.listReceiptsForApproval", { + payload: Schema.Struct(receiptListQuery), + success: ReceiptApprovalQueueResponse, + error: rpcProblems(ListReceiptsProblem), +}) + .middleware(PersonOrServiceCredential) + .pipe(withAccessSpec(RECEIPT_APPROVAL_QUEUE_ACCESS)); + +/** Lists the approved, unsettled receipts visible to the caller's settlement grant. */ +export const ListReceiptsForSettlement = Rpc.make("receipts.listReceiptsForSettlement", { + payload: Schema.Struct({ cursor: Schema.optional(ReceiptCursor) }), + success: ReceiptSettlementQueueResponse, + error: rpcProblems(ListReceiptsProblem), +}) + .middleware(PersonCredential) + .pipe(withAccessSpec(settlementAccess("receipts.settlement-queue", "SnapshotRead"))); + +/** Reads the immutable settlement evidence visible to the caller's settlement grant. */ +export const ReadReceiptSettlementForFinance = Rpc.make( + "receipts.readReceiptSettlementForFinance", + { + payload: ReceiptById, + success: ReceiptSettlementEvidenceResource, + error: rpcProblems(ReadReceiptSettlementProblem), + }, +) + .middleware(PersonCredential) + .pipe(withAccessSpec(settlementAccess("receipts.by-id", "SnapshotRead"))); + +/** Records immutable evidence of an externally settled approved receipt. */ +export const SettleReceipt = Rpc.make("receipts.settleReceipt", { + payload: Schema.Struct({ + receiptId: ReceiptId, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: RecordReceiptSettlementRequest, + }), + success: ReceiptSettlementEvidenceResource, + error: rpcProblems(SettleReceiptProblem), +}) + .middleware(PersonCredential) + .pipe(withAccessSpec(settlementAccess("receipts.by-id", "Transaction"))); + +/** Approves a pending receipt. */ +export const ApproveReceipt = Rpc.make("receipts.approveReceipt", { + payload: ReceiptTransitionPayload, + success: ReceiptResource, + error: rpcProblems(ReceiptTransitionProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "approveReceipt", + canonicalScopeResolver: "receipts.by-id", + requirements: ["receipts.pending", "receipts.approver-relationship"], + decisionTime: "Transaction", + }), + ), + ); + +/** Rejects a pending receipt. */ +export const RejectReceipt = Rpc.make("receipts.rejectReceipt", { + payload: ReceiptTransitionPayload, + success: ReceiptResource, + error: rpcProblems(ReceiptTransitionProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "approveReceipt", + canonicalScopeResolver: "receipts.by-id", + requirements: ["receipts.pending", "receipts.approver-relationship"], + decisionTime: "Transaction", + }), + ), + ); + +/** Reopens a rejected receipt for the owner's correction. */ +export const ReopenReceipt = Rpc.make("receipts.reopenReceipt", { + payload: ReceiptTransitionPayload, + success: ReceiptResource, + error: rpcProblems(ReceiptTransitionProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "approveReceipt", + canonicalScopeResolver: "receipts.by-id", + requirements: ["receipts.rejected", "receipts.approver-relationship"], + decisionTime: "Transaction", + }), + ), + ); + +/** Reads E2E-only receipt lifecycle evidence. Only the internal ingress serves it. */ +export const ReadReceiptEvidence = Rpc.make("receipts.readReceiptEvidence", { + payload: ReceiptById, + success: ReceiptLifecycleEvidenceResponse, + error: rpcProblems(ReadReceiptEvidenceProblem), +}) + .middleware(SessionCredential) + .pipe(withAccessSpec(INTERNAL_RECEIPT_EVIDENCE_ACCESS)); + +export class ReceiptsRpcs extends RpcGroup.make( + ReadReceiptFile, + ReadReceiptFileForApproval, + SubmitReceipt, + ReviseReceipt, + WithdrawReceipt, + ListReceipts, + ListReceiptsForApproval, + ApproveReceipt, + ListReceiptsForSettlement, + ReadReceiptSettlementForFinance, + SettleReceipt, + RejectReceipt, + ReopenReceipt, +) {} + +/** Internal receipt operations, served only on the internal ingress. */ +export class InternalReceiptsRpcs extends RpcGroup.make(ReadReceiptEvidence) {} diff --git a/packages/rpc/src/recruitment.ts b/packages/rpc/src/recruitment.ts new file mode 100644 index 000000000..65fa24c31 --- /dev/null +++ b/packages/rpc/src/recruitment.ts @@ -0,0 +1,666 @@ +/** + * Recruitment: applicant assignment, interview scheduling and conduct, invitation responses, and + * maintenance of questionnaires and interview staffing. + * + * @since 0.3.0 + */ +import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; +import { + CancelInterviewObservationSchema, + FinalizeInterviewObservationSchema, + InterviewRecommendationSchema, + InterviewReport, + InterviewReportQuery, + InterviewSchemaId, + InterviewStaffingManagement, + QuestionnaireManagement, + RecruitmentAssignmentBoardQuerySchema, + RecruitmentAssignmentBoardSchema, + RecruitmentInterviewConductObservationSchema, + RecruitmentInterviewId, + RecruitmentInterviewQuestionKindSchema, + RecruitmentInterviewQuestionSnapshot, + RecruitmentInterviewerOptionSchema, + RecruitmentInvitationCapabilitySchema, + RecruitmentInvitationRejectInputSchema, + RecruitmentInvitationRequestNewTimeInputSchema, + RecruitmentInvitationResponseMessageSchema, + RecruitmentInvitationResponseObservationSchema, + RecruitmentMaintenanceCommand, + RecruitmentMaintenanceResult, + RecruitmentSchedulingBoardSchema, + RecruitmentSchedulingInterviewSchema, + type RecruitmentAssignmentBoard, + type RecruitmentAssignmentBoardQuery, + type RecruitmentInterviewConductObservation, + type RecruitmentSchedulingInterview, +} from "@vektorprogrammet/domain/recruitment"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { invitationNativeAccess, personNativeAccess, withAccessSpec } from "./access.js"; +import { InvitationCapabilityCredential, PersonCredential } from "./credential.js"; +import { IdempotencyKey, problemUnion, rpcProblems, StrongETag } from "./problem.js"; +import { + CancelInterviewResponse, + CorrectInterviewAssessmentRequest, + CorrectInterviewAssessmentResponse, + CreateApplicationInterviewRequest, + FinalizeInterviewRequest, + FinalizeInterviewResponse, + RecruitmentInterviewResource, + ScheduleInterviewRequest, + ScheduleInterviewResponse, +} from "./v2-schemas.js"; + +export { + CancelInterviewObservationSchema, + FinalizeInterviewObservationSchema, + InterviewRecommendationSchema, + InterviewReport, + InterviewReportQuery, + InterviewSchemaId, + InterviewStaffingManagement, + QuestionnaireManagement, + RecruitmentAssignmentBoardQuerySchema, + RecruitmentAssignmentBoardSchema, + RecruitmentInterviewConductObservationSchema, + RecruitmentInterviewId, + RecruitmentInterviewQuestionKindSchema, + RecruitmentInterviewQuestionSnapshot, + RecruitmentInterviewerOptionSchema, + RecruitmentInvitationCapabilitySchema, + RecruitmentInvitationRejectInputSchema, + RecruitmentInvitationRequestNewTimeInputSchema, + RecruitmentInvitationResponseMessageSchema, + RecruitmentInvitationResponseObservationSchema, + RecruitmentMaintenanceCommand, + RecruitmentMaintenanceResult, + RecruitmentSchedulingInterviewSchema, +}; + +export type { + RecruitmentAssignmentBoard, + RecruitmentAssignmentBoardQuery, + RecruitmentInterviewConductObservation, + RecruitmentSchedulingInterview, +}; + +/** Interview invitation state visible to the invitee. */ +export const InvitationResponseObservation = + RecruitmentInvitationResponseObservationSchema.annotate({ + identifier: "InvitationResponseObservation", + }); + +/** + * The invitation beside its strong entity tag, which a response takes as `ifMatch`. A response to + * a pending invitation must name the current tag. + */ +export const InvitationResponseResource = Schema.Struct({ + observation: InvitationResponseObservation, + etag: StrongETag, +}).annotate({ identifier: "InvitationResponseResource" }); + +export type InvitationResponseResource = typeof InvitationResponseResource.Type; + +/** The entity tag of the invitation after a response. */ +export const InvitationResponseReceipt = Schema.Struct({ etag: StrongETag }).annotate({ + identifier: "InvitationResponseReceipt", +}); + +/** + * The response capability of an invitation. It travels in the payload, and the handler decodes it: + * a malformed or empty capability names no invitation, and answers resource.not-found like an + * unknown one. + */ +export const InvitationCapabilityPayload = Schema.String.annotate({ + identifier: "InvitationCapabilityPayload", +}); + +/** Candidates and interviewers in the departments where the caller manages interviews. */ +export const AssignmentBoard = RecruitmentAssignmentBoardSchema.annotate({ + identifier: "AssignmentBoard", +}); + +/** One interview with the strong entity tag that `recruitment.scheduleInterview` takes. */ +export const SchedulingInterview = RecruitmentSchedulingInterviewSchema.mapMembers((members) => [ + members[0].mapFields((fields) => ({ ...fields, etag: StrongETag })), + members[1].mapFields((fields) => ({ ...fields, etag: StrongETag })), + members[2].mapFields((fields) => ({ ...fields, etag: StrongETag })), + members[3].mapFields((fields) => ({ ...fields, etag: StrongETag })), +]).annotate({ identifier: "SchedulingInterview" }); + +/** Interviews visible to the current member. */ +export const SchedulingBoard = Schema.Struct({ + departmentId: RecruitmentSchedulingBoardSchema.fields.departmentId, + interviews: Schema.Array(SchedulingInterview), +}).annotate({ identifier: "SchedulingBoard" }); + +export type SchedulingBoard = typeof SchedulingBoard.Type; + +/** Interview questions, answers, and conduct state. */ +export const ConductObservation = RecruitmentInterviewConductObservationSchema.annotate({ + identifier: "ConductObservation", +}); + +/** + * The conduct of one interview beside the strong entity tag that finalization, correction, and + * cancellation take as `ifMatch`. + */ +export const InterviewConductResource = Schema.Struct({ + detail: ConductObservation, + etag: StrongETag, +}).annotate({ identifier: "InterviewConductResource" }); + +export type InterviewConductResource = typeof InterviewConductResource.Type; + +/** The assigned interview beside the entity tag that `recruitment.scheduleInterview` takes. */ +export const ApplicationInterviewResource = Schema.Struct({ + interview: RecruitmentInterviewResource, + etag: StrongETag, +}).annotate({ identifier: "ApplicationInterviewResource" }); + +export type ApplicationInterviewResource = typeof ApplicationInterviewResource.Type; + +/** A committed schedule beside the interview's new entity tag. */ +export const ScheduleInterviewResult = Schema.Struct({ + result: ScheduleInterviewResponse, + etag: StrongETag, +}).annotate({ identifier: "ScheduleInterviewResult" }); + +export type ScheduleInterviewResult = typeof ScheduleInterviewResult.Type; + +/** A committed finalization beside the interview's new entity tag. */ +export const FinalizeInterviewResult = Schema.Struct({ + result: FinalizeInterviewResponse, + etag: StrongETag, +}).annotate({ identifier: "FinalizeInterviewResult" }); + +export type FinalizeInterviewResult = typeof FinalizeInterviewResult.Type; + +/** A committed correction beside the interview's new entity tag. */ +export const CorrectInterviewAssessmentResult = Schema.Struct({ + result: CorrectInterviewAssessmentResponse, + etag: StrongETag, +}).annotate({ identifier: "CorrectInterviewAssessmentResult" }); + +export type CorrectInterviewAssessmentResult = typeof CorrectInterviewAssessmentResult.Type; + +/** A committed cancellation beside the interview's new entity tag. */ +export const CancelInterviewResult = Schema.Struct({ + result: CancelInterviewResponse, + etag: StrongETag, +}).annotate({ identifier: "CancelInterviewResult" }); + +export type CancelInterviewResult = typeof CancelInterviewResult.Type; + +/** Problems of `recruitment.readInvitationResponse`. */ +export const ReadInvitationResponseProblem = problemUnion("ReadInvitationResponseProblem", [ + "resource.not-found", + "internal.error", + "recruitment.unavailable", +]); + +const invitationResponseCodes = [ + "authority.denied", + "resource.not-found", + "precondition.failed", + "internal.error", + "dependency.unavailable", + "invitation.already-responded", +] as const; + +/** Problems of `recruitment.confirmInvitation`. */ +export const ConfirmInvitationProblem = problemUnion( + "ConfirmInvitationProblem", + invitationResponseCodes, +); + +/** Problems of `recruitment.rejectInvitation`. */ +export const RejectInvitationProblem = problemUnion( + "RejectInvitationProblem", + invitationResponseCodes, +); + +/** Problems of `recruitment.requestNewInvitationTime`. */ +export const RequestNewInvitationTimeProblem = problemUnion( + "RequestNewInvitationTimeProblem", + invitationResponseCodes, +); + +/** Problems of `recruitment.readAssignmentBoard`. */ +export const ReadAssignmentBoardProblem = problemUnion("ReadAssignmentBoardProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "internal.error", + "recruitment.admission-period-not-found", + "application.ambiguous-period", + "recruitment.unavailable", +]); + +/** Problems of `recruitment.readSchedulingBoard`. */ +export const ReadSchedulingBoardProblem = problemUnion("ReadSchedulingBoardProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "internal.error", + "recruitment.unavailable", +]); + +/** Problems of `recruitment.readInterviewReport`. */ +export const ReadInterviewReportProblem = problemUnion("ReadInterviewReportProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "transaction.conflict", + "internal.error", + "recruitment.unavailable", +]); + +const interviewCommandCodes = [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "internal.error", + "dependency.unavailable", + "idempotency.unavailable", +] as const; + +/** Problems of `recruitment.createApplicationInterview`. */ +export const CreateApplicationInterviewProblem = problemUnion("CreateApplicationInterviewProblem", [ + ...interviewCommandCodes, + "recruitment.admission-period-not-found", + "recruitment.application-not-found", + "application.ambiguous-period", + "recruitment.interview-schema-not-found", + "recruitment.application-already-assigned", + "recruitment.interview-schema-inactive", +]); + +/** Problems of `recruitment.scheduleInterview`. */ +export const ScheduleInterviewProblem = problemUnion("ScheduleInterviewProblem", [ + ...interviewCommandCodes, + "precondition.failed", + "recruitment.interview-not-found", + "recruitment.already-scheduled", + "recruitment.schedule-in-past", +]); + +/** Problems of `recruitment.readInterviewConduct`. */ +export const ReadInterviewConductProblem = problemUnion("ReadInterviewConductProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "internal.error", + "recruitment.interview-not-found", + "recruitment.interview-not-scheduled", + "recruitment.invitation-not-accepted", + "recruitment.unavailable", +]); + +const conductCommandCodes = [ + ...interviewCommandCodes, + "precondition.failed", + "recruitment.interview-not-found", + "recruitment.already-finalized", + "recruitment.already-cancelled", + "recruitment.interview-not-scheduled", +] as const; + +/** Problems of `recruitment.finalizeInterview`. */ +export const FinalizeInterviewProblem = problemUnion("FinalizeInterviewProblem", [ + ...conductCommandCodes, + "recruitment.invitation-not-accepted", + "recruitment.conduct-invalid", +]); + +/** Problems of `recruitment.correctInterviewAssessment`. */ +export const CorrectInterviewAssessmentProblem = problemUnion("CorrectInterviewAssessmentProblem", [ + ...conductCommandCodes, + "recruitment.invitation-not-accepted", + "recruitment.conduct-invalid", +]); + +/** Problems of `recruitment.cancelInterview`. */ +export const CancelInterviewProblem = problemUnion("CancelInterviewProblem", conductCommandCodes); + +/** Problems of the maintenance reads and command, which share one vocabulary. */ +export const RecruitmentMaintenanceProblem = problemUnion("RecruitmentMaintenanceProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "resource.not-found", + "precondition.failed", + "recruitment.invalid-command", + "recruitment.ineligible", + "recruitment.empty-active-questionnaire", + "recruitment.terminal", + "recruitment.unavailable", + "idempotency.unavailable", + "internal.error", +]); + +/** Reads an interview invitation by its response capability. */ +export const ReadInvitationResponse = Rpc.make("recruitment.readInvitationResponse", { + payload: Schema.Struct({ capability: InvitationCapabilityPayload }), + success: InvitationResponseResource, + error: rpcProblems(ReadInvitationResponseProblem), +}) + .middleware(InvitationCapabilityCredential) + .pipe(withAccessSpec(invitationNativeAccess([], "SnapshotRead"))); + +/** Accepts an interview invitation by its response capability. */ +export const ConfirmInvitation = Rpc.make("recruitment.confirmInvitation", { + payload: Schema.Struct({ capability: InvitationCapabilityPayload, ifMatch: StrongETag }), + success: InvitationResponseReceipt, + error: rpcProblems(ConfirmInvitationProblem), +}) + .middleware(InvitationCapabilityCredential) + .pipe(withAccessSpec(invitationNativeAccess([], "Transaction"))); + +/** Rejects an interview invitation by its response capability, with an optional message. */ +export const RejectInvitation = Rpc.make("recruitment.rejectInvitation", { + payload: Schema.Struct({ + capability: InvitationCapabilityPayload, + ifMatch: StrongETag, + request: RecruitmentInvitationRejectInputSchema, + }), + success: InvitationResponseReceipt, + error: rpcProblems(RejectInvitationProblem), +}) + .middleware(InvitationCapabilityCredential) + .pipe(withAccessSpec(invitationNativeAccess([], "Transaction"))); + +/** Asks for another interview time by the invitation's response capability. */ +export const RequestNewInvitationTime = Rpc.make("recruitment.requestNewInvitationTime", { + payload: Schema.Struct({ + capability: InvitationCapabilityPayload, + ifMatch: StrongETag, + request: RecruitmentInvitationRequestNewTimeInputSchema, + }), + success: InvitationResponseReceipt, + error: rpcProblems(RequestNewInvitationTimeProblem), +}) + .middleware(InvitationCapabilityCredential) + .pipe(withAccessSpec(invitationNativeAccess([], "Transaction"))); + +/** Applicants and interviewers where the caller manages interviews. */ +export const ReadAssignmentBoard = Rpc.make("recruitment.readAssignmentBoard", { + payload: RecruitmentAssignmentBoardQuerySchema, + success: AssignmentBoard, + error: rpcProblems(ReadAssignmentBoardProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "reviewApplicants", + canonicalScopeResolver: "recruitment.application-assignments", + requirements: ["organization.single-department-administrator"], + decisionTime: "SnapshotRead", + }), + ), + ); + +/** + * Limited completed interview observations for an explicitly selected department admission + * period; proven self assessments are excluded. Raw conduct access remains separate. + */ +export const ReadInterviewReport = Rpc.make("recruitment.readInterviewReport", { + payload: InterviewReportQuery, + success: InterviewReport, + error: rpcProblems(ReadInterviewReportProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "recruitment.read-interview-report", + canonicalScopeResolver: "recruitment.interview-report", + requirements: ["organization.single-department-administrator"], + decisionTime: "SnapshotRead", + }), + ), + ); + +/** Interviews visible to the current member, each with the tag that scheduling names. */ +export const ReadSchedulingBoard = Rpc.make("recruitment.readSchedulingBoard", { + success: SchedulingBoard, + error: rpcProblems(ReadSchedulingBoardProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "recruitment.read-interviews", + canonicalScopeResolver: "recruitment.interviews", + requirements: ["organization.single-department-member"], + decisionTime: "SnapshotRead", + }), + ), + ); + +/** Assigns, or replays by its idempotency key, an applicant to an eligible interviewer. */ +export const CreateApplicationInterview = Rpc.make("recruitment.createApplicationInterview", { + payload: Schema.Struct({ + applicationId: PublicApplicationIdSchema, + idempotencyKey: IdempotencyKey, + request: CreateApplicationInterviewRequest, + }), + success: ApplicationInterviewResource, + error: rpcProblems(CreateApplicationInterviewProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "reviewApplicants", + canonicalScopeResolver: "recruitment.application-by-id", + requirements: [ + "organization.single-department-administrator", + "recruitment.interviewer-eligible", + ], + decisionTime: "Transaction", + }), + ), + ); + +/** Schedules, or replays by its idempotency key, an assigned interview and its invitation. */ +export const ScheduleInterview = Rpc.make("recruitment.scheduleInterview", { + payload: Schema.Struct({ + interviewId: RecruitmentInterviewId, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: ScheduleInterviewRequest, + }), + success: ScheduleInterviewResult, + error: rpcProblems(ScheduleInterviewProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "recruitment.schedule-interview", + canonicalScopeResolver: "recruitment.interview-by-id", + requirements: ["recruitment.assigned-interviewer-or-administrator"], + decisionTime: "Transaction", + }), + ), + ); + +/** The questions and conduct state of one interview, with the tag that its commands take. */ +export const ReadInterviewConduct = Rpc.make("recruitment.readInterviewConduct", { + payload: Schema.Struct({ interviewId: RecruitmentInterviewId }), + success: InterviewConductResource, + error: rpcProblems(ReadInterviewConductProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "recruitment.conduct-interview", + canonicalScopeResolver: "recruitment.interview-by-id", + requirements: [ + "recruitment.assigned-interviewer-or-co-interviewer", + "recruitment.not-known-self", + ], + decisionTime: "SnapshotRead", + }), + ), + ); + +/** Records, or replays by its idempotency key, the answers and scores of an interview. */ +export const FinalizeInterview = Rpc.make("recruitment.finalizeInterview", { + payload: Schema.Struct({ + interviewId: RecruitmentInterviewId, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: FinalizeInterviewRequest, + }), + success: FinalizeInterviewResult, + error: rpcProblems(FinalizeInterviewProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "recruitment.conduct-interview", + canonicalScopeResolver: "recruitment.interview-by-id", + requirements: ["recruitment.assigned-interviewer", "recruitment.not-known-self"], + decisionTime: "Transaction", + }), + ), + ); + +/** Appends, or replays by its idempotency key, a replacement assessment of a completed interview. */ +export const CorrectInterviewAssessment = Rpc.make("recruitment.correctInterviewAssessment", { + payload: Schema.Struct({ + interviewId: RecruitmentInterviewId, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: CorrectInterviewAssessmentRequest, + }), + success: CorrectInterviewAssessmentResult, + error: rpcProblems(CorrectInterviewAssessmentProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "recruitment.conduct-interview", + canonicalScopeResolver: "recruitment.interview-by-id", + requirements: [ + "recruitment.assigned-interviewer-or-co-interviewer", + "recruitment.not-known-self", + ], + decisionTime: "Transaction", + }), + ), + ); + +/** Cancels, or replays by its idempotency key, an interview before finalization. */ +export const CancelInterview = Rpc.make("recruitment.cancelInterview", { + payload: Schema.Struct({ + interviewId: RecruitmentInterviewId, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + }), + success: CancelInterviewResult, + error: rpcProblems(CancelInterviewProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "recruitment.conduct-interview", + canonicalScopeResolver: "recruitment.interview-by-id", + requirements: ["recruitment.assigned-interviewer", "recruitment.not-known-self"], + decisionTime: "Transaction", + }), + ), + ); + +/** + * Global questionnaire definitions and immutable maintenance history, for a current global + * administrator. + */ +export const ReadQuestionnaires = Rpc.make("recruitment.readQuestionnaires", { + success: QuestionnaireManagement, + error: rpcProblems(RecruitmentMaintenanceProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "recruitment.maintain", + canonicalScopeResolver: "recruitment.maintenance", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** Interviews, eligible candidates, and staffing history in the current coordinator scope. */ +export const ReadInterviewStaffing = Rpc.make("recruitment.readInterviewStaffing", { + success: InterviewStaffingManagement, + error: rpcProblems(RecruitmentMaintenanceProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "recruitment.maintain", + canonicalScopeResolver: "recruitment.maintenance", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** + * Creates or revises a questionnaire, or replaces a nonterminal interview staffing pair with an + * observed revision and reason. The command's `commandId` must equal its idempotency key. + */ +export const MaintainRecruitment = Rpc.make("recruitment.maintainRecruitment", { + payload: Schema.Struct({ + idempotencyKey: IdempotencyKey, + request: RecruitmentMaintenanceCommand, + }), + success: RecruitmentMaintenanceResult, + error: rpcProblems(RecruitmentMaintenanceProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "recruitment.maintain", + canonicalScopeResolver: "recruitment.maintenance", + decisionTime: "Transaction", + }), + ), + ); + +export class RecruitmentRpcs extends RpcGroup.make( + ReadInvitationResponse, + ConfirmInvitation, + RejectInvitation, + RequestNewInvitationTime, + ReadAssignmentBoard, + ReadSchedulingBoard, + ReadInterviewReport, + CreateApplicationInterview, + ScheduleInterview, + ReadInterviewConduct, + FinalizeInterview, + CorrectInterviewAssessment, + CancelInterview, + ReadQuestionnaires, + ReadInterviewStaffing, + MaintainRecruitment, +) {} diff --git a/packages/rpc/src/script-client.ts b/packages/rpc/src/script-client.ts new file mode 100644 index 000000000..73e02b4ff --- /dev/null +++ b/packages/rpc/src/script-client.ts @@ -0,0 +1,51 @@ +/** + * A promise client for journey drivers, acceptance probes, and other scripts that call the native + * backend as one person at a time. + * + * @since 0.3.0 + */ +import { Cause, type Effect, Exit, ManagedRuntime } from "effect"; +import { NativeRpcClient, nativeRpcClientLayer, withForwardedHeaders } from "./client.js"; +import { isProblem, problemBody } from "./problem.js"; + +/** The answer of one RPC call, with the HTTP status its outcome had under the HTTP contract. */ +export type ScriptCallResult
= + | { readonly ok: true; readonly status: 200; readonly value: A } + | { + readonly ok: false; + readonly status: number; + readonly code: string; + readonly problem: ReturnType; + } + | { readonly ok: false; readonly status: 500; readonly code: "defect"; readonly defect: string }; + +/** + * The script client of the backend at `origin`. + * + * @remarks + * `call` sends one RPC with the headers given, such as a person's `cookie` and the dashboard + * `origin`, and never rejects for an RPC failure: a declared problem answers its code and the + * registry status of that code, and a defect or transport failure answers `defect`. `dispose` + * releases the client. + */ +export const nativeScriptClient = (origin: string) => { + const runtime = ManagedRuntime.make(nativeRpcClientLayer(origin)); + + const call = ( + headers: Readonly>, + rpc: (client: NativeRpcClient["Service"]) => Effect.Effect, + ): Promise> => + runtime + .runPromiseExit(NativeRpcClient.use(rpc).pipe(withForwardedHeaders(headers))) + .then((exit): ScriptCallResult => { + if (Exit.isSuccess(exit)) return { ok: true, status: 200, value: exit.value }; + + const failure = Cause.squash(exit.cause); + + return isProblem(failure) + ? { ok: false, status: failure.status, code: failure.code, problem: problemBody(failure) } + : { ok: false, status: 500, code: "defect", defect: String(failure) }; + }); + + return { call, dispose: () => runtime.dispose() }; +}; diff --git a/packages/rpc/src/social-events.ts b/packages/rpc/src/social-events.ts new file mode 100644 index 000000000..1a50732b2 --- /dev/null +++ b/packages/rpc/src/social-events.ts @@ -0,0 +1,122 @@ +/** + * Social events: department and semester scoped team social events. + * + * @since 0.3.0 + */ +import { + CreateSocialEventRequest, + SocialEventAudience, + SocialEventId, + SocialEventListResource, + SocialEventObservedAt, + SocialEventResource, + SocialEventScope, + SocialEventScopeResource, +} from "@vektorprogrammet/domain"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { personNativeAccess, withAccessSpec } from "./access.js"; +import { PersonCredential } from "./credential.js"; +import { IdempotencyKey, problemUnion, rpcProblems } from "./problem.js"; + +export { + CreateSocialEventRequest, + SocialEventAudience, + SocialEventId, + SocialEventListResource, + SocialEventObservedAt, + SocialEventResource, + SocialEventScope, + SocialEventScopeResource, +}; + +export const SocialEventsReadScopeProblem = problemUnion("SocialEventsReadScopeProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "internal.error", + "dependency.unavailable", + "organization.unavailable", +]); + +export const SocialEventsListProblem = problemUnion("SocialEventsListProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "scope.invalid", + "internal.error", + "dependency.unavailable", + "organization.unavailable", +]); + +export const SocialEventsCreateProblem = problemUnion("SocialEventsCreateProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "validation.failed", + "scope.invalid", + "internal.error", + "dependency.unavailable", + "organization.unavailable", + "idempotency.unavailable", + "transaction.conflict", +]); + +/** The caller's authorized departments and canonical semesters. */ +export const ReadSocialEventScope = Rpc.make("social-events.readScope", { + success: SocialEventScopeResource, + error: rpcProblems(SocialEventsReadScopeProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "social-events.read-scope", + canonicalScopeResolver: "social-events.scope", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** The social events of one authorized department and semester. */ +export const ListSocialEvents = Rpc.make("social-events.list", { + payload: SocialEventScope, + success: SocialEventListResource, + error: rpcProblems(SocialEventsListProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "social-events.read", + canonicalScopeResolver: "social-events.list", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** Creates, or replays by its idempotency key, one social event. */ +export const CreateSocialEvent = Rpc.make("social-events.create", { + payload: Schema.Struct({ idempotencyKey: IdempotencyKey, request: CreateSocialEventRequest }), + success: SocialEventResource, + error: rpcProblems(SocialEventsCreateProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "social-events.create", + canonicalScopeResolver: "social-events.create", + decisionTime: "Transaction", + }), + ), + ); + +export class SocialEventsRpcs extends RpcGroup.make( + ReadSocialEventScope, + ListSocialEvents, + CreateSocialEvent, +) {} diff --git a/packages/rpc/src/system.ts b/packages/rpc/src/system.ts new file mode 100644 index 000000000..a2492e6b3 --- /dev/null +++ b/packages/rpc/src/system.ts @@ -0,0 +1,196 @@ +/** + * System: the caller's first-party session and session management. + * + * Every RPC here takes the Better Auth session cookie alone (`SessionCredential`). The health + * probe stays plain HTTP at `GET /health`, outside this group. + * + * @since 0.3.0 + */ +import { PersonId } from "@vektorprogrammet/domain/organization"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { browserSessionNativeAccess, withAccessSpec } from "./access.js"; +import { SessionCredential } from "./credential.js"; +import { IdempotencyKey, problemUnion, rpcProblems } from "./problem.js"; + +/** A bounded opaque session identifier. */ +export const SessionId = Schema.String.pipe( + Schema.check( + Schema.makeFilter((value) => value.length > 0 && value.length <= 128, { + message: "a bounded opaque session identifier", + }), + ), +); + +/** Credential-free owner-only session metadata. */ +export const SessionResponse = Schema.Struct({ + sessionId: SessionId, + personId: PersonId, + createdAt: Schema.String, + updatedAt: Schema.String, + expiresAt: Schema.String, + ipAddress: Schema.NullOr(Schema.String), + userAgent: Schema.NullOr(Schema.String), + current: Schema.Boolean, +}).annotate({ + identifier: "SessionResponse", + description: "Safe first-party session metadata without credential material.", +}); + +export type SessionResponse = typeof SessionResponse.Type; + +export const SessionListResponse = Schema.Array(SessionResponse).annotate({ + identifier: "SessionListResponse", + description: "Sessions owned by the authenticated person.", +}); + +export const SessionReadProblem = problemUnion("SessionReadProblem", [ + "credential.missing", + "credential.invalid", + "internal.error", + "identity.unavailable", +]); + +export const SessionListProblem = problemUnion("SessionListProblem", [ + "credential.missing", + "credential.invalid", + "internal.error", + "identity.unavailable", +]); + +/** Every session command but the owned-session delete names only the caller's own session. */ +export const SessionCommandProblem = problemUnion("SessionCommandProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "internal.error", + "dependency.unavailable", + "idempotency.unavailable", +]); + +export const OwnedSessionDeleteProblem = problemUnion("OwnedSessionDeleteProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "resource.not-found", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "internal.error", + "dependency.unavailable", + "idempotency.unavailable", +]); + +/** A replayable session command: its idempotency key, and nothing else. */ +const SessionCommand = Schema.Struct({ idempotencyKey: IdempotencyKey }); + +/** Reads safe metadata for the authoritative current Better Auth session. */ +export const ReadSession = Rpc.make("system.readSession", { + success: SessionResponse, + error: rpcProblems(SessionReadProblem), +}) + .middleware(SessionCredential) + .pipe( + withAccessSpec( + browserSessionNativeAccess({ + canonicalScopeResolver: "identity.current-session", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** Revokes the authoritative current session. */ +export const DeleteSession = Rpc.make("system.deleteSession", { + payload: SessionCommand, + success: Schema.Void, + error: rpcProblems(SessionCommandProblem), +}) + .middleware(SessionCredential) + .pipe( + withAccessSpec( + browserSessionNativeAccess({ + canonicalScopeResolver: "identity.current-session", + decisionTime: "Transaction", + }), + ), + ); + +/** Lists only safe metadata for sessions owned by the authenticated person. */ +export const ListSessions = Rpc.make("system.listSessions", { + success: SessionListResponse, + error: rpcProblems(SessionListProblem), +}) + .middleware(SessionCredential) + .pipe( + withAccessSpec( + browserSessionNativeAccess({ + canonicalScopeResolver: "identity.owned-sessions", + requirements: ["sessions.owner"], + decisionTime: "SnapshotRead", + }), + ), + ); + +/** Revokes one owned session; missing and non-owned identifiers are concealed. */ +export const DeleteOwnedSession = Rpc.make("system.deleteOwnedSession", { + payload: Schema.Struct({ idempotencyKey: IdempotencyKey, sessionId: SessionId }), + success: Schema.Void, + error: rpcProblems(OwnedSessionDeleteProblem), +}) + .middleware(SessionCredential) + .pipe( + withAccessSpec( + browserSessionNativeAccess({ + canonicalScopeResolver: "identity.session-by-id", + requirements: ["sessions.owner"], + concealRequirement: true, + decisionTime: "Transaction", + }), + ), + ); + +/** Revokes every owned session except the authoritative current session. */ +export const RevokeOtherSessions = Rpc.make("system.revokeOtherSessions", { + payload: SessionCommand, + success: Schema.Void, + error: rpcProblems(SessionCommandProblem), +}) + .middleware(SessionCredential) + .pipe( + withAccessSpec( + browserSessionNativeAccess({ + canonicalScopeResolver: "identity.current-session", + decisionTime: "Transaction", + }), + ), + ); + +/** Revokes every owned session, including the authoritative current session. */ +export const RevokeAllSessions = Rpc.make("system.revokeAllSessions", { + payload: SessionCommand, + success: Schema.Void, + error: rpcProblems(SessionCommandProblem), +}) + .middleware(SessionCredential) + .pipe( + withAccessSpec( + browserSessionNativeAccess({ + canonicalScopeResolver: "identity.current-session", + decisionTime: "Transaction", + }), + ), + ); + +export class SystemRpcs extends RpcGroup.make( + ReadSession, + DeleteSession, + ListSessions, + DeleteOwnedSession, + RevokeOtherSessions, + RevokeAllSessions, +) {} diff --git a/packages/rpc/src/team-application.ts b/packages/rpc/src/team-application.ts new file mode 100644 index 000000000..bca425ba5 --- /dev/null +++ b/packages/rpc/src/team-application.ts @@ -0,0 +1,280 @@ +/** + * Team applications: public intake and submission, and staff review of one team's applications. + * + * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is + * `packages/http-api/src/team-application.ts` at the base commit named there. + * + * @since 0.3.0 + */ +import { Team, TeamId } from "@vektorprogrammet/domain/organization"; +import { + PublicTeamApplicationIntake, + TEAM_APPLICATION_INTAKE_LIST_LIMIT, + TEAM_APPLICATION_PAGE_SIZE, + TeamApplication, + TeamApplicationConfirmation, + TeamApplicationCursor, + TeamApplicationId, + TeamApplicationInput, + TeamApplicationIntake, + TeamApplicationIntakeListItem, + TeamApplicationSummary, +} from "@vektorprogrammet/domain/team-application"; +import { Rfc3339InstantSchema } from "@vektorprogrammet/domain/time"; +import { Schema } from "effect"; +import { Rpc, RpcGroup } from "effect/unstable/rpc"; +import { anonymousNativeAccess, personNativeAccess, withAccessSpec } from "./access.js"; +import { PersonCredential } from "./credential.js"; +import { IdempotencyKey, problemUnion, rpcProblems, StrongETag } from "./problem.js"; + +export { + PublicTeamApplicationIntake, + TeamApplicationConfirmation, + TeamApplicationCursor, + TeamApplicationId, + TeamApplicationInput, + TeamApplicationIntakeListItem, + TeamApplicationSummary, +}; + +/** Staff intake settings with the team entity tag that `ifMatch` must repeat. */ +export const TeamApplicationIntakeResource = Schema.Struct({ + ...TeamApplicationIntake.fields, + etag: StrongETag, +}).annotate({ + identifier: "TeamApplicationIntakeResource", + description: "Team intake settings, their open state now, and the team revision entity tag.", +}); + +export const TeamApplicationListResponse = Schema.Struct({ + teamId: TeamId, + teamName: Team.fields.name, + items: Schema.Array(TeamApplicationSummary).pipe( + Schema.check(Schema.isMaxLength(TEAM_APPLICATION_PAGE_SIZE)), + ), + nextCursor: Schema.optional(TeamApplicationCursor), + intake: TeamApplicationIntakeResource, + canManage: Schema.Boolean, +}).annotate({ + identifier: "TeamApplicationListResponse", + description: + "One bounded newest-first page of a team's applications. canManage is a presentation projection.", +}); + +export const TeamApplicationResource = Schema.Struct({ + ...TeamApplication.fields, + teamName: Team.fields.name, + canManage: Schema.Boolean, +}).annotate({ + identifier: "TeamApplicationResource", + description: "One application with its private applicant fields.", +}); + +export const TeamApplicationIntakeListResponse = Schema.Array(TeamApplicationIntakeListItem) + .pipe(Schema.check(Schema.isMaxLength(TEAM_APPLICATION_INTAKE_LIST_LIMIT))) + .annotate({ + identifier: "TeamApplicationIntakeListResponse", + description: `Intake state for at most ${TEAM_APPLICATION_INTAKE_LIST_LIMIT} active teams, ordered by team identifier.`, + }); + +/** Absence keeps a setting; a null deadline clears it; acceptApplication cannot be deleted. */ +export const TeamApplicationIntakeMergePatch = Schema.Struct({ + acceptApplication: Schema.optional(Schema.NullOr(Schema.Boolean)), + deadline: Schema.optional(Schema.NullOr(Rfc3339InstantSchema)), +}).annotate({ identifier: "TeamApplicationIntakeMergePatch" }); + +export type TeamApplicationIntakeMergePatch = typeof TeamApplicationIntakeMergePatch.Type; + +export const TeamApplicationsReadIntakeProblem = problemUnion("TeamApplicationsReadIntakeProblem", [ + "resource.not-found", + "internal.error", +]); + +export const TeamApplicationsListIntakesProblem = problemUnion( + "TeamApplicationsListIntakesProblem", + ["internal.error"], +); + +export const TeamApplicationsSubmitProblem = problemUnion("TeamApplicationsSubmitProblem", [ + "resource.not-found", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "team-application.intake-closed", + "transaction.conflict", + "rate-limit.exceeded", + "internal.error", + "idempotency.unavailable", +]); + +/** A cursor that passes `TeamApplicationCursor` but names no position answers request.malformed. */ +export const TeamApplicationsListProblem = problemUnion("TeamApplicationsListProblem", [ + "credential.missing", + "credential.invalid", + "request.malformed", + "authority.denied", + "resource.not-found", + "internal.error", +]); + +export const TeamApplicationsStaffReadProblem = problemUnion("TeamApplicationsStaffReadProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "resource.not-found", + "internal.error", +]); + +export const TeamApplicationsDeleteProblem = problemUnion("TeamApplicationsDeleteProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "resource.not-found", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "internal.error", + "idempotency.unavailable", +]); + +export const TeamApplicationsReviseIntakeProblem = problemUnion( + "TeamApplicationsReviseIntakeProblem", + [ + "credential.missing", + "credential.invalid", + "authority.denied", + "idempotency.in-flight", + "idempotency.digest-conflict", + "idempotency.response-expired", + "transaction.conflict", + "precondition.failed", + "validation.failed", + "validation.no-change", + "validation.field-not-deletable", + "internal.error", + "idempotency.unavailable", + ], +); + +/** Whether one active team accepts applications now. Unknown and inactive teams are not found. */ +export const ReadTeamApplicationIntake = Rpc.make("team-applications.readTeamApplicationIntake", { + payload: Schema.Struct({ teamId: TeamId }), + success: PublicTeamApplicationIntake, + error: rpcProblems(TeamApplicationsReadIntakeProblem), +}).pipe(withAccessSpec(anonymousNativeAccess("team-applications.public-intake"))); + +/** The intake state of at most `TEAM_APPLICATION_INTAKE_LIST_LIMIT` active teams. */ +export const ListTeamApplicationIntakes = Rpc.make("team-applications.listTeamApplicationIntakes", { + success: TeamApplicationIntakeListResponse, + error: rpcProblems(TeamApplicationsListIntakesProblem), +}).pipe(withAccessSpec(anonymousNativeAccess("team-applications.public-intakes"))); + +/** + * Stores one application to an open team and queues its receipt and team notification. An + * idempotency-key replay returns the original result. Every submission, including a replay, counts + * against one public rate limit. + */ +export const SubmitTeamApplication = Rpc.make("team-applications.submitTeamApplication", { + payload: Schema.Struct({ + teamId: TeamId, + idempotencyKey: IdempotencyKey, + request: TeamApplicationInput, + }), + success: TeamApplicationConfirmation, + error: rpcProblems(TeamApplicationsSubmitProblem), +}).pipe( + withAccessSpec(anonymousNativeAccess("team-applications.application-create", "Transaction")), +); + +/** A bounded page of the team's applications, to a current, nonsuspended member of that team. */ +export const ListTeamApplications = Rpc.make("team-applications.listTeamApplications", { + payload: Schema.Struct({ teamId: TeamId, cursor: Schema.optional(TeamApplicationCursor) }), + success: TeamApplicationListResponse, + error: rpcProblems(TeamApplicationsListProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "team-applications.read", + canonicalScopeResolver: "team-applications.team-applications", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** One application, to a current, nonsuspended member of its team. */ +export const ReadTeamApplication = Rpc.make("team-applications.readTeamApplication", { + payload: Schema.Struct({ applicationId: TeamApplicationId }), + success: TeamApplicationResource, + error: rpcProblems(TeamApplicationsStaffReadProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "team-applications.read", + canonicalScopeResolver: "team-applications.application-by-id", + decisionTime: "SnapshotRead", + }), + ), + ); + +/** + * Removes one application and its undelivered notifications. Only the current team leader can + * delete. + */ +export const DeleteTeamApplication = Rpc.make("team-applications.deleteTeamApplication", { + payload: Schema.Struct({ applicationId: TeamApplicationId, idempotencyKey: IdempotencyKey }), + success: Schema.Void, + error: rpcProblems(TeamApplicationsDeleteProblem), +}) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "team-applications.manage", + canonicalScopeResolver: "team-applications.application-by-id", + decisionTime: "Transaction", + }), + ), + ); + +/** + * Opens or closes intake and sets or clears the deadline (a JSON merge patch). Requires the current + * team leader and the observed team entity tag. + */ +export const ReviseTeamApplicationIntake = Rpc.make( + "team-applications.reviseTeamApplicationIntake", + { + payload: Schema.Struct({ + teamId: TeamId, + idempotencyKey: IdempotencyKey, + ifMatch: StrongETag, + request: TeamApplicationIntakeMergePatch, + }), + success: TeamApplicationIntakeResource, + error: rpcProblems(TeamApplicationsReviseIntakeProblem), + }, +) + .middleware(PersonCredential) + .pipe( + withAccessSpec( + personNativeAccess({ + capability: "team-applications.manage", + canonicalScopeResolver: "team-applications.intake-by-team", + decisionTime: "Transaction", + }), + ), + ); + +export class TeamApplicationsRpcs extends RpcGroup.make( + ReadTeamApplicationIntake, + ListTeamApplicationIntakes, + SubmitTeamApplication, + ListTeamApplications, + ReadTeamApplication, + DeleteTeamApplication, + ReviseTeamApplicationIntake, +) {} diff --git a/packages/http-api/src/v2-schemas.ts b/packages/rpc/src/v2-schemas.ts similarity index 88% rename from packages/http-api/src/v2-schemas.ts rename to packages/rpc/src/v2-schemas.ts index bff349a01..3faf2af2d 100644 --- a/packages/http-api/src/v2-schemas.ts +++ b/packages/rpc/src/v2-schemas.ts @@ -33,37 +33,9 @@ import { Receipt, ReceiptSettlementCommandRequestSchema, ReceiptSettlementEvidenceSchema, - isIsoDate, } from "@vektorprogrammet/domain/receipt"; import { Schema } from "effect"; -import { Multipart } from "effect/unstable/http"; -import { HttpApiSchema } from "effect/unstable/httpapi"; -import { StrongETag } from "./http-semantics.js"; - -const receiptDescriptionPart = Schema.String.pipe( - Schema.check( - Schema.makeFilter((value) => value.length > 0 && value !== "null", { - message: "nonempty receipt description text", - }), - ), -); - -const receiptAmountPart = Schema.String.pipe( - Schema.check( - Schema.makeFilter( - (value) => { - const amount = Number(value); - - return value !== "null" && Number.isSafeInteger(amount) && amount > 0; - }, - { message: "positive safe integer text" }, - ), - ), -); - -const receiptDatePart = Schema.String.pipe( - Schema.check(Schema.makeFilter(isIsoDate, { message: "a valid YYYY-MM-DD date" })), -); +import { StrongETag } from "./problem.js"; /** Strict `{}` body for state transitions without public payload fields. */ export const EmptyJsonRequest = Schema.Struct({}).annotate({ @@ -203,31 +175,6 @@ export const CorrectInterviewAssessmentRequest = Schema.Struct({ export type CorrectInterviewAssessmentRequest = typeof CorrectInterviewAssessmentRequest.Type; -export const SubmitReceiptMultipartV2 = Schema.Struct({ - description: receiptDescriptionPart, - amountOre: receiptAmountPart, - receiptDate: receiptDatePart, - file: Multipart.PersistedFileSchema, -}) - .pipe(HttpApiSchema.asMultipart()) - .annotate({ identifier: "SubmitReceiptMultipartV2" }); - -export const ReviseReceiptMultipartV2 = Schema.Struct({ - description: Schema.optional(receiptDescriptionPart), - amountOre: Schema.optional(receiptAmountPart), - receiptDate: Schema.optional(receiptDatePart), - file: Schema.optional(Multipart.PersistedFileSchema), -}) - .pipe( - Schema.check( - Schema.makeFilter((value) => Object.keys(value).length > 0, { - message: "at least one changed field", - }), - ), - HttpApiSchema.asMultipart(), - ) - .annotate({ identifier: "ReviseReceiptMultipartV2" }); - export const CreateArticleRequest = Schema.Struct({ title: ArticleDraft.jsonCreate.fields.title, bodyHtml: ArticleDraft.jsonCreate.fields.bodyHtml, diff --git a/packages/rpc/test/contract.test.ts b/packages/rpc/test/contract.test.ts new file mode 100644 index 000000000..abc3240f6 --- /dev/null +++ b/packages/rpc/test/contract.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option, Schema } from "effect"; +import { + anonymousNativeAccess, + InternalNativeRpcs, + NativeRpcs, + ProblemBoundary, + ReadSocialEventScope, + reflectAccessSpec, + withAccessSpec, +} from "../src/index.js"; +import { isProblem, Problem, problemUnion, rpcProblems } from "../src/problem.js"; + +const allRpcs = [...NativeRpcs.requests.values(), ...InternalNativeRpcs.requests.values()]; + +// The operation-id grammar that command receipts store (`receipt-transaction.ts`). +const operationId = + /^[a-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*(?:\.[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)+$/u; + +describe("the native RPC contract", () => { + it("serves every operation of every context", () => { + // 106 external operations and the internal evidence read; /health stays plain HTTP. + expect(NativeRpcs.requests.size).toBe(106); + expect(InternalNativeRpcs.requests.size).toBe(1); + }); + + it("gives every RPC exactly one AccessSpec", () => { + const missing = allRpcs.flatMap((rpc) => + Option.isNone(reflectAccessSpec(rpc)) ? [rpc._tag] : [], + ); + + expect(missing).toEqual([]); + }); + + it("names every RPC by an operation id that a command receipt can store", () => { + expect(allRpcs.flatMap((rpc) => (operationId.test(rpc._tag) ? [] : [rpc._tag]))).toEqual([]); + }); + + it("runs every RPC inside the defect boundary", () => { + const unbounded = allRpcs.flatMap((rpc) => + [...rpc.middlewares].some((middleware) => middleware === ProblemBoundary) ? [] : [rpc._tag], + ); + + expect(unbounded).toEqual([]); + }); + + it("rejects a second AccessSpec on one RPC", () => { + expect(() => + withAccessSpec(anonymousNativeAccess("system.health"))(ReadSocialEventScope), + ).toThrow(/multiple AccessSpec annotations/u); + }); +}); + +describe("rpcProblems", () => { + const Declared = rpcProblems( + problemUnion("DeclaredProblem", ["authority.denied", "idempotency.in-flight"]), + ); + + it.effect("round-trips a declared problem through its wire body", () => + Effect.gen(function* () { + const wire = yield* Schema.encodeEffect(Declared)(Problem.make("authority.denied")); + + expect(wire).toMatchObject({ code: "authority.denied", status: 403 }); + + const decoded = yield* Schema.decodeEffect(Declared)(wire); + + expect(isProblem(decoded) && decoded.code).toBe("authority.denied"); + }), + ); + + it.effect("refuses to encode a code the RPC does not declare", () => + Effect.gen(function* () { + const failure = yield* Effect.flip( + Schema.encodeUnknownEffect(Declared)(Problem.make("internal.error")), + ); + + expect(failure).toBeInstanceOf(Schema.SchemaError); + }), + ); +}); diff --git a/packages/http-api/tsconfig.json b/packages/rpc/tsconfig.json similarity index 100% rename from packages/http-api/tsconfig.json rename to packages/rpc/tsconfig.json diff --git a/packages/http-api/vitest.config.ts b/packages/rpc/vitest.config.ts similarity index 100% rename from packages/http-api/vitest.config.ts rename to packages/rpc/vitest.config.ts diff --git a/packages/sdk/AGENTS.md b/packages/sdk/AGENTS.md deleted file mode 100644 index c619355de..000000000 --- a/packages/sdk/AGENTS.md +++ /dev/null @@ -1,18 +0,0 @@ -[//]: # "guide: generated from docs/model/contexts.cml, the @construct tags, and package.json exports by just guides write; do not edit" - -# packages/sdk - -Generated native API client. -Package `@vektorprogrammet/sdk`. - -## Entry points - -| Import | Module | -| ---------------------------------- | -------------------------------------------------------- | -| `@vektorprogrammet/sdk` | [src/promise.ts](src/promise.ts) | -| `@vektorprogrammet/sdk/effect` | [src/effect-client.ts](src/effect-client.ts) | -| `@vektorprogrammet/sdk/operations` | [native-api-operations.json](native-api-operations.json) | - -Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. - -[//]: # "guide: end" diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md deleted file mode 100644 index 139a8da67..000000000 --- a/packages/sdk/CHANGELOG.md +++ /dev/null @@ -1,13 +0,0 @@ -# @vektorprogrammet/sdk - -## 0.1.2 - -### Patch Changes - -- Add profilePhoto field to ProfileResource API types - -## 0.1.1 - -### Patch Changes - -- Add role field to ProfileResource API types diff --git a/packages/sdk/CLAUDE.md b/packages/sdk/CLAUDE.md deleted file mode 100644 index 43c994c2d..000000000 --- a/packages/sdk/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/packages/sdk/package.json b/packages/sdk/package.json deleted file mode 100644 index 22e8e575c..000000000 --- a/packages/sdk/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@vektorprogrammet/sdk", - "version": "0.2.0", - "private": true, - "homepage": "https://github.com/vektorprogrammet/mono-web", - "type": "module", - "exports": { - ".": { - "@vektorprogrammet/source": "./src/promise.ts", - "types": "./dist/promise.d.ts", - "default": "./dist/promise.js" - }, - "./effect": { - "@vektorprogrammet/source": "./src/effect-client.ts", - "types": "./dist/effect-client.d.ts", - "default": "./dist/effect-client.js" - }, - "./operations": "./native-api-operations.json" - }, - "scripts": { - "generate": "bun run scripts/generate-operations.ts", - "build": "tsc -p tsconfig.json", - "check-types": "tsc -p tsconfig.json --noEmit && tsc --noEmit -p tsconfig.test.json", - "lint": "oxlint", - "test": "vitest run" - }, - "dependencies": { - "@vektorprogrammet/http-api": "workspace:*", - "effect": "catalog:effect-v4" - }, - "devDependencies": { - "@effect/vitest": "catalog:effect-v4", - "@types/node": "^22", - "typescript": "7.0.2", - "vitest": "catalog:effect-v4" - } -} diff --git a/packages/sdk/scripts/generate-operations.ts b/packages/sdk/scripts/generate-operations.ts deleted file mode 100644 index d246403c7..000000000 --- a/packages/sdk/scripts/generate-operations.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { - ExternalNativeApi, - NativeApiReleaseName, - NativeApiReleaseVersion, -} from "@vektorprogrammet/http-api"; -import { OpenApi } from "effect/unstable/httpapi"; -import { Schema } from "effect"; -import assert from "node:assert/strict"; - -// Writes the ignored operation index that `@vektorprogrammet/sdk/operations` publishes. -// Turbo runs this `generate` task before type checks and builds. -const methods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const; - -const packageManifest = Schema.decodeSync( - Schema.fromJsonString(Schema.Struct({ version: Schema.String })), -)(await readFile(new URL("../package.json", import.meta.url), "utf8")); - -assert( - packageManifest.version === NativeApiReleaseVersion, - "SDK package version is not synchronized", -); - -const operations = Object.entries(OpenApi.fromApi(ExternalNativeApi).paths) - .flatMap(([path, pathItem]) => - methods.flatMap((method) => { - const operation = pathItem[method]; - - if (operation === undefined) return []; - - return [ - { - group: operation.operationId.split(".", 1)[0]!, - method: method.toUpperCase(), - operationId: operation.operationId, - path, - }, - ]; - }), - ) - .sort((left, right) => left.operationId.localeCompare(right.operationId)); - -// Keys are written in sorted order, so JSON.stringify keeps the published encoding stable. -const operationIndex = { - operations, - release: { name: NativeApiReleaseName, version: NativeApiReleaseVersion }, - schemaVersion: 1, - source: { - contract: "@vektorprogrammet/http-api/ExternalNativeApi", - projection: "effect/unstable/httpapi/HttpApiClient.make", - }, -}; - -await writeFile( - new URL("../native-api-operations.json", import.meta.url), - `${JSON.stringify(operationIndex, null, 2)}\n`, - "utf8", -); - -process.stdout.write( - `generated NativeApi ${NativeApiReleaseVersion} SDK operation index: ${operations.length} operations\n`, -); diff --git a/packages/sdk/src/__tests__/generated-native-client.test.ts b/packages/sdk/src/__tests__/generated-native-client.test.ts deleted file mode 100644 index 7862857be..000000000 --- a/packages/sdk/src/__tests__/generated-native-client.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { - ApproveReceiptEndpoint, - ContentArticleDetailSchema, - CreateArticleEndpoint, - CreateArticleRequest, - IdempotencyKey, - isProblem, - PersonSecurity, - Problem, - PublishArticleEndpoint, - ReadArticleEndpoint, - ReadContentWorkspaceEndpoint, - ReadSessionEndpoint, - ReceiptId, - ReviseArticleEndpoint, - SessionResponse, - SessionSecurity, - StrongETag, - UnpublishArticleEndpoint, -} from "@vektorprogrammet/http-api"; -import { assert, describe, expectTypeOf, it } from "@effect/vitest"; -import { Array, Effect, Layer, Order, Redacted, Ref, Schema } from "effect"; -import { HttpRouter, HttpServer } from "effect/unstable/http"; -import { HttpApi, HttpApiBuilder, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"; -import { createEffectClient, type FetchCapability } from "../effect-client.js"; -import { createPromiseClient, type PromiseSdk } from "../promise.js"; - -const idempotencyKey = IdempotencyKey.make("AAAAAAAAAAAAAAAAAAAAAA"); - -const etag = StrongETag.make('"vkr2.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"'); - -const sessionCookie = "better-auth.session_token=sdk-session"; - -/** - * Serves `app`, built from the contract's endpoints, as a web handler for the scope of the test, - * and gives the generated client a Fetch capability that answers through it. - */ -const serveContract = (app: Layer.Layer) => - Effect.acquireRelease( - Effect.sync(() => HttpRouter.toWebHandler(app, { disableLogger: true })), - ({ dispose }) => Effect.promise(() => dispose()), - ).pipe( - Effect.map( - ({ handler }): FetchCapability => - (input, init) => - handler(new Request(input, init)), - ), - ); - -/** Passes each request to its handler and records the session cookie that the client presented. */ -const recordingSecurity = (cookies: Ref.Ref>) => - Layer.mergeAll( - Layer.succeed(PersonSecurity)( - PersonSecurity.of({ - cookieHeader: (httpEffect, { credential }) => - Ref.update(cookies, Array.append(Redacted.value(credential))).pipe( - Effect.andThen(httpEffect), - ), - oauthUserBearer: (httpEffect) => httpEffect, - }), - ), - Layer.succeed(SessionSecurity)( - SessionSecurity.of({ - cookieHeader: (httpEffect, { credential }) => - Ref.update(cookies, Array.append(Redacted.value(credential))).pipe( - Effect.andThen(httpEffect), - ), - }), - ), - ); - -describe("generated NativeApi client", () => { - it("accepts the complete command union through one Promise endpoint", () => { - // The union payload type-checks through the patched HttpApiEndpoint.ClientRequest of - // patches/effect@4.0.0-rc.116.patch, exception EX-0010 of docs/effect-exceptions.json. - type Request = Parameters[0]; - - expectTypeOf<{ - query: Request["query"]; - headers: Request["headers"]; - payload: Request["payload"]; - }>().toExtend(); - }); - - it.effect( - "sends an action path, its reflected headers, and the session cookie to the contract", - () => - Effect.gen(function* () { - const cookies = yield* Ref.make>([]); - - const approvals = yield* Ref.make< - ReadonlyArray<{ - readonly receiptId: string; - readonly idempotencyKey: string; - readonly ifMatch: string; - }> - >([]); - - const api = HttpApi.make("sdk-receipt-approval").add( - HttpApiGroup.make("receipts").add(ApproveReceiptEndpoint), - ); - - const handlers = HttpApiBuilder.group(api, "receipts", (group) => - group.handle("approveReceipt", ({ params, headers }) => - Ref.update( - approvals, - Array.append({ - receiptId: params.receiptId, - idempotencyKey: headers["idempotency-key"], - ifMatch: headers["if-match"], - }), - ).pipe(Effect.andThen(Effect.fail(Problem.make("authority.denied")))), - ), - ); - - const fetch = yield* serveContract( - HttpApiBuilder.layer(api).pipe( - Layer.provide(handlers), - Layer.provide(recordingSecurity(cookies)), - Layer.provide(HttpServer.layerServices), - ), - ); - - const client = createEffectClient("https://api.example.test", { - cookie: sessionCookie, - fetch, - }); - - const failure = yield* client.receipts - .approveReceipt({ - params: { receiptId: ReceiptId.make("receipt-1") }, - headers: { "idempotency-key": idempotencyKey, "if-match": etag }, - payload: {}, - }) - .pipe(Effect.flip); - - assert.isTrue(isProblem(failure) && failure.code === "authority.denied"); - assert.deepStrictEqual(yield* Ref.get(approvals), [ - { receiptId: "receipt-1", idempotencyKey, ifMatch: etag }, - ]); - assert.deepStrictEqual(yield* Ref.get(cookies), [sessionCookie]); - }), - ); - - it.effect("decodes the body and declared headers of a private session read", () => - Effect.gen(function* () { - const cookies = yield* Ref.make>([]); - - const session = yield* Schema.decodeEffect(SessionResponse)({ - sessionId: "session-1", - personId: "person-1", - createdAt: "2026-09-02T08:00:00.000Z", - updatedAt: "2026-09-02T08:00:00.000Z", - expiresAt: "2026-09-09T08:00:00.000Z", - ipAddress: null, - userAgent: null, - current: true, - }); - - const headers = { "cache-control": "private, no-store", vary: "Origin" } as const; - - const api = HttpApi.make("sdk-session").add( - HttpApiGroup.make("system").add(ReadSessionEndpoint), - ); - - const handlers = HttpApiBuilder.group(api, "system", (group) => - group.handle("readSession", () => - Effect.succeed(HttpApiSchema.withHeaders({ body: session, headers })), - ), - ); - - const fetch = yield* serveContract( - HttpApiBuilder.layer(api).pipe( - Layer.provide(handlers), - Layer.provide(recordingSecurity(cookies)), - Layer.provide(HttpServer.layerServices), - ), - ); - - const result = yield* createEffectClient("https://api.example.test", { - cookie: sessionCookie, - fetch, - }).system.readSession(); - - assert.deepStrictEqual(result.body, session); - assert.deepStrictEqual(result.headers, headers); - assert.deepStrictEqual(yield* Ref.get(cookies), [sessionCookie]); - }), - ); -}); - -describe("generated content SDK", () => { - type ContentOperation = - | "readContentWorkspace" - | "readArticle" - | "createArticle" - | "reviseArticle" - | "publishArticle" - | "unpublishArticle"; - - interface ContentRequest { - readonly operation: ContentOperation; - readonly articleId?: number; - readonly idempotencyKey?: string; - readonly ifMatch?: string; - readonly payload?: object; - } - - const articleId = ContentArticleDetailSchema.fields.articleId.make(7); - - const createPayload = Schema.decodeSync(CreateArticleRequest)({ - title: "Tittel", - bodyHtml: "

Brødtekst

", - departmentIds: ["department-1"], - }); - - it.effect( - "routes every operation, its mutation headers, and its payload through the contract", - () => - Effect.gen(function* () { - const cookies = yield* Ref.make>([]); - const received = yield* Ref.make>([]); - - const deny = (request: ContentRequest) => - Ref.update(received, Array.append(request)).pipe( - Effect.andThen(Effect.fail(Problem.make("authority.denied"))), - ); - - const api = HttpApi.make("sdk-content").add( - HttpApiGroup.make("content").add( - ReadContentWorkspaceEndpoint, - ReadArticleEndpoint, - CreateArticleEndpoint, - ReviseArticleEndpoint, - PublishArticleEndpoint, - UnpublishArticleEndpoint, - ), - ); - - const handlers = HttpApiBuilder.group(api, "content", (group) => - group - .handle("readContentWorkspace", () => deny({ operation: "readContentWorkspace" })) - .handle("readArticle", ({ params }) => - deny({ operation: "readArticle", articleId: params.articleId }), - ) - .handle("createArticle", ({ headers, payload }) => - deny({ - operation: "createArticle", - idempotencyKey: headers["idempotency-key"], - payload, - }), - ) - .handle("reviseArticle", ({ params, headers, payload }) => - deny({ - operation: "reviseArticle", - articleId: params.articleId, - idempotencyKey: headers["idempotency-key"], - ifMatch: headers["if-match"], - payload, - }), - ) - .handle("publishArticle", ({ params, headers, payload }) => - deny({ - operation: "publishArticle", - articleId: params.articleId, - idempotencyKey: headers["idempotency-key"], - ifMatch: headers["if-match"], - payload, - }), - ) - .handle("unpublishArticle", ({ params, headers, payload }) => - deny({ - operation: "unpublishArticle", - articleId: params.articleId, - idempotencyKey: headers["idempotency-key"], - ifMatch: headers["if-match"], - payload, - }), - ), - ); - - const fetch = yield* serveContract( - HttpApiBuilder.layer(api).pipe( - Layer.provide(handlers), - Layer.provide(recordingSecurity(cookies)), - Layer.provide(HttpServer.layerServices), - ), - ); - - const client = createPromiseClient("http://api.test", { cookie: sessionCookie, fetch }); - - const settled = yield* Effect.promise(() => - Promise.allSettled([ - client.content.readContentWorkspace({ query: {} }), - client.content.readArticle({ params: { articleId }, headers: {} }), - client.content.createArticle({ - headers: { "idempotency-key": idempotencyKey }, - payload: createPayload, - }), - client.content.reviseArticle({ - params: { articleId }, - headers: { "idempotency-key": idempotencyKey, "if-match": etag }, - payload: { sticky: false }, - }), - client.content.publishArticle({ - params: { articleId }, - headers: { "idempotency-key": idempotencyKey, "if-match": etag }, - payload: {}, - }), - client.content.unpublishArticle({ - params: { articleId }, - headers: { "idempotency-key": idempotencyKey, "if-match": etag }, - payload: {}, - }), - ]), - ); - - for (const outcome of settled) { - assert.isTrue( - outcome.status === "rejected" && - isProblem(outcome.reason) && - outcome.reason.code === "authority.denied", - ); - } - - const byOperation = Order.mapInput( - Order.String, - (request: ContentRequest) => request.operation, - ); - - assert.deepStrictEqual(Array.sort(yield* Ref.get(received), byOperation), [ - { operation: "createArticle", idempotencyKey, payload: createPayload }, - { operation: "publishArticle", articleId, idempotencyKey, ifMatch: etag, payload: {} }, - { operation: "readArticle", articleId }, - { operation: "readContentWorkspace" }, - { - operation: "reviseArticle", - articleId, - idempotencyKey, - ifMatch: etag, - payload: { sticky: false }, - }, - { operation: "unpublishArticle", articleId, idempotencyKey, ifMatch: etag, payload: {} }, - ]); - assert.deepStrictEqual(yield* Ref.get(cookies), Array.replicate(sessionCookie, 6)); - }), - ); -}); diff --git a/packages/sdk/src/config.ts b/packages/sdk/src/config.ts deleted file mode 100644 index c0831042f..000000000 --- a/packages/sdk/src/config.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * SDK runtime configuration decoded through Effect Config. - * - * @since 0.2.0 - */ -import { Config, Effect, Option } from "effect"; - -const optionalString = (name: string) => Config.option(Config.String(name)); - -const configuredApiUrl = Config.all([ - optionalString("API_URL"), - optionalString("VITE_API_URL"), -]).pipe( - Config.map( - ([serverApiUrl, browserApiUrl]) => - Option.getOrUndefined(serverApiUrl) ?? Option.getOrUndefined(browserApiUrl), - ), -); - -const configuredFixtureMode = Config.all([ - optionalString("API_MODE"), - optionalString("VITE_API_MODE"), -]).pipe( - Config.map( - ([serverMode, browserMode]) => - Option.getOrUndefined(serverMode) === "fixture" || - Option.getOrUndefined(browserMode) === "fixture", - ), -); - -/** Portable SDK configuration description for injected ConfigProviders. */ -export const sdkRuntimeConfig = Config.all({ - apiUrl: configuredApiUrl, - isFixtureMode: configuredFixtureMode, -}); - -/** - * Compatibility adapter for the SDK's long-standing constant exports. Effect - * Config owns the environment read; composition roots can use sdkRuntimeConfig - * with an injected provider instead. - */ -const configured = sdkRuntimeConfig.pipe(Effect.runSync); - -export const apiUrl: string | undefined = configured.apiUrl; - -export const isFixtureMode: boolean = configured.isFixtureMode; diff --git a/packages/sdk/src/effect-client.ts b/packages/sdk/src/effect-client.ts deleted file mode 100644 index a2bc9e2e7..000000000 --- a/packages/sdk/src/effect-client.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Effect-native client projected directly from the canonical NativeApi contract. - * - * @since 0.2.0 - */ -import { ExternalNativeApi } from "@vektorprogrammet/http-api"; -import { Predicate, Effect } from "effect"; -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; -import { HttpApiClient } from "effect/unstable/httpapi"; -import { apiUrl } from "./config.js"; - -/** A Cookie header value or a lazy server-side Cookie header reader. */ -export type CookieOption = string | (() => string | undefined); - -/** Fetch capability supplied by the SDK composition root. */ -export type FetchCapability = Effect.Success; - -/** Runtime options shared by every generated operation. */ -export interface ClientOptions { - readonly cookie?: CookieOption; - readonly fetch?: FetchCapability; - readonly origin?: string; - readonly headers?: Readonly> | (() => Readonly>); -} - -const resolveCookie = (cookie: CookieOption | undefined): string | undefined => - Predicate.isFunction(cookie) ? cookie() : cookie; - -const resolveHeaders = (headers: ClientOptions["headers"]): Readonly> => - Predicate.isFunction(headers) ? headers() : (headers ?? {}); - -const transformClient = - (options: ClientOptions) => - (client: HttpClient.HttpClient): HttpClient.HttpClient => - client.pipe( - HttpClient.mapRequest((request) => { - let next = HttpClientRequest.setHeaders(request, resolveHeaders(options.headers)); - const cookie = resolveCookie(options.cookie); - - if (cookie !== undefined && cookie.length > 0) - next = HttpClientRequest.setHeader(next, "Cookie", cookie); - - if (options.origin !== undefined && options.origin.length > 0) - next = HttpClientRequest.setHeader(next, "Origin", options.origin); - - return next; - }), - HttpClient.transformResponse((response) => { - const operation = response.pipe( - Effect.provideService(FetchHttpClient.RequestInit, { credentials: "include" }), - ); - - return options.fetch === undefined - ? operation - : operation.pipe(Effect.provideService(FetchHttpClient.Fetch, options.fetch)); - }), - ); - -const makeNativeClient = ( - baseUrl: string | undefined, - options: ClientOptions, -): Effect.Effect => - HttpApiClient.make(ExternalNativeApi, { - baseUrl, - transformClient: transformClient(options), - }).pipe(Effect.provide(FetchHttpClient.layer)); - -type NativeClient = HttpApiClient.Client< - (typeof ExternalNativeApi.groups)[keyof typeof ExternalNativeApi.groups] ->; - -/** - * Creates the complete Effect SDK directly from `ExternalNativeApi`. - * - * The Effect HTTP API client owns request encoding, response decoding, and the - * endpoint error channel. No SDK-local route registry or response decoder is - * involved. - */ -export const createEffectClient = ( - baseUrl: string | undefined, - options: ClientOptions = {}, -): NativeClient => Effect.runSync(makeNativeClient(baseUrl, options)); - -/** Creates an Effect SDK from the SDK-owned environment configuration. */ -export const createConfiguredEffectClient = (options: ClientOptions = {}): NativeClient => - createEffectClient(apiUrl, options); - -/** - * Public Effect SDK shape generated by Effect from `ExternalNativeApi`. - * - * A method whose endpoint takes a union of commands accepts the whole union as its payload - * only through the patched `HttpApiEndpoint.ClientRequest` of - * `patches/effect@4.0.0-rc.116.patch`, exception EX-0010 of docs/effect-exceptions.json. - */ -export type EffectSdk = NativeClient; - -/** - * Failure channel of one generated operation: the endpoint's declared problems - * plus the client's transport and response-decoding errors. - */ -export type EffectSdkFailure< - Group extends keyof EffectSdk, - Operation extends keyof EffectSdk[Group], -> = EffectSdk[Group][Operation] extends ( - ...args: never -) => Effect.Effect - ? Failure - : never; diff --git a/packages/sdk/src/env.d.ts b/packages/sdk/src/env.d.ts deleted file mode 100644 index ba39401a8..000000000 --- a/packages/sdk/src/env.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// `import.meta.env` is injected by Vite in browser bundles; plain tsc/Node has no such type. -interface ImportMeta { - readonly env?: Record; -} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts deleted file mode 100644 index a16e0cbf5..000000000 --- a/packages/sdk/src/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Native API SDK projected from the canonical Effect `ExternalNativeApi`. - * - * @since 0.2.0 - */ -export { - createConfiguredPromiseClient, - createPromiseClient, - type ClientOptions, - type PromiseSdk, -} from "./promise.js"; - -export { apiUrl, isFixtureMode, sdkRuntimeConfig } from "./config.js"; diff --git a/packages/sdk/src/promise.ts b/packages/sdk/src/promise.ts deleted file mode 100644 index e169f25f2..000000000 --- a/packages/sdk/src/promise.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Promise adapter for the Effect-native generated SDK. - * - * @since 0.2.0 - */ -import { Effect } from "effect"; -import { - createConfiguredEffectClient, - createEffectClient, - type ClientOptions, - type EffectSdk, -} from "./effect-client.js"; - -/** Converts the generated Effect client methods to Promise-returning methods. */ -export type PromiseSdk
= [A] extends [ - (...args: never[]) => Effect.Effect, -] - ? (...args: Parameters) => Promise - : { readonly [Key in keyof A]: PromiseSdk }; - -const toPromiseClient = (client: EffectSdk): PromiseSdk => { - const groups = Object.entries(client).map(([groupName, endpoints]) => [ - groupName, - Object.fromEntries( - Object.entries(endpoints).map(([name, method]) => [ - name, - (request: never) => Effect.runPromise(method(request)), - ]), - ), - ]); - - // SAFETY: Every generated group and endpoint key is retained; each endpoint receives its unchanged request and only its Effect return is converted to Promise. - return Object.fromEntries(groups) as PromiseSdk; -}; - -/** Creates the complete Promise SDK projected from `ExternalNativeApi`. */ -export const createPromiseClient = ( - baseUrl: string | undefined, - options: ClientOptions = {}, -): PromiseSdk => toPromiseClient(createEffectClient(baseUrl, options)); - -/** Creates a Promise SDK from the SDK-owned environment configuration. */ -export const createConfiguredPromiseClient = (options: ClientOptions = {}): PromiseSdk => - toPromiseClient(createConfiguredEffectClient(options)); - -export type { ClientOptions } from "./effect-client.js"; diff --git a/packages/sdk/src/reflected-header-contract.ts b/packages/sdk/src/reflected-header-contract.ts deleted file mode 100644 index b54578af0..000000000 --- a/packages/sdk/src/reflected-header-contract.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Compile-time gate for mutation headers reflected by `HttpApiClient.make`. - * - * This module intentionally defines no SDK-local header aliases. It compares - * generated operation parameters with the canonical HTTP API schema types. - */ -import type { IdempotencyHeaders, IdempotencyIfMatchHeaders } from "@vektorprogrammet/http-api"; -import type { EffectSdk } from "./effect-client.js"; - -type Equal = - (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 - ? (() => Value extends Right ? 1 : 2) extends () => Value extends Left ? 1 : 2 - ? true - : false - : false; - -type Assert = Condition; - -type ApproveRequest = Parameters[0]; - -type SubmitRequest = Parameters[0]; - -type ApproveHeaders = ApproveRequest extends { readonly headers: infer Headers } ? Headers : never; - -type SubmitHeaders = SubmitRequest extends { readonly headers: infer Headers } ? Headers : never; - -const reflectedIdempotencyIfMatchHeaders: Assert> = - true; - -const reflectedIdempotencyHeaders: Assert> = true; - -void reflectedIdempotencyIfMatchHeaders; - -void reflectedIdempotencyHeaders; diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json deleted file mode 100644 index 273a52f1e..000000000 --- a/packages/sdk/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "lib": ["ESNext", "DOM", "DOM.Iterable"], - "types": ["node"], - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true - }, - "include": ["src"], - "exclude": ["src/__tests__"] -} diff --git a/packages/sdk/tsconfig.test.json b/packages/sdk/tsconfig.test.json deleted file mode 100644 index b43023408..000000000 --- a/packages/sdk/tsconfig.test.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "declaration": false, - "noEmit": true, - "rootDir": "." - }, - "include": ["src/__tests__", "scripts"], - "exclude": [] -} diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts deleted file mode 100644 index eba9f0b23..000000000 --- a/packages/sdk/vitest.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { defineConfig, mergeConfig } from "vitest/config"; -import { sharedVitestConfig } from "../../vitest.shared.js"; - -export default mergeConfig(sharedVitestConfig, defineConfig({})); diff --git a/patches/effect@4.0.0-rc.116.patch b/patches/effect@4.0.0-rc.116.patch deleted file mode 100644 index 6ae6941bd..000000000 --- a/patches/effect@4.0.0-rc.116.patch +++ /dev/null @@ -1,40 +0,0 @@ -diff --git a/dist/unstable/httpapi/HttpApiEndpoint.d.ts b/dist/unstable/httpapi/HttpApiEndpoint.d.ts -index bb77f657d985be023e5cb43a2b4cb4f8542e5f8c..05ea981a6a9594a0d471846054dc431eb95ce24f 100644 ---- a/dist/unstable/httpapi/HttpApiEndpoint.d.ts -+++ b/dist/unstable/httpapi/HttpApiEndpoint.d.ts -@@ -286,12 +286,8 @@ export type ClientRequest | Brand ? { -- readonly payload: FormData; --} : { -- readonly payload: Payload["Type"]; --} : { -- readonly payload: Payload["Type"]; -+}) & ([Payload["Type"]] extends [never] ? {} : { -+ readonly payload: Payload["Type"] extends infer P ? P extends Brand | Brand ? FormData : P : Payload["Type"]; - })) extends infer Req ? keyof Req extends never ? (void | { - readonly responseMode?: ResponseMode; - readonly sseOptions?: Sse.DecodeOptions | undefined; -diff --git a/src/unstable/httpapi/HttpApiEndpoint.ts b/src/unstable/httpapi/HttpApiEndpoint.ts -index 3fc9406fe262da8dfd98a91cefe9fcefdfc0805b..1da17b857a5e5f82b2505328b717edba6eb03ee5 100644 ---- a/src/unstable/httpapi/HttpApiEndpoint.ts -+++ b/src/unstable/httpapi/HttpApiEndpoint.ts -@@ -506,11 +506,11 @@ export type ClientRequest< - & ([Query["Type"]] extends [never] ? {} : { readonly query: Query["Type"] }) - & ([Headers["Type"]] extends [never] ? {} : { readonly headers: Headers["Type"] }) - & ([Payload["Type"]] extends [never] ? {} -- : Payload["Type"] extends infer P ? -- P extends Brand | Brand -- ? { readonly payload: FormData } -- : { readonly payload: Payload["Type"] } -- : { readonly payload: Payload["Type"] }) -+ : { readonly payload: Payload["Type"] extends infer P -+ ? P extends Brand | Brand -+ ? FormData -+ : P -+ : Payload["Type"] }) - ) extends infer Req ? keyof Req extends never ? (void | { - readonly responseMode?: ResponseMode - readonly sseOptions?: Sse.DecodeOptions | undefined diff --git a/tools/acceptance/AGENTS.md b/tools/acceptance/AGENTS.md index c3831da47..0bc427012 100644 --- a/tools/acceptance/AGENTS.md +++ b/tools/acceptance/AGENTS.md @@ -3,6 +3,7 @@ # tools/acceptance Local API and browser acceptance probes of single journeys. +Package `@monoweb/acceptance`. ## Entry points diff --git a/tools/acceptance/acceptance-process.ts b/tools/acceptance/acceptance-process.ts new file mode 100644 index 000000000..917b20ce9 --- /dev/null +++ b/tools/acceptance/acceptance-process.ts @@ -0,0 +1,194 @@ +/** The processes, environment, and readiness of the acceptance probes, as Effects on platform services. */ +import { ConfigProvider, Data, type Duration, Effect, Predicate, Schema, Stream } from "effect"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess } from "effect/unstable/process"; + +/** The grace between SIGTERM and SIGKILL when an owned process group is stopped. */ +const ownedProcessGrace: Duration.Input = "5 seconds"; + +/** A command of an acceptance probe that exited with a code other than 0, or outlived its deadline. */ +export class AcceptanceCommandFailed extends Data.TaggedError("AcceptanceCommandFailed")<{ + readonly command: string; + readonly reason: string; + readonly stderr: string; +}> { + override get message(): string { + return `${this.command} ${this.reason}: ${this.stderr}`; + } +} + +/** One command of an acceptance probe. */ +export interface AcceptanceCommand { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string; + /** Values over the inherited environment; an `undefined` value removes the variable. */ + readonly env?: Readonly> | undefined; +} + +/** A command that runs to completion within `deadline`. */ +export interface CompletedCommand extends AcceptanceCommand { + readonly deadline: Duration.Input; + /** `inherit` shows the standard error of the command; `pipe` keeps it for the failure. */ + readonly stderr?: "inherit" | "pipe" | undefined; +} + +/** + * The standard output of a command that must exit with code 0 before its deadline. At the + * deadline its process group is stopped, SIGTERM first and SIGKILL after five seconds. + */ +export const commandOutput = (spec: CompletedCommand) => + Effect.scoped( + Effect.gen(function* () { + const handle = yield* ChildProcess.make(spec.command, spec.args, { + cwd: spec.cwd, + env: { ...spec.env }, + extendEnv: true, + stdin: "ignore", + stderr: spec.stderr ?? "inherit", + forceKillAfter: ownedProcessGrace, + }); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + spec.stderr === "pipe" + ? Stream.mkString(Stream.decodeText(handle.stderr)) + : Effect.succeed(""), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); + + if (exitCode !== 0) + return yield* new AcceptanceCommandFailed({ + command: spec.command, + reason: `exited with code ${exitCode}`, + stderr, + }); + + return stdout; + }), + ).pipe( + Effect.timeoutOrElse({ + duration: spec.deadline, + orElse: () => + Effect.fail( + new AcceptanceCommandFailed({ + command: spec.command, + reason: "outlived its deadline", + stderr: "", + }), + ), + }), + ); + +/** A long-running process that the caller scope owns. */ +export interface OwnedChild { + /** The exit code; an exit on a signal fails. */ + readonly exitCode: Effect.Effect; + /** Stops the process group, SIGTERM first and SIGKILL after five seconds, and awaits its exit. */ + readonly stop: Effect.Effect; +} + +/** A long-running command whose standard output and error go to `output`. */ +export interface OwnedCommand extends AcceptanceCommand { + readonly output: (text: string) => void; +} + +/** + * Starts a long-running process group in the caller scope. The scope stops the group like + * `stop` when it closes, so no owned process outlives the probe. + */ +export const startOwnedProcess = Effect.fnUntraced(function* (spec: OwnedCommand) { + const handle = yield* ChildProcess.make(spec.command, spec.args, { + cwd: spec.cwd, + env: { ...spec.env }, + extendEnv: true, + stdin: "ignore", + forceKillAfter: ownedProcessGrace, + }); + + yield* handle.all.pipe( + Stream.decodeText(), + Stream.runForEach((text) => Effect.sync(() => spec.output(text))), + Effect.ignore, + Effect.forkScoped, + ); + + const child: OwnedChild = { + exitCode: handle.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + (error) => + new AcceptanceCommandFailed({ + command: spec.command, + reason: "exited on a signal", + stderr: error.message, + }), + ), + ), + stop: handle.kill({ forceKillAfter: ownedProcessGrace }).pipe(Effect.ignore), + }; + + return child; +}); + +/** + * The names of the inherited environment variables under `prefix`, the segments of a name that + * `_` separates, such as `["CONTACT"]` for every `CONTACT_*` variable. + */ +export const ambientVariables = ( + prefix: ReadonlyArray, +): Effect.Effect, ConfigProvider.SourceError> => + Effect.gen(function* () { + const provider = yield* ConfigProvider.ConfigProvider; + const node = yield* provider.load(prefix); + + if (node === undefined) return []; + + const own = + Predicate.isTagged(node, "Value") || node.value !== undefined ? [prefix.join("_")] : []; + + if (!Predicate.isTagged(node, "Record")) return own; + + const nested = yield* Effect.forEach([...node.keys], (key) => + ambientVariables([...prefix, key]), + ); + + return [...own, ...nested.flat()]; + }); + +/** + * The inherited variables that a backend of a probe must not see: every `CONTACT_*` and every + * `PUBLIC_APPLICATION_EFFECT_*` but the mode, each mapped to `undefined` to remove it. + */ +export const withheldVariables = Effect.gen(function* () { + const contact = yield* ambientVariables(["CONTACT"]); + const effects = yield* ambientVariables(["PUBLIC", "APPLICATION", "EFFECT"]); + + return Object.fromEntries( + [...contact, ...effects].flatMap((key) => + key === "PUBLIC_APPLICATION_EFFECT_MODE" ? [] : [[key, undefined]], + ), + ) satisfies Record; +}); + +/** Whether `url` answers a 2xx status; a transport failure counts as not ready. */ +export const answersOk = (url: string): Effect.Effect => + HttpClient.get(url).pipe( + Effect.map((response) => response.status >= 200 && response.status <= 299), + Effect.orElseSucceed(() => false), + ); + +const IndentedJsonText = Schema.fromJsonString(Schema.Unknown, { space: 2 }); + +/** + * The JSON text of evidence indented by two spaces, as `JSON.stringify(value, null, 2)` writes it; + * `jsonText` writes the compact form. + */ +export const indentedJsonText = (value: A): Effect.Effect => + Schema.encodeEffect(IndentedJsonText)(value).pipe(Effect.orDie); + +/** A failed step of an acceptance probe, such as a readiness deadline. */ +export class ProbeFailure extends Data.TaggedError("ProbeFailure")<{ readonly message: string }> {} diff --git a/tools/acceptance/applicant-progress-0107.ts b/tools/acceptance/applicant-progress-0107.ts index c352cec18..1f37a2da0 100644 --- a/tools/acceptance/applicant-progress-0107.ts +++ b/tools/acceptance/applicant-progress-0107.ts @@ -9,14 +9,21 @@ import { } from "../../packages/domain/src/recruitment/schema.js"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { writeFile } from "node:fs/promises"; import { inspect } from "node:util"; -import { createPromiseClient } from "../../packages/sdk/src/promise.js"; import { decodeApplicantProgressResponse } from "../../packages/domain/src/application/schema.js"; +import { nativeScriptClient } from "../../packages/rpc/src/script-client.js"; import type { Page } from "playwright"; -import { join } from "node:path"; import type { Pool, PoolClient } from "pg"; -import {Array as Arr, Match, Predicate, Schema } from "effect"; +import { Array as Arr, Effect, Match, Path, Predicate, Schema } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { + committed, + firstSetCookie, + jsonText, + step, + withPoolClient, + writeEvidence, +} from "./journey-step.ts"; import { admissionJourneyClock } from "../e2e/journey-clock.ts"; export const applicantProgressUnlinkedIdentity = { @@ -61,71 +68,81 @@ const finalizedAt = fromJourneyNow(-1); const pendingAcceptedAt = fromJourneyNow(-1, 60); -const clone = async ( +const clone = Effect.fnUntraced(function* ( client: PoolClient, table: string, predicate: string, values: Readonly, -) => { - await client.query( - `INSERT INTO public.${table} SELECT (jsonb_populate_record(NULL::public.${table},to_jsonb(source)||$1::jsonb)).* FROM public.${table} source WHERE ${predicate}`, - [JSON.stringify(values)], +) { + yield* step(() => + client.query( + `INSERT INTO public.${table} SELECT (jsonb_populate_record(NULL::public.${table},to_jsonb(source)||$1::jsonb)).* FROM public.${table} source WHERE ${predicate}`, + [canonicalJson(values)], + ), ); -}; +}); -const linkApplicant = async ( +const linkApplicant = Effect.fnUntraced(function* ( client: PoolClient, suffix: string, applicationId: string, applicantId: string, -) => { +) { const invitationId = `applicant-progress-account-${suffix}`; - await client.query( - `INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES($1,$2,$3,$4,$5,'Claimed',$6,$7)`, - [ - invitationId, - applicationId, - applicantId, - digest(invitationId), - accountClaimExpiresAt, - personId, - accountClaimedAt, - ], - ); - await client.query( - `INSERT INTO public.applicant_account_links(applicant_id,person_id,linked_at,invitation_id) VALUES($1,$2,$3,$4)`, - [applicantId, personId, accountClaimedAt, invitationId], + yield* step(() => + client.query( + `INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES($1,$2,$3,$4,$5,'Claimed',$6,$7)`, + [ + invitationId, + applicationId, + applicantId, + digest(invitationId), + accountClaimExpiresAt, + personId, + accountClaimedAt, + ], + ), ); -}; + yield* step(() => + client.query( + `INSERT INTO public.applicant_account_links(applicant_id,person_id,linked_at,invitation_id) VALUES($1,$2,$3,$4)`, + [applicantId, personId, accountClaimedAt, invitationId], + ), + ); +}); type ProgressSeedState = "received" | "pending" | "new-time" | "rejected" | "completed"; -const seedProgressState = async (client: PoolClient, state: ProgressSeedState, ordinal: number) => { +const seedProgressState = Effect.fnUntraced(function* ( + client: PoolClient, + state: ProgressSeedState, + ordinal: number, +) { const applicantId = `applicant-progress-${state}-0107`; const applicationId = `application-progress-${state}-0107`; const interviewId = `interview-progress-${state}-0107`; const invitationId = `invitation-progress-${state}-0107`; - await clone(client, "admission_applicants", `applicant_id='${base.applicant}'`, { + yield* clone(client, "admission_applicants", `applicant_id='${base.applicant}'`, { applicant_id: applicantId, normalized_email: `${state}.progress@example.invalid`, email: `${state}.progress@example.invalid`, first_name: state, last_name: "Progress", }); - await clone(client, "admission_applications", `application_id='${base.application}'`, { + yield* clone(client, "admission_applications", `application_id='${base.application}'`, { application_id: applicationId, applicant_id: applicantId, submitted_at: fromJourneyNow(-20, ordinal), }); - await linkApplicant(client, state, applicationId, applicantId); + yield* linkApplicant(client, state, applicationId, applicantId); if (state === "received") return; - await clone(client, "recruitment_interviews", `interview_id='${base.interview}'`, { + yield* clone(client, "recruitment_interviews", `interview_id='${base.interview}'`, { interview_id: interviewId, application_id: applicationId, }); - await clone(client, "recruitment_interview_schedules", `interview_id='${base.schedule}'`, { + yield* clone(client, "recruitment_interview_schedules", `interview_id='${base.schedule}'`, { interview_id: interviewId, scheduled_at: fromJourneyNow(ordinal - 1), room: `P-${ordinal}01`, @@ -140,7 +157,7 @@ const seedProgressState = async (client: PoolClient, state: ProgressSeedState, o const responded = responseState === "Pending" ? null : respondedAt; const responseMessage = responseState === "RequestedNewTime" ? "Trenger et nytt tidspunkt" : null; - await clone(client, "recruitment_invitations", `invitation_id='${base.invitation}'`, { + yield* clone(client, "recruitment_invitations", `invitation_id='${base.invitation}'`, { invitation_id: invitationId, interview_id: interviewId, capability_sha256: digest(invitationId), @@ -151,23 +168,27 @@ const seedProgressState = async (client: PoolClient, state: ProgressSeedState, o }); if (responseState !== "Pending") { - await client.query( - `INSERT INTO public.recruitment_invitation_response_audit(invitation_id,interview_id,schedule_revision,response_revision,response_state,response_message,responded_at) VALUES($1,$2,1,1,$3,$4,$5)`, - [invitationId, interviewId, responseState, responseMessage, responded], + yield* step(() => + client.query( + `INSERT INTO public.recruitment_invitation_response_audit(invitation_id,interview_id,schedule_revision,response_revision,response_state,response_message,responded_at) VALUES($1,$2,1,1,$3,$4,$5)`, + [invitationId, interviewId, responseState, responseMessage, responded], + ), ); } if (responseState === "Rejected" || responseState === "RequestedNewTime") { - await client.query( - `INSERT INTO public.recruitment_invitation_response_outbox(effect_id,effect_type,invitation_id,interview_id,schedule_revision,response_revision,response_state,response_message,ordinal,payload_json,status,attempts,delivered_at) VALUES($1,'SendInterviewInvitationResponse',$2,$3,1,1,$4,$5,0,'{}'::jsonb,'Delivered',1,$6)`, - [ - `recruitment-invitation-response:${invitationId}:1`, - invitationId, - interviewId, - responseState, - responseMessage, - responded, - ], + yield* step(() => + client.query( + `INSERT INTO public.recruitment_invitation_response_outbox(effect_id,effect_type,invitation_id,interview_id,schedule_revision,response_revision,response_state,response_message,ordinal,payload_json,status,attempts,delivered_at) VALUES($1,'SendInterviewInvitationResponse',$2,$3,1,1,$4,$5,0,'{}'::jsonb,'Delivered',1,$6)`, + [ + `recruitment-invitation-response:${invitationId}:1`, + invitationId, + interviewId, + responseState, + responseMessage, + responded, + ], + ), ); } @@ -194,65 +215,71 @@ const seedProgressState = async (client: PoolClient, state: ProgressSeedState, o notificationState: "Pending", }); - await client.query( - `INSERT INTO public.recruitment_interview_conducts(interview_id,answers,explanatory_power,role_model,suitability,finalized_by_person_id,finalized_at,interview_revision,recommendation) VALUES($1,'[]'::jsonb,7,8,9,$2,$3,2,'Ja')`, - [interviewId, personId, finalizedAt], - ); - await client.query( - `INSERT INTO public.recruitment_interview_lifecycle_command_receipts(command_id,command_sha256,command_json,observation_json,kind,interview_id,resulting_revision,committed_at) VALUES($1,$2,$3::jsonb,$4::jsonb,'InterviewFinalized',$5,2,$6)`, - [ - commandId, - sha256Hex(canonicalJsonBytes(command)), - canonicalJson(command), - canonicalJson(observation), - interviewId, - finalizedAt, - ], - ); - await client.query( - `INSERT INTO public.recruitment_interview_lifecycle_audit(command_id,interview_id,kind,actor_person_id,resulting_revision,occurred_at) VALUES($1,$2,'InterviewFinalized',$3,2,$4)`, - [commandId, interviewId, personId, finalizedAt], + yield* step(() => + client.query( + `INSERT INTO public.recruitment_interview_conducts(interview_id,answers,explanatory_power,role_model,suitability,finalized_by_person_id,finalized_at,interview_revision,recommendation) VALUES($1,'[]'::jsonb,7,8,9,$2,$3,2,'Ja')`, + [interviewId, personId, finalizedAt], + ), ); -}; + yield* step(() => + client.query( + `INSERT INTO public.recruitment_interview_lifecycle_command_receipts(command_id,command_sha256,command_json,observation_json,kind,interview_id,resulting_revision,committed_at) VALUES($1,$2,$3::jsonb,$4::jsonb,'InterviewFinalized',$5,2,$6)`, + [ + commandId, + sha256Hex(canonicalJsonBytes(command)), + canonicalJson(command), + canonicalJson(observation), + interviewId, + finalizedAt, + ], + ), + ); + yield* step(() => + client.query( + `INSERT INTO public.recruitment_interview_lifecycle_audit(command_id,interview_id,kind,actor_person_id,resulting_revision,occurred_at) VALUES($1,$2,'InterviewFinalized',$3,2,$4)`, + [commandId, interviewId, personId, finalizedAt], + ), + ); +}); -const seedScopedApplication = async ( +const seedScopedApplication = Effect.fnUntraced(function* ( client: PoolClient, suffix: "assigned" | "returning", activePlacement: boolean, -) => { +) { const departmentId = `applicant-progress-${suffix}-department`; const periodId = `applicant-progress-${suffix}-period`; const fieldId = `applicant-progress-${suffix}-field`; const applicantId = `applicant-progress-${suffix}-applicant`; const applicationId = `applicant-progress-${suffix}-application`; - await clone(client, "admission_period_departments", `department_id='${base.department}'`, { + yield* clone(client, "admission_period_departments", `department_id='${base.department}'`, { department_id: departmentId, name: `Progress ${suffix}`, }); - await clone(client, "organization_departments", `department_id='${base.department}'`, { + yield* clone(client, "organization_departments", `department_id='${base.department}'`, { department_id: departmentId, name: `Progress ${suffix}`, short_name: `P-${suffix}`, email: `${suffix}.department@example.invalid`, native_creation_command_id: null, }); - await clone(client, "admission_periods", `admission_period_id='${base.period}'`, { + yield* clone(client, "admission_periods", `admission_period_id='${base.period}'`, { admission_period_id: periodId, department_id: departmentId, last_command_id: `applicant-progress-${suffix}-period-seed`, }); - await clone(client, "admission_period_fields_of_study", `field_of_study_id='${base.field}'`, { + yield* clone(client, "admission_period_fields_of_study", `field_of_study_id='${base.field}'`, { field_of_study_id: fieldId, department_id: departmentId, name: `Progress ${suffix} study`, }); - await clone(client, "admission_applicants", `applicant_id='${base.applicant}'`, { + yield* clone(client, "admission_applicants", `applicant_id='${base.applicant}'`, { applicant_id: applicantId, normalized_email: `${suffix}.scoped.progress@example.invalid`, email: `${suffix}.scoped.progress@example.invalid`, field_of_study_id: fieldId, }); - await clone(client, "admission_applications", `application_id='${base.application}'`, { + yield* clone(client, "admission_applications", `application_id='${base.application}'`, { application_id: applicationId, applicant_id: applicantId, admission_period_id: periodId, @@ -260,67 +287,73 @@ const seedScopedApplication = async ( field_of_study_id: fieldId, submitted_at: fromJourneyNow(-20, suffix === "assigned" ? 10 : 11), }); - await linkApplicant(client, suffix, applicationId, applicantId); - await client.query( - `INSERT INTO public.organization_volunteer_affiliations(person_id,department_id,status,revision) VALUES($1,$2,$3,1)`, - [personId, departmentId, suffix === "returning" ? "Inactive" : "Active"], + yield* linkApplicant(client, suffix, applicationId, applicantId); + yield* step(() => + client.query( + `INSERT INTO public.organization_volunteer_affiliations(person_id,department_id,status,revision) VALUES($1,$2,$3,1)`, + [personId, departmentId, suffix === "returning" ? "Inactive" : "Active"], + ), ); - const school = await client.query( - `INSERT INTO public.schools_directory_schools(name,contact_person,email,phone,language,active,revision) VALUES($1,'Synthetic Contact',$2,'90000000','Norwegian',true,0) RETURNING school_id`, - [`Progress ${suffix} school`, `${suffix}.school@example.invalid`], + const school = yield* step(() => + client.query( + `INSERT INTO public.schools_directory_schools(name,contact_person,email,phone,language,active,revision) VALUES($1,'Synthetic Contact',$2,'90000000','Norwegian',true,0) RETURNING school_id`, + [`Progress ${suffix} school`, `${suffix}.school@example.invalid`], + ), ); const schoolId = school.rows[0].school_id; - await client.query( - `INSERT INTO public.schools_directory_departments(school_id,department_id,revision) VALUES($1,$2,0)`, - [schoolId, departmentId], + yield* step(() => + client.query( + `INSERT INTO public.schools_directory_departments(school_id,department_id,revision) VALUES($1,$2,0)`, + [schoolId, departmentId], + ), ); const placementId = `placement-${digest(`applicant-progress-${suffix}-placement`)}`; - await client.query( - `INSERT INTO public.assistant_placements(placement_id,person_id,department_id,semester_id,school_id,day,workdays,block,active,revision) VALUES($1,$2,$3,$4,$5,'Monday',4,'1',$6,1)`, - [placementId, personId, departmentId, base.semester, schoolId, activePlacement], + yield* step(() => + client.query( + `INSERT INTO public.assistant_placements(placement_id,person_id,department_id,semester_id,school_id,day,workdays,block,active,revision) VALUES($1,$2,$3,$4,$5,'Monday',4,'1',$6,1)`, + [placementId, personId, departmentId, base.semester, schoolId, activePlacement], + ), ); if (suffix === "returning") { - await client.query( - `INSERT INTO public.admission_returning_registrations(registration_id,application_id,applicant_id,person_id,placement_id,department_id,semester_id,admission_period_id,revision,command_id,year_of_study,monday_unavailable,tuesday_unavailable,wednesday_unavailable,thursday_unavailable,friday_unavailable,position_weeks,preferred_group,language,preferred_school,team_interest,team_ids,registered_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,1,$9,3,false,false,false,false,false,4,'all','Norsk',NULL,false,'[]'::jsonb,$10)`, - [ - `returning-registration-${digest("applicant-progress-returning-registration")}`, - applicationId, - applicantId, - personId, - placementId, - departmentId, - base.semester, - periodId, - "applicant-progress-returning-command", - accountClaimedAt, - ], + yield* step(() => + client.query( + `INSERT INTO public.admission_returning_registrations(registration_id,application_id,applicant_id,person_id,placement_id,department_id,semester_id,admission_period_id,revision,command_id,year_of_study,monday_unavailable,tuesday_unavailable,wednesday_unavailable,thursday_unavailable,friday_unavailable,position_weeks,preferred_group,language,preferred_school,team_interest,team_ids,registered_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,1,$9,3,false,false,false,false,false,4,'all','Norsk',NULL,false,'[]'::jsonb,$10)`, + [ + `returning-registration-${digest("applicant-progress-returning-registration")}`, + applicationId, + applicantId, + personId, + placementId, + departmentId, + base.semester, + periodId, + "applicant-progress-returning-command", + accountClaimedAt, + ], + ), ); } -}; - -export const seedApplicantProgress0107 = async (pool: Pool) => { - const client = await pool.connect(); - - try { - await client.query("BEGIN"); - await seedProgressState(client, "received", 1); - await seedProgressState(client, "pending", 2); - await seedProgressState(client, "new-time", 3); - await seedProgressState(client, "rejected", 4); - await seedProgressState(client, "completed", 5); - await seedScopedApplication(client, "assigned", true); - await seedScopedApplication(client, "returning", false); - await client.query("COMMIT"); - } catch (cause) { - await client.query("ROLLBACK"); - throw cause; - } finally { - client.release(); - } -}; +}); + +export const seedApplicantProgress0107 = Effect.fnUntraced(function* (pool: Pool) { + yield* withPoolClient(pool, (client) => + committed( + client, + Effect.gen(function* () { + yield* seedProgressState(client, "received", 1); + yield* seedProgressState(client, "pending", 2); + yield* seedProgressState(client, "new-time", 3); + yield* seedProgressState(client, "rejected", 4); + yield* seedProgressState(client, "completed", 5); + yield* seedScopedApplication(client, "assigned", true); + yield* seedScopedApplication(client, "returning", false); + }), + ), + ); +}); const forbidden = /email|phone|recommendation|answers|capability|interviewer|score/iu; @@ -331,7 +364,13 @@ const assertNoForbiddenKeys = (value: Schema.Json): void => { return; } - if (value === null || Predicate.isString(value) || Predicate.isNumber(value) || Predicate.isBoolean(value)) return; + if ( + value === null || + Predicate.isString(value) || + Predicate.isNumber(value) || + Predicate.isBoolean(value) + ) + return; for (const [key, child] of Object.entries(value)) { assert.equal(forbidden.test(key), false, `forbidden applicant-progress field ${key}`); @@ -339,7 +378,7 @@ const assertNoForbiddenKeys = (value: Schema.Json): void => { } }; -export const runApplicantProgress0107 = async (input: { +export const runApplicantProgress0107 = Effect.fnUntraced(function* (input: { readonly pool: Pool; readonly page: Page; readonly cookie: string; @@ -348,48 +387,64 @@ export const runApplicantProgress0107 = async (input: { readonly artifacts: string; readonly errors: string[]; readonly audit: (page: Page) => Promise>; -}) => { - const request = (path: string, cookie = input.cookie) => - fetch(`${input.api}${path}`, { headers: { cookie, origin: input.ui } }); - - assert.equal((await fetch(`${input.api}/api/applicant-progress`)).status, 401); - assert.equal((await request("/api/applicant-progress?applicationId=other")).status, 400); - const response = await request("/api/applicant-progress"); - assert.equal(response.status, 200); - assert.equal(response.headers.get("cache-control"), "private, no-store"); - assert.equal(response.headers.get("referrer-policy"), "no-referrer"); - - const unlinkedSignIn = await fetch(`${input.api}/api/auth/sign-in/email`, { - method: "POST", - headers: { origin: input.ui, "content-type": "application/json" }, - body: JSON.stringify({ - email: applicantProgressUnlinkedIdentity.email, - password: applicantProgressUnlinkedIdentity.password, - }), +}) { + const native = nativeScriptClient(input.api); + + /** Reads the progress as the person whose session `cookie` names, or anonymously. */ + const readProgress = (cookie: string | null = input.cookie) => + native.call(cookie === null ? { origin: input.ui } : { cookie, origin: input.ui }, (client) => + client["admissions.readApplicantProgress"](), + ); + + /** The progress that a successful read answered, decoded again by the domain decoder. */ + const progressOf = Effect.fnUntraced(function* (cookie: string = input.cookie) { + const answer = yield* step(() => readProgress(cookie)); + + if (!answer.ok) { + throw new Error(`applicant progress failed: ${inspect(answer, { depth: 10 })}`); + } + + return decodeApplicantProgressResponse(answer.value); }); + assert.equal((yield* step(() => readProgress(null))).status, 401); + const body = yield* progressOf(); + + const http = yield* HttpClient.HttpClient; + const path = yield* Path.Path; + + const unlinkedSignIn = yield* http.execute( + HttpClientRequest.post(`${input.api}/api/auth/sign-in/email`).pipe( + HttpClientRequest.setHeaders({ origin: input.ui }), + HttpClientRequest.bodyText( + yield* jsonText({ + email: applicantProgressUnlinkedIdentity.email, + password: applicantProgressUnlinkedIdentity.password, + }), + "application/json", + ), + ), + ); + assert.equal(unlinkedSignIn.status, 200); - const unlinkedCookie = unlinkedSignIn.headers.get("set-cookie")?.split(";")[0]; + const unlinkedCookie = firstSetCookie(unlinkedSignIn); assert.ok(unlinkedCookie); - const unlinkedResponse = await request("/api/applicant-progress", unlinkedCookie); - assert.equal(unlinkedResponse.status, 200); - assert.deepEqual(decodeApplicantProgressResponse(await unlinkedResponse.json()).applications, []); + assert.deepEqual((yield* progressOf(unlinkedCookie)).applications, []); - const expiredSessions = await input.pool.query( - `UPDATE auth.session SET "expiresAt"=date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')-interval '1 minute' WHERE "userId"=$1`, - [applicantProgressUnlinkedIdentity.personId], + const expiredSessions = yield* step(() => + input.pool.query( + `UPDATE auth.session SET "expiresAt"=date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')-interval '1 minute' WHERE "userId"=$1`, + [applicantProgressUnlinkedIdentity.personId], + ), ); assert.ok((expiredSessions.rowCount ?? 0) >= 1); - assert.equal((await request("/api/applicant-progress", unlinkedCookie)).status, 401); - const body = decodeApplicantProgressResponse(await response.json()); + assert.equal((yield* step(() => readProgress(unlinkedCookie))).status, 401); const tags = body.applications.map((application) => application.progress._tag); const observedStates = new Set(tags); - const readCompletedApplicationState = async () => { - const current = decodeApplicantProgressResponse( - await (await request("/api/applicant-progress")).json(), - ); + const readCompletedApplicationState = Effect.fnUntraced(function* () { + const current = yield* progressOf(); const application = current.applications.find( (candidate) => candidate.applicationId === "application-progress-completed-0107", @@ -399,22 +454,7 @@ export const runApplicantProgress0107 = async (input: { observedStates.add(application.progress._tag); return application.progress._tag; - }; - - const sdk = createPromiseClient(input.api, { cookie: input.cookie, origin: input.ui }); - - const readSdk = async () => { - try { - return await sdk.admissions.readApplicantProgress(); - } catch (cause) { - throw new Error(`applicant progress SDK failed: ${inspect(cause, { depth: 10 })}`, { cause }); - } - }; - - const sdkResponse = await readSdk(); - const sdkBody = decodeApplicantProgressResponse(sdkResponse.body); - assert.equal(sdkBody.personId, body.personId); - assert.deepEqual(sdkBody.applications, body.applications); + }); for (const tag of [ "ApplicationReceived", @@ -444,9 +484,9 @@ export const runApplicantProgress0107 = async (input: { assertNoForbiddenKeys(body); - await input.page.goto(`${input.ui}/dashboard/soknad`); - await input.page.getByRole("heading", { name: "Min søknad", exact: true }).waitFor(); - const renderedText = await input.page.locator("body").innerText(); + yield* step(() => input.page.goto(`${input.ui}/dashboard/soknad`)); + yield* step(() => input.page.getByRole("heading", { name: "Min søknad", exact: true }).waitFor()); + const renderedText: string = yield* step(() => input.page.locator("body").innerText()); for (const title of [ "Søknaden er mottatt", @@ -459,30 +499,37 @@ export const runApplicantProgress0107 = async (input: { "Du har fått skoletildeling", ] as const) { assert.ok( - (await input.page.getByRole("heading", { name: title, exact: true }).count()) >= 1, + (yield* step(() => input.page.getByRole("heading", { name: title, exact: true }).count())) >= + 1, `missing rendered applicant-progress heading ${title}: ${renderedText}`, ); } - assert.equal(await input.page.getByText("P-201", { exact: true }).count(), 1); + assert.equal(yield* step(() => input.page.getByText("P-201", { exact: true }).count()), 1); assert.equal(forbidden.test(renderedText), false); - const violations = await input.audit(input.page); + const violations = yield* step(() => input.audit(input.page)); assert.deepEqual(violations, []); const mapLink = input.page.getByRole("link", { name: "Åpne kart", exact: true }).first(); - await mapLink.focus(); + yield* step(() => mapLink.focus()); assert.equal( - await mapLink.evaluate((element) => element.ownerDocument.activeElement === element), + yield* step(() => + mapLink.evaluate((element) => element.ownerDocument.activeElement === element), + ), true, ); - await input.page.screenshot({ - path: join(input.artifacts, "applicant-progress-desktop.png"), - fullPage: true, - }); - await input.page.setViewportSize({ width: 390, height: 844 }); - await input.page.screenshot({ - path: join(input.artifacts, "applicant-progress-mobile.png"), - fullPage: true, - }); + yield* step(() => + input.page.screenshot({ + path: path.join(input.artifacts, "applicant-progress-desktop.png"), + fullPage: true, + }), + ); + yield* step(() => input.page.setViewportSize({ width: 390, height: 844 })); + yield* step(() => + input.page.screenshot({ + path: path.join(input.artifacts, "applicant-progress-mobile.png"), + fullPage: true, + }), + ); const completedCard = input.page.getByRole("article").filter({ has: input.page.getByRole("heading", { name: "Intervjuet er fullført", exact: true }), @@ -493,13 +540,17 @@ export const runApplicantProgress0107 = async (input: { exact: true, }); - await handoffLink.focus(); + yield* step(() => handoffLink.focus()); assert.equal( - await handoffLink.evaluate((element) => element.ownerDocument.activeElement === element), + yield* step(() => + handoffLink.evaluate((element) => element.ownerDocument.activeElement === element), + ), true, ); - await handoffLink.click(); - await input.page.waitForURL((url) => url.pathname.endsWith("/dashboard/assistenter")); + yield* step(() => handoffLink.click()); + yield* step(() => + input.page.waitForURL((url) => url.pathname.endsWith("/dashboard/assistenter")), + ); assert.equal( new URL(input.page.url()).searchParams.get("departmentId"), "department-native-conduct-0063", @@ -509,7 +560,9 @@ export const runApplicantProgress0107 = async (input: { "semester-native-conduct-0063", ); assert.equal( - await input.page.evaluate("document.documentElement.scrollWidth <= window.innerWidth"), + yield* step(() => + input.page.evaluate("document.documentElement.scrollWidth <= window.innerWidth"), + ), true, ); @@ -518,21 +571,26 @@ export const runApplicantProgress0107 = async (input: { exact: true, }); - await ownAffiliation.getByRole("button", { name: "Be om tilknytning", exact: true }).click(); - await ownAffiliation - .getByRole("status") - .getByText("Endringen er lagret.", { exact: true }) - .waitFor(); - await input.page.goto(`${input.ui}/dashboard/soknad`); - await input.page.getByRole("heading", { name: "Tilknytning er søkt", exact: true }).waitFor(); - assert.equal(await readCompletedApplicationState(), "AffiliationPending"); - - await input.page.setViewportSize({ width: 1280, height: 900 }); - await input.page.goto( - `${input.ui}/dashboard/assistenter?${new URLSearchParams({ - departmentId: "department-native-conduct-0063", - semesterId: "semester-native-conduct-0063", - }).toString()}`, + yield* step(() => + ownAffiliation.getByRole("button", { name: "Be om tilknytning", exact: true }).click(), + ); + yield* step(() => + ownAffiliation.getByRole("status").getByText("Endringen er lagret.", { exact: true }).waitFor(), + ); + yield* step(() => input.page.goto(`${input.ui}/dashboard/soknad`)); + yield* step(() => + input.page.getByRole("heading", { name: "Tilknytning er søkt", exact: true }).waitFor(), + ); + assert.equal(yield* readCompletedApplicationState(), "AffiliationPending"); + + yield* step(() => input.page.setViewportSize({ width: 1280, height: 900 })); + yield* step(() => + input.page.goto( + `${input.ui}/dashboard/assistenter?${new URLSearchParams({ + departmentId: "department-native-conduct-0063", + semesterId: "semester-native-conduct-0063", + }).toString()}`, + ), ); const approveAffiliation = input.page.getByRole("button", { @@ -540,52 +598,54 @@ export const runApplicantProgress0107 = async (input: { exact: true, }); - await approveAffiliation.click(); - await input.page.getByText("Endringen er lagret.", { exact: true }).waitFor(); - await input.page.goto(`${input.ui}/dashboard/soknad`); - await input.page - .getByRole("heading", { name: "Du er tilknyttet avdelingen", exact: true }) - .waitFor(); - assert.equal(await readCompletedApplicationState(), "AffiliationActive"); - - const client = await input.pool.connect(); - - try { - await client.query("BEGIN"); - await client.query( - `UPDATE public.recruitment_invitations SET response_state='Accepted',response_message=NULL,responded_at=$1,response_revision=1 WHERE invitation_id='invitation-progress-pending-0107'`, - [pendingAcceptedAt], - ); - await client.query( - `INSERT INTO public.recruitment_invitation_response_audit(invitation_id,interview_id,schedule_revision,response_revision,response_state,response_message,responded_at) VALUES('invitation-progress-pending-0107','interview-progress-pending-0107',1,1,'Accepted',NULL,$1)`, - [pendingAcceptedAt], - ); - await client.query("COMMIT"); - } catch (cause) { - await client.query("ROLLBACK"); - throw cause; - } finally { - client.release(); - } + yield* step(() => approveAffiliation.click()); + yield* step(() => input.page.getByText("Endringen er lagret.", { exact: true }).waitFor()); + yield* step(() => input.page.goto(`${input.ui}/dashboard/soknad`)); + yield* step(() => + input.page.getByRole("heading", { name: "Du er tilknyttet avdelingen", exact: true }).waitFor(), + ); + assert.equal(yield* readCompletedApplicationState(), "AffiliationActive"); + + yield* withPoolClient(input.pool, (client) => + committed( + client, + Effect.gen(function* () { + yield* step(() => + client.query( + `UPDATE public.recruitment_invitations SET response_state='Accepted',response_message=NULL,responded_at=$1,response_revision=1 WHERE invitation_id='invitation-progress-pending-0107'`, + [pendingAcceptedAt], + ), + ); + yield* step(() => + client.query( + `INSERT INTO public.recruitment_invitation_response_audit(invitation_id,interview_id,schedule_revision,response_revision,response_state,response_message,responded_at) VALUES('invitation-progress-pending-0107','interview-progress-pending-0107',1,1,'Accepted',NULL,$1)`, + [pendingAcceptedAt], + ), + ); + }), + ), + ); - await input.page.reload(); + yield* step(() => input.page.reload()); assert.equal( - await input.page - .getByRole("heading", { name: "Du er invitert til intervju", exact: true }) - .count(), + yield* step(() => + input.page.getByRole("heading", { name: "Du er invitert til intervju", exact: true }).count(), + ), 0, ); assert.ok( - (await input.page - .getByRole("heading", { name: "Intervjuet er avtalt", exact: true }) - .count()) >= 2, + (yield* step(() => + input.page.getByRole("heading", { name: "Intervjuet er avtalt", exact: true }).count(), + )) >= 2, ); - await input.page.goto( - `${input.ui}/dashboard/assistenter?${new URLSearchParams({ - departmentId: "department-native-conduct-0063", - semesterId: "semester-native-conduct-0063", - }).toString()}`, + yield* step(() => + input.page.goto( + `${input.ui}/dashboard/assistenter?${new URLSearchParams({ + departmentId: "department-native-conduct-0063", + semesterId: "semester-native-conduct-0063", + }).toString()}`, + ), ); const placement = input.page.getByRole("form", { @@ -593,45 +653,52 @@ export const runApplicantProgress0107 = async (input: { exact: true, }); - await placement.getByRole("combobox", { name: "Frivillig", exact: true }).selectOption(personId); - await placement - .getByRole("combobox", { name: "Skole", exact: true }) - .selectOption({ label: "Applicant handoff school" }); - await placement.getByRole("combobox", { name: "Ukedag", exact: true }).selectOption("Monday"); - await placement.getByLabel("Antall undervisningsdager").fill("4"); - await placement.getByRole("combobox", { name: "Bolk", exact: true }).selectOption("1"); - await placement.getByRole("button", { name: "Opprett plassering", exact: true }).click(); - await placement.getByRole("status").getByText("Endringen er lagret.", { exact: true }).waitFor(); - await input.page.goto(`${input.ui}/dashboard/soknad`); + yield* step(() => + placement.getByRole("combobox", { name: "Frivillig", exact: true }).selectOption(personId), + ); + yield* step(() => + placement + .getByRole("combobox", { name: "Skole", exact: true }) + .selectOption({ label: "Applicant handoff school" }), + ); + yield* step(() => + placement.getByRole("combobox", { name: "Ukedag", exact: true }).selectOption("Monday"), + ); + yield* step(() => placement.getByLabel("Antall undervisningsdager").fill("4")); + yield* step(() => + placement.getByRole("combobox", { name: "Bolk", exact: true }).selectOption("1"), + ); + yield* step(() => + placement.getByRole("button", { name: "Opprett plassering", exact: true }).click(), + ); + yield* step(() => + placement.getByRole("status").getByText("Endringen er lagret.", { exact: true }).waitFor(), + ); + yield* step(() => input.page.goto(`${input.ui}/dashboard/soknad`)); assert.ok( - (await input.page - .getByRole("heading", { name: "Du har fått skoletildeling", exact: true }) - .count()) >= 1, + (yield* step(() => + input.page.getByRole("heading", { name: "Du har fått skoletildeling", exact: true }).count(), + )) >= 1, ); - assert.equal(await readCompletedApplicationState(), "AssignedToSchool"); - assert.deepEqual(await input.audit(input.page), []); + assert.equal(yield* readCompletedApplicationState(), "AssignedToSchool"); + assert.deepEqual(yield* step(() => input.audit(input.page)), []); - const signOut = await fetch(`${input.api}/api/auth/sign-out`, { - method: "POST", - headers: { cookie: input.cookie, origin: input.ui }, - }); + const signOut = yield* http.execute( + HttpClientRequest.post(`${input.api}/api/auth/sign-out`).pipe( + HttpClientRequest.setHeaders({ cookie: input.cookie, origin: input.ui }), + ), + ); assert.equal(signOut.status, 200); - assert.equal((await request("/api/applicant-progress")).status, 401); + assert.equal((yield* step(() => readProgress())).status, 401); + yield* step(() => native.dispose()); assert.deepEqual(input.errors, []); - await writeFile( - join(input.artifacts, "applicant-progress-targeted-evidence.json"), - `${JSON.stringify( - { - states: [...observedStates].sort(), - applicationCount: body.applications.length, - accessibilityViolations: violations.length, - sourceReload: "Pending->Accepted", - }, - null, - 2, - )}\n`, - ); + yield* writeEvidence(path.join(input.artifacts, "applicant-progress-targeted-evidence.json"), { + states: [...observedStates].sort(), + applicationCount: body.applications.length, + accessibilityViolations: violations.length, + sourceReload: "Pending->Accepted", + }); return { states: [...observedStates].sort(), @@ -639,4 +706,4 @@ export const runApplicantProgress0107 = async (input: { accessibilityViolations: violations.length, sourceReload: "Pending->Accepted", }; -}; +}); diff --git a/tools/acceptance/co-interviewer-correction-0106.ts b/tools/acceptance/co-interviewer-correction-0106.ts index f8a1da316..12dde2dd0 100644 --- a/tools/acceptance/co-interviewer-correction-0106.ts +++ b/tools/acceptance/co-interviewer-correction-0106.ts @@ -2,9 +2,6 @@ import { decodePostgresObservations, type PostgresObservation } from "./postgres import { RecruitmentInterviewConductObservationSchema } from "../../packages/domain/src/recruitment/schema.js"; import assert from "node:assert/strict"; import { randomBytes } from "node:crypto"; -import { writeFile } from "node:fs/promises"; - -import { join } from "node:path"; import type { Browser, Page } from "@playwright/test"; import type { Pool } from "pg"; import { @@ -13,7 +10,11 @@ import { } from "./interview-correction-boundaries.ts"; import { assertInterviewCorrectionIntegrity } from "./interview-correction-integrity.ts"; import type { CoInterviewerCorrection0106Fixture } from "./recommendation-preupgrade-fixture.ts"; -import { Predicate, Schema } from "effect"; +import { Effect, Exit, FileSystem, Path, type PlatformError, Predicate, Schema } from "effect"; +import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"; +import { firstSetCookie, jsonText, type JourneyStepFailed, step } from "./journey-step.ts"; +import { indentedJsonText, ProbeFailure } from "./acceptance-process.ts"; +import { replacedFetch } from "../../apps/dashboard/e2e/native-rpc-ledger.ts"; import { admissionJourneyClock } from "../e2e/journey-clock.ts"; type CorrectionRequestHeaders = { @@ -31,8 +32,6 @@ type Score = Readonly<{ type Detail = typeof RecruitmentInterviewConductObservationSchema.Type; -type DetailResponse = Readonly<{ body: Detail; etag: string }>; - type BoardItem = Readonly<{ interviewId: string; etag: string; @@ -75,13 +74,20 @@ export type CoInterviewerCorrectionJourneyContext = Readonly<{ readonly table: string; readonly rows: ReadonlyArray<{ readonly value: Schema.Json }>; }>; - effectSnapshot: () => Promise< + effectSnapshot: () => Effect.Effect< ReadonlyArray<{ readonly table: string; readonly rows: ReadonlyArray<{ readonly value: Schema.Json }>; - }> + }>, + JourneyStepFailed + >; + auditPage: ( + page: Page, + state: string, + ) => Effect.Effect< + ReadonlyArray, + JourneyStepFailed | PlatformError.PlatformError | Schema.SchemaError >; - auditPage: (page: Page, state: string) => Promise>; recordGate: (...observations: string[]) => void; }>; @@ -106,9 +112,9 @@ const sharedAssessment = (detail: Detail) => ({ finalizedAt: detail.finalizedAt, }); -export async function runCoInterviewerCorrectionJourney( +export const runCoInterviewerCorrectionJourney = Effect.fnUntraced(function* ( context: CoInterviewerCorrectionJourneyContext, -): Promise { +) { const { pool, browser, @@ -126,6 +132,8 @@ export async function runCoInterviewerCorrectionJourney( recordGate, } = context; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const stages: string[] = []; const statuses: Record = {}; @@ -152,31 +160,45 @@ export async function runCoInterviewerCorrectionJourney( return result; }; - const getDetail = async (cookie: string): Promise => { - const response = await fetch( - `${api}/api/recruitment/interviews/${encodeURIComponent(fixture.targetInterviewId)}`, - { headers: headers(cookie) }, + /** One RPC as the route it replaced answered, with the person's cookie and the dashboard origin. */ + const recruitment = (tag: string, payload: Schema.Json, cookie: string) => + replacedFetch({ origin: api, tag, payload, headers: headers(cookie) }); + + const readConduct = (cookie: string) => + recruitment( + "recruitment.readInterviewConduct", + { interviewId: fixture.targetInterviewId }, + cookie, ); + const getDetail = Effect.fnUntraced(function* (cookie: string) { + const response = yield* step(() => readConduct(cookie)); + const responseEtag = response.headers.get("etag"); - assert.equal(response.status, 200, await response.clone().text()); + assert.equal(response.status, 200, yield* step(() => response.clone().text())); assert.ok(responseEtag, "detail response must carry a strong ETag"); - const body: unknown = await response.json(); + const body: unknown = yield* step(() => response.json()); - try { - return { body: Schema.decodeUnknownSync(DetailSchema)(body), etag: responseEtag }; - } catch (cause) { - throw new Error(`invalid interview detail response: ${JSON.stringify(body)}`, { cause }); - } - }; + const decoded = yield* Effect.exit(Schema.decodeUnknownEffect(DetailSchema)(body)); - const getBoard = async (cookie: string): Promise => { - const response = await fetch(`${api}/api/recruitment/interviews`, { headers: headers(cookie) }); - assert.equal(response.status, 200, await response.clone().text()); - const body: unknown = await response.json(); + if (Exit.isFailure(decoded)) + return yield* new ProbeFailure({ + message: `invalid interview detail response: ${yield* jsonText(body)}`, + }); - return Schema.decodeUnknownSync(BoardSchema)(body); - }; + return { body: decoded.value, etag: responseEtag }; + }); + + const getBoard = Effect.fnUntraced(function* (cookie: string) { + const response = yield* step(() => + recruitment("recruitment.readSchedulingBoard", null, cookie), + ); + + assert.equal(response.status, 200, yield* step(() => response.clone().text())); + const body: unknown = yield* step(() => response.json()); + + return yield* Schema.decodeUnknownEffect(BoardSchema)(body); + }); const boardItem = (board: Board): BoardItem => { const item = board.interviews.find( @@ -195,107 +217,110 @@ export async function runCoInterviewerCorrectionJourney( key: string, interviewId = fixture.targetInterviewId, ) => - fetch(`${api}/api/recruitment/interviews/${encodeURIComponent(interviewId)}:correct`, { - method: "POST", - headers: { ...headers(cookie, etag, key), "content-type": "application/json" }, - body: JSON.stringify(body), - }); + recruitment( + "recruitment.correctInterviewAssessment", + { interviewId, idempotencyKey: key, ifMatch: etag, request: body }, + cookie, + ); - const snapshot = async (): Promise => ({ - interview: decodePostgresObservations( - ( - await pool.query( - `SELECT interview_id, interviewer_person_id, co_interviewer_person_id, revision + const snapshot = Effect.fnUntraced(function* () { + return { + interview: decodePostgresObservations( + (yield* step(() => + pool.query( + `SELECT interview_id, interviewer_person_id, co_interviewer_person_id, revision FROM public.recruitment_interviews WHERE interview_id=$1`, - [fixture.targetInterviewId], - ) - ).rows, - ), - conduct: decodePostgresObservations( - ( - await pool.query( - `SELECT to_jsonb(conduct) AS value + [fixture.targetInterviewId], + ), + )).rows, + ), + conduct: decodePostgresObservations( + (yield* step(() => + pool.query( + `SELECT to_jsonb(conduct) AS value FROM public.recruitment_interview_conducts AS conduct WHERE interview_id=$1`, - [fixture.targetInterviewId], - ) - ).rows, - ), - corrections: decodePostgresObservations( - ( - await pool.query( - `SELECT interview_id, predecessor_revision, resulting_revision, answers, + [fixture.targetInterviewId], + ), + )).rows, + ), + corrections: decodePostgresObservations( + (yield* step(() => + pool.query( + `SELECT interview_id, predecessor_revision, resulting_revision, answers, explanatory_power, role_model, suitability, recommendation, corrected_by_person_id, corrected_at, command_id FROM public.recruitment_interview_correction_assessments WHERE interview_id=$1 ORDER BY resulting_revision`, - [fixture.targetInterviewId], - ) - ).rows, - ), - receipts: decodePostgresObservations( - ( - await pool.query( - `SELECT command_id, command_sha256, command_json, observation_json, + [fixture.targetInterviewId], + ), + )).rows, + ), + receipts: decodePostgresObservations( + (yield* step(() => + pool.query( + `SELECT command_id, command_sha256, command_json, observation_json, interview_id, predecessor_revision, resulting_revision, committed_at FROM public.recruitment_interview_correction_command_receipts WHERE interview_id=$1 ORDER BY resulting_revision, command_id`, - [fixture.targetInterviewId], - ) - ).rows, - ), - audit: decodePostgresObservations( - ( - await pool.query( - `SELECT command_id, interview_id, actor_person_id, predecessor_revision, + [fixture.targetInterviewId], + ), + )).rows, + ), + audit: decodePostgresObservations( + (yield* step(() => + pool.query( + `SELECT command_id, interview_id, actor_person_id, predecessor_revision, resulting_revision, occurred_at FROM public.recruitment_interview_correction_audit WHERE interview_id=$1 ORDER BY resulting_revision, command_id`, - [fixture.targetInterviewId], - ) - ).rows, - ), - nativeHttpReceipts: decodePostgresObservations( - ( - await pool.query( - `SELECT identity_sha256, request_sha256, operation_id, state, status, media_type, + [fixture.targetInterviewId], + ), + )).rows, + ), + nativeHttpReceipts: decodePostgresObservations( + (yield* step(() => + pool.query( + `SELECT identity_sha256, request_sha256, operation_id, state, status, media_type, encode(body_bytes, 'hex') AS body_hex, headers_json, committed_at, full_expires_at, tombstoned_at FROM public.native_http_idempotency_receipts ORDER BY identity_sha256, request_sha256`, - ) - ).rows, - ), + ), + )).rows, + ), + }; + }); + + const assertUnchanged = Effect.fnUntraced(function* (before: PersistedSnapshot, label: string) { + return assert.deepEqual(yield* snapshot(), before, `${label} changed correction state`); }); - const assertUnchanged = async (before: PersistedSnapshot, label: string) => - assert.deepEqual(await snapshot(), before, `${label} changed correction state`); - - const countWrites = async (): Promise< - Readonly<{ assessments: number; receipts: number; audit: number; revision: number }> - > => { - const result = await pool.query<{ - assessments: number; - receipts: number; - audit: number; - revision: number; - }>( - `SELECT + const countWrites = Effect.fnUntraced(function* () { + const result = yield* step(() => + pool.query<{ + assessments: number; + receipts: number; + audit: number; + revision: number; + }>( + `SELECT (SELECT count(*)::int FROM public.recruitment_interview_correction_assessments WHERE interview_id=$1) AS assessments, (SELECT count(*)::int FROM public.recruitment_interview_correction_command_receipts WHERE interview_id=$1) AS receipts, (SELECT count(*)::int FROM public.recruitment_interview_correction_audit WHERE interview_id=$1) AS audit, (SELECT revision FROM public.recruitment_interviews WHERE interview_id=$1) AS revision`, - [fixture.targetInterviewId], + [fixture.targetInterviewId], + ), ); assert.equal(result.rows.length, 1, "correction aggregate count must be present"); return result.rows[0]!; - }; + }); const correctionPayload = ( detail: Detail, @@ -314,30 +339,38 @@ export async function runCoInterviewerCorrectionJourney( recommendation, }); - const openInterview = async (page: Page) => { - await page - .getByRole("article") - .filter({ hasText: "history Recommendation" }) - .getByRole("button", { name: "Åpne intervju", exact: true }) - .click(); - await page.getByRole("heading", { name: "Intervju med history Recommendation" }).waitFor(); - }; + const openInterview = Effect.fnUntraced(function* (page: Page) { + yield* step(() => + page + .getByRole("article") + .filter({ hasText: "history Recommendation" }) + .getByRole("button", { name: "Åpne intervju", exact: true }) + .click(), + ); + yield* step(() => + page.getByRole("heading", { name: "Intervju med history Recommendation" }).waitFor(), + ); + }); - const fillCorrection = async ( + const fillCorrection = Effect.fnUntraced(function* ( page: Page, answer: string, recommendation: "Ja" | "Kanskje" | "Nei", score: Score, - ) => { - await page.locator("#question-interview-schema-native-conduct-0063-q0").fill(answer); - await page.locator("#question-interview-schema-native-conduct-0063-q1-1").check(); - await page.locator("#question-interview-schema-native-conduct-0063-q2-0").check(); - await page.locator("#question-interview-schema-native-conduct-0063-q3-0").check(); - await page.locator("#interviewer-recommendation").selectOption(recommendation); - await page.locator("#score-explanatoryPower").selectOption(String(score.explanatoryPower)); - await page.locator("#score-roleModel").selectOption(String(score.roleModel)); - await page.locator("#score-suitability").selectOption(String(score.suitability)); - }; + ) { + yield* step(() => + page.locator("#question-interview-schema-native-conduct-0063-q0").fill(answer), + ); + yield* step(() => page.locator("#question-interview-schema-native-conduct-0063-q1-1").check()); + yield* step(() => page.locator("#question-interview-schema-native-conduct-0063-q2-0").check()); + yield* step(() => page.locator("#question-interview-schema-native-conduct-0063-q3-0").check()); + yield* step(() => page.locator("#interviewer-recommendation").selectOption(recommendation)); + yield* step(() => + page.locator("#score-explanatoryPower").selectOption(String(score.explanatoryPower)), + ); + yield* step(() => page.locator("#score-roleModel").selectOption(String(score.roleModel))); + yield* step(() => page.locator("#score-suitability").selectOption(String(score.suitability))); + }); const waitForCorrection = (page: Page) => page.waitForResponse((response) => { @@ -361,30 +394,32 @@ export async function runCoInterviewerCorrectionJourney( } }); - const submitBrowserCorrection = async (page: Page) => { - await page.getByRole("button", { name: "Rett intervju", exact: true }).click(); - await page.getByRole("dialog").waitFor({ state: "visible" }); + const submitBrowserCorrection = Effect.fnUntraced(function* (page: Page) { + yield* step(() => page.getByRole("button", { name: "Rett intervju", exact: true }).click()); + yield* step(() => page.getByRole("dialog").waitFor({ state: "visible" })); const responsePromise = waitForCorrection(page); - await page - .getByRole("dialog") - .getByRole("button", { name: "Rett intervju", exact: true }) - .press("Enter"); - const response = await responsePromise; - assert.equal(response.status(), 200, await response.text()); - }; + yield* step(() => + page + .getByRole("dialog") + .getByRole("button", { name: "Rett intervju", exact: true }) + .press("Enter"), + ); + const response = yield* step(() => responsePromise); + assert.equal(response.status(), 200, yield* step(() => response.text())); + }); - const login = async (identity: NativeLogin, label: string) => { - const browserContext = await browser.newContext(); - const page = await browserContext.newPage(); + const login = Effect.fnUntraced(function* (identity: NativeLogin, label: string) { + const browserContext = yield* step(() => browser.newContext()); + const page = yield* step(() => browserContext.newPage()); page.on("pageerror", () => errors.push(`${label}-pageerror`)); secrets.push(identity.email, identity.password); - await page.goto(`${ui}/login`); - await page.getByLabel("E-post", { exact: true }).fill(identity.email); - await page.getByLabel("Passord", { exact: true }).fill(identity.password); - await page.getByRole("button", { name: "Logg inn", exact: true }).click(); - await page.waitForURL(/\/dashboard\/?$/); - await page.goto(`${ui}/dashboard/intervjuer`); - const cookies = await browserContext.cookies(); + yield* step(() => page.goto(`${ui}/login`)); + yield* step(() => page.getByLabel("E-post", { exact: true }).fill(identity.email)); + yield* step(() => page.getByLabel("Passord", { exact: true }).fill(identity.password)); + yield* step(() => page.getByRole("button", { name: "Logg inn", exact: true }).click()); + yield* step(() => page.waitForURL(/\/dashboard\/?$/)); + yield* step(() => page.goto(`${ui}/dashboard/intervjuer`)); + const cookies = yield* step(() => browserContext.cookies()); assert.ok(cookies.length > 0, "native login did not establish a browser session"); secrets.push(...cookies.map((cookie) => cookie.value)); @@ -393,42 +428,42 @@ export async function runCoInterviewerCorrectionJourney( page, cookie: cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; "), }; - }; + }); - const signInCookie = async (identity: NativeLogin, label: string): Promise => { + const signInCookie = Effect.fnUntraced(function* (identity: NativeLogin, label: string) { secrets.push(identity.email, identity.password); - let response: Response | undefined; + const body = yield* jsonText(identity); + let response: HttpClientResponse.HttpClientResponse | undefined; for (let retry = 0; retry <= 30; retry += 1) { - response = await fetch(`${api}/api/auth/sign-in/email`, { - method: "POST", - headers: { origin: ui, "content-type": "application/json" }, - body: JSON.stringify(identity), - }); + response = yield* HttpClient.execute( + HttpClientRequest.post(`${api}/api/auth/sign-in/email`).pipe( + HttpClientRequest.setHeaders({ origin: ui }), + HttpClientRequest.bodyText(body, "application/json"), + ), + ); if (response.status !== 429) break; - const cooldown = Promise.withResolvers(); - setTimeout(cooldown.resolve, 1_000); - await cooldown.promise; + yield* Effect.sleep("1 second"); } assert.ok(response, `${label} native login did not return a response`); - assert.equal(response.status, 200, `${label} native login failed: ${await response.text()}`); - const cookie = response.headers.get("set-cookie")?.match(/^([^=;]+=[^;]+)/u)?.[1]; + assert.equal(response.status, 200, `${label} native login failed: ${yield* response.text}`); + const cookie = firstSetCookie(response); assert.ok(cookie, `${label} native login did not return a session cookie`); secrets.push(cookie); return cookie; - }; + }); - const primaryInitial = await getDetail(primaryCookie); - const primaryBoardInitial = await getBoard(primaryCookie); + const primaryInitial = yield* getDetail(primaryCookie); + const primaryBoardInitial = yield* getBoard(primaryCookie); const primaryBoardItemInitial = boardItem(primaryBoardInitial); - const co = await login(fixture.coInterviewer, "co-interviewer"); + const co = yield* login(fixture.coInterviewer, "co-interviewer"); - try { - const coInitial = await getDetail(co.cookie); - const coBoardInitial = await getBoard(co.cookie); + return yield* Effect.gen(function* () { + const coInitial = yield* getDetail(co.cookie); + const coBoardInitial = yield* getBoard(co.cookie); const coBoardItemInitial = boardItem(coBoardInitial); assert.deepEqual(sharedAssessment(coInitial.body), sharedAssessment(primaryInitial.body)); assert.deepEqual(coBoardItemInitial.coInterviewer, { @@ -446,126 +481,130 @@ export async function runCoInterviewerCorrectionJourney( "ordinary primary and co-interviewer receive one shared completed assessment without co contact data", ); - const unassignedCookie = await signInCookie(fixture.unassignedMember, "unassigned-member"); - const unassignedBoard = await getBoard(unassignedCookie); + const unassignedCookie = yield* signInCookie(fixture.unassignedMember, "unassigned-member"); + const unassignedBoard = yield* getBoard(unassignedCookie); assert.equal( unassignedBoard.interviews.some( (candidate) => candidate.interviewId === fixture.targetInterviewId, ), false, ); - const deniedBefore = await snapshot(); + const deniedBefore = yield* snapshot(); - const unassignedRead = await fetch( - `${api}/api/recruitment/interviews/${encodeURIComponent(fixture.targetInterviewId)}`, - { headers: headers(unassignedCookie) }, - ); + const unassignedRead = yield* step(() => readConduct(unassignedCookie)); status("unassigned:read", unassignedRead.status); - assert.equal(unassignedRead.status, 403, await unassignedRead.text()); - - const unassignedCorrect = await postCorrection( - unassignedCookie, - correctionPayload(coInitial.body, "Unassigned correction.", "Nei", { - explanatoryPower: 1, - roleModel: 1, - suitability: 1, - }), - coInitial.etag, - freshId("co-interviewer-unassigned"), + assert.equal(unassignedRead.status, 403, yield* step(() => unassignedRead.text())); + + const unassignedCorrect = yield* step(() => + postCorrection( + unassignedCookie, + correctionPayload(coInitial.body, "Unassigned correction.", "Nei", { + explanatoryPower: 1, + roleModel: 1, + suitability: 1, + }), + coInitial.etag, + freshId("co-interviewer-unassigned"), + ), ); status("unassigned:correct", unassignedCorrect.status); - assert.equal(unassignedCorrect.status, 403, await unassignedCorrect.text()); - await assertUnchanged(deniedBefore, "unassigned member denial"); + assert.equal(unassignedCorrect.status, 403, yield* step(() => unassignedCorrect.text())); + yield* assertUnchanged(deniedBefore, "unassigned member denial"); stage( "same-department ordinary unassigned member has neither board, detail, nor correction access", ); - await co.page.goto(`${ui}/dashboard/intervjuer`); + yield* step(() => co.page.goto(`${ui}/dashboard/intervjuer`)); const coCard = co.page.getByRole("article").filter({ hasText: "history Recommendation" }); - await coCard.waitFor(); - const cardText = await coCard.innerText(); + yield* step(() => coCard.waitFor()); + const cardText = yield* step(() => coCard.innerText()); assert.match(cardText, /Medintervjuer/u); assert.match(cardText, new RegExp(fixture.coInterviewer.displayName, "u")); - await openInterview(co.page); + yield* openInterview(co.page); stage( "real active co-interviewer signs in and opens the designated completed interview from the board", ); const sourceChangedTo = fixture.unassignedMember.personId; - await pool.query( - `UPDATE public.recruitment_interviews + yield* step(() => + pool.query( + `UPDATE public.recruitment_interviews SET co_interviewer_person_id=$1 WHERE interview_id=$2`, - [sourceChangedTo, fixture.targetInterviewId], + [sourceChangedTo, fixture.targetInterviewId], + ), ); - try { - const changedPrimary = await getDetail(primaryCookie); - const changedBoardItem = boardItem(await getBoard(primaryCookie)); + yield* Effect.gen(function* () { + const changedPrimary = yield* getDetail(primaryCookie); + const changedBoardItem = boardItem(yield* getBoard(primaryCookie)); assert.notEqual(changedPrimary.etag, primaryInitial.etag); assert.notEqual(changedBoardItem.etag, primaryBoardItemInitial.etag); - const changedCo = await fetch( - `${api}/api/recruitment/interviews/${encodeURIComponent(fixture.targetInterviewId)}`, - { headers: headers(co.cookie) }, - ); + const changedCo = yield* step(() => readConduct(co.cookie)); status("designation-changed:co-read", changedCo.status); - assert.equal(changedCo.status, 403, await changedCo.text()); - const staleAuthorityBefore = await snapshot(); - - const oldAuthorityWrite = await postCorrection( - primaryCookie, - correctionPayload(primaryInitial.body, "Stale authority ETag.", "Nei", { - explanatoryPower: 2, - roleModel: 2, - suitability: 2, - }), - primaryInitial.etag, - freshId("co-interviewer-source-etag"), + assert.equal(changedCo.status, 403, yield* step(() => changedCo.text())); + const staleAuthorityBefore = yield* snapshot(); + + const oldAuthorityWrite = yield* step(() => + postCorrection( + primaryCookie, + correctionPayload(primaryInitial.body, "Stale authority ETag.", "Nei", { + explanatoryPower: 2, + roleModel: 2, + suitability: 2, + }), + primaryInitial.etag, + freshId("co-interviewer-source-etag"), + ), ); status("designation-changed:old-etag", oldAuthorityWrite.status); - assert.equal(oldAuthorityWrite.status, 412, await oldAuthorityWrite.text()); - await assertUnchanged(staleAuthorityBefore, "old authority ETag"); - } finally { - await pool.query( - `UPDATE public.recruitment_interviews + assert.equal(oldAuthorityWrite.status, 412, yield* step(() => oldAuthorityWrite.text())); + yield* assertUnchanged(staleAuthorityBefore, "old authority ETag"); + }).pipe( + Effect.ensuring( + step(() => + pool.query( + `UPDATE public.recruitment_interviews SET co_interviewer_person_id=$1 WHERE interview_id=$2`, - [fixture.coInterviewer.personId, fixture.targetInterviewId], - ); - } + [fixture.coInterviewer.personId, fixture.targetInterviewId], + ), + ).pipe(Effect.orDie), + ), + ); - assert.equal((await getDetail(co.cookie)).body.revision, coInitial.body.revision); + assert.equal((yield* getDetail(co.cookie)).body.revision, coInitial.body.revision); stage( "controlled nullable co-interviewer source change invalidates primary representation ETags and co authority", ); - const firstBrowserDetail = await getDetail(co.cookie); + const firstBrowserDetail = yield* getDetail(co.cookie); - const originalConduct = ( - await pool.query( + const originalConduct = (yield* step(() => + pool.query( `SELECT to_jsonb(conduct) AS value FROM public.recruitment_interview_conducts AS conduct WHERE interview_id=$1`, [fixture.targetInterviewId], - ) - ).rows[0]?.value; + ), + )).rows[0]?.value; assert.ok( originalConduct, "completed co-interviewer fixture requires immutable original conduct", ); - await fillCorrection(co.page, "Co-interviewer browser correction.", "Ja", { + yield* fillCorrection(co.page, "Co-interviewer browser correction.", "Ja", { explanatoryPower: 4, roleModel: 5, suitability: 6, }); - await submitBrowserCorrection(co.page); - const afterBrowserCorrection = await getDetail(co.cookie); + yield* submitBrowserCorrection(co.page); + const afterBrowserCorrection = yield* getDetail(co.cookie); assert.equal(afterBrowserCorrection.body.revision, firstBrowserDetail.body.revision + 1); assert.equal(afterBrowserCorrection.body.recommendation, "Ja"); assert.equal( @@ -573,30 +612,32 @@ export async function runCoInterviewerCorrectionJourney( firstBrowserDetail.body.history.length + 1, ); - const correctionActor = await pool.query( - `SELECT corrected_by_person_id AS "correctedByPersonId" + const correctionActor = yield* step(() => + pool.query( + `SELECT corrected_by_person_id AS "correctedByPersonId" FROM public.recruitment_interview_correction_assessments WHERE interview_id=$1 ORDER BY resulting_revision DESC LIMIT 1`, - [fixture.targetInterviewId], + [fixture.targetInterviewId], + ), ); assert.deepEqual(correctionActor.rows, [ { correctedByPersonId: fixture.coInterviewer.personId }, ]); assert.deepEqual( - ( - await pool.query( + (yield* step(() => + pool.query( `SELECT to_jsonb(conduct) AS value FROM public.recruitment_interview_conducts AS conduct WHERE interview_id=$1`, [fixture.targetInterviewId], - ) - ).rows[0]?.value, + ), + )).rows[0]?.value, originalConduct, ); - const primaryAfterBrowserCorrection = await getDetail(primaryCookie); + const primaryAfterBrowserCorrection = yield* getDetail(primaryCookie); assert.deepEqual( sharedAssessment(primaryAfterBrowserCorrection.body), sharedAssessment(afterBrowserCorrection.body), @@ -605,123 +646,136 @@ export async function runCoInterviewerCorrectionJourney( "keyboard browser correction appends one co-attributed assessment while primary fresh-read sees the identical shared history", ); - const authorityBefore = await snapshot(); + const authorityBefore = yield* snapshot(); - const finalization = await fetch( - `${api}/api/recruitment/interviews/${encodeURIComponent(fixture.targetInterviewId)}:finalize`, - { - method: "POST", - headers: { - ...headers(co.cookie, afterBrowserCorrection.etag, freshId("co-interviewer-finalize")), - "content-type": "application/json", + const finalization = yield* step(() => + recruitment( + "recruitment.finalizeInterview", + { + interviewId: fixture.targetInterviewId, + idempotencyKey: freshId("co-interviewer-finalize"), + ifMatch: afterBrowserCorrection.etag, + request: Schema.encodeSync(Schema.Json)({ + answers: afterBrowserCorrection.body.answers, + score: afterBrowserCorrection.body.score, + recommendation: afterBrowserCorrection.body.recommendation, + }), }, - body: JSON.stringify({ - answers: afterBrowserCorrection.body.answers, - score: afterBrowserCorrection.body.score, - recommendation: afterBrowserCorrection.body.recommendation, - }), - }, + co.cookie, + ), ); status("co-interviewer:finalize", finalization.status); - assert.equal(finalization.status, 403, await finalization.text()); + assert.equal(finalization.status, 403, yield* step(() => finalization.text())); - const cancellation = await fetch( - `${api}/api/recruitment/interviews/${encodeURIComponent(fixture.targetInterviewId)}:cancel`, - { - method: "POST", - headers: { - ...headers(co.cookie, afterBrowserCorrection.etag, freshId("co-interviewer-cancel")), - "content-type": "application/json", + const cancellation = yield* step(() => + recruitment( + "recruitment.cancelInterview", + { + interviewId: fixture.targetInterviewId, + idempotencyKey: freshId("co-interviewer-cancel"), + ifMatch: afterBrowserCorrection.etag, }, - body: "{}", - }, + co.cookie, + ), ); status("co-interviewer:cancel", cancellation.status); - assert.equal(cancellation.status, 403, await cancellation.text()); + assert.equal(cancellation.status, 403, yield* step(() => cancellation.text())); - const schedule = await fetch( - `${api}/api/recruitment/interviews/${encodeURIComponent(fixture.targetInterviewId)}:schedule`, - { - method: "POST", - headers: { - ...headers( - co.cookie, - boardItem(await getBoard(co.cookie)).etag, - freshId("co-interviewer-schedule"), - ), - "content-type": "application/json", + const scheduleBoardItem = boardItem(yield* getBoard(co.cookie)); + + const schedule = yield* step(() => + recruitment( + "recruitment.scheduleInterview", + { + interviewId: fixture.targetInterviewId, + idempotencyKey: freshId("co-interviewer-schedule"), + ifMatch: scheduleBoardItem.etag, + request: { + // A schedule ahead of the backend's clock, so that only authority denies the command. + scheduledAt: admissionJourneyClock().fromNow(7), + room: "Denied co-interviewer room", + campus: "Gløshaugen", + mapLink: "https://maps.example.invalid/co-interviewer-denied-0106", + message: "This command must remain denied.", + }, }, - body: JSON.stringify({ - // A schedule ahead of the backend's clock, so that only authority denies the command. - scheduledAt: admissionJourneyClock().fromNow(7), - room: "Denied co-interviewer room", - campus: "Gløshaugen", - mapLink: "https://maps.example.invalid/co-interviewer-denied-0106", - message: "This command must remain denied.", - }), - }, + co.cookie, + ), ); status("co-interviewer:schedule", schedule.status); - assert.equal(schedule.status, 403, await schedule.text()); - await assertUnchanged(authorityBefore, "co-interviewer lifecycle and schedule denial"); + assert.equal(schedule.status, 403, yield* step(() => schedule.text())); + yield* assertUnchanged(authorityBefore, "co-interviewer lifecycle and schedule denial"); stage( "co-interviewer designation alone grants neither finalization, cancellation, nor scheduling authority", ); - const staleContext = await browser.newContext({ - storageState: await co.browserContext.storageState(), - }); + const staleStorageState = yield* step(() => co.browserContext.storageState()); + + const staleContext = yield* step(() => + browser.newContext({ + storageState: staleStorageState, + }), + ); - const stalePage = await staleContext.newPage(); + const stalePage = yield* step(() => staleContext.newPage()); stalePage.on("pageerror", () => errors.push("co-interviewer-stale-pageerror")); - try { - await stalePage.goto(`${ui}/dashboard/intervjuer`); - await openInterview(stalePage); - await fillCorrection(stalePage, "Co-interviewer stale draft.", "Kanskje", { + yield* Effect.gen(function* () { + yield* step(() => stalePage.goto(`${ui}/dashboard/intervjuer`)); + yield* openInterview(stalePage); + yield* fillCorrection(stalePage, "Co-interviewer stale draft.", "Kanskje", { explanatoryPower: 7, roleModel: 7, suitability: 7, }); - await fillCorrection(co.page, "Co-interviewer newer correction.", "Nei", { + yield* fillCorrection(co.page, "Co-interviewer newer correction.", "Nei", { explanatoryPower: 8, roleModel: 8, suitability: 8, }); - await submitBrowserCorrection(co.page); - const staleBefore = await snapshot(); + yield* submitBrowserCorrection(co.page); + const staleBefore = yield* snapshot(); const staleResponsePromise = waitForCorrection(stalePage); - await stalePage.getByRole("button", { name: "Rett intervju", exact: true }).click(); - await stalePage.getByRole("dialog").waitFor({ state: "visible" }); - await stalePage - .getByRole("dialog") - .getByRole("button", { name: "Rett intervju", exact: true }) - .press("Enter"); - const staleResponse = await staleResponsePromise; + yield* step(() => + stalePage.getByRole("button", { name: "Rett intervju", exact: true }).click(), + ); + yield* step(() => stalePage.getByRole("dialog").waitFor({ state: "visible" })); + yield* step(() => + stalePage + .getByRole("dialog") + .getByRole("button", { name: "Rett intervju", exact: true }) + .press("Enter"), + ); + const staleResponse = yield* step(() => staleResponsePromise); status("co-interviewer:stale", staleResponse.status()); - assert.equal(staleResponse.status(), 409, await staleResponse.text()); - await stalePage - .getByText( - "Intervjuet er endret. Utkastet er beholdt; åpne intervjuet på nytt for å hente gjeldende versjon.", - { exact: true }, - ) - .waitFor(); + assert.equal(staleResponse.status(), 409, yield* step(() => staleResponse.text())); + yield* step(() => + stalePage + .getByText( + "Intervjuet er endret. Utkastet er beholdt; åpne intervjuet på nytt for å hente gjeldende versjon.", + { exact: true }, + ) + .waitFor(), + ); assert.equal( - await stalePage.locator("#question-interview-schema-native-conduct-0063-q0").inputValue(), + yield* step(() => + stalePage.locator("#question-interview-schema-native-conduct-0063-q0").inputValue(), + ), "Co-interviewer stale draft.", ); - assert.equal(await stalePage.locator("#interviewer-recommendation").inputValue(), "Kanskje"); - await assertUnchanged(staleBefore, "co-interviewer stale correction"); - } finally { - await staleContext.close(); - } + assert.equal( + yield* step(() => stalePage.locator("#interviewer-recommendation").inputValue()), + "Kanskje", + ); + yield* assertUnchanged(staleBefore, "co-interviewer stale correction"); + }).pipe(Effect.ensuring(step(() => staleContext.close()).pipe(Effect.orDie))); stage("co-interviewer stale browser command retains the visible draft and writes nothing"); - const replayBase = await getDetail(co.cookie); + const replayBase = yield* getDetail(co.cookie); const replayPayload = correctionPayload( replayBase.body, @@ -740,47 +794,44 @@ export async function runCoInterviewerCorrectionJourney( payload: replayPayload, }; - const firstReplay = await postCorrection( - co.cookie, - acceptedReplay.payload, - acceptedReplay.etag, - acceptedReplay.key, + const firstReplay = yield* step(() => + postCorrection(co.cookie, acceptedReplay.payload, acceptedReplay.etag, acceptedReplay.key), ); status("co-interviewer:replay-first", firstReplay.status); assert.equal(firstReplay.status, 200); - const firstReplayBytes = await firstReplay.text(); + const firstReplayBytes = yield* step(() => firstReplay.text()); - const exactReplay = await postCorrection( - co.cookie, - acceptedReplay.payload, - acceptedReplay.etag, - acceptedReplay.key, + const exactReplay = yield* step(() => + postCorrection(co.cookie, acceptedReplay.payload, acceptedReplay.etag, acceptedReplay.key), ); status("co-interviewer:replay-exact", exactReplay.status); assert.equal(exactReplay.status, 200); - assert.equal(await exactReplay.text(), firstReplayBytes); + assert.equal(yield* step(() => exactReplay.text()), firstReplayBytes); stage("co-interviewer exact correction replay remains byte-stable"); - const concurrentBase = await getDetail(co.cookie); - const writesBeforeConcurrent = await countWrites(); + const concurrentBase = yield* getDetail(co.cookie); + const writesBeforeConcurrent = yield* countWrites(); const concurrentRecommendations: ReadonlyArray<"Ja" | "Nei"> = ["Ja", "Nei"]; - const concurrent = await Promise.all( + const concurrent = yield* Effect.all( concurrentRecommendations.map((recommendation, index) => - postCorrection( - co.cookie, - correctionPayload( - concurrentBase.body, - `Co-interviewer concurrent ${index}.`, - recommendation, - { explanatoryPower: 3 + index, roleModel: 4 + index, suitability: 5 + index }, + step(() => + postCorrection( + co.cookie, + correctionPayload( + concurrentBase.body, + `Co-interviewer concurrent ${index}.`, + recommendation, + { explanatoryPower: 3 + index, roleModel: 4 + index, suitability: 5 + index }, + ), + concurrentBase.etag, + freshId(`co-interviewer-concurrent-${index}`), ), - concurrentBase.etag, - freshId(`co-interviewer-concurrent-${index}`), ), ), + { concurrency: "unbounded" }, ); for (const [index, response] of concurrent.entries()) { @@ -794,7 +845,7 @@ export async function runCoInterviewerCorrectionJourney( (response) => response.status === 200 || [409, 412].includes(response.status), ), ); - const writesAfterConcurrent = await countWrites(); + const writesAfterConcurrent = yield* countWrites(); assert.deepEqual(writesAfterConcurrent, { assessments: writesBeforeConcurrent.assessments + 1, receipts: writesBeforeConcurrent.receipts + 1, @@ -805,7 +856,7 @@ export async function runCoInterviewerCorrectionJourney( "same-revision co-interviewer corrections produce one winner and one no-write stale loser", ); - const boundaryResult = await assertInterviewCorrectionBoundaries({ + const boundaryResult = yield* assertInterviewCorrectionBoundaries({ pool, api, origin: ui, @@ -820,34 +871,41 @@ export async function runCoInterviewerCorrectionJourney( recordGate, }); - await assertInterviewCorrectionIntegrity(pool, fixture.targetInterviewId, { + yield* assertInterviewCorrectionIntegrity({ + pool, + interviewId: fixture.targetInterviewId, expectedCorrectedByPersonId: fixture.coInterviewer.personId, expectedCoInterviewerPersonId: fixture.coInterviewer.personId, }); - assert.deepEqual(await effectSnapshot(), effectsBefore); - const primaryFinal = await getDetail(primaryCookie); - const coFinal = await getDetail(co.cookie); + assert.deepEqual(yield* effectSnapshot(), effectsBefore); + const primaryFinal = yield* getDetail(primaryCookie); + const coFinal = yield* getDetail(co.cookie); assert.deepEqual(sharedAssessment(primaryFinal.body), sharedAssessment(coFinal.body)); stage( "denial, revocation, exact replay, rollback, native receipt, and SQL correction integrity gates preserve one shared aggregate with no effects", ); - await co.page.setViewportSize({ width: 1280, height: 900 }); - await co.page - .locator(".fs-conduct") - .screenshot({ path: join(artifacts, "co-interviewer-correction-desktop.png") }); - assert.deepEqual(await auditPage(co.page, "co-interviewer-correction-desktop"), []); - await co.page.setViewportSize({ width: 390, height: 844 }); + yield* step(() => co.page.setViewportSize({ width: 1280, height: 900 })); + yield* step(() => + co.page + .locator(".fs-conduct") + .screenshot({ path: path.join(artifacts, "co-interviewer-correction-desktop.png") }), + ); + assert.deepEqual(yield* auditPage(co.page, "co-interviewer-correction-desktop"), []); + yield* step(() => co.page.setViewportSize({ width: 390, height: 844 })); assert.ok( - (await co.page.locator("html").evaluate((element: HTMLElement) => element.scrollWidth)) <= - 390, + (yield* step(() => + co.page.locator("html").evaluate((element: HTMLElement) => element.scrollWidth), + )) <= 390, "co-interviewer correction must fit the mobile viewport", ); - await co.page - .locator(".fs-conduct") - .screenshot({ path: join(artifacts, "co-interviewer-correction-mobile.png") }); - assert.deepEqual(await auditPage(co.page, "co-interviewer-correction-mobile"), []); - await co.page.setViewportSize({ width: 1280, height: 900 }); + yield* step(() => + co.page + .locator(".fs-conduct") + .screenshot({ path: path.join(artifacts, "co-interviewer-correction-mobile.png") }), + ); + assert.deepEqual(yield* auditPage(co.page, "co-interviewer-correction-mobile"), []); + yield* step(() => co.page.setViewportSize({ width: 1280, height: 900 })); assert.deepEqual(errors, []); stage("co-interviewer desktop and mobile correction surfaces pass Axe without page errors"); @@ -859,28 +917,22 @@ export async function runCoInterviewerCorrectionJourney( coInterviewerPersonId: fixture.coInterviewer.personId, }; - await writeFile( - join(artifacts, "co-interviewer-targeted-evidence.json"), - JSON.stringify( - { - revision, - fixture: { - interviewId: fixture.targetInterviewId, - primaryPersonId: fixture.primaryPersonId, - coInterviewer: { - personId: fixture.coInterviewer.personId, - displayName: fixture.coInterviewer.displayName, - }, + yield* fs.writeFileString( + path.join(artifacts, "co-interviewer-targeted-evidence.json"), + yield* indentedJsonText({ + revision, + fixture: { + interviewId: fixture.targetInterviewId, + primaryPersonId: fixture.primaryPersonId, + coInterviewer: { + personId: fixture.coInterviewer.personId, + displayName: fixture.coInterviewer.displayName, }, - result, }, - null, - 2, - ), + result, + }), ); return result; - } finally { - await co.browserContext.close(); - } -} + }).pipe(Effect.ensuring(step(() => co.browserContext.close()).pipe(Effect.orDie))); +}); diff --git a/tools/acceptance/interview-correction-boundaries.ts b/tools/acceptance/interview-correction-boundaries.ts index 43a6d2793..cabff4c66 100644 --- a/tools/acceptance/interview-correction-boundaries.ts +++ b/tools/acceptance/interview-correction-boundaries.ts @@ -1,10 +1,13 @@ import { decodePostgresObservations, type PostgresObservation } from "./postgres-observation.js"; -import { NativeProblem } from "../../packages/http-api/src/http-semantics.js"; +import { NativeProblem } from "../../packages/rpc/src/problem.js"; +import { replacedFetch, replacedHttpResponse } from "../../apps/dashboard/e2e/native-rpc-ledger.js"; import { RecruitmentInterviewConductObservationSchema } from "../../packages/domain/src/recruitment/schema.js"; -import { Schema } from "effect"; +import { Effect, Fiber, Option, Schema } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import assert from "node:assert/strict"; import { createHash, randomBytes } from "node:crypto"; import type { Pool, PoolClient } from "pg"; +import { jsonText, type JourneyStepFailed, step } from "./journey-step.ts"; type CorrectionRequestHeaders = { origin: string; @@ -50,15 +53,39 @@ type Detail = typeof RecruitmentInterviewConductObservationSchema.Type; type OwnedSnapshot = Readonly>>; -type Mutation = (client: PoolClient) => Promise; +type Mutation = (client: PoolClient) => Effect.Effect; -const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +/** Runs one statement of a setup or restore mutation on the connection. */ +const execute = ( + client: PoolClient, + text: string, + values?: ReadonlyArray, +): Effect.Effect => + step(() => client.query(text, values === undefined ? undefined : [...values])).pipe( + Effect.asVoid, + ); + +/** + * Runs the cleanup after the body however the body ends. A failed cleanup fails the whole, as a + * `finally` block that throws does; otherwise the outcome of the body stands. + */ +const withCleanup = ( + body: Effect.Effect, + cleanup: Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const outcome = yield* Effect.exit(body); + + yield* cleanup; + + return yield* outcome; + }); const freshId = (prefix: string) => `${prefix}-${randomBytes(8).toString("hex")}`; -export async function assertInterviewCorrectionBoundaries( +export const assertInterviewCorrectionBoundaries = Effect.fnUntraced(function* ( context: InterviewCorrectionBoundaryContext, -): Promise { +) { const { pool, api, @@ -89,8 +116,9 @@ export async function assertInterviewCorrectionBoundaries( assert.notEqual(actorPersonId, otherPersonId, "authority fixture needs two distinct persons"); - const identityRows = await pool.query( - `SELECT i.interview_id AS "interviewId", i.interviewer_person_id AS "interviewerPersonId", + const identityRows = yield* step(() => + pool.query( + `SELECT i.interview_id AS "interviewId", i.interviewer_person_id AS "interviewerPersonId", i.co_interviewer_person_id AS "coInterviewerPersonId", a.applicant_id AS "applicantId", a.application_id AS "applicationId", i.department_id AS "departmentId", @@ -100,7 +128,8 @@ export async function assertInterviewCorrectionBoundaries( LEFT JOIN public.applicant_account_links l USING(applicant_id) WHERE i.interview_id = ANY($1::text[]) ORDER BY i.interview_id`, - [[interviewId, selfLinkRaceInterviewId]], + [[interviewId, selfLinkRaceInterviewId]], + ), ); assert.equal( @@ -109,7 +138,7 @@ export async function assertInterviewCorrectionBoundaries( "correction fixtures must expose primary and race interviews", ); - const identities = Schema.decodeUnknownSync( + const identities = yield* Schema.decodeUnknownEffect( Schema.Array( Schema.Struct({ interviewId: Schema.String, @@ -148,16 +177,18 @@ export async function assertInterviewCorrectionBoundaries( `self-link race fixture must designate the ${participantRole} actor`, ); - const departments = await pool.query( - `SELECT department_id AS "departmentId" + const departments = yield* step(() => + pool.query( + `SELECT department_id AS "departmentId" FROM public.admission_period_departments WHERE department_id <> $1 ORDER BY department_id LIMIT 1`, - [identity.departmentId], + [identity.departmentId], + ), ); - const differentDepartment = Schema.decodeUnknownSync(Schema.String)( + const differentDepartment = yield* Schema.decodeUnknownEffect(Schema.String)( departments.rows[0]?.departmentId, ); @@ -175,8 +206,12 @@ export async function assertInterviewCorrectionBoundaries( return result; }; + // Each call is the RPC that replaced the route, answered as that route's HTTP response. const get = (id: string, requestCookie: string | null = cookie) => - fetch(`${api}/api/recruitment/interviews/${id}`, { + replacedFetch({ + origin: api, + tag: "recruitment.readInterviewConduct", + payload: { interviewId: id }, headers: headers(requestCookie), }); @@ -187,125 +222,143 @@ export async function assertInterviewCorrectionBoundaries( key: string, requestCookie: string | null = cookie, ) => - fetch(`${api}/api/recruitment/interviews/${id}:correct`, { - method: "POST", - headers: { - ...headers(requestCookie, etag, key), - "content-type": "application/json", - }, - body: JSON.stringify(body), + replacedFetch({ + origin: api, + tag: "recruitment.correctInterviewAssessment", + payload: { interviewId: id, idempotencyKey: key, ifMatch: etag, request: body }, + headers: headers(requestCookie), }); + /** A body that is no RPC request message at all, posted to the RPC endpoint. */ const postRaw = ( - id: string, + _id: string, body: string, - etag: string, - key: string, + _etag: string, + _key: string, requestCookie: string | null = cookie, ) => - fetch(`${api}/api/recruitment/interviews/${id}:correct`, { - method: "POST", - headers: { - ...headers(requestCookie, etag, key), - "content-type": "application/json", - }, - body, + Effect.gen(function* () { + const answer = yield* HttpClient.execute( + HttpClientRequest.post(`${api}/api/rpc`).pipe( + HttpClientRequest.setHeaders(headers(requestCookie)), + HttpClientRequest.bodyText(body, "application/json"), + ), + ); + + const text = yield* answer.text; + + return yield* step(() => replacedHttpResponse(new Response(text, { status: answer.status }))); }); const interviewIds = [interviewId, selfLinkRaceInterviewId]; const applicantIds = [identity.applicantId, raceIdentity.applicantId]; const departmentIds = [identity.departmentId, differentDepartment]; - const readRows = async (query: string, values: readonly unknown[] = []) => - decodePostgresObservations((await pool.query(query, [...values])).rows); + const readRows = Effect.fnUntraced(function* (query: string, values: readonly unknown[] = []) { + return decodePostgresObservations((yield* step(() => pool.query(query, [...values]))).rows); + }); + + /** + * Borrows a pool connection for the use, rolls back whatever transaction the use left open, + * and returns the connection however the use ends. + */ + const withConnection = (use: (client: PoolClient) => Effect.Effect) => + Effect.acquireUseRelease( + step(() => pool.connect()), + (client) => + use(client).pipe(Effect.ensuring(step(() => client.query("ROLLBACK")).pipe(Effect.ignore))), + (client) => Effect.sync(() => client.release()), + ); /** Exact rows, not counts: failed requests must not create a receipt or alter any owned state. */ - const snapshot = async (): Promise => ({ - interviews: await readRows( - `SELECT interview_id, application_id, department_id, interviewer_person_id, + const snapshot = Effect.fnUntraced(function* () { + return { + interviews: yield* readRows( + `SELECT interview_id, application_id, department_id, interviewer_person_id, co_interviewer_person_id, interview_schema_id, assigned_by_person_id, assigned_at, revision FROM public.recruitment_interviews WHERE interview_id = ANY($1::text[]) ORDER BY interview_id`, - [interviewIds], - ), - corrections: await readRows( - `SELECT interview_id, predecessor_revision, resulting_revision, answers, + [interviewIds], + ), + corrections: yield* readRows( + `SELECT interview_id, predecessor_revision, resulting_revision, answers, explanatory_power, role_model, suitability, recommendation, corrected_by_person_id, corrected_at, command_id FROM public.recruitment_interview_correction_assessments WHERE interview_id = ANY($1::text[]) ORDER BY interview_id, resulting_revision`, - [interviewIds], - ), - correctionReceipts: await readRows( - `SELECT command_id, command_sha256, command_json, observation_json, interview_id, + [interviewIds], + ), + correctionReceipts: yield* readRows( + `SELECT command_id, command_sha256, command_json, observation_json, interview_id, predecessor_revision, resulting_revision, committed_at FROM public.recruitment_interview_correction_command_receipts WHERE interview_id = ANY($1::text[]) ORDER BY interview_id, resulting_revision, command_id`, - [interviewIds], - ), - correctionAudit: await readRows( - `SELECT command_id, interview_id, actor_person_id, predecessor_revision, + [interviewIds], + ), + correctionAudit: yield* readRows( + `SELECT command_id, interview_id, actor_person_id, predecessor_revision, resulting_revision, occurred_at FROM public.recruitment_interview_correction_audit WHERE interview_id = ANY($1::text[]) ORDER BY interview_id, resulting_revision, command_id`, - [interviewIds], - ), - nativeHttpReceipts: await readRows( - `SELECT identity_sha256, request_sha256, operation_id, state, status, media_type, + [interviewIds], + ), + nativeHttpReceipts: yield* readRows( + `SELECT identity_sha256, request_sha256, operation_id, state, status, media_type, encode(body_bytes, 'hex') AS body_hex, headers_json, committed_at, full_expires_at, tombstoned_at FROM public.native_http_idempotency_receipts WHERE operation_id = 'recruitment.correctInterviewAssessment' ORDER BY identity_sha256`, - ), - applicantLinks: await readRows( - `SELECT applicant_id, person_id, linked_at, invitation_id + ), + applicantLinks: yield* readRows( + `SELECT applicant_id, person_id, linked_at, invitation_id FROM public.applicant_account_links WHERE applicant_id = ANY($1::text[]) ORDER BY applicant_id`, - [applicantIds], - ), - applicantInvitations: await readRows( - `SELECT invitation_id, application_id, applicant_id, token_digest, expires_at, + [applicantIds], + ), + applicantInvitations: yield* readRows( + `SELECT invitation_id, application_id, applicant_id, token_digest, expires_at, state, issued_by, issued_at FROM public.applicant_account_invitations WHERE applicant_id = ANY($1::text[]) ORDER BY applicant_id, invitation_id`, - [applicantIds], - ), - memberships: await readRows( - `SELECT membership_id, person_id, team_id, deleted_team_name, start_at, end_at, + [applicantIds], + ), + memberships: yield* readRows( + `SELECT membership_id, person_id, team_id, deleted_team_name, start_at, end_at, position_id, is_team_leader, is_suspended, revision FROM public.organization_memberships WHERE membership_id = $1`, - [membershipId], - ), - departments: await readRows( - `SELECT department_id, name, short_name, email, address, city, latitude, + [membershipId], + ), + departments: yield* readRows( + `SELECT department_id, name, short_name, email, address, city, latitude, longitude, slack_channel, logo_path, active, revision FROM public.organization_departments WHERE department_id = ANY($1::text[]) ORDER BY department_id`, - [departmentIds], - ), - sessions: await readRows( - `SELECT id, "expiresAt", md5(token) AS token_digest, "createdAt", "updatedAt", + [departmentIds], + ), + sessions: yield* readRows( + `SELECT id, "expiresAt", md5(token) AS token_digest, "createdAt", "updatedAt", "ipAddress", "userAgent", "userId" FROM auth."session" WHERE "userId" = $1 ORDER BY id`, - [actorPersonId], - ), + [actorPersonId], + ), + }; }); - const assertSnapshot = async (before: OwnedSnapshot, label: string) => { - const after = await snapshot(); + const assertSnapshot = Effect.fnUntraced(function* (before: OwnedSnapshot, label: string) { + const after = yield* snapshot(); assert.deepEqual(after, before, `${label} changed owned state`); - }; + }); const assertCorrectionRowsUnchanged = ( before: OwnedSnapshot, @@ -327,56 +380,47 @@ export async function assertInterviewCorrectionBoundaries( * restored before this function returns, and every connection is rolled * back in finally even after an assertion or restoration error. */ - const withMutation = async ( - mutate: Mutation, - restore: Mutation, - action: () => Promise, - ): Promise => { - const before = await snapshot(); - const client = await pool.connect(); - let result!: T; - let actionError: unknown; - - try { - await client.query("BEGIN"); - await mutate(client); - await client.query("COMMIT"); - - try { - result = await action(); - } catch (cause) { - actionError = cause; - } + const withMutation = ( + mutate: Mutation, + restore: Mutation, + action: () => Effect.Effect, + ) => + Effect.gen(function* () { + const before = yield* snapshot(); - await client.query("BEGIN"); - await restore(client); - await client.query("COMMIT"); - await assertSnapshot(before, "mutation restoration"); + return yield* withConnection((client) => + Effect.gen(function* () { + yield* step(() => client.query("BEGIN")); + yield* mutate(client); + yield* step(() => client.query("COMMIT")); - if (actionError !== undefined) throw actionError; + const outcome = yield* Effect.exit(action()); - return result; - } finally { - await client.query("ROLLBACK").catch(() => undefined); - client.release(); - } - }; + yield* step(() => client.query("BEGIN")); + yield* restore(client); + yield* step(() => client.query("COMMIT")); + yield* assertSnapshot(before, "mutation restoration"); + + return yield* outcome; + }), + ); + }); - const detail = async (): Promise<{ body: Detail; etag: string }> => { - const response = await get(interviewId); + const detail = Effect.fnUntraced(function* () { + const response = yield* step(() => get(interviewId)); status("detail", response.status); - assert.equal(response.status, 200, await response.clone().text()); + assert.equal(response.status, 200, yield* step(() => response.clone().text())); const etag = response.headers.get("etag"); if (etag === null) throw new Error("detail response did not include an ETag"); return { - body: Schema.decodeUnknownSync(RecruitmentInterviewConductObservationSchema)( - await response.json(), + body: yield* Schema.decodeUnknownEffect(RecruitmentInterviewConductObservationSchema)( + yield* step(() => response.json()), ), etag, }; - }; + }); const validPayload = (body: Detail) => ({ expectedRevision: body.revision, @@ -385,128 +429,124 @@ export async function assertInterviewCorrectionBoundaries( recommendation: body.recommendation ?? "Ja", }); - const denied = async ( + const denied = Effect.fnUntraced(function* ( name: string, expected: 401 | 403, requestCookie: string | null, body: Detail, etag: string, - ) => { - const before = await snapshot(); - const read = await get(interviewId, requestCookie); + ) { + const before = yield* snapshot(); + const read = yield* step(() => get(interviewId, requestCookie)); status(`${name}:read`, read.status); - assert.equal(read.status, expected, `${name} read: ${await read.text()}`); + assert.equal(read.status, expected, `${name} read: ${yield* step(() => read.text())}`); - const write = await post( - interviewId, - validPayload(body), - etag, - freshId(`correction-boundary-${name}`), - requestCookie, + const write = yield* step(() => + post( + interviewId, + validPayload(body), + etag, + freshId(`correction-boundary-${name}`), + requestCookie, + ), ); status(`${name}:write`, write.status); - assert.equal(write.status, expected, `${name} write: ${await write.text()}`); - - const replay = await post( - interviewId, - acceptedReplay.payload, - acceptedReplay.etag, - acceptedReplay.key, - requestCookie, + assert.equal(write.status, expected, `${name} write: ${yield* step(() => write.text())}`); + + const replay = yield* step(() => + post( + interviewId, + acceptedReplay.payload, + acceptedReplay.etag, + acceptedReplay.key, + requestCookie, + ), ); status(`${name}:replay`, replay.status); - assert.equal(replay.status, expected, `${name} replay: ${await replay.text()}`); - await assertSnapshot(before, name); - }; + assert.equal(replay.status, expected, `${name} replay: ${yield* step(() => replay.text())}`); + yield* assertSnapshot(before, name); + }); - const initial = await detail(); - await denied("missing-credential", 401, null, initial.body, initial.etag); + const initial = yield* detail(); + yield* denied("missing-credential", 401, null, initial.body, initial.etag); record("anonymous detail and correction deny with no native receipt or domain writes"); const originalInterview = identity; assert.equal(originalInterview.interviewId, interviewId); - await withMutation( + yield* withMutation( (client) => - client - .query( - `UPDATE public.recruitment_interviews + execute( + client, + `UPDATE public.recruitment_interviews SET ${participantColumn}=$1 WHERE interview_id=$2`, - [otherPersonId, interviewId], - ) - .then(() => undefined), + [otherPersonId, interviewId], + ), (client) => - client - .query( - `UPDATE public.recruitment_interviews + execute( + client, + `UPDATE public.recruitment_interviews SET ${participantColumn}=$1 WHERE interview_id=$2`, - [originalInterview[participantField], interviewId], - ) - .then(() => undefined), + [originalInterview[participantField], interviewId], + ), () => denied(`wrong-${participantRole}-participant`, 403, cookie, initial.body, initial.etag), ); record(`removed ${participantRole} participant designation denies detail and correction`); - const originalMembership = ( - await readRows( - `SELECT membership_id, person_id, team_id, deleted_team_name, start_at, end_at, + const originalMembership = (yield* readRows( + `SELECT membership_id, person_id, team_id, deleted_team_name, start_at, end_at, position_id, is_team_leader, is_suspended, revision FROM public.organization_memberships WHERE membership_id=$1`, - [membershipId], - ) - )[0]; + [membershipId], + ))[0]; assert.ok(originalMembership); - await withMutation( + yield* withMutation( (client) => - client - .query( - `UPDATE public.organization_memberships + execute( + client, + `UPDATE public.organization_memberships SET end_at = start_at + interval '1 second' WHERE membership_id=$1`, - [membershipId], - ) - .then(() => undefined), + [membershipId], + ), (client) => - client - .query( - `UPDATE public.organization_memberships + execute( + client, + `UPDATE public.organization_memberships SET end_at=$1 WHERE membership_id=$2`, - [originalMembership.end_at, membershipId], - ) - .then(() => undefined), + [originalMembership.end_at, membershipId], + ), () => denied("ended-membership", 403, cookie, initial.body, initial.etag), ); record("ended membership denies detail and correction"); - await withMutation( + yield* withMutation( (client) => - client - .query( - `UPDATE public.organization_memberships + execute( + client, + `UPDATE public.organization_memberships SET is_suspended=true WHERE membership_id=$1`, - [membershipId], - ) - .then(() => undefined), + [membershipId], + ), (client) => - client - .query( - `UPDATE public.organization_memberships + execute( + client, + `UPDATE public.organization_memberships SET is_suspended=$1 WHERE membership_id=$2`, - [originalMembership.is_suspended, membershipId], - ) - .then(() => undefined), + [originalMembership.is_suspended, membershipId], + ), () => denied("suspended-membership", 403, cookie, initial.body, initial.etag), ); record("suspended membership denies detail and correction"); - const actorSessions = await readRows( + const actorSessions = yield* readRows( `SELECT id, "expiresAt", token, "createdAt", "updatedAt", "ipAddress", "userAgent", "userId" FROM auth."session" WHERE "userId" = $1 @@ -515,90 +555,86 @@ export async function assertInterviewCorrectionBoundaries( ); assert.ok(actorSessions.length > 0, "authority fixture must provide an actor session"); - await withMutation( + yield* withMutation( + (client) => execute(client, `DELETE FROM auth."session" WHERE "userId"=$1`, [actorPersonId]), (client) => - client - .query(`DELETE FROM auth."session" WHERE "userId"=$1`, [actorPersonId]) - .then(() => undefined), - async (client) => { - for (const session of actorSessions) { - await client.query( - `INSERT INTO auth."session" + Effect.forEach( + actorSessions, + (session) => + execute( + client, + `INSERT INTO auth."session" (id, "expiresAt", token, "createdAt", "updatedAt", "ipAddress", "userAgent", "userId") VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, - [ - session.id, - session["expiresAt"], - session.token, - session["createdAt"], - session["updatedAt"], - session["ipAddress"], - session["userAgent"], - session["userId"], - ], - ); - } - }, + [ + session.id, + session["expiresAt"], + session.token, + session["createdAt"], + session["updatedAt"], + session["ipAddress"], + session["userAgent"], + session["userId"], + ], + ), + { discard: true }, + ), () => denied("revoked-credential", 401, cookie, initial.body, initial.etag), ); record( "actual actor-session deletion denies detail and correction, then restores exact session rows", ); - const originalDepartment = ( - await readRows( - `SELECT department_id, name, short_name, email, address, city, latitude, + const originalDepartment = (yield* readRows( + `SELECT department_id, name, short_name, email, address, city, latitude, longitude, slack_channel, logo_path, active, revision FROM public.organization_departments WHERE department_id=$1`, - [identity.departmentId], - ) - )[0]; + [identity.departmentId], + ))[0]; assert.ok(originalDepartment); - await withMutation( + yield* withMutation( (client) => - client - .query(`UPDATE public.organization_departments SET active=false WHERE department_id=$1`, [ - identity.departmentId, - ]) - .then(() => undefined), + execute( + client, + `UPDATE public.organization_departments SET active=false WHERE department_id=$1`, + [identity.departmentId], + ), (client) => - client - .query(`UPDATE public.organization_departments SET active=$1 WHERE department_id=$2`, [ - originalDepartment.active, - identity.departmentId, - ]) - .then(() => undefined), + execute( + client, + `UPDATE public.organization_departments SET active=$1 WHERE department_id=$2`, + [originalDepartment.active, identity.departmentId], + ), () => denied("inactive-department", 403, cookie, initial.body, initial.etag), ); record("inactive department denies detail and correction"); - await withMutation( + yield* withMutation( (client) => - client - .query(`UPDATE public.recruitment_interviews SET department_id=$1 WHERE interview_id=$2`, [ - differentDepartment, - interviewId, - ]) - .then(() => undefined), + execute( + client, + `UPDATE public.recruitment_interviews SET department_id=$1 WHERE interview_id=$2`, + [differentDepartment, interviewId], + ), (client) => - client - .query(`UPDATE public.recruitment_interviews SET department_id=$1 WHERE interview_id=$2`, [ - originalInterview.departmentId, - interviewId, - ]) - .then(() => undefined), + execute( + client, + `UPDATE public.recruitment_interviews SET department_id=$1 WHERE interview_id=$2`, + [originalInterview.departmentId, interviewId], + ), () => denied("wrong-department", 403, cookie, initial.body, initial.etag), ); record("wrong department denies detail and correction"); - const detailFor = async (id: string): Promise<{ body: Detail; etag: string }> => { - const response = await get(id); + const detailFor = Effect.fnUntraced(function* (id: string) { + const response = yield* step(() => get(id)); if (response.status !== 200) { - const diagnostic = await pool.query( - `SELECT i.interview_id, i.department_id, i.interviewer_person_id, + const diagnostic = yield* step(() => + pool.query( + `SELECT i.interview_id, i.department_id, i.interviewer_person_id, i.co_interviewer_person_id, link.person_id AS linked_applicant_person_id, EXISTS ( SELECT 1 FROM public.recruitment_interview_conducts AS conduct @@ -620,12 +656,14 @@ export async function assertInterviewCorrectionBoundaries( JOIN public.admission_applications AS application USING(application_id) LEFT JOIN public.applicant_account_links AS link USING(applicant_id) WHERE i.interview_id=$1`, - [id, actorPersonId], + [id, actorPersonId], + ), ); - assert.fail( - `detail ${id} returned ${response.status}: ${await response.text()}; source=${JSON.stringify(diagnostic.rows)}`, - ); + const refusal = yield* step(() => response.text()); + const source = yield* jsonText(diagnostic.rows); + + assert.fail(`detail ${id} returned ${response.status}: ${refusal}; source=${source}`); } const etag = response.headers.get("etag"); @@ -633,14 +671,14 @@ export async function assertInterviewCorrectionBoundaries( if (etag === null) throw new Error("detail response did not include an ETag"); return { - body: Schema.decodeUnknownSync(RecruitmentInterviewConductObservationSchema)( - await response.json(), + body: yield* Schema.decodeUnknownEffect(RecruitmentInterviewConductObservationSchema)( + yield* step(() => response.json()), ), etag, }; - }; + }); - const raceSeed = await detailFor(selfLinkRaceInterviewId); + const raceSeed = yield* detailFor(selfLinkRaceInterviewId); const raceSeedPayload = { ...acceptedReplay.payload, @@ -649,125 +687,147 @@ export async function assertInterviewCorrectionBoundaries( const raceSeedKey = freshId("correction-boundary-self-link-seed"); - const raceSeedResponse = await post( - selfLinkRaceInterviewId, - raceSeedPayload, - raceSeed.etag, - raceSeedKey, + const raceSeedResponse = yield* step(() => + post(selfLinkRaceInterviewId, raceSeedPayload, raceSeed.etag, raceSeedKey), ); status("self-link-seed:write", raceSeedResponse.status); - assert.equal(raceSeedResponse.status, 200, await raceSeedResponse.text()); + assert.equal(raceSeedResponse.status, 200, yield* step(() => raceSeedResponse.text())); const acceptedRaceReplay = { key: raceSeedKey, etag: raceSeed.etag, payload: raceSeedPayload }; - const raceFresh = await detailFor(selfLinkRaceInterviewId); + const raceFresh = yield* detailFor(selfLinkRaceInterviewId); const racePayload = validPayload(raceFresh.body); - const raceBefore = await snapshot(); - const locker = await pool.connect(); - let waiting: Promise | undefined; - let exactReplayWaiting: Promise | undefined; + const raceBefore = yield* snapshot(); const raceInvitation = freshId("correction-boundary-self-link-race"); - try { - await locker.query("BEGIN"); - await locker.query( - `SELECT applicant_id FROM public.admission_applicants WHERE applicant_id=$1 FOR UPDATE`, - [raceIdentity.applicantId], - ); - exactReplayWaiting = post( - selfLinkRaceInterviewId, - acceptedRaceReplay.payload, - acceptedRaceReplay.etag, - acceptedRaceReplay.key, - ); - const lockerPid = Number((await locker.query("SELECT pg_backend_pid() AS pid")).rows[0].pid); - let exactReplayBlocked = false; + yield* withConnection((locker) => + Effect.gen(function* () { + yield* step(() => locker.query("BEGIN")); + yield* step(() => + locker.query( + `SELECT applicant_id FROM public.admission_applicants WHERE applicant_id=$1 FOR UPDATE`, + [raceIdentity.applicantId], + ), + ); + + const exactReplayWaiting = yield* Effect.forkChild( + step(() => + post( + selfLinkRaceInterviewId, + acceptedRaceReplay.payload, + acceptedRaceReplay.etag, + acceptedRaceReplay.key, + ), + ), + ); - for (let attempt = 0; attempt < 100; attempt++) { - const blockingRows = await pool.query( - `SELECT pid FROM pg_stat_activity WHERE pid <> $1 AND $1 = ANY(pg_blocking_pids(pid))`, - [lockerPid], + const lockerPid = Number( + (yield* step(() => locker.query("SELECT pg_backend_pid() AS pid"))).rows[0].pid, ); - if (blockingRows.rows.length > 0) { - exactReplayBlocked = true; - break; + let exactReplayBlocked = false; + + for (let attempt = 0; attempt < 100; attempt++) { + const blockingRows = yield* step(() => + pool.query( + `SELECT pid FROM pg_stat_activity WHERE pid <> $1 AND $1 = ANY(pg_blocking_pids(pid))`, + [lockerPid], + ), + ); + + if (blockingRows.rows.length > 0) { + exactReplayBlocked = true; + break; + } + + yield* Effect.sleep("10 millis"); } - await sleep(10); - } + assert.ok( + exactReplayBlocked, + `exact replay was not blocked by canonical applicant lock lockerPid=${lockerPid}`, + ); - assert.ok( - exactReplayBlocked, - `exact replay was not blocked by canonical applicant lock lockerPid=${lockerPid}`, - ); - waiting = post( - selfLinkRaceInterviewId, - racePayload, - raceFresh.etag, - freshId("correction-boundary-self-link-race-request"), - ); - await locker.query( - `INSERT INTO public.applicant_account_invitations + const waiting = yield* Effect.forkChild( + step(() => + post( + selfLinkRaceInterviewId, + racePayload, + raceFresh.etag, + freshId("correction-boundary-self-link-race-request"), + ), + ), + ); + + yield* step(() => + locker.query( + `INSERT INTO public.applicant_account_invitations (invitation_id, application_id, applicant_id, token_digest, expires_at, state, issued_by, issued_at) VALUES ($1,$2,$3,$4,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')+interval '24 hours','Claimed',$5,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'))`, - [ - raceInvitation, - raceIdentity.applicationId, - raceIdentity.applicantId, - createHash("sha256").update(raceInvitation).digest("hex"), - actorPersonId, - ], - ); - await locker.query( - `INSERT INTO public.applicant_account_links (applicant_id, person_id, linked_at, invitation_id) + [ + raceInvitation, + raceIdentity.applicationId, + raceIdentity.applicantId, + createHash("sha256").update(raceInvitation).digest("hex"), + actorPersonId, + ], + ), + ); + yield* step(() => + locker.query( + `INSERT INTO public.applicant_account_links (applicant_id, person_id, linked_at, invitation_id) VALUES ($1,$2,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'),$3)`, - [raceIdentity.applicantId, actorPersonId, raceInvitation], - ); - await locker.query("COMMIT"); - const exactReplayResponse = await exactReplayWaiting; - status("self-link-race:exact-replay-while-committing", exactReplayResponse.status); + [raceIdentity.applicantId, actorPersonId, raceInvitation], + ), + ); + yield* step(() => locker.query("COMMIT")); + const exactReplayResponse = yield* Fiber.join(exactReplayWaiting); + status("self-link-race:exact-replay-while-committing", exactReplayResponse.status); - const exactReplayBody = Schema.decodeUnknownSync(NativeProblem)( - await exactReplayResponse.json(), - ); + const exactReplayBody = yield* Schema.decodeUnknownEffect(NativeProblem)( + yield* step(() => exactReplayResponse.json()), + ); - assert.ok([403, 409].includes(exactReplayResponse.status), JSON.stringify(exactReplayBody)); + assert.ok([403, 409].includes(exactReplayResponse.status), yield* jsonText(exactReplayBody)); - if (exactReplayResponse.status === 409) { - assert.equal(exactReplayBody.code, "transaction.conflict"); - } + if (exactReplayResponse.status === 409) { + assert.equal(exactReplayBody.code, "transaction.conflict"); + } - const raceResponse = await waiting; - status("self-link-race:write", raceResponse.status); - const raceBody = Schema.decodeUnknownSync(NativeProblem)(await raceResponse.json()); - assert.ok([403, 409].includes(raceResponse.status), JSON.stringify(raceBody)); - - if (raceResponse.status === 409) assert.equal(raceBody.code, "transaction.conflict"); - const deniedRead = await get(selfLinkRaceInterviewId); - status("self-link-race:read-after-commit", deniedRead.status); - assert.equal(deniedRead.status, 403, await deniedRead.text()); - - const replay = await post( - selfLinkRaceInterviewId, - acceptedRaceReplay.payload, - acceptedRaceReplay.etag, - acceptedRaceReplay.key, - ); + const raceResponse = yield* Fiber.join(waiting); + status("self-link-race:write", raceResponse.status); - status("self-link-race:replay", replay.status); - assert.equal(replay.status, 403, await replay.text()); - const raceAfterResponse = await snapshot(); - assertCorrectionRowsUnchanged(raceBefore, raceAfterResponse, "self-link race response"); - } finally { - await locker.query("ROLLBACK").catch(() => undefined); - locker.release(); - } + const raceBody = yield* Schema.decodeUnknownEffect(NativeProblem)( + yield* step(() => raceResponse.json()), + ); + + assert.ok([403, 409].includes(raceResponse.status), yield* jsonText(raceBody)); + + if (raceResponse.status === 409) assert.equal(raceBody.code, "transaction.conflict"); + const deniedRead = yield* step(() => get(selfLinkRaceInterviewId)); + status("self-link-race:read-after-commit", deniedRead.status); + assert.equal(deniedRead.status, 403, yield* step(() => deniedRead.text())); + + const replay = yield* step(() => + post( + selfLinkRaceInterviewId, + acceptedRaceReplay.payload, + acceptedRaceReplay.etag, + acceptedRaceReplay.key, + ), + ); + + status("self-link-race:replay", replay.status); + assert.equal(replay.status, 403, yield* step(() => replay.text())); + const raceAfterResponse = yield* snapshot(); + assertCorrectionRowsUnchanged(raceBefore, raceAfterResponse, "self-link race response"); + }), + ); record( "genuine accepted self-link fixture correction is denied or transaction-conflicted after committed self-link; fresh read and exact replay deny", ); - const current = await detail(); + const current = yield* detail(); const currentPayload = validPayload(current.body); const currentAnswers = currentPayload.answers; assert.ok( @@ -817,49 +877,75 @@ export async function assertInterviewCorrectionBoundaries( ["null-recommendation", { ...currentPayload, recommendation: null }], ]; + const semanticInvalidCases = new Set([ + "unknown-question-same-cardinality", + "duplicate-question-same-cardinality", + "missing-question-entry", + ]); + + const decodeMalformed = Schema.decodeOption( + Schema.fromJsonString(Schema.Struct({ code: Schema.Literal("request.malformed") })), + ); + for (const [name, invalid] of invalidCases) { - const before = await snapshot(); + const before = yield* snapshot(); const response = name === "malformed-json" - ? await postRaw( + ? yield* postRaw( interviewId, - Schema.decodeUnknownSync(Schema.String)(invalid), + yield* Schema.decodeUnknownEffect(Schema.String)(invalid), current.etag, freshId(`correction-boundary-invalid-${name}`), ) - : await post( - interviewId, - invalid, - current.etag, - freshId(`correction-boundary-invalid-${name}`), + : yield* step(() => + post( + interviewId, + invalid, + current.etag, + freshId(`correction-boundary-invalid-${name}`), + ), ); status(`invalid:${name}`, response.status); - assert.equal(response.status, name === "malformed-json" ? 400 : 422, await response.text()); - await assertSnapshot(before, `invalid ${name}`); + + // Domain validation of the answers still answers recruitment.conduct-invalid (422). A payload + // outside its schema fails in the RPC server before the handler, as a defect (500). A body that + // is not JSON is refused by the ingress before any RPC runs, as request.malformed (400). + if (name === "malformed-json") { + const refusal = yield* step(() => response.text()); + + assert.equal(response.status, 400, refusal); + + assert.ok(Option.isSome(decodeMalformed(refusal)), refusal); + } else { + assert.equal( + response.status, + semanticInvalidCases.has(name) ? 422 : 500, + yield* step(() => response.text()), + ); + } + + yield* assertSnapshot(before, `invalid ${name}`); } record( - "malformed JSON, missing answer/question entry, and strict schema failures map to 400/422; each leaves exact state unchanged", + "malformed RPC bodies and schema failures are refused before the handler, and answer mismatches map to 422; each leaves exact state unchanged", ); - const sameKeyBefore = await snapshot(); + const sameKeyBefore = yield* snapshot(); const conflictingPayload = { ...currentPayload, recommendation: currentPayload.recommendation === "Nei" ? "Ja" : "Nei", }; - const sameKeyResponse = await post( - interviewId, - conflictingPayload, - current.etag, - acceptedReplay.key, + const sameKeyResponse = yield* step(() => + post(interviewId, conflictingPayload, current.etag, acceptedReplay.key), ); status("same-key-different-payload", sameKeyResponse.status); - assert.equal(sameKeyResponse.status, 409, await sameKeyResponse.text()); - await assertSnapshot(sameKeyBefore, "same-key different payload"); + assert.equal(sameKeyResponse.status, 409, yield* step(() => sameKeyResponse.text())); + yield* assertSnapshot(sameKeyBefore, "same-key different payload"); record( "same idempotency key with a different payload returns digest conflict without any owned write", ); @@ -876,16 +962,20 @@ export async function assertInterviewCorrectionBoundaries( ] as const; const quote = (identifier: string) => `"${identifier}"`; - const rollbackClient = await pool.connect(); - - try { - await rollbackClient.query("BEGIN"); - await rollbackClient.query(`CREATE SEQUENCE public.${quote(rollbackSequence)}`); - await rollbackClient.query( - `CREATE TABLE public.${quote(rollbackControl)} (stage integer NOT NULL CHECK (stage BETWEEN 1 AND 4))`, - ); - await rollbackClient.query(`INSERT INTO public.${quote(rollbackControl)} (stage) VALUES (1)`); - await rollbackClient.query(` + yield* withConnection((rollbackClient) => + Effect.gen(function* () { + yield* step(() => rollbackClient.query("BEGIN")); + yield* step(() => rollbackClient.query(`CREATE SEQUENCE public.${quote(rollbackSequence)}`)); + yield* step(() => + rollbackClient.query( + `CREATE TABLE public.${quote(rollbackControl)} (stage integer NOT NULL CHECK (stage BETWEEN 1 AND 4))`, + ), + ); + yield* step(() => + rollbackClient.query(`INSERT INTO public.${quote(rollbackControl)} (stage) VALUES (1)`), + ); + yield* step(() => + rollbackClient.query(` CREATE FUNCTION public.${quote(rollbackFunction)}() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE configured integer; BEGIN @@ -896,123 +986,134 @@ export async function assertInterviewCorrectionBoundaries( END IF; RETURN NEW; END $$; - `); - - for (const [, table, stage, event] of rollbackTriggers) { - await rollbackClient.query( - `CREATE TRIGGER ${quote(`${rollbackFunction}_${stage}`)} AFTER ${event} ON public.${table} - FOR EACH ROW EXECUTE FUNCTION public.${quote(rollbackFunction)}('${stage}')`, + `), ); - } - - await rollbackClient.query("COMMIT"); - } finally { - await rollbackClient.query("ROLLBACK").catch(() => undefined); - rollbackClient.release(); - } - try { - for (const [name, , stage] of rollbackTriggers) { - const setup = await pool.connect(); - - try { - await setup.query("BEGIN"); - await setup.query(`UPDATE public.${quote(rollbackControl)} SET stage=$1`, [Number(stage)]); - await setup.query(`ALTER SEQUENCE public.${quote(rollbackSequence)} RESTART WITH 1`); - await setup.query("COMMIT"); - } finally { - await setup.query("ROLLBACK").catch(() => undefined); - setup.release(); + for (const [, table, stage, event] of rollbackTriggers) { + yield* step(() => + rollbackClient.query( + `CREATE TRIGGER ${quote(`${rollbackFunction}_${stage}`)} AFTER ${event} ON public.${table} + FOR EACH ROW EXECUTE FUNCTION public.${quote(rollbackFunction)}('${stage}')`, + ), + ); } - const before = await snapshot(); + yield* step(() => rollbackClient.query("COMMIT")); + }), + ); - const response = await post( - interviewId, - currentPayload, - current.etag, - freshId(`correction-boundary-rollback-${name}`), - ); + yield* withCleanup( + Effect.gen(function* () { + for (const [name, , stage] of rollbackTriggers) { + yield* withConnection((setup) => + Effect.gen(function* () { + yield* step(() => setup.query("BEGIN")); + yield* step(() => + setup.query(`UPDATE public.${quote(rollbackControl)} SET stage=$1`, [Number(stage)]), + ); + yield* step(() => + setup.query(`ALTER SEQUENCE public.${quote(rollbackSequence)} RESTART WITH 1`), + ); + yield* step(() => setup.query("COMMIT")); + }), + ); - status(`rollback:${name}`, response.status); + const before = yield* snapshot(); - const failureBody = Schema.decodeUnknownSync(NativeProblem)(await response.json()); + const response = yield* step(() => + post( + interviewId, + currentPayload, + current.etag, + freshId(`correction-boundary-rollback-${name}`), + ), + ); - assert.equal(response.status, 503, JSON.stringify(failureBody)); - assert.equal(failureBody.code, "dependency.unavailable", JSON.stringify(failureBody)); + status(`rollback:${name}`, response.status); - const marker = await pool.query( - `SELECT last_value, is_called FROM public.${quote(rollbackSequence)}`, - ); + const failureBody = yield* Schema.decodeUnknownEffect(NativeProblem)( + yield* step(() => response.json()), + ); - assert.equal(marker.rows[0]?.is_called, true, `${name} rollback marker did not fire`); - assert.equal( - Number(marker.rows[0]?.last_value), - Number(stage), - `${name} rollback probe stopped before injected stage`, - ); - await assertSnapshot(before, `rollback after ${name}`); - } + assert.equal(response.status, 503, yield* jsonText(failureBody)); + assert.equal(failureBody.code, "dependency.unavailable", yield* jsonText(failureBody)); - record( - "each aggregate, assessment, domain-receipt, and audit failure trigger fired and rolled back all domain and native HTTP receipt rows", - ); - } finally { - const cleanup = await pool.connect(); - - try { - await cleanup.query("BEGIN"); + const marker = yield* step(() => + pool.query(`SELECT last_value, is_called FROM public.${quote(rollbackSequence)}`), + ); - for (const [, table, stage] of rollbackTriggers) { - await cleanup.query( - `DROP TRIGGER IF EXISTS ${quote(`${rollbackFunction}_${stage}`)} ON public.${table}`, + assert.equal(marker.rows[0]?.is_called, true, `${name} rollback marker did not fire`); + assert.equal( + Number(marker.rows[0]?.last_value), + Number(stage), + `${name} rollback probe stopped before injected stage`, ); + yield* assertSnapshot(before, `rollback after ${name}`); } - await cleanup.query(`DROP FUNCTION IF EXISTS public.${quote(rollbackFunction)}()`); - await cleanup.query(`DROP TABLE IF EXISTS public.${quote(rollbackControl)}`); - await cleanup.query(`DROP SEQUENCE IF EXISTS public.${quote(rollbackSequence)}`); - await cleanup.query("COMMIT"); - } finally { - await cleanup.query("ROLLBACK").catch(() => undefined); - cleanup.release(); - } - } + record( + "each aggregate, assessment, domain-receipt, and audit failure trigger fired and rolled back all domain and native HTTP receipt rows", + ); + }), + withConnection((cleanup) => + Effect.gen(function* () { + yield* step(() => cleanup.query("BEGIN")); + + for (const [, table, stage] of rollbackTriggers) { + yield* step(() => + cleanup.query( + `DROP TRIGGER IF EXISTS ${quote(`${rollbackFunction}_${stage}`)} ON public.${table}`, + ), + ); + } - const differentLinkInvitation = freshId("correction-boundary-different-link"); - const differentLinkClient = await pool.connect(); + yield* step(() => + cleanup.query(`DROP FUNCTION IF EXISTS public.${quote(rollbackFunction)}()`), + ); + yield* step(() => cleanup.query(`DROP TABLE IF EXISTS public.${quote(rollbackControl)}`)); + yield* step(() => + cleanup.query(`DROP SEQUENCE IF EXISTS public.${quote(rollbackSequence)}`), + ); + yield* step(() => cleanup.query("COMMIT")); + }), + ), + ); - try { - await differentLinkClient.query("BEGIN"); - await differentLinkClient.query( - `INSERT INTO public.applicant_account_invitations + const differentLinkInvitation = freshId("correction-boundary-different-link"); + yield* withConnection((differentLinkClient) => + Effect.gen(function* () { + yield* step(() => differentLinkClient.query("BEGIN")); + yield* step(() => + differentLinkClient.query( + `INSERT INTO public.applicant_account_invitations (invitation_id, application_id, applicant_id, token_digest, expires_at, state, issued_by, issued_at) VALUES ($1,$2,$3,$4,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')+interval '24 hours','Claimed',$5,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'))`, - [ - differentLinkInvitation, - identity.applicationId, - identity.applicantId, - createHash("sha256").update(differentLinkInvitation).digest("hex"), - actorPersonId, - ], - ); - await differentLinkClient.query( - `INSERT INTO public.applicant_account_links (applicant_id, person_id, linked_at, invitation_id) + [ + differentLinkInvitation, + identity.applicationId, + identity.applicantId, + createHash("sha256").update(differentLinkInvitation).digest("hex"), + actorPersonId, + ], + ), + ); + yield* step(() => + differentLinkClient.query( + `INSERT INTO public.applicant_account_links (applicant_id, person_id, linked_at, invitation_id) VALUES ($1,$2,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'),$3)`, - [identity.applicantId, otherPersonId, differentLinkInvitation], - ); - await differentLinkClient.query("COMMIT"); - } finally { - await differentLinkClient.query("ROLLBACK").catch(() => undefined); - differentLinkClient.release(); - } + [identity.applicantId, otherPersonId, differentLinkInvitation], + ), + ); + yield* step(() => differentLinkClient.query("COMMIT")); + }), + ); - const differentRead = await get(interviewId); + const differentRead = yield* step(() => get(interviewId)); status("different-linked-person:read", differentRead.status); - assert.equal(differentRead.status, 200, await differentRead.text()); + assert.equal(differentRead.status, 200, yield* step(() => differentRead.text())); record( "different-person applicant link is a committed teardown-owned fixture and remains readable without correction writes", ); return { gates, statuses }; -} +}); diff --git a/tools/acceptance/interview-correction-integrity.ts b/tools/acceptance/interview-correction-integrity.ts index 7013e3c2b..a8cdd8dc7 100644 --- a/tools/acceptance/interview-correction-integrity.ts +++ b/tools/acceptance/interview-correction-integrity.ts @@ -6,7 +6,9 @@ import { import assert from "node:assert/strict"; import { randomBytes } from "node:crypto"; import type { Pool, PoolClient } from "pg"; -import { Predicate, Schema } from "effect"; +import { Effect, Predicate, Schema } from "effect"; +import { canonicalJson } from "../../packages/domain/src/shared-kernel/index.js"; +import { type JourneyStepFailed, rolledBack, step, withPoolClient } from "./journey-step.ts"; type CorrectionSeed = Readonly<{ predecessorRevision: number; @@ -26,61 +28,73 @@ type PersistedSnapshot = Readonly<{ audit: ReadonlyArray; }>; -type DatabaseError = Readonly<{ - message?: unknown; - constraint?: unknown; -}>; +// pg's DatabaseError always carries `constraint`, undefined when no constraint failed. +const DatabaseError = Schema.Struct({ + message: Schema.String, + constraint: Schema.optional(Schema.String), +}); export type InterviewCorrectionIntegrityOptions = Readonly<{ + pool: Pool; + interviewId: string; expectedCorrectedByPersonId?: string; expectedCoInterviewerPersonId?: string | null; }>; const freshId = (prefix: string) => `${prefix}-${randomBytes(12).toString("hex")}`; -const queryRows = async (client: PostgresQueryable, text: string, values: unknown[] = []) => - decodePostgresObservations((await client.query(text, values)).rows); +const queryRows = Effect.fnUntraced(function* ( + client: PostgresQueryable, + text: string, + values: unknown[] = [], +) { + return decodePostgresObservations((yield* step(() => client.query(text, values))).rows); +}); -const readSnapshot = async (pool: Pool, interviewId: string): Promise => ({ - aggregate: await queryRows( - pool, - `SELECT interview_id, revision, co_interviewer_person_id +const readSnapshot = Effect.fnUntraced(function* (pool: Pool, interviewId: string) { + const snapshot: PersistedSnapshot = { + aggregate: yield* queryRows( + pool, + `SELECT interview_id, revision, co_interviewer_person_id FROM public.recruitment_interviews WHERE interview_id=$1`, - [interviewId], - ), - assessments: await queryRows( - pool, - `SELECT interview_id, predecessor_revision, resulting_revision, answers, + [interviewId], + ), + assessments: yield* queryRows( + pool, + `SELECT interview_id, predecessor_revision, resulting_revision, answers, explanatory_power, role_model, suitability, recommendation, corrected_by_person_id, corrected_at, command_id FROM public.recruitment_interview_correction_assessments WHERE interview_id=$1 ORDER BY resulting_revision`, - [interviewId], - ), - receipts: await queryRows( - pool, - `SELECT command_id, command_sha256, command_json, observation_json, + [interviewId], + ), + receipts: yield* queryRows( + pool, + `SELECT command_id, command_sha256, command_json, observation_json, interview_id, predecessor_revision, resulting_revision, committed_at FROM public.recruitment_interview_correction_command_receipts WHERE interview_id=$1 ORDER BY resulting_revision, command_id`, - [interviewId], - ), - audit: await queryRows( - pool, - `SELECT command_id, interview_id, actor_person_id, predecessor_revision, + [interviewId], + ), + audit: yield* queryRows( + pool, + `SELECT command_id, interview_id, actor_person_id, predecessor_revision, resulting_revision, occurred_at FROM public.recruitment_interview_correction_audit WHERE interview_id=$1 ORDER BY resulting_revision, command_id`, - [interviewId], - ), + [interviewId], + ), + }; + + return snapshot; }); -const readCorrectionSeed = async (pool: Pool, interviewId: string): Promise => { - const rows = await queryRows( +const readCorrectionSeed = Effect.fnUntraced(function* (pool: Pool, interviewId: string) { + const rows = yield* queryRows( pool, `SELECT predecessor_revision AS "predecessorRevision", resulting_revision AS "resultingRevision", answers, @@ -107,7 +121,7 @@ const readCorrectionSeed = async (pool: Pool, interviewId: string): Promise { - const rows = await queryRows( + return seed; +}); + +const readCommandIds = Effect.fnUntraced(function* (pool: Pool, interviewId: string) { + const rows = yield* queryRows( pool, `SELECT command_id AS "commandId" FROM public.recruitment_interview_correction_assessments @@ -131,38 +147,32 @@ const readCommandIds = async (pool: Pool, interviewId: string) => { ); assert.equal(rows.length, 1); - assert.ok(Predicate.isString(rows[0]!.commandId)); + const commandId = rows[0]!.commandId; + assert.ok(Predicate.isString(commandId)); - return Schema.decodeSync(Schema.String)(rows[0]!.commandId); -}; + return commandId; +}); -const assertRejectedAndUnchanged = async ( +const assertRejectedAndUnchanged = Effect.fnUntraced(function* ( pool: Pool, interviewId: string, label: string, - mutation: (client: PoolClient) => Promise, + mutation: (client: PoolClient) => Effect.Effect, expected: { message?: RegExp; constraint?: string }, -) => { - const before = await readSnapshot(pool, interviewId); - const client = await pool.connect(); - let failure: DatabaseError | undefined; - - try { - await client.query("BEGIN"); - - try { - await mutation(client); - } catch (cause) { - // pg's DatabaseError always carries `constraint`, undefined when no constraint failed. - failure = Schema.decodeUnknownSync( - Schema.Struct({ message: Schema.String, constraint: Schema.optional(Schema.String) }), - )(cause); - } - - await client.query("ROLLBACK"); - } finally { - client.release(); - } +) { + const before = yield* readSnapshot(pool, interviewId); + + const failure = yield* withPoolClient(pool, (client) => + rolledBack( + client, + mutation(client).pipe( + Effect.as(undefined), + Effect.catchTag("JourneyStepFailed", (failed) => + Schema.decodeUnknownEffect(DatabaseError)(failed.cause), + ), + ), + ), + ); assert.ok(failure, `${label} unexpectedly succeeded`); assert.ok(Predicate.isString(failure.message), `${label} did not return a database error`); @@ -176,80 +186,85 @@ const assertRejectedAndUnchanged = async ( } assert.deepEqual( - await readSnapshot(pool, interviewId), + yield* readSnapshot(pool, interviewId), before, `${label} changed persisted state`, ); -}; +}); -const insertAssessment = async ( +const insertAssessment = Effect.fnUntraced(function* ( client: PoolClient, interviewId: string, seed: CorrectionSeed, commandId: string, predecessorRevision: number, resultingRevision: number, -) => { - await client.query( - `INSERT INTO public.recruitment_interview_correction_assessments +) { + yield* step(() => + client.query( + `INSERT INTO public.recruitment_interview_correction_assessments (interview_id, predecessor_revision, resulting_revision, answers, explanatory_power, role_model, suitability, recommendation, corrected_by_person_id, corrected_at, command_id) VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'),$10)`, - [ - interviewId, - predecessorRevision, - resultingRevision, - JSON.stringify(seed.answers), - seed.explanatoryPower, - seed.roleModel, - seed.suitability, - seed.recommendation, - seed.correctedByPersonId, - commandId, - ], + [ + interviewId, + predecessorRevision, + resultingRevision, + canonicalJson(seed.answers), + seed.explanatoryPower, + seed.roleModel, + seed.suitability, + seed.recommendation, + seed.correctedByPersonId, + commandId, + ], + ), ); -}; +}); -const alignAggregateForCandidate = async ( +const alignAggregateForCandidate = Effect.fnUntraced(function* ( client: PoolClient, interviewId: string, resultingRevision: number, -) => { - await client.query( - `UPDATE public.recruitment_interviews +) { + yield* step(() => + client.query( + `UPDATE public.recruitment_interviews SET revision=$2 WHERE interview_id=$1`, - [interviewId, resultingRevision], + [interviewId, resultingRevision], + ), ); -}; +}); -const insertReceipt = async ( +const insertReceipt = Effect.fnUntraced(function* ( client: PoolClient, commandId: string, interviewId: string, predecessorRevision: number, resultingRevision: number, -) => { - await client.query( - `INSERT INTO public.recruitment_interview_correction_command_receipts +) { + yield* step(() => + client.query( + `INSERT INTO public.recruitment_interview_correction_command_receipts (command_id, command_sha256, command_json, observation_json, interview_id, predecessor_revision, resulting_revision, committed_at) VALUES ($1, repeat('a', 64), '{}'::jsonb, '{}'::jsonb, $2, $3, $4, date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC'))`, - [commandId, interviewId, predecessorRevision, resultingRevision], + [commandId, interviewId, predecessorRevision, resultingRevision], + ), ); -}; +}); -export async function assertInterviewCorrectionIntegrity( - pool: Pool, - interviewId: string, - options: InterviewCorrectionIntegrityOptions = {}, -): Promise { - const seed = await readCorrectionSeed(pool, interviewId); - const commandId = await readCommandIds(pool, interviewId); +export const assertInterviewCorrectionIntegrity = Effect.fnUntraced(function* ( + options: InterviewCorrectionIntegrityOptions, +) { + const { pool, interviewId } = options; + const seed = yield* readCorrectionSeed(pool, interviewId); + const commandId = yield* readCommandIds(pool, interviewId); const currentRevision = seed.resultingRevision; const candidateRevision = currentRevision + 1; - const baseline = await readSnapshot(pool, interviewId); + const baseline = yield* readSnapshot(pool, interviewId); assert.equal(baseline.aggregate.length, 1, "the corrected interview aggregate must exist"); assert.equal(baseline.aggregate[0]!.revision, currentRevision); @@ -268,7 +283,7 @@ export async function assertInterviewCorrectionIntegrity( "latest correction must retain the expected actor", ); - const audit = await queryRows( + const audit = yield* queryRows( pool, `SELECT actor_person_id FROM public.recruitment_interview_correction_audit @@ -279,89 +294,101 @@ export async function assertInterviewCorrectionIntegrity( assert.deepEqual(audit, [{ actor_person_id: options.expectedCorrectedByPersonId }]); } - await assertRejectedAndUnchanged( + yield* assertRejectedAndUnchanged( pool, interviewId, "assessment update immutability", (client) => - client.query( - `UPDATE public.recruitment_interview_correction_assessments + step(() => + client.query( + `UPDATE public.recruitment_interview_correction_assessments SET recommendation = CASE recommendation WHEN 'Ja' THEN 'Nei' ELSE 'Ja' END WHERE interview_id=$1 AND resulting_revision=$2`, - [interviewId, currentRevision], + [interviewId, currentRevision], + ), ), { message: /corrections are immutable/i }, ); - await assertRejectedAndUnchanged( + yield* assertRejectedAndUnchanged( pool, interviewId, "assessment delete immutability", (client) => - client.query( - `DELETE FROM public.recruitment_interview_correction_assessments + step(() => + client.query( + `DELETE FROM public.recruitment_interview_correction_assessments WHERE interview_id=$1 AND resulting_revision=$2`, - [interviewId, currentRevision], + [interviewId, currentRevision], + ), ), { message: /corrections are immutable/i }, ); - await assertRejectedAndUnchanged( + yield* assertRejectedAndUnchanged( pool, interviewId, "receipt update immutability", (client) => - client.query( - `UPDATE public.recruitment_interview_correction_command_receipts + step(() => + client.query( + `UPDATE public.recruitment_interview_correction_command_receipts SET observation_json='{}'::jsonb WHERE command_id=$1`, - [commandId], + [commandId], + ), ), { message: /corrections are immutable/i }, ); - await assertRejectedAndUnchanged( + yield* assertRejectedAndUnchanged( pool, interviewId, "receipt delete immutability", (client) => - client.query( - `DELETE FROM public.recruitment_interview_correction_command_receipts + step(() => + client.query( + `DELETE FROM public.recruitment_interview_correction_command_receipts WHERE command_id=$1`, - [commandId], + [commandId], + ), ), { message: /corrections are immutable/i }, ); - await assertRejectedAndUnchanged( + yield* assertRejectedAndUnchanged( pool, interviewId, "audit update immutability", (client) => - client.query( - `UPDATE public.recruitment_interview_correction_audit + step(() => + client.query( + `UPDATE public.recruitment_interview_correction_audit SET occurred_at=date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC') WHERE command_id=$1`, - [commandId], + [commandId], + ), ), { message: /corrections are immutable/i }, ); - await assertRejectedAndUnchanged( + yield* assertRejectedAndUnchanged( pool, interviewId, "audit delete immutability", (client) => - client.query( - `DELETE FROM public.recruitment_interview_correction_audit + step(() => + client.query( + `DELETE FROM public.recruitment_interview_correction_audit WHERE command_id=$1`, - [commandId], + [commandId], + ), ), { message: /corrections are immutable/i }, ); - await assertRejectedAndUnchanged( + yield* assertRejectedAndUnchanged( pool, interviewId, "skipped correction predecessor", - async (client) => { - await alignAggregateForCandidate(client, interviewId, candidateRevision); - await insertAssessment( + Effect.fnUntraced(function* (client) { + yield* alignAggregateForCandidate(client, interviewId, candidateRevision); + yield* insertAssessment( client, interviewId, seed, @@ -369,10 +396,10 @@ export async function assertInterviewCorrectionIntegrity( currentRevision - 1, candidateRevision, ); - }, + }), { message: /predecessor must be the current effective revision/i }, ); - await assertRejectedAndUnchanged( + yield* assertRejectedAndUnchanged( pool, interviewId, "aggregate revision mismatch", @@ -388,14 +415,14 @@ export async function assertInterviewCorrectionIntegrity( { message: /aggregate revision must match correction revision/i }, ); - await assertRejectedAndUnchanged( + yield* assertRejectedAndUnchanged( pool, interviewId, "receipt assessment tuple mismatch", - async (client) => { + Effect.fnUntraced(function* (client) { const candidateCommandId = freshId("integrity-receipt"); - await alignAggregateForCandidate(client, interviewId, candidateRevision); - await insertAssessment( + yield* alignAggregateForCandidate(client, interviewId, candidateRevision); + yield* insertAssessment( client, interviewId, seed, @@ -403,25 +430,25 @@ export async function assertInterviewCorrectionIntegrity( currentRevision, candidateRevision, ); - await insertReceipt( + yield* insertReceipt( client, candidateCommandId, interviewId, seed.predecessorRevision, seed.resultingRevision, ); - }, + }), { constraint: "correction_receipt_assessment_chain_fk" }, ); - await assertRejectedAndUnchanged( + yield* assertRejectedAndUnchanged( pool, interviewId, "audit receipt tuple mismatch", - async (client) => { + Effect.fnUntraced(function* (client) { const candidateCommandId = freshId("integrity-audit"); - await alignAggregateForCandidate(client, interviewId, candidateRevision); - await insertAssessment( + yield* alignAggregateForCandidate(client, interviewId, candidateRevision); + yield* insertAssessment( client, interviewId, seed, @@ -429,27 +456,29 @@ export async function assertInterviewCorrectionIntegrity( currentRevision, candidateRevision, ); - await insertReceipt( + yield* insertReceipt( client, candidateCommandId, interviewId, currentRevision, candidateRevision, ); - await client.query( - `INSERT INTO public.recruitment_interview_correction_audit + yield* step(() => + client.query( + `INSERT INTO public.recruitment_interview_correction_audit (command_id, interview_id, actor_person_id, predecessor_revision, resulting_revision, occurred_at) VALUES ($1,$2,$3,$4,$5,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'))`, - [ - candidateCommandId, - interviewId, - seed.correctedByPersonId, - seed.predecessorRevision, - seed.resultingRevision, - ], + [ + candidateCommandId, + interviewId, + seed.correctedByPersonId, + seed.predecessorRevision, + seed.resultingRevision, + ], + ), ); - }, + }), { constraint: "correction_audit_receipt_chain_fk" }, ); -} +}); diff --git a/tools/acceptance/interview-report-observation.ts b/tools/acceptance/interview-report-observation.ts index 4f4146f48..aee63c72c 100644 --- a/tools/acceptance/interview-report-observation.ts +++ b/tools/acceptance/interview-report-observation.ts @@ -3,15 +3,38 @@ import { RecruitmentInterviewConductObservationSchema } from "../../packages/dom import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { createRequire } from "node:module"; -import { join } from "node:path"; -import { writeFile } from "node:fs/promises"; import { InterviewReport, InterviewReportQuery, InterviewReportRow, } from "../../packages/domain/src/recruitment/report.js"; -import { Match, Predicate, Schema } from "effect"; +import { + Effect, + Fiber, + FileSystem, + Match, + Path, + type PlatformError, + Predicate, + Schema, +} from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { + jsonText, + type JourneyStepFailed, + step, + thrownBy, + withPoolClient, +} from "./journey-step.ts"; +import { indentedJsonText, ProbeFailure } from "./acceptance-process.ts"; +import { nativeRpcPath } from "../../packages/rpc/src/api.js"; +import { + nativeRpcOutcome, + nativeRpcRequestBody, + nativeRpcStatus, + nativeRpcValue, +} from "../../apps/dashboard/e2e/native-operations.js"; const ids = { person: "report-coordinator-0103", @@ -77,18 +100,31 @@ type Options = { correctionMode: boolean; returningPopulated?: boolean; expectedHistoricalReportRecommendation?: "Ja" | "Kanskje" | "Nei"; - auditPage: (page: any, state: string) => Promise; + auditPage: ( + page: any, + state: string, + ) => Effect.Effect; recordGate: (...observations: string[]) => void; }; -export async function seedInterviewReportCoordinator(o: { pool: any; secrets: string[] }) { +export const seedInterviewReportCoordinator = Effect.fnUntraced(function* (o: { + pool: any; + secrets: string[]; +}) { const { pool, secrets } = o; - const clone = async (table: string, where: string, values: Schema.JsonObject) => - pool.query( - `INSERT INTO ${table} SELECT (jsonb_populate_record(NULL::${table},to_jsonb(s)||$1::jsonb)).* FROM ${table} s WHERE ${where} ON CONFLICT DO NOTHING`, - [JSON.stringify(values)], + const clone = Effect.fnUntraced(function* ( + table: string, + where: string, + values: Schema.JsonObject, + ) { + return yield* step(() => + pool.query( + `INSERT INTO ${table} SELECT (jsonb_populate_record(NULL::${table},to_jsonb(s)||$1::jsonb)).* FROM ${table} s WHERE ${where} ON CONFLICT DO NOTHING`, + [JSON.stringify(values)], + ), ); + }); const ids = { person: "report-coordinator-0103", @@ -100,21 +136,21 @@ export async function seedInterviewReportCoordinator(o: { pool: any; secrets: st const email = "coordinator.report@example.invalid"; if (!secrets.includes(email)) secrets.push(email); - await clone("public.person_profiles", "person_id='journey-conduct-leader-0063'", { + yield* clone("public.person_profiles", "person_id='journey-conduct-leader-0063'", { person_id: ids.person, first_name: "Report", last_name: "Coordinator", }); - await clone("public.person_contact_profiles", "person_id='journey-conduct-leader-0063'", { + yield* clone("public.person_contact_profiles", "person_id='journey-conduct-leader-0063'", { person_id: ids.person, email, }); - await clone('auth."user"', "id='journey-conduct-leader-0063'", { + yield* clone('auth."user"', "id='journey-conduct-leader-0063'", { id: ids.person, email, name: "Report Coordinator", }); - await clone('auth."account"', "\"userId\"='journey-conduct-leader-0063'", { + yield* clone('auth."account"', "\"userId\"='journey-conduct-leader-0063'", { id: "report-account-0103", userId: ids.person, accountId: ids.person, @@ -123,15 +159,18 @@ export async function seedInterviewReportCoordinator(o: { pool: any; secrets: st // independent department, so the leadership reaches the department (O8-11). The conduct seed // also runs against the pre-0037 schema, so the classification is set here; every clone of the // team or department below, and in recommendation-check.ts, inherits it. - await pool.query( - `UPDATE public.organization_teams SET kind='DepartmentBoard' WHERE team_id=$1`, - [ids.team], + yield* step(() => + pool.query(`UPDATE public.organization_teams SET kind='DepartmentBoard' WHERE team_id=$1`, [ + ids.team, + ]), ); - await pool.query( - `UPDATE public.organization_departments SET independent=true WHERE department_id=$1`, - [ids.department], + yield* step(() => + pool.query( + `UPDATE public.organization_departments SET independent=true WHERE department_id=$1`, + [ids.department], + ), ); - await clone( + yield* clone( "public.organization_memberships", "membership_id='membership-native-conduct-leader-0063'", { @@ -141,11 +180,13 @@ export async function seedInterviewReportCoordinator(o: { pool: any; secrets: st position_id: "teamleader", }, ); -} +}); -export async function observeInterviewReport(o: Options) { +export const observeInterviewReport = Effect.fnUntraced(function* (o: Options) { const { pool, api, ui } = o; - const require = createRequire(join(o.root, "apps/dashboard/package.json")); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const require = createRequire(path.join(o.root, "apps/dashboard/package.json")); const { expect } = require("@playwright/test"); const gates: string[] = []; @@ -154,30 +195,37 @@ export async function observeInterviewReport(o: Options) { o.recordGate(...observations); }; - await seedInterviewReportCoordinator({ pool, secrets: o.secrets }); + yield* seedInterviewReportCoordinator({ pool, secrets: o.secrets }); const email = "coordinator.report@example.invalid"; - const clone = async (table: string, where: string, values: Schema.JsonObject) => - pool.query( - `INSERT INTO ${table} SELECT (jsonb_populate_record(NULL::${table},to_jsonb(s)||$1::jsonb)).* FROM ${table} s WHERE ${where}`, - [JSON.stringify(values)], + const clone = Effect.fnUntraced(function* ( + table: string, + where: string, + values: Schema.JsonObject, + ) { + return yield* step(() => + pool.query( + `INSERT INTO ${table} SELECT (jsonb_populate_record(NULL::${table},to_jsonb(s)||$1::jsonb)).* FROM ${table} s WHERE ${where}`, + [JSON.stringify(values)], + ), ); + }); - await clone("public.organization_departments", `department_id='${ids.department}'`, { + yield* clone("public.organization_departments", `department_id='${ids.department}'`, { department_id: ids.otherDepartment, name: "Other report department", short_name: "Other0103", email: "other.report@example.invalid", }); - await clone("public.organization_teams", `team_id='${ids.team}'`, { + yield* clone("public.organization_teams", `team_id='${ids.team}'`, { team_id: ids.otherTeam, department_id: ids.otherDepartment, }); - await clone("public.admission_period_departments", `department_id='${ids.department}'`, { + yield* clone("public.admission_period_departments", `department_id='${ids.department}'`, { department_id: ids.otherDepartment, name: "Other report", }); - await clone( + yield* clone( "public.admission_period_fields_of_study", "field_of_study_id='field-native-conduct-0063'", { field_of_study_id: "report-other-field-0103", department_id: ids.otherDepartment }, @@ -188,12 +236,16 @@ export async function observeInterviewReport(o: Options) { [ids.empty, 2023, ids.department], [ids.foreign, 2025, ids.otherDepartment], ] as const) { - await clone("public.admission_period_semesters", "semester_id='semester-native-conduct-0063'", { - semester_id: `${period}-semester`, - start_at: `${year}-01-01T00:00:00Z`, - end_at: `${year}-12-31T23:59:59Z`, - }); - await clone("public.admission_periods", `admission_period_id='${ids.period}'`, { + yield* clone( + "public.admission_period_semesters", + "semester_id='semester-native-conduct-0063'", + { + semester_id: `${period}-semester`, + start_at: `${year}-01-01T00:00:00Z`, + end_at: `${year}-12-31T23:59:59Z`, + }, + ); + yield* clone("public.admission_periods", `admission_period_id='${ids.period}'`, { admission_period_id: period, semester_id: `${period}-semester`, department_id: department, @@ -202,17 +254,17 @@ export async function observeInterviewReport(o: Options) { }); } - await clone("public.organization_departments", `department_id='${ids.department}'`, { + yield* clone("public.organization_departments", `department_id='${ids.department}'`, { department_id: "report-empty-department", name: "Empty report department", short_name: "Empty0103", email: "empty.report@example.invalid", }); - await clone("public.organization_teams", `team_id='${ids.team}'`, { + yield* clone("public.organization_teams", `team_id='${ids.team}'`, { team_id: "report-empty-team", department_id: "report-empty-department", }); - await clone("public.admission_period_departments", `department_id='${ids.department}'`, { + yield* clone("public.admission_period_departments", `department_id='${ids.department}'`, { department_id: "report-empty-department", name: "Empty report", }); @@ -223,7 +275,7 @@ export async function observeInterviewReport(o: Options) { interview = `report-interview-${f.key}`; const department = f.key === "foreign" ? ids.otherDepartment : ids.department; - await clone("public.admission_applicants", "applicant_id='applicant-native-conduct-a-0063'", { + yield* clone("public.admission_applicants", "applicant_id='applicant-native-conduct-a-0063'", { applicant_id: applicant, field_of_study_id: f.key === "foreign" ? "report-other-field-0103" : "field-native-conduct-0063", @@ -232,7 +284,7 @@ export async function observeInterviewReport(o: Options) { first_name: "Report", last_name: f.key.startsWith("tie-") ? "Tie" : f.key, }); - await clone( + yield* clone( "public.admission_applications", "application_id='application-native-conduct-a-0063'", { @@ -248,12 +300,16 @@ export async function observeInterviewReport(o: Options) { department_id: department, }, ); - await clone("public.recruitment_interviews", "interview_id='interview-native-conduct-a-0063'", { - interview_id: interview, - application_id: application, - department_id: department, - }); - await clone( + yield* clone( + "public.recruitment_interviews", + "interview_id='interview-native-conduct-a-0063'", + { + interview_id: interview, + application_id: application, + department_id: department, + }, + ); + yield* clone( "public.recruitment_interview_conducts", "interview_id='interview-native-conduct-a-0063'", { @@ -266,130 +322,162 @@ export async function observeInterviewReport(o: Options) { ); } - const link = async (key: string, client = pool) => { + const link = Effect.fnUntraced(function* (key: string, client = pool) { const invitation = `report-link-${key}`; - await client.query( - `INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES($1,$2,$3,$4,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')+interval '24 hours','Claimed',$5,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'))`, - [ - invitation, - `report-application-${key}`, - `report-applicant-${key}`, - createHash("sha256").update(invitation).digest("hex"), - ids.person, - ], + yield* step(() => + client.query( + `INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES($1,$2,$3,$4,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')+interval '24 hours','Claimed',$5,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'))`, + [ + invitation, + `report-application-${key}`, + `report-applicant-${key}`, + createHash("sha256").update(invitation).digest("hex"), + ids.person, + ], + ), ); - await client.query( - `INSERT INTO public.applicant_account_links VALUES($1,$2,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'),$3)`, - [`report-applicant-${key}`, ids.person, invitation], + yield* step(() => + client.query( + `INSERT INTO public.applicant_account_links VALUES($1,$2,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'),$3)`, + [`report-applicant-${key}`, ids.person, invitation], + ), ); - }; + }); - await link("known-self"); + yield* link("known-self"); - const signIn = await fetch(`${api}/api/auth/sign-in/email`, { - method: "POST", - headers: { origin: ui, "content-type": "application/json" }, - body: JSON.stringify({ email, password: o.password }), - }); + const signIn = yield* HttpClient.execute( + HttpClientRequest.post(`${api}/api/auth/sign-in/email`).pipe( + HttpClientRequest.setHeaders({ origin: ui }), + HttpClientRequest.bodyText( + yield* jsonText({ email, password: o.password }), + "application/json", + ), + ), + ); assert.equal(signIn.status, 200); - const cookie = signIn.headers - .getSetCookie() - .map((v) => v.split(";")[0]) + const cookie = Object.values(signIn.cookies.cookies) + .map((sent) => `${sent.name}=${sent.valueEncoded}`) .join("; "); assert.ok(cookie); o.secrets.push(cookie, ...cookie.split("; ").map((v) => v.slice(v.indexOf("=") + 1))); - assert.equal((await signIn.json()).user.id, ids.person); - - const get = ( - query: Record = {}, - session = cookie, - extra: Record = {}, - ) => - fetch(`${api}/api/recruitment/interview-report?${new URLSearchParams(query).toString()}`, { - headers: { cookie: session, origin: ui, ...extra }, + assert.equal( + (yield* Schema.decodeUnknownEffect( + Schema.Struct({ user: Schema.Struct({ id: Schema.String }) }), + )(yield* signIn.json)).user.id, + ids.person, + ); + + /** + * One RPC as a browser posts it, with the session cookie and the dashboard origin. The payload is + * hand built, so a value that its schema refuses reaches the server, which answers a defect. + */ + const rpc = (tag: string, payload: Schema.Json, session: string) => + Effect.gen(function* () { + const response = yield* HttpClient.execute( + HttpClientRequest.post(`${api}${nativeRpcPath}`).pipe( + HttpClientRequest.setHeaders( + session !== "" ? { origin: ui, cookie: session } : { origin: ui }, + ), + HttpClientRequest.bodyText(nativeRpcRequestBody(tag, payload), "application/json"), + ), + ); + + const text = yield* response.text; + const outcome = nativeRpcOutcome(text); + + return { + status: nativeRpcStatus(text) ?? response.status, + value: nativeRpcValue(text), + problem: Predicate.isTagged(outcome, "Problem") ? outcome.problem : undefined, + text, + }; }); - const read = async (query: Record = {}): Promise => { - const response = await get(query); + const get = (query: Record = {}, session = cookie) => + rpc("recruitment.readInterviewReport", query, session); - if (response.status !== 200) { - const problem = await response.json().catch(() => ({})); + /** The code of the problem that an RPC answered; an answer without one fails the gate. */ + const problemCode = (response: { + readonly problem: { readonly code: string } | undefined; + readonly text: string; + }) => { + assert.ok(response.problem !== undefined, response.text); + + return response.problem.code; + }; - const authority = ( - await pool.query( + const read = Effect.fnUntraced(function* (query: Record = {}) { + const response = yield* get(query); + + if (response.status !== 200) { + const authority = (yield* step(() => + pool.query( `SELECT m.membership_id,m.is_team_leader,m.is_suspended,m.end_at,t.team_id,t.kind team_kind,t.active team_active,d.department_id,d.independent department_independent,d.active department_active FROM public.organization_memberships m JOIN public.organization_teams t USING(team_id) JOIN public.organization_departments d USING(department_id) WHERE m.person_id=$1`, [ids.person], - ) - ).rows; + ), + )).rows; throw new Error( - JSON.stringify({ + yield* jsonText({ gate: "report read", query, status: response.status, - code: problem.code, + problem: response.text, authority, }), ); } - assert.match(response.headers.get("cache-control") ?? "", /no-store/); - - return Schema.decodeUnknownSync(InterviewReport)(await response.json(), { + return yield* Schema.decodeUnknownEffect(InterviewReport)(response.value, { onExcessProperty: "error", }); - }; + }); - const snapshot = async () => { - const tables = ( - await pool.query( + const snapshot = Effect.fnUntraced(function* () { + const tables = (yield* step(() => + pool.query( `SELECT schemaname,tablename FROM pg_tables WHERE schemaname IN ('public','auth') ORDER BY schemaname,tablename`, - ) - ).rows; + ), + )).rows; const hashes = []; for (const t of tables) hashes.push([ `${t.schemaname}.${t.tablename}`, - ( - await pool.query( + (yield* step(() => + pool.query( `SELECT md5(coalesce(string_agg(to_jsonb(t)::text,'|' ORDER BY to_jsonb(t)::text),'')) hash FROM "${t.schemaname}"."${t.tablename}" t`, - ) - ).rows[0].hash, + ), + )).rows[0].hash, ]); return hashes; - }; + }); - const before = await snapshot(); - const unselected = await read(); + const before = yield* snapshot(); + const unselected = yield* read(); assert.equal(unselected.selectedPeriodId, null); assert.equal(unselected.rows.length, 0); assert.ok(unselected.periods.some((p) => p.id === ids.closed)); assert.ok(!unselected.periods.some((p) => p.id === ids.foreign)); - const { createPromiseClient } = require("@vektorprogrammet/sdk"); - - const sdkResult = await createPromiseClient(api, { - cookie, - origin: ui, - }).recruitment.readInterviewReport({ - query: Schema.decodeSync(InterviewReportQuery)({ admissionPeriodId: ids.period }), - }); - const report: InterviewReport = Schema.decodeUnknownSync(InterviewReport)(sdkResult.body, { - onExcessProperty: "error", - }); + const report: InterviewReport = yield* read( + yield* Schema.encodeEffect(InterviewReportQuery)( + yield* Schema.decodeEffect(InterviewReportQuery)({ admissionPeriodId: ids.period }), + ), + ); - const sqlRows = ( - await pool.query( + const sqlRows = (yield* step(() => + pool.query( `SELECT i.interview_id FROM public.recruitment_interviews i JOIN public.admission_applications a USING(application_id) JOIN public.recruitment_interview_conducts c USING(interview_id) LEFT JOIN public.applicant_account_links l USING(applicant_id) WHERE a.admission_period_id=$1 AND (l.person_id IS NULL OR l.person_id<>$2) ORDER BY i.interview_id`, [ids.period, ids.person], - ) - ).rows; + ), + )).rows; assert.deepEqual( report.rows.map((r) => r.interviewId).sort(), @@ -422,8 +510,8 @@ export async function observeInterviewReport(o: Options) { ].sort(), ); - const persisted = ( - await pool.query( + const persisted = (yield* step(() => + pool.query( `SELECT COALESCE(correction.explanatory_power, conduct.explanatory_power) AS explanatory_power, COALESCE(correction.role_model, conduct.role_model) AS role_model, @@ -439,8 +527,8 @@ export async function observeInterviewReport(o: Options) { ) correction ON TRUE WHERE conduct.interview_id=$1`, [row.interviewId], - ) - ).rows[0]; + ), + )).rows[0]; assert.deepEqual( [row.explanatoryPower, row.roleModel, row.suitability, row.recommendation], @@ -454,7 +542,7 @@ export async function observeInterviewReport(o: Options) { } for (const filter of ["Ja", "Kanskje", "Nei", "not-recorded"]) { - const filtered = await read({ admissionPeriodId: ids.period, recommendation: filter }); + const filtered = yield* read({ admissionPeriodId: ids.period, recommendation: filter }); assert.deepEqual( filtered.rows, report.rows.filter((r) => (r.recommendation ?? "not-recorded") === filter), @@ -468,7 +556,7 @@ export async function observeInterviewReport(o: Options) { } for (const filter of ["Returning", "Unknown"]) { - const filtered = await read({ admissionPeriodId: ids.period, participation: filter }); + const filtered = yield* read({ admissionPeriodId: ids.period, participation: filter }); assert.deepEqual( filtered.rows, report.rows.filter((r) => r.participation === filter), @@ -483,7 +571,7 @@ export async function observeInterviewReport(o: Options) { for (const sort of ["applicant", "recommendation", "total"]) for (const direction of ["asc", "desc"]) { - const rows = (await read({ admissionPeriodId: ids.period, sort, direction })).rows; + const rows = (yield* read({ admissionPeriodId: ids.period, sort, direction })).rows; const values = rows.map((r) => Match.value(sort).pipe( @@ -502,24 +590,32 @@ export async function observeInterviewReport(o: Options) { } } - assert.equal((await read({ admissionPeriodId: ids.closed })).rows.length, 1); - assert.equal((await read({ admissionPeriodId: ids.empty })).rows.length, 0); - assert.equal((await get({ admissionPeriodId: ids.foreign })).status, 403); - assert.equal((await get({}, "")).status, 401); - assert.equal((await get({}, o.ordinaryCookie)).status, 403); + assert.equal((yield* read({ admissionPeriodId: ids.closed })).rows.length, 1); + assert.equal((yield* read({ admissionPeriodId: ids.empty })).rows.length, 0); + assert.equal((yield* get({ admissionPeriodId: ids.foreign })).status, 403); + assert.equal((yield* get({}, "")).status, 401); + assert.equal((yield* get({}, o.ordinaryCookie)).status, 403); + + const conduct = (session: string) => + rpc( + "recruitment.readInterviewConduct", + { interviewId: "interview-native-conduct-a-0063" }, + session, + ); - const assignedDetailResponse = await fetch( - `${api}/api/recruitment/interviews/interview-native-conduct-a-0063`, - { headers: { cookie: o.ordinaryCookie, origin: ui } }, - ); + const assignedDetailResponse = yield* conduct(o.ordinaryCookie); - assert.equal(assignedDetailResponse.status, 200, await assignedDetailResponse.clone().text()); + assert.equal(assignedDetailResponse.status, 200, assignedDetailResponse.text); - const assignedDetail = Schema.decodeUnknownSync(RecruitmentInterviewConductObservationSchema)( - await assignedDetailResponse.json(), - ); + const assignedResource = yield* Schema.decodeUnknownEffect( + Schema.Struct({ detail: Schema.Json, etag: Schema.String }), + )(assignedDetailResponse.value); - const assignedETag = assignedDetailResponse.headers.get("etag"); + const assignedDetail = yield* Schema.decodeUnknownEffect( + RecruitmentInterviewConductObservationSchema, + )(assignedResource.detail); + + const assignedETag = assignedResource.etag; assert.ok(Predicate.isString(assignedETag)); assert.ok(Predicate.isNumber(assignedDetail.revision)); assert.ok(Array.isArray(assignedDetail.answers)); @@ -529,96 +625,82 @@ export async function observeInterviewReport(o: Options) { ); assert.notEqual(assignedDetail.recommendation, null); - const coordinatorDetailResponse = await fetch( - `${api}/api/recruitment/interviews/interview-native-conduct-a-0063`, - { headers: { cookie, origin: ui } }, - ); + const coordinatorDetailResponse = yield* conduct(cookie); - assert.equal(coordinatorDetailResponse.status, 403, await coordinatorDetailResponse.text()); + assert.equal(coordinatorDetailResponse.status, 403, coordinatorDetailResponse.text); - const coordinatorCorrectionResponse = await fetch( - `${api}/api/recruitment/interviews/interview-native-conduct-a-0063:correct`, + const coordinatorCorrectionResponse = yield* rpc( + "recruitment.correctInterviewAssessment", { - method: "POST", - headers: { - cookie, - origin: ui, - "content-type": "application/json", - "if-match": assignedETag, - "idempotency-key": "report-coordinator-correction-denied-0103", - }, - body: JSON.stringify({ + interviewId: "interview-native-conduct-a-0063", + idempotencyKey: "report-coordinator-correction-denied-0103", + ifMatch: assignedETag, + request: yield* Schema.encodeEffect(Schema.Json)({ expectedRevision: assignedDetail.revision, answers: assignedDetail.answers, score: assignedDetail.score, recommendation: assignedDetail.recommendation, }), }, + cookie, ); - assert.equal( - coordinatorCorrectionResponse.status, - 403, - await coordinatorCorrectionResponse.text(), - ); - assert.deepEqual(await snapshot(), before); + assert.equal(coordinatorCorrectionResponse.status, 403, coordinatorCorrectionResponse.text); + assert.deepEqual(yield* snapshot(), before); record( "report coordinator detail and correction deny for a non-assigned interview; no co-interviewer grant or writes", ); - assert.equal( - ( - await fetch(`${api}/api/recruitment/interviews/interview-native-conduct-a-0063`, { - headers: { cookie, origin: ui }, - }) - ).status, - 403, - ); + assert.equal((yield* conduct(cookie)).status, 403); + + // The payload schema drops an unknown member, and refuses a value outside it before the + // handler: the server answers a defect where the HTTP query answered 400 or 422. + assert.equal((yield* get({ departmentId: ids.department })).status, 200); for (const [key, value] of [ - ["departmentId", ids.department], ["sort", "bogus"], ["recommendation", ""], ] as const) - assert.ok([400, 422].includes((await get({ [key]: value })).status)); - assert.deepEqual(await snapshot(), before); + assert.equal((yield* get({ [key]: value })).status, 500); + assert.deepEqual(yield* snapshot(), before); record( "real ordinary-interviewer conduct reported to non-assigned leader; exact population/projection/history/SQL and closed/empty periods; sort/filter/ties; no report writes; raw conduct remains denied", ); - await pool.query( - `UPDATE public.organization_memberships SET team_id='report-empty-team' WHERE membership_id=$1`, - [ids.member], + yield* step(() => + pool.query( + `UPDATE public.organization_memberships SET team_id='report-empty-team' WHERE membership_id=$1`, + [ids.member], + ), ); - const emptyDepartmentBefore = await snapshot(); - const emptyDepartment = await read(); + const emptyDepartmentBefore = yield* snapshot(); + const emptyDepartment = yield* read(); assert.deepEqual(emptyDepartment.periods, []); assert.deepEqual(emptyDepartment.rows, []); - assert.deepEqual(await snapshot(), emptyDepartmentBefore); - await pool.query(`UPDATE public.organization_memberships SET team_id=$1 WHERE membership_id=$2`, [ - ids.team, - ids.member, - ]); + assert.deepEqual(yield* snapshot(), emptyDepartmentBefore); + yield* step(() => + pool.query(`UPDATE public.organization_memberships SET team_id=$1 WHERE membership_id=$2`, [ + ids.team, + ids.member, + ]), + ); record("authorized department without periods returns explicit empty selection and no rows"); - const priorEtag = (await get({ admissionPeriodId: ids.period })).headers.get("etag"); - const oldCondition = priorEtag ?? "*"; + // The report carries no entity tag over RPC, so a denial takes no prior conditional token. const authorityDenials: Array<{ condition: string; status: number; code: string }> = []; - const denyMutation = async (setup: string, restore: string) => { - await pool.query(setup); - const baseline = await snapshot(); + const denyMutation = Effect.fnUntraced(function* (setup: string, restore: string) { + yield* step(() => pool.query(setup)); + const baseline = yield* snapshot(); - const denial = await get({ admissionPeriodId: ids.period }, cookie, { - "if-none-match": oldCondition, - }); + const denial = yield* get({ admissionPeriodId: ids.period }, cookie); const status = denial.status; assert.ok([401, 403].includes(status)); - const code = (await denial.json()).code; + const code = problemCode(denial); assert.ok(["authority.denied", "credential.invalid"].includes(code)); authorityDenials.push({ condition: setup, status, code }); - assert.deepEqual(await snapshot(), baseline); - await pool.query(restore); - }; + assert.deepEqual(yield* snapshot(), baseline); + yield* step(() => pool.query(restore)); + }); for (const [field, bad, good] of [ ["is_suspended", "true", "false"], @@ -626,122 +708,134 @@ export async function observeInterviewReport(o: Options) { ["end_at", "start_at+interval '24 hours'", "NULL"], ["is_team_leader", "false", "true"], ]) - await denyMutation( + yield* denyMutation( `UPDATE public.organization_memberships SET ${field}=${bad} WHERE membership_id='${ids.member}'`, `UPDATE public.organization_memberships SET ${field}=${good} WHERE membership_id='${ids.member}'`, ); - await denyMutation( + yield* denyMutation( `UPDATE public.organization_teams SET active=false WHERE team_id='${ids.team}'`, `UPDATE public.organization_teams SET active=true WHERE team_id='${ids.team}'`, ); - await denyMutation( + yield* denyMutation( `UPDATE public.organization_departments SET active=false WHERE department_id='${ids.department}'`, `UPDATE public.organization_departments SET active=true WHERE department_id='${ids.department}'`, ); - await clone("public.organization_memberships", `membership_id='${ids.member}'`, { + yield* clone("public.organization_memberships", `membership_id='${ids.member}'`, { membership_id: "report-ambiguous-membership", team_id: ids.otherTeam, }); - const ambiguousResponse = await get(); + const ambiguousResponse = yield* get(); const ambiguousStatus = ambiguousResponse.status; assert.ok([401, 403].includes(ambiguousStatus)); authorityDenials.push({ condition: "ambiguous-department", status: ambiguousStatus, - code: (await ambiguousResponse.json()).code, + code: problemCode(ambiguousResponse), }); - await pool.query( - `DELETE FROM public.organization_memberships WHERE membership_id='report-ambiguous-membership'`, + yield* step(() => + pool.query( + `DELETE FROM public.organization_memberships WHERE membership_id='report-ambiguous-membership'`, + ), ); - await pool.query( - `INSERT INTO public.organization_global_administrator_grants(grant_id,person_id,start_at) VALUES('report-admin-grant',$1,'2026-01-01')`, - [ids.person], + yield* step(() => + pool.query( + `INSERT INTO public.organization_global_administrator_grants(grant_id,person_id,start_at) VALUES('report-admin-grant',$1,'2026-01-01')`, + [ids.person], + ), ); - await denyMutation( + yield* denyMutation( `DELETE FROM public.organization_memberships WHERE membership_id='${ids.member}'`, `INSERT INTO public.organization_memberships(membership_id,person_id,team_id,start_at,position_id,is_team_leader,is_suspended,revision) VALUES('${ids.member}','${ids.person}','${ids.team}','2026-01-01','teamleader',true,false,0)`, ); - await pool.query( - `DELETE FROM public.organization_global_administrator_grants WHERE grant_id='report-admin-grant'`, + yield* step(() => + pool.query( + `DELETE FROM public.organization_global_administrator_grants WHERE grant_id='report-admin-grant'`, + ), ); record( "anonymous/member/admin-only/ambiguous/revoked/ended/suspended/inactive scope denied, including prior conditional token", ); let concurrentReadStatus: number | undefined; - const locker = await pool.connect(); + yield* withPoolClient(pool, (locker: any) => + Effect.gen(function* () { + yield* step(() => locker.query("BEGIN")); + yield* step(() => + locker.query( + `SELECT applicant_id FROM public.admission_applicants WHERE applicant_id='report-applicant-race' FOR UPDATE`, + ), + ); + const pid = (yield* step(() => locker.query("SELECT pg_backend_pid() pid"))).rows[0].pid; + const waiting = yield* Effect.forkChild(get({ admissionPeriodId: ids.period })); + let blocked = false; + + for (let n = 0; n < 100; n++) { + blocked = + (yield* step(() => + pool.query( + `SELECT count(*)::int n FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid))`, + [pid], + ), + )).rows[0].n > 0; - try { - await locker.query("BEGIN"); - await locker.query( - `SELECT applicant_id FROM public.admission_applicants WHERE applicant_id='report-applicant-race' FOR UPDATE`, - ); - const pid = (await locker.query("SELECT pg_backend_pid() pid")).rows[0].pid; - const waiting = get({ admissionPeriodId: ids.period }); - let blocked = false; - - for (let n = 0; n < 100; n++) { - blocked = - ( - await pool.query( - `SELECT count(*)::int n FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid))`, - [pid], - ) - ).rows[0].n > 0; + if (blocked) break; + yield* Effect.sleep("50 millis"); + } - if (blocked) break; - await new Promise((r) => setTimeout(r, 50)); - } + assert.ok(blocked, "report held by authoritative applicant custody"); + yield* link("race", locker); + yield* step(() => locker.query("COMMIT")); + const afterLink = yield* snapshot(); + const response = yield* Fiber.join(waiting); + concurrentReadStatus = response.status; + assert.ok([200, 409, 503].includes(response.status)); - assert.ok(blocked, "report held by authoritative applicant custody"); - await link("race", locker); - await locker.query("COMMIT"); - const afterLink = await snapshot(); - const response = await waiting; - concurrentReadStatus = response.status; - assert.ok([200, 409, 503].includes(response.status)); - - if (response.status === 200) - assert.ok( - !(await response.json()).rows.some((r: any) => r.interviewId === "report-interview-race"), - ); + if (response.status === 200) + assert.ok( + !(yield* Schema.decodeUnknownEffect(InterviewReport)(response.value)).rows.some( + (r) => r.interviewId === "report-interview-race", + ), + ); - for (const filter of ["all", "Ja", "Nei", "Kanskje", "not-recorded"]) - assert.ok( - !(await read({ admissionPeriodId: ids.period, recommendation: filter })).rows.some((r) => - ["report-interview-race", "report-interview-known-self"].includes(r.interviewId), - ), - ); - assert.deepEqual(await snapshot(), afterLink); - } finally { - await locker.query("ROLLBACK"); - locker.release(); - } + for (const filter of ["all", "Ja", "Nei", "Kanskje", "not-recorded"]) + assert.ok( + !(yield* read({ admissionPeriodId: ids.period, recommendation: filter })).rows.some((r) => + ["report-interview-race", "report-interview-known-self"].includes(r.interviewId), + ), + ); + assert.deepEqual(yield* snapshot(), afterLink); + }).pipe(Effect.ensuring(step(() => locker.query("ROLLBACK")).pipe(Effect.orDie))), + ); record( "known self excluded from every filter/count; absent/different links eligible; actual concurrent link/read custody excludes newly known self", ); - const context = await o.browser.newContext({ viewport: { width: 1280, height: 900 } }); + const context = yield* step(() => + o.browser.newContext({ viewport: { width: 1280, height: 900 } }), + ); + const errors: string[] = []; const expectedFaults: string[] = []; let intentionalReadFailure = false; - try { + return yield* Effect.gen(function* () { for (const part of cookie.split("; ")) { const n = part.indexOf("="); - await context.addCookies([ - { - name: part.slice(0, n), - value: part.slice(n + 1), - url: ui, - httpOnly: true, - sameSite: "Lax", - }, - ]); + yield* step(() => + context.addCookies([ + { + name: part.slice(0, n), + value: part.slice(n + 1), + url: ui, + httpOnly: true, + sameSite: "Lax", + }, + ]), + ); } - const page = await context.newPage(); + const page = yield* step(() => context.newPage()); page.on("pageerror", () => errors.push("pageerror")); page.on("console", (m: any) => { if (m.type() === "error") { @@ -750,12 +844,14 @@ export async function observeInterviewReport(o: Options) { else errors.push("console-error"); } }); - const baseline = await snapshot(); - await page.goto(`${ui}/dashboard/intervjuer`); - await page.getByRole("link", { name: "Fullførte intervjuer", exact: true }).click(); - await expect(page.getByRole("status")).toContainText("Velg en opptaksperiode"); + const baseline = yield* snapshot(); + yield* step(() => page.goto(`${ui}/dashboard/intervjuer`)); + yield* step(() => + page.getByRole("link", { name: "Fullførte intervjuer", exact: true }).click(), + ); + yield* step(() => expect(page.getByRole("status")).toContainText("Velg en opptaksperiode")); - const submit = async () => { + const submit = Effect.fnUntraced(function* () { const fields = new URLSearchParams(); for (const name of [ @@ -765,26 +861,30 @@ export async function observeInterviewReport(o: Options) { "sort", "direction", ]) - fields.set(name, await page.locator(`[name="${name}"]`).inputValue()); + fields.set(name, yield* step(() => page.locator(`[name="${name}"]`).inputValue())); const target = new URL(page.url()); target.search = fields.toString(); - await page.getByRole("button", { name: "Vis rapport", exact: true }).focus(); - await page.keyboard.press("Enter"); - await expect(page).toHaveURL(target.href); - await expect(page.locator('section[aria-labelledby="report-heading"]')).toHaveAttribute( - "aria-busy", - "false", + yield* step(() => page.getByRole("button", { name: "Vis rapport", exact: true }).focus()); + yield* step(() => page.keyboard.press("Enter")); + yield* step(() => expect(page).toHaveURL(target.href)); + yield* step(() => + expect(page.locator('section[aria-labelledby="report-heading"]')).toHaveAttribute( + "aria-busy", + "false", + ), ); - }; + }); - await page.getByLabel("Opptaksperiode", { exact: true }).selectOption(ids.period); - await submit(); - const expected = (await read({ admissionPeriodId: ids.period })).rows.length; - await expect(page.getByRole("status")).toHaveText(`${expected} fullførte intervjuer`); + yield* step(() => page.getByLabel("Opptaksperiode", { exact: true }).selectOption(ids.period)); + yield* submit(); + const expected = (yield* read({ admissionPeriodId: ids.period })).rows.length; + yield* step(() => + expect(page.getByRole("status")).toHaveText(`${expected} fullførte intervjuer`), + ); - const assertRendered = async () => { + const assertRendered = Effect.fnUntraced(function* () { const query = Object.fromEntries(new URL(page.url()).searchParams); - const expectedRows = (await read(query)).rows; + const expectedRows = (yield* read(query)).rows; const expectedCells = expectedRows.map((r) => [ `${r.firstName} ${r.lastName}`, @@ -796,123 +896,158 @@ export async function observeInterviewReport(o: Options) { String(r.explanatoryPower + r.roleModel + r.suitability), ]); - await expect - .poll(async () => - page.locator("tbody tr").evaluateAll((elements: any[]) => - elements.map((element: any) => - Array.from(element.querySelectorAll("th,td")) - .filter((_, index) => index !== 2) - .map((cell: any) => cell.textContent.trim()), + yield* step(() => + expect + .poll(() => + page.locator("tbody tr").evaluateAll((elements: any[]) => + elements.map((element: any) => + Array.from(element.querySelectorAll("th,td")) + .filter((_, index) => index !== 2) + .map((cell: any) => cell.textContent.trim()), + ), ), - ), - ) - .toEqual(expectedCells); - }; + ) + .toEqual(expectedCells), + ); + }); - await assertRendered(); + yield* assertRendered(); for (const label of ["Søker", "Anbefaling", "Sum"]) { for (let n = 0; n < 2; n++) { const control = page.getByRole("link", { name: label, exact: true }); - const expectedUrl = new URL(await control.getAttribute("href"), page.url()).href; - await control.focus(); - await page.keyboard.press("Enter"); - await expect(page).toHaveURL(expectedUrl); - await expect(page.locator('section[aria-labelledby="report-heading"]')).toHaveAttribute( - "aria-busy", - "false", + + const expectedUrl = new URL(yield* step(() => control.getAttribute("href")), page.url()) + .href; + + yield* step(() => control.focus()); + yield* step(() => page.keyboard.press("Enter")); + yield* step(() => expect(page).toHaveURL(expectedUrl)); + yield* step(() => + expect(page.locator('section[aria-labelledby="report-heading"]')).toHaveAttribute( + "aria-busy", + "false", + ), ); - await expect(control.locator("..")).toHaveAttribute( - "aria-sort", - new URL(page.url()).searchParams.get("direction") === "desc" ? "descending" : "ascending", + yield* step(() => + expect(control.locator("..")).toHaveAttribute( + "aria-sort", + new URL(page.url()).searchParams.get("direction") === "desc" + ? "descending" + : "ascending", + ), ); - await assertRendered(); + yield* assertRendered(); } } - await page.getByLabel("Anbefaling", { exact: true }).focus(); - await page.keyboard.press("End"); - await expect(page.getByLabel("Anbefaling", { exact: true })).toHaveValue("not-recorded"); - await submit(); - await assertRendered(); - await expect(page.getByRole("status")).toHaveText( - `${o.correctionMode ? 0 : 1} fullførte intervjuer`, + yield* step(() => page.getByLabel("Anbefaling", { exact: true }).focus()); + yield* step(() => page.keyboard.press("End")); + yield* step(() => + expect(page.getByLabel("Anbefaling", { exact: true })).toHaveValue("not-recorded"), + ); + yield* submit(); + yield* assertRendered(); + yield* step(() => + expect(page.getByRole("status")).toHaveText( + `${o.correctionMode ? 0 : 1} fullførte intervjuer`, + ), ); if (o.correctionMode) { - await expect(page.locator("tbody tr")).toHaveCount(0); + yield* step(() => expect(page.locator("tbody tr")).toHaveCount(0)); } else { - await expect(page.locator("tbody")).toContainText("Ikke registrert"); + yield* step(() => expect(page.locator("tbody")).toContainText("Ikke registrert")); } const selectedUrl = page.url(); - await page.reload(); + yield* step(() => page.reload()); assert.equal(page.url(), selectedUrl); - await expect(page.getByLabel("Anbefaling", { exact: true })).toHaveValue("not-recorded"); - await o.auditPage(page, "report-desktop-filtered"); - await page.screenshot({ path: join(o.artifacts, "report-desktop.png") }); - await page.setViewportSize({ width: 390, height: 844 }); - await page.getByLabel("Anbefaling", { exact: true }).focus(); - await o.auditPage(page, "report-mobile-focus"); - await page.screenshot({ path: join(o.artifacts, "report-mobile.png") }); - await page.getByLabel("Anbefaling", { exact: true }).selectOption("all"); - await submit(); - await expect(page.getByRole("status")).toHaveText(`${expected} fullførte intervjuer`); + yield* step(() => + expect(page.getByLabel("Anbefaling", { exact: true })).toHaveValue("not-recorded"), + ); + yield* o.auditPage(page, "report-desktop-filtered"); + yield* step(() => page.screenshot({ path: path.join(o.artifacts, "report-desktop.png") })); + yield* step(() => page.setViewportSize({ width: 390, height: 844 })); + yield* step(() => page.getByLabel("Anbefaling", { exact: true }).focus()); + yield* o.auditPage(page, "report-mobile-focus"); + yield* step(() => page.screenshot({ path: path.join(o.artifacts, "report-mobile.png") })); + yield* step(() => page.getByLabel("Anbefaling", { exact: true }).selectOption("all")); + yield* submit(); + yield* step(() => + expect(page.getByRole("status")).toHaveText(`${expected} fullførte intervjuer`), + ); const sumFocus = page.getByRole("link", { name: "Sum", exact: true }); - await sumFocus.focus(); - await sumFocus.scrollIntoViewIfNeeded(); - await expect(sumFocus).toBeFocused(); - const sumBounds = await sumFocus.boundingBox(); + yield* step(() => sumFocus.focus()); + yield* step(() => sumFocus.scrollIntoViewIfNeeded()); + yield* step(() => expect(sumFocus).toBeFocused()); + const sumBounds = yield* step(() => sumFocus.boundingBox()); assert.ok(sumBounds && sumBounds.x >= 0 && sumBounds.x + sumBounds.width <= 390); - await o.auditPage(page, "report-mobile-sum-focus"); - await page.screenshot({ path: join(o.artifacts, "report-mobile-sum.png") }); - await page.getByLabel("Opptaksperiode", { exact: true }).selectOption(ids.closed); - await submit(); - await expect(page.getByRole("status")).toHaveText("1 fullførte intervjuer"); - await page.getByLabel("Opptaksperiode", { exact: true }).selectOption(ids.empty); - await submit(); - await expect(page.getByRole("status")).toHaveText("0 fullførte intervjuer"); - await expect(page.getByText("Ingen fullførte intervjuer samsvarer med valgene.")).toBeVisible(); + yield* o.auditPage(page, "report-mobile-sum-focus"); + yield* step(() => page.screenshot({ path: path.join(o.artifacts, "report-mobile-sum.png") })); + yield* step(() => page.getByLabel("Opptaksperiode", { exact: true }).selectOption(ids.closed)); + yield* submit(); + yield* step(() => expect(page.getByRole("status")).toHaveText("1 fullførte intervjuer")); + yield* step(() => page.getByLabel("Opptaksperiode", { exact: true }).selectOption(ids.empty)); + yield* submit(); + yield* step(() => expect(page.getByRole("status")).toHaveText("0 fullførte intervjuer")); + yield* step(() => + expect(page.getByText("Ingen fullførte intervjuer samsvarer med valgene.")).toBeVisible(), + ); // Actual backend SQL read failure, restored before retry. No mutation of stored conduct. intentionalReadFailure = true; - await pool.query( - "ALTER TABLE public.recruitment_interview_conducts RENAME TO report_temporarily_unavailable_conducts", + yield* step(() => + pool.query( + "ALTER TABLE public.recruitment_interview_conducts RENAME TO report_temporarily_unavailable_conducts", + ), ); - try { - await page.getByLabel("Opptaksperiode", { exact: true }).selectOption(ids.period); - await page.getByRole("button", { name: "Vis rapport", exact: true }).click(); - await expect(page.getByRole("alert")).toContainText("Rapporten kunne ikke hentes"); - await expect(page.locator("tbody")).toHaveCount(0); - await o.auditPage(page, "report-failed-read"); - await page.screenshot({ path: join(o.artifacts, "report-failure-mobile.png") }); - } finally { - await pool.query( - "ALTER TABLE public.report_temporarily_unavailable_conducts RENAME TO recruitment_interview_conducts", + yield* Effect.gen(function* () { + yield* step(() => + page.getByLabel("Opptaksperiode", { exact: true }).selectOption(ids.period), ); - } + yield* step(() => page.getByRole("button", { name: "Vis rapport", exact: true }).click()); + yield* step(() => + expect(page.getByRole("alert")).toContainText("Rapporten kunne ikke hentes"), + ); + yield* step(() => expect(page.locator("tbody")).toHaveCount(0)); + yield* o.auditPage(page, "report-failed-read"); + yield* step(() => + page.screenshot({ path: path.join(o.artifacts, "report-failure-mobile.png") }), + ); + }).pipe( + Effect.ensuring( + step(() => + pool.query( + "ALTER TABLE public.report_temporarily_unavailable_conducts RENAME TO recruitment_interview_conducts", + ), + ).pipe(Effect.orDie), + ), + ); - await page.getByRole("button", { name: "Prøv igjen", exact: true }).click(); - await expect(page.getByRole("status")).toHaveText(`${expected} fullførte intervjuer`); + yield* step(() => page.getByRole("button", { name: "Prøv igjen", exact: true }).click()); + yield* step(() => + expect(page.getByRole("status")).toHaveText(`${expected} fullførte intervjuer`), + ); intentionalReadFailure = false; // Browser back supersedes a pending period request; releasing its real response cannot relabel rows. - await page.goto(`${ui}/dashboard/intervjuer/rapport?admissionPeriodId=${ids.empty}`); - await page.getByLabel("Opptaksperiode", { exact: true }).selectOption(ids.period); - await submit(); - let release!: () => void, captured!: () => void; - let settle!: (result: { fulfilled: boolean; fetchStatus: number | null }) => void; - let terminal!: (result: { kind: "finished" | "failed"; error: string | null }) => void; + yield* step(() => + page.goto(`${ui}/dashboard/intervjuer/rapport?admissionPeriodId=${ids.empty}`), + ); + yield* step(() => page.getByLabel("Opptaksperiode", { exact: true }).selectOption(ids.period)); + yield* submit(); let heldRequest: any; - const settled = new Promise<{ fulfilled: boolean; fetchStatus: number | null }>((resolve) => { - settle = resolve; - }); + // The browser callbacks below settle these; the journey awaits them as steps. + const settled = Promise.withResolvers<{ fulfilled: boolean; fetchStatus: number | null }>(); + const settle = settled.resolve; - const terminated = new Promise<{ kind: "finished" | "failed"; error: string | null }>( - (resolve) => { - terminal = resolve; - }, - ); + const terminated = Promise.withResolvers<{ + kind: "finished" | "failed"; + error: string | null; + }>(); + + const terminal = terminated.resolve; const finished = (request: any) => { if (request === heldRequest) terminal({ kind: "finished", error: null }); @@ -926,20 +1061,14 @@ export async function observeInterviewReport(o: Options) { page.on("requestfinished", finished); page.on("requestfailed", failed); - const bounded = async (promise: Promise, gate: string): Promise => { - let timer: ReturnType | undefined; - - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(gate)), 10000); - }), - ]); - } finally { - clearTimeout(timer); - } - }; + /** The value of the promise, or a failure named by the gate after ten seconds. */ + const bounded = (promise: Promise, gate: string) => + step(() => promise).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.fail(new ProbeFailure({ message: gate })), + }), + ); let supersededResponse: | { @@ -950,67 +1079,78 @@ export async function observeInterviewReport(o: Options) { } | undefined; - const hold = new Promise((resolve) => { - release = resolve; - }); - - const arrived = new Promise((resolve) => { - captured = resolve; - }); + const hold = Promise.withResolvers(); + const release = () => hold.resolve(); + const arrived = Promise.withResolvers(); + const captured = () => arrived.resolve(); const observedNavigations: Array<{ path: string; period: string | null }> = []; - await page.route("**/*", async (route: any) => { - const requested = new URL(route.request().url()); - - if (requested.pathname.includes("rapport")) - observedNavigations.push({ - path: requested.pathname, - period: requested.searchParams.get("admissionPeriodId"), - }); + yield* step(() => + page.route("**/*", (route: any) => { + const requested = new URL(route.request().url()); + + if (requested.pathname.includes("rapport")) + observedNavigations.push({ + path: requested.pathname, + period: requested.searchParams.get("admissionPeriodId"), + }); - if (requested.searchParams.get("admissionPeriodId") === ids.closed) { - assert.equal(heldRequest, undefined, "only one superseded request may be held"); - heldRequest = route.request(); - let fetchStatus: number | null = null; - - try { - const response = await route.fetch(); - fetchStatus = response.status(); - captured(); - await hold; - await route.fulfill({ response }); - settle({ fulfilled: true, fetchStatus }); - } catch { - // A failed interception is accepted below only when this same browser - // request independently reports the router's expected abort. - settle({ fulfilled: false, fetchStatus }); + if (requested.searchParams.get("admissionPeriodId") === ids.closed) { + assert.equal(heldRequest, undefined, "only one superseded request may be held"); + heldRequest = route.request(); + let fetchStatus: number | null = null; + + return route + .fetch() + .then((response: any) => { + fetchStatus = response.status(); + captured(); + + return hold.promise.then(() => route.fulfill({ response })); + }) + .then( + () => settle({ fulfilled: true, fetchStatus }), + // A failed interception is accepted below only when this same browser + // request independently reports the router's expected abort. + () => settle({ fulfilled: false, fetchStatus }), + ); } - } else await route.continue(); - }); - try { - await page.getByLabel("Opptaksperiode", { exact: true }).selectOption(ids.closed); - await page.getByRole("button", { name: "Vis rapport", exact: true }).click(); - await Promise.race([ - arrived, - new Promise((_, reject) => - setTimeout( - () => - reject( - new Error( - JSON.stringify({ gate: "superseded report request", observedNavigations }), - ), - ), - 10000, - ), - ), - ]); - await expect(page.getByRole("status")).toHaveText("Henter rapporten …"); - await expect(page.locator("tbody")).toHaveCount(0); - await page.goBack(); + return route.continue(); + }), + ); + + yield* Effect.gen(function* () { + yield* step(() => + page.getByLabel("Opptaksperiode", { exact: true }).selectOption(ids.closed), + ); + yield* step(() => page.getByRole("button", { name: "Vis rapport", exact: true }).click()); + yield* step(() => arrived.promise).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => + Effect.fail( + new ProbeFailure({ + message: JSON.stringify({ gate: "superseded report request", observedNavigations }), + }), + ), + }), + ); + yield* step(() => expect(page.getByRole("status")).toHaveText("Henter rapporten …")); + yield* step(() => expect(page.locator("tbody")).toHaveCount(0)); + yield* step(() => page.goBack()); release(); - const fulfillment = await bounded(settled, "superseded response fulfillment did not settle"); - const termination = await bounded(terminated, "superseded browser request did not terminate"); + + const fulfillment = yield* bounded( + settled.promise, + "superseded response fulfillment did not settle", + ); + + const termination = yield* bounded( + terminated.promise, + "superseded browser request did not terminate", + ); + supersededResponse = { ...fulfillment, ...termination }; assert.equal(fulfillment.fetchStatus, 200, "held response must be a real successful report"); @@ -1018,27 +1158,37 @@ export async function observeInterviewReport(o: Options) { if (!fulfillment.fulfilled) assert.deepEqual(termination, { kind: "failed", error: "net::ERR_ABORTED" }); - await expect(page).toHaveURL( - `${ui}/dashboard/intervjuer/rapport?admissionPeriodId=${ids.empty}`, + yield* step(() => + expect(page).toHaveURL(`${ui}/dashboard/intervjuer/rapport?admissionPeriodId=${ids.empty}`), ); - await expect(page.locator("section[aria-busy]")).toHaveAttribute("aria-busy", "false"); - await expect(page.getByLabel("Opptaksperiode", { exact: true })).toHaveValue(ids.empty); - await expect(page.getByRole("status")).toHaveText("0 fullførte intervjuer"); - await expect(page.locator("tbody")).toHaveCount(0); - await expect( - page.getByText("Ingen fullførte intervjuer samsvarer med valgene."), - ).toBeVisible(); - await page.screenshot({ path: join(o.artifacts, "report-superseded-settled.png") }); - await page.reload(); - await expect(page.getByRole("status")).toHaveText("0 fullførte intervjuer"); - } finally { - release(); - await page.unroute("**/*"); - page.off("requestfinished", finished); - page.off("requestfailed", failed); - } + yield* step(() => + expect(page.locator("section[aria-busy]")).toHaveAttribute("aria-busy", "false"), + ); + yield* step(() => + expect(page.getByLabel("Opptaksperiode", { exact: true })).toHaveValue(ids.empty), + ); + yield* step(() => expect(page.getByRole("status")).toHaveText("0 fullførte intervjuer")); + yield* step(() => expect(page.locator("tbody")).toHaveCount(0)); + yield* step(() => + expect(page.getByText("Ingen fullførte intervjuer samsvarer med valgene.")).toBeVisible(), + ); + yield* step(() => + page.screenshot({ path: path.join(o.artifacts, "report-superseded-settled.png") }), + ); + yield* step(() => page.reload()); + yield* step(() => expect(page.getByRole("status")).toHaveText("0 fullførte intervjuer")); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + release(); + yield* step(() => page.unroute("**/*")); + page.off("requestfinished", finished); + page.off("requestfailed", failed); + }).pipe(Effect.orDie), + ), + ); - assert.deepEqual(await snapshot(), baseline); + assert.deepEqual(yield* snapshot(), baseline); assert.deepEqual(errors, []); record( "production navigation/keyboard sorts/filter/count/reload, historical/empty period, desktop/mobile Axe and viewport; real SQL failure/retry and superseded-period navigation; no report writes", @@ -1055,38 +1205,44 @@ export async function observeInterviewReport(o: Options) { concurrentReadStatus, supersededResponse, authorityDenials, - conditionalRequest: { priorEtag, ifNoneMatch: oldCondition }, + conditionalRequest: "dropped: an RPC read takes no If-None-Match", scope: "all completed native, not first-time-only; local synthetic; no effects", }; - await writeFile(join(o.artifacts, "report-evidence.json"), JSON.stringify(evidence, null, 2)); + yield* fs.writeFileString( + path.join(o.artifacts, "report-evidence.json"), + yield* indentedJsonText(evidence), + ); return evidence; - } catch (error) { - const pages = context.pages(); - const current = pages[pages.length - 1]; - - if (current) { - await current - .screenshot({ path: join(o.artifacts, "report-browser-failure.png") }) - .catch(() => {}); - - let detail = JSON.stringify({ - revision: o.revision, - gates, - error: error instanceof Error ? error.message : "report browser failure", - path: new URL(current.url()).pathname, - query: Object.fromEntries(new URL(current.url()).searchParams), - status: await current.getByRole("status").allTextContents(), - alerts: await current.getByRole("alert").allTextContents(), - }); - - for (const secret of o.secrets) detail = detail.replaceAll(secret, "[redacted]"); - await writeFile(join(o.artifacts, "report-browser-failure.json"), detail); - } + }).pipe( + Effect.tapCause((cause) => + Effect.gen(function* () { + const pages = context.pages(); + const current = pages[pages.length - 1]; + + if (current === undefined) return; + + const error = thrownBy(cause); + + yield* step(() => + current.screenshot({ path: path.join(o.artifacts, "report-browser-failure.png") }), + ).pipe(Effect.ignore); + + let detail = yield* jsonText({ + revision: o.revision, + gates, + error: error.message, + path: new URL(current.url()).pathname, + query: Object.fromEntries(new URL(current.url()).searchParams), + status: yield* step(() => current.getByRole("status").allTextContents()), + alerts: yield* step(() => current.getByRole("alert").allTextContents()), + }); - throw error; - } finally { - await context.close(); - } -} + for (const secret of o.secrets) detail = detail.replaceAll(secret, "[redacted]"); + yield* fs.writeFileString(path.join(o.artifacts, "report-browser-failure.json"), detail); + }), + ), + Effect.ensuring(step(() => context.close()).pipe(Effect.orDie)), + ); +}); diff --git a/tools/acceptance/journey-step.ts b/tools/acceptance/journey-step.ts new file mode 100644 index 000000000..ed9de5f34 --- /dev/null +++ b/tools/acceptance/journey-step.ts @@ -0,0 +1,168 @@ +import { Cause, Effect, FileSystem, type PlatformError, Schema } from "effect"; +import { dual } from "effect/Function"; +import type { HttpClientResponse } from "effect/unstable/http"; + +/** A rejection of the promise-based driver code, such as Playwright or `pg`, that a journey step awaited. */ +export class JourneyStepFailed extends Schema.TaggedError()( + "JourneyStepFailed", + { cause: Schema.Defect() }, +) {} + +/** + * Runs one call of promise-based driver code as a journey step. A rejection fails the step with + * the original rejection as its cause, so the entry reports the same error that the driver raised. + */ +export const step =

>( + evaluate: () => P, +): Effect.Effect, JourneyStepFailed> => + Effect.tryPromise({ + try: () => Promise.resolve(evaluate()), + catch: (cause) => JourneyStepFailed.make({ cause }), + }); + +/** + * Fails a program with the original rejection of a failed step, as a defect, so that a Promise + * caller receives the same error that the driver raised. + */ +export const surfaceStepFailure = ( + effect: Effect.Effect, +): Effect.Effect, R> => + // SAFETY: the predicate catches every JourneyStepFailed, so none remains in the error channel. + effect.pipe( + Effect.catchIf(Schema.is(JourneyStepFailed), (failed) => Effect.die(failed.cause)), + ) as Effect.Effect, R>; + +/** + * The error that a failed program threw or failed with: the original rejection of a failed step, or + * else the squashed failure or defect, as a `catch` block of the driver code would have received it. + * A thrown value that is no error is wrapped in one whose message is its string and cause the value. + */ +export const thrownBy = (cause: Cause.Cause): Error => { + const squashed = Cause.squash(cause); + const thrown = Schema.is(JourneyStepFailed)(squashed) ? squashed.cause : squashed; + + return thrown instanceof Error ? thrown : new Error(String(thrown), { cause: thrown }); +}; + +/** A connection that a pool lends and takes back, as `pg` `PoolClient` does. */ +interface LentConnection { + readonly query: (text: string, values?: ReadonlyArray) => PromiseLike; + readonly release: () => void; +} + +type Queryable = Pick; + +interface ConnectionPool { + readonly connect: () => PromiseLike; +} + +/** The connection type that a pool lends. */ +type LentBy

= Awaited>; + +/** Borrows one pool connection for the use, and returns it to the pool however the use ends. */ +export const withPoolClient: { +

( + use: (client: LentBy

) => Effect.Effect, + ): (pool: P) => Effect.Effect; +

( + pool: P, + use: (client: LentBy

) => Effect.Effect, + ): Effect.Effect; +} = dual( + 2, +

( + pool: P, + use: (client: LentBy

) => Effect.Effect, + ): Effect.Effect => + Effect.acquireUseRelease( + // SAFETY: `connect` of the pool type P answers ReturnType, which awaits to LentBy

. + step(() => pool.connect() as PromiseLike>), + use, + (client) => Effect.sync(() => client.release()), + ), +); + +/** + * Runs the body in a transaction of the connection: it commits after the body succeeds, and rolls + * back when the body or the commit fails, which then fails with the original cause. + */ +export const committed: { + ( + body: Effect.Effect, + ): (client: Queryable) => Effect.Effect; + ( + client: Queryable, + body: Effect.Effect, + ): Effect.Effect; +} = dual( + 2, + ( + client: Queryable, + body: Effect.Effect, + ): Effect.Effect => + step(() => client.query("BEGIN")).pipe( + Effect.andThen( + body.pipe( + Effect.tap(() => step(() => client.query("COMMIT"))), + Effect.onError(() => step(() => client.query("ROLLBACK")).pipe(Effect.ignore)), + ), + ), + ), +); + +/** Runs the body in a transaction of the connection that always rolls back. */ +export const rolledBack: { + ( + body: Effect.Effect, + ): (client: Queryable) => Effect.Effect; + ( + client: Queryable, + body: Effect.Effect, + ): Effect.Effect; +} = dual( + 2, + ( + client: Queryable, + body: Effect.Effect, + ): Effect.Effect => + step(() => client.query("BEGIN")).pipe( + Effect.andThen(body), + Effect.ensuring(step(() => client.query("ROLLBACK")).pipe(Effect.ignore)), + ), +); + +const CompactJson = Schema.fromJsonString(Schema.Unknown); + +const IndentedJson = Schema.fromJsonString(Schema.Unknown, { space: 2 }); + +/** The JSON text of a value, the bytes that `JSON.stringify(value)` writes. */ +export const jsonText = (value: A): Effect.Effect => + Schema.encodeEffect(CompactJson)(value); + +type EvidenceWrite = Effect.Effect< + void, + Schema.SchemaError | PlatformError.PlatformError, + FileSystem.FileSystem +>; + +/** Writes the evidence file of a journey: the JSON text of the value, indented by two, and a newline. */ +export const writeEvidence: { + (value: A): (path: string) => EvidenceWrite; + (path: string, value: A): EvidenceWrite; +} = dual( + 2, + (path: string, value: A): EvidenceWrite => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const text = yield* Schema.encodeEffect(IndentedJson)(value); + + yield* fs.writeFileString(path, `${text}\n`); + }), +); + +/** The `name=value` pair of the first cookie that a response sets, in the order of its headers. */ +export const firstSetCookie = (response: HttpClientResponse.HttpClientResponse) => { + const [first] = Object.values(response.cookies.cookies); + + return first === undefined ? undefined : `${first.name}=${first.valueEncoded}`; +}; diff --git a/tools/acceptance/onboarding-check.ts b/tools/acceptance/onboarding-check.ts index cb783aa75..eee11693a 100644 --- a/tools/acceptance/onboarding-check.ts +++ b/tools/acceptance/onboarding-check.ts @@ -1,22 +1,45 @@ -import { createServer as httpServer } from "node:http"; /** 0099 real local API + browser acceptance. Reuses native identity seed and owned process lifecycle. */ import assert from "node:assert/strict"; -import { execFileSync, spawn } from "node:child_process"; -import { randomBytes, createHash } from "node:crypto"; -import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { createHash, randomBytes } from "node:crypto"; import { createRequire } from "node:module"; -import { stopOwnedProcess } from "./owned-process.js"; +import process from "node:process"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; import { - type DisposablePostgres, - loopbackPortFree, - reserveLoopbackPorts, - startDisposablePostgres, -} from "../postgres/index.ts"; -import { Predicate, Schema, Struct } from "effect"; - -const root = new URL("../../", import.meta.url).pathname; + Cause, + Console, + Data, + Effect, + FileSystem, + Fiber, + Layer, + Path, + Predicate, + Schedule, + Schema, + Struct, +} from "effect"; +import { + FetchHttpClient, + Headers, + HttpClient, + HttpClientRequest, + type HttpClientResponse, +} from "effect/unstable/http"; +import { SubmitApplicationRequest } from "../../packages/rpc/src/v2-schemas.js"; +import { IdempotencyKey } from "../../packages/rpc/src/problem.js"; +import { nativeScriptClient } from "../../packages/rpc/src/script-client.js"; +import { replacedFetch } from "../../apps/dashboard/e2e/native-rpc-ledger.ts"; +import { loopbackPortFree, reserveLoopbackPorts, startDisposablePostgres } from "../postgres/index.ts"; +import { jsonText } from "../../apps/backend/src/rpc/problem.js"; +import { + answersOk, + commandOutput, + indentedJsonText, + ProbeFailure, + startOwnedProcess, + withheldVariables, +} from "./acceptance-process.ts"; const requireDatabase = createRequire( new URL("../../packages/database/package.json", import.meta.url), @@ -24,25 +47,59 @@ const requireDatabase = createRequire( const { Pool } = requireDatabase("pg"); -const run = (command: string, args: string[], env = process.env, timeout = 60_000) => - execFileSync(command, args, { cwd: root, env, encoding: "utf8", timeout }); +/** A PostgreSQL call, or the disposable cluster, that failed. */ +class DatabaseFailure extends Data.TaggedError("DatabaseFailure")<{ readonly cause: unknown }> {} -const mode = process.argv[2]; +/** A request whose answer could not be read. */ +class RequestFailure extends Data.TaggedError("RequestFailure")<{ readonly cause: unknown }> {} -assert.ok( - process.argv.length === 3 && (mode === "--browser" || mode === "--api-only"), - "Usage: bun run tools/acceptance/onboarding-check.ts --browser | --api-only", -); +/** The failure of the probe, with every credential redacted from its message. */ +class OnboardingFailed extends Data.TaggedError("OnboardingFailed")<{ readonly message: string }> {} + +/** The rows of one query; the probe reads the columns that its statement selects. */ +interface QueryResult { + readonly rows: Array; +} + +/** One acknowledged onboarding mail, as the backend delivers it. */ +const MailMessage = Schema.Struct({ deliveryId: Schema.String, text: Schema.String, to: Schema.String }); -const revision = run("git", ["rev-parse", "HEAD"]).trim(); +type MailMessage = typeof MailMessage.Type; -assert.equal(run("git", ["status", "--porcelain"]).trim(), "", "requires committed clean artifact"); +const JsonBody = Schema.fromJsonString(Schema.Json); -const artifacts = await mkdtemp(join(tmpdir(), "vektor-onboarding-0099-")); +/** One answer, from the identity engine over HTTP or from an RPC as its route's HTTP response. */ +interface Answer { + readonly status: number; + readonly json: Effect.Effect; + readonly cookie: string | undefined; +} + +const webAnswer = (response: Response): Answer => ({ + status: response.status, + json: Effect.tryPromise({ + try: () => response.json(), + catch: (cause) => new RequestFailure({ cause }), + }), + cookie: response.headers.get("set-cookie")?.split(";")[0], +}); + +const clientAnswer = (response: HttpClientResponse.HttpClientResponse): Answer => { + const cookie = Object.values(response.cookies.cookies)[0]; + + return { + status: response.status, + json: response.json.pipe(Effect.mapError((cause) => new RequestFailure({ cause }))), + cookie: cookie === undefined ? undefined : `${cookie.name}=${cookie.valueEncoded}`, + }; +}; -const children: ReturnType[] = []; +const mode = process.argv[2]; -const outputs: string[] = []; +assert.ok( + process.argv.length === 3 && (mode === "--browser" || mode === "--api-only"), + "Usage: bun run tools/acceptance/onboarding-check.ts --browser | --api-only", +); const secrets = new Set(); @@ -59,688 +116,774 @@ const assertNoSecrets = (text: string) => { if (safe(text) !== text) throw new Error("Retained evidence contains credentials"); }; -const runBrowser = async (args: string[], env: NodeJS.ProcessEnv) => { - const child = spawn("bun", args, { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] }); - children.push(child); - let output = ""; - child.stdout?.on("data", (chunk) => (output += String(chunk))); - child.stderr?.on("data", (chunk) => (output += String(chunk))); - - const timer = setTimeout(() => { - void stopOwnedProcess(child); - }, 300000); - - try { - await new Promise((resolve, reject) => { - child.once("error", () => reject(new Error("Browser child failed to start"))); - child.once("exit", (code) => - code === 0 ? resolve() : reject(new Error("Browser journey failed: " + safe(output))), - ); - }); - } finally { - clearTimeout(timer); - } -}; - -const start = (command: string, args: string[], env = process.env) => { - const child = spawn(command, args, { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] }); - children.push(child); - child.stdout?.on("data", (chunk) => outputs.push(String(chunk))); - child.stderr?.on("data", (chunk) => outputs.push(String(chunk))); +const journey = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.resolve(import.meta.dirname, "../.."); - return child; -}; + const run = (command: string, args: ReadonlyArray, env = {}) => + commandOutput({ command, args, cwd: root, env, deadline: "60 seconds" }); -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const revision = (yield* run("git", ["rev-parse", "HEAD"])).trim(); -let postgres: DisposablePostgres | undefined; - -let pool: InstanceType; + assert.equal( + (yield* run("git", ["status", "--porcelain"])).trim(), + "", + "requires committed clean artifact", + ); -let evidence: Schema.JsonObject | undefined; + const artifacts = yield* fs.makeTempDirectory({ prefix: "vektor-onboarding-0099-" }); + const outputs: string[] = []; + const mailboxToken = randomBytes(32).toString("hex"); + secrets.add(mailboxToken); + const mail = new Map(); + let attempts = 0; + let rejectNext = false; + + const checks = Effect.gen(function* () { + yield* Effect.addFinalizer(() => + fs.remove(path.join(artifacts, "manifest.json"), { force: true }).pipe(Effect.ignore), + ); -const mailboxToken = randomBytes(32).toString("hex"); + const [backendPort, mailboxPort] = yield* Effect.tryPromise({ + try: () => reserveLoopbackPorts(2), + catch: (cause) => new DatabaseFailure({ cause }), + }); -secrets.add(mailboxToken); + const dashboardPort = 5174; -const mail = new Map(); + assert.ok( + yield* Effect.promise(() => loopbackPortFree(dashboardPort)), + `loopback port ${dashboardPort} is in use`, + ); -let mailbox: ReturnType | undefined; + const postgres = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => startDisposablePostgres(), + catch: (cause) => new DatabaseFailure({ cause }), + }), + (cluster) => Effect.promise(() => cluster.stop()), + ); -let attempts = 0; + const postgresUrl = postgres.url; + secrets.add(postgresUrl); -let rejectNext = false; + const pool = yield* Effect.acquireRelease( + Effect.sync(() => new Pool({ connectionString: postgresUrl })), + (owned) => Effect.promise(() => owned.end()), + ); -try { - const [backendPort, mailboxPort] = await reserveLoopbackPorts(2); - const dashboardPort = 5174; - assert.ok(await loopbackPortFree(dashboardPort), `loopback port ${dashboardPort} is in use`); - postgres = await startDisposablePostgres(); - const postgresUrl = postgres.url; - secrets.add(postgresUrl); - pool = new Pool({ connectionString: postgresUrl }); + const query = (text: string, values?: ReadonlyArray) => + Effect.tryPromise({ + try: (): Promise => pool.query(text, values), + catch: (cause) => new DatabaseFailure({ cause }), + }); - const backendOrigin = `http://127.0.0.1:${backendPort}`; - const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; - mailbox = httpServer(async (req, res) => { - if (req.headers.authorization !== `Bearer ${mailboxToken}`) { - res.writeHead(401).end(); + const backendOrigin = `http://127.0.0.1:${backendPort}`; + const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; - return; - } + yield* Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + hostname: "127.0.0.1", + port: mailboxPort, + fetch(request) { + if (request.headers.get("authorization") !== `Bearer ${mailboxToken}`) + return new Response(null, { status: 401 }); - let body = ""; + return request.text().then((body) => { + if (request.method !== "POST") return Response.json([...mail.values()].map((entry) => entry.json)); - for await (const chunk of req) body += chunk; + attempts++; - if (req.method === "POST") { - attempts++; + if (rejectNext) { + rejectNext = false; - if (rejectNext) { - rejectNext = false; - res.writeHead(503).end(); + return new Response(null, { status: 503 }); + } - return; - } + const json = Schema.decodeSync(JsonBody)(body); + const text = Schema.encodeSync(JsonBody)(json); + const message = Schema.decodeUnknownSync(MailMessage)(json); + const old = mail.get(message.deliveryId); - const parsed = JSON.parse(body); - const old = mail.get(parsed.deliveryId); + if (old !== undefined && old.text !== text) return new Response(null, { status: 409 }); - if (old && JSON.stringify(old) !== JSON.stringify(parsed)) { - res.writeHead(409).end(); + mail.set(message.deliveryId, { message, json, text }); - return; - } + return new Response(null, { status: 204 }); + }); + }, + }), + ), + (server) => Effect.promise(() => server.stop(true)), + ); - mail.set(parsed.deliveryId, parsed); - res.writeHead(204).end(); + const mailboxOrigin = `http://127.0.0.1:${mailboxPort}`; + + const environment = { + ...(yield* withheldVariables), + ONBOARDING_DELIVERY_URL: mailboxOrigin + "/mail", + ONBOARDING_DELIVERY_TOKEN: mailboxToken, + ONBOARDING_DELIVERY_TIMEOUT_MS: "1000", + ONBOARDING_DELIVERY_SENDER: "coordinator@example.invalid", + BACKEND_HOST: "127.0.0.1", + BACKEND_PORT: String(backendPort), + BACKEND_PG_URL: postgresUrl, + BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), + NATIVE_IDENTITY_DEPLOYMENT: "local", + NATIVE_IDENTITY_TRUSTED_ORIGINS: yield* jsonText([dashboardOrigin]), + OAUTH_CANONICAL_ORIGIN: backendOrigin, + OAUTH_DASHBOARD_ORIGIN: dashboardOrigin, + OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", + PUBLIC_APPLICATION_EFFECT_MODE: "disabled", + PASSWORD_RESET_DELIVERY_MODE: "disabled", + RECEIPT_DELIVERY_MODE: "disabled", + JOURNEY_SEED_PG_URL: postgresUrl, + }; + + secrets.add(environment.BETTER_AUTH_SECRET); + yield* run("bun", ["apps/dashboard/e2e/native-recruitment-journey-seed.mjs"], environment); + const departmentId = "department-native-journey-0049"; + const semesterId = "semester-native-journey-0049"; + const leaderId = "journey-rec-leader-0049"; + + const persons = { + leader: { email: "lina.leader@example.invalid", password: "journey-secret-0123456789abcdef" }, + existing: { + email: "irene.intervjuer@example.invalid", + password: "journey-secret-0123456789abcdef", + }, + applicant: { + email: "onboarding-browser@example.invalid", + password: "onboarding-browser-password-0099", + }, + }; + + for (const person of Object.values(persons)) secrets.add(person.password); + yield* query( + `DELETE FROM organization_global_administrator_grants;INSERT INTO schools_directory_schools(school_id,name,contact_person,email,phone,language,active,revision) OVERRIDING SYSTEM VALUE VALUES(995,'Onboarding school','Contact','school@example.invalid','12345678','Norwegian',true,0);INSERT INTO schools_directory_departments VALUES(995,'${departmentId}',0);`, + ); - return; - } + const startBackend = startOwnedProcess({ + command: "bun", + args: ["run", "--cwd", "apps/backend", "start"], + cwd: root, + env: environment, + output: (text) => outputs.push(text), + }); - res.setHeader("content-type", "application/json"); - res.end(JSON.stringify([...mail.values()])); - }); - await new Promise((resolve) => mailbox!.listen(mailboxPort, "127.0.0.1", resolve)); - const mailboxOrigin = `http://127.0.0.1:${mailboxPort}`; - - const environment = { - ...process.env, - ONBOARDING_DELIVERY_URL: mailboxOrigin + "/mail", - ONBOARDING_DELIVERY_TOKEN: mailboxToken, - ONBOARDING_DELIVERY_TIMEOUT_MS: "1000", - ONBOARDING_DELIVERY_SENDER: "coordinator@example.invalid", - BACKEND_HOST: "127.0.0.1", - BACKEND_PORT: String(backendPort), - BACKEND_PG_URL: postgresUrl, - BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), - NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify([dashboardOrigin]), - OAUTH_CANONICAL_ORIGIN: backendOrigin, - OAUTH_DASHBOARD_ORIGIN: dashboardOrigin, - OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - PUBLIC_APPLICATION_EFFECT_MODE: "disabled", - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_MODE: "disabled", - JOURNEY_SEED_PG_URL: postgresUrl, - }; + let backend = yield* startBackend; - secrets.add(environment.BETTER_AUTH_SECRET); - - for (const key of Object.keys(environment)) - if ( - key.startsWith("CONTACT_") || - (key.startsWith("PUBLIC_APPLICATION_EFFECT_") && key !== "PUBLIC_APPLICATION_EFFECT_MODE") - ) - Reflect.deleteProperty(environment, key); - run("bun", ["apps/dashboard/e2e/native-recruitment-journey-seed.mjs"], environment); - const departmentId = "department-native-journey-0049"; - const semesterId = "semester-native-journey-0049"; - const leaderId = "journey-rec-leader-0049"; - secrets.add(environment.BETTER_AUTH_SECRET); - - const persons = { - leader: { email: "lina.leader@example.invalid", password: "journey-secret-0123456789abcdef" }, - existing: { - email: "irene.intervjuer@example.invalid", - password: "journey-secret-0123456789abcdef", - }, - applicant: { - email: "onboarding-browser@example.invalid", - password: "onboarding-browser-password-0099", - }, - }; + const ready = answersOk(backendOrigin + "/health").pipe( + Effect.filterOrFail( + (up) => up, + () => new ProbeFailure({ message: "backend startup failed" }), + ), + Effect.retry({ schedule: Schedule.spaced("200 millis"), times: 151 }), + ); - for (const person of Object.values(persons)) secrets.add(person.password); - await pool.query( - `DELETE FROM organization_global_administrator_grants;INSERT INTO schools_directory_schools(school_id,name,contact_person,email,phone,language,active,revision) OVERRIDING SYSTEM VALUE VALUES(995,'Onboarding school','Contact','school@example.invalid','12345678','Norwegian',true,0);INSERT INTO schools_directory_departments VALUES(995,'${departmentId}',0);`, - ); - let backend = start("bun", ["run", "--cwd", "apps/backend", "start"], environment); + yield* ready; + + const request = ( + path: string, + cookie?: string, + body?: Schema.Json, + etag?: string, + key = randomBytes(18).toString("hex"), + ): Effect.Effect => { + const url = new URL(path, backendOrigin); + + // The identity engine stays HTTP; every onboarding operation is the RPC that replaced its + // route, answered as that route's HTTP response. + if (url.pathname.startsWith("/api/auth/")) + return Effect.gen(function* () { + let headers = Headers.fromInput({ origin: dashboardOrigin }); + + if (cookie !== undefined && cookie !== "") headers = Headers.set(headers, "cookie", cookie); + + if (body !== undefined) { + headers = Headers.set(headers, "idempotency-key", key); + + if (etag !== undefined && etag !== "") headers = Headers.set(headers, "if-match", etag); + } + + const base = + body === undefined + ? HttpClientRequest.get(backendOrigin + path, { headers }) + : HttpClientRequest.post(backendOrigin + path, { headers }).pipe( + HttpClientRequest.bodyText(yield* jsonText(body), "application/json"), + ); + + return clientAnswer( + yield* HttpClient.execute(base).pipe( + Effect.mapError((cause) => new RequestFailure({ cause })), + ), + ); + }); + + const rpcHeaders = { origin: dashboardOrigin, cookie }; + + const replaced = (tag: string, payload: Schema.Json) => + Effect.tryPromise({ + try: () => replacedFetch({ origin: backendOrigin, tag, payload, headers: rpcHeaders }), + catch: (cause) => new RequestFailure({ cause }), + }).pipe(Effect.map(webAnswer)); + + if (url.pathname === "/api/onboarding/claim") return replaced("onboarding.claim", body ?? null); + + const scope = { departmentId: url.searchParams.get("departmentId") ?? "" }; + + return body === undefined + ? replaced("onboarding.readBoard", scope) + : replaced("onboarding.command", { + ...scope, + idempotencyKey: key, + ifMatch: etag ?? "", + request: body, + }); + }; + + const expectStatus = (answer: Answer, status: number) => + Effect.gen(function* () { + const body = yield* answer.json; + assert.equal( + answer.status, + status, + Predicate.isString(body.code) && /^[a-z.-]+$/.test(body.code) + ? body.code + : "Unexpected HTTP status", + ); - const ready = async () => { - for (let n = 0; ; n++) { - try { - if ((await fetch(backendOrigin + "/health")).ok) return; - } catch {} + return body; + }); - if (n > 150) throw Error("backend startup failed"); - await delay(200); - } - }; + const login = (person: { email: string; password: string }) => + Effect.gen(function* () { + const response = yield* request("/api/auth/sign-in/email", undefined, person); + assert.equal(response.status, 200); + const cookie = response.cookie; + assert.ok(cookie !== undefined && cookie !== ""); - await ready(); + return cookie; + }); - const request = ( - path: string, - cookie?: string, - body?: Schema.Json, - etag?: string, - key = randomBytes(18).toString("hex"), - ) => { - const nativeHeaders = new Headers(); - nativeHeaders.set("origin", dashboardOrigin); + const leader = yield* login(persons.leader); + const existing = yield* login(persons.existing); + const boardPath = "/api/onboarding?departmentId=" + departmentId; + const board = Effect.flatMap(request(boardPath, leader), (answer) => expectStatus(answer, 200)); - if (cookie) { - nativeHeaders.set("cookie", cookie); - } + // Public applications are served as an RPC; an applicant submits anonymously. + const applications = yield* Effect.acquireRelease( + Effect.sync(() => nativeScriptClient(backendOrigin)), + (client) => Effect.promise(() => client.dispose()), + ); - if (!(body === undefined)) { - nativeHeaders.set("content-type", "application/json"); - nativeHeaders.set("idempotency-key", key); + const submit = (email: string, firstName: string) => + Effect.gen(function* () { + const submission = yield* Schema.decodeEffect(SubmitApplicationRequest)({ + departmentId, + firstName, + lastName: "Applicant", + phone: "12345678", + email, + gender: 0, + fieldOfStudyId: "field-native-journey-0049", + yearOfStudy: 2, + availability: { + mondayUnavailable: false, + tuesdayUnavailable: true, + wednesdayUnavailable: false, + thursdayUnavailable: false, + fridayUnavailable: false, + positionWeeks: 4, + preferredGroup: "all", + language: "Norsk", + }, + }); + + const answer = yield* Effect.promise(() => + applications.call({}, (client) => + client["admissions.submitApplication"]({ + idempotencyKey: IdempotencyKey.make(randomBytes(18).toString("base64url")), + request: submission, + }), + ), + ); - if (etag) { - nativeHeaders.set("if-match", etag); - } - } + assert.ok(answer.ok, yield* jsonText(answer)); + + return answer.value; + }); + + const browserApplication = yield* submit(persons.applicant.email, "Onboarding"); + let revokedReplayObserved = false; + + const lastMail = () => [...mail.values()].at(-1)!.message; + + const issue = (applicationId: string) => + Effect.gen(function* () { + const before = yield* board; + const key = randomBytes(18).toString("hex"); + const body = { applicationId, action: "Issue" }; + const response = yield* request(boardPath, leader, body, before.etag, key); + yield* expectStatus(response, 200); + const count = mail.size; + yield* expectStatus(yield* request(boardPath, leader, body, before.etag, key), 200); + assert.equal(mail.size, count); + + if (!revokedReplayObserved) { + yield* query( + `UPDATE organization_memberships SET is_suspended=true WHERE person_id=$1`, + [leaderId], + ); + + yield* Effect.gen(function* () { + yield* expectStatus(yield* request(boardPath, leader, body, before.etag, key), 403); + yield* expectStatus(yield* request(boardPath, leader), 403); + }).pipe( + Effect.ensuring( + query(`UPDATE organization_memberships SET is_suspended=false WHERE person_id=$1`, [ + leaderId, + ]).pipe(Effect.orDie), + ), + ); + + revokedReplayObserved = true; + } + + yield* expectStatus( + yield* request(boardPath, leader, { applicationId, action: "Revoke" }, before.etag, key), + 409, + ); - const requestBody: Pick = - body === undefined ? {} : { body: JSON.stringify(body) }; + return lastMail(); + }); - return fetch(backendOrigin + path, { - method: body === undefined ? "GET" : "POST", - headers: nativeHeaders, - ...requestBody, - }); - }; + const claimToken = (message: MailMessage) => + new URL(message.text.split(" ").at(-1)!).hash.slice(1); - const expectStatus = async (response: Response, status: number) => { - const body = await response.json(); - assert.equal( - response.status, - status, - Predicate.isString(body.code) && /^[a-z.-]+$/.test(body.code) - ? body.code - : "Unexpected HTTP status", + yield* expectStatus(yield* request(boardPath, existing), 403); + yield* expectStatus(yield* request(boardPath), 401); + yield* query( + `INSERT INTO organization_departments(department_id,name,short_name,email,city,active,revision) VALUES('onboarding-wrong-dept','Other','Other','other@example.invalid','Other',true,0)`, + ); + yield* expectStatus( + yield* request("/api/onboarding?departmentId=onboarding-wrong-dept", leader), + 403, ); - return body; - }; - - const login = async (person: { email: string; password: string }) => { - const response = await request("/api/auth/sign-in/email", undefined, person); - assert.equal(response.status, 200); - const cookie = response.headers.get("set-cookie")?.split(";")[0]; - assert.ok(cookie); + const existingApplication = yield* submit("onboarding-existing@example.invalid", "Existing"); - return cookie; - }; + const priorProfile = Effect.map( + query( + `SELECT jsonb_build_object('user',u,'account',a,'profile',p)::text AS value FROM auth."user" u JOIN auth."account" a ON a."userId"=u.id JOIN person_profiles p ON p.person_id=u.id WHERE u.id='journey-rec-interviewer-a-0049'`, + ), + (result) => result.rows, + ); - const leader = await login(persons.leader); - const existing = await login(persons.existing); - const boardPath = "/api/onboarding?departmentId=" + departmentId; - const board = async () => expectStatus(await request(boardPath, leader), 200); - - const submit = async (email: string, firstName: string) => - expectStatus( - await request("/api/applications", undefined, { - departmentId, - firstName, - lastName: "Applicant", - phone: "12345678", - email, - gender: 0, - fieldOfStudyId: "field-native-journey-0049", - yearOfStudy: 2, - availability: { - mondayUnavailable: false, - tuesdayUnavailable: true, - wednesdayUnavailable: false, - thursdayUnavailable: false, - fridayUnavailable: false, - positionWeeks: 4, - preferredGroup: "all", - language: "Norsk", - }, + const prior = yield* priorProfile; + const invitation = yield* issue(existingApplication.applicationId); + const token = claimToken(invitation); + yield* expectStatus( + yield* request("/api/onboarding/claim", undefined, { mode: "ExistingAccount", token }), + 401, + ); + yield* expectStatus( + yield* request("/api/onboarding/claim", existing, { mode: "ExistingAccount", token }), + 200, + ); + yield* expectStatus( + yield* request("/api/onboarding/claim", existing, { mode: "ExistingAccount", token }), + 400, + ); + assert.deepEqual(yield* priorProfile, prior); + const collisionApplication = yield* submit(persons.existing.email, "Collision"); + const collision = yield* issue(collisionApplication.applicationId); + yield* expectStatus( + yield* request("/api/onboarding/claim", undefined, { + mode: "NewAccount", + token: claimToken(collision), + password: persons.applicant.password, }), - 201, + 409, ); - const browserApplication = await submit(persons.applicant.email, "Onboarding"); - let revokedReplayObserved = false; + const concurrentApplication = yield* submit( + "onboarding-concurrent@example.invalid", + "Concurrent", + ); - const issue = async (applicationId: string) => { - const before = await board(); - const key = randomBytes(18).toString("hex"); - const body = { applicationId, action: "Issue" }; - const response = await request(boardPath, leader, body, before.etag, key); - await expectStatus(response, 200); - const count = mail.size; - await expectStatus(await request(boardPath, leader, body, before.etag, key), 200); - assert.equal(mail.size, count); + const concurrent = yield* issue(concurrentApplication.applicationId); + const concurrentToken = claimToken(concurrent); - if (!revokedReplayObserved) { - await pool.query(`UPDATE organization_memberships SET is_suspended=true WHERE person_id=$1`, [ - leaderId, - ]); - - try { - await expectStatus(await request(boardPath, leader, body, before.etag, key), 403); - await expectStatus(await request(boardPath, leader), 403); - } finally { - await pool.query( - `UPDATE organization_memberships SET is_suspended=false WHERE person_id=$1`, - [leaderId], - ); - } + const concurrentClaim = request("/api/onboarding/claim", undefined, { + mode: "NewAccount", + token: concurrentToken, + password: persons.applicant.password, + }); - revokedReplayObserved = true; - } + const claims = yield* Effect.all([concurrentClaim, concurrentClaim], { + concurrency: "unbounded", + }); - await expectStatus( - await request(boardPath, leader, { applicationId, action: "Revoke" }, before.etag, key), - 409, + assert.deepEqual( + claims.map((r) => r.status).toSorted((a, b) => a - b), + [200, 400], + ); + const retryApplication = yield* submit("onboarding-retry@example.invalid", "Retry"); + rejectNext = true; + const pre = yield* board; + yield* expectStatus( + yield* request( + boardPath, + leader, + { applicationId: retryApplication.applicationId, action: "Issue" }, + pre.etag, + ), + 200, ); - return [...mail.values()].at(-1)!; - }; - - await expectStatus(await request(boardPath, existing), 403); - await expectStatus(await request(boardPath), 401); - await pool.query( - `INSERT INTO organization_departments(department_id,name,short_name,email,city,active,revision) VALUES('onboarding-wrong-dept','Other','Other','other@example.invalid','Other',true,0)`, - ); - await expectStatus( - await request("/api/onboarding?departmentId=onboarding-wrong-dept", leader), - 403, - ); - - const existingApplication = await submit("onboarding-existing@example.invalid", "Existing"); - - const prior = ( - await pool.query( - `SELECT jsonb_build_object('user',u,'account',a,'profile',p)::text AS value FROM auth."user" u JOIN auth."account" a ON a."userId"=u.id JOIN person_profiles p ON p.person_id=u.id WHERE u.id='journey-rec-interviewer-a-0049'`, - ) - ).rows; + const retryDelivery = Effect.map( + board, + (current) => + current.items.find( + (item: { applicationId: string }) => + item.applicationId === retryApplication.applicationId, + ).delivery, + ); - const invitation = await issue(existingApplication.applicationId); - const token = new URL(invitation.text.split(" ").at(-1)!).hash.slice(1); - await expectStatus( - await request("/api/onboarding/claim", undefined, { mode: "ExistingAccount", token }), - 401, - ); - await expectStatus( - await request("/api/onboarding/claim", existing, { mode: "ExistingAccount", token }), - 200, - ); - await expectStatus( - await request("/api/onboarding/claim", existing, { mode: "ExistingAccount", token }), - 400, - ); - assert.deepEqual( - ( - await pool.query( - `SELECT jsonb_build_object('user',u,'account',a,'profile',p)::text AS value FROM auth."user" u JOIN auth."account" a ON a."userId"=u.id JOIN person_profiles p ON p.person_id=u.id WHERE u.id='journey-rec-interviewer-a-0049'`, - ) - ).rows, - prior, - ); - const collisionApplication = await submit(persons.existing.email, "Collision"); - const collision = await issue(collisionApplication.applicationId); - const collisionToken = new URL(collision.text.split(" ").at(-1)!).hash.slice(1); - await expectStatus( - await request("/api/onboarding/claim", undefined, { - mode: "NewAccount", - token: collisionToken, - password: persons.applicant.password, - }), - 409, - ); - const concurrentApplication = await submit("onboarding-concurrent@example.invalid", "Concurrent"); - const concurrent = await issue(concurrentApplication.applicationId); - const concurrentToken = new URL(concurrent.text.split(" ").at(-1)!).hash.slice(1); + assert.equal(yield* retryDelivery, "Pending"); + yield* backend.stop; + backend = yield* startBackend; + yield* ready; + const retryPre = yield* board; + yield* expectStatus( + yield* request( + boardPath, + leader, + { applicationId: retryApplication.applicationId, action: "RetryDelivery" }, + retryPre.etag, + ), + 200, + ); + assert.equal(yield* retryDelivery, "Delivered"); + const revokePre = yield* board; + yield* expectStatus( + yield* request( + boardPath, + leader, + { applicationId: retryApplication.applicationId, action: "Revoke" }, + revokePre.etag, + ), + 200, + ); - const claims = await Promise.all([ - request("/api/onboarding/claim", undefined, { - mode: "NewAccount", - token: concurrentToken, - password: persons.applicant.password, - }), - request("/api/onboarding/claim", undefined, { - mode: "NewAccount", - token: concurrentToken, - password: persons.applicant.password, - }), - ]); - - assert.deepEqual(claims.map((r) => r.status).sort((a, b) => a - b), [200, 400]); - const retryApplication = await submit("onboarding-retry@example.invalid", "Retry"); - rejectNext = true; - const pre = await board(); - await expectStatus( - await request( - boardPath, - leader, - { applicationId: retryApplication.applicationId, action: "Issue" }, - pre.etag, - ), - 200, - ); - assert.equal( - (await board()).items.find( - (item: { applicationId: string }) => item.applicationId === retryApplication.applicationId, - ).delivery, - "Pending", - ); - await stopOwnedProcess(backend); - backend = start("bun", ["run", "--cwd", "apps/backend", "start"], environment); - await ready(); - const retryPre = await board(); - await expectStatus( - await request( - boardPath, - leader, - { applicationId: retryApplication.applicationId, action: "RetryDelivery" }, - retryPre.etag, - ), - 200, - ); - assert.equal( - (await board()).items.find( - (item: { applicationId: string }) => item.applicationId === retryApplication.applicationId, - ).delivery, - "Delivered", - ); - const revokePre = await board(); - await expectStatus( - await request( - boardPath, - leader, - { applicationId: retryApplication.applicationId, action: "Revoke" }, - revokePre.etag, - ), - 200, - ); + const revoked = [...mail.values()].find( + (item) => item.message.to === "onboarding-retry@example.invalid", + )!.message; - const revoked = [...mail.values()].find( - (item) => item.to === "onboarding-retry@example.invalid", - )!; + yield* expectStatus( + yield* request("/api/onboarding/claim", undefined, { + mode: "NewAccount", + token: claimToken(revoked), + password: persons.applicant.password, + }), + 400, + ); + const rollbackApplication = yield* submit("onboarding-rollback@example.invalid", "Rollback"); + const rollbackInvite = yield* issue(rollbackApplication.applicationId); + const rollbackToken = claimToken(rollbackInvite); + yield* query( + `CREATE FUNCTION public.reject_onboarding_test_credential() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF EXISTS(SELECT 1 FROM auth."user" WHERE id=NEW."userId" AND email='onboarding-rollback@example.invalid') THEN RAISE EXCEPTION 'synthetic credential write rejection'; END IF; RETURN NEW; END $$; CREATE TRIGGER reject_onboarding_test_credential BEFORE INSERT ON auth."account" FOR EACH ROW EXECUTE FUNCTION public.reject_onboarding_test_credential()`, + ); - await expectStatus( - await request("/api/onboarding/claim", undefined, { - mode: "NewAccount", - token: new URL(revoked.text.split(" ").at(-1)!).hash.slice(1), - password: persons.applicant.password, - }), - 400, - ); - const rollbackApplication = await submit("onboarding-rollback@example.invalid", "Rollback"); - const rollbackInvite = await issue(rollbackApplication.applicationId); - const rollbackToken = new URL(rollbackInvite.text.split(" ").at(-1)!).hash.slice(1); - await pool.query( - `CREATE FUNCTION public.reject_onboarding_test_credential() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF EXISTS(SELECT 1 FROM auth."user" WHERE id=NEW."userId" AND email='onboarding-rollback@example.invalid') THEN RAISE EXCEPTION 'synthetic credential write rejection'; END IF; RETURN NEW; END $$; CREATE TRIGGER reject_onboarding_test_credential BEFORE INSERT ON auth."account" FOR EACH ROW EXECUTE FUNCTION public.reject_onboarding_test_credential()`, - ); + yield* Effect.gen(function* () { + yield* expectStatus( + yield* request("/api/onboarding/claim", undefined, { + mode: "NewAccount", + token: rollbackToken, + password: persons.applicant.password, + }), + 500, + ); + assert.equal( + (yield* query( + `SELECT count(*)::int AS count FROM person_contact_profiles WHERE email='onboarding-rollback@example.invalid'`, + )).rows[0].count, + 0, + ); + assert.equal( + (yield* query( + `SELECT count(*)::int AS count FROM auth."user" WHERE email='onboarding-rollback@example.invalid'`, + )).rows[0].count, + 0, + ); + }).pipe( + Effect.ensuring( + query( + `DROP TRIGGER reject_onboarding_test_credential ON auth."account"; DROP FUNCTION public.reject_onboarding_test_credential()`, + ).pipe(Effect.orDie), + ), + ); - try { - await expectStatus( - await request("/api/onboarding/claim", undefined, { + yield* expectStatus( + yield* request("/api/onboarding/claim", undefined, { mode: "NewAccount", token: rollbackToken, password: persons.applicant.password, }), - 500, - ); - assert.equal( - ( - await pool.query( - `SELECT count(*)::int AS count FROM person_contact_profiles WHERE email='onboarding-rollback@example.invalid'`, - ) - ).rows[0].count, - 0, + 200, ); - assert.equal( - ( - await pool.query( - `SELECT count(*)::int AS count FROM auth."user" WHERE email='onboarding-rollback@example.invalid'`, - ) - ).rows[0].count, - 0, + const expiring = yield* submit("onboarding-expiry@example.invalid", "Expiry"); + const expiryToken = "onboard_" + randomBytes(32).toString("hex"); + const expiryDigest = createHash("sha256").update(expiryToken).digest("hex"); + yield* query( + `INSERT INTO applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) SELECT 'expiry-race',application_id,applicant_id,$2,date_trunc('milliseconds',clock_timestamp(),'UTC')+interval '3 seconds','Open',$3,date_trunc('milliseconds',clock_timestamp(),'UTC')-interval '24 hours'+interval '3 seconds' FROM admission_applications WHERE application_id=$1`, + [expiring.applicationId, expiryDigest, leaderId], ); - } finally { - await pool.query( - `DROP TRIGGER reject_onboarding_test_credential ON auth."account"; DROP FUNCTION public.reject_onboarding_test_credential()`, + yield* query( + `INSERT INTO applicant_account_delivery(invitation_id,state,recipient) VALUES('expiry-race','Delivered','onboarding-expiry@example.invalid')`, ); - } - await expectStatus( - await request("/api/onboarding/claim", undefined, { - mode: "NewAccount", - token: rollbackToken, - password: persons.applicant.password, - }), - 200, - ); - const expiring = await submit("onboarding-expiry@example.invalid", "Expiry"); - const expiryToken = "onboard_" + randomBytes(32).toString("hex"); - const expiryDigest = createHash("sha256").update(expiryToken).digest("hex"); - await pool.query( - `INSERT INTO applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) SELECT 'expiry-race',application_id,applicant_id,$2,date_trunc('milliseconds',clock_timestamp(),'UTC')+interval '3 seconds','Open',$3,date_trunc('milliseconds',clock_timestamp(),'UTC')-interval '24 hours'+interval '3 seconds' FROM admission_applications WHERE application_id=$1`, - [expiring.applicationId, expiryDigest, leaderId], - ); - await pool.query( - `INSERT INTO applicant_account_delivery(invitation_id,state,recipient) VALUES('expiry-race','Delivered','onboarding-expiry@example.invalid')`, - ); - const holder = await pool.connect(); - await holder.query("BEGIN"); - await holder.query( - `SELECT p.applicant_id FROM admission_applicants p JOIN admission_applications a USING(applicant_id) WHERE a.application_id=$1 FOR UPDATE OF p`, - [expiring.applicationId], - ); - - try { - const delayed = request("/api/onboarding/claim", undefined, { - mode: "NewAccount", - token: expiryToken, - password: persons.applicant.password, + const holder = yield* Effect.tryPromise({ + try: (): Promise => pool.connect(), + catch: (cause) => new DatabaseFailure({ cause }), }); - for (let n = 0; ; n++) { - const waiting = await pool.query( - `SELECT count(*)::int AS count FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%admission_applicants%'`, + const hold = (text: string, values?: ReadonlyArray) => + Effect.tryPromise({ + try: (): Promise => holder.query(text, values), + catch: (cause) => new DatabaseFailure({ cause }), + }); + + yield* hold("BEGIN"); + yield* hold( + `SELECT p.applicant_id FROM admission_applicants p JOIN admission_applications a USING(applicant_id) WHERE a.application_id=$1 FOR UPDATE OF p`, + [expiring.applicationId], + ); + + yield* Effect.gen(function* () { + const delayed = yield* Effect.forkChild( + request("/api/onboarding/claim", undefined, { + mode: "NewAccount", + token: expiryToken, + password: persons.applicant.password, + }), ); - if (waiting.rows[0].count > 0) break; + for (let n = 0; ; n++) { + const waiting = yield* query( + `SELECT count(*)::int AS count FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%admission_applicants%'`, + ); - if (n > 100) throw new Error("Claim did not wait on applicant lock"); - await delay(20); - } + if (waiting.rows[0].count > 0) break; - for (;;) { - const expired = await pool.query( - `SELECT expires_at 100) + return yield* new ProbeFailure({ message: "Claim did not wait on applicant lock" }); + yield* Effect.sleep("20 millis"); + } - if (expired.rows[0].expired) break; - await delay(30); - } + for (;;) { + const expired = yield* query( + `SELECT expires_at holder.release())), + ), + ), + ); + + assert.equal( + (yield* query( `SELECT count(*)::int AS count FROM auth."user" WHERE email='onboarding-expiry@example.invalid'`, - ) - ).rows[0].count, - 0, - ); - // Expiry is enforced immediately; physical secret cleanup belongs to the backend lifetime. - const cleanupApplication = await submit("onboarding-cleanup@example.invalid", "Cleanup"); - await stopOwnedProcess(backend); - // Seed an already-expired pending effect; invitation timestamps are immutable. - const cleanupId = "expiry-cleanup"; - const cleanupToken = "onboard_" + randomBytes(32).toString("hex"); - await pool.query( - `INSERT INTO applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) SELECT $2,application_id,applicant_id,$3,date_trunc('milliseconds',clock_timestamp(),'UTC')-interval '1 second','Open',$4,date_trunc('milliseconds',clock_timestamp(),'UTC')-interval '24 hours 1 second' FROM admission_applications WHERE application_id=$1`, - [ - cleanupApplication.applicationId, - cleanupId, - createHash("sha256").update(cleanupToken).digest("hex"), - leaderId, - ], - ); - await pool.query( - `INSERT INTO applicant_account_delivery(invitation_id,state,recipient,secret,envelope) VALUES($1,'Pending','onboarding-cleanup@example.invalid',$2,'{}'::jsonb)`, - [cleanupId, cleanupToken], - ); - await pool.query(`CREATE FUNCTION public.reject_onboarding_expiry_rehearsal() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic expiry cleanup failure'; END $$; + )).rows[0].count, + 0, + ); + // Expiry is enforced immediately; physical secret cleanup belongs to the backend lifetime. + const cleanupApplication = yield* submit("onboarding-cleanup@example.invalid", "Cleanup"); + yield* backend.stop; + // Seed an already-expired pending effect; invitation timestamps are immutable. + const cleanupId = "expiry-cleanup"; + const cleanupToken = "onboard_" + randomBytes(32).toString("hex"); + yield* query( + `INSERT INTO applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) SELECT $2,application_id,applicant_id,$3,date_trunc('milliseconds',clock_timestamp(),'UTC')-interval '1 second','Open',$4,date_trunc('milliseconds',clock_timestamp(),'UTC')-interval '24 hours 1 second' FROM admission_applications WHERE application_id=$1`, + [ + cleanupApplication.applicationId, + cleanupId, + createHash("sha256").update(cleanupToken).digest("hex"), + leaderId, + ], + ); + yield* query( + `INSERT INTO applicant_account_delivery(invitation_id,state,recipient,secret,envelope) VALUES($1,'Pending','onboarding-cleanup@example.invalid',$2,'{}'::jsonb)`, + [cleanupId, cleanupToken], + ); + yield* query(`CREATE FUNCTION public.reject_onboarding_expiry_rehearsal() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic expiry cleanup failure'; END $$; CREATE TRIGGER reject_onboarding_expiry_rehearsal BEFORE UPDATE ON applicant_account_delivery FOR EACH ROW WHEN (OLD.state='Pending' AND NEW.state='Cancelled') EXECUTE FUNCTION public.reject_onboarding_expiry_rehearsal()`); - backend = start("bun", ["run", "--cwd", "apps/backend", "start"], environment); + backend = yield* startBackend; - const failedWorkerExit = await new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error("expiry worker failure did not stop backend")), - 30000, + const failedWorkerExit = yield* backend.exitCode.pipe( + Effect.timeoutOrElse({ + duration: "30 seconds", + orElse: () => + Effect.fail(new ProbeFailure({ message: "expiry worker failure did not stop backend" })), + }), ); - backend.once("exit", (code) => { - clearTimeout(timer); - resolve(code); - }); - }); - - assert.equal(failedWorkerExit, 1, "unexpected expiry worker failure must not report success"); - await pool.query( - "DROP TRIGGER reject_onboarding_expiry_rehearsal ON applicant_account_delivery; DROP FUNCTION public.reject_onboarding_expiry_rehearsal()", - ); - backend = start("bun", ["run", "--cwd", "apps/backend", "start"], environment); - await ready(); + assert.equal(failedWorkerExit, 1, "unexpected expiry worker failure must not report success"); + yield* query( + "DROP TRIGGER reject_onboarding_expiry_rehearsal ON applicant_account_delivery; DROP FUNCTION public.reject_onboarding_expiry_rehearsal()", + ); + backend = yield* startBackend; + yield* ready; - for (let attempt = 0; ; attempt++) { - const cleaned = ( - await pool.query( + for (let attempt = 0; ; attempt++) { + const cleaned = (yield* query( "SELECT state, secret IS NULL AS secret_removed, envelope IS NULL AS envelope_removed FROM applicant_account_delivery WHERE invitation_id=$1", [cleanupId], + )).rows[0]; + + if ( + cleaned.state === "Cancelled" && + cleaned.secret_removed === true && + cleaned.envelope_removed === true ) - ).rows[0]; - - if (cleaned.state === "Cancelled" && cleaned.secret_removed && cleaned.envelope_removed) break; - assert.ok(attempt < 50, "startup expiry cleanup must erase the pending secret"); - await delay(100); - } - - const manifest = { - revision, - backendOrigin, - dashboardOrigin, - artifacts, - departmentId, - semesterId, - schoolId: 995, - persons, - applicationId: browserApplication.applicationId, - mailboxOrigin, - mailboxToken, - }; + break; + assert.ok(attempt < 50, "startup expiry cleanup must erase the pending secret"); + yield* Effect.sleep("100 millis"); + } - const manifestPath = join(artifacts, "manifest.json"); - await writeFile(manifestPath, JSON.stringify(manifest), { mode: 0o600 }); - let browserEvidence: Schema.Json = null; + const manifest = { + revision, + backendOrigin, + dashboardOrigin, + artifacts, + departmentId, + semesterId, + schoolId: 995, + persons, + applicationId: browserApplication.applicationId, + mailboxOrigin, + mailboxToken, + }; + + const manifestPath = path.join(artifacts, "manifest.json"); + yield* fs.writeFileString(manifestPath, yield* jsonText(manifest), { mode: 0o600 }); + let browserEvidence: Schema.Json = null; + + if (mode === "--browser") { + let output = ""; + + const browserRun = yield* startOwnedProcess({ + command: "bun", + args: ["apps/dashboard/e2e/run-real-native-onboarding.mjs"], + cwd: root, + env: { ...environment, ONBOARDING_JOURNEY_MANIFEST: manifestPath }, + output: (text) => { + output += text; + }, + }); - if (mode === "--browser") { - await runBrowser(["apps/dashboard/e2e/run-real-native-onboarding.mjs"], { - ...environment, - ONBOARDING_JOURNEY_MANIFEST: manifestPath, - }); - browserEvidence = Schema.decodeSync(Schema.fromJsonString(Schema.Json))( - await readFile(join(artifacts, "browser-evidence.json"), "utf8"), - ); + const browserFailed = () => new ProbeFailure({ message: "Browser journey failed: " + safe(output) }); + + const code = yield* browserRun.exitCode.pipe( + Effect.timeoutOrElse({ + duration: "300 seconds", + orElse: () => browserRun.stop.pipe(Effect.andThen(Effect.fail(browserFailed()))), + }), + Effect.catchTag("AcceptanceCommandFailed", () => Effect.fail(browserFailed())), + ); + + if (code !== 0) return yield* browserFailed(); + + browserEvidence = yield* Schema.decodeEffect(JsonBody)( + yield* fs.readFileString(path.join(artifacts, "browser-evidence.json")), + ); - const observed = ( - await pool.query( + const observed = (yield* query( `SELECT l.person_id FROM applicant_account_links l JOIN admission_applicants a USING(applicant_id) WHERE a.email=$1`, [persons.applicant.email], - ) - ).rows; + )).rows; - assert.equal(observed.length, 1); - assert.equal( - ( - await pool.query( + assert.equal(observed.length, 1); + assert.equal( + (yield* query( `SELECT count(*)::int AS count FROM assistant_placements WHERE person_id=$1 AND active`, [observed[0].person_id], - ) - ).rows[0].count, - 1, - ); - } + )).rows[0].count, + 1, + ); + } - assert.equal( - ( - await pool.query( + assert.equal( + (yield* query( `SELECT count(*)::int AS count FROM applicant_account_delivery WHERE state IN ('Delivered','Cancelled') AND (secret IS NOT NULL OR envelope IS NOT NULL)`, - ) - ).rows[0].count, - 0, - ); - evidence = { - revision, - passed: true, - mode, - browserEvidence, - mailAttempts: attempts, - acceptedDeliveries: mail.size, - gates: [ - "public application native authority", - "scoped coordinator invitation and idempotency conflict", - "wrong department, inactive issuer and revoked receipt replay denied", - "real credential write failure rolls back account/profile/link/consumption", - "existing claim preserves profile/credentials", - "email collision rejected", - "concurrent claim one account", - "failed delivery restart retry", - "revoked and consumed token rejected", - "terminal secret cleanup", - "expired pending secret erased on restart; failed expiry worker exits nonzero", - "claim rejects expiry crossed while waiting for applicant lock", - ], - scope: "synthetic loopback only", - }; -} catch (error) { - throw new Error(safe(error instanceof Error ? error.message : "Onboarding runtime failed")); -} finally { - if (pool) await pool.end(); - - for (const child of children.reverse()) await stopOwnedProcess(child); - - if (mailbox) await new Promise((resolve) => mailbox!.close(() => resolve())); - await postgres?.stop(); - await rm(join(artifacts, "manifest.json"), { force: true }); - - if (evidence) { - assertNoSecrets(JSON.stringify(evidence)); - await writeFile( - join(artifacts, "evidence.json"), - JSON.stringify( - Struct.assign(evidence, { cleanup: "owned processes and credential manifest removed" }), - null, - 2, - ), + )).rows[0].count, + 0, ); - process.stdout.write(join(artifacts, "evidence.json") + "\n"); - } -} + + const evidence: Schema.JsonObject = { + revision, + passed: true, + mode, + browserEvidence, + mailAttempts: attempts, + acceptedDeliveries: mail.size, + gates: [ + "public application native authority", + "scoped coordinator invitation and idempotency conflict", + "wrong department, inactive issuer and revoked receipt replay denied", + "real credential write failure rolls back account/profile/link/consumption", + "existing claim preserves profile/credentials", + "email collision rejected", + "concurrent claim one account", + "failed delivery restart retry", + "revoked and consumed token rejected", + "terminal secret cleanup", + "expired pending secret erased on restart; failed expiry worker exits nonzero", + "claim rejects expiry crossed while waiting for applicant lock", + ], + scope: "synthetic loopback only", + }; + + return evidence; + }).pipe(Effect.scoped); + + const evidence = yield* checks; + assertNoSecrets(yield* jsonText(evidence)); + const evidencePath = path.join(artifacts, "evidence.json"); + + yield* fs.writeFileString( + evidencePath, + yield* indentedJsonText( + Struct.assign(evidence, { cleanup: "owned processes and credential manifest removed" }), + ), + ); + yield* Console.log(evidencePath); +}); + +BunRuntime.runMain( + journey.pipe( + Effect.catchCause((cause) => { + const error = Cause.squash(cause); + + return Effect.fail( + new OnboardingFailed({ + message: safe(error instanceof Error ? error.message : "Onboarding runtime failed"), + }), + ); + }), + Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer)), + ), +); diff --git a/tools/acceptance/owned-process.test.ts b/tools/acceptance/owned-process.test.ts deleted file mode 100644 index 0a8e56c4a..000000000 --- a/tools/acceptance/owned-process.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { spawn } from "node:child_process"; -import { stopOwnedProcess } from "./owned-process"; - -describe("owned process lifecycle", () => { - it("confirms owned process exit even when graceful termination is ignored", async () => { - const child = spawn("bun", [ - "-e", - 'process.on("SIGTERM", () => {}); process.stdout.write("ready"); setInterval(() => {}, 1000);', - ]); - - try { - await new Promise((resolve, reject) => { - child.once("error", reject); - child.stdout.once("data", () => resolve()); - }); - // Real clock on purpose: Bun's fake timers also freeze this test's timeout, - // so a missing SIGKILL escalation would hang instead of failing. - await stopOwnedProcess(child); - expect(child.signalCode).toBe("SIGKILL"); - } finally { - if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); - } - }, 10_000); -}); diff --git a/tools/acceptance/owned-process.ts b/tools/acceptance/owned-process.ts deleted file mode 100644 index 638197d91..000000000 --- a/tools/acceptance/owned-process.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ChildProcess } from "node:child_process"; - -/** Same TERM/KILL-and-observe pattern as the homepage contact acceptance runner. */ -export const stopOwnedProcess = async (child: ChildProcess): Promise => { - if (child.exitCode !== null || child.signalCode !== null) return; - await new Promise((resolve) => { - const timer = setTimeout(() => child.kill("SIGKILL"), 5_000); - child.once("exit", () => { - clearTimeout(timer); - resolve(); - }); - child.kill("SIGTERM"); - }); -}; diff --git a/tools/acceptance/package.json b/tools/acceptance/package.json new file mode 100644 index 000000000..2c282badd --- /dev/null +++ b/tools/acceptance/package.json @@ -0,0 +1,10 @@ +{ + "name": "@monoweb/acceptance", + "version": "0.1.0", + "private": true, + "type": "module", + "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", + "effect": "catalog:effect-v4" + } +} diff --git a/tools/acceptance/password-recovery-check.ts b/tools/acceptance/password-recovery-check.ts index c37ea8dd2..6b37a63f7 100644 --- a/tools/acceptance/password-recovery-check.ts +++ b/tools/acceptance/password-recovery-check.ts @@ -1,27 +1,43 @@ -import { type Server } from "bun"; /** Spec0054.2: real PostgreSQL, HTTP acknowledgement mailbox and production browser journey. */ import assert from "node:assert/strict"; -import { execFileSync, spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { mkdtemp, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { createRequire } from "node:module"; -import process from "node:process"; -import { Predicate, Console, Effect, Layer, Schema } from "effect"; -import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; -import { MailDeliveryRequest, Mail } from "../../packages/domain/src/mail.js"; -import { stopOwnedProcess } from "./owned-process.js"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import { + Cause, + Config, + Console, + Data, + Duration, + Effect, + FileSystem, + Layer, + Path, + Predicate, + Schedule, + Schema, +} from "effect"; import { - type DisposablePostgres, - loopbackPortFree, - reserveLoopbackPorts, - startDisposablePostgres, -} from "../postgres/index.ts"; + FetchHttpClient, + HttpClient, + HttpClientRequest, + type HttpClientResponse, +} from "effect/unstable/http"; +import { MailDeliveryRequest, Mail } from "../../packages/domain/src/mail.js"; +import { loopbackPortFree, reserveLoopbackPorts, startDisposablePostgres } from "../postgres/index.ts"; import { drainPasswordResetMail } from "../../packages/database/src/password-recovery.js"; import { HttpMailLive } from "../../apps/backend/src/mail/http.js"; - -const root = new URL("../../", import.meta.url).pathname; +import { nativeRpcRequestBody, nativeRpcStatus } from "../../apps/dashboard/e2e/native-operations.js"; +import { auditSettledPage } from "../../apps/dashboard/e2e/settled-axe.js"; +import { jsonText } from "../../apps/backend/src/rpc/problem.js"; +import { + answersOk, + commandOutput, + indentedJsonText, + ProbeFailure, + startOwnedProcess, +} from "./acceptance-process.ts"; const requireDatabase = createRequire( new URL("../../packages/database/package.json", import.meta.url), @@ -35,521 +51,585 @@ const { Pool } = requireDatabase("pg"); const { chromium } = requireDashboard("@playwright/test"); -const logs: string[] = []; - -const run = (command: string, args: string[], env = process.env, cwd = root) => - execFileSync(command, args, { - cwd, - env, - encoding: "utf8", - timeout: 180000, - stdio: ["ignore", "pipe", "pipe"], - }); - -assert.equal(run("git", ["status", "--porcelain"]).trim(), "", "committed clean tree required"); - -const revision = run("git", ["rev-parse", "HEAD"]).trim(); +/** A PostgreSQL call, or the disposable cluster, that failed. */ +class DatabaseFailure extends Data.TaggedError("DatabaseFailure")<{ readonly cause: unknown }> {} -const artifacts = await mkdtemp(join(tmpdir(), "vektor-recovery-0054-")); +/** A Playwright call that failed. */ +class BrowserFailure extends Data.TaggedError("BrowserFailure")<{ readonly cause: unknown }> {} -const children: ReturnType[] = []; +/** The rows of one query; the probe reads the columns that its statement selects. */ +interface QueryResult { + readonly rows: Array; +} -const start = (command: string, args: string[], env = process.env, cwd = root) => { - const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); - children.push(child); - child.stdout?.on("data", (chunk) => logs.push(String(chunk))); - child.stderr?.on("data", (chunk) => logs.push(String(chunk))); +/** The name and value of the first cookie that a response sets, as a `Cookie` header sends it. */ +const firstCookie = (response: HttpClientResponse.HttpClientResponse): string | undefined => { + const cookie = Object.values(response.cookies.cookies)[0]; - return child; + return cookie === undefined ? undefined : `${cookie.name}=${cookie.valueEncoded}`; }; -const wait = async (test: () => Promise) => { - for (let i = 0; i < 150; i++) { - try { - if (await test()) return; - } catch {} +/** One Playwright call; the probe loads Playwright untyped, so its handles are `any`. */ +const browserStep = (step: () => Promise): Effect.Effect => + Effect.tryPromise({ try: step, catch: (cause) => new BrowserFailure({ cause }) }); - await new Promise((ok) => setTimeout(ok, 100)); - } +const MailboxMessages = Schema.Array(Schema.Struct({ text: Schema.String })); - throw new Error("Readiness timeout"); -}; +const journey = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.resolve(import.meta.dirname, "../.."); + const logs: string[] = []; -let postgres: DisposablePostgres | undefined; + const run = (command: string, args: ReadonlyArray, env = {}, cwd = root) => + commandOutput({ command, args, cwd, env, deadline: "180 seconds", stderr: "pipe" }); -let pool: InstanceType; + assert.equal( + (yield* run("git", ["status", "--porcelain"])).trim(), + "", + "committed clean tree required", + ); -let browser: any; + const revision = (yield* run("git", ["rev-parse", "HEAD"])).trim(); + const artifacts = yield* fs.makeTempDirectory({ prefix: "vektor-recovery-0054-" }); -let page: any; + const start = (command: string, args: ReadonlyArray, env = {}, cwd = root) => + startOwnedProcess({ command, args, cwd, env, output: (text) => logs.push(text) }); -let mailbox: Server | undefined; + const wait = (url: string) => + answersOk(url).pipe( + Effect.filterOrFail( + (ready) => ready, + () => new ProbeFailure({ message: "Readiness timeout" }), + ), + Effect.retry({ schedule: Schedule.spaced("100 millis"), times: 149 }), + ); -const secrets: string[] = []; + const secrets: string[] = []; + const gates: string[] = []; + const submissions: { tokenPresent: boolean; queryAbsent: boolean }[] = []; + let page: any; -const gates: string[] = []; + const checks = Effect.gen(function* () { + const [apiPort] = yield* Effect.tryPromise({ + try: () => reserveLoopbackPorts(1), + catch: (cause) => new DatabaseFailure({ cause }), + }); -const submissions: { tokenPresent: boolean; queryAbsent: boolean }[] = []; + const uiPort = 5174; -try { - const [apiPort] = await reserveLoopbackPorts(1), - uiPort = 5174; + assert.ok( + yield* Effect.promise(() => loopbackPortFree(uiPort)), + `loopback port ${uiPort} is in use`, + ); - assert.ok(await loopbackPortFree(uiPort), `loopback port ${uiPort} is in use`); + const postgres = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => startDisposablePostgres(), + catch: (cause) => new DatabaseFailure({ cause }), + }), + (cluster) => Effect.promise(() => cluster.stop()), + ); - postgres = await startDisposablePostgres(); - const pg = postgres.url; - pool = new Pool({ connectionString: pg }); + const pg = postgres.url; - const canonicalOrigin = `http://127.0.0.1:${apiPort}`, - dashboardOrigin = `http://127.0.0.1:${uiPort}`; + const pool = yield* Effect.acquireRelease( + Effect.sync(() => new Pool({ connectionString: pg })), + (owned) => Effect.promise(() => owned.end()), + ); - const env = { - ...process.env, - BACKEND_HOST: "127.0.0.1", - BACKEND_PORT: String(apiPort), - BACKEND_PG_URL: pg, - BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), - NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify([dashboardOrigin]), - OAUTH_CANONICAL_ORIGIN: canonicalOrigin, - OAUTH_DASHBOARD_ORIGIN: dashboardOrigin, - OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - PUBLIC_APPLICATION_EFFECT_MODE: "disabled", - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_MODE: "disabled", - JOURNEY_SEED_PG_URL: pg, - API_URL: canonicalOrigin, - VITE_API_URL: canonicalOrigin, - PASSWORD_RECOVERY_ENGINE: "native", - HOST: "127.0.0.1", - PORT: String(uiPort), - NODE_ENV: "production", - DASHBOARD_MOUNT: "/", - }; + const query = (text: string, values?: ReadonlyArray) => + Effect.tryPromise({ + try: (): Promise => pool.query(text, values), + catch: (cause) => new DatabaseFailure({ cause }), + }); - run("bun", ["apps/dashboard/e2e/native-recruitment-journey-seed.mjs"], env); - start("bun", ["apps/backend/src/main.ts"], env); - await wait(async () => { - const r = await fetch(`${canonicalOrigin}/health`); + const canonicalOrigin = `http://127.0.0.1:${apiPort}`, + dashboardOrigin = `http://127.0.0.1:${uiPort}`; + + /** The status that `system.readSession` answers for a cookie, sent as the browser sends it. */ + const readSessionStatus = (cookie: string) => + HttpClient.execute( + HttpClientRequest.post(`${canonicalOrigin}/api/rpc`, { + headers: { cookie, origin: dashboardOrigin }, + }).pipe( + HttpClientRequest.bodyText( + nativeRpcRequestBody("system.readSession"), + "application/json", + ), + ), + ).pipe( + Effect.flatMap((response) => response.text), + Effect.map(nativeRpcStatus), + ); + + const env = { + BACKEND_HOST: "127.0.0.1", + BACKEND_PORT: String(apiPort), + BACKEND_PG_URL: pg, + BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), + NATIVE_IDENTITY_DEPLOYMENT: "local", + NATIVE_IDENTITY_TRUSTED_ORIGINS: yield* jsonText([dashboardOrigin]), + OAUTH_CANONICAL_ORIGIN: canonicalOrigin, + OAUTH_DASHBOARD_ORIGIN: dashboardOrigin, + OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", + PUBLIC_APPLICATION_EFFECT_MODE: "disabled", + PASSWORD_RESET_DELIVERY_MODE: "disabled", + RECEIPT_DELIVERY_MODE: "disabled", + JOURNEY_SEED_PG_URL: pg, + API_URL: canonicalOrigin, + VITE_API_URL: canonicalOrigin, + PASSWORD_RECOVERY_ENGINE: "native", + HOST: "127.0.0.1", + PORT: String(uiPort), + NODE_ENV: "production", + DASHBOARD_MOUNT: "/", + }; + + yield* run("bun", ["apps/dashboard/e2e/native-recruitment-journey-seed.mjs"], env); + yield* start("bun", ["apps/backend/src/main.ts"], env); + yield* wait(`${canonicalOrigin}/health`); + + const email = "lina.leader@example.invalid", + oldPassword = "journey-secret-0123456789abcdef", + newPassword = "New-password-recovery-0123456789"; + + secrets.push(email, oldPassword, newPassword); + + /** One identity-engine request that follows no redirect, waiting out a real rate limit. */ + const post = (path: string, body: Schema.Json, origin = dashboardOrigin) => + Effect.gen(function* () { + const request = HttpClientRequest.post(`${canonicalOrigin}/api/auth/${path}`, { + headers: { origin }, + }).pipe(HttpClientRequest.bodyText(yield* jsonText(body), "application/json")); + + for (let attempt = 0; attempt < 4; attempt++) { + const response = yield* HttpClient.execute(request).pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + ); + + if (response.status !== 429) return response; + const retry = Number(response.headers["x-retry-after"]); + assert.ok( + Number.isFinite(retry) && retry >= 0 && retry <= 60, + "bounded engine retry window", + ); + gates.push("real credential rate limit observed; waited retry window"); + yield* Effect.sleep(Duration.millis(retry * 1000 + 100)); + } + + return yield* new ProbeFailure({ message: "Credential rate limit did not clear" }); + }); - return r.ok; - }); + const login = (password: string) => + Effect.gen(function* () { + const response = yield* post("sign-in/email", { email, password }); + assert.equal(response.status, 200); + const cookie = firstCookie(response); + assert.ok(cookie !== undefined); - const email = "lina.leader@example.invalid", - oldPassword = "journey-secret-0123456789abcdef", - newPassword = "New-password-recovery-0123456789"; - - secrets.push(email, oldPassword, newPassword); - - const post = async ( - path: string, - body: Schema.Json, - origin = dashboardOrigin, - ): Promise => { - for (let attempt = 0; attempt < 4; attempt++) { - const response = await fetch(`${canonicalOrigin}/api/auth/${path}`, { - method: "POST", - headers: { origin, "content-type": "application/json" }, - body: JSON.stringify(body), - redirect: "manual", + return cookie; }); - if (response.status !== 429) return response; - const retry = Number(response.headers.get("x-retry-after")); - assert.ok(Number.isFinite(retry) && retry >= 0 && retry <= 60, "bounded engine retry window"); - gates.push("real credential rate limit observed; waited retry window"); - await response.body?.cancel(); - await new Promise((ok) => setTimeout(ok, retry * 1000 + 100)); - } + const cookie1 = yield* login(oldPassword), + cookie2 = yield* login(oldPassword); - throw new Error("Credential rate limit did not clear"); - }; + secrets.push(cookie1, cookie2); + const messages = new Map(); + let rejectMail = false; + const mailboxToken = randomBytes(24).toString("hex"); + secrets.push(mailboxToken); - const login = async (password: string) => { - const r = await post("sign-in/email", { email, password }); - assert.equal(r.status, 200); + const mailbox = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + if (request.headers.get("authorization") !== `Bearer ${mailboxToken}`) + return new Response(null, { status: 401 }); - return r.headers.getSetCookie()[0]!.split(";")[0]!; - }; + if (request.method === "GET") return Response.json([...messages.values()]); - const cookie1 = await login(oldPassword), - cookie2 = await login(oldPassword); + if (rejectMail) return new Response(null, { status: 503 }); - secrets.push(cookie1, cookie2); - const messages = new Map(); - let rejectMail = false; - const mailboxToken = randomBytes(24).toString("hex"); - secrets.push(mailboxToken); - mailbox = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - async fetch(request) { - if (request.headers.get("authorization") !== `Bearer ${mailboxToken}`) - return new Response(null, { status: 401 }); + return request.json().then((json) => { + const body = Schema.decodeUnknownSync(MailDeliveryRequest)(json); + messages.set(body.deliveryId, body); - if (request.method === "GET") return Response.json([...messages.values()]); + return Response.json({ acknowledged: true }); + }); + }, + }), + ), + (server) => Effect.promise(() => server.stop(true)), + ); - if (rejectMail) return new Response(null, { status: 503 }); + const delivery = HttpMailLive({ + endpoint: new URL(`http://127.0.0.1:${mailbox.port}/mail`), + token: mailboxToken, + deliveryTimeoutMilliseconds: 2000, + }); - const body = Schema.decodeUnknownSync(MailDeliveryRequest)(await request.json()); + const drainWith = (provider: Layer.Layer) => + Mail.use((mail) => + drainPasswordResetMail( + pool, + { + oauth: { + canonicalOrigin, + dashboardOrigin, + nativeApiResource: "urn:vektorprogrammet:native-api", + }, + }, + mail, + "recovery@example.invalid", + ), + ).pipe(Effect.provide(provider.pipe(Layer.provide(FetchHttpClient.layer)))); - messages.set(body.deliveryId, body); + const drain = drainWith(delivery); - return Response.json({ acknowledged: true }); - }, - }); + yield* run("bun", ["run", "build"], env, path.join(root, "apps/dashboard")); + yield* start("bun", ["server.mjs"], env, path.join(root, "apps/dashboard")); + yield* wait(`${dashboardOrigin}/glemt-passord`); - const delivery = HttpMailLive({ - endpoint: new URL(`http://127.0.0.1:${mailbox.port}/mail`), - token: mailboxToken, - deliveryTimeoutMilliseconds: 2000, - }); + const executablePath = yield* Config.String("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH").pipe( + Config.withDefault("/etc/profiles/per-user/nori/bin/chromium"), + ); - const drainWith = (provider: Layer.Layer) => - Effect.runPromise( - Mail.use((mail) => - drainPasswordResetMail(pool, - { - oauth: { - canonicalOrigin, - dashboardOrigin, - nativeApiResource: "urn:vektorprogrammet:native-api", - }, - }, - mail, - "recovery@example.invalid",), - ).pipe(Effect.provide(provider.pipe(Layer.provide(FetchHttpClient.layer)))), - ); - - const drain = () => drainWith(delivery); - - run("bun", ["run", "build"], env, join(root, "packages/sdk")); - run("bun", ["run", "build"], env, join(root, "apps/dashboard")); - start("bun", ["server.mjs"], env, join(root, "apps/dashboard")); - await wait(async () => (await fetch(`${dashboardOrigin}/glemt-passord`)).ok); - browser = await chromium.launch({ - headless: true, - executablePath: - process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ?? "/etc/profiles/per-user/nori/bin/chromium", - }); - const browserContext = await browser.newContext(); - page = await browserContext.newPage(); - page.on("request", (request: any) => { - const url = new URL(request.url()); - - if ( - request.method() === "POST" && - ["/tilbakestill-passord", "/tilbakestill-passord.data"].includes(url.pathname) - ) { - const body = new URLSearchParams(request.postData() ?? ""); - submissions.push({ - tokenPresent: !!body.get("token"), - queryAbsent: !url.searchParams.has("token"), - }); - } - }); - const errors: string[] = []; - page.on("pageerror", () => errors.push("Browser runtime error")); - await page.goto(`${dashboardOrigin}/glemt-passord`); - await page.getByLabel("E-post", { exact: true }).fill(email); - await page.getByRole("button", { name: "Send tilbakestillingslenke" }).click(); - await page.getByText("Hvis kontoen finnes", { exact: false }).waitFor(); - await page.goto(`${dashboardOrigin}/glemt-passord`); - await page.getByLabel("E-post", { exact: true }).fill("unknown@example.invalid"); - await page.getByRole("button", { name: "Send tilbakestillingslenke" }).click(); - await page.getByText("Hvis kontoen finnes", { exact: false }).waitFor(); - assert.equal( - (await pool.query("SELECT count(*)::int n FROM auth.password_reset_email_outbox")).rows[0].n, - 1, - ); - assert.equal(await drainWith(HttpMailLive(undefined)), "Failed"); - rejectMail = true; - assert.equal(await drain(), "Failed"); - rejectMail = false; - - const operator = start("bun", ["apps/backend/src/password-recovery/drain-main.ts", "--once"], { - ...env, - MAIL_DELIVERY_URL: `http://127.0.0.1:${mailbox.port}/mail`, - MAIL_DELIVERY_TOKEN: mailboxToken, - MAIL_DELIVERY_TIMEOUT_MS: "2000", - MAIL_SENDER: "recovery@example.invalid", - }); + const browser = yield* Effect.acquireRelease( + browserStep(() => chromium.launch({ headless: true, executablePath })), + (owned) => Effect.promise(() => owned.close()), + ); - assert.equal(await new Promise((resolve) => operator.once("exit", resolve)), 0); - assert.equal( - (await pool.query("SELECT status FROM auth.password_reset_email_outbox")).rows[0].status, - "Delivered", - ); - gates.push( - "known/unknown concealment, durable acceptance, missing authority, HTTP503 retry and ACK", - ); + const browserContext = yield* browserStep(() => browser.newContext()); + page = yield* browserStep(() => browserContext.newPage()); + page.on("request", (request: any) => { + const url = new URL(request.url()); + + if ( + request.method() === "POST" && + ["/tilbakestill-passord", "/tilbakestill-passord.data"].includes(url.pathname) + ) { + const body = new URLSearchParams(request.postData() ?? ""); + const token = body.get("token"); + submissions.push({ + tokenPresent: token !== null && token !== "", + queryAbsent: !url.searchParams.has("token"), + }); + } + }); + const errors: string[] = []; + page.on("pageerror", () => errors.push("Browser runtime error")); + yield* browserStep(() => page.goto(`${dashboardOrigin}/glemt-passord`)); + yield* browserStep(() => page.getByLabel("E-post", { exact: true }).fill(email)); + yield* browserStep(() => + page.getByRole("button", { name: "Send tilbakestillingslenke" }).click(), + ); + yield* browserStep(() => page.getByText("Hvis kontoen finnes", { exact: false }).waitFor()); + yield* browserStep(() => page.goto(`${dashboardOrigin}/glemt-passord`)); + yield* browserStep(() => + page.getByLabel("E-post", { exact: true }).fill("unknown@example.invalid"), + ); + yield* browserStep(() => + page.getByRole("button", { name: "Send tilbakestillingslenke" }).click(), + ); + yield* browserStep(() => page.getByText("Hvis kontoen finnes", { exact: false }).waitFor()); + assert.equal( + (yield* query("SELECT count(*)::int n FROM auth.password_reset_email_outbox")).rows[0].n, + 1, + ); + assert.equal(yield* drainWith(HttpMailLive(undefined)), "Failed"); + rejectMail = true; + assert.equal(yield* drain, "Failed"); + rejectMail = false; + + const operator = yield* start( + "bun", + ["apps/backend/src/password-recovery/drain-main.ts", "--once"], + { + ...env, + MAIL_DELIVERY_URL: `http://127.0.0.1:${mailbox.port}/mail`, + MAIL_DELIVERY_TOKEN: mailboxToken, + MAIL_DELIVERY_TIMEOUT_MS: "2000", + MAIL_SENDER: "recovery@example.invalid", + }, + ); - const received = Schema.decodeUnknownSync(Schema.Array(Schema.Struct({ text: Schema.String })))( - await ( - await fetch(`http://127.0.0.1:${mailbox.port}/mail`, { - headers: { authorization: `Bearer ${mailboxToken}` }, - }) - ).json(), - ); + assert.equal(yield* operator.exitCode, 0); + assert.equal( + (yield* query("SELECT status FROM auth.password_reset_email_outbox")).rows[0].status, + "Delivered", + ); + gates.push( + "known/unknown concealment, durable acceptance, missing authority, HTTP503 retry and ACK", + ); - const resetUrl = received[0]!.text.match( - /https?:\/\/[^\s]+\/api\/auth\/reset-password\/[^\s]+/u, - )?.[0]; - - assert.ok(resetUrl, "password reset email contains its reset link"); - const token = new URL(resetUrl).pathname.split("/").at(-1)!; - secrets.push(token, resetUrl); - await page.goto(resetUrl); - assert.equal(new URL(page.url()).pathname, "/tilbakestill-passord"); - await page.getByLabel("Nytt passord", { exact: true }).fill("x".repeat(129)); - await page.getByLabel("Gjenta passord", { exact: true }).fill("x".repeat(129)); - await page.getByRole("button", { name: "Lagre passord" }).click(); - await page.getByRole("alert").waitFor(); - await page.getByLabel("Nytt passord", { exact: true }).fill(newPassword); - await page.getByLabel("Gjenta passord", { exact: true }).fill(newPassword); - await page.getByRole("button", { name: "Lagre passord" }).click(); - await page.waitForURL("**/login?reset=true"); - gates.push("browser policy rejection then corrected reset reaches login"); - - for (const cookie of [cookie1, cookie2]) { - const r = await fetch(`${canonicalOrigin}/api/session`, { - headers: { cookie, origin: dashboardOrigin }, - }); + const received = yield* HttpClient.execute( + HttpClientRequest.get(`http://127.0.0.1:${mailbox.port}/mail`, { + headers: { authorization: `Bearer ${mailboxToken}` }, + }), + ).pipe( + Effect.flatMap((response) => response.json), + Effect.flatMap(Schema.decodeUnknownEffect(MailboxMessages)), + ); - assert.equal(r.status, 401); - } + const resetUrl = received[0]!.text.match( + /https?:\/\/[^\s]+\/api\/auth\/reset-password\/[^\s]+/u, + )?.[0]; + + assert.ok(resetUrl !== undefined, "password reset email contains its reset link"); + const token = new URL(resetUrl).pathname.split("/").at(-1)!; + secrets.push(token, resetUrl); + yield* browserStep(() => page.goto(resetUrl)); + assert.equal(new URL(page.url()).pathname, "/tilbakestill-passord"); + yield* browserStep(() => + page.getByLabel("Nytt passord", { exact: true }).fill("x".repeat(129)), + ); + yield* browserStep(() => + page.getByLabel("Gjenta passord", { exact: true }).fill("x".repeat(129)), + ); + yield* browserStep(() => page.getByRole("button", { name: "Lagre passord" }).click()); + yield* browserStep(() => page.getByRole("alert").waitFor()); + yield* browserStep(() => page.getByLabel("Nytt passord", { exact: true }).fill(newPassword)); + yield* browserStep(() => page.getByLabel("Gjenta passord", { exact: true }).fill(newPassword)); + yield* browserStep(() => page.getByRole("button", { name: "Lagre passord" }).click()); + yield* browserStep(() => page.waitForURL("**/login?reset=true")); + gates.push("browser policy rejection then corrected reset reaches login"); + + for (const cookie of [cookie1, cookie2]) { + assert.equal(yield* readSessionStatus(cookie), 401); + } - gates.push("both old sessions denied"); - assert.equal((await post("sign-in/email", { email, password: oldPassword })).status, 401); - await login(newPassword); - assert.equal((await post("reset-password", { token, newPassword: oldPassword })).status, 400); - gates.push( - "browser callback/reset, both old sessions denied, old password denied, new login, reused token denied", - ); + gates.push("both old sessions denied"); + assert.equal((yield* post("sign-in/email", { email, password: oldPassword })).status, 401); + yield* login(newPassword); + assert.equal( + (yield* post("reset-password", { token, newPassword: oldPassword })).status, + 400, + ); + gates.push( + "browser callback/reset, both old sessions denied, old password denied, new login, reused token denied", + ); - const requestReset = () => - post("request-password-reset", { + const requestReset = post("request-password-reset", { email, redirectTo: `${dashboardOrigin}/tilbakestill-passord`, }); - assert.equal((await requestReset()).status, 200); - await pool.query( - `UPDATE auth.verification SET "expiresAt"=date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')-INTERVAL '1 second' WHERE identifier LIKE 'reset-password:%'`, - ); - assert.equal(await drain(), "Quarantined"); - await page.goto( - `${canonicalOrigin}/api/auth/reset-password/expired-or-malformed?callbackURL=${encodeURIComponent(`${dashboardOrigin}/tilbakestill-passord`)}`, - ); - await page.getByText("Lenken er ugyldig eller utløpt.").waitFor(); - assert.equal( - (await post("request-password-reset", { email, redirectTo: `${dashboardOrigin}/login` })) - .status, - 403, - ); - assert.equal( - ( - await post( + assert.equal((yield* requestReset).status, 200); + yield* query( + `UPDATE auth.verification SET "expiresAt"=date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')-INTERVAL '1 second' WHERE identifier LIKE 'reset-password:%'`, + ); + assert.equal(yield* drain, "Quarantined"); + yield* browserStep(() => + page.goto( + `${canonicalOrigin}/api/auth/reset-password/expired-or-malformed?callbackURL=${encodeURIComponent(`${dashboardOrigin}/tilbakestill-passord`)}`, + ), + ); + yield* browserStep(() => page.getByText("Lenken er ugyldig eller utløpt.").waitFor()); + assert.equal( + (yield* post("request-password-reset", { email, redirectTo: `${dashboardOrigin}/login` })) + .status, + 403, + ); + assert.equal( + (yield* post( "request-password-reset", { email, redirectTo: `${dashboardOrigin}/tilbakestill-passord` }, "https://evil.example", - ) - ).status, - 403, - ); - assert.equal( - ( - await fetch( + )).status, + 403, + ); + assert.equal( + (yield* HttpClient.get( `${canonicalOrigin}/api/auth/reset-password/malformed?callbackURL=${encodeURIComponent(`${dashboardOrigin}/login`)}`, - { redirect: "manual" }, - ) - ).status, - 403, - ); - gates.push("expired quarantine, invalid callback page, origin and callback path denial"); - await pool.query( - `CREATE FUNCTION auth.reject_recovery_enqueue() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic enqueue failure'; END $$; CREATE TRIGGER reject_recovery_enqueue BEFORE INSERT ON auth.password_reset_email_outbox FOR EACH ROW EXECUTE FUNCTION auth.reject_recovery_enqueue()`, - ); - assert.equal((await requestReset()).status, 503); - await pool.query( - `DROP TRIGGER reject_recovery_enqueue ON auth.password_reset_email_outbox; DROP FUNCTION auth.reject_recovery_enqueue()`, - ); - gates.push("enqueue failure replaces swallowed engine success with503"); - await requestReset(); - await pool.query( - `UPDATE auth.password_reset_email_outbox SET status='Processing',claim_id=gen_random_uuid(),claimed_at=date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')-INTERVAL '2 minutes' WHERE status='Pending'`, - ); - // An expired claim may already have sent its mail, so the drain quarantines it instead of resending. - assert.equal(await drain(), "Empty"); - assert.equal( - ( - await pool.query( + ).pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }))).status, + 403, + ); + gates.push("expired quarantine, invalid callback page, origin and callback path denial"); + yield* query( + `CREATE FUNCTION auth.reject_recovery_enqueue() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic enqueue failure'; END $$; CREATE TRIGGER reject_recovery_enqueue BEFORE INSERT ON auth.password_reset_email_outbox FOR EACH ROW EXECUTE FUNCTION auth.reject_recovery_enqueue()`, + ); + assert.equal((yield* requestReset).status, 503); + yield* query( + `DROP TRIGGER reject_recovery_enqueue ON auth.password_reset_email_outbox; DROP FUNCTION auth.reject_recovery_enqueue()`, + ); + gates.push("enqueue failure replaces swallowed engine success with503"); + yield* requestReset; + yield* query( + `UPDATE auth.password_reset_email_outbox SET status='Processing',claim_id=gen_random_uuid(),claimed_at=date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')-INTERVAL '2 minutes' WHERE status='Pending'`, + ); + // An expired claim may already have sent its mail, so the drain quarantines it instead of resending. + assert.equal(yield* drain, "Empty"); + assert.equal( + (yield* query( `SELECT count(*)::int AS n FROM auth.password_reset_email_outbox WHERE status='Quarantined' AND last_failure_code='stale-claim'`, - ) - ).rows[0].n, - 1, - ); - await requestReset(); - const simultaneous = await Promise.all([drain(), drain()]); - assert.deepEqual(simultaneous.sort(), ["Delivered", "Empty"]); - gates.push("stale claim quarantined and concurrent SKIP LOCKED claims"); + )).rows[0].n, + 1, + ); + yield* requestReset; + const simultaneous = yield* Effect.all([drain, drain], { concurrency: "unbounded" }); + assert.deepEqual(simultaneous.toSorted(), ["Delivered", "Empty"]); + gates.push("stale claim quarantined and concurrent SKIP LOCKED claims"); - const nextToken = async () => { - assert.equal((await requestReset()).status, 200); + const nextToken = Effect.gen(function* () { + assert.equal((yield* requestReset).status, 200); - const row = ( - await pool.query( + const row = (yield* query( `SELECT identifier FROM auth.verification WHERE identifier LIKE 'reset-password:%' ORDER BY "createdAt" DESC LIMIT 1`, - ) - ).rows[0]; - - const value = row.identifier.slice("reset-password:".length); - secrets.push(value); + )).rows[0]; - return value; - }; + const value: string = row.identifier.slice("reset-password:".length); + secrets.push(value); - const concurrentToken = await nextToken(); + return value; + }); - const resetStatuses = await Promise.all([ - post("reset-password", { token: concurrentToken, newPassword: "Concurrent-password-A-12345" }), - post("reset-password", { token: concurrentToken, newPassword: "Concurrent-password-B-12345" }), - ]); + const concurrentToken = yield* nextToken; + + const resetStatuses = yield* Effect.all( + [ + post("reset-password", { + token: concurrentToken, + newPassword: "Concurrent-password-A-12345", + }), + post("reset-password", { + token: concurrentToken, + newPassword: "Concurrent-password-B-12345", + }), + ], + { concurrency: "unbounded" }, + ); - assert.deepEqual(resetStatuses.map((r) => r.status).sort((a, b) => a - b), [200, 400]); + assert.deepEqual( + resetStatuses.map((r) => r.status).toSorted((a, b) => a - b), + [200, 400], + ); - const winner = - resetStatuses[0]!.status === 200 - ? "Concurrent-password-A-12345" - : "Concurrent-password-B-12345"; + const winner = + resetStatuses[0].status === 200 + ? "Concurrent-password-A-12345" + : "Concurrent-password-B-12345"; - await login(winner); - gates.push("one consumed token, concurrent different-password reset exactly one success"); - const auditFailureToken = await nextToken(); - await pool.query( - `CREATE FUNCTION auth.reject_recovery_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.event_kind='password-reset-success' THEN RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER reject_recovery_audit BEFORE INSERT ON auth.identity_security_audit FOR EACH ROW EXECUTE FUNCTION auth.reject_recovery_audit()`, - ); - assert.equal( - (await post("reset-password", { token: auditFailureToken, newPassword })).status, - 503, - ); - await pool.query( - `DROP TRIGGER reject_recovery_audit ON auth.identity_security_audit; DROP FUNCTION auth.reject_recovery_audit()`, - ); - assert.equal( - ( - await pool.query( + yield* login(winner); + gates.push("one consumed token, concurrent different-password reset exactly one success"); + const auditFailureToken = yield* nextToken; + yield* query( + `CREATE FUNCTION auth.reject_recovery_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.event_kind='password-reset-success' THEN RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER reject_recovery_audit BEFORE INSERT ON auth.identity_security_audit FOR EACH ROW EXECUTE FUNCTION auth.reject_recovery_audit()`, + ); + assert.equal( + (yield* post("reset-password", { token: auditFailureToken, newPassword })).status, + 503, + ); + yield* query( + `DROP TRIGGER reject_recovery_audit ON auth.identity_security_audit; DROP FUNCTION auth.reject_recovery_audit()`, + ); + assert.equal( + (yield* query( `SELECT count(*)::int n FROM auth.session WHERE "userId"='journey-rec-leader-0049'`, - ) - ).rows[0].n, - 0, - ); - const partialCookie = await login(newPassword); - gates.push( - "audit failure after password update and session deletion returns503; credential changed", - ); - const deletionFailureToken = await nextToken(); - await pool.query( - `CREATE FUNCTION auth.reject_recovery_session_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic session deletion failure'; END $$; CREATE TRIGGER reject_recovery_session_delete BEFORE DELETE ON auth.session FOR EACH ROW EXECUTE FUNCTION auth.reject_recovery_session_delete()`, - ); - assert.ok( - (await post("reset-password", { token: deletionFailureToken, newPassword: oldPassword })) - .status >= 500, - ); - await pool.query( - `DROP TRIGGER reject_recovery_session_delete ON auth.session; DROP FUNCTION auth.reject_recovery_session_delete()`, - ); + )).rows[0].n, + 0, + ); + const partialCookie = yield* login(newPassword); + gates.push( + "audit failure after password update and session deletion returns503; credential changed", + ); + const deletionFailureToken = yield* nextToken; + yield* query( + `CREATE FUNCTION auth.reject_recovery_session_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic session deletion failure'; END $$; CREATE TRIGGER reject_recovery_session_delete BEFORE DELETE ON auth.session FOR EACH ROW EXECUTE FUNCTION auth.reject_recovery_session_delete()`, + ); + assert.ok( + (yield* post("reset-password", { token: deletionFailureToken, newPassword: oldPassword })) + .status >= 500, + ); + yield* query( + `DROP TRIGGER reject_recovery_session_delete ON auth.session; DROP FUNCTION auth.reject_recovery_session_delete()`, + ); - const stillLive = await fetch(`${canonicalOrigin}/api/session`, { - headers: { cookie: partialCookie, origin: dashboardOrigin }, - }); + assert.equal(yield* readSessionStatus(partialCookie), 200); + yield* login(oldPassword); + gates.push("session deletion failure returns5xx; password changed with old session still live"); + const expiredToken = yield* nextToken; + yield* query( + `UPDATE auth.verification SET "expiresAt"=date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')-INTERVAL '1 second' WHERE identifier=$1`, + [`reset-password:${expiredToken}`], + ); + yield* browserStep(() => + page.goto( + `${canonicalOrigin}/api/auth/reset-password/${expiredToken}?callbackURL=${encodeURIComponent(`${dashboardOrigin}/tilbakestill-passord`)}`, + ), + ); + yield* browserStep(() => page.getByText("Lenken er ugyldig eller utløpt.").waitFor()); + yield* browserStep(() => page.setViewportSize({ width: 390, height: 844 })); + yield* browserStep(() => + page.screenshot({ path: path.join(artifacts, "invalid-link-mobile.png") }), + ); + const axe = yield* browserStep(() => auditSettledPage(page)); + assert.deepEqual(axe, []); + gates.push("actual expired token callback, mobile invalid-link view, Axe"); + + for (const [id, identifier, value] of [ + [ + "invalid-verification-proof", + "not-a-reset-identifier", + "journey-rec-leader-0049", + "verification-invalid", + ], + [ + "mismatched-verification-proof", + "reset-password:synthetic-mismatch", + "wrong-person", + "authority-mismatch", + ], + ] as const) { + yield* query( + `INSERT INTO auth.verification(id,identifier,value,"expiresAt","createdAt","updatedAt") VALUES($1,$2,$3,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')+INTERVAL '1 hour',date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'),date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'))`, + [id, identifier, value], + ); + yield* query( + `INSERT INTO auth.password_reset_email_outbox(effect_id,verification_id,subject_person_id,status) VALUES($1,$2,'journey-rec-leader-0049','Pending')`, + [`password-reset:${id}`, id], + ); + } - assert.equal(stillLive.status, 200); - await login(oldPassword); - gates.push("session deletion failure returns5xx; password changed with old session still live"); - const expiredToken = await nextToken(); - await pool.query( - `UPDATE auth.verification SET "expiresAt"=date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')-INTERVAL '1 second' WHERE identifier=$1`, - [`reset-password:${expiredToken}`], - ); - await page.goto( - `${canonicalOrigin}/api/auth/reset-password/${expiredToken}?callbackURL=${encodeURIComponent(`${dashboardOrigin}/tilbakestill-passord`)}`, - ); - await page.getByText("Lenken er ugyldig eller utløpt.").waitFor(); - await page.setViewportSize({ width: 390, height: 844 }); - await page.screenshot({ path: join(artifacts, "invalid-link-mobile.png") }); - const AxeBuilder = requireDashboard("@axe-core/playwright").default; - const axe = await new AxeBuilder({ page }).analyze(); - assert.deepEqual(axe.violations, []); - gates.push("actual expired token callback, mobile invalid-link view, Axe"); - - for (const [id, identifier, value] of [ - [ - "invalid-verification-proof", - "not-a-reset-identifier", - "journey-rec-leader-0049", - "verification-invalid", - ], - [ - "mismatched-verification-proof", - "reset-password:synthetic-mismatch", - "wrong-person", - "authority-mismatch", - ], - ]) { - await pool.query( - `INSERT INTO auth.verification(id,identifier,value,"expiresAt","createdAt","updatedAt") VALUES($1,$2,$3,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')+INTERVAL '1 hour',date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'),date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'))`, - [id, identifier, value], - ); - await pool.query( - `INSERT INTO auth.password_reset_email_outbox(effect_id,verification_id,subject_person_id,status) VALUES($1,$2,'journey-rec-leader-0049','Pending')`, - [`password-reset:${id}`, id], - ); - } - - // Drain earlier reset-consumed/expired effects first, then malformed fixtures; none can be mailed. - for (let n = 0; n < 16; n++) { - const result = await drain(); - - if (result === "Empty") break; - assert.equal(result, "Quarantined"); - } - - const invalid = ( - await pool.query( + // Drain earlier reset-consumed/expired effects first, then malformed fixtures; none can be mailed. + for (let n = 0; n < 16; n++) { + const result = yield* drain; + + if (result === "Empty") break; + assert.equal(result, "Quarantined"); + } + + const invalid = (yield* query( `SELECT last_failure_code FROM auth.password_reset_email_outbox WHERE verification_id IN ('invalid-verification-proof','mismatched-verification-proof') ORDER BY verification_id`, - ) - ).rows; + )).rows; - assert.deepEqual( - invalid.map((r: any) => r.last_failure_code), - ["verification-invalid", "authority-mismatch"], - ); - assert.ok(submissions.length >= 2); - assert.ok(submissions.every((item) => item.tokenPresent && item.queryAbsent)); - gates.push( - "invalid and authority-mismatched verification quarantined; browser token only in request body", - ); + assert.deepEqual( + invalid.map((r: any) => r.last_failure_code), + ["verification-invalid", "authority-mismatch"], + ); + assert.ok(submissions.length >= 2); + assert.ok(submissions.every((item) => item.tokenPresent && item.queryAbsent)); + gates.push( + "invalid and authority-mismatched verification quarantined; browser token only in request body", + ); - const audits = ( - await pool.query( + const audits = (yield* query( "SELECT event_kind,subject_person_id,details,request_correlation FROM auth.identity_security_audit ORDER BY occurred_at", - ) - ).rows; - - const outbox = (await pool.query("SELECT * FROM auth.password_reset_email_outbox")).rows; - const safe = JSON.stringify({ audits, outbox, logs }); - - for (const secret of secrets) assert.ok(!safe.includes(secret), "sensitive value leaked"); - assert.ok(audits.some((r: any) => r.event_kind === "password-reset-success")); - assert.deepEqual(errors, []); - await page.goto(`${dashboardOrigin}/tilbakestill-passord`); - await page.screenshot({ path: join(artifacts, "invalid-link.png") }); - await writeFile( - join(artifacts, "evidence.json"), - JSON.stringify( - { + )).rows; + + const outbox = (yield* query("SELECT * FROM auth.password_reset_email_outbox")).rows; + const safe = yield* jsonText({ audits, outbox, logs }); + + for (const secret of secrets) assert.ok(!safe.includes(secret), "sensitive value leaked"); + assert.ok(audits.some((r: any) => r.event_kind === "password-reset-success")); + assert.deepEqual(errors, []); + yield* browserStep(() => page.goto(`${dashboardOrigin}/tilbakestill-passord`)); + yield* browserStep(() => page.screenshot({ path: path.join(artifacts, "invalid-link.png") })); + yield* fs.writeFileString( + path.join(artifacts, "evidence.json"), + yield* indentedJsonText({ revision, gates, audits: audits.map((r: any) => ({ @@ -559,54 +639,55 @@ try { outbox: outbox.map((r: any) => ({ status: r.status, attempts: r.attempts })), browserErrors: errors, transport: "synthetic authenticated loopback HTTP mailbox; not production email", - }, - null, - 2, - ), - ); - await Effect.runPromise( - Console.log(JSON.stringify({ result: "Passed", artifacts, revision, gates })), - ); -} catch (error) { - let text = page - ? await page - .locator("body") - .innerText() - .catch(() => "") - : ""; - - for (const secret of secrets) text = text.replaceAll(secret, "[redacted]"); - await Effect.runPromise( - Console.error( - JSON.stringify({ - failure: error instanceof Error ? error.name : "Failure", - assertion: - error !== null && - (error === null || Predicate.isObjectOrArray(error)) && - "actual" in error && - "expected" in error - ? { - actual: Predicate.isNumber(error.actual) - ? error.actual - : Object.prototype.toString.call(error.actual), - expected: Predicate.isNumber(error.expected) - ? error.expected - : Object.prototype.toString.call(error.expected), - } - : null, - gates, - submissions, - pageText: text.slice(0, 1800), - artifacts, }), - ), - ); - process.exitCode = 1; -} finally { - await browser?.close(); - await mailbox?.stop(true); - await pool?.end(); - - for (const child of children.reverse()) await stopOwnedProcess(child); - await postgres?.stop(); -} + ); + yield* Console.log(yield* jsonText({ result: "Passed", artifacts, revision, gates })); + }); + + /** The redacted report of a failure, written before the browser and processes are released. */ + const report = (cause: Cause.Cause) => + Effect.gen(function* () { + const error = Cause.squash(cause); + + let text = + page === undefined + ? "" + : yield* Effect.promise( + (): Promise => + page + .locator("body") + .innerText() + .catch(() => ""), + ); + + for (const secret of secrets) text = text.replaceAll(secret, "[redacted]"); + yield* Console.error( + yield* jsonText({ + failure: error instanceof Error ? error.name : "Failure", + assertion: + Predicate.isObjectOrArray(error) && "actual" in error && "expected" in error + ? { + actual: Predicate.isNumber(error.actual) + ? error.actual + : Object.prototype.toString.call(error.actual), + expected: Predicate.isNumber(error.expected) + ? error.expected + : Object.prototype.toString.call(error.expected), + } + : null, + gates, + submissions, + pageText: text.slice(0, 1800), + artifacts, + }), + ); + }); + + yield* checks.pipe(Effect.tapCause(report), Effect.scoped); +}); + +// The failure report above is the redacted evidence; the runtime's own report could print secrets. +BunRuntime.runMain( + journey.pipe(Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer))), + { disableErrorReporting: true }, +); diff --git a/tools/acceptance/postgres-observation.ts b/tools/acceptance/postgres-observation.ts index 6f53560dd..9f8141859 100644 --- a/tools/acceptance/postgres-observation.ts +++ b/tools/acceptance/postgres-observation.ts @@ -8,7 +8,10 @@ export const PostgresObservation = Schema.Record( export type PostgresObservation = typeof PostgresObservation.Type; -export const decodePostgresObservations = Schema.decodeUnknownSync( +/** Decodes the rows of a query, and throws on a row that is no observation. */ +export const decodePostgresObservations: ( + rows: ReadonlyArray, +) => ReadonlyArray = Schema.decodeUnknownSync( Schema.Array(PostgresObservation), ); diff --git a/tools/acceptance/recommendation-check.ts b/tools/acceptance/recommendation-check.ts index cae3159d7..38e6ee316 100644 --- a/tools/acceptance/recommendation-check.ts +++ b/tools/acceptance/recommendation-check.ts @@ -1,17 +1,34 @@ /**0101: previous-schema history -> actual migration -> production browser/API/PostgreSQL. */ import assert from "node:assert/strict"; -import { execFileSync, spawn } from "node:child_process"; -import { - createServer as createHttpServer, - type IncomingMessage, - type Server, - type ServerResponse, -} from "node:http"; -import { mkdtemp, writeFile, readdir, readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { createHash, randomBytes } from "node:crypto"; import { createRequire } from "node:module"; +import process from "node:process"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import { + Array as Arr, + type Cause, + Config, + Console, + Data, + DateTime, + type Duration, + Effect, + Exit, + Fiber, + FiberSet, + FileSystem, + Layer, + Option, + Path, + Predicate, + Queue, + Redacted, + Runtime, + Schema, + Stream, +} from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { observeInterviewReport, seedInterviewReportCoordinator, @@ -22,14 +39,13 @@ import { runApplicantProgress0107, seedApplicantProgress0107, } from "./applicant-progress-0107.ts"; -import { stopOwnedProcess } from "./owned-process.js"; import { - type DisposablePostgres, loopbackPortFree, reserveLoopbackPorts, startDisposablePostgres, } from "../postgres/index.ts"; import { + type RunCommand, runReturningAssistantBrowserJourney, runReturningAssistantLoginProbe, seedReturningAssistant, @@ -48,23 +64,31 @@ import { } from "./interview-correction-boundaries.ts"; import { assertInterviewCorrectionIntegrity } from "./interview-correction-integrity.ts"; import { runCoInterviewerCorrectionJourney } from "./co-interviewer-correction-0106.ts"; +import { + answersOk, + commandOutput, + indentedJsonText, + type OwnedChild, + ProbeFailure, + startOwnedProcess, +} from "./acceptance-process.ts"; +import { jsonText, step, thrownBy } from "./journey-step.ts"; import { DatabaseLive } from "../../packages/database/src/layers.js"; import { TestPlatform } from "../../packages/database/src/test-support/platform.js"; import { AdmissionsLive } from "../../packages/database/src/admissions/index.js"; import { OrganizationLive } from "../../packages/database/src/organization/index.js"; import { ProfileLive } from "../../packages/database/src/profile/index.js"; -import { - deliverNextRecruitmentInvitation, - type RecruitmentInvitationDeliveryResult, -} from "../../packages/database/src/recruitment/index.js"; +import { deliverNextRecruitmentInvitation } from "../../packages/database/src/recruitment/index.js"; import { RecruitmentNotificationDeliveryError, RecruitmentNotificationEvidenceSchema, } from "../../packages/domain/src/recruitment/index.js"; import { deliverJson } from "../../apps/backend/src/delivery/http.js"; import { NotificationGateway } from "../../packages/domain/src/notification/service.js"; -import { Array as Arr, Predicate, Schema } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; +import { PublicApplicationIdSchema } from "../../packages/domain/src/application/schema.js"; +import { nativeScriptClient } from "../../packages/rpc/src/script-client.js"; +import { replacedFetch } from "../../apps/dashboard/e2e/native-rpc-ledger.ts"; +import { auditSettledPage } from "../../apps/dashboard/e2e/settled-axe.ts"; const root = new URL("../../", import.meta.url).pathname; @@ -74,8 +98,6 @@ const uiRequire = createRequire(new URL("../../apps/dashboard/package.json", imp const { Pool } = dbRequire("pg"); -const { Effect, Layer, Redacted } = dbRequire("effect"); - const fixtureKeys = { invalid0: "invalid-recommendation-0101-0", invalid1: "invalid-recommendation-0101-1", @@ -90,155 +112,23 @@ const fixtureKeys = { linkRace: "identity-race-recommendation-0101", } as const; -class ReturningLoginProbeComplete extends Error { - constructor(readonly result: A) { - super("returning login probe complete"); - } -} - -class ReturningTargetedComplete extends Error { - constructor(readonly result: A) { - super("returning targeted journey complete"); - } -} - -class CorrectionTargetedComplete extends Error { - constructor(readonly result: A) { - super("correction targeted journey complete"); - } -} - -class CoInterviewerTargetedComplete extends Error { - constructor(readonly result: A) { - super("co-interviewer targeted journey complete"); - } -} - -class ApplicantProgressTargetedComplete extends Error { - constructor(readonly result: A) { - super("applicant progress targeted journey complete"); - } +/** + * How a run of the probe ended: the full journey passed, or a targeted mode finished with the + * report that its result line carries after the revision, artifacts, and gates. + */ +interface Outcome { + readonly result: string; + readonly report?: { readonly probe: unknown } | { readonly journey: unknown }; } -const coInterviewerMode = process.argv.includes("--co-interviewer-mode"); - -const applicantProgressMode = process.argv.includes("--applicant-progress-mode"); - -if (process.argv.includes("--report")) validateInterviewReportFixture(); - -if (process.argv.includes("--validate-fixture")) { - // oxlint-effect-plugin allow(no-ambient-console): dev only: EX-0009: local fixture validation result. - console.log("All recommendation fixture idempotency keys satisfy the canonical schema"); - process.exit(0); +/** The redacted failure of the probe, reported as its failure evidence before the exit. */ +class RecommendationFailed extends Data.TaggedError("RecommendationFailed")<{ + readonly message: string; +}> { + /** The failure evidence reports the failure already, so the runtime logs nothing more. */ + readonly [Runtime.errorReported] = false; } -const { chromium } = uiRequire("@playwright/test"); - -const AxeBuilder = uiRequire("@axe-core/playwright").default; - -const run = (cmd: string, args: string[], env = process.env, cwd = root): string => { - try { - return execFileSync(cmd, args, { - cwd, - env, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: 180000, - }); - } catch (cause) { - throw new Error( - `runtime command failed: ${cmd} ${args.join(" ")}: ${cause instanceof Error ? cause.message : String(cause)}`, - { cause }, - ); - } -}; - -assert.equal(run("git", ["status", "--porcelain", "--", ".", ":(exclude).serena"]).trim(), ""); - -const revision = run("git", ["rev-parse", "HEAD"]).trim(); - -const artifacts = await mkdtemp(join(tmpdir(), "vektor-recommendation-0101-")); - -const logs: string[] = []; - -const children: ReturnType[] = []; - -const start = (cmd: string, args: string[], env = process.env, cwd = root) => { - const c = spawn(cmd, args, { env, cwd, stdio: ["ignore", "pipe", "pipe"] }); - children.push(c); - c.stdout?.on("data", (v) => logs.push(String(v))); - c.stderr?.on("data", (v) => logs.push(String(v))); - - return c; -}; - -const ready = async (test: () => Promise) => { - for (let i = 0; i < 150; i++) { - try { - if (await test()) return; - } catch {} - - await new Promise((r) => setTimeout(r, 100)); - } - - throw new Error("Readiness failed"); -}; - -let postgres: DisposablePostgres | undefined; - -let pool: any, browser: any, page: any, heldIdentityClient: any, backend: any; - -let correctionPre0039Fixture: InterviewCorrectionPre0039Fixture | undefined; - -let acceptedCorrectionReplay: InterviewCorrectionReplayRequest | undefined; - -let coInterviewerFixture: CoInterviewerCorrection0106Fixture | undefined; - -let expectedHistoricalReportRecommendation: "Ja" | "Kanskje" | "Nei" | undefined; - -let effectServer: Server | undefined; - -const effectCalls: EffectReceiverCall[] = []; - -const effectAttempts = new Map(); - -const invitationCapabilities = new Map(); - -let releaseEffectDelivery = false; - -const gates: string[] = []; - -const recordGate = (...observations: string[]) => { - gates.push(...observations); - // oxlint-effect-plugin allow(no-ambient-console): dev only: EX-0009: bounded synthetic rehearsal milestones. - console.log(JSON.stringify({ observed: observations })); -}; - -const secrets: string[] = []; - -const accessibility: Array<{ state: string; violations: ReadonlyArray }> = []; - -const auditPage = async (page: any, state: string) => { - const violations = (await new AxeBuilder({ page }).analyze()).violations.map( - (violation: any) => ({ - id: violation.id, - impact: violation.impact, - nodes: violation.nodes.map((node: any) => ({ - target: node.target, - checks: node.any.map((check: any) => ({ id: check.id, data: check.data })), - })), - }), - ); - - accessibility.push({ state, violations }); - let evidence = JSON.stringify(accessibility, null, 2); - - for (const secret of secrets) evidence = evidence.replaceAll(secret, "[redacted]"); - await writeFile(join(artifacts, "accessibility.json"), evidence); - - return violations; -}; - const assertNoRecommendation = (value: Schema.Json): void => { if (Arr.isArray(value)) for (const item of value) assertNoRecommendation(item); else if ( @@ -262,14 +152,6 @@ type EffectReceiverCall = { readonly status: number; }; -const readRequestText = async (request: IncomingMessage): Promise => { - const chunks: string[] = []; - - for await (const chunk of request) chunks.push(String(chunk)); - - return chunks.join(""); -}; - const stringField = (value: Schema.Json, key: string): string => { if (value === null || !(value === null || Predicate.isObjectOrArray(value)) || !(key in value)) return ""; @@ -278,84 +160,28 @@ const stringField = (value: Schema.Json, key: string): string => { return Predicate.isString(field) ? field : ""; }; -const startEffectReceiver = async ( - token: string, - portNumber: number, - captureInvitationCapability: (interviewId: string, capability: string) => void, -): Promise => { - const server = createHttpServer(async (request: IncomingMessage, response: ServerResponse) => { - if (request.method !== "POST" || request.url !== "/effects") { - response.statusCode = 404; - response.end(); - - return; - } - - if (request.headers.authorization !== `Bearer ${token}`) { - response.statusCode = 401; - response.end(); - - return; - } - - let body: Schema.Json; - - try { - body = Schema.decodeUnknownSync(Schema.Json)(JSON.parse(await readRequestText(request))); - } catch { - response.statusCode = 400; - response.end(); - - return; - } - - if (stringField(body, "_tag") === "SendInterviewInvitation") { - const interviewId = stringField(body, "interviewId"); - const capability = stringField(body, "responseCapability"); - - if (interviewId !== "" && capability !== "") - captureInvitationCapability(interviewId, capability); - } +const decodeJsonBody = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json)); - const effectId = request.headers["idempotency-key"]; - const normalizedEffectId = Predicate.isString(effectId) ? effectId : ""; - const attempt = (effectAttempts.get(normalizedEffectId) ?? 0) + 1; - effectAttempts.set(normalizedEffectId, attempt); - const kind = stringField(body, "_tag"); - const status = releaseEffectDelivery || kind === "SendInterviewInvitation" ? 204 : 503; - effectCalls.push({ - effectId: normalizedEffectId, - commandId: stringField(body, "commandId"), - origin: stringField(body, "origin"), - kind: stringField(body, "_tag"), - attempt, - status, - }); - response.statusCode = status; - response.end(); - }); +/** The value of a JSON text, decoded through Schema. */ +const decodeJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); - const listening = Promise.withResolvers(); - server.once("error", listening.reject); - server.listen(portNumber, "127.0.0.1", listening.resolve); - await listening.promise; +/** The JSON value of a JSON text, decoded through Schema. */ +const decodeJsonValue = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json)); - return server; -}; +/** The current instant as an ISO 8601 string, as the delivery evidence records it. */ +const isoNow = DateTime.now.pipe(Effect.map(DateTime.formatIso)); -const deliverRecruitmentInvitationOnce = async ({ +const deliverRecruitmentInvitationOnce = ({ pgUrl, endpoint, token, claimId, - now, }: { readonly pgUrl: string; readonly endpoint: URL; readonly token: string; readonly claimId: string; - readonly now: () => string; -}): Promise => { +}) => { const databaseLayer = DatabaseLive({ url: Redacted.make(pgUrl), applicationName: "native-returning-invitation-delivery", @@ -382,2484 +208,2833 @@ const deliverRecruitmentInvitationOnce = async ({ deliveryTimeoutMilliseconds: 2_000, }; + /** Delivers one request to the loopback receiver and answers its delivery evidence. */ + const deliverLoopback = < + Request extends Schema.Json & { + readonly effectId: (typeof RecruitmentNotificationEvidenceSchema.Type)["effectId"]; + }, + >( + request: Request, + message: string, + ) => + deliverJson(request, transport, { + "idempotency-key": request.effectId, + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.andThen(isoNow), + Effect.map((deliveredAt) => + RecruitmentNotificationEvidenceSchema.make({ + effectId: request.effectId, + deliveredAt, + providerReference: `loopback:${request.effectId}`, + }), + ), + Effect.mapError(() => + RecruitmentNotificationDeliveryError.make({ effectId: request.effectId, message }), + ), + ); + const gateway = Layer.succeed( NotificationGateway, NotificationGateway.of({ deliverInterviewCompletionReceipt: (request) => - deliverJson(request, transport, { - "idempotency-key": request.effectId, - }).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.map(() => - RecruitmentNotificationEvidenceSchema.make({ - effectId: request.effectId, - deliveredAt: now(), - providerReference: `loopback:${request.effectId}`, - }), - ), - Effect.mapError( - () => - new RecruitmentNotificationDeliveryError({ - effectId: request.effectId, - message: "loopback interview completion receipt delivery unavailable", - }), - ), - ), + deliverLoopback(request, "loopback interview completion receipt delivery unavailable"), deliverInterviewInvitation: (request) => - deliverJson(request, transport, { - "idempotency-key": request.effectId, - }).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.map(() => - RecruitmentNotificationEvidenceSchema.make({ - effectId: request.effectId, - deliveredAt: now(), - providerReference: `loopback:${request.effectId}`, - }), - ), - Effect.mapError( - () => - new RecruitmentNotificationDeliveryError({ - effectId: request.effectId, - message: "loopback recruitment invitation delivery unavailable", - }), - ), - ), + deliverLoopback(request, "loopback recruitment invitation delivery unavailable"), deliverInterviewInvitationResponse: (request) => - deliverJson(request, transport, { - "idempotency-key": request.effectId, - }).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.map(() => - RecruitmentNotificationEvidenceSchema.make({ - effectId: request.effectId, - deliveredAt: now(), - providerReference: `loopback:${request.effectId}`, - }), - ), - Effect.mapError( - () => - new RecruitmentNotificationDeliveryError({ - effectId: request.effectId, - message: "loopback recruitment invitation response delivery unavailable", - }), - ), - ), + deliverLoopback(request, "loopback recruitment invitation response delivery unavailable"), }), ); - return Effect.runPromise( - Effect.scoped( - deliverNextRecruitmentInvitation(claimId, now()).pipe( - Effect.provide(gateway), - Effect.provide(authorityLayers), - ), + return Effect.scoped( + isoNow.pipe( + Effect.flatMap((now) => deliverNextRecruitmentInvitation(claimId, now)), + Effect.provide(Layer.merge(gateway, authorityLayers)), ), ); }; -try { - // The cluster reserves its own port through the same construct, so no port repeats. - const [apiPort, effectPort] = await reserveLoopbackPorts(2), - uiPort = 5174; - - assert.ok(await loopbackPortFree(uiPort), `loopback port ${uiPort} is in use`); - - postgres = await startDisposablePostgres(); - - const pg = postgres.url, - api = `http://127.0.0.1:${apiPort}`, - ui = `http://127.0.0.1:${uiPort}`; - - const effectMode = process.argv.includes("--returning-mode") ? "http" : "disabled"; - const effectToken = randomBytes(32).toString("hex"); - pool = new Pool({ connectionString: pg }); - - const env: NodeJS.ProcessEnv = { - ...process.env, - JOURNEY_SEED_PG_URL: pg, - BACKEND_PG_URL: pg, - BACKEND_HOST: "127.0.0.1", - BACKEND_PORT: String(apiPort), - BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), - NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify([ui]), - OAUTH_CANONICAL_ORIGIN: api, - OAUTH_DASHBOARD_ORIGIN: ui, - OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - PUBLIC_APPLICATION_EFFECT_MODE: effectMode, - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_MODE: "disabled", - - API_URL: api, - VITE_API_URL: api, - DASHBOARD_MOUNT: "/", - HOST: "127.0.0.1", - PORT: String(uiPort), - NODE_ENV: "production", - }; - - if (effectMode === "http") { - env.PUBLIC_APPLICATION_EFFECT_ENDPOINT = `http://127.0.0.1:${effectPort}/effects`; - env.PUBLIC_APPLICATION_EFFECT_TOKEN = effectToken; - env.PUBLIC_APPLICATION_EFFECT_POLL_MS = "1000"; - env.PUBLIC_APPLICATION_EFFECT_TIMEOUT_MS = "2000"; - } - - assert.ok(env.BETTER_AUTH_SECRET); - secrets.push(env.BETTER_AUTH_SECRET); - recordGate("disposable PostgreSQL is ready"); - run("bun", ["tools/verification/recommendation-preupgrade-fixture.ts"], { - ...env, - RECOMMENDATION_PREUPGRADE_THROUGH_0038: "1", - }); - - const historicalBefore = ( - await pool.query( - `SELECT to_jsonb(c)-'recommendation' value FROM public.recruitment_interview_conducts c WHERE interview_id='interview-recommendation-history'`, - ) - ).rows[0].value; - - recordGate("previous-schema history fixture migrated"); - correctionPre0039Fixture = await seedInterviewCorrectionPre0039Fixture({ pool }); - recordGate("seeded 0105 pre-0039 correction rows from existing native base"); - run( - "bun", - ["run", "identity:seed"], - { - ...env, - IDENTITY_SEED_PG_URL: pg, - IDENTITY_SEED_PERSONS: JSON.stringify([ - { - personId: "journey-conduct-leader-0063", - firstName: "Lina", - lastName: "Lagleder", - email: "lina.conduct@example.invalid", - password: "journey-conduct-secret-0123456789", - }, - ...(coInterviewerMode ? coInterviewerCorrection0106IdentitySeeds : []), - ...(applicantProgressMode ? [applicantProgressUnlinkedIdentity] : []), - ]), - }, - join(root, "packages/database"), - ); +const program = Effect.gen(function* () { + const coInterviewerMode = process.argv.includes("--co-interviewer-mode"); + const applicantProgressMode = process.argv.includes("--applicant-progress-mode"); - if (coInterviewerMode) { - coInterviewerFixture = await seedCoInterviewerCorrection0106Fixture({ pool }); - recordGate( - "seeded synthetic 0106 co-interviewer designation after the canonical migration chain", - ); - } + if (process.argv.includes("--report")) validateInterviewReportFixture(); - await seedReturningAssistant({ pool, run, env, root }); - // Also classifies team-native-conduct-0063 as the board (Styret) of an independent department, - // so promoting the conduct leader to its leader below grants department reach (O8-11). - await seedInterviewReportCoordinator({ pool, secrets }); + if (process.argv.includes("--validate-fixture")) { + yield* Console.log("All recommendation fixture idempotency keys satisfy the canonical schema"); - if (effectMode === "http") { - effectServer = await startEffectReceiver(effectToken, effectPort, (interviewId, capability) => { - invitationCapabilities.set(interviewId, capability); - secrets.push(capability); - }); - secrets.push(effectToken); + return; } - const effectSnapshot = async () => { - const tables = ( - await pool.query( - `SELECT schemaname,tablename FROM pg_tables WHERE schemaname IN ('public','auth') AND (tablename LIKE '%outbox%' OR tablename LIKE '%effect%') ORDER BY schemaname,tablename`, - ) - ).rows; - - return Promise.all( - tables.map(async ({ schemaname, tablename }: any) => ({ - table: `${schemaname}.${tablename}`, - rows: ( - await pool.query( - `SELECT to_jsonb(t) value FROM "${schemaname}"."${tablename}" t ORDER BY to_jsonb(t)::text`, - ) - ).rows, - })), - ); - }; - - const effectsBefore = await effectSnapshot(); - - const link = async (suffix: string, personId: string, sql = pool) => { - const invitation = `identity-recommendation-${suffix}`; - await sql.query( - `INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES($1,$2,$3,$4,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')+interval '24 hours','Claimed','journey-conduct-leader-0063',date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'))`, - [ - invitation, - `application-recommendation-${suffix}`, - `applicant-recommendation-${suffix}`, - createHash("sha256").update(invitation).digest("hex"), - ], - ); - await sql.query( - `INSERT INTO public.applicant_account_links VALUES($1,$2,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'),$3)`, - [`applicant-recommendation-${suffix}`, personId, invitation], - ); - }; - - await pool.query( - `INSERT INTO public.person_profiles(person_id,first_name,last_name,revision) VALUES('recommendation-other-0101','Other','Interviewer',0)`, - ); - await link("self", "journey-conduct-leader-0063"); - await link("maybe", "recommendation-other-0101"); + const { chromium } = uiRequire("@playwright/test"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; - if (applicantProgressMode) { - await seedApplicantProgress0107(pool); - recordGate("seeded synthetic current-semester applicant progress states"); - secrets.push( - applicantProgressUnlinkedIdentity.email, - applicantProgressUnlinkedIdentity.password, - ); - } + const run: RunCommand = (command, args, env = {}, cwd = root) => + commandOutput({ command, args, cwd, env, deadline: "180 seconds", stderr: "pipe" }); - const invitationCapability = randomBytes(32).toString("base64url"); - secrets.push(invitationCapability); - await pool.query( - `UPDATE public.recruitment_invitations SET capability_sha256=$1 WHERE invitation_id='invitation-recommendation-maybe'`, - [createHash("sha256").update(invitationCapability).digest("hex")], + assert.equal( + (yield* run("git", ["status", "--porcelain", "--", ".", ":(exclude).serena"])).trim(), + "", ); - backend = start("bun", ["apps/backend/src/main.ts"], env); - await ready(async () => (await fetch(`${api}/health`)).ok); + const revision = (yield* run("git", ["rev-parse", "HEAD"])).trim(); + const artifacts = yield* fs.makeTempDirectory({ prefix: "vektor-recommendation-0101-" }); + const logs: string[] = []; + + /** Starts an owned process whose output the probe keeps for its failure evidence. */ + const start = ( + command: string, + args: ReadonlyArray, + env: Readonly>, + cwd = root, + ) => startOwnedProcess({ command, args, cwd, env, output: (text) => logs.push(text) }); + + /** Polls the check every 100 ms, 150 times; a failed check counts as not ready. */ + const ready = (check: Effect.Effect) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 150; attempt++) { + if (yield* check.pipe(Effect.catchCause(() => Effect.succeed(false)))) return; + + yield* Effect.sleep("100 millis"); + } - const historicalAfter = ( - await pool.query( - `SELECT to_jsonb(c)-'recommendation' value,recommendation FROM public.recruitment_interview_conducts c WHERE interview_id='interview-recommendation-history'`, - ) - ).rows[0]; + return yield* new ProbeFailure({ message: "Readiness failed" }); + }); - assert.deepEqual(historicalAfter.value, historicalBefore); - assert.equal(historicalAfter.recommendation, null); - recordGate( - "immutable historical row survived actual0037 upgrade without invented recommendation", - ); - assert.ok(correctionPre0039Fixture); - await assertInterviewCorrectionPre0039Preserved(pool, correctionPre0039Fixture); - recordGate( - "0039/0040 upgrades preserved original interview/schedule/invitation/conduct/lifecycle rows", + let pool: any, browser: any, page: any, heldIdentityClient: any; + let backend: OwnedChild; + let correctionPre0039Fixture: InterviewCorrectionPre0039Fixture | undefined; + let acceptedCorrectionReplay: InterviewCorrectionReplayRequest | undefined; + let coInterviewerFixture: CoInterviewerCorrection0106Fixture | undefined; + let expectedHistoricalReportRecommendation: "Ja" | "Kanskje" | "Nei" | undefined; + const effectCalls: EffectReceiverCall[] = []; + const effectAttempts = new Map(); + const invitationCapabilities = new Map(); + let releaseEffectDelivery = false; + const gates: string[] = []; + const secrets: string[] = []; + const accessibility: Array<{ state: string; violations: ReadonlyArray }> = []; + + // The lines of standard output, in the order the journey and its callbacks report them. + const lines = yield* Queue.unbounded(); + + const printer = yield* Stream.fromQueue(lines).pipe( + Stream.runForEach((line) => Console.log(line)), + Effect.forkChild, ); - run("bun", ["run", "build"], env, join(root, "packages/sdk")); - run("bun", ["run", "build"], env, join(root, "apps/dashboard")); - start("bun", ["server.mjs"], env, join(root, "apps/dashboard")); - await ready(async () => (await fetch(`${ui}/login`)).ok); - const password = "journey-conduct-secret-0123456789", - email = "lina.conduct@example.invalid"; + const say = (line: string) => { + Queue.offerUnsafe(lines, line); + }; - secrets.push(password, email); + /** Ends standard output and waits until every reported line is written. */ + const flushOutput = Queue.end(lines).pipe(Effect.andThen(Fiber.join(printer))); - const credentialCheck = await fetch(`${api}/api/auth/sign-in/email`, { - method: "POST", - headers: { origin: ui, "content-type": "application/json" }, - body: JSON.stringify({ email, password }), - }); + const recordGate = (...observations: string[]) => { + gates.push(...observations); + say(JSON.stringify({ observed: observations })); + }; - if (!credentialCheck.ok) { - const failure = await credentialCheck.json(); - throw new Error(`Native sign-in failed: ${credentialCheck.status} ${JSON.stringify(failure)}`); - } + const auditPage = (auditedPage: any, state: string) => + Effect.gen(function* () { + const violations: ReadonlyArray = yield* step(() => auditSettledPage(auditedPage)); - await credentialCheck.body?.cancel(); - recordGate("seeded native credentials accepted by real identity engine"); - browser = await chromium.launch({ - headless: true, - executablePath: - process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ?? "/etc/profiles/per-user/nori/bin/chromium", - }); - const context = await browser.newContext(); - const errors: string[] = []; - context.on("page", (p: any) => p.on("pageerror", () => errors.push("pageerror"))); - page = await context.newPage(); - await page.goto(`${ui}/login`); - await page.getByLabel("E-post", { exact: true }).fill(email); - await page.getByLabel("Passord", { exact: true }).fill(password); - await page.getByRole("button", { name: "Logg inn", exact: true }).click(); - await page.waitForURL(/\/dashboard\/?$/); - await page.goto(`${ui}/dashboard/intervjuer`); - - if (process.argv.includes("--returning-login-probe")) { - const probe = await runReturningAssistantLoginProbe({ browser, pool, ui, artifacts }); - throw new ReturningLoginProbeComplete(probe); - } + accessibility.push({ state, violations }); + let evidence = yield* indentedJsonText(accessibility); - assert.equal(await page.getByRole("link", { name: "Søkerkontoer", exact: true }).count(), 0); - const cookies = await context.cookies(); - const cookie = cookies.map((c: any) => `${c.name}=${c.value}`).join("; "); - secrets.push(...cookies.map((c: any) => c.value)); + for (const secret of secrets) evidence = evidence.replaceAll(secret, "[redacted]"); + yield* fs.writeFileString(path.join(artifacts, "accessibility.json"), evidence); - if (applicantProgressMode) { - // Leadership of the department's board (classified by seedInterviewReportCoordinator). - await pool.query( - `UPDATE public.organization_memberships - SET is_team_leader=true, position_id='teamleader' - WHERE membership_id='membership-native-conduct-leader-0063'`, - ); - - const journey = await runApplicantProgress0107({ - pool, - page, - cookie, - api, - ui, - artifacts, - errors, - audit: (journeyPage) => auditPage(journeyPage, "applicant-progress"), + return violations; }); - recordGate("observed applicant-owned progress through the real browser/API/PostgreSQL path"); + /** Fails when a retained artifact other than a screenshot contains a credential. */ + const assertArtifactsRedacted = Effect.gen(function* () { + for (const name of yield* fs.readDirectory(artifacts)) { + const file = path.join(artifacts, name); - for (const entry of await readdir(artifacts, { withFileTypes: true })) { - if (entry.isFile() && !entry.name.endsWith(".png")) { - const text = await readFile(join(artifacts, entry.name), "utf8"); + if ((yield* fs.stat(file)).type === "File" && !name.endsWith(".png")) { + const text = yield* fs.readFileString(file); for (const secret of secrets) - assert.ok( - !text.includes(secret), - `retained artifact ${entry.name} contains a credential`, - ); + assert.ok(!text.includes(secret), `retained artifact ${name} contains a credential`); } } + }); - throw new ApplicantProgressTargetedComplete(journey); - } + /** The loopback receiver of the backend's effects, on its reserved port for the scope. */ + const startEffectReceiver = ( + token: string, + portNumber: number, + captureInvitationCapability: (interviewId: string, capability: string) => void, + ) => + Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + hostname: "127.0.0.1", + port: portNumber, + fetch(request) { + const url = new URL(request.url); + + if (request.method !== "POST" || `${url.pathname}${url.search}` !== "/effects") + return new Response(null, { status: 404 }); + + if (request.headers.get("authorization") !== `Bearer ${token}`) + return new Response(null, { status: 401 }); + + return request.text().then((text) => { + const decoded = decodeJsonBody(text); + + if (Option.isNone(decoded)) return new Response(null, { status: 400 }); + + const body = decoded.value; + + if (stringField(body, "_tag") === "SendInterviewInvitation") { + const interviewId = stringField(body, "interviewId"); + const capability = stringField(body, "responseCapability"); + + if (interviewId !== "" && capability !== "") + captureInvitationCapability(interviewId, capability); + } + + const effectId = request.headers.get("idempotency-key") ?? ""; + const attempt = (effectAttempts.get(effectId) ?? 0) + 1; + effectAttempts.set(effectId, attempt); + const kind = stringField(body, "_tag"); + + const status = + releaseEffectDelivery || kind === "SendInterviewInvitation" ? 204 : 503; + + effectCalls.push({ + effectId, + commandId: stringField(body, "commandId"), + origin: stringField(body, "origin"), + kind: stringField(body, "_tag"), + attempt, + status, + }); + + return new Response(null, { status }); + }); + }, + }), + ), + (server) => Effect.promise(() => server.stop(true)), + ); - if (coInterviewerMode) { - if (coInterviewerFixture === undefined) - throw new Error("co-interviewer targeted mode requires its synthetic designation fixture"); - - const journey = await runCoInterviewerCorrectionJourney({ - pool, - browser, - primaryCookie: cookie, - api, - ui, - artifacts, - revision, - fixture: coInterviewerFixture, - errors, - secrets, - effectsBefore, - effectSnapshot, - auditPage, - recordGate, - }); + // Runs journey programs for the callbacks of the promise-based helpers, in the probe's scope. + const runCallback = yield* FiberSet.makeRuntimePromise(); - for (const entry of await readdir(artifacts, { withFileTypes: true })) { - if (entry.isFile() && !entry.name.endsWith(".png")) { - const text = await readFile(join(artifacts, entry.name), "utf8"); + const recommendationJourney = Effect.gen(function* () { + // The cluster reserves its own port through the same construct, so no port repeats. + const [apiPort, effectPort] = yield* step(() => reserveLoopbackPorts(2)), + uiPort = 5174; - for (const secret of secrets) - assert.ok( - !text.includes(secret), - `retained artifact ${entry.name} contains a credential`, - ); - } - } + assert.ok(apiPort !== undefined && effectPort !== undefined); + assert.ok(yield* step(() => loopbackPortFree(uiPort)), `loopback port ${uiPort} is in use`); - throw new CoInterviewerTargetedComplete(journey); - } + const postgres = yield* Effect.acquireRelease( + step(() => startDisposablePostgres()), + (cluster) => step(() => cluster.stop()).pipe(Effect.orDie), + ); - if (process.argv.includes("--returning-mode")) { - let currentStage = "returning:startup"; - const returningStages: string[] = []; + const pg = postgres.url, + api = `http://127.0.0.1:${apiPort}`, + ui = `http://127.0.0.1:${uiPort}`; - const stage = (name: string) => { - currentStage = name; - returningStages.push(name); - // oxlint-effect-plugin allow(no-ambient-console): dev only: EX-0009: emit bounded returning-stage evidence. - console.log(JSON.stringify({ returningStage: name })); - }; + const effectMode = process.argv.includes("--returning-mode") ? "http" : "disabled"; + const effectToken = randomBytes(32).toString("hex"); - const bounded = async ( - label: string, - operation: Promise, - timeoutMs = 90_000, - ): Promise => { - let timer: NodeJS.Timeout | undefined; - - try { - return await Promise.race([ - operation, - new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error(`${label} timed out at ${currentStage}`)), - timeoutMs, - ); - }), - ]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } - }; + const journeyPool = yield* Effect.acquireRelease( + Effect.sync(() => new Pool({ connectionString: pg })), + (owned) => step(() => owned.end()).pipe(Effect.orDie), + ); - const returningResult = await bounded( - "returning browser journey", - runReturningAssistantBrowserJourney({ - browser, - page, - pool, - api, - ui, - artifacts, - auditPage, - errors, - stage, - coordinatorEmail: "coordinator.report@example.invalid", - coordinatorPassword: password, - readInvitationCapability: (interviewId) => invitationCapabilities.get(interviewId), - deliverRecruitmentInvitation: async (claimId) => { - const result = await deliverRecruitmentInvitationOnce({ - pgUrl: pg, - endpoint: new URL(`${api.replace(/:\d+$/u, `:${effectPort}`)}/effects`), - token: effectToken, - claimId, - now: () => new Date().toISOString(), - }); + pool = journeyPool; - assert.equal(result._tag, "Delivered"); + // A connection that holds an applicant lock rolls back and returns to the pool before it ends. + yield* Effect.addFinalizer(() => + heldIdentityClient === undefined + ? Effect.void + : step(() => heldIdentityClient.query("ROLLBACK")).pipe( + Effect.andThen(Effect.sync(() => heldIdentityClient.release())), + Effect.orDie, + ), + ); - const outbox = await pool.query( - "SELECT status,attempts FROM public.recruitment_invitation_outbox WHERE effect_id=$1", - [result.claim.effectId], - ); + // Values over the inherited environment, which every command and process of the probe extends. + const baseEnv = { + JOURNEY_SEED_PG_URL: pg, + BACKEND_PG_URL: pg, + BACKEND_HOST: "127.0.0.1", + BACKEND_PORT: String(apiPort), + BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), + NATIVE_IDENTITY_DEPLOYMENT: "local", + NATIVE_IDENTITY_TRUSTED_ORIGINS: yield* jsonText([ui]), + OAUTH_CANONICAL_ORIGIN: api, + OAUTH_DASHBOARD_ORIGIN: ui, + OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", + PUBLIC_APPLICATION_EFFECT_MODE: effectMode, + PASSWORD_RESET_DELIVERY_MODE: "disabled", + RECEIPT_DELIVERY_MODE: "disabled", + + API_URL: api, + VITE_API_URL: api, + DASHBOARD_MOUNT: "/", + HOST: "127.0.0.1", + PORT: String(uiPort), + NODE_ENV: "production", + }; - assert.deepEqual(outbox.rows, [{ status: "Delivered", attempts: 1 }]); + const effectEnv = { + PUBLIC_APPLICATION_EFFECT_ENDPOINT: `http://127.0.0.1:${effectPort}/effects`, + PUBLIC_APPLICATION_EFFECT_TOKEN: effectToken, + PUBLIC_APPLICATION_EFFECT_POLL_MS: "1000", + PUBLIC_APPLICATION_EFFECT_TIMEOUT_MS: "2000", + }; - return result; - }, - }), - ); + const env = effectMode === "http" ? { ...baseEnv, ...effectEnv } : baseEnv; - if (effectMode === "http") { - const recruitmentInvitationCalls = effectCalls.filter( - (call) => call.kind === "SendInterviewInvitation", - ); + assert.ok(env.BETTER_AUTH_SECRET); + secrets.push(env.BETTER_AUTH_SECRET); + recordGate("disposable PostgreSQL is ready"); + yield* run("bun", ["tools/verification/recommendation-preupgrade-fixture.ts"], { + ...env, + RECOMMENDATION_PREUPGRADE_THROUGH_0038: "1", + }); - assert.equal(recruitmentInvitationCalls.length, 1); - assert.deepEqual( - recruitmentInvitationCalls.map((call) => call.status), - [204], - ); + const historicalBefore = (yield* step(() => + pool.query( + `SELECT to_jsonb(c)-'recommendation' value FROM public.recruitment_interview_conducts c WHERE interview_id='interview-recommendation-history'`, + ), + )).rows[0].value; + + recordGate("previous-schema history fixture migrated"); + correctionPre0039Fixture = yield* seedInterviewCorrectionPre0039Fixture({ pool }); + recordGate("seeded 0105 pre-0039 correction rows from existing native base"); + yield* run( + "bun", + ["run", "identity:seed"], + { + ...env, + IDENTITY_SEED_PG_URL: pg, + IDENTITY_SEED_PERSONS: yield* jsonText([ + { + personId: "journey-conduct-leader-0063", + firstName: "Lina", + lastName: "Lagleder", + email: "lina.conduct@example.invalid", + password: "journey-conduct-secret-0123456789", + }, + ...(coInterviewerMode ? coInterviewerCorrection0106IdentitySeeds : []), + ...(applicantProgressMode ? [applicantProgressUnlinkedIdentity] : []), + ]), + }, + path.join(root, "packages/database"), + ); + + if (coInterviewerMode) { + coInterviewerFixture = yield* seedCoInterviewerCorrection0106Fixture({ pool }); recordGate( - "manually drove existing recruitment invitation delivery helper through loopback ACK", + "seeded synthetic 0106 co-interviewer designation after the canonical migration chain", ); } + yield* seedReturningAssistant({ pool, run, env, root }); + // Also classifies team-native-conduct-0063 as the board (Styret) of an independent department, + // so promoting the conduct leader to its leader below grants department reach (O8-11). + yield* seedInterviewReportCoordinator({ pool, secrets }); + if (effectMode === "http") { - const ReturningOutboxRow = Schema.Struct({ - effect_id: Schema.String, - command_id: Schema.String, - effect_type: Schema.String, - ordinal: Schema.Int, - status: Schema.String, - attempts: Schema.Int, - claimed_at: Schema.NullOr(Schema.Union([Schema.String, Schema.Date])), - last_failure_tag: Schema.NullOr(Schema.String), - origin: Schema.String, + yield* startEffectReceiver(effectToken, effectPort, (interviewId, capability) => { + invitationCapabilities.set(interviewId, capability); + secrets.push(capability); }); + secrets.push(effectToken); + } + + const effectSnapshot = Effect.fnUntraced(function* () { + const tables = (yield* step(() => + pool.query( + `SELECT schemaname,tablename FROM pg_tables WHERE schemaname IN ('public','auth') AND (tablename LIKE '%outbox%' OR tablename LIKE '%effect%') ORDER BY schemaname,tablename`, + ), + )).rows; + + return yield* Effect.forEach( + tables, + ({ schemaname, tablename }: any) => + step(() => + pool.query( + `SELECT to_jsonb(t) value FROM "${schemaname}"."${tablename}" t ORDER BY to_jsonb(t)::text`, + ), + ).pipe(Effect.map(({ rows }: any) => ({ table: `${schemaname}.${tablename}`, rows }))), + { concurrency: "unbounded" }, + ); + }); - type ReturningOutboxRow = typeof ReturningOutboxRow.Type; + const effectsBefore = yield* effectSnapshot(); + + const link = Effect.fnUntraced(function* (suffix: string, personId: string, sql = pool) { + const invitation = `identity-recommendation-${suffix}`; + yield* step(() => + sql.query( + `INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES($1,$2,$3,$4,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')+interval '24 hours','Claimed','journey-conduct-leader-0063',date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'))`, + [ + invitation, + `application-recommendation-${suffix}`, + `applicant-recommendation-${suffix}`, + createHash("sha256").update(invitation).digest("hex"), + ], + ), + ); + yield* step(() => + sql.query( + `INSERT INTO public.applicant_account_links VALUES($1,$2,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'),$3)`, + [`applicant-recommendation-${suffix}`, personId, invitation], + ), + ); + }); - const expectedEffectTypes = [ - "SendApplicantActivationOrConfirmation", - "CreateAdmissionSubscription", - "WriteApplicationAudit", - ] as const; + yield* step(() => + pool.query( + `INSERT INTO public.person_profiles(person_id,first_name,last_name,revision) VALUES('recommendation-other-0101','Other','Interviewer',0)`, + ), + ); + yield* link("self", "journey-conduct-leader-0063"); + yield* link("maybe", "recommendation-other-0101"); - const acceptedReturningRegistrations = await pool.query( - "SELECT command_id,registration_id FROM public.admission_returning_command_receipts ORDER BY command_id", + if (applicantProgressMode) { + yield* seedApplicantProgress0107(pool); + recordGate("seeded synthetic current-semester applicant progress states"); + secrets.push( + applicantProgressUnlinkedIdentity.email, + applicantProgressUnlinkedIdentity.password, ); + } - assert.equal(acceptedReturningRegistrations.rows.length, 6); + const invitationCapability = randomBytes(32).toString("base64url"); + secrets.push(invitationCapability); + yield* step(() => + pool.query( + `UPDATE public.recruitment_invitations SET capability_sha256=$1 WHERE invitation_id='invitation-recommendation-maybe'`, + [createHash("sha256").update(invitationCapability).digest("hex")], + ), + ); - const expectedOutboxCount = - acceptedReturningRegistrations.rows.length * expectedEffectTypes.length; + backend = yield* start("bun", ["apps/backend/src/main.ts"], env); + yield* ready(answersOk(`${api}/health`)); - const hasExactReturningEffects = (rows: ReadonlyArray) => { - if (rows.length !== expectedOutboxCount) return false; + const historicalAfter = (yield* step(() => + pool.query( + `SELECT to_jsonb(c)-'recommendation' value,recommendation FROM public.recruitment_interview_conducts c WHERE interview_id='interview-recommendation-history'`, + ), + )).rows[0]; - if (rows.some((row) => row.origin !== "ReturningAssistant")) return false; + assert.deepEqual(historicalAfter.value, historicalBefore); + assert.equal(historicalAfter.recommendation, null); + recordGate( + "immutable historical row survived actual0037 upgrade without invented recommendation", + ); + assert.ok(correctionPre0039Fixture); + yield* assertInterviewCorrectionPre0039Preserved({ + connection: pool, + fixture: correctionPre0039Fixture, + }); + recordGate( + "0039/0040 upgrades preserved original interview/schedule/invitation/conduct/lifecycle rows", + ); + yield* run("bun", ["run", "build"], env, path.join(root, "apps/dashboard")); + yield* start("bun", ["server.mjs"], env, path.join(root, "apps/dashboard")); + yield* ready(answersOk(`${ui}/login`)); - return acceptedReturningRegistrations.rows.every( - ({ command_id }: { command_id: string }) => { - const commandRows = rows - .filter((row) => row.command_id === command_id) - .sort((left, right) => left.ordinal - right.ordinal); + const password = "journey-conduct-secret-0123456789", + email = "lina.conduct@example.invalid"; - return ( - commandRows.length === expectedEffectTypes.length && - commandRows.map((row) => row.ordinal).join(",") === "0,1,2" && - commandRows.map((row) => row.effect_type).join(",") === expectedEffectTypes.join(",") - ); - }, - ); - }; + secrets.push(password, email); - const readReturningOutbox = async () => - Schema.decodeUnknownSync(Schema.Array(ReturningOutboxRow))( - ( - await pool.query( - `SELECT effect_id,command_id,effect_type,ordinal,status,attempts,claimed_at,last_failure_tag,origin - FROM public.admission_application_outbox - WHERE origin='ReturningAssistant' - ORDER BY effect_id`, - ) - ).rows, - ); + const credentialCheck = yield* HttpClient.execute( + HttpClientRequest.post(`${api}/api/auth/sign-in/email`).pipe( + HttpClientRequest.setHeaders({ origin: ui }), + HttpClientRequest.bodyText(yield* jsonText({ email, password }), "application/json"), + ), + ); - const waitForOutbox = async ( - predicate: (rows: ReadonlyArray) => boolean, - ) => { - for (let attempt = 0; attempt < 120; attempt += 1) { - const rows = await readReturningOutbox(); + if (credentialCheck.status < 200 || credentialCheck.status > 299) { + const failure = yield* credentialCheck.json; - if (predicate(rows)) return rows; - await new Promise((resolve) => setTimeout(resolve, 250)); - } + return yield* new ProbeFailure({ + message: `Native sign-in failed: ${credentialCheck.status} ${yield* jsonText(failure)}`, + }); + } - throw new Error("returning effect outbox did not reach expected state"); - }; + yield* credentialCheck.text; + recordGate("seeded native credentials accepted by real identity engine"); - const failedRows = await bounded( - "returning effect first failure", - waitForOutbox( - (rows) => - hasExactReturningEffects(rows) && - rows.filter((row) => row.ordinal === 0).length === - acceptedReturningRegistrations.rows.length && - rows.filter((row) => row.ordinal !== 0).length === - acceptedReturningRegistrations.rows.length * 2 && - rows - .filter((row) => row.ordinal === 0) - .every( - (row) => - row.status === "Failed" && - row.attempts >= 1 && - row.claimed_at === null && - row.last_failure_tag === "PublicApplicationEffectDeliveryError", - ) && - rows - .filter((row) => row.ordinal !== 0) - .every( - (row) => - row.status === "Pending" && - row.attempts === 0 && - row.claimed_at === null && - row.last_failure_tag === null, - ), - ), - 30_000, - ); + const executablePath = yield* Config.String("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH").pipe( + Config.withDefault("/etc/profiles/per-user/nori/bin/chromium"), + ); - assert.equal(failedRows.length, expectedOutboxCount); - assert.ok(hasExactReturningEffects(failedRows)); - assert.equal(failedRows.filter((row) => row.ordinal === 0).length, 6); - assert.equal(failedRows.filter((row) => row.ordinal !== 0).length, 12); - assert.ok( - failedRows.filter((row) => row.ordinal === 0).every((row) => row.status === "Failed"), - ); - assert.ok( - failedRows.filter((row) => row.ordinal !== 0).every((row) => row.status === "Pending"), - ); - const heldFailedRows = await readReturningOutbox(); - assert.equal(heldFailedRows.length, expectedOutboxCount); - assert.ok(hasExactReturningEffects(heldFailedRows)); - assert.ok( - heldFailedRows - .filter((row) => row.ordinal === 0) - .every( - (row) => - row.status === "Failed" && - row.attempts >= 1 && - row.claimed_at === null && - row.last_failure_tag === "PublicApplicationEffectDeliveryError", - ), - ); - assert.ok( - heldFailedRows - .filter((row) => row.ordinal !== 0) - .every( - (row) => - row.status === "Pending" && - row.attempts === 0 && - row.claimed_at === null && - row.last_failure_tag === null, - ), - ); + browser = yield* Effect.acquireRelease( + step(() => chromium.launch({ headless: true, executablePath })), + (launched: any) => step(() => launched.close()).pipe(Effect.orDie), + ); + const context = yield* step(() => browser.newContext()); + const errors: string[] = []; + context.on("page", (p: any) => p.on("pageerror", () => errors.push("pageerror"))); + page = yield* step(() => context.newPage()); + yield* step(() => page.goto(`${ui}/login`)); + yield* step(() => page.getByLabel("E-post", { exact: true }).fill(email)); + yield* step(() => page.getByLabel("Passord", { exact: true }).fill(password)); + yield* step(() => page.getByRole("button", { name: "Logg inn", exact: true }).click()); + yield* step(() => page.waitForURL(/\/dashboard\/?$/)); + yield* step(() => page.goto(`${ui}/dashboard/intervjuer`)); - const preRestartEffectCalls = effectCalls.filter( - (call) => call.origin === "ReturningAssistant", - ); + if (process.argv.includes("--returning-login-probe")) { + const probe = yield* runReturningAssistantLoginProbe({ browser, pool, ui, artifacts }); - assert.ok(preRestartEffectCalls.length >= 6); - assert.ok(preRestartEffectCalls.every((call) => call.status === 503)); - assert.ok( - preRestartEffectCalls.every( - (call) => call.kind === "SendApplicantActivationOrConfirmation", - ), - ); - const preRestartEffectIds = new Set(preRestartEffectCalls.map((call) => call.effectId)); - assert.ok( - heldFailedRows - .filter((row) => row.ordinal === 0) - .every((row) => preRestartEffectIds.has(row.effect_id)), - "each failed returning activation effect reached the loopback receiver before restart", - ); - recordGate( - `returning notification/subscription/audit loopback failure held until deliberate restart (${acceptedReturningRegistrations.rows.length} registrations × ${expectedEffectTypes.length} effects)`, - ); - await stopOwnedProcess(backend); - releaseEffectDelivery = true; - backend = start("bun", ["apps/backend/src/main.ts"], env); - await ready(async () => (await fetch(`${api}/health`)).ok); - - const deliveredRows = await bounded( - "returning effect restart delivery", - waitForOutbox( - (rows) => - hasExactReturningEffects(rows) && rows.every((row) => row.status === "Delivered"), + return { result: "ReturningLoginProbe", report: { probe } } satisfies Outcome; + } + + assert.equal( + yield* step(() => page.getByRole("link", { name: "Søkerkontoer", exact: true }).count()), + 0, + ); + const cookies = yield* step(() => context.cookies()); + const cookie = cookies.map((c: any) => `${c.name}=${c.value}`).join("; "); + secrets.push(...cookies.map((c: any) => c.value)); + + if (applicantProgressMode) { + // Leadership of the department's board (classified by seedInterviewReportCoordinator). + yield* step(() => + pool.query( + `UPDATE public.organization_memberships + SET is_team_leader=true, position_id='teamleader' + WHERE membership_id='membership-native-conduct-leader-0063'`, ), - 90_000, ); - assert.equal(deliveredRows.length, expectedOutboxCount); - assert.ok(hasExactReturningEffects(deliveredRows)); - assert.ok(deliveredRows.every((row) => row.status === "Delivered")); - const expectedEffectsById = new Map(deliveredRows.map((row) => [row.effect_id, row])); + const journey = yield* runApplicantProgress0107({ + pool, + page, + cookie, + api, + ui, + artifacts, + errors, + audit: (journeyPage) => runCallback(auditPage(journeyPage, "applicant-progress")), + }); - const returningEffectCalls = effectCalls.filter( - (call) => call.origin === "ReturningAssistant", - ); + recordGate("observed applicant-owned progress through the real browser/API/PostgreSQL path"); - assert.ok( - returningEffectCalls.every((call) => { - const outboxRow = expectedEffectsById.get(call.effectId); - - return ( - outboxRow !== undefined && - call.commandId === outboxRow.command_id && - call.origin === outboxRow.origin && - call.kind === outboxRow.effect_type - ); - }), - "effect receiver observed only the immutable expected envelopes", - ); - - for (const outboxRow of deliveredRows) { - const calls = effectCalls.filter((call) => call.effectId === outboxRow.effect_id); - const acknowledgements = calls.filter((call) => call.status === 204); - assert.equal(acknowledgements.length, 1); - assert.equal(acknowledgements[0]?.commandId, outboxRow.command_id); - assert.equal(acknowledgements[0]?.origin, outboxRow.origin); - assert.equal(acknowledgements[0]?.kind, outboxRow.effect_type); - - if (outboxRow.ordinal === 0) { - assert.ok( - calls.some((call) => call.status === 503), - `effect ${outboxRow.effect_id} had a pre-restart failure`, - ); - } - } + yield* assertArtifactsRedacted; - await writeFile( - join(artifacts, "returning-effect-evidence.json"), - JSON.stringify( - { - outbox: deliveredRows, - registrations: acceptedReturningRegistrations.rows, - calls: effectCalls, - preRestartCalls: preRestartEffectCalls, - restart: true, - }, - null, - 2, - ), - ); - recordGate("returning effect worker restarted and acknowledged all loopback effects"); + return { result: "ApplicantProgressTargeted", report: { journey } } satisfies Outcome; } - const reportEvidence = await bounded( - "0103 report observer", - observeInterviewReport({ - root, + if (coInterviewerMode) { + if (coInterviewerFixture === undefined) + return yield* new ProbeFailure({ + message: "co-interviewer targeted mode requires its synthetic designation fixture", + }); + + const journey = yield* runCoInterviewerCorrectionJourney({ pool, browser, + primaryCookie: cookie, api, ui, artifacts, - ordinaryCookie: cookie, - password, - secrets, - correctionMode: false, - returningPopulated: true, revision, + fixture: coInterviewerFixture, + errors, + secrets, + effectsBefore, + effectSnapshot, auditPage, recordGate, - }), - ); + }); - const observedReturningGates = new Set( - returningResult.trace - .values() - .filter((entry) => entry.phase === "negative-gate") - .map((entry) => Schema.decodeUnknownSync(Schema.String)(entry.gate)) - .toArray(), - ); - - const reportGates = Array.isArray(reportEvidence?.gates) ? reportEvidence.gates : []; - - const returningFalsifierManifest: Array<{ - falsifier: string; - gate: string; - status: "observed" | "missing"; - }> = ( - [ - ["anonymous", "anonymous-options"], - ["no-history/team-only", "no-placement-despite-affiliation"], - ["missing-linkedPerson", "missing-applicant-person-link"], - ["multiple-linkedPerson", "multiple-applicant-person-links"], - ["ambiguous-study-mapping", "ambiguous-study-mapping-structural-primary-key"], - ["invalid/inactive-study-mapping", "inactive-study-mapping"], - ["wrong-team", "cross-department-team"], - ["closed-period", "closed-period"], - [ - "retained inactive placement in different historical department/semester", - "retained-inactive-cross-department-placement", - ], - [ - "original-app-receipt-activation-conduct", - "preserved-original-receipt-activation-conduct", - ], - ["new-period-no-new-interview", "new-period-no-new-interview"], - ] as const - ).map(([falsifier, gate]) => ({ - falsifier, - gate, - status: observedReturningGates.has(gate) ? "observed" : "missing", - })); - - returningFalsifierManifest.push( - { - falsifier: "report-self-privacy/read-only", - gate: "0103 report observer", - status: reportGates.some( - (gate) => Predicate.isString(gate) && gate.includes("no report writes"), - ) - ? "observed" - : "missing", - }, - { - falsifier: "report-exact-period/classification/filter-reload", - gate: "0103 report observer", - status: reportGates.some((gate) => Predicate.isString(gate) && gate.includes("period")) - ? "observed" - : "missing", - }, - ); - await writeFile( - join(artifacts, "returning-targeted-evidence.json"), - JSON.stringify( - { revision, returningStages, returningResult, returningFalsifierManifest, reportEvidence }, - null, - 2, - ), - ); - assert.deepEqual( - accessibility.filter((result) => result.violations.length > 0), - [], - "Returning registration and report accessibility violations retained in accessibility.json", - ); - throw new ReturningTargetedComplete({ returningStages, reportEvidence }); - } + yield* assertArtifactsRedacted; - const get = (id: string) => - fetch(`${api}/api/recruitment/interviews/${id}`, { headers: { cookie, origin: ui } }); + return { result: "CoInterviewerTargeted", report: { journey } } satisfies Outcome; + } - const post = (id: string, body: Schema.Json, key: string, etag: string) => - fetch(`${api}/api/recruitment/interviews/${id}:finalize`, { - method: "POST", - headers: { - cookie, - origin: ui, - "content-type": "application/json", - "idempotency-key": key, - "if-match": etag, - }, - body: JSON.stringify(body), - }); + if (process.argv.includes("--returning-mode")) { + let currentStage = "returning:startup"; + const returningStages: string[] = []; - const correctPost = (id: string, body: Schema.Json, key: string, etag: string) => - fetch(`${api}/api/recruitment/interviews/${id}:correct`, { - method: "POST", - headers: { - cookie, - origin: ui, - "content-type": "application/json", - "idempotency-key": key, - "if-match": etag, - }, - body: JSON.stringify(body), - }); + const stage = (name: string) => { + currentStage = name; + returningStages.push(name); + say(JSON.stringify({ returningStage: name })); + }; - const open = async (p: any, name: string) => { - await p - .getByRole("article") - .filter({ hasText: name }) - .getByRole("button", { name: "Åpne intervju" }) - .click(); - await p.getByRole("heading", { name: `Intervju med ${name}` }).waitFor(); - }; + /** The operation, or a failure that names the stage it reached, after `timeout`. */ + const bounded = ( + label: string, + operation: Effect.Effect, + timeout: Duration.Input = "90 seconds", + ) => + operation.pipe( + Effect.timeoutOrElse({ + duration: timeout, + orElse: () => + Effect.fail(new ProbeFailure({ message: `${label} timed out at ${currentStage}` })), + }), + ); - const waitForRecruitmentOperation = (p: any, operation: string) => - p.waitForResponse((response: any) => { - if ( - response.request().method() !== "POST" || - new URL(response.url()).pathname !== "/recruitment" - ) - return false; + const returningResult = yield* bounded( + "returning browser journey", + runReturningAssistantBrowserJourney({ + browser, + page, + pool, + api, + ui, + artifacts, + auditPage, + errors, + stage, + coordinatorEmail: "coordinator.report@example.invalid", + coordinatorPassword: password, + readInvitationCapability: (interviewId) => invitationCapabilities.get(interviewId), + deliverRecruitmentInvitation: Effect.fnUntraced(function* (claimId: string) { + const result = yield* deliverRecruitmentInvitationOnce({ + pgUrl: pg, + endpoint: new URL(`${api.replace(/:\d+$/u, `:${effectPort}`)}/effects`), + token: effectToken, + claimId, + }); + + assert.equal(result._tag, "Delivered"); + + const outbox = yield* step(() => + pool.query( + "SELECT status,attempts FROM public.recruitment_invitation_outbox WHERE effect_id=$1", + [result.claim.effectId], + ), + ); - try { - const payload: unknown = response.request().postDataJSON(); + assert.deepEqual(outbox.rows, [{ status: "Delivered", attempts: 1 }]); - return ( - (payload === null || Predicate.isObjectOrArray(payload)) && - payload !== null && - "operation" in payload && - payload.operation === operation + return result; + }), + }), + ); + + if (effectMode === "http") { + const recruitmentInvitationCalls = effectCalls.filter( + (call) => call.kind === "SendInterviewInvitation", + ); + + assert.equal(recruitmentInvitationCalls.length, 1); + assert.deepEqual( + recruitmentInvitationCalls.map((call) => call.status), + [204], + ); + recordGate( + "manually drove existing recruitment invitation delivery helper through loopback ACK", ); - } catch { - return false; } - }); - const fill = async (p: any) => { - await p - .locator("#question-interview-schema-native-conduct-0063-q0") - .fill("Jeg vil forklare matematikk tydelig."); - await p.locator("#question-interview-schema-native-conduct-0063-q1-1").check(); - await p.locator("#question-interview-schema-native-conduct-0063-q2-0").check(); - await p.locator("#question-interview-schema-native-conduct-0063-q3-0").check(); + if (effectMode === "http") { + const ReturningOutboxRow = Schema.Struct({ + effect_id: Schema.String, + command_id: Schema.String, + effect_type: Schema.String, + ordinal: Schema.Int, + status: Schema.String, + attempts: Schema.Int, + claimed_at: Schema.NullOr(Schema.Union([Schema.String, Schema.Date])), + last_failure_tag: Schema.NullOr(Schema.String), + origin: Schema.String, + }); - for (const axis of ["explanatoryPower", "roleModel", "suitability"]) - await p.locator(`#score-${axis}`).selectOption("8"); - }; + type ReturningOutboxRow = typeof ReturningOutboxRow.Type; - if (process.argv.includes("--correction-mode")) { - const correctionId = "interview-recommendation-history"; - const correctionName = "history Recommendation"; - const correctionStages: string[] = []; - const stage = (name: string) => correctionStages.push(name); - - const operationFor = (request: any): string | undefined => { - if (request.method() !== "POST" || new URL(request.url()).pathname !== "/recruitment") - return undefined; - - try { - const payload: unknown = request.postDataJSON(); - - return (payload === null || Predicate.isObjectOrArray(payload)) && - payload !== null && - "operation" in payload && - Predicate.isString(payload.operation) - ? payload.operation - : undefined; - } catch { - return undefined; - } - }; + const expectedEffectTypes = [ + "SendApplicantActivationOrConfirmation", + "CreateAdmissionSubscription", + "WriteApplicationAudit", + ] as const; - const responseFor = (operation: string) => - page.waitForResponse((response: any) => operationFor(response.request()) === operation); + const acceptedReturningRegistrations = yield* step(() => + pool.query( + "SELECT command_id,registration_id FROM public.admission_returning_command_receipts ORDER BY command_id", + ), + ); - const correctionPageOpen = async () => { - await page - .getByRole("article") - .filter({ hasText: correctionName }) - .getByRole("button", { name: "Åpne intervju", exact: true }) - .click(); - await page.getByRole("heading", { name: `Intervju med ${correctionName}` }).waitFor(); - }; + assert.equal(acceptedReturningRegistrations.rows.length, 6); - const saveCorrection = async (recommendation: "Ja" | "Kanskje" | "Nei") => { - await fill(page); - await page.locator("#interviewer-recommendation").selectOption(recommendation); - await page.locator("#score-explanatoryPower").selectOption("4"); - await page.locator("#score-roleModel").selectOption("5"); - await page.locator("#score-suitability").selectOption("6"); - await page.getByRole("button", { name: "Rett intervju", exact: true }).click(); - await page.getByRole("dialog").waitFor({ state: "visible" }); - const responsePromise = responseFor("correctInterviewAssessment"); - await page - .getByRole("dialog") - .getByRole("button", { name: "Rett intervju", exact: true }) - .press("Enter"); - const response = await responsePromise; - - if (!response.ok()) - throw new Error(`correction response ${response.status()}: ${await response.text()}`); - await page.locator("#interviewer-recommendation").waitFor({ state: "visible" }); - assert.equal(await page.locator("#interviewer-recommendation").inputValue(), recommendation); - }; + const expectedOutboxCount = + acceptedReturningRegistrations.rows.length * expectedEffectTypes.length; - const before = await get(correctionId); - assert.equal(before.status, 200); - const beforeBody = await before.json(); + const hasExactReturningEffects = (rows: ReadonlyArray) => { + if (rows.length !== expectedOutboxCount) return false; - const originalConductSql = ( - await pool.query( - `SELECT to_jsonb(c) AS value - FROM public.recruitment_interview_conducts c - WHERE interview_id=$1`, - [correctionId], - ) - ).rows[0].value; - - assert.equal(beforeBody.completionState, "Completed"); - assert.equal(beforeBody.history[0]?._tag, "Original"); - assert.equal(beforeBody.history[0]?.recommendation, null); - stage("historical-null completed detail opens"); - await correctionPageOpen(); - await saveCorrection("Ja"); - stage("browser keyboard correction saves and refreshes detail"); - const afterFirst = await (await get(correctionId)).json(); - assert.equal(afterFirst.recommendation, "Ja"); - assert.deepEqual(afterFirst.answers, [ - { - questionId: "interview-schema-native-conduct-0063-q0", - answer: "Jeg vil forklare matematikk tydelig.", - }, - { questionId: "interview-schema-native-conduct-0063-q1", answer: "Teknologi" }, - { questionId: "interview-schema-native-conduct-0063-q2", answer: "Praksis" }, - { - questionId: "interview-schema-native-conduct-0063-q3", - answer: ["Samarbeid"], - }, - ]); - assert.deepEqual(afterFirst.score, { - explanatoryPower: 4, - roleModel: 5, - suitability: 6, - }); - assert.equal(afterFirst.finalizedByPersonId, beforeBody.finalizedByPersonId); - assert.equal(afterFirst.finalizedAt, beforeBody.finalizedAt); - assert.deepEqual(afterFirst.history[0], beforeBody.history[0]); - await page.reload(); - await correctionPageOpen(); - assert.equal(await page.locator("#interviewer-recommendation").inputValue(), "Ja"); - const staleCorrectionEtag = (await get(correctionId)).headers.get("etag")!; - - const staleCorrectionContext = await browser.newContext({ - storageState: await context.storageState(), - }); + if (rows.some((row) => row.origin !== "ReturningAssistant")) return false; - const staleCorrection = await staleCorrectionContext.newPage(); - staleCorrection.on("pageerror", () => errors.push("stale-correction-pageerror")); - await staleCorrection.goto(`${ui}/dashboard/intervjuer`); - await staleCorrection - .getByRole("article") - .filter({ hasText: correctionName }) - .getByRole("button", { name: "Åpne intervju", exact: true }) - .click(); - await staleCorrection - .getByRole("heading", { name: `Intervju med ${correctionName}` }) - .waitFor(); - await staleCorrection - .locator("#question-interview-schema-native-conduct-0063-q0") - .fill("Stale correction draft."); - await staleCorrection.locator("#interviewer-recommendation").selectOption("Kanskje"); - await staleCorrection.locator("#score-explanatoryPower").selectOption("7"); - await page - .locator("#question-interview-schema-native-conduct-0063-q0") - .fill("Et nytt tydelig svar."); - await page.locator("#score-explanatoryPower").selectOption("9"); - await page.locator("#interviewer-recommendation").selectOption("Nei"); - - type CorrectionAttempt = { - readonly phase: "failed-save" | "retry"; - readonly payload: unknown; - readonly ifMatch: string | undefined; - readonly idempotencyKey: string | undefined; - readonly fetchedStatus: number; - }; + return acceptedReturningRegistrations.rows.every( + ({ command_id }: { command_id: string }) => { + const commandRows = rows + .filter((row) => row.command_id === command_id) + .sort((left, right) => left.ordinal - right.ordinal); - const correctionAttempts: CorrectionAttempt[] = []; - let resolveFailedSave!: () => void; - let rejectFailedSave!: (cause: unknown) => void; - let resolveCorrectionRetry!: () => void; - let rejectCorrectionRetry!: (cause: unknown) => void; + return ( + commandRows.length === expectedEffectTypes.length && + commandRows.map((row) => row.ordinal).join(",") === "0,1,2" && + commandRows.map((row) => row.effect_type).join(",") === + expectedEffectTypes.join(",") + ); + }, + ); + }; - const failedSaveSettled = new Promise((resolve, reject) => { - resolveFailedSave = resolve; - rejectFailedSave = reject; - }); + const readReturningOutbox = Effect.fnUntraced(function* () { + return yield* Schema.decodeUnknownEffect(Schema.Array(ReturningOutboxRow))( + (yield* step(() => + pool.query( + `SELECT effect_id,command_id,effect_type,ordinal,status,attempts,claimed_at,last_failure_tag,origin + FROM public.admission_application_outbox + WHERE origin='ReturningAssistant' + ORDER BY effect_id`, + ), + )).rows, + ); + }); - const correctionRetrySettled = new Promise((resolve, reject) => { - resolveCorrectionRetry = resolve; - rejectCorrectionRetry = reject; - }); + const waitForOutbox = Effect.fnUntraced(function* ( + predicate: (rows: ReadonlyArray) => boolean, + ) { + for (let attempt = 0; attempt < 120; attempt += 1) { + const rows = yield* readReturningOutbox(); - const correctionRoute = async (route: any) => { - const request = route.request(); + if (predicate(rows)) return rows; + yield* Effect.sleep("250 millis"); + } - if (request.method() !== "POST" || new URL(request.url()).pathname !== "/recruitment") { - await route.continue(); + return yield* new ProbeFailure({ + message: "returning effect outbox did not reach expected state", + }); + }); - return; - } + const failedRows = yield* bounded( + "returning effect first failure", + waitForOutbox( + (rows) => + hasExactReturningEffects(rows) && + rows.filter((row) => row.ordinal === 0).length === + acceptedReturningRegistrations.rows.length && + rows.filter((row) => row.ordinal !== 0).length === + acceptedReturningRegistrations.rows.length * 2 && + rows + .filter((row) => row.ordinal === 0) + .every( + (row) => + row.status === "Failed" && + row.attempts >= 1 && + row.claimed_at === null && + row.last_failure_tag === "PublicApplicationEffectDeliveryError", + ) && + rows + .filter((row) => row.ordinal !== 0) + .every( + (row) => + row.status === "Pending" && + row.attempts === 0 && + row.claimed_at === null && + row.last_failure_tag === null, + ), + ), + 30_000, + ); - let payload: unknown; + assert.equal(failedRows.length, expectedOutboxCount); + assert.ok(hasExactReturningEffects(failedRows)); + assert.equal(failedRows.filter((row) => row.ordinal === 0).length, 6); + assert.equal(failedRows.filter((row) => row.ordinal !== 0).length, 12); + assert.ok( + failedRows.filter((row) => row.ordinal === 0).every((row) => row.status === "Failed"), + ); + assert.ok( + failedRows.filter((row) => row.ordinal !== 0).every((row) => row.status === "Pending"), + ); + const heldFailedRows = yield* readReturningOutbox(); + assert.equal(heldFailedRows.length, expectedOutboxCount); + assert.ok(hasExactReturningEffects(heldFailedRows)); + assert.ok( + heldFailedRows + .filter((row) => row.ordinal === 0) + .every( + (row) => + row.status === "Failed" && + row.attempts >= 1 && + row.claimed_at === null && + row.last_failure_tag === "PublicApplicationEffectDeliveryError", + ), + ); + assert.ok( + heldFailedRows + .filter((row) => row.ordinal !== 0) + .every( + (row) => + row.status === "Pending" && + row.attempts === 0 && + row.claimed_at === null && + row.last_failure_tag === null, + ), + ); - try { - payload = request.postDataJSON(); - } catch { - await route.continue(); + const preRestartEffectCalls = effectCalls.filter( + (call) => call.origin === "ReturningAssistant", + ); - return; - } + assert.ok(preRestartEffectCalls.length >= 6); + assert.ok(preRestartEffectCalls.every((call) => call.status === 503)); + assert.ok( + preRestartEffectCalls.every( + (call) => call.kind === "SendApplicantActivationOrConfirmation", + ), + ); + const preRestartEffectIds = new Set(preRestartEffectCalls.map((call) => call.effectId)); + assert.ok( + heldFailedRows + .filter((row) => row.ordinal === 0) + .every((row) => preRestartEffectIds.has(row.effect_id)), + "each failed returning activation effect reached the loopback receiver before restart", + ); + recordGate( + `returning notification/subscription/audit loopback failure held until deliberate restart (${acceptedReturningRegistrations.rows.length} registrations × ${expectedEffectTypes.length} effects)`, + ); + yield* backend.stop; + releaseEffectDelivery = true; + backend = yield* start("bun", ["apps/backend/src/main.ts"], env); + yield* ready(answersOk(`${api}/health`)); + + const deliveredRows = yield* bounded( + "returning effect restart delivery", + waitForOutbox( + (rows) => + hasExactReturningEffects(rows) && rows.every((row) => row.status === "Delivered"), + ), + 90_000, + ); - if ( - payload === null || - !(payload === null || Predicate.isObjectOrArray(payload)) || - !("operation" in payload) || - payload.operation !== "correctInterviewAssessment" - ) { - await route.continue(); + assert.equal(deliveredRows.length, expectedOutboxCount); + assert.ok(hasExactReturningEffects(deliveredRows)); + assert.ok(deliveredRows.every((row) => row.status === "Delivered")); + const expectedEffectsById = new Map(deliveredRows.map((row) => [row.effect_id, row])); - return; - } + const returningEffectCalls = effectCalls.filter( + (call) => call.origin === "ReturningAssistant", + ); - const headersValue = "headers" in payload ? payload.headers : undefined; - - const operationHeaders = - (headersValue === null || Predicate.isObjectOrArray(headersValue)) && headersValue !== null - ? headersValue - : {}; - - const ifMatch = - "if-match" in operationHeaders && Predicate.isString(operationHeaders["if-match"]) - ? operationHeaders["if-match"] - : undefined; - - const idempotencyKey = - "idempotency-key" in operationHeaders && - Predicate.isString(operationHeaders["idempotency-key"]) - ? operationHeaders["idempotency-key"] - : undefined; - - const phase: CorrectionAttempt["phase"] = - correctionAttempts.length === 0 ? "failed-save" : "retry"; - - try { - const response = await route.fetch({ timeout: 30_000 }); - correctionAttempts.push({ - phase, - payload, - ifMatch, - idempotencyKey, - fetchedStatus: response.status(), - }); + assert.ok( + returningEffectCalls.every((call) => { + const outboxRow = expectedEffectsById.get(call.effectId); - if (phase === "failed-save") { - await response.body(); - await route.abort("failed"); - resolveFailedSave(); + return ( + outboxRow !== undefined && + call.commandId === outboxRow.command_id && + call.origin === outboxRow.origin && + call.kind === outboxRow.effect_type + ); + }), + "effect receiver observed only the immutable expected envelopes", + ); - return; + for (const outboxRow of deliveredRows) { + const calls = effectCalls.filter((call) => call.effectId === outboxRow.effect_id); + const acknowledgements = calls.filter((call) => call.status === 204); + assert.equal(acknowledgements.length, 1); + assert.equal(acknowledgements[0]?.commandId, outboxRow.command_id); + assert.equal(acknowledgements[0]?.origin, outboxRow.origin); + assert.equal(acknowledgements[0]?.kind, outboxRow.effect_type); + + if (outboxRow.ordinal === 0) { + assert.ok( + calls.some((call) => call.status === 503), + `effect ${outboxRow.effect_id} had a pre-restart failure`, + ); + } } - await route.fulfill({ response }); - resolveCorrectionRetry(); - } catch (cause) { - (phase === "failed-save" ? rejectFailedSave : rejectCorrectionRetry)(cause); + yield* fs.writeFileString( + path.join(artifacts, "returning-effect-evidence.json"), + yield* indentedJsonText({ + outbox: deliveredRows, + registrations: acceptedReturningRegistrations.rows, + calls: effectCalls, + preRestartCalls: preRestartEffectCalls, + restart: true, + }), + ); + recordGate("returning effect worker restarted and acknowledged all loopback effects"); } - }; - const rowsBeforeFailedSave = ( - await pool.query( - `SELECT resulting_revision FROM public.recruitment_interview_correction_assessments - WHERE interview_id=$1 ORDER BY resulting_revision`, - [correctionId], - ) - ).rows; + const reportEvidence = yield* bounded( + "0103 report observer", + observeInterviewReport({ + root, + pool, + browser, + api, + ui, + artifacts, + ordinaryCookie: cookie, + password, + secrets, + correctionMode: false, + returningPopulated: true, + revision, + auditPage, + recordGate, + }), + ); - assert.deepEqual( - rowsBeforeFailedSave.map((row: any) => row.resulting_revision), - [2], - ); - await page.route("**/recruitment", correctionRoute); - await page.getByRole("button", { name: "Rett intervju", exact: true }).last().click(); - await page.getByRole("dialog").waitFor({ state: "visible" }); - await page - .getByRole("dialog") - .getByRole("button", { name: "Rett intervju", exact: true }) - .press("Enter"); - await failedSaveSettled; - await page.locator("#interviewer-recommendation").waitFor({ state: "visible" }); - assert.equal(await page.locator("#interviewer-recommendation").inputValue(), "Nei"); - assert.equal(await page.locator("#score-explanatoryPower").inputValue(), "9"); - - const rowsBeforeRetry = ( - await pool.query( - `SELECT resulting_revision FROM public.recruitment_interview_correction_assessments - WHERE interview_id=$1 ORDER BY resulting_revision`, - [correctionId], - ) - ).rows; + const observedReturningGates = new Set( + returningResult.trace + .values() + .filter((entry) => entry.phase === "negative-gate") + .map((entry) => Schema.decodeUnknownSync(Schema.String)(entry.gate)) + .toArray(), + ); - assert.deepEqual( - rowsBeforeRetry.map((row: any) => row.resulting_revision), - [...rowsBeforeFailedSave.map((row: any) => row.resulting_revision), 3], - ); - await page.getByRole("button", { name: "Rett intervju", exact: true }).last().click(); - await page.getByRole("dialog").waitFor({ state: "visible" }); - await page - .getByRole("dialog") - .getByRole("button", { name: "Rett intervju", exact: true }) - .press("Enter"); - await correctionRetrySettled; - assert.equal(correctionAttempts.length, 2); - assert.deepEqual(correctionAttempts[1], { - ...correctionAttempts[0], - phase: "retry", - }); - assert.equal(correctionAttempts[0]!.fetchedStatus, 200); - await page.locator("#interviewer-recommendation").waitFor({ state: "visible" }); - await page.unroute("**/recruitment", correctionRoute); - await page.reload(); - await correctionPageOpen(); - assert.equal(await page.locator("#interviewer-recommendation").inputValue(), "Nei"); - const browserHistory: any[] = (await (await get(correctionId)).json()).history; - assert.deepEqual( - browserHistory.map((entry) => [entry._tag, entry.revision]), - [ - ["Original", 1], - ["Correction", 2], - ["Correction", 3], - ], - ); - - const formatUiInstant = (instant: string): string => - new Intl.DateTimeFormat("nb-NO", { dateStyle: "long", timeStyle: "short" }).format( - new Date(instant), - ); - - const historyView = page.locator(".fs-history"); - await historyView.waitFor(); - const historyText = () => historyView.textContent(); - assert.match((await historyText()) ?? "", /Original vurdering, versjon 1/u); - assert.match((await historyText()) ?? "", /Korrigering, versjon 2/u); - assert.match((await historyText()) ?? "", /Korrigering, versjon 3/u); - assert.match((await historyText()) ?? "", /journey-conduct-leader-0063/u); - const completionText = (await page.locator(".fs-conduct__completion").textContent()) ?? ""; - assert.match(completionText, /Fullført/u); - assert.match(completionText, /journey-conduct-leader-0063/u); - assert.match(completionText, new RegExp(formatUiInstant(beforeBody.finalizedAt), "u")); - assert.match((await historyText()) ?? "", /Svar/u); - assert.match((await historyText()) ?? "", /Et nytt tydelig svar/u); - assert.match((await historyText()) ?? "", /Forklaringskraft7/u); - assert.match((await historyText()) ?? "", /Rollemodell8/u); - assert.match((await historyText()) ?? "", /Egnethet9/u); - assert.match((await historyText()) ?? "", /Ja/u); - assert.match((await historyText()) ?? "", /Nei/u); - assert.match( - (await historyText()) ?? "", - new RegExp(formatUiInstant(browserHistory[0].finalizedAt), "u"), - ); - assert.match( - (await historyText()) ?? "", - new RegExp(formatUiInstant(browserHistory[1].correctedAt), "u"), - ); - assert.match( - (await historyText()) ?? "", - new RegExp(formatUiInstant(browserHistory[2].correctedAt), "u"), - ); - const originalEntry = browserHistory[0]; - assert.deepEqual(originalEntry, beforeBody.history[0]); - const originalView = historyView.locator(".fs-history__entry").nth(0); - const originalText = (await originalView.textContent()) ?? ""; - assert.match(originalText, /Original vurdering, versjon 1/u); - assert.match(originalText, new RegExp(formatUiInstant(originalEntry.finalizedAt), "u")); - assert.match(originalText, new RegExp(originalEntry.finalizedByPersonId, "u")); - - for (const [label, value] of [ - ["Forklaringskraft", originalEntry.score.explanatoryPower], - ["Rollemodell", originalEntry.score.roleModel], - ["Egnethet", originalEntry.score.suitability], - ] as const) { - assert.match(originalText, new RegExp(`${label}${value}`, "u")); + const reportGates = Array.isArray(reportEvidence?.gates) ? reportEvidence.gates : []; + + const returningFalsifierManifest: Array<{ + falsifier: string; + gate: string; + status: "observed" | "missing"; + }> = ( + [ + ["anonymous", "anonymous-options"], + ["no-history/team-only", "no-placement-despite-affiliation"], + ["missing-linkedPerson", "missing-applicant-person-link"], + ["multiple-linkedPerson", "multiple-applicant-person-links"], + ["ambiguous-study-mapping", "ambiguous-study-mapping-structural-primary-key"], + ["invalid/inactive-study-mapping", "inactive-study-mapping"], + ["wrong-team", "cross-department-team"], + ["closed-period", "closed-period"], + [ + "retained inactive placement in different historical department/semester", + "retained-inactive-cross-department-placement", + ], + [ + "original-app-receipt-activation-conduct", + "preserved-original-receipt-activation-conduct", + ], + ["new-period-no-new-interview", "new-period-no-new-interview"], + ] as const + ).map(([falsifier, gate]) => ({ + falsifier, + gate, + status: observedReturningGates.has(gate) ? "observed" : "missing", + })); + + returningFalsifierManifest.push( + { + falsifier: "report-self-privacy/read-only", + gate: "0103 report observer", + status: reportGates.some( + (gate) => Predicate.isString(gate) && gate.includes("no report writes"), + ) + ? "observed" + : "missing", + }, + { + falsifier: "report-exact-period/classification/filter-reload", + gate: "0103 report observer", + status: reportGates.some((gate) => Predicate.isString(gate) && gate.includes("period")) + ? "observed" + : "missing", + }, + ); + yield* fs.writeFileString( + path.join(artifacts, "returning-targeted-evidence.json"), + yield* indentedJsonText({ + revision, + returningStages, + returningResult, + returningFalsifierManifest, + reportEvidence, + }), + ); + assert.deepEqual( + accessibility.filter((result) => result.violations.length > 0), + [], + "Returning registration and report accessibility violations retained in accessibility.json", + ); + + return { + result: "ReturningTargeted", + report: { journey: { returningStages, reportEvidence } }, + } satisfies Outcome; } - assert.match(originalText, /AnbefalingIkke registrert/u); - const originalAnswerNodes = originalView.locator(".fs-history__answer"); - assert.equal(await originalAnswerNodes.count(), Math.max(1, originalEntry.answers.length)); + // Each call is the RPC that replaced the route, answered as that route's HTTP response. + const get = (id: string) => + replacedFetch({ + origin: api, + tag: "recruitment.readInterviewConduct", + payload: { interviewId: id }, + headers: { cookie, origin: ui }, + }); - if (originalEntry.answers.length === 0) { - assert.match(originalText, /Ingen svar registrert/u); - } else { - for (const answer of originalEntry.answers) { - const question = beforeBody.questions.find( - (candidate: any) => candidate.questionId === answer.questionId, - ); + const post = (id: string, body: Schema.Json, key: string, etag: string) => + replacedFetch({ + origin: api, + tag: "recruitment.finalizeInterview", + payload: { interviewId: id, idempotencyKey: key, ifMatch: etag, request: body }, + headers: { cookie, origin: ui }, + }); - const renderedAnswer = Array.isArray(answer.answer) - ? answer.answer.join(", ") - : answer.answer; + const correctPost = (id: string, body: Schema.Json, key: string, etag: string) => + replacedFetch({ + origin: api, + tag: "recruitment.correctInterviewAssessment", + payload: { interviewId: id, idempotencyKey: key, ifMatch: etag, request: body }, + headers: { cookie, origin: ui }, + }); - assert.match( - originalText, - new RegExp(`${question?.prompt ?? answer.questionId}: ${renderedAnswer}`, "u"), - ); - } - } + const open = Effect.fnUntraced(function* (p: any, name: string) { + yield* step(() => + p + .getByRole("article") + .filter({ hasText: name }) + .getByRole("button", { name: "Åpne intervju" }) + .click(), + ); + yield* step(() => p.getByRole("heading", { name: `Intervju med ${name}` }).waitFor()); + }); - assert.equal(await historyView.locator("input,textarea,select,button").count(), 0); - await page.locator("#interviewer-recommendation").focus(); - assert.equal( - await page - .locator("#interviewer-recommendation") - .evaluate((element: HTMLSelectElement) => element.ownerDocument.activeElement === element), - true, - ); - await page - .locator(".fs-conduct") - .screenshot({ path: join(artifacts, "correction-history-desktop.png") }); - await auditPage(page, "correction-history-desktop"); - await page.setViewportSize({ width: 390, height: 844 }); - assert.ok( - (await page.locator("html").evaluate((element: HTMLElement) => element.scrollWidth)) <= 390, - ); - await page - .locator(".fs-conduct") - .screenshot({ path: join(artifacts, "correction-history-mobile.png") }); - await auditPage(page, "correction-history-mobile"); - await page.setViewportSize({ width: 1280, height: 900 }); - stage( - "browser ordered original and correction history shows completion metadata after reload with keyboard/mobile/Axe evidence", - ); - - const staleCorrectionResponsePromise = staleCorrection.waitForResponse( - (response: any) => operationFor(response.request()) === "correctInterviewAssessment", - ); - - await staleCorrection.getByRole("button", { name: "Rett intervju", exact: true }).click(); - await staleCorrection.getByRole("dialog").waitFor({ state: "visible" }); - await staleCorrection - .getByRole("dialog") - .getByRole("button", { name: "Rett intervju", exact: true }) - .press("Enter"); - const staleCorrectionResponse = await staleCorrectionResponsePromise; - assert.equal(staleCorrectionResponse.status(), 409); - const staleCorrectionRequest = staleCorrectionResponse.request(); - assert.equal(new URL(staleCorrectionRequest.url()).pathname, "/recruitment"); - const staleCorrectionRequestBody = staleCorrectionRequest.postDataJSON(); - assert.equal(staleCorrectionRequestBody.operation, "correctInterviewAssessment"); - assert.equal(staleCorrectionRequestBody.payload.expectedRevision, 2); - assert.equal(staleCorrectionRequestBody.headers["if-match"], staleCorrectionEtag); - assert.match(staleCorrectionRequestBody.headers["idempotency-key"] ?? "", /^[a-z0-9-]+$/); - await staleCorrection - .getByText( - "Intervjuet er endret. Utkastet er beholdt; åpne intervjuet på nytt for å hente gjeldende versjon.", - { exact: true }, - ) - .waitFor(); - assert.equal( - await staleCorrection - .locator("#question-interview-schema-native-conduct-0063-q0") - .inputValue(), - "Stale correction draft.", - ); - assert.equal( - await staleCorrection.locator("#interviewer-recommendation").inputValue(), - "Kanskje", - ); + const waitForRecruitmentOperation = (p: any, operation: string) => + p.waitForResponse((response: any) => { + if ( + response.request().method() !== "POST" || + new URL(response.url()).pathname !== "/recruitment" + ) + return false; - const staleRowsAfterConflict = ( - await pool.query( - `SELECT resulting_revision FROM public.recruitment_interview_correction_assessments - WHERE interview_id=$1 ORDER BY resulting_revision`, - [correctionId], - ) - ).rows; + try { + const payload: unknown = response.request().postDataJSON(); - assert.deepEqual( - staleRowsAfterConflict.map((row: any) => row.resulting_revision), - [2, 3], - ); - await staleCorrection.close(); - await staleCorrectionContext.close(); - stage("stale correction rejects old base and preserves visible draft without persistence"); - stage( - "lost correction response retries identical payload, base and idempotency key without duplicate persistence", - ); - stage("second correction and reload preserve ordered history"); - const afterSecond = await (await get(correctionId)).json(); - assert.ok( - afterSecond.history.filter((entry: any) => Predicate.isTagged(entry, "Correction")).length >= - 2, - ); - const afterSecondEtag = (await get(correctionId)).headers.get("etag"); - assert.deepEqual(afterSecond.answers, [ - { questionId: "interview-schema-native-conduct-0063-q0", answer: "Et nytt tydelig svar." }, - { questionId: "interview-schema-native-conduct-0063-q1", answer: "Teknologi" }, - { questionId: "interview-schema-native-conduct-0063-q2", answer: "Praksis" }, - { - questionId: "interview-schema-native-conduct-0063-q3", - answer: ["Samarbeid"], - }, - ]); - assert.deepEqual(afterSecond.score, { explanatoryPower: 9, roleModel: 5, suitability: 6 }); + return ( + (payload === null || Predicate.isObjectOrArray(payload)) && + payload !== null && + "operation" in payload && + payload.operation === operation + ); + } catch { + return false; + } + }); - const secondCorrections = afterSecond.history.filter((entry: any) => - Predicate.isTagged(entry, "Correction"), - ); + const fill = Effect.fnUntraced(function* (p: any) { + yield* step(() => + p + .locator("#question-interview-schema-native-conduct-0063-q0") + .fill("Jeg vil forklare matematikk tydelig."), + ); + yield* step(() => p.locator("#question-interview-schema-native-conduct-0063-q1-1").check()); + yield* step(() => p.locator("#question-interview-schema-native-conduct-0063-q2-0").check()); + yield* step(() => p.locator("#question-interview-schema-native-conduct-0063-q3-0").check()); - assert.equal(secondCorrections.length, 2); - assert.deepEqual( - secondCorrections.map((entry: any) => [entry.predecessorRevision, entry.revision]), - [ - [1, 2], - [2, 3], - ], - ); - assert.ok( - secondCorrections.every( - (entry: any) => - entry.correctedByPersonId === "journey-conduct-leader-0063" && - Predicate.isString(entry.correctedAt) && - Predicate.isString(entry.commandId), - ), - ); + for (const axis of ["explanatoryPower", "roleModel", "suitability"]) + yield* step(() => p.locator(`#score-${axis}`).selectOption("8")); + }); - const originalConductAfterSql = ( - await pool.query( - `SELECT to_jsonb(c) AS value - FROM public.recruitment_interview_conducts c - WHERE interview_id=$1`, - [correctionId], - ) - ).rows[0].value; - - assert.deepEqual(originalConductAfterSql, originalConductSql); - - const correctionRows = ( - await pool.query( - `SELECT predecessor_revision AS "predecessorRevision", - resulting_revision AS "resultingRevision", - answers, explanatory_power AS "explanatoryPower", - role_model AS "roleModel", suitability, recommendation, - corrected_by_person_id AS "correctedByPersonId", - command_id AS "commandId" - FROM public.recruitment_interview_correction_assessments - WHERE interview_id=$1 ORDER BY resulting_revision`, - [correctionId], - ) - ).rows; - - assert.equal(correctionRows.length, 2); - assert.deepEqual( - correctionRows.map((row: any) => [row.predecessorRevision, row.resultingRevision]), - [ - [1, 2], - [2, 3], - ], - ); - assert.ok( - correctionRows.every( - (row: any) => - row.correctedByPersonId === "journey-conduct-leader-0063" && - Predicate.isString(row.commandId), - ), - ); + if (process.argv.includes("--correction-mode")) { + const correctionId = "interview-recommendation-history"; + const correctionName = "history Recommendation"; + const correctionStages: string[] = []; + const stage = (name: string) => correctionStages.push(name); + + const operationFor = (request: any): string | undefined => { + if (request.method() !== "POST" || new URL(request.url()).pathname !== "/recruitment") + return undefined; + + try { + const payload: unknown = request.postDataJSON(); + + return (payload === null || Predicate.isObjectOrArray(payload)) && + payload !== null && + "operation" in payload && + Predicate.isString(payload.operation) + ? payload.operation + : undefined; + } catch { + return undefined; + } + }; - const receiptRows = ( - await pool.query( - `SELECT command_id AS "commandId", predecessor_revision AS "predecessorRevision", - resulting_revision AS "resultingRevision", command_json AS "commandJson", - observation_json AS "observationJson" - FROM public.recruitment_interview_correction_command_receipts - WHERE interview_id=$1 ORDER BY resulting_revision`, - [correctionId], - ) - ).rows; - - const auditRows = ( - await pool.query( - `SELECT command_id AS "commandId", actor_person_id AS "actorPersonId", - predecessor_revision AS "predecessorRevision", - resulting_revision AS "resultingRevision" - FROM public.recruitment_interview_correction_audit - WHERE interview_id=$1 ORDER BY resulting_revision`, - [correctionId], - ) - ).rows; - - assert.equal(receiptRows.length, 2); - assert.equal(auditRows.length, 2); - assert.deepEqual( - receiptRows.map((row: any) => [ - row.commandId, - row.predecessorRevision, - row.resultingRevision, - ]), - correctionRows.map((row: any) => [ - row.commandId, - row.predecessorRevision, - row.resultingRevision, - ]), - ); - assert.ok( - receiptRows.every( - (row: any) => - (row.commandJson === null || Predicate.isObjectOrArray(row.commandJson)) && - row.commandJson !== null && - (row.observationJson === null || Predicate.isObjectOrArray(row.observationJson)) && - row.observationJson !== null, - ), - ); - assert.deepEqual( - auditRows.map((row: any) => [ - row.commandId, - row.actorPersonId, - row.predecessorRevision, - row.resultingRevision, - ]), - correctionRows.map((row: any) => [ - row.commandId, - "journey-conduct-leader-0063", - row.predecessorRevision, - row.resultingRevision, - ]), - ); - stage("SQL original conduct immutability and correction chain receipt audit linkage"); - assert.ok(afterSecondEtag); - - const writeCount = async () => - ( - await pool.query( - `SELECT - (SELECT count(*) FROM public.recruitment_interview_correction_assessments WHERE interview_id=$1) AS assessments, - (SELECT count(*) FROM public.recruitment_interview_correction_command_receipts WHERE interview_id=$1) AS receipts, - (SELECT count(*) FROM public.recruitment_interview_correction_audit WHERE interview_id=$1) AS audit, - (SELECT revision FROM public.recruitment_interviews WHERE interview_id=$1) AS revision`, - [correctionId], - ) - ).rows[0]; + const responseFor = (operation: string) => + page.waitForResponse((response: any) => operationFor(response.request()) === operation); - const beforeMismatch = await writeCount(); + const correctionPageOpen = Effect.fnUntraced(function* () { + yield* step(() => + page + .getByRole("article") + .filter({ hasText: correctionName }) + .getByRole("button", { name: "Åpne intervju", exact: true }) + .click(), + ); + yield* step(() => + page.getByRole("heading", { name: `Intervju med ${correctionName}` }).waitFor(), + ); + }); - const mismatched = await correctPost( - correctionId, - { - expectedRevision: afterFirst.revision, - answers: afterFirst.answers, - score: afterFirst.score, - recommendation: afterFirst.recommendation, - }, - "correction-old-body-new-header-0105", - afterSecondEtag!, - ); + const saveCorrection = Effect.fnUntraced(function* ( + recommendation: "Ja" | "Kanskje" | "Nei", + ) { + yield* fill(page); + yield* step(() => page.locator("#interviewer-recommendation").selectOption(recommendation)); + yield* step(() => page.locator("#score-explanatoryPower").selectOption("4")); + yield* step(() => page.locator("#score-roleModel").selectOption("5")); + yield* step(() => page.locator("#score-suitability").selectOption("6")); + yield* step(() => page.getByRole("button", { name: "Rett intervju", exact: true }).click()); + yield* step(() => page.getByRole("dialog").waitFor({ state: "visible" })); + const responsePromise = responseFor("correctInterviewAssessment"); + yield* step(() => + page + .getByRole("dialog") + .getByRole("button", { name: "Rett intervju", exact: true }) + .press("Enter"), + ); + const response = yield* step(() => responsePromise); - assert.equal(mismatched.status, 412); - assert.deepEqual(await writeCount(), beforeMismatch); - stage( - "old displayed body with newer opaque ETag rejects at correction boundary without writes", - ); + if (response.ok() !== true) + throw new Error( + `correction response ${response.status()}: ${yield* step(() => response.text())}`, + ); + yield* step(() => + page.locator("#interviewer-recommendation").waitFor({ state: "visible" }), + ); + assert.equal( + yield* step(() => page.locator("#interviewer-recommendation").inputValue()), + recommendation, + ); + }); - const correctionPayload = (detail: any, recommendation: "Ja" | "Kanskje" | "Nei") => ({ - expectedRevision: detail.revision, - answers: [ - { questionId: "interview-schema-native-conduct-0063-q0", answer: "API correction answer" }, + const before = yield* step(() => get(correctionId)); + assert.equal(before.status, 200); + const beforeBody = yield* step(() => before.json()); + + const originalConductSql = (yield* step(() => + pool.query( + `SELECT to_jsonb(c) AS value + FROM public.recruitment_interview_conducts c + WHERE interview_id=$1`, + [correctionId], + ), + )).rows[0].value; + + assert.equal(beforeBody.completionState, "Completed"); + assert.equal(beforeBody.history[0]?._tag, "Original"); + assert.equal(beforeBody.history[0]?.recommendation, null); + stage("historical-null completed detail opens"); + yield* correctionPageOpen(); + yield* saveCorrection("Ja"); + stage("browser keyboard correction saves and refreshes detail"); + const afterFirst = yield* step(() => get(correctionId).then((response) => response.json())); + assert.equal(afterFirst.recommendation, "Ja"); + assert.deepEqual(afterFirst.answers, [ + { + questionId: "interview-schema-native-conduct-0063-q0", + answer: "Jeg vil forklare matematikk tydelig.", + }, { questionId: "interview-schema-native-conduct-0063-q1", answer: "Teknologi" }, { questionId: "interview-schema-native-conduct-0063-q2", answer: "Praksis" }, - { questionId: "interview-schema-native-conduct-0063-q3", answer: ["Samarbeid"] }, - ], - score: { explanatoryPower: 7, roleModel: 8, suitability: 9 }, - recommendation, - }); + { + questionId: "interview-schema-native-conduct-0063-q3", + answer: ["Samarbeid"], + }, + ]); + assert.deepEqual(afterFirst.score, { + explanatoryPower: 4, + roleModel: 5, + suitability: 6, + }); + assert.equal(afterFirst.finalizedByPersonId, beforeBody.finalizedByPersonId); + assert.equal(afterFirst.finalizedAt, beforeBody.finalizedAt); + assert.deepEqual(afterFirst.history[0], beforeBody.history[0]); + yield* step(() => page.reload()); + yield* correctionPageOpen(); + assert.equal( + yield* step(() => page.locator("#interviewer-recommendation").inputValue()), + "Ja", + ); + const staleCorrectionEtag = (yield* step(() => get(correctionId))).headers.get("etag")!; - const firstDirectDetail = await (await get(correctionId)).json(); - const firstDirectEtag = (await get(correctionId)).headers.get("etag")!; - const firstDirectPayload = correctionPayload(firstDirectDetail, "Kanskje"); + const staleStorageState = yield* step(() => context.storageState()); - const firstDirect = await correctPost( - correctionId, - firstDirectPayload, - "correction-replay-0105-a", - firstDirectEtag, - ); + const staleCorrectionContext = yield* step(() => + browser.newContext({ + storageState: staleStorageState, + }), + ); - if (firstDirect.status !== 200) { - throw new Error(`first direct correction ${firstDirect.status}: ${await firstDirect.text()}`); - } + const staleCorrection = yield* step(() => staleCorrectionContext.newPage()); + staleCorrection.on("pageerror", () => errors.push("stale-correction-pageerror")); + yield* step(() => staleCorrection.goto(`${ui}/dashboard/intervjuer`)); + yield* step(() => + staleCorrection + .getByRole("article") + .filter({ hasText: correctionName }) + .getByRole("button", { name: "Åpne intervju", exact: true }) + .click(), + ); + yield* step(() => + staleCorrection.getByRole("heading", { name: `Intervju med ${correctionName}` }).waitFor(), + ); + yield* step(() => + staleCorrection + .locator("#question-interview-schema-native-conduct-0063-q0") + .fill("Stale correction draft."), + ); + yield* step(() => + staleCorrection.locator("#interviewer-recommendation").selectOption("Kanskje"), + ); + yield* step(() => staleCorrection.locator("#score-explanatoryPower").selectOption("7")); + yield* step(() => + page + .locator("#question-interview-schema-native-conduct-0063-q0") + .fill("Et nytt tydelig svar."), + ); + yield* step(() => page.locator("#score-explanatoryPower").selectOption("9")); + yield* step(() => page.locator("#interviewer-recommendation").selectOption("Nei")); + + type CorrectionAttempt = { + readonly phase: "failed-save" | "retry"; + readonly payload: unknown; + readonly ifMatch: string | undefined; + readonly idempotencyKey: string | undefined; + readonly fetchedStatus: number; + }; - const acceptedCorrectionRequest = { - rawKey: "correction-replay-0105-a", - originalIfMatch: firstDirectEtag, - exactFourFieldPayload: firstDirectPayload, - }; + const correctionAttempts: CorrectionAttempt[] = []; + // The route handler below settles these; the journey awaits them as steps. + const failedSaveSettled = Promise.withResolvers(); + const correctionRetrySettled = Promise.withResolvers(); - acceptedCorrectionReplay = { - key: acceptedCorrectionRequest.rawKey, - etag: acceptedCorrectionRequest.originalIfMatch, - payload: acceptedCorrectionRequest.exactFourFieldPayload, - }; - const firstBytes = await firstDirect.text(); - const secondDirectDetail = await (await get(correctionId)).json(); - const secondDirectEtag = (await get(correctionId)).headers.get("etag")!; + const correctionRoute = (route: any): Promise => { + const request = route.request(); - const secondDirect = await correctPost( - correctionId, - correctionPayload(secondDirectDetail, "Nei"), - "correction-replay-0105-b", - secondDirectEtag, - ); + if (request.method() !== "POST" || new URL(request.url()).pathname !== "/recruitment") + return route.continue(); - assert.equal(secondDirect.status, 200); + let payload: unknown; - const replay = await correctPost( - correctionId, - acceptedCorrectionRequest.exactFourFieldPayload, - acceptedCorrectionRequest.rawKey, - acceptedCorrectionRequest.originalIfMatch, - ); + try { + payload = request.postDataJSON(); + } catch { + return route.continue(); + } - assert.equal(replay.status, 200); - assert.equal(await replay.text(), firstBytes); - stage("exact correction replay remains byte-stable after later correction"); - const raceDetail = await (await get(correctionId)).json(); - const raceEtag = (await get(correctionId)).headers.get("etag")!; - const beforeRaceWrites = await writeCount(); + if ( + payload === null || + !(payload === null || Predicate.isObjectOrArray(payload)) || + !("operation" in payload) || + payload.operation !== "correctInterviewAssessment" + ) + return route.continue(); + + const headersValue = "headers" in payload ? payload.headers : undefined; + + const operationHeaders = + (headersValue === null || Predicate.isObjectOrArray(headersValue)) && + headersValue !== null + ? headersValue + : {}; + + const ifMatch = + "if-match" in operationHeaders && Predicate.isString(operationHeaders["if-match"]) + ? operationHeaders["if-match"] + : undefined; + + const idempotencyKey = + "idempotency-key" in operationHeaders && + Predicate.isString(operationHeaders["idempotency-key"]) + ? operationHeaders["idempotency-key"] + : undefined; + + const phase: CorrectionAttempt["phase"] = + correctionAttempts.length === 0 ? "failed-save" : "retry"; + + return route + .fetch({ timeout: 30_000 }) + .then((response: any) => { + correctionAttempts.push({ + phase, + payload, + ifMatch, + idempotencyKey, + fetchedStatus: response.status(), + }); + + if (phase === "failed-save") + return response + .body() + .then(() => route.abort("failed")) + .then(() => failedSaveSettled.resolve()); + + return route.fulfill({ response }).then(() => correctionRetrySettled.resolve()); + }) + .catch((cause: unknown) => + (phase === "failed-save" ? failedSaveSettled : correctionRetrySettled).reject(cause), + ); + }; - const race = await Promise.all( - ["correction-race-0105-a", "correction-race-0105-b"].map((key, index) => + const rowsBeforeFailedSave = (yield* step(() => + pool.query( + `SELECT resulting_revision FROM public.recruitment_interview_correction_assessments + WHERE interview_id=$1 ORDER BY resulting_revision`, + [correctionId], + ), + )).rows; + + assert.deepEqual( + rowsBeforeFailedSave.map((row: any) => row.resulting_revision), + [2], + ); + yield* step(() => page.route("**/recruitment", correctionRoute)); + yield* step(() => + page.getByRole("button", { name: "Rett intervju", exact: true }).last().click(), + ); + yield* step(() => page.getByRole("dialog").waitFor({ state: "visible" })); + yield* step(() => + page + .getByRole("dialog") + .getByRole("button", { name: "Rett intervju", exact: true }) + .press("Enter"), + ); + yield* step(() => failedSaveSettled.promise); + yield* step(() => page.locator("#interviewer-recommendation").waitFor({ state: "visible" })); + assert.equal( + yield* step(() => page.locator("#interviewer-recommendation").inputValue()), + "Nei", + ); + assert.equal(yield* step(() => page.locator("#score-explanatoryPower").inputValue()), "9"); + + const rowsBeforeRetry = (yield* step(() => + pool.query( + `SELECT resulting_revision FROM public.recruitment_interview_correction_assessments + WHERE interview_id=$1 ORDER BY resulting_revision`, + [correctionId], + ), + )).rows; + + assert.deepEqual( + rowsBeforeRetry.map((row: any) => row.resulting_revision), + [...rowsBeforeFailedSave.map((row: any) => row.resulting_revision), 3], + ); + yield* step(() => + page.getByRole("button", { name: "Rett intervju", exact: true }).last().click(), + ); + yield* step(() => page.getByRole("dialog").waitFor({ state: "visible" })); + yield* step(() => + page + .getByRole("dialog") + .getByRole("button", { name: "Rett intervju", exact: true }) + .press("Enter"), + ); + yield* step(() => correctionRetrySettled.promise); + assert.equal(correctionAttempts.length, 2); + assert.deepEqual(correctionAttempts[1], { + ...correctionAttempts[0], + phase: "retry", + }); + assert.equal(correctionAttempts[0]!.fetchedStatus, 200); + yield* step(() => page.locator("#interviewer-recommendation").waitFor({ state: "visible" })); + yield* step(() => page.unroute("**/recruitment", correctionRoute)); + yield* step(() => page.reload()); + yield* correctionPageOpen(); + assert.equal( + yield* step(() => page.locator("#interviewer-recommendation").inputValue()), + "Nei", + ); + + const browserHistory: any[] = (yield* step(() => + get(correctionId).then((response) => response.json()), + )).history; + + assert.deepEqual( + browserHistory.map((entry) => [entry._tag, entry.revision]), + [ + ["Original", 1], + ["Correction", 2], + ["Correction", 3], + ], + ); + + const formatUiInstant = (instant: string): string => + new Intl.DateTimeFormat("nb-NO", { dateStyle: "long", timeStyle: "short" }).format( + DateTime.toDateUtc(DateTime.makeUnsafe(instant)), + ); + + const historyView = page.locator(".fs-history"); + yield* step(() => historyView.waitFor()); + const historyText = () => historyView.textContent(); + assert.match((yield* step(() => historyText())) ?? "", /Original vurdering, versjon 1/u); + assert.match((yield* step(() => historyText())) ?? "", /Korrigering, versjon 2/u); + assert.match((yield* step(() => historyText())) ?? "", /Korrigering, versjon 3/u); + assert.match((yield* step(() => historyText())) ?? "", /journey-conduct-leader-0063/u); + + const completionText = + (yield* step(() => page.locator(".fs-conduct__completion").textContent())) ?? ""; + + assert.match(completionText, /Fullført/u); + assert.match(completionText, /journey-conduct-leader-0063/u); + assert.match(completionText, new RegExp(formatUiInstant(beforeBody.finalizedAt), "u")); + assert.match((yield* step(() => historyText())) ?? "", /Svar/u); + assert.match((yield* step(() => historyText())) ?? "", /Et nytt tydelig svar/u); + assert.match((yield* step(() => historyText())) ?? "", /Forklaringskraft7/u); + assert.match((yield* step(() => historyText())) ?? "", /Rollemodell8/u); + assert.match((yield* step(() => historyText())) ?? "", /Egnethet9/u); + assert.match((yield* step(() => historyText())) ?? "", /Ja/u); + assert.match((yield* step(() => historyText())) ?? "", /Nei/u); + assert.match( + (yield* step(() => historyText())) ?? "", + new RegExp(formatUiInstant(browserHistory[0].finalizedAt), "u"), + ); + assert.match( + (yield* step(() => historyText())) ?? "", + new RegExp(formatUiInstant(browserHistory[1].correctedAt), "u"), + ); + assert.match( + (yield* step(() => historyText())) ?? "", + new RegExp(formatUiInstant(browserHistory[2].correctedAt), "u"), + ); + const originalEntry = browserHistory[0]; + assert.deepEqual(originalEntry, beforeBody.history[0]); + const originalView = historyView.locator(".fs-history__entry").nth(0); + const originalText = (yield* step(() => originalView.textContent())) ?? ""; + assert.match(originalText, /Original vurdering, versjon 1/u); + assert.match(originalText, new RegExp(formatUiInstant(originalEntry.finalizedAt), "u")); + assert.match(originalText, new RegExp(originalEntry.finalizedByPersonId, "u")); + + for (const [label, value] of [ + ["Forklaringskraft", originalEntry.score.explanatoryPower], + ["Rollemodell", originalEntry.score.roleModel], + ["Egnethet", originalEntry.score.suitability], + ] as const) { + assert.match(originalText, new RegExp(`${label}${value}`, "u")); + } + + assert.match(originalText, /AnbefalingIkke registrert/u); + const originalAnswerNodes = originalView.locator(".fs-history__answer"); + assert.equal( + yield* step(() => originalAnswerNodes.count()), + Math.max(1, originalEntry.answers.length), + ); + + if (originalEntry.answers.length === 0) { + assert.match(originalText, /Ingen svar registrert/u); + } else { + for (const answer of originalEntry.answers) { + const question = beforeBody.questions.find( + (candidate: any) => candidate.questionId === answer.questionId, + ); + + const renderedAnswer = Array.isArray(answer.answer) + ? answer.answer.join(", ") + : answer.answer; + + assert.match( + originalText, + new RegExp(`${question?.prompt ?? answer.questionId}: ${renderedAnswer}`, "u"), + ); + } + } + + assert.equal( + yield* step(() => historyView.locator("input,textarea,select,button").count()), + 0, + ); + yield* step(() => page.locator("#interviewer-recommendation").focus()); + assert.equal( + yield* step(() => + page + .locator("#interviewer-recommendation") + .evaluate( + (element: HTMLSelectElement) => element.ownerDocument.activeElement === element, + ), + ), + true, + ); + yield* step(() => + page + .locator(".fs-conduct") + .screenshot({ path: path.join(artifacts, "correction-history-desktop.png") }), + ); + yield* auditPage(page, "correction-history-desktop"); + yield* step(() => page.setViewportSize({ width: 390, height: 844 })); + assert.ok( + (yield* step(() => + page.locator("html").evaluate((element: HTMLElement) => element.scrollWidth), + )) <= 390, + ); + yield* step(() => + page + .locator(".fs-conduct") + .screenshot({ path: path.join(artifacts, "correction-history-mobile.png") }), + ); + yield* auditPage(page, "correction-history-mobile"); + yield* step(() => page.setViewportSize({ width: 1280, height: 900 })); + stage( + "browser ordered original and correction history shows completion metadata after reload with keyboard/mobile/Axe evidence", + ); + + const staleCorrectionResponsePromise = staleCorrection.waitForResponse( + (response: any) => operationFor(response.request()) === "correctInterviewAssessment", + ); + + yield* step(() => + staleCorrection.getByRole("button", { name: "Rett intervju", exact: true }).click(), + ); + yield* step(() => staleCorrection.getByRole("dialog").waitFor({ state: "visible" })); + yield* step(() => + staleCorrection + .getByRole("dialog") + .getByRole("button", { name: "Rett intervju", exact: true }) + .press("Enter"), + ); + const staleCorrectionResponse = yield* step(() => staleCorrectionResponsePromise); + assert.equal(staleCorrectionResponse.status(), 409); + const staleCorrectionRequest = staleCorrectionResponse.request(); + assert.equal(new URL(staleCorrectionRequest.url()).pathname, "/recruitment"); + const staleCorrectionRequestBody = staleCorrectionRequest.postDataJSON(); + assert.equal(staleCorrectionRequestBody.operation, "correctInterviewAssessment"); + assert.equal(staleCorrectionRequestBody.payload.expectedRevision, 2); + assert.equal(staleCorrectionRequestBody.headers["if-match"], staleCorrectionEtag); + assert.match(staleCorrectionRequestBody.headers["idempotency-key"] ?? "", /^[a-z0-9-]+$/); + yield* step(() => + staleCorrection + .getByText( + "Intervjuet er endret. Utkastet er beholdt; åpne intervjuet på nytt for å hente gjeldende versjon.", + { exact: true }, + ) + .waitFor(), + ); + assert.equal( + yield* step(() => + staleCorrection.locator("#question-interview-schema-native-conduct-0063-q0").inputValue(), + ), + "Stale correction draft.", + ); + assert.equal( + yield* step(() => staleCorrection.locator("#interviewer-recommendation").inputValue()), + "Kanskje", + ); + + const staleRowsAfterConflict = (yield* step(() => + pool.query( + `SELECT resulting_revision FROM public.recruitment_interview_correction_assessments + WHERE interview_id=$1 ORDER BY resulting_revision`, + [correctionId], + ), + )).rows; + + assert.deepEqual( + staleRowsAfterConflict.map((row: any) => row.resulting_revision), + [2, 3], + ); + yield* step(() => staleCorrection.close()); + yield* step(() => staleCorrectionContext.close()); + stage("stale correction rejects old base and preserves visible draft without persistence"); + stage( + "lost correction response retries identical payload, base and idempotency key without duplicate persistence", + ); + stage("second correction and reload preserve ordered history"); + const afterSecond = yield* step(() => get(correctionId).then((response) => response.json())); + assert.ok( + afterSecond.history.filter((entry: any) => Predicate.isTagged(entry, "Correction")) + .length >= 2, + ); + const afterSecondEtag = (yield* step(() => get(correctionId))).headers.get("etag"); + assert.deepEqual(afterSecond.answers, [ + { questionId: "interview-schema-native-conduct-0063-q0", answer: "Et nytt tydelig svar." }, + { questionId: "interview-schema-native-conduct-0063-q1", answer: "Teknologi" }, + { questionId: "interview-schema-native-conduct-0063-q2", answer: "Praksis" }, + { + questionId: "interview-schema-native-conduct-0063-q3", + answer: ["Samarbeid"], + }, + ]); + assert.deepEqual(afterSecond.score, { explanatoryPower: 9, roleModel: 5, suitability: 6 }); + + const secondCorrections = afterSecond.history.filter((entry: any) => + Predicate.isTagged(entry, "Correction"), + ); + + assert.equal(secondCorrections.length, 2); + assert.deepEqual( + secondCorrections.map((entry: any) => [entry.predecessorRevision, entry.revision]), + [ + [1, 2], + [2, 3], + ], + ); + assert.ok( + secondCorrections.every( + (entry: any) => + entry.correctedByPersonId === "journey-conduct-leader-0063" && + Predicate.isString(entry.correctedAt) && + Predicate.isString(entry.commandId), + ), + ); + + const originalConductAfterSql = (yield* step(() => + pool.query( + `SELECT to_jsonb(c) AS value + FROM public.recruitment_interview_conducts c + WHERE interview_id=$1`, + [correctionId], + ), + )).rows[0].value; + + assert.deepEqual(originalConductAfterSql, originalConductSql); + + const correctionRows = (yield* step(() => + pool.query( + `SELECT predecessor_revision AS "predecessorRevision", + resulting_revision AS "resultingRevision", + answers, explanatory_power AS "explanatoryPower", + role_model AS "roleModel", suitability, recommendation, + corrected_by_person_id AS "correctedByPersonId", + command_id AS "commandId" + FROM public.recruitment_interview_correction_assessments + WHERE interview_id=$1 ORDER BY resulting_revision`, + [correctionId], + ), + )).rows; + + assert.equal(correctionRows.length, 2); + assert.deepEqual( + correctionRows.map((row: any) => [row.predecessorRevision, row.resultingRevision]), + [ + [1, 2], + [2, 3], + ], + ); + assert.ok( + correctionRows.every( + (row: any) => + row.correctedByPersonId === "journey-conduct-leader-0063" && + Predicate.isString(row.commandId), + ), + ); + + const receiptRows = (yield* step(() => + pool.query( + `SELECT command_id AS "commandId", predecessor_revision AS "predecessorRevision", + resulting_revision AS "resultingRevision", command_json AS "commandJson", + observation_json AS "observationJson" + FROM public.recruitment_interview_correction_command_receipts + WHERE interview_id=$1 ORDER BY resulting_revision`, + [correctionId], + ), + )).rows; + + const auditRows = (yield* step(() => + pool.query( + `SELECT command_id AS "commandId", actor_person_id AS "actorPersonId", + predecessor_revision AS "predecessorRevision", + resulting_revision AS "resultingRevision" + FROM public.recruitment_interview_correction_audit + WHERE interview_id=$1 ORDER BY resulting_revision`, + [correctionId], + ), + )).rows; + + assert.equal(receiptRows.length, 2); + assert.equal(auditRows.length, 2); + assert.deepEqual( + receiptRows.map((row: any) => [ + row.commandId, + row.predecessorRevision, + row.resultingRevision, + ]), + correctionRows.map((row: any) => [ + row.commandId, + row.predecessorRevision, + row.resultingRevision, + ]), + ); + assert.ok( + receiptRows.every( + (row: any) => + (row.commandJson === null || Predicate.isObjectOrArray(row.commandJson)) && + row.commandJson !== null && + (row.observationJson === null || Predicate.isObjectOrArray(row.observationJson)) && + row.observationJson !== null, + ), + ); + assert.deepEqual( + auditRows.map((row: any) => [ + row.commandId, + row.actorPersonId, + row.predecessorRevision, + row.resultingRevision, + ]), + correctionRows.map((row: any) => [ + row.commandId, + "journey-conduct-leader-0063", + row.predecessorRevision, + row.resultingRevision, + ]), + ); + stage("SQL original conduct immutability and correction chain receipt audit linkage"); + assert.ok(afterSecondEtag); + + const writeCount = Effect.fnUntraced(function* () { + return (yield* step(() => + pool.query( + `SELECT + (SELECT count(*) FROM public.recruitment_interview_correction_assessments WHERE interview_id=$1) AS assessments, + (SELECT count(*) FROM public.recruitment_interview_correction_command_receipts WHERE interview_id=$1) AS receipts, + (SELECT count(*) FROM public.recruitment_interview_correction_audit WHERE interview_id=$1) AS audit, + (SELECT revision FROM public.recruitment_interviews WHERE interview_id=$1) AS revision`, + [correctionId], + ), + )).rows[0]; + }); + + const beforeMismatch = yield* writeCount(); + + const mismatched = yield* step(() => correctPost( correctionId, - correctionPayload(raceDetail, index === 0 ? "Ja" : "Kanskje"), - key, - raceEtag, + { + expectedRevision: afterFirst.revision, + answers: afterFirst.answers, + score: afterFirst.score, + recommendation: afterFirst.recommendation, + }, + "correction-old-body-new-header-0105", + afterSecondEtag!, + ), + ); + + assert.equal(mismatched.status, 412); + assert.deepEqual(yield* writeCount(), beforeMismatch); + stage( + "old displayed body with newer opaque ETag rejects at correction boundary without writes", + ); + + const correctionPayload = (detail: any, recommendation: "Ja" | "Kanskje" | "Nei") => ({ + expectedRevision: detail.revision, + answers: [ + { + questionId: "interview-schema-native-conduct-0063-q0", + answer: "API correction answer", + }, + { questionId: "interview-schema-native-conduct-0063-q1", answer: "Teknologi" }, + { questionId: "interview-schema-native-conduct-0063-q2", answer: "Praksis" }, + { questionId: "interview-schema-native-conduct-0063-q3", answer: ["Samarbeid"] }, + ], + score: { explanatoryPower: 7, roleModel: 8, suitability: 9 }, + recommendation, + }); + + const firstDirectDetail = yield* step(() => + get(correctionId).then((response) => response.json()), + ); + + const firstDirectEtag = (yield* step(() => get(correctionId))).headers.get("etag")!; + const firstDirectPayload = correctionPayload(firstDirectDetail, "Kanskje"); + + const firstDirect = yield* step(() => + correctPost(correctionId, firstDirectPayload, "correction-replay-0105-a", firstDirectEtag), + ); + + if (firstDirect.status !== 200) { + throw new Error( + `first direct correction ${firstDirect.status}: ${yield* step(() => firstDirect.text())}`, + ); + } + + const acceptedCorrectionRequest = { + rawKey: "correction-replay-0105-a", + originalIfMatch: firstDirectEtag, + exactFourFieldPayload: firstDirectPayload, + }; + + acceptedCorrectionReplay = { + key: acceptedCorrectionRequest.rawKey, + etag: acceptedCorrectionRequest.originalIfMatch, + payload: acceptedCorrectionRequest.exactFourFieldPayload, + }; + const firstBytes = yield* step(() => firstDirect.text()); + + const secondDirectDetail = yield* step(() => + get(correctionId).then((response) => response.json()), + ); + + const secondDirectEtag = (yield* step(() => get(correctionId))).headers.get("etag")!; + + const secondDirect = yield* step(() => + correctPost( + correctionId, + correctionPayload(secondDirectDetail, "Nei"), + "correction-replay-0105-b", + secondDirectEtag, + ), + ); + + assert.equal(secondDirect.status, 200); + + const replay = yield* step(() => + correctPost( + correctionId, + acceptedCorrectionRequest.exactFourFieldPayload, + acceptedCorrectionRequest.rawKey, + acceptedCorrectionRequest.originalIfMatch, + ), + ); + + assert.equal(replay.status, 200); + assert.equal(yield* step(() => replay.text()), firstBytes); + stage("exact correction replay remains byte-stable after later correction"); + const raceDetail = yield* step(() => get(correctionId).then((response) => response.json())); + const raceEtag = (yield* step(() => get(correctionId))).headers.get("etag")!; + const beforeRaceWrites = yield* writeCount(); + + const race = yield* step(() => + Promise.all( + ["correction-race-0105-a", "correction-race-0105-b"].map((key, index) => + correctPost( + correctionId, + correctionPayload(raceDetail, index === 0 ? "Ja" : "Kanskje"), + key, + raceEtag, + ), + ), + ), + ); + + assert.equal(race.filter((response) => response.status === 200).length, 1); + assert.equal(race.filter((response) => response.status !== 200).length, 1); + assert.ok(race.every((response) => [200, 409, 412].includes(response.status))); + const winningRaceIndex = race.findIndex((response) => response.status === 200); + expectedHistoricalReportRecommendation = winningRaceIndex === 0 ? "Ja" : "Kanskje"; + const afterRaceWrites = yield* writeCount(); + assert.equal(Number(afterRaceWrites.assessments), Number(beforeRaceWrites.assessments) + 1); + assert.equal(Number(afterRaceWrites.receipts), Number(beforeRaceWrites.receipts) + 1); + assert.equal(Number(afterRaceWrites.audit), Number(beforeRaceWrites.audit) + 1); + assert.equal(Number(afterRaceWrites.revision), Number(beforeRaceWrites.revision) + 1); + stage("same-revision concurrent corrections have one winner and one no-write loser"); + const finalDetail = yield* step(() => get(correctionId).then((response) => response.json())); + assert.equal(finalDetail.history[0]?.recommendation, null); + assert.equal(finalDetail.finalizedByPersonId, beforeBody.finalizedByPersonId); + assert.equal(finalDetail.finalizedAt, beforeBody.finalizedAt); + assert.equal(finalDetail.effectiveRevision, 6); + assert.equal(finalDetail.revision, 6); + assert.equal( + finalDetail.history.filter((entry: any) => Predicate.isTagged(entry, "Correction")).length, + 5, + ); + assert.ok(finalDetail.canFinalize === false && finalDetail.canCancel === false); + assert.deepEqual(yield* effectSnapshot(), effectsBefore); + stage("corrections create no notification or application effects"); + yield* step(() => + pool.query( + `UPDATE public.organization_memberships SET is_suspended=true WHERE membership_id='membership-native-conduct-leader-0063'`, + ), + ); + assert.equal((yield* step(() => get(correctionId))).status, 403); + assert.equal( + (yield* step(() => + correctPost( + correctionId, + acceptedCorrectionRequest.exactFourFieldPayload, + acceptedCorrectionRequest.rawKey, + acceptedCorrectionRequest.originalIfMatch, + ), + )).status, + 403, + ); + yield* step(() => + pool.query( + `UPDATE public.organization_memberships SET is_suspended=false WHERE membership_id='membership-native-conduct-leader-0063'`, + ), + ); + yield* step(() => + pool.query( + `UPDATE public.recruitment_interviews SET interviewer_person_id='recommendation-other-0101' WHERE interview_id=$1`, + [correctionId], + ), + ); + assert.equal((yield* step(() => get(correctionId))).status, 403); + assert.equal( + (yield* step(() => + correctPost( + correctionId, + acceptedCorrectionRequest.exactFourFieldPayload, + acceptedCorrectionRequest.rawKey, + acceptedCorrectionRequest.originalIfMatch, + ), + )).status, + 403, + ); + yield* step(() => + pool.query( + `UPDATE public.recruitment_interviews SET interviewer_person_id='journey-conduct-leader-0063' WHERE interview_id=$1`, + [correctionId], + ), + ); + stage("authority revocation denies correction detail and replay before receipt reuse"); + yield* step(() => + pool.query( + `UPDATE public.organization_memberships SET end_at=date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC') - interval '24 hours' WHERE membership_id='membership-native-conduct-leader-0063'`, + ), + ); + assert.equal((yield* step(() => get(correctionId))).status, 403); + assert.equal( + (yield* step(() => + correctPost( + correctionId, + acceptedCorrectionRequest.exactFourFieldPayload, + acceptedCorrectionRequest.rawKey, + acceptedCorrectionRequest.originalIfMatch, + ), + )).status, + 403, + ); + yield* step(() => + pool.query( + `UPDATE public.organization_memberships SET end_at=NULL WHERE membership_id='membership-native-conduct-leader-0063'`, + ), + ); + stage("ended membership denies correction detail and replay"); + const rollbackBefore = yield* writeCount(); + yield* step(() => + pool.query(` + CREATE OR REPLACE FUNCTION public.test_correction_audit_failure() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + RAISE EXCEPTION 'synthetic correction audit failure'; + END; + $$; + DROP TRIGGER IF EXISTS test_correction_audit_failure + ON public.recruitment_interview_correction_audit; + CREATE TRIGGER test_correction_audit_failure + AFTER INSERT ON public.recruitment_interview_correction_audit + FOR EACH ROW EXECUTE FUNCTION public.test_correction_audit_failure(); + `), + ); + const rollbackEtag = (yield* step(() => get(correctionId))).headers.get("etag")!; + + const rollbackResponse = yield* step(() => + correctPost( + correctionId, + correctionPayload(finalDetail, "Ja"), + "correction-rollback-0105", + rollbackEtag, + ), + ); + + assert.ok([500, 503].includes(rollbackResponse.status)); + assert.deepEqual(yield* writeCount(), rollbackBefore); + yield* step(() => + pool.query(` + DROP TRIGGER test_correction_audit_failure + ON public.recruitment_interview_correction_audit; + DROP FUNCTION public.test_correction_audit_failure(); + `), + ); + stage( + "synthetic audit failure rolls back correction assessment, aggregate, receipt and audit", + ); + yield* assertInterviewCorrectionIntegrity({ pool, interviewId: correctionId }); + recordGate( + "direct SQL correction integrity rejects immutable mutations, invalid predecessor, aggregate mismatch, and cross-command receipt/audit tuples without writes", + ); + yield* fs.writeFileString( + path.join(artifacts, "correction-targeted-evidence.json"), + yield* indentedJsonText({ + revision, + correctionStages, + beforeBody, + afterFirst, + afterSecond, + finalDetail, + failedSaveRetry: correctionAttempts, + }), + ); + yield* step(() => page.goto(`${ui}/dashboard/intervjuer`)); + } + + yield* open(page, "Sofie Gjennomfører"); + yield* fill(page); + assert.equal(yield* step(() => page.locator("#interviewer-recommendation").inputValue()), ""); + yield* step(() => + page + .locator(".fs-conduct") + .screenshot({ path: path.join(artifacts, "editable-desktop.png") }), + ); + yield* auditPage(page, "editable-desktop"); + yield* step(() => page.setViewportSize({ width: 390, height: 844 })); + assert.ok( + (yield* step(() => + page.locator("html").evaluate((element: HTMLElement) => element.scrollWidth), + )) <= page.viewportSize().width, + ); + yield* step(() => + page.locator(".fs-conduct").screenshot({ path: path.join(artifacts, "editable-mobile.png") }), + ); + yield* auditPage(page, "editable-mobile"); + + // Viewport observations preserve fixed chrome; tall element captures can stitch it + // across fields that are visible when the actual viewport is scrolled. + for (const [selector, name] of [ + ["#question-interview-schema-native-conduct-0063-q0", "editable-mobile-answer-viewport"], + ["#interviewer-recommendation", "editable-mobile-recommendation-viewport"], + ] as const) { + yield* step(() => + page + .locator(selector) + .evaluate((element: HTMLElement) => element.scrollIntoView({ block: "center" })), + ); + yield* step(() => page.locator(selector).focus()); + yield* step(() => page.screenshot({ path: path.join(artifacts, `${name}.png`) })); + } + + yield* step(() => page.setViewportSize({ width: 1280, height: 900 })); + yield* step(() => page.getByRole("button", { name: "Fullfør intervju", exact: true }).click()); + yield* step(() => + page + .getByText("Svar på alle spørsmål, velg alle tre scorer og en anbefaling.", { exact: true }) + .waitFor(), + ); + assert.equal(yield* step(() => page.locator("#score-suitability").inputValue()), "8"); + yield* step(() => page.locator("#interviewer-recommendation").focus()); + yield* step(() => page.keyboard.press("Home")); + yield* step(() => page.keyboard.press("ArrowDown")); + yield* step(() => page.keyboard.press("Enter")); + assert.equal(yield* step(() => page.locator("#interviewer-recommendation").inputValue()), "Ja"); + const finalResponsePromise = waitForRecruitmentOperation(page, "finalizeInterview"); + yield* step(() => page.getByRole("button", { name: "Fullfør intervju", exact: true }).click()); + yield* step(() => page.getByRole("dialog").waitFor()); + yield* step(() => page.screenshot({ path: path.join(artifacts, "confirmation.png") })); + yield* auditPage(page, "confirmation"); + const finalResponse = finalResponsePromise; + yield* step(() => + page + .getByRole("dialog") + .getByRole("button", { name: "Fullfør intervju", exact: true }) + .press("Enter"), + ); + assert.equal((yield* step(() => finalResponse)).status(), 200); + yield* step(() => page.getByText("Intervjuet er fullført.", { exact: true }).waitFor()); + yield* step(() => page.reload()); + yield* open(page, "Sofie Gjennomfører"); + assert.equal(yield* step(() => page.locator("#interviewer-recommendation").inputValue()), "Ja"); + yield* step(() => + page + .locator(".fs-conduct") + .screenshot({ path: path.join(artifacts, "recommendation-desktop.png") }), + ); + yield* auditPage(page, "finalized-desktop"); + yield* step(() => page.setViewportSize({ width: 390, height: 844 })); + assert.ok( + (yield* step(() => + page.locator("html").evaluate((element: HTMLElement) => element.scrollWidth), + )) <= page.viewportSize().width, + ); + yield* step(() => + page + .locator(".fs-conduct") + .screenshot({ path: path.join(artifacts, "recommendation-mobile.png") }), + ); + yield* auditPage(page, "finalized-mobile"); + yield* step(() => page.setViewportSize({ width: 1280, height: 900 })); + recordGate( + "ordinary assigned member: required choice, keyboard finalization, reload and exact stale-correction conflict retention", + ); + + const answers = [ + { questionId: "interview-schema-native-conduct-0063-q0", answer: "Et tydelig svar" }, + { questionId: "interview-schema-native-conduct-0063-q1", answer: "Teknologi" }, + { questionId: "interview-schema-native-conduct-0063-q2", answer: "Praksis" }, + { questionId: "interview-schema-native-conduct-0063-q3", answer: ["Samarbeid"] }, + ]; + + const payload = { answers, score: { explanatoryPower: 7, roleModel: 8, suitability: 9 } }; + + const id = "interview-recommendation-maybe", + initial = yield* step(() => get(id)); + + assert.equal(initial.status, 200); + const etag = initial.headers.get("etag")!; + + const lifecycleSnapshot = Effect.fnUntraced(function* () { + return yield* jsonText( + (yield* step(() => + pool.query( + `SELECT (SELECT jsonb_agg(to_jsonb(c) ORDER BY c.interview_id) FROM public.recruitment_interview_conducts c) conducts,(SELECT jsonb_agg(to_jsonb(r) ORDER BY r.command_id) FROM public.recruitment_interview_lifecycle_command_receipts r) receipts,(SELECT jsonb_agg(to_jsonb(a) ORDER BY a.command_id) FROM public.recruitment_interview_lifecycle_audit a) audit,(SELECT jsonb_agg(to_jsonb(h) ORDER BY to_jsonb(h)::text) FROM public.native_http_idempotency_receipts h) native_receipts`, + ), + )).rows[0], + ); + }); + + const before = yield* lifecycleSnapshot(); + + const decodeDieCause = Schema.decodeOption( + Schema.fromJsonString(Schema.Tuple([Schema.TaggedStruct("Die", {})])), + ); + + // A recommendation outside its schema fails in the RPC server before the handler, as a defect + // (500) whose cause is a Die, where the HTTP route answered validation.failed (422). + for (const [i, value] of [undefined, null, "invalid", 9].entries()) { + const body = value === undefined ? payload : { ...payload, recommendation: value }; + + const refused = yield* step(() => + post( + id, + body, + [fixtureKeys.invalid0, fixtureKeys.invalid1, fixtureKeys.invalid2, fixtureKeys.invalid3][ + i + ]!, + etag, + ), + ); + + const refusal = yield* step(() => refused.text()); + + assert.equal(refused.status, 500, refusal); + + assert.ok(Option.isSome(decodeDieCause(refusal)), refusal); + } + + assert.equal(yield* lifecycleSnapshot(), before); + + const first = yield* step(() => + post(id, { ...payload, recommendation: "Kanskje" }, fixtureKeys.maybe, etag), + ); + + assert.equal(first.status, 200); + const bytes = yield* step(() => first.text()); + assert.equal( + yield* step(() => + post(id, { ...payload, recommendation: "Kanskje" }, fixtureKeys.maybe, etag).then( + (response) => response.text(), ), ), + bytes, + ); + assert.equal( + (yield* step(() => post(id, { ...payload, recommendation: "Nei" }, fixtureKeys.maybe, etag))) + .status, + 409, + ); + const no = yield* step(() => get("interview-recommendation-no")); + + const race = yield* step(() => + Promise.all([ + post( + "interview-recommendation-no", + { ...payload, recommendation: "Nei" }, + fixtureKeys.raceA, + no.headers.get("etag")!, + ), + post( + "interview-recommendation-no", + { ...payload, recommendation: "Ja" }, + fixtureKeys.raceB, + no.headers.get("etag")!, + ), + ]), ); - assert.equal(race.filter((response) => response.status === 200).length, 1); - assert.equal(race.filter((response) => response.status !== 200).length, 1); - assert.ok(race.every((response) => [200, 409, 412].includes(response.status))); - const winningRaceIndex = race.findIndex((response) => response.status === 200); - expectedHistoricalReportRecommendation = winningRaceIndex === 0 ? "Ja" : "Kanskje"; - const afterRaceWrites = await writeCount(); - assert.equal(Number(afterRaceWrites.assessments), Number(beforeRaceWrites.assessments) + 1); - assert.equal(Number(afterRaceWrites.receipts), Number(beforeRaceWrites.receipts) + 1); - assert.equal(Number(afterRaceWrites.audit), Number(beforeRaceWrites.audit) + 1); - assert.equal(Number(afterRaceWrites.revision), Number(beforeRaceWrites.revision) + 1); - stage("same-revision concurrent corrections have one winner and one no-write loser"); - const finalDetail = await (await get(correctionId)).json(); - assert.equal(finalDetail.history[0]?.recommendation, null); - assert.equal(finalDetail.finalizedByPersonId, beforeBody.finalizedByPersonId); - assert.equal(finalDetail.finalizedAt, beforeBody.finalizedAt); - assert.equal(finalDetail.effectiveRevision, 6); - assert.equal(finalDetail.revision, 6); + assert.ok(race.filter((r) => r.status === 200).length === 1); + assert.ok(race.every((r) => [200, 409, 412].includes(r.status))); + + const saved = (yield* step(() => + get("interview-recommendation-no").then((response) => response.json()), + )).recommendation; + + assert.equal(saved, race[0].status === 200 ? "Nei" : "Ja"); + + // Ensure Nei has an independent exact round trip even when Ja won the concurrent race. + if (saved !== "Nei") { + const b = yield* step(() => get("interview-native-conduct-b-0063")); + assert.equal( + (yield* step(() => + post( + "interview-native-conduct-b-0063", + { ...payload, recommendation: "Nei" }, + fixtureKeys.no, + b.headers.get("etag")!, + ), + )).status, + 200, + ); + assert.equal( + (yield* step(() => + get("interview-native-conduct-b-0063").then((response) => response.json()), + )).recommendation, + "Nei", + ); + } + assert.equal( - finalDetail.history.filter((entry: any) => Predicate.isTagged(entry, "Correction")).length, - 5, + (yield* step(() => get(id).then((response) => response.json()))).recommendation, + "Kanskje", ); - assert.ok(finalDetail.canFinalize === false && finalDetail.canCancel === false); - assert.deepEqual(await effectSnapshot(), effectsBefore); - stage("corrections create no notification or application effects"); - await pool.query( - `UPDATE public.organization_memberships SET is_suspended=true WHERE membership_id='membership-native-conduct-leader-0063'`, + recordGate( + "missing/null/unknown/numeric rejected without effects; all choices roundtrip; exact replay and conflicting/concurrent writes fenced", + ); + const effectsAfterInterviewCompletions = yield* effectSnapshot(); + yield* step(() => + pool.query( + `UPDATE public.organization_memberships SET is_suspended=true WHERE membership_id='membership-native-conduct-leader-0063'`, + ), ); - assert.equal((await get(correctionId)).status, 403); + assert.equal((yield* step(() => get(id))).status, 403); assert.equal( - ( - await correctPost( - correctionId, - acceptedCorrectionRequest.exactFourFieldPayload, - acceptedCorrectionRequest.rawKey, - acceptedCorrectionRequest.originalIfMatch, - ) - ).status, + (yield* step(() => + post(id, { ...payload, recommendation: "Kanskje" }, fixtureKeys.maybe, etag), + )).status, 403, ); - await pool.query( - `UPDATE public.organization_memberships SET is_suspended=false WHERE membership_id='membership-native-conduct-leader-0063'`, + yield* step(() => + pool.query( + `UPDATE public.organization_memberships SET is_suspended=false WHERE membership_id='membership-native-conduct-leader-0063'`, + ), ); - await pool.query( - `UPDATE public.recruitment_interviews SET interviewer_person_id='recommendation-other-0101' WHERE interview_id=$1`, - [correctionId], + recordGate("suspended authority denies reads and stored receipt replay"); + const authorizationBefore = yield* lifecycleSnapshot(); + + // Change current source authority, not authentication claims or an authorization stub. + yield* step(() => + pool.query( + `UPDATE public.recruitment_interviews SET interviewer_person_id='recommendation-other-0101' WHERE interview_id=$1`, + [id], + ), ); - assert.equal((await get(correctionId)).status, 403); + assert.equal((yield* step(() => get(id))).status, 403); assert.equal( - ( - await correctPost( - correctionId, - acceptedCorrectionRequest.exactFourFieldPayload, - acceptedCorrectionRequest.rawKey, - acceptedCorrectionRequest.originalIfMatch, - ) - ).status, + (yield* step(() => + post(id, { ...payload, recommendation: "Kanskje" }, fixtureKeys.maybe, etag), + )).status, 403, ); - await pool.query( - `UPDATE public.recruitment_interviews SET interviewer_person_id='journey-conduct-leader-0063' WHERE interview_id=$1`, - [correctionId], + yield* step(() => + pool.query( + `UPDATE public.recruitment_interviews SET interviewer_person_id='journey-conduct-leader-0063' WHERE interview_id=$1`, + [id], + ), ); - stage("authority revocation denies correction detail and replay before receipt reuse"); - await pool.query( - `UPDATE public.organization_memberships SET end_at=date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC') - interval '24 hours' WHERE membership_id='membership-native-conduct-leader-0063'`, + yield* step(() => + pool.query( + `UPDATE public.organization_memberships SET end_at=date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC') - interval '24 hours' WHERE membership_id='membership-native-conduct-leader-0063'`, + ), ); - assert.equal((await get(correctionId)).status, 403); + assert.equal((yield* step(() => get(id))).status, 403); assert.equal( - ( - await correctPost( - correctionId, - acceptedCorrectionRequest.exactFourFieldPayload, - acceptedCorrectionRequest.rawKey, - acceptedCorrectionRequest.originalIfMatch, - ) - ).status, + (yield* step(() => + post(id, { ...payload, recommendation: "Kanskje" }, fixtureKeys.maybe, etag), + )).status, 403, ); - await pool.query( - `UPDATE public.organization_memberships SET end_at=NULL WHERE membership_id='membership-native-conduct-leader-0063'`, - ); - stage("ended membership denies correction detail and replay"); - const rollbackBefore = await writeCount(); - await pool.query(` - CREATE OR REPLACE FUNCTION public.test_correction_audit_failure() - RETURNS trigger LANGUAGE plpgsql AS $$ - BEGIN - RAISE EXCEPTION 'synthetic correction audit failure'; - END; - $$; - DROP TRIGGER IF EXISTS test_correction_audit_failure - ON public.recruitment_interview_correction_audit; - CREATE TRIGGER test_correction_audit_failure - AFTER INSERT ON public.recruitment_interview_correction_audit - FOR EACH ROW EXECUTE FUNCTION public.test_correction_audit_failure(); - `); - const rollbackEtag = (await get(correctionId)).headers.get("etag")!; - - const rollbackResponse = await correctPost( - correctionId, - correctionPayload(finalDetail, "Ja"), - "correction-rollback-0105", - rollbackEtag, - ); - - assert.ok([500, 503].includes(rollbackResponse.status)); - assert.deepEqual(await writeCount(), rollbackBefore); - await pool.query(` - DROP TRIGGER test_correction_audit_failure - ON public.recruitment_interview_correction_audit; - DROP FUNCTION public.test_correction_audit_failure(); - `); - stage("synthetic audit failure rolls back correction assessment, aggregate, receipt and audit"); - await assertInterviewCorrectionIntegrity(pool, correctionId); - recordGate( - "direct SQL correction integrity rejects immutable mutations, invalid predecessor, aggregate mismatch, and cross-command receipt/audit tuples without writes", + yield* step(() => + pool.query( + `UPDATE public.organization_memberships SET end_at=NULL WHERE membership_id='membership-native-conduct-leader-0063'`, + ), ); - await writeFile( - join(artifacts, "correction-targeted-evidence.json"), - JSON.stringify( - { - revision, - correctionStages, - beforeBody, - afterFirst, - afterSecond, - finalDetail, - failedSaveRetry: correctionAttempts, - }, - null, - 2, + yield* step(() => + pool.query( + `INSERT INTO public.organization_departments SELECT (jsonb_populate_record(NULL::public.organization_departments,to_jsonb(d)||'{"department_id":"department-other-recommendation-0101"}'::jsonb)).* FROM public.organization_departments d WHERE department_id='department-native-conduct-0063'`, ), ); - await page.goto(`${ui}/dashboard/intervjuer`); - } + yield* step(() => + pool.query( + `INSERT INTO public.organization_teams SELECT (jsonb_populate_record(NULL::public.organization_teams,to_jsonb(t)||'{"team_id":"team-other-recommendation-0101","department_id":"department-other-recommendation-0101"}'::jsonb)).* FROM public.organization_teams t WHERE team_id='team-native-conduct-0063'`, + ), + ); + yield* step(() => + pool.query( + `UPDATE public.organization_memberships SET team_id='team-other-recommendation-0101' WHERE membership_id='membership-native-conduct-leader-0063'`, + ), + ); + assert.equal((yield* step(() => get(id))).status, 403); + assert.equal( + (yield* step(() => + post(id, { ...payload, recommendation: "Kanskje" }, fixtureKeys.maybe, etag), + )).status, + 403, + ); + yield* step(() => + pool.query( + `UPDATE public.organization_memberships SET team_id='team-native-conduct-0063' WHERE membership_id='membership-native-conduct-leader-0063'`, + ), + ); + yield* step(() => + pool.query( + `INSERT INTO public.admission_period_departments + SELECT (jsonb_populate_record(NULL::public.admission_period_departments, + to_jsonb(d)||'{"department_id":"department-other-recommendation-0101"}'::jsonb)).* + FROM public.admission_period_departments d + WHERE d.department_id='department-native-conduct-0063' + ON CONFLICT (department_id) DO NOTHING`, + ), + ); + assert.equal(yield* lifecycleSnapshot(), authorizationBefore); + yield* step(() => + pool.query( + `INSERT INTO public.recruitment_interview_conducts + SELECT (jsonb_populate_record(NULL::public.recruitment_interview_conducts, + to_jsonb(c)||'{"interview_id":"interview-recommendation-link-race","interview_revision":1}'::jsonb)).* + FROM public.recruitment_interview_conducts c + WHERE c.interview_id='interview-native-conduct-a-0063' + ON CONFLICT (interview_id) DO NOTHING`, + ), + ); + const lifecycleBeforeCorrections = yield* lifecycleSnapshot(); - await open(page, "Sofie Gjennomfører"); - await fill(page); - assert.equal(await page.locator("#interviewer-recommendation").inputValue(), ""); - await page.locator(".fs-conduct").screenshot({ path: join(artifacts, "editable-desktop.png") }); - await auditPage(page, "editable-desktop"); - await page.setViewportSize({ width: 390, height: 844 }); - assert.ok( - (await page.locator("html").evaluate((element: HTMLElement) => element.scrollWidth)) <= - page.viewportSize().width, - ); - await page.locator(".fs-conduct").screenshot({ path: join(artifacts, "editable-mobile.png") }); - await auditPage(page, "editable-mobile"); - - // Viewport observations preserve fixed chrome; tall element captures can stitch it - // across fields that are visible when the actual viewport is scrolled. - for (const [selector, name] of [ - ["#question-interview-schema-native-conduct-0063-q0", "editable-mobile-answer-viewport"], - ["#interviewer-recommendation", "editable-mobile-recommendation-viewport"], - ] as const) { - await page - .locator(selector) - .evaluate((element: HTMLElement) => element.scrollIntoView({ block: "center" })); - await page.locator(selector).focus(); - await page.screenshot({ path: join(artifacts, `${name}.png`) }); - } + if (acceptedCorrectionReplay !== undefined && process.argv.includes("--correction-mode")) { + const boundaryResult = yield* assertInterviewCorrectionBoundaries({ + pool, + api, + origin: ui, + cookie, + interviewId: "interview-recommendation-history", + actorPersonId: "journey-conduct-leader-0063", + otherPersonId: "recommendation-other-0101", + membershipId: "membership-native-conduct-leader-0063", + selfLinkRaceInterviewId: "interview-recommendation-link-race", + acceptedReplay: acceptedCorrectionReplay, + recordGate, + }); - await page.setViewportSize({ width: 1280, height: 900 }); - await page.getByRole("button", { name: "Fullfør intervju", exact: true }).click(); - await page - .getByText("Svar på alle spørsmål, velg alle tre scorer og en anbefaling.", { exact: true }) - .waitFor(); - assert.equal(await page.locator("#score-suitability").inputValue(), "8"); - await page.locator("#interviewer-recommendation").focus(); - await page.keyboard.press("Home"); - await page.keyboard.press("ArrowDown"); - await page.keyboard.press("Enter"); - assert.equal(await page.locator("#interviewer-recommendation").inputValue(), "Ja"); - const finalResponsePromise = waitForRecruitmentOperation(page, "finalizeInterview"); - await page.getByRole("button", { name: "Fullfør intervju", exact: true }).click(); - await page.getByRole("dialog").waitFor(); - await page.screenshot({ path: join(artifacts, "confirmation.png") }); - await auditPage(page, "confirmation"); - const finalResponse = finalResponsePromise; - await page - .getByRole("dialog") - .getByRole("button", { name: "Fullfør intervju", exact: true }) - .press("Enter"); - assert.equal((await finalResponse).status(), 200); - await page.getByText("Intervjuet er fullført.", { exact: true }).waitFor(); - await page.reload(); - await open(page, "Sofie Gjennomfører"); - assert.equal(await page.locator("#interviewer-recommendation").inputValue(), "Ja"); - await page - .locator(".fs-conduct") - .screenshot({ path: join(artifacts, "recommendation-desktop.png") }); - await auditPage(page, "finalized-desktop"); - await page.setViewportSize({ width: 390, height: 844 }); - assert.ok( - (await page.locator("html").evaluate((element: HTMLElement) => element.scrollWidth)) <= - page.viewportSize().width, - ); - await page - .locator(".fs-conduct") - .screenshot({ path: join(artifacts, "recommendation-mobile.png") }); - await auditPage(page, "finalized-mobile"); - await page.setViewportSize({ width: 1280, height: 900 }); - recordGate( - "ordinary assigned member: required choice, keyboard finalization, reload and exact stale-correction conflict retention", - ); + yield* fs.writeFileString( + path.join(artifacts, "correction-boundaries.json"), + yield* indentedJsonText({ revision, ...boundaryResult }), + ); + } - const answers = [ - { questionId: "interview-schema-native-conduct-0063-q0", answer: "Et tydelig svar" }, - { questionId: "interview-schema-native-conduct-0063-q1", answer: "Teknologi" }, - { questionId: "interview-schema-native-conduct-0063-q2", answer: "Praksis" }, - { questionId: "interview-schema-native-conduct-0063-q3", answer: ["Samarbeid"] }, - ]; + // The capability travels in the payload, with no session beside it. + const applicantResponse = yield* step(() => + replacedFetch({ + origin: api, + tag: "recruitment.readInvitationResponse", + payload: { capability: invitationCapability }, + headers: { origin: ui }, + }), + ); - const payload = { answers, score: { explanatoryPower: 7, roleModel: 8, suitability: 9 } }; + assert.equal(applicantResponse.status, 200); + const applicantObservation = yield* step(() => applicantResponse.text()); + assertNoRecommendation(yield* decodeJsonValue(applicantObservation)); + assert.ok(!applicantObservation.includes("Kanskje")); - const id = "interview-recommendation-maybe", - initial = await get(id); + // The public application confirmation is an anonymous RPC. + const confirmations = nativeScriptClient(api); - assert.equal(initial.status, 200); - const etag = initial.headers.get("etag")!; + const applicationProjection = yield* step(() => + confirmations.call({}, (client) => + client["admissions.readApplicationConfirmation"]({ + applicationId: PublicApplicationIdSchema.make("application-recommendation-maybe"), + }), + ), + ); - const lifecycleSnapshot = async () => - JSON.stringify( - ( - await pool.query( - `SELECT (SELECT jsonb_agg(to_jsonb(c) ORDER BY c.interview_id) FROM public.recruitment_interview_conducts c) conducts,(SELECT jsonb_agg(to_jsonb(r) ORDER BY r.command_id) FROM public.recruitment_interview_lifecycle_command_receipts r) receipts,(SELECT jsonb_agg(to_jsonb(a) ORDER BY a.command_id) FROM public.recruitment_interview_lifecycle_audit a) audit,(SELECT jsonb_agg(to_jsonb(h) ORDER BY to_jsonb(h)::text) FROM public.native_http_idempotency_receipts h) native_receipts`, - ) - ).rows[0], + yield* step(() => confirmations.dispose()); + assert.equal(applicationProjection.status, 200); + const applicationBody = yield* jsonText(applicationProjection); + assertNoRecommendation(yield* decodeJsonValue(applicationBody)); + assert.ok(!applicationBody.includes("Kanskje")); + recordGate( + "wrong department denied; actual applicant capability projection excludes recommendation", ); - const before = await lifecycleSnapshot(); + recordGate( + "removed assignment and ended membership deny read/write/replay without lifecycle writes", + ); - for (const [i, value] of [undefined, null, "invalid", 9].entries()) { - const body = value === undefined ? payload : { ...payload, recommendation: value }; + const lifecycleBeforeSelf = yield* lifecycleSnapshot(); + assert.equal((yield* step(() => get("interview-recommendation-self"))).status, 403); assert.equal( - ( - await post( - id, - body, - [fixtureKeys.invalid0, fixtureKeys.invalid1, fixtureKeys.invalid2, fixtureKeys.invalid3][ - i - ]!, + (yield* step(() => + post( + "interview-recommendation-self", + { ...payload, recommendation: "Ja" }, + fixtureKeys.selfFinalize, etag, - ) - ).status, - 422, + ), + )).status, + 403, ); - } - - assert.equal(await lifecycleSnapshot(), before); - const first = await post(id, { ...payload, recommendation: "Kanskje" }, fixtureKeys.maybe, etag); - assert.equal(first.status, 200); - const bytes = await first.text(); - assert.equal( - await ( - await post(id, { ...payload, recommendation: "Kanskje" }, fixtureKeys.maybe, etag) - ).text(), - bytes, - ); - assert.equal( - (await post(id, { ...payload, recommendation: "Nei" }, fixtureKeys.maybe, etag)).status, - 409, - ); - const no = await get("interview-recommendation-no"); - - const race = await Promise.all([ - post( - "interview-recommendation-no", - { ...payload, recommendation: "Nei" }, - fixtureKeys.raceA, - no.headers.get("etag")!, - ), - post( - "interview-recommendation-no", - { ...payload, recommendation: "Ja" }, - fixtureKeys.raceB, - no.headers.get("etag")!, - ), - ]); - assert.ok(race.filter((r) => r.status === 200).length === 1); - assert.ok(race.every((r) => [200, 409, 412].includes(r.status))); - const saved = (await (await get("interview-recommendation-no")).json()).recommendation; - assert.equal(saved, race[0].status === 200 ? "Nei" : "Ja"); + const selfCancel = yield* step(() => + replacedFetch({ + origin: api, + tag: "recruitment.cancelInterview", + payload: { + interviewId: "interview-recommendation-self", + idempotencyKey: fixtureKeys.selfCancel, + ifMatch: etag, + }, + headers: { cookie, origin: ui }, + }), + ); - // Ensure Nei has an independent exact round trip even when Ja won the concurrent race. - if (saved !== "Nei") { - const b = await get("interview-native-conduct-b-0063"); - assert.equal( - ( - await post( - "interview-native-conduct-b-0063", - { ...payload, recommendation: "Nei" }, - fixtureKeys.no, - b.headers.get("etag")!, - ) - ).status, - 200, + assert.equal(selfCancel.status, 403); + assert.deepEqual(yield* effectSnapshot(), effectsAfterInterviewCompletions); + // This separate actual onboarding action observes its applicant-facing projection. + const onboardingToken = `onboard_${randomBytes(32).toString("hex")}`; + secrets.push(onboardingToken); + yield* step(() => + pool.query( + `INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES('identity-recommendation-no','application-recommendation-no','applicant-recommendation-no',$1,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')+interval '24 hours','Open','journey-conduct-leader-0063',date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'))`, + [createHash("sha256").update(onboardingToken).digest("hex")], + ), ); - assert.equal( - (await (await get("interview-native-conduct-b-0063")).json()).recommendation, - "Nei", + yield* step(() => + pool.query( + `INSERT INTO public.applicant_account_delivery(invitation_id,state,secret,recipient) VALUES('identity-recommendation-no','Pending',$1,'no@example.invalid')`, + [onboardingToken], + ), ); - } - assert.equal((await (await get(id)).json()).recommendation, "Kanskje"); - recordGate( - "missing/null/unknown/numeric rejected without effects; all choices roundtrip; exact replay and conflicting/concurrent writes fenced", - ); - const effectsAfterInterviewCompletions = await effectSnapshot(); - await pool.query( - `UPDATE public.organization_memberships SET is_suspended=true WHERE membership_id='membership-native-conduct-leader-0063'`, - ); - assert.equal((await get(id)).status, 403); - assert.equal( - (await post(id, { ...payload, recommendation: "Kanskje" }, fixtureKeys.maybe, etag)).status, - 403, - ); - await pool.query( - `UPDATE public.organization_memberships SET is_suspended=false WHERE membership_id='membership-native-conduct-leader-0063'`, - ); - recordGate("suspended authority denies reads and stored receipt replay"); - const authorizationBefore = await lifecycleSnapshot(); + const onboardingProjection = yield* step(() => + replacedFetch({ + origin: api, + tag: "onboarding.claim", + payload: { mode: "ExistingAccount", token: onboardingToken }, + headers: { cookie, origin: ui }, + }), + ); - // Change current source authority, not authentication claims or an authorization stub. - await pool.query( - `UPDATE public.recruitment_interviews SET interviewer_person_id='recommendation-other-0101' WHERE interview_id=$1`, - [id], - ); - assert.equal((await get(id)).status, 403); - assert.equal( - (await post(id, { ...payload, recommendation: "Kanskje" }, fixtureKeys.maybe, etag)).status, - 403, - ); - await pool.query( - `UPDATE public.recruitment_interviews SET interviewer_person_id='journey-conduct-leader-0063' WHERE interview_id=$1`, - [id], - ); - await pool.query( - `UPDATE public.organization_memberships SET end_at=date_trunc('milliseconds', CURRENT_TIMESTAMP, 'UTC') - interval '24 hours' WHERE membership_id='membership-native-conduct-leader-0063'`, - ); - assert.equal((await get(id)).status, 403); - assert.equal( - (await post(id, { ...payload, recommendation: "Kanskje" }, fixtureKeys.maybe, etag)).status, - 403, - ); - await pool.query( - `UPDATE public.organization_memberships SET end_at=NULL WHERE membership_id='membership-native-conduct-leader-0063'`, - ); - await pool.query( - `INSERT INTO public.organization_departments SELECT (jsonb_populate_record(NULL::public.organization_departments,to_jsonb(d)||'{"department_id":"department-other-recommendation-0101"}'::jsonb)).* FROM public.organization_departments d WHERE department_id='department-native-conduct-0063'`, - ); - await pool.query( - `INSERT INTO public.organization_teams SELECT (jsonb_populate_record(NULL::public.organization_teams,to_jsonb(t)||'{"team_id":"team-other-recommendation-0101","department_id":"department-other-recommendation-0101"}'::jsonb)).* FROM public.organization_teams t WHERE team_id='team-native-conduct-0063'`, - ); - await pool.query( - `UPDATE public.organization_memberships SET team_id='team-other-recommendation-0101' WHERE membership_id='membership-native-conduct-leader-0063'`, - ); - assert.equal((await get(id)).status, 403); - assert.equal( - (await post(id, { ...payload, recommendation: "Kanskje" }, fixtureKeys.maybe, etag)).status, - 403, - ); - await pool.query( - `UPDATE public.organization_memberships SET team_id='team-native-conduct-0063' WHERE membership_id='membership-native-conduct-leader-0063'`, - ); - await pool.query( - `INSERT INTO public.admission_period_departments - SELECT (jsonb_populate_record(NULL::public.admission_period_departments, - to_jsonb(d)||'{"department_id":"department-other-recommendation-0101"}'::jsonb)).* - FROM public.admission_period_departments d - WHERE d.department_id='department-native-conduct-0063' - ON CONFLICT (department_id) DO NOTHING`, - ); - assert.equal(await lifecycleSnapshot(), authorizationBefore); - await pool.query( - `INSERT INTO public.recruitment_interview_conducts - SELECT (jsonb_populate_record(NULL::public.recruitment_interview_conducts, - to_jsonb(c)||'{"interview_id":"interview-recommendation-link-race","interview_revision":1}'::jsonb)).* - FROM public.recruitment_interview_conducts c - WHERE c.interview_id='interview-native-conduct-a-0063' - ON CONFLICT (interview_id) DO NOTHING`, - ); - const lifecycleBeforeCorrections = await lifecycleSnapshot(); - - if (acceptedCorrectionReplay !== undefined && process.argv.includes("--correction-mode")) { - const boundaryResult = await assertInterviewCorrectionBoundaries({ - pool, - api, - origin: ui, - cookie, - interviewId: "interview-recommendation-history", - actorPersonId: "journey-conduct-leader-0063", - otherPersonId: "recommendation-other-0101", - membershipId: "membership-native-conduct-leader-0063", - selfLinkRaceInterviewId: "interview-recommendation-link-race", - acceptedReplay: acceptedCorrectionReplay, - recordGate, + assert.equal(onboardingProjection.status, 200); + assert.deepEqual(yield* step(() => onboardingProjection.json()), { + state: "Claimed", + departmentId: "department-native-conduct-0063", }); - - await writeFile( - join(artifacts, "correction-boundaries.json"), - JSON.stringify({ revision, ...boundaryResult }, null, 2), + const effectsAfterOnboarding = yield* effectSnapshot(); + recordGate( + "actual application confirmation and onboarding claim projections exclude recommendation; recommendation produced no effect rows", ); - } - - const applicantResponse = await fetch(`${api}/api/recruitment/invitation-response`, { - headers: { "x-recruitment-invitation-capability": invitationCapability, origin: ui }, - }); - assert.equal(applicantResponse.status, 200); - const applicantObservation = await applicantResponse.text(); - assertNoRecommendation(JSON.parse(applicantObservation)); - assert.ok(!applicantObservation.includes("Kanskje")); - - const applicationProjection = await fetch( - `${api}/api/applications/application-recommendation-maybe`, - ); + assert.equal((yield* step(() => get("interview-recommendation-no"))).status, 403); + const winner = race.findIndex((r) => r.status === 200); + assert.equal( + (yield* step(() => + post( + "interview-recommendation-no", + { ...payload, recommendation: winner === 0 ? "Nei" : "Ja" }, + winner === 0 ? fixtureKeys.raceA : fixtureKeys.raceB, + no.headers.get("etag")!, + ), + )).status, + 403, + ); - assert.equal(applicationProjection.status, 200); - const applicationBody = await applicationProjection.text(); - assertNoRecommendation(JSON.parse(applicationBody)); - assert.ok(!applicationBody.includes("Kanskje")); - recordGate( - "wrong department denied; actual applicant capability projection excludes recommendation", - ); + if (!process.argv.includes("--correction-mode")) { + yield* run("bun", ["packages/database/runtime/recommendation-domain-replay.ts"], env); + const raceRead = yield* step(() => get("interview-recommendation-link-race")); + assert.equal(raceRead.status, 200); + const locker = yield* step(() => pool.connect()); + heldIdentityClient = locker; + yield* step(() => locker.query("BEGIN")); + yield* step(() => + locker.query( + `SELECT applicant_id FROM public.admission_applicants WHERE applicant_id='applicant-recommendation-link-race' FOR UPDATE`, + ), + ); - recordGate( - "removed assignment and ended membership deny read/write/replay without lifecycle writes", - ); + const lockerPid = (yield* step(() => locker.query("SELECT pg_backend_pid() pid"))).rows[0] + .pid; - const lifecycleBeforeSelf = await lifecycleSnapshot(); - assert.equal((await get("interview-recommendation-self")).status, 403); - assert.equal( - ( - await post( - "interview-recommendation-self", + const waiting = post( + "interview-recommendation-link-race", { ...payload, recommendation: "Ja" }, - fixtureKeys.selfFinalize, - etag, - ) - ).status, - 403, - ); - - const selfCancel = await fetch( - `${api}/api/recruitment/interviews/interview-recommendation-self:cancel`, - { - method: "POST", - headers: { - cookie, - origin: ui, - "content-type": "application/json", - "if-match": etag, - "idempotency-key": fixtureKeys.selfCancel, - }, - body: "{}", - }, - ); - - assert.equal(selfCancel.status, 403); - assert.deepEqual(await effectSnapshot(), effectsAfterInterviewCompletions); - // This separate actual onboarding action observes its applicant-facing projection. - const onboardingToken = `onboard_${randomBytes(32).toString("hex")}`; - secrets.push(onboardingToken); - await pool.query( - `INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES('identity-recommendation-no','application-recommendation-no','applicant-recommendation-no',$1,date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')+interval '24 hours','Open','journey-conduct-leader-0063',date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'))`, - [createHash("sha256").update(onboardingToken).digest("hex")], - ); - await pool.query( - `INSERT INTO public.applicant_account_delivery(invitation_id,state,secret,recipient) VALUES('identity-recommendation-no','Pending',$1,'no@example.invalid')`, - [onboardingToken], - ); + fixtureKeys.linkRace, + raceRead.headers.get("etag")!, + ); - const onboardingProjection = await fetch(`${api}/api/onboarding/claim`, { - method: "POST", - headers: { cookie, origin: ui, "content-type": "application/json" }, - body: JSON.stringify({ mode: "ExistingAccount", token: onboardingToken }), - }); + yield* ready( + Effect.gen(function* () { + return ( + (yield* step(() => + pool.query( + `SELECT count(*)::int n FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid))`, + [lockerPid], + ), + )).rows[0].n > 0 + ); + }), + ); + yield* link("link-race", "journey-conduct-leader-0063", locker); + yield* step(() => locker.query("COMMIT")); + locker.release(); + heldIdentityClient = undefined; + // Finalization retries a serialization failure once (74b037e2), so the request that waited on + // the link answers with the self-denial of its retry instead of 409 transaction.conflict. + const staleIdentity = yield* step(() => waiting); + assert.equal(staleIdentity.status, 403); + assert.equal((yield* step(() => staleIdentity.json())).code, "authority.denied"); + assert.equal( + (yield* step(() => + post( + "interview-recommendation-link-race", + { ...payload, recommendation: "Ja" }, + fixtureKeys.linkRace, + raceRead.headers.get("etag")!, + ), + )).status, + 403, + ); + assert.equal((yield* step(() => get("interview-recommendation-link-race"))).status, 403); + } - assert.equal(onboardingProjection.status, 200); - assert.deepEqual(await onboardingProjection.json(), { - state: "Claimed", - departmentId: "department-native-conduct-0063", - }); - const effectsAfterOnboarding = await effectSnapshot(); - recordGate( - "actual application confirmation and onboarding claim projections exclude recommendation; recommendation produced no effect rows", - ); + const readLocker = yield* step(() => pool.connect()); + heldIdentityClient = readLocker; + yield* step(() => readLocker.query("BEGIN")); + yield* step(() => + readLocker.query( + `SELECT applicant_id FROM public.admission_applicants WHERE applicant_id='applicant-recommendation-read-race' FOR UPDATE`, + ), + ); - assert.equal((await get("interview-recommendation-no")).status, 403); - const winner = race.findIndex((r) => r.status === 200); - assert.equal( - ( - await post( - "interview-recommendation-no", - { ...payload, recommendation: winner === 0 ? "Nei" : "Ja" }, - winner === 0 ? fixtureKeys.raceA : fixtureKeys.raceB, - no.headers.get("etag")!, - ) - ).status, - 403, - ); + const readLockerPid = (yield* step(() => readLocker.query("SELECT pg_backend_pid() pid"))) + .rows[0].pid; - if (!process.argv.includes("--correction-mode")) { - run("bun", ["packages/database/runtime/recommendation-domain-replay.ts"], env); - const raceRead = await get("interview-recommendation-link-race"); - assert.equal(raceRead.status, 200); - const locker = await pool.connect(); - heldIdentityClient = locker; - await locker.query("BEGIN"); - await locker.query( - `SELECT applicant_id FROM public.admission_applicants WHERE applicant_id='applicant-recommendation-link-race' FOR UPDATE`, - ); - const lockerPid = (await locker.query("SELECT pg_backend_pid() pid")).rows[0].pid; - - const waiting = post( - "interview-recommendation-link-race", - { ...payload, recommendation: "Ja" }, - fixtureKeys.linkRace, - raceRead.headers.get("etag")!, - ); - - await ready( - async () => - ( - await pool.query( - `SELECT count(*)::int n FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid))`, - [lockerPid], - ) - ).rows[0].n > 0, + const waitingRead = get("interview-recommendation-read-race"); + yield* ready( + Effect.gen(function* () { + return ( + (yield* step(() => + pool.query( + `SELECT count(*)::int n FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid))`, + [readLockerPid], + ), + )).rows[0].n > 0 + ); + }), ); - await link("link-race", "journey-conduct-leader-0063", locker); - await locker.query("COMMIT"); - locker.release(); + yield* link("read-race", "journey-conduct-leader-0063", readLocker); + yield* step(() => readLocker.query("COMMIT")); + readLocker.release(); heldIdentityClient = undefined; - // Finalization retries a serialization failure once (74b037e2), so the request that waited on - // the link answers with the self-denial of its retry instead of 409 transaction.conflict. - const staleIdentity = await waiting; - assert.equal(staleIdentity.status, 403); - assert.equal((await staleIdentity.json()).code, "authority.denied"); - assert.equal( - ( - await post( - "interview-recommendation-link-race", - { ...payload, recommendation: "Ja" }, - fixtureKeys.linkRace, - raceRead.headers.get("etag")!, - ) - ).status, - 403, + assert.equal((yield* step(() => waitingRead)).status, 403); + assert.equal(yield* lifecycleSnapshot(), lifecycleBeforeSelf); + recordGate( + "known self denied before read/finalize/cancel and both receipt layers; different Person allowed; a finalization waiting on the identity link retries its serialization failure once and is denied as self", ); - assert.equal((await get("interview-recommendation-link-race")).status, 403); - } - - const readLocker = await pool.connect(); - heldIdentityClient = readLocker; - await readLocker.query("BEGIN"); - await readLocker.query( - `SELECT applicant_id FROM public.admission_applicants WHERE applicant_id='applicant-recommendation-read-race' FOR UPDATE`, - ); - const readLockerPid = (await readLocker.query("SELECT pg_backend_pid() pid")).rows[0].pid; - const waitingRead = get("interview-recommendation-read-race"); - await ready( - async () => - ( - await pool.query( - `SELECT count(*)::int n FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid))`, - [readLockerPid], - ) - ).rows[0].n > 0, - ); - await link("read-race", "journey-conduct-leader-0063", readLocker); - await readLocker.query("COMMIT"); - readLocker.release(); - heldIdentityClient = undefined; - assert.equal((await waitingRead).status, 403); - assert.equal(await lifecycleSnapshot(), lifecycleBeforeSelf); - recordGate( - "known self denied before read/finalize/cancel and both receipt layers; different Person allowed; a finalization waiting on the identity link retries its serialization failure once and is denied as self", - ); - let immutable = false; - try { - await pool.query( - `UPDATE public.recruitment_interview_conducts SET recommendation='Ja' WHERE interview_id='interview-recommendation-history'`, + const immutableUpdate = yield* Effect.exit( + step(() => + pool.query( + `UPDATE public.recruitment_interview_conducts SET recommendation='Ja' WHERE interview_id='interview-recommendation-history'`, + ), + ), ); - } catch { - immutable = true; - } - - assert.ok(immutable); - for (const value of [null, "wrong", ""]) { - let rejected = false; + assert.ok(Exit.isFailure(immutableUpdate)); - try { - await pool.query( - `INSERT INTO public.recruitment_interview_conducts(interview_id,answers,explanatory_power,role_model,suitability,finalized_by_person_id,finalized_at,interview_revision,recommendation) VALUES('invalid-direct-0101','[]',1,1,1,'journey-conduct-leader-0063',date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'),1,$1)`, - [value], + for (const value of [null, "wrong", ""]) { + const insert = yield* Effect.exit( + step(() => + pool.query( + `INSERT INTO public.recruitment_interview_conducts(interview_id,answers,explanatory_power,role_model,suitability,finalized_by_person_id,finalized_at,interview_revision,recommendation) VALUES('invalid-direct-0101','[]',1,1,1,'journey-conduct-leader-0063',date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'),1,$1)`, + [value], + ), + ), ); - } catch (e) { - rejected = Predicate.hasProperty(e, "code") && e.code === "23514"; - } - assert.ok(rejected); - } + const refusal = Exit.isFailure(insert) ? thrownBy(insert.cause) : undefined; - await page.reload(); - await open(page, "history Recommendation"); - const currentHistoryDetail = await (await get("interview-recommendation-history")).json(); - // A historical row keeps no recommendation until a correction records one; the select then - // shows its empty "Velg anbefaling" option. - assert.equal( - await page.locator("#interviewer-recommendation").inputValue(), - currentHistoryDetail.recommendation ?? "", - ); - assert.equal( - await page.locator("#interviewer-recommendation option:checked").textContent(), - currentHistoryDetail.recommendation ?? "Velg anbefaling", - ); - await page.locator(".fs-conduct").screenshot({ path: join(artifacts, "historical-desktop.png") }); - await auditPage(page, "historical-desktop"); - await page.setViewportSize({ width: 390, height: 844 }); - await page.locator(".fs-conduct").screenshot({ path: join(artifacts, "historical-mobile.png") }); - await auditPage(page, "historical-mobile"); - // Leadership of the department's board (classified by seedInterviewReportCoordinator). - await pool.query( - `UPDATE public.organization_memberships SET is_team_leader=true,position_id='teamleader' WHERE membership_id='membership-native-conduct-leader-0063'`, - ); - await page.setViewportSize({ width: 1280, height: 900 }); - await page.reload(); - await page.getByRole("link", { name: "Søkerkontoer", exact: true }).click(); - await page.waitForURL(/\/dashboard\/onboarding$/); - await page.getByRole("heading", { name: "Søkerkontoer", exact: true }).waitFor(); - await page.goto(`${ui}/dashboard/intervjuer`); - const controlPanel = page.getByRole("link", { name: "Kontrollpanel", exact: true }); - await controlPanel.waitFor(); - // Only a global administrator maintains interview schemas since 7472f5ea, so a board leader's - // navigation offers no schema link. - assert.equal(await page.getByRole("link", { name: "Intervjuskjema", exact: true }).count(), 0); - await controlPanel.click(); - await page.waitForURL(/\/dashboard\/?$/); - await page.getByRole("heading", { name: "Velkommen, Lina Lagleder", exact: true }).waitFor(); - await pool.query( - `UPDATE public.organization_memberships SET is_team_leader=false,position_id='member' WHERE membership_id='membership-native-conduct-leader-0063'`, - ); + assert.ok(Predicate.hasProperty(refusal, "code") && refusal.code === "23514"); + } - recordGate( - "owned interview shell retains role-scoped onboarding and dashboard navigation without the global administrators' schema link", - ); + yield* step(() => page.reload()); + yield* open(page, "history Recommendation"); - const rows = ( - await pool.query( - `SELECT interview_id,recommendation,answers,explanatory_power,role_model,suitability FROM public.recruitment_interview_conducts ORDER BY interview_id`, - ) - ).rows; + const currentHistoryDetail = yield* step(() => + get("interview-recommendation-history").then((response) => response.json()), + ); + // A historical row keeps no recommendation until a correction records one; the select then + // shows its empty "Velg anbefaling" option. - assert.equal( - rows.find((r: any) => r.interview_id === "interview-native-conduct-a-0063").recommendation, - "Ja", - ); - assert.equal( - rows.find((r: any) => r.interview_id === "interview-recommendation-history").recommendation, - null, - ); - const maybeRow = rows.find((r: any) => r.interview_id === id); - assert.deepEqual(maybeRow.answers, answers); - assert.equal(maybeRow.explanatory_power, 7); - assert.equal(maybeRow.role_model, 8); - assert.equal(maybeRow.suitability, 9); - - const lifecycle = ( - await pool.query( - `SELECT c.interview_id,c.recommendation,a.kind,a.resulting_revision,r.command_id FROM public.recruitment_interview_conducts c JOIN public.recruitment_interview_lifecycle_audit a USING(interview_id) JOIN public.recruitment_interview_lifecycle_command_receipts r ON r.command_id=a.command_id ORDER BY c.interview_id`, - ) - ).rows; - - const beforeLifecycle = Schema.decodeUnknownSync( - Schema.StructWithRest(Schema.Struct({ - conducts: Schema.Json, - receipts: Schema.Array( - Schema.StructWithRest(Schema.Struct({ command_id: Schema.String, kind: Schema.String }), [ - Schema.Record(Schema.String, Schema.Json), - ]), + assert.equal( + yield* step(() => page.locator("#interviewer-recommendation").inputValue()), + currentHistoryDetail.recommendation ?? "", + ); + assert.equal( + yield* step(() => page.locator("#interviewer-recommendation option:checked").textContent()), + currentHistoryDetail.recommendation ?? "Velg anbefaling", + ); + yield* step(() => + page + .locator(".fs-conduct") + .screenshot({ path: path.join(artifacts, "historical-desktop.png") }), + ); + yield* auditPage(page, "historical-desktop"); + yield* step(() => page.setViewportSize({ width: 390, height: 844 })); + yield* step(() => + page + .locator(".fs-conduct") + .screenshot({ path: path.join(artifacts, "historical-mobile.png") }), + ); + yield* auditPage(page, "historical-mobile"); + // Leadership of the department's board (classified by seedInterviewReportCoordinator). + yield* step(() => + pool.query( + `UPDATE public.organization_memberships SET is_team_leader=true,position_id='teamleader' WHERE membership_id='membership-native-conduct-leader-0063'`, ), - audit: Schema.Json, - }), [Schema.Record(Schema.String, Schema.Json)]), - )(JSON.parse(lifecycleBeforeCorrections)); - - const afterLifecycle = Schema.decodeUnknownSync( - Schema.StructWithRest(Schema.Struct({ - conducts: Schema.Json, - receipts: Schema.Array( - Schema.StructWithRest(Schema.Struct({ command_id: Schema.String, kind: Schema.String }), [ - Schema.Record(Schema.String, Schema.Json), - ]), + ); + yield* step(() => page.setViewportSize({ width: 1280, height: 900 })); + yield* step(() => page.reload()); + yield* step(() => page.getByRole("link", { name: "Søkerkontoer", exact: true }).click()); + yield* step(() => page.waitForURL(/\/dashboard\/onboarding$/)); + yield* step(() => page.getByRole("heading", { name: "Søkerkontoer", exact: true }).waitFor()); + yield* step(() => page.goto(`${ui}/dashboard/intervjuer`)); + const controlPanel = page.getByRole("link", { name: "Kontrollpanel", exact: true }); + yield* step(() => controlPanel.waitFor()); + // Only a global administrator maintains interview schemas since 7472f5ea, so a board leader's + // navigation offers no schema link. + assert.equal( + yield* step(() => page.getByRole("link", { name: "Intervjuskjema", exact: true }).count()), + 0, + ); + yield* step(() => controlPanel.click()); + yield* step(() => page.waitForURL(/\/dashboard\/?$/)); + yield* step(() => + page.getByRole("heading", { name: "Velkommen, Lina Lagleder", exact: true }).waitFor(), + ); + yield* step(() => + pool.query( + `UPDATE public.organization_memberships SET is_team_leader=false,position_id='member' WHERE membership_id='membership-native-conduct-leader-0063'`, ), - audit: Schema.Json, - }), [Schema.Record(Schema.String, Schema.Json)]), - )(JSON.parse(await lifecycleSnapshot())); - - assert.deepEqual(afterLifecycle.conducts, beforeLifecycle.conducts); - assert.deepEqual(afterLifecycle.receipts, beforeLifecycle.receipts); - assert.deepEqual(afterLifecycle.audit, beforeLifecycle.audit); - - const knownFinalizationCommandIds = beforeLifecycle.receipts - .values() - .filter((receipt) => receipt.kind === "InterviewFinalized") - .map((receipt) => receipt.command_id) - .toArray() - .sort(); - - assert.ok(knownFinalizationCommandIds.length > 0); - assert.deepEqual(lifecycle.map((row: any) => row.command_id).sort(), knownFinalizationCommandIds); - assert.ok(lifecycle.every((r: any) => r.kind === "InterviewFinalized")); - assert.equal(new Set(lifecycle.map((r: any) => r.interview_id)).size, lifecycle.length); - assert.deepEqual(await effectSnapshot(), effectsAfterOnboarding); - assert.deepEqual(errors, []); - assert.ok( - accessibility.every((result: any) => result.violations.length === 0), - "Accessibility violations retained in accessibility.json", - ); + ); - for (const secret of secrets) assert.ok(!JSON.stringify(logs).includes(secret)); - recordGate( - "historical immutable not-recorded display; direct storage constraints; desktop/mobile Axe; independent public-schema SQL", - ); + recordGate( + "owned interview shell retains role-scoped onboarding and dashboard navigation without the global administrators' schema link", + ); - const reportEvidence = process.argv.includes("--report") - ? await observeInterviewReport({ - root, - pool, - browser, - api, - ui, - artifacts, - ordinaryCookie: cookie, - password, - secrets, - correctionMode: process.argv.includes("--correction-mode"), - expectedHistoricalReportRecommendation, - revision, - auditPage, - recordGate, - }) - : undefined; + const rows = (yield* step(() => + pool.query( + `SELECT interview_id,recommendation,answers,explanatory_power,role_model,suitability FROM public.recruitment_interview_conducts ORDER BY interview_id`, + ), + )).rows; - assert.ok( - accessibility.every((result: any) => result.violations.length === 0), - "Report accessibility violations retained", - ); - await writeFile( - join(artifacts, "evidence.json"), - JSON.stringify( - { - revision, - reportEvidence, - gates, - rows, - lifecycle, - pageErrors: errors, - observer: "independent PostgreSQL connection", - noNotificationEffects: true, - }, + assert.equal( + rows.find((r: any) => r.interview_id === "interview-native-conduct-a-0063").recommendation, + "Ja", + ); + assert.equal( + rows.find((r: any) => r.interview_id === "interview-recommendation-history").recommendation, null, - 2, - ), - ); + ); + const maybeRow = rows.find((r: any) => r.interview_id === id); + assert.deepEqual(maybeRow.answers, answers); + assert.equal(maybeRow.explanatory_power, 7); + assert.equal(maybeRow.role_model, 8); + assert.equal(maybeRow.suitability, 9); + + const lifecycle = (yield* step(() => + pool.query( + `SELECT c.interview_id,c.recommendation,a.kind,a.resulting_revision,r.command_id FROM public.recruitment_interview_conducts c JOIN public.recruitment_interview_lifecycle_audit a USING(interview_id) JOIN public.recruitment_interview_lifecycle_command_receipts r ON r.command_id=a.command_id ORDER BY c.interview_id`, + ), + )).rows; + + const beforeLifecycle = yield* Schema.decodeUnknownEffect( + Schema.StructWithRest( + Schema.Struct({ + conducts: Schema.Json, + receipts: Schema.Array( + Schema.StructWithRest( + Schema.Struct({ command_id: Schema.String, kind: Schema.String }), + [Schema.Record(Schema.String, Schema.Json)], + ), + ), + audit: Schema.Json, + }), + [Schema.Record(Schema.String, Schema.Json)], + ), + )(yield* decodeJsonText(lifecycleBeforeCorrections)); + + const afterLifecycle = yield* Schema.decodeUnknownEffect( + Schema.StructWithRest( + Schema.Struct({ + conducts: Schema.Json, + receipts: Schema.Array( + Schema.StructWithRest( + Schema.Struct({ command_id: Schema.String, kind: Schema.String }), + [Schema.Record(Schema.String, Schema.Json)], + ), + ), + audit: Schema.Json, + }), + [Schema.Record(Schema.String, Schema.Json)], + ), + )(yield* decodeJsonText(yield* lifecycleSnapshot())); - for (const entry of await readdir(artifacts, { withFileTypes: true })) { - if (entry.isFile() && !entry.name.endsWith(".png")) { - const text = await readFile(join(artifacts, entry.name), "utf8"); + assert.deepEqual(afterLifecycle.conducts, beforeLifecycle.conducts); + assert.deepEqual(afterLifecycle.receipts, beforeLifecycle.receipts); + assert.deepEqual(afterLifecycle.audit, beforeLifecycle.audit); - for (const secret of secrets) - assert.ok(!text.includes(secret), `retained artifact ${entry.name} contains a credential`); - } - } + const knownFinalizationCommandIds = beforeLifecycle.receipts + .values() + .filter((receipt) => receipt.kind === "InterviewFinalized") + .map((receipt) => receipt.command_id) + .toArray() + .sort(); - // oxlint-effect-plugin allow(no-ambient-console): dev only: EX-0009: sanitized local acceptance artifact location. - console.log(JSON.stringify({ result: "Passed", revision, artifacts, gates })); -} catch (error) { - if (error instanceof ReturningLoginProbeComplete) { - // oxlint-effect-plugin allow(no-ambient-console): dev only: EX-0009: emit the bounded local login-probe result. - console.log( - JSON.stringify({ - result: "ReturningLoginProbe", - revision, - artifacts, - gates, - probe: error.result, - }), - ); - } else if (error instanceof ReturningTargetedComplete) { - // oxlint-effect-plugin allow(no-ambient-console): dev only: EX-0009: emit the bounded returning journey result. - console.log( - JSON.stringify({ - result: "ReturningTargeted", - revision, - artifacts, - gates, - journey: error.result, - }), + assert.ok(knownFinalizationCommandIds.length > 0); + assert.deepEqual( + lifecycle.map((row: any) => row.command_id).sort(), + knownFinalizationCommandIds, ); - } else if (error instanceof ApplicantProgressTargetedComplete) { - // oxlint-effect-plugin allow(no-ambient-console): dev only: EX-0009: emit the bounded applicant-progress result. - console.log( - JSON.stringify({ - result: "ApplicantProgressTargeted", - revision, - artifacts, - gates, - journey: error.result, - }), + assert.ok(lifecycle.every((r: any) => r.kind === "InterviewFinalized")); + assert.equal(new Set(lifecycle.map((r: any) => r.interview_id)).size, lifecycle.length); + assert.deepEqual(yield* effectSnapshot(), effectsAfterOnboarding); + assert.deepEqual(errors, []); + assert.ok( + accessibility.every((result: any) => result.violations.length === 0), + "Accessibility violations retained in accessibility.json", ); - } else if (error instanceof CoInterviewerTargetedComplete) { - // oxlint-effect-plugin allow(no-ambient-console): dev only: EX-0009: emit the bounded co-interviewer result. - console.log( - JSON.stringify({ - result: "CoInterviewerTargeted", - revision, - artifacts, - gates, - journey: error.result, - }), + + for (const secret of secrets) assert.ok(!(yield* jsonText(logs)).includes(secret)); + recordGate( + "historical immutable not-recorded display; direct storage constraints; desktop/mobile Axe; independent public-schema SQL", + ); + + const reportEvidence = process.argv.includes("--report") + ? yield* observeInterviewReport({ + root, + pool, + browser, + api, + ui, + artifacts, + ordinaryCookie: cookie, + password, + secrets, + correctionMode: process.argv.includes("--correction-mode"), + expectedHistoricalReportRecommendation, + revision, + auditPage, + recordGate, + }) + : undefined; + + assert.ok( + accessibility.every((result: any) => result.violations.length === 0), + "Report accessibility violations retained", ); - } else if (error instanceof CorrectionTargetedComplete) { - // oxlint-effect-plugin allow(no-ambient-console): dev only: EX-0009: emit the bounded correction result. - console.log( - JSON.stringify({ - result: "CorrectionTargeted", + yield* fs.writeFileString( + path.join(artifacts, "evidence.json"), + yield* indentedJsonText({ revision, - artifacts, + reportEvidence, gates, - journey: error.result, + rows, + lifecycle, + pageErrors: errors, + observer: "independent PostgreSQL connection", + noNotificationEffects: true, }), ); - } else { - let detail = error instanceof Error ? (error.stack ?? error.message) : String(error); - const activeQueries = pool - ? await pool - .query( - `SELECT pid,state,wait_event_type,wait_event,left(query,240) AS query + yield* assertArtifactsRedacted; + + return { result: "Passed" } satisfies Outcome; + }); + + /** Writes the redacted failure evidence while the cluster and browser still answer, then fails. */ + const reportFailure = (cause: Cause.Cause) => + Effect.gen(function* () { + const error = thrownBy(cause); + let detail = error.stack ?? error.message; + + const activeQueries: ReadonlyArray = + pool === undefined + ? [] + : yield* step(() => + pool.query( + `SELECT pid,state,wait_event_type,wait_event,left(query,240) AS query FROM pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid() ORDER BY pid`, - ) - .then((result: { rows: unknown[] }) => result.rows) - .catch(() => []) - : []; + ), + ).pipe( + Effect.map((result: { rows: unknown[] }) => result.rows), + Effect.orElseSucceed(() => []), + ); - detail += ` Active PostgreSQL queries: ${JSON.stringify(activeQueries)}`; + detail += ` Active PostgreSQL queries: ${yield* jsonText(activeQueries)}`; - if (page) - detail += ` Current page: ${await page - .locator("body") - .innerText() - .catch(() => "unavailable")}`; + if (page !== undefined) + detail += ` Current page: ${yield* step(() => page.locator("body").innerText()).pipe( + Effect.orElseSucceed(() => "unavailable"), + )}`; - const safe = (value: string) => - secrets.reduce((result, secret) => result.replaceAll(secret, "[redacted]"), value); + const safe = (value: string) => + secrets.reduce((result, secret) => result.replaceAll(secret, "[redacted]"), value); - detail = safe(detail); + detail = safe(detail); - const returningOutbox = pool - ? await pool - .query( - `SELECT effect_id,command_id,effect_type,ordinal,status,attempts,claimed_at,last_failure_tag,origin + const returningOutbox: ReadonlyArray = + pool === undefined + ? [] + : yield* step(() => + pool.query( + `SELECT effect_id,command_id,effect_type,ordinal,status,attempts,claimed_at,last_failure_tag,origin FROM public.admission_application_outbox WHERE origin='ReturningAssistant' ORDER BY effect_id`, - ) - .then((result: { rows: unknown[] }) => result.rows) - .catch(() => []) - : []; - - const returningEffectCalls = effectCalls - .values() - .filter((call) => call.origin === "ReturningAssistant") - .map(({ effectId, commandId, kind, status }) => ({ effectId, commandId, kind, status })) - .toArray(); - - const failureEvidence = { - result: "Failed", - revision, - gates, - detail, - logs: logs.map(safe), - returningOutbox, - returningEffectCalls, - }; + ), + ).pipe( + Effect.map((result: { rows: unknown[] }) => result.rows), + Effect.orElseSucceed(() => []), + ); - await writeFile(join(artifacts, "failure.json"), JSON.stringify(failureEvidence, null, 2)); - await writeFile(join(artifacts, "runtime.log"), `${logs.map(safe).join("")}${detail}\n`); - // oxlint-effect-plugin allow(no-ambient-console): dev only: EX-0009: redacted local rehearsal failure evidence. - console.error(JSON.stringify({ ...failureEvidence, artifacts })); - process.exitCode = 1; - } -} finally { - await browser?.close(); + const returningEffectCalls = effectCalls + .values() + .filter((call) => call.origin === "ReturningAssistant") + .map(({ effectId, commandId, kind, status }) => ({ effectId, commandId, kind, status })) + .toArray(); - if (effectServer !== undefined) { - const closed = Promise.withResolvers(); - effectServer.close(() => closed.resolve()); - await closed.promise; - } + const failureEvidence = { + result: "Failed", + revision, + gates, + detail, + logs: logs.map(safe), + returningOutbox, + returningEffectCalls, + }; - if (heldIdentityClient) { - await heldIdentityClient.query("ROLLBACK"); - heldIdentityClient.release(); - } + yield* fs.writeFileString( + path.join(artifacts, "failure.json"), + yield* indentedJsonText(failureEvidence), + ); + yield* fs.writeFileString( + path.join(artifacts, "runtime.log"), + `${logs.map(safe).join("")}${detail}\n`, + ); + yield* Console.error(yield* jsonText({ ...failureEvidence, artifacts })); - await pool?.end(); + return yield* new RecommendationFailed({ message: detail }); + }); - for (const child of children.reverse()) await stopOwnedProcess(child); - await postgres?.stop(); -} + yield* Effect.scoped( + recommendationJourney.pipe( + Effect.tap((outcome: Outcome) => + jsonText({ result: outcome.result, revision, artifacts, gates, ...outcome.report }).pipe( + Effect.map(say), + ), + ), + Effect.catchCause(reportFailure), + ), + ).pipe(Effect.ensuring(flushOutput)); +}); + +BunRuntime.runMain( + Effect.scoped(program).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer)), + ), +); diff --git a/tools/acceptance/recommendation-preupgrade-fixture.ts b/tools/acceptance/recommendation-preupgrade-fixture.ts index 975eb97ff..42af0d33d 100644 --- a/tools/acceptance/recommendation-preupgrade-fixture.ts +++ b/tools/acceptance/recommendation-preupgrade-fixture.ts @@ -1,4 +1,4 @@ -import { Record as Rec, Schema } from "effect"; +import { Effect, Record as Rec, Schema } from "effect"; import assert from "node:assert/strict"; import type { Pool, PoolClient } from "pg"; @@ -15,6 +15,7 @@ import { sha256Hex, } from "../../packages/domain/src/shared-kernel/index.js"; import type { PostgresQueryable } from "./postgres-observation.js"; +import { committed, step, withPoolClient } from "./journey-step.ts"; const leaderPersonId = "journey-conduct-leader-0063"; @@ -149,19 +150,21 @@ export const coInterviewerCorrection0106IdentitySeeds = [ export type CoInterviewerCorrection0106Fixture = typeof coInterviewerCorrection0106Fixture; -const clone = async ( +const clone = Effect.fnUntraced(function* ( client: PoolClient, table: string, where: string, overrides: Schema.JsonObject, -): Promise => { - await client.query( - `INSERT INTO public.${table} +) { + yield* step(() => + client.query( + `INSERT INTO public.${table} SELECT (jsonb_populate_record(NULL::public.${table}, to_jsonb(source) || $1::jsonb)).* FROM public.${table} source WHERE ${where}`, - [JSON.stringify(overrides)], + [canonicalJson(overrides)], + ), ); -}; +}); const lifecycleRows = (interviewId: string) => { if (interviewId === fixtureIds.cancelled) { @@ -223,46 +226,51 @@ const lifecycleRows = (interviewId: string) => { }; }; -const insertLifecycleRows = async (client: PoolClient, interviewId: string): Promise => { +const insertLifecycleRows = Effect.fnUntraced(function* (client: PoolClient, interviewId: string) { const row = lifecycleRows(interviewId); const digest = sha256Hex(canonicalJsonBytes(row.command)); - await client.query( - `INSERT INTO public.recruitment_interview_lifecycle_command_receipts + yield* step(() => + client.query( + `INSERT INTO public.recruitment_interview_lifecycle_command_receipts (command_id, command_sha256, command_json, observation_json, kind, interview_id, resulting_revision, committed_at) VALUES ($1, $2, $3::jsonb, $4::jsonb, $5, $6, $7, $8)`, - [ - row.command.commandId, - digest, - canonicalJson(row.command), - canonicalJson(row.observation), - row.kind, - interviewId, - row.resultingRevision, - row.occurredAt, - ], + [ + row.command.commandId, + digest, + canonicalJson(row.command), + canonicalJson(row.observation), + row.kind, + interviewId, + row.resultingRevision, + row.occurredAt, + ], + ), ); - await client.query( - `INSERT INTO public.recruitment_interview_lifecycle_audit + yield* step(() => + client.query( + `INSERT INTO public.recruitment_interview_lifecycle_audit (command_id, interview_id, kind, actor_person_id, resulting_revision, occurred_at) VALUES ($1, $2, $3, $4, $5, $6)`, - [ - row.command.commandId, - interviewId, - row.kind, - leaderPersonId, - row.resultingRevision, - row.occurredAt, - ], + [ + row.command.commandId, + interviewId, + row.kind, + leaderPersonId, + row.resultingRevision, + row.occurredAt, + ], + ), ); -}; +}); const recordIds = Object.values(fixtureIds); -export const readInterviewCorrectionPre0039Snapshot = async ( +export const readInterviewCorrectionPre0039Snapshot = Effect.fnUntraced(function* ( connection: PostgresQueryable, -): Promise => { - const result = await connection.query( - `SELECT +) { + const result = yield* step(() => + connection.query( + `SELECT COALESCE((SELECT jsonb_agg(to_jsonb(entry) - 'co_interviewer_person_id' ORDER BY entry.interview_id) FROM public.recruitment_interviews entry WHERE entry.interview_id = ANY($1::text[])), '[]'::jsonb) AS interviews, COALESCE((SELECT jsonb_agg(to_jsonb(entry) ORDER BY entry.interview_id) @@ -281,238 +289,257 @@ export const readInterviewCorrectionPre0039Snapshot = async ( FROM public.recruitment_interview_lifecycle_command_receipts entry WHERE entry.interview_id = ANY($1::text[])), '[]'::jsonb) AS "lifecycleReceipts", COALESCE((SELECT jsonb_agg(to_jsonb(entry) ORDER BY entry.interview_id, entry.command_id) FROM public.recruitment_interview_lifecycle_audit entry WHERE entry.interview_id = ANY($1::text[])), '[]'::jsonb) AS "lifecycleAudits"`, - [recordIds], + [recordIds], + ), ); assert.equal(result.rows.length, 1); - return Schema.decodeUnknownSync(InterviewCorrectionPre0039SnapshotSchema)(result.rows[0]); -}; + return yield* Schema.decodeUnknownEffect(InterviewCorrectionPre0039SnapshotSchema)( + result.rows[0], + ); +}); -export const seedInterviewCorrectionPre0039Fixture = async ({ +export const seedInterviewCorrectionPre0039Fixture = Effect.fnUntraced(function* ({ pool, }: { readonly pool: Pool; -}): Promise => { - const base = await pool.query( - `SELECT count(*)::int AS count FROM public.recruitment_interviews WHERE interview_id = $1`, - [baseInterviewId], +}) { + const base = yield* step(() => + pool.query( + `SELECT count(*)::int AS count FROM public.recruitment_interviews WHERE interview_id = $1`, + [baseInterviewId], + ), ); assert.equal(base.rows[0]?.count, 1, "native conduct seed must run before the 0105 fixture"); - const client = await pool.connect(); - - try { - await client.query("BEGIN"); - - try { - for (const key of Rec.keys(fixtureIds)) { - const interviewId = fixtureIds[key]; - - if (key === "historicalNull") continue; - const applicantId = fixtureApplicants[key]; - const applicationId = fixtureApplications[key]; - const invitationId = fixtureInvitations[key]; - await clone( - client, - "admission_applicants", - "applicant_id='applicant-native-conduct-a-0063'", - { - applicant_id: applicantId, - email: `${key}.correction@example.invalid`, - normalized_email: `${key}.correction@example.invalid`, - first_name: `Correction ${key}`, - }, - ); - await clone( - client, - "admission_applications", - "application_id='application-native-conduct-a-0063'", - { - application_id: applicationId, - applicant_id: applicantId, - }, - ); - await clone(client, "recruitment_interviews", `interview_id='${baseInterviewId}'`, { - interview_id: interviewId, - application_id: applicationId, - }); - await clone( - client, - "recruitment_interview_schedules", - `interview_id='${baseInterviewId}'`, - { - interview_id: interviewId, - }, - ); - await clone( - client, - "recruitment_invitations", - "invitation_id='invitation-native-conduct-a-0063'", - { - invitation_id: invitationId, - interview_id: interviewId, - capability_sha256: `${({ explicit: "e", historicalNull: "a", unfinished: "b", cancelled: "c" } as const)[key]}${"f".repeat(63)}`, - }, - ); - await clone( - client, - "recruitment_invitation_response_audit", - "invitation_id='invitation-native-conduct-a-0063'", - { - invitation_id: invitationId, + yield* withPoolClient(pool, (client) => + committed( + client, + Effect.gen(function* () { + for (const key of Rec.keys(fixtureIds)) { + const interviewId = fixtureIds[key]; + + if (key === "historicalNull") continue; + const applicantId = fixtureApplicants[key]; + const applicationId = fixtureApplications[key]; + const invitationId = fixtureInvitations[key]; + yield* clone( + client, + "admission_applicants", + "applicant_id='applicant-native-conduct-a-0063'", + { + applicant_id: applicantId, + email: `${key}.correction@example.invalid`, + normalized_email: `${key}.correction@example.invalid`, + first_name: `Correction ${key}`, + }, + ); + yield* clone( + client, + "admission_applications", + "application_id='application-native-conduct-a-0063'", + { + application_id: applicationId, + applicant_id: applicantId, + }, + ); + yield* clone(client, "recruitment_interviews", `interview_id='${baseInterviewId}'`, { interview_id: interviewId, - }, - ); - await clone( - client, - "recruitment_interview_question_snapshots", - `interview_id='${baseInterviewId}'`, - { - interview_id: interviewId, - }, - ); - } - - await client.query( - `INSERT INTO public.recruitment_interview_conducts + application_id: applicationId, + }); + yield* clone( + client, + "recruitment_interview_schedules", + `interview_id='${baseInterviewId}'`, + { + interview_id: interviewId, + }, + ); + yield* clone( + client, + "recruitment_invitations", + "invitation_id='invitation-native-conduct-a-0063'", + { + invitation_id: invitationId, + interview_id: interviewId, + capability_sha256: `${({ explicit: "e", historicalNull: "a", unfinished: "b", cancelled: "c" } as const)[key]}${"f".repeat(63)}`, + }, + ); + yield* clone( + client, + "recruitment_invitation_response_audit", + "invitation_id='invitation-native-conduct-a-0063'", + { + invitation_id: invitationId, + interview_id: interviewId, + }, + ); + yield* clone( + client, + "recruitment_interview_question_snapshots", + `interview_id='${baseInterviewId}'`, + { + interview_id: interviewId, + }, + ); + } + + yield* step(() => + client.query( + `INSERT INTO public.recruitment_interview_conducts (interview_id, answers, explanatory_power, role_model, suitability, recommendation, finalized_by_person_id, finalized_at, interview_revision) VALUES ($1, $3::jsonb, 4, 5, 6, 'Ja', $2, $4, 2)`, - [ - fixtureIds.explicit, - leaderPersonId, - canonicalJson(fixtureAnswers), - fixtureTimestamps.explicitFinalizedAt, - ], - ); - await client.query( - `UPDATE public.recruitment_interviews SET revision = 2 WHERE interview_id = $1`, - [fixtureIds.explicit], - ); - await client.query( - `UPDATE public.recruitment_interviews SET revision = 2 WHERE interview_id = $1`, - [fixtureIds.cancelled], - ); - await client.query( - `INSERT INTO public.recruitment_interview_cancellations + [ + fixtureIds.explicit, + leaderPersonId, + canonicalJson(fixtureAnswers), + fixtureTimestamps.explicitFinalizedAt, + ], + ), + ); + yield* step(() => + client.query( + `UPDATE public.recruitment_interviews SET revision = 2 WHERE interview_id = $1`, + [fixtureIds.explicit], + ), + ); + yield* step(() => + client.query( + `UPDATE public.recruitment_interviews SET revision = 2 WHERE interview_id = $1`, + [fixtureIds.cancelled], + ), + ); + yield* step(() => + client.query( + `INSERT INTO public.recruitment_interview_cancellations (interview_id, cancelled_by_person_id, cancelled_at, interview_revision) VALUES ($1, $2, $3, 2)`, - [fixtureIds.cancelled, leaderPersonId, fixtureTimestamps.cancelledAt], - ); - await insertLifecycleRows(client, fixtureIds.explicit); - await insertLifecycleRows(client, fixtureIds.cancelled); - await client.query("COMMIT"); - } catch (cause) { - await client.query("ROLLBACK"); - throw cause; - } - } finally { - client.release(); - } + [fixtureIds.cancelled, leaderPersonId, fixtureTimestamps.cancelledAt], + ), + ); + yield* insertLifecycleRows(client, fixtureIds.explicit); + yield* insertLifecycleRows(client, fixtureIds.cancelled); + }), + ), + ); - const before0039 = await readInterviewCorrectionPre0039Snapshot(pool); + const before0039 = yield* readInterviewCorrectionPre0039Snapshot(pool); assert.equal(before0039.conducts.length, 2); assert.equal(before0039.cancellations.length, 1); assert.equal(before0039.lifecycleReceipts.length, 2); assert.equal(before0039.lifecycleAudits.length, 2); return { ids: interviewCorrectionPre0039Fixture, before0039 }; -}; +}); -export const assertInterviewCorrectionPre0039Preserved = async ( - connection: PostgresQueryable, - fixture: InterviewCorrectionPre0039Fixture, -): Promise => { - const after0039 = await readInterviewCorrectionPre0039Snapshot(connection); +export const assertInterviewCorrectionPre0039Preserved = Effect.fnUntraced(function* ({ + connection, + fixture, +}: { + readonly connection: PostgresQueryable; + readonly fixture: InterviewCorrectionPre0039Fixture; +}) { + const after0039 = yield* readInterviewCorrectionPre0039Snapshot(connection); assert.deepEqual( after0039, fixture.before0039, "migration changed pre-existing recruitment records", ); -}; +}); -export const seedCoInterviewerCorrection0106Fixture = async ({ +export const seedCoInterviewerCorrection0106Fixture = Effect.fnUntraced(function* ({ pool, }: { readonly pool: Pool; -}): Promise => { +}) { const fixture = coInterviewerCorrection0106Fixture; - const client = await pool.connect(); - - try { - await client.query("BEGIN"); - - try { - const identities = await client.query( - `SELECT person_id AS "personId" + yield* withPoolClient(pool, (client) => + committed( + client, + Effect.gen(function* () { + const identities = yield* step(() => + client.query( + `SELECT person_id AS "personId" FROM public.person_profiles WHERE person_id = ANY($1::text[]) ORDER BY person_id`, - [[fixture.coInterviewer.personId, fixture.unassignedMember.personId].sort()], - ); + [[fixture.coInterviewer.personId, fixture.unassignedMember.personId].sort()], + ), + ); - assert.deepEqual( - identities.rows.map((row) => row.personId), - [fixture.coInterviewer.personId, fixture.unassignedMember.personId].sort(), - "0106 identity seed must create both ordinary native Persons before designation", - ); + assert.deepEqual( + identities.rows.map((row) => row.personId), + [fixture.coInterviewer.personId, fixture.unassignedMember.personId].sort(), + "0106 identity seed must create both ordinary native Persons before designation", + ); - const sourceMembership = await client.query( - `SELECT membership.membership_id AS "membershipId", membership.person_id AS "personId", + const sourceMembership = yield* step(() => + client.query( + `SELECT membership.membership_id AS "membershipId", membership.person_id AS "personId", membership.is_team_leader AS "isTeamLeader", membership.is_suspended AS "isSuspended", membership.end_at AS "endAt", team.team_id AS "teamId", team.department_id AS "departmentId" FROM public.organization_memberships AS membership JOIN public.organization_teams AS team USING(team_id) WHERE membership.membership_id = $1`, - [fixture.primaryMembershipId], - ); - - assert.deepEqual(sourceMembership.rows, [ - { - membershipId: fixture.primaryMembershipId, - personId: fixture.primaryPersonId, - isTeamLeader: false, - isSuspended: false, - endAt: null, - teamId: fixture.teamId, - departmentId: fixture.departmentId, - }, - ]); - - const existingMemberships = await client.query( - `SELECT membership_id AS "membershipId" - FROM public.organization_memberships - WHERE membership_id = ANY($1::text[]) - ORDER BY membership_id`, - [[fixture.coInterviewer.membershipId, fixture.unassignedMember.membershipId].sort()], - ); - - assert.equal(existingMemberships.rows.length, 0, "0106 fixture memberships must be fresh"); + [fixture.primaryMembershipId], + ), + ); - for (const person of [fixture.coInterviewer, fixture.unassignedMember]) { - await clone(client, "person_contact_profiles", `person_id='${fixture.primaryPersonId}'`, { - person_id: person.personId, - email: person.email, - }); - await clone( - client, - "organization_memberships", - `membership_id='${fixture.primaryMembershipId}'`, + assert.deepEqual(sourceMembership.rows, [ { - membership_id: person.membershipId, - person_id: person.personId, - is_team_leader: false, - position_id: "member", - is_suspended: false, - revision: 0, + membershipId: fixture.primaryMembershipId, + personId: fixture.primaryPersonId, + isTeamLeader: false, + isSuspended: false, + endAt: null, + teamId: fixture.teamId, + departmentId: fixture.departmentId, }, + ]); + + const existingMemberships = yield* step(() => + client.query( + `SELECT membership_id AS "membershipId" + FROM public.organization_memberships + WHERE membership_id = ANY($1::text[]) + ORDER BY membership_id`, + [[fixture.coInterviewer.membershipId, fixture.unassignedMember.membershipId].sort()], + ), ); - } - await client.query( - `INSERT INTO public.admission_period_departments + assert.equal(existingMemberships.rows.length, 0, "0106 fixture memberships must be fresh"); + + for (const person of [fixture.coInterviewer, fixture.unassignedMember]) { + yield* clone( + client, + "person_contact_profiles", + `person_id='${fixture.primaryPersonId}'`, + { + person_id: person.personId, + email: person.email, + }, + ); + yield* clone( + client, + "organization_memberships", + `membership_id='${fixture.primaryMembershipId}'`, + { + membership_id: person.membershipId, + person_id: person.personId, + is_team_leader: false, + position_id: "member", + is_suspended: false, + revision: 0, + }, + ); + } + + yield* step(() => + client.query( + `INSERT INTO public.admission_period_departments SELECT (jsonb_populate_record( NULL::public.admission_period_departments, to_jsonb(department) || jsonb_build_object('department_id', $1::text) @@ -520,70 +547,72 @@ export const seedCoInterviewerCorrection0106Fixture = async ({ FROM public.admission_period_departments AS department WHERE department.department_id=$2 ON CONFLICT (department_id) DO NOTHING`, - [fixture.differentDepartmentId, fixture.departmentId], - ); + [fixture.differentDepartmentId, fixture.departmentId], + ), + ); - const candidates = await client.query( - `SELECT interview_id AS "interviewId", interviewer_person_id AS "interviewerPersonId", + const candidates = yield* step(() => + client.query( + `SELECT interview_id AS "interviewId", interviewer_person_id AS "interviewerPersonId", co_interviewer_person_id AS "coInterviewerPersonId" FROM public.recruitment_interviews WHERE interview_id = ANY($1::text[]) ORDER BY interview_id`, - [[fixture.targetInterviewId, fixture.selfLinkRaceInterviewId].sort()], - ); - - assert.equal( - candidates.rows.length, - 2, - "0106 fixture needs completed and self-link race interviews", - ); + [[fixture.targetInterviewId, fixture.selfLinkRaceInterviewId].sort()], + ), + ); - for (const row of candidates.rows) { - assert.equal(row.interviewerPersonId, fixture.primaryPersonId); assert.equal( - row.coInterviewerPersonId, - null, - "pre-0040 fixture rows must remain undesignated before the synthetic 0106 designation", + candidates.rows.length, + 2, + "0106 fixture needs completed and self-link race interviews", ); - } - const designated = await client.query( - `UPDATE public.recruitment_interviews + for (const row of candidates.rows) { + assert.equal(row.interviewerPersonId, fixture.primaryPersonId); + assert.equal( + row.coInterviewerPersonId, + null, + "pre-0040 fixture rows must remain undesignated before the synthetic 0106 designation", + ); + } + + const designated = yield* step(() => + client.query( + `UPDATE public.recruitment_interviews SET co_interviewer_person_id=$1 WHERE interview_id = ANY($2::text[]) RETURNING interview_id AS "interviewId", co_interviewer_person_id AS "coInterviewerPersonId"`, - [ - fixture.coInterviewer.personId, - [fixture.targetInterviewId, fixture.selfLinkRaceInterviewId], - ], - ); - - assert.deepEqual( - designated.rows.sort((left, right) => - String(left.interviewId).localeCompare(String(right.interviewId)), - ), - [fixture.targetInterviewId, fixture.selfLinkRaceInterviewId].sort().map((interviewId) => ({ - interviewId, - coInterviewerPersonId: fixture.coInterviewer.personId, - })), - ); - await clone( - client, - "recruitment_interview_conducts", - `interview_id='${fixtureIds.explicit}'`, - { - interview_id: fixture.selfLinkRaceInterviewId, - interview_revision: 1, - }, - ); - await client.query("COMMIT"); - } catch (cause) { - await client.query("ROLLBACK"); - throw cause; - } - } finally { - client.release(); - } + [ + fixture.coInterviewer.personId, + [fixture.targetInterviewId, fixture.selfLinkRaceInterviewId], + ], + ), + ); + + assert.deepEqual( + designated.rows.sort((left, right) => + String(left.interviewId).localeCompare(String(right.interviewId)), + ), + [fixture.targetInterviewId, fixture.selfLinkRaceInterviewId] + .sort() + .map((interviewId) => ({ + interviewId, + coInterviewerPersonId: fixture.coInterviewer.personId, + })), + ); + yield* clone( + client, + "recruitment_interview_conducts", + `interview_id='${fixtureIds.explicit}'`, + { + interview_id: fixture.selfLinkRaceInterviewId, + interview_revision: 1, + }, + ); + }), + ), + ); return fixture; -}; +}); diff --git a/tools/acceptance/returning-assistant-journey.ts b/tools/acceptance/returning-assistant-journey.ts index 49f283f0c..ebb41eda6 100644 --- a/tools/acceptance/returning-assistant-journey.ts +++ b/tools/acceptance/returning-assistant-journey.ts @@ -1,24 +1,23 @@ -import { type RecruitmentInvitationDeliveryResult } from "../../packages/database/src/recruitment/index.js"; -import { NativeProblem } from "../../packages/http-api/src/http-semantics.js"; -import { AdmissionsRegisterReturningAssistantProblem } from "../../packages/http-api/src/endpoint-problems.js"; +import type { RecruitmentInvitationDeliveryResult } from "../../packages/database/src/recruitment/outbox.js"; +import { isNativeRpcPath, nativeRpcPath } from "../../packages/rpc/src/api.js"; +import { IdempotencyKey, NativeProblem } from "../../packages/rpc/src/problem.js"; +import { nativeScriptClient } from "../../packages/rpc/src/script-client.js"; import { RecruitmentInterviewResource, ScheduleInterviewResponse, FinalizeInterviewResponse, -} from "../../packages/http-api/src/v2-schemas.js"; +} from "../../packages/rpc/src/v2-schemas.js"; import { RecruitmentInterviewConductObservationSchema, RecruitmentInvitationResponseObservationSchema, } from "../../packages/domain/src/recruitment/schema.js"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { Pool } from "pg"; -import type { Browser, Locator, Page } from "@playwright/test"; +import type { Pool, PoolClient } from "pg"; +import type { Browser, Locator, Page, Route } from "@playwright/test"; import { AdmissionFieldOfStudyId } from "../../packages/domain/src/admission-period/schema.js"; import { publicApplicationCommandDigest } from "../../packages/domain/src/application/digest.js"; -import { ReturningAssistantRegistrationResponseSchema } from "../../packages/domain/src/application/returning.js"; +import { ReturningAssistantRegistrationInputSchema } from "../../packages/domain/src/application/returning.js"; import { SubmitPublicApplicationCommandSchema, PublicApplicationCommandIdSchema, @@ -29,8 +28,71 @@ import { PublicApplicationYearOfStudySchema, } from "../../packages/domain/src/application/schema.js"; import { DepartmentId } from "../../packages/domain/src/organization/schema.js"; -import { Match, Predicate, Schema } from "effect"; +import { + type Cause, + Data, + Effect, + Exit, + FileSystem, + Match, + Option, + Path, + type PlatformError, + Predicate, + Schema, +} from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import type { ChildProcessSpawner } from "effect/unstable/process"; +import type { AcceptanceCommandFailed } from "./acceptance-process.ts"; +import { indentedJsonText } from "./acceptance-process.ts"; +import { committed, firstSetCookie, jsonText, step, thrownBy } from "./journey-step.ts"; import { admissionJourneyClock } from "../e2e/journey-clock.ts"; +import { replacedFetch } from "../../apps/dashboard/e2e/native-rpc-ledger.ts"; + +/** Runs a command to completion and answers its standard output. */ +export type RunCommand = ( + command: string, + args: ReadonlyArray, + env?: Readonly>, + cwd?: string, +) => Effect.Effect< + string, + AcceptanceCommandFailed | PlatformError.PlatformError, + ChildProcessSpawner.ChildProcessSpawner +>; + +/** A statement of the returning seed that failed, named by its label. */ +class ReturningSeedFailed extends Data.TaggedError("ReturningSeedFailed")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +/** A browser action of the returning journey that failed, with the evidence of its phase written. */ +class ReturningBrowserActionFailed extends Data.TaggedError("ReturningBrowserActionFailed")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +/** The name of a thrown error. */ +const errorName = (error: Error): string => error.name; + +const decodeJsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +/** The value of a JSON text, decoded through Schema. */ +const decodeJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); + +/** One RPC request on the JSON wire, as a browser sends it without the typed client. */ +const WireRequest = Schema.TaggedStruct("Request", { + id: Schema.String, + tag: Schema.String, + payload: Schema.Unknown, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +}); + +/** The one answer of a request that succeeded. */ +const WireSuccess = Schema.Tuple([ + Schema.TaggedStruct("Exit", { exit: Schema.TaggedStruct("Success", { value: Schema.Unknown }) }), +]); const person = { personId: "journey-returning-assistant-0104", @@ -167,6 +229,41 @@ const negativeProbePersons = [ }, ] as const; +/** + * One recruitment RPC as the page's browser context would send it: with the context's cookies and + * the dashboard origin, answered as the HTTP response of the route that the RPC replaced. + */ +const pageRpc = ( + page: Page, + input: { + readonly api: string; + readonly ui: string; + readonly tag: string; + readonly payload: Schema.Json; + }, +) => + page + .context() + .cookies(input.api) + .then((cookies) => + replacedFetch({ + origin: input.api, + tag: input.tag, + payload: input.payload, + headers: { + origin: input.ui, + cookie: cookies.map(({ name, value }) => `${name}=${value}`).join("; "), + }, + }), + ) + .then((response) => + response.text().then((text) => ({ + status: () => response.status, + headers: () => Object.fromEntries(response.headers), + text: () => Promise.resolve(text), + })), + ); + export const returningAssistantFixture = { person, semesterId, @@ -178,120 +275,131 @@ export const returningAssistantFixture = { applicationId, }; -export const seedReturningAssistant = async ({ +export const seedReturningAssistant = Effect.fnUntraced(function* ({ pool, run, env, root, }: { readonly pool: Pool; - readonly run: (command: string, args: string[], env?: NodeJS.ProcessEnv, cwd?: string) => string; - readonly env: NodeJS.ProcessEnv; + readonly run: RunCommand; + readonly env: Readonly>; readonly root: string; -}) => { - run( +}) { + const path = yield* Path.Path; + + yield* run( "bun", ["run", "identity:seed"], { ...env, IDENTITY_SEED_PG_URL: env.JOURNEY_SEED_PG_URL, NATIVE_IDENTITY_TRUSTED_ORIGINS: env.NATIVE_IDENTITY_TRUSTED_ORIGINS, - IDENTITY_SEED_PERSONS: JSON.stringify([person, ...negativeProbePersons]), + IDENTITY_SEED_PERSONS: yield* jsonText([person, ...negativeProbePersons]), BETTER_AUTH_SECRET: env.BETTER_AUTH_SECRET, }, - join(root, "packages/database"), + path.join(root, "packages/database"), ); - const client = await pool.connect(); - const seedQuery = async (label: string, text: string, values?: unknown[]) => { - try { - return values === undefined ? await client.query(text) : await client.query(text, values); - } catch (cause) { - throw new Error( - `returning seed ${label}: ${cause instanceof Error ? cause.message : String(cause)}`, - { cause }, - ); - } - }; + yield* Effect.acquireUseRelease( + step(() => pool.connect()), + (client: PoolClient) => { + const seedQuery = (label: string, text: string, values?: unknown[]) => + step(() => (values === undefined ? client.query(text) : client.query(text, values))).pipe( + Effect.mapError( + (failed) => + new ReturningSeedFailed({ + message: `returning seed ${label}: ${failed.cause instanceof Error ? failed.cause.message : String(failed.cause)}`, + cause: failed.cause, + }), + ), + ); - try { - await client.query("BEGIN"); - await seedQuery( - "next semester", - `INSERT INTO public.admission_period_semesters(semester_id,start_at,end_at,revision) + return committed( + client, + Effect.gen(function* () { + yield* seedQuery( + "next semester", + `INSERT INTO public.admission_period_semesters(semester_id,start_at,end_at,revision) VALUES($1,$2,$3,0) ON CONFLICT (semester_id) DO NOTHING`, - [nextSemesterId, nextPeriodStartAt, nextPeriodEndAt], - ); - await seedQuery( - "next admission period", - `INSERT INTO public.admission_periods(admission_period_id,department_id,semester_id,start_at,end_at,revision,last_command_id) + [nextSemesterId, nextPeriodStartAt, nextPeriodEndAt], + ); + yield* seedQuery( + "next admission period", + `INSERT INTO public.admission_periods(admission_period_id,department_id,semester_id,start_at,end_at,revision,last_command_id) VALUES($1,$2,$3,$4,$5,0,'returning-next-period-seed-0104') ON CONFLICT (admission_period_id) DO NOTHING`, - [nextAdmissionPeriodId, departmentId, nextSemesterId, nextPeriodStartAt, nextPeriodEndAt], - ); - await seedQuery( - "historical department", - `INSERT INTO public.organization_departments(department_id,name,short_name,email,city,active,revision) + [ + nextAdmissionPeriodId, + departmentId, + nextSemesterId, + nextPeriodStartAt, + nextPeriodEndAt, + ], + ); + yield* seedQuery( + "historical department", + `INSERT INTO public.organization_departments(department_id,name,short_name,email,city,active,revision) VALUES($1,'Returning History','RH','returning-history@example.invalid','History City',true,0) ON CONFLICT (department_id) DO NOTHING`, - [historicalDepartmentId], - ); - await seedQuery( - "foreign department", - `INSERT INTO public.organization_departments(department_id,name,short_name,email,city,active,revision) + [historicalDepartmentId], + ); + yield* seedQuery( + "foreign department", + `INSERT INTO public.organization_departments(department_id,name,short_name,email,city,active,revision) VALUES($1,'Returning Foreign','RF','returning-foreign@example.invalid','Foreign City',true,0) ON CONFLICT (department_id) DO NOTHING`, - [foreignDepartmentId], - ); - await seedQuery( - "foreign team", - `INSERT INTO public.organization_teams(team_id,department_id,name) + [foreignDepartmentId], + ); + yield* seedQuery( + "foreign team", + `INSERT INTO public.organization_teams(team_id,department_id,name) VALUES($1,$2,'Returning Foreign Team') ON CONFLICT (team_id) DO NOTHING`, - [foreignTeamId, foreignDepartmentId], - ); - await seedQuery( - "historical semester", - `INSERT INTO public.admission_period_semesters(semester_id,start_at,end_at,revision) + [foreignTeamId, foreignDepartmentId], + ); + yield* seedQuery( + "historical semester", + `INSERT INTO public.admission_period_semesters(semester_id,start_at,end_at,revision) VALUES($1,'2025-01-01T00:00:00Z','2025-06-30T23:59:59.999Z',0) ON CONFLICT (semester_id) DO NOTHING`, - [historicalSemesterId], - ); - await seedQuery( - "volunteer affiliation", - `INSERT INTO public.organization_volunteer_affiliations(person_id,department_id,status,revision) + [historicalSemesterId], + ); + yield* seedQuery( + "volunteer affiliation", + `INSERT INTO public.organization_volunteer_affiliations(person_id,department_id,status,revision) VALUES($1,$2,'Active',1) ON CONFLICT DO NOTHING`, - [person.personId, departmentId], - ); - await seedQuery( - "volunteer affiliation audit", - `INSERT INTO public.organization_volunteer_affiliation_audit(person_id,department_id,revision,action,actor_person_id,occurred_at) + [person.personId, departmentId], + ); + yield* seedQuery( + "volunteer affiliation audit", + `INSERT INTO public.organization_volunteer_affiliation_audit(person_id,department_id,revision,action,actor_person_id,occurred_at) VALUES($1,$2,1,'Establish','journey-conduct-leader-0063','2026-01-04T00:00:00Z') ON CONFLICT DO NOTHING`, - [person.personId, departmentId], - ); - await seedQuery( - "applicant", - `INSERT INTO public.admission_applicants(applicant_id,normalized_email,email,first_name,last_name,phone,gender,field_of_study_id,year_of_study,activation_digest) + [person.personId, departmentId], + ); + yield* seedQuery( + "applicant", + `INSERT INTO public.admission_applicants(applicant_id,normalized_email,email,first_name,last_name,phone,gender,field_of_study_id,year_of_study,activation_digest) VALUES($1,'rita.returning@example.invalid','rita.returning@example.invalid','Rita','Tilbake','90000104',0,$2,2,$3) ON CONFLICT DO NOTHING`, - [applicantId, fieldOfStudyId, originalActivationDigest], - ); - await seedQuery( - "application", - `INSERT INTO public.admission_applications(application_id,applicant_id,admission_period_id,department_id,field_of_study_id,year_of_study,submitted_at,revision) + [applicantId, fieldOfStudyId, originalActivationDigest], + ); + yield* seedQuery( + "application", + `INSERT INTO public.admission_applications(application_id,applicant_id,admission_period_id,department_id,field_of_study_id,year_of_study,submitted_at,revision) VALUES($1,$2,$3,$4,$5,2,$6,0) ON CONFLICT DO NOTHING`, - [ - applicationId, - applicantId, - admissionPeriodId, - departmentId, - fieldOfStudyId, - applicationSubmittedAt, - ], - ); - await seedQuery( - "original public receipt", - `INSERT INTO public.admission_application_command_receipts( + [ + applicationId, + applicantId, + admissionPeriodId, + departmentId, + fieldOfStudyId, + applicationSubmittedAt, + ], + ); + yield* seedQuery( + "original public receipt", + `INSERT INTO public.admission_application_command_receipts( command_id,command_sha256,command_json,observation_json,application_id,committed_at ) VALUES( $1::text,$2::text, @@ -310,254 +418,254 @@ export const seedReturningAssistant = async ({ jsonb_build_object('_tag','Submitted','commandId',$1::text,'applicationId',$11::text), $11::text,$12 ) ON CONFLICT DO NOTHING`, - [ - originalPublicCommandId, - originalPublicCommandDigest, - departmentId, - originalPublicCommand.firstName, - originalPublicCommand.lastName, - originalPublicCommand.phone, - originalPublicCommand.email, - originalPublicCommand.gender, - fieldOfStudyId, - originalPublicCommand.yearOfStudy, - applicationId, - applicationSubmittedAt, - ], - ); - await seedQuery( - "original public audit", - `INSERT INTO public.admission_application_audit( + [ + originalPublicCommandId, + originalPublicCommandDigest, + departmentId, + originalPublicCommand.firstName, + originalPublicCommand.lastName, + originalPublicCommand.phone, + originalPublicCommand.email, + originalPublicCommand.gender, + fieldOfStudyId, + originalPublicCommand.yearOfStudy, + applicationId, + applicationSubmittedAt, + ], + ); + yield* seedQuery( + "original public audit", + `INSERT INTO public.admission_application_audit( command_id,application_id,applicant_id,action,application_revision,occurred_at ) VALUES($1,$2,$3,'PublicApplicationSubmitted',0,$4) ON CONFLICT DO NOTHING`, - [originalPublicCommandId, applicationId, applicantId, applicationSubmittedAt], - ); - await seedQuery( - "invitation", - `INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) + [originalPublicCommandId, applicationId, applicantId, applicationSubmittedAt], + ); + yield* seedQuery( + "invitation", + `INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES($1,$2,$3,$4,$5,'Claimed','journey-conduct-leader-0063',$6) ON CONFLICT DO NOTHING`, - [ - invitationId, - applicationId, - applicantId, - createHash("sha256").update(invitationId).digest("hex"), - accountClaimExpiresAt, - accountClaimIssuedAt, - ], - ); - await seedQuery( - "account link", - `INSERT INTO public.applicant_account_links(applicant_id,person_id,linked_at,invitation_id) + [ + invitationId, + applicationId, + applicantId, + createHash("sha256").update(invitationId).digest("hex"), + accountClaimExpiresAt, + accountClaimIssuedAt, + ], + ); + yield* seedQuery( + "account link", + `INSERT INTO public.applicant_account_links(applicant_id,person_id,linked_at,invitation_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING`, - [applicantId, person.personId, accountLinkedAt, invitationId], - ); + [applicantId, person.personId, accountLinkedAt, invitationId], + ); - const negativeApplicants = [ - { - applicantId: "applicant-returning-no-placement-0104", - personId: negativeProbePersons[0].personId, - email: negativeProbePersons[0].email, - field: fieldOfStudyId, - applicationId: "application-returning-no-placement-0104", - }, - { - applicantId: "applicant-returning-invalid-study-0104", - personId: negativeProbePersons[3].personId, - email: negativeProbePersons[3].email, - field: fieldOfStudyId, - applicationId: "application-returning-invalid-study-0104", - }, - { - applicantId: "applicant-returning-multi-a-0104", - personId: negativeProbePersons[2].personId, - email: "returning.multi-a@example.invalid", - field: fieldOfStudyId, - applicationId: "application-returning-multi-a-0104", - }, - { - applicantId: "applicant-returning-multi-b-0104", - personId: negativeProbePersons[2].personId, - email: "returning.multi-b@example.invalid", - field: fieldOfStudyId, - applicationId: "application-returning-multi-b-0104", - }, - ] as const; + const negativeApplicants = [ + { + applicantId: "applicant-returning-no-placement-0104", + personId: negativeProbePersons[0].personId, + email: negativeProbePersons[0].email, + field: fieldOfStudyId, + applicationId: "application-returning-no-placement-0104", + }, + { + applicantId: "applicant-returning-invalid-study-0104", + personId: negativeProbePersons[3].personId, + email: negativeProbePersons[3].email, + field: fieldOfStudyId, + applicationId: "application-returning-invalid-study-0104", + }, + { + applicantId: "applicant-returning-multi-a-0104", + personId: negativeProbePersons[2].personId, + email: "returning.multi-a@example.invalid", + field: fieldOfStudyId, + applicationId: "application-returning-multi-a-0104", + }, + { + applicantId: "applicant-returning-multi-b-0104", + personId: negativeProbePersons[2].personId, + email: "returning.multi-b@example.invalid", + field: fieldOfStudyId, + applicationId: "application-returning-multi-b-0104", + }, + ] as const; - for (const [index, negative] of negativeApplicants.entries()) { - await seedQuery( - `negative applicant ${index}`, - `INSERT INTO public.admission_applicants(applicant_id,normalized_email,email,first_name,last_name,phone,gender,field_of_study_id,year_of_study,activation_digest) + for (const [index, negative] of negativeApplicants.entries()) { + yield* seedQuery( + `negative applicant ${index}`, + `INSERT INTO public.admission_applicants(applicant_id,normalized_email,email,first_name,last_name,phone,gender,field_of_study_id,year_of_study,activation_digest) VALUES($1,$2,$2,'Negative','Probe','9000010${index}',0,$3,2,NULL) ON CONFLICT DO NOTHING`, - [negative.applicantId, negative.email, negative.field], - ); - await seedQuery( - `negative application ${index}`, - `INSERT INTO public.admission_applications(application_id,applicant_id,admission_period_id,department_id,field_of_study_id,year_of_study,submitted_at,revision) + [negative.applicantId, negative.email, negative.field], + ); + yield* seedQuery( + `negative application ${index}`, + `INSERT INTO public.admission_applications(application_id,applicant_id,admission_period_id,department_id,field_of_study_id,year_of_study,submitted_at,revision) VALUES($1,$2,$3,$4,$5,2,$6,0) ON CONFLICT DO NOTHING`, - [ - negative.applicationId, - negative.applicantId, - admissionPeriodId, - departmentId, - negative.field, - applicationSubmittedAt, - ], - ); - const negativeInvitation = `invitation-returning-negative-${index}-0104`; - await seedQuery( - `negative invitation ${index}`, - `INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) + [ + negative.applicationId, + negative.applicantId, + admissionPeriodId, + departmentId, + negative.field, + applicationSubmittedAt, + ], + ); + const negativeInvitation = `invitation-returning-negative-${index}-0104`; + yield* seedQuery( + `negative invitation ${index}`, + `INSERT INTO public.applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES($1,$2,$3,$4,$5,'Claimed','journey-conduct-leader-0063',$6) ON CONFLICT DO NOTHING`, - [ - negativeInvitation, - negative.applicationId, - negative.applicantId, - createHash("sha256").update(negativeInvitation).digest("hex"), - accountClaimExpiresAt, - accountClaimIssuedAt, - ], - ); - await seedQuery( - `negative account link ${index}`, - `INSERT INTO public.applicant_account_links(applicant_id,person_id,linked_at,invitation_id) + [ + negativeInvitation, + negative.applicationId, + negative.applicantId, + createHash("sha256").update(negativeInvitation).digest("hex"), + accountClaimExpiresAt, + accountClaimIssuedAt, + ], + ); + yield* seedQuery( + `negative account link ${index}`, + `INSERT INTO public.applicant_account_links(applicant_id,person_id,linked_at,invitation_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING`, - [negative.applicantId, negative.personId, accountLinkedAt, negativeInvitation], - ); - } + [negative.applicantId, negative.personId, accountLinkedAt, negativeInvitation], + ); + } - await seedQuery( - "no-placement affiliation", - `INSERT INTO public.organization_volunteer_affiliations(person_id,department_id,status,revision) + yield* seedQuery( + "no-placement affiliation", + `INSERT INTO public.organization_volunteer_affiliations(person_id,department_id,status,revision) VALUES($1,$2,'Active',1) ON CONFLICT DO NOTHING`, - [negativeProbePersons[0].personId, departmentId], - ); - await seedQuery( - "invalid-study affiliation", - `INSERT INTO public.organization_volunteer_affiliations(person_id,department_id,status,revision) + [negativeProbePersons[0].personId, departmentId], + ); + yield* seedQuery( + "invalid-study affiliation", + `INSERT INTO public.organization_volunteer_affiliations(person_id,department_id,status,revision) VALUES($1,$2,'Active',1) ON CONFLICT DO NOTHING`, - [negativeProbePersons[3].personId, departmentId], - ); - await seedQuery( - "school", - `INSERT INTO public.schools_directory_schools(name,contact_person,email,phone,language,active,revision) + [negativeProbePersons[3].personId, departmentId], + ); + yield* seedQuery( + "school", + `INSERT INTO public.schools_directory_schools(name,contact_person,email,phone,language,active,revision) VALUES('Returning School','School Contact','school-returning@example.invalid','+47 900000106','Norwegian',true,0) ON CONFLICT DO NOTHING`, - ); + ); - const school = await seedQuery( - "school lookup", - "SELECT school_id FROM public.schools_directory_schools WHERE name='Returning School'", - ); + const school = yield* seedQuery( + "school lookup", + "SELECT school_id FROM public.schools_directory_schools WHERE name='Returning School'", + ); - assert.equal(school.rows.length, 1); - await seedQuery( - "school department", - "INSERT INTO public.schools_directory_departments(school_id,department_id,revision) VALUES($1,$2,0) ON CONFLICT DO NOTHING", - [school.rows[0].school_id, departmentId], - ); - await seedQuery( - "historical school department", - "INSERT INTO public.schools_directory_departments(school_id,department_id,revision) VALUES($1,$2,0) ON CONFLICT DO NOTHING", - [school.rows[0].school_id, historicalDepartmentId], - ); - await seedQuery( - "historical volunteer affiliation", - `INSERT INTO public.organization_volunteer_affiliations(person_id,department_id,status,revision) + assert.equal(school.rows.length, 1); + yield* seedQuery( + "school department", + "INSERT INTO public.schools_directory_departments(school_id,department_id,revision) VALUES($1,$2,0) ON CONFLICT DO NOTHING", + [school.rows[0].school_id, departmentId], + ); + yield* seedQuery( + "historical school department", + "INSERT INTO public.schools_directory_departments(school_id,department_id,revision) VALUES($1,$2,0) ON CONFLICT DO NOTHING", + [school.rows[0].school_id, historicalDepartmentId], + ); + yield* seedQuery( + "historical volunteer affiliation", + `INSERT INTO public.organization_volunteer_affiliations(person_id,department_id,status,revision) VALUES($1,$2,'Inactive',1) ON CONFLICT DO NOTHING`, - [person.personId, historicalDepartmentId], - ); - await seedQuery( - "historical placement", - `INSERT INTO public.assistant_placements(placement_id,person_id,department_id,semester_id,school_id,day,workdays,block,active,revision) + [person.personId, historicalDepartmentId], + ); + yield* seedQuery( + "historical placement", + `INSERT INTO public.assistant_placements(placement_id,person_id,department_id,semester_id,school_id,day,workdays,block,active,revision) VALUES($1,$2,$3,$4,$5,'Tuesday',4,'1',false,2) ON CONFLICT DO NOTHING`, - [ - `placement-${"0".repeat(64)}`, - person.personId, - historicalDepartmentId, - historicalSemesterId, - school.rows[0].school_id, - ], - ); - await seedQuery( - "placement", - `INSERT INTO public.assistant_placements(placement_id,person_id,department_id,semester_id,school_id,day,workdays,block,active,revision) + [ + `placement-${"0".repeat(64)}`, + person.personId, + historicalDepartmentId, + historicalSemesterId, + school.rows[0].school_id, + ], + ); + yield* seedQuery( + "placement", + `INSERT INTO public.assistant_placements(placement_id,person_id,department_id,semester_id,school_id,day,workdays,block,active,revision) VALUES($1,$2,$3,$4,$5,'Monday',4,'1',true,1) ON CONFLICT DO NOTHING`, - [placementId, person.personId, departmentId, semesterId, school.rows[0].school_id], - ); - await seedQuery( - "invalid-study placement", - `INSERT INTO public.assistant_placements(placement_id,person_id,department_id,semester_id,school_id,day,workdays,block,active,revision) + [placementId, person.personId, departmentId, semesterId, school.rows[0].school_id], + ); + yield* seedQuery( + "invalid-study placement", + `INSERT INTO public.assistant_placements(placement_id,person_id,department_id,semester_id,school_id,day,workdays,block,active,revision) VALUES($1,$2,$3,$4,$5,'Monday',4,'1',true,1) ON CONFLICT DO NOTHING`, - [ - `placement-${"b".repeat(64)}`, - negativeProbePersons[3].personId, - departmentId, - semesterId, - school.rows[0].school_id, - ], - ); - await seedQuery( - "placement audit", - `INSERT INTO public.assistant_placement_audit(placement_id,revision,actor_person_id,occurred_at,action,snapshot) + [ + `placement-${"b".repeat(64)}`, + negativeProbePersons[3].personId, + departmentId, + semesterId, + school.rows[0].school_id, + ], + ); + yield* seedQuery( + "placement audit", + `INSERT INTO public.assistant_placement_audit(placement_id,revision,actor_person_id,occurred_at,action,snapshot) VALUES($1::text,1,$2::text,$6,'Create',jsonb_build_object('placementId',$1::text,'personId',$2::text,'departmentId',$3::text,'semesterId',$4::text,'schoolId',$5::text,'day','Monday','workdays',4,'block','1','active',true,'revision',1)) ON CONFLICT DO NOTHING`, - [ - placementId, - person.personId, - departmentId, - semesterId, - school.rows[0].school_id, - placementCreatedAt, - ], - ); - await seedQuery( - "historical placement audit", - `INSERT INTO public.assistant_placement_audit(placement_id,revision,actor_person_id,occurred_at,action,snapshot) + [ + placementId, + person.personId, + departmentId, + semesterId, + school.rows[0].school_id, + placementCreatedAt, + ], + ); + yield* seedQuery( + "historical placement audit", + `INSERT INTO public.assistant_placement_audit(placement_id,revision,actor_person_id,occurred_at,action,snapshot) VALUES ($1,1,'journey-conduct-leader-0063','2025-01-04T00:00:00Z','Create',jsonb_build_object('placementId',$1::text,'personId',$2::text,'departmentId',$3::text,'semesterId',$4::text,'schoolId',$5::text,'day','Tuesday','workdays',4,'block','1','active',true,'revision',1)), ($1,2,'journey-conduct-leader-0063','2025-06-30T00:00:00Z','Remove',jsonb_build_object('placementId',$1::text,'personId',$2::text,'departmentId',$3::text,'semesterId',$4::text,'schoolId',$5::text,'day','Tuesday','workdays',4,'block','1','active',false,'revision',2)) ON CONFLICT DO NOTHING`, - [ - `placement-${"0".repeat(64)}`, - person.personId, - historicalDepartmentId, - historicalSemesterId, - school.rows[0].school_id, - ], - ); - await seedQuery( - "interview", - `INSERT INTO public.recruitment_interviews(interview_id,application_id,department_id,interviewer_person_id,interview_schema_id,assigned_by_person_id,assigned_at,revision) + [ + `placement-${"0".repeat(64)}`, + person.personId, + historicalDepartmentId, + historicalSemesterId, + school.rows[0].school_id, + ], + ); + yield* seedQuery( + "interview", + `INSERT INTO public.recruitment_interviews(interview_id,application_id,department_id,interviewer_person_id,interview_schema_id,assigned_by_person_id,assigned_at,revision) VALUES('interview-returning-0104',$1,$2,'journey-returning-assistant-0104','interview-schema-native-conduct-0063','journey-conduct-leader-0063',$3,1) ON CONFLICT DO NOTHING`, - [applicationId, departmentId, interviewAssignedAt], - ); - await seedQuery( - "question snapshots", - `INSERT INTO public.recruitment_interview_question_snapshots(interview_id,question_id,ordinal,prompt,help_text,kind,alternatives) + [applicationId, departmentId, interviewAssignedAt], + ); + yield* seedQuery( + "question snapshots", + `INSERT INTO public.recruitment_interview_question_snapshots(interview_id,question_id,ordinal,prompt,help_text,kind,alternatives) SELECT 'interview-returning-0104',question_id,ordinal,prompt,help_text,kind,alternatives FROM public.recruitment_interview_schema_questions WHERE interview_schema_id='interview-schema-native-conduct-0063' ON CONFLICT DO NOTHING`, - ); - await seedQuery( - "interview conduct", - `INSERT INTO public.recruitment_interview_conducts(interview_id,answers,explanatory_power,role_model,suitability,finalized_by_person_id,finalized_at,interview_revision,recommendation) + ); + yield* seedQuery( + "interview conduct", + `INSERT INTO public.recruitment_interview_conducts(interview_id,answers,explanatory_power,role_model,suitability,finalized_by_person_id,finalized_at,interview_revision,recommendation) VALUES('interview-returning-0104','[]'::jsonb,8,8,8,'journey-conduct-leader-0063',$1,1,'Ja') ON CONFLICT DO NOTHING`, - [interviewFinalizedAt], - ); - await client.query("COMMIT"); - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } + [interviewFinalizedAt], + ); + }), + ); + }, + (client) => Effect.sync(() => client.release()), + ); - const counts = await pool.query( - `SELECT (SELECT count(*)::int FROM public.applicant_account_links WHERE person_id=$1) links, + const counts = yield* step(() => + pool.query( + `SELECT (SELECT count(*)::int FROM public.applicant_account_links WHERE person_id=$1) links, (SELECT count(*)::int FROM public.assistant_placements WHERE person_id=$1) placements, (SELECT count(*)::int FROM public.organization_departments WHERE department_id=$2) foreign_departments, (SELECT count(*)::int FROM public.organization_teams WHERE team_id=$3 AND department_id=$2) foreign_teams`, - [person.personId, foreignDepartmentId, foreignTeamId], + [person.personId, foreignDepartmentId, foreignTeamId], + ), ); assert.deepEqual(counts.rows[0], { @@ -566,9 +674,12 @@ export const seedReturningAssistant = async ({ foreign_departments: 1, foreign_teams: 1, }); -}; +}); -export const runReturningAssistantBrowserJourney = async ({ +export const runReturningAssistantBrowserJourney = Effect.fnUntraced(function* < + AuditError, + DeliveryError, +>({ browser, page, pool, @@ -589,28 +700,40 @@ export const runReturningAssistantBrowserJourney = async ({ readonly api: string; readonly ui: string; readonly artifacts: string; - readonly auditPage: (page: Page, state: string) => Promise; + readonly auditPage: (page: Page, state: string) => Effect.Effect; readonly errors: string[]; readonly stage?: (name: string) => void; readonly coordinatorEmail: string; readonly coordinatorPassword: string; readonly readInvitationCapability?: (interviewId: string) => string | undefined; - readonly deliverRecruitmentInvitation?: (claimId: string) => Promise; -}) => { + readonly deliverRecruitmentInvitation?: ( + claimId: string, + ) => Effect.Effect; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const trace: Array = []; - try { + /** Writes the journey trace, indented by two spaces as the evidence files are. */ + const writeTrace = Effect.gen(function* () { + yield* fs.writeFileString( + path.join(artifacts, "returning-registration-trace.json"), + yield* indentedJsonText(trace), + ); + }); + + yield* Effect.gen(function* () { stage?.("returning:browser.newContext"); - const context = await browser.newContext(); + const context = yield* step(() => browser.newContext()); const responses: string[] = []; stage?.("returning:browser.newPage"); - const returning = await context.newPage(); + const returning = yield* step(() => context.newPage()); returning.on("request", (request) => { const url = new URL(request.url()); if ( url.pathname.includes("/dashboard/tidligere-assistenter") || - url.pathname.includes("/api/returning-assistant/") || + isNativeRpcPath(url.pathname) || url.pathname.includes("/api/auth/") ) { responses.push(`request ${request.method()} ${url.pathname}`); @@ -620,58 +743,74 @@ export const runReturningAssistantBrowserJourney = async ({ if (frame === returning.mainFrame()) responses.push(`navigation ${new URL(frame.url()).pathname}`); }); - returning.on("response", async (response) => { + returning.on("response", (response) => { const url = new URL(response.url()); if ( !url.pathname.includes("/dashboard/tidligere-assistenter") && - !url.pathname.includes("/api/returning-assistant/") && + !isNativeRpcPath(url.pathname) && !url.pathname.includes("/api/auth/") ) return; - let code = "unknown"; - if (url.pathname.endsWith(".data")) { - const body = await response.text().catch(() => ""); + const note = (code: string) => + responses.push(`response ${response.status()} ${url.pathname} code=${code}`); - try { - const value: unknown = JSON.parse(body); + if (!url.pathname.endsWith(".data")) { + note("unknown"); - if ( - value !== null && - (value === null || Predicate.isObjectOrArray(value)) && - "code" in value && - Predicate.isString(value.code) - ) - code = value.code; - } catch {} + return; } - responses.push(`response ${response.status()} ${url.pathname} code=${code}`); + return response + .text() + .catch(() => "") + .then((body) => { + const value = decodeJsonOption(body); + + note( + Option.isSome(value) && + Predicate.isObjectOrArray(value.value) && + "code" in value.value && + Predicate.isString(value.value.code) + ? value.value.code + : "unknown", + ); + }); }); returning.on("pageerror", (error: Error) => errors.push(`returning:${error.message}`)); - const captureReturningFailure = async (phase: string, cause: unknown): Promise => { - const markup = await returning.content().catch(() => ""); - await writeFile( - join(artifacts, `returning-${phase}-failure.html`), + const captureReturningFailure = Effect.fnUntraced(function* ( + phase: string, + failure: Cause.Cause, + ) { + const cause = thrownBy(failure); + const markup = yield* step(() => returning.content().catch(() => "")); + yield* fs.writeFileString( + path.join(artifacts, `returning-${phase}-failure.html`), markup.replaceAll(person.email, "[redacted]"), ); - await returning.screenshot({ - path: join(artifacts, `returning-${phase}-failure.png`), - fullPage: true, - }); - const kind = cause instanceof Error ? cause.name : Object.prototype.toString.call(cause); - const causeMessage = cause instanceof Error ? cause.message : String(cause); - await writeFile( - join(artifacts, "returning-registration-trace.json"), - JSON.stringify(trace, null, 2), - ); - throw new Error( - `returning ${phase} failed phase=browser-action kind=${kind} cause=${causeMessage} url=${returning.url()} responses=${responses.join(" | ")}`, - { cause }, + yield* step(() => + returning.screenshot({ + path: path.join(artifacts, `returning-${phase}-failure.png`), + fullPage: true, + }), ); - }; + const kind = cause.name; + const causeMessage = cause.message; + yield* writeTrace; + + return yield* new ReturningBrowserActionFailed({ + message: `returning ${phase} failed phase=browser-action kind=${kind} cause=${causeMessage} url=${returning.url()} responses=${responses.join(" | ")}`, + cause, + }); + }); + + /** Runs a browser action; its failure is captured as the evidence of the phase, then fails. */ + const capturedAs = + (phase: string) => + (action: Effect.Effect) => + action.pipe(Effect.catchCause((failure) => captureReturningFailure(phase, failure))); returning.on("request", (request) => { const url = new URL(request.url()); @@ -699,27 +838,64 @@ export const runReturningAssistantBrowserJourney = async ({ const destination = "/dashboard/tidligere-assistenter"; stage?.("returning:login"); - try { - await returning.goto(`${ui}/login?redirectTo=${encodeURIComponent(destination)}`); - await returning.getByLabel("E-post", { exact: true }).fill(person.email); - await returning.getByLabel("Passord", { exact: true }).fill(person.password); - await returning - .getByRole("button", { name: "Logg inn", exact: true }) - .click({ noWaitAfter: true }); - await returning.waitForURL(/\/dashboard\/tidligere-assistenter$/); - } catch (cause) { - await captureReturningFailure("login", cause); - } + yield* Effect.gen(function* () { + yield* step(() => + returning.goto(`${ui}/login?redirectTo=${encodeURIComponent(destination)}`), + ); + yield* step(() => returning.getByLabel("E-post", { exact: true }).fill(person.email)); + yield* step(() => returning.getByLabel("Passord", { exact: true }).fill(person.password)); + yield* step(() => + returning + .getByRole("button", { name: "Logg inn", exact: true }) + .click({ noWaitAfter: true }), + ); + yield* step(() => returning.waitForURL(/\/dashboard\/tidligere-assistenter$/)); + }).pipe(capturedAs("login")); - const cookieHeader = (await context.cookies()) + const cookieHeader = (yield* step(() => context.cookies())) .map((cookie) => `${cookie.name}=${cookie.value}`) .join("; "); - const optionsResponse = await context.request.get(`${api}/api/returning-assistant/options`, { - headers: { origin: ui, accept: "application/json", cookie: cookieHeader }, + const native = nativeScriptClient(api); + + /** The browser context's current session, sent from the dashboard origin. */ + const sessionHeaders = Effect.fnUntraced(function* () { + return { + cookie: (yield* step(() => context.cookies())) + .map((cookie) => `${cookie.name}=${cookie.value}`) + .join("; "), + origin: ui, + }; }); - const waitForDashboardAction = async (periodId: string, trigger: () => Promise) => { + const readOptions = (headers: Readonly>) => + native.call(headers, (client) => client["admissions.readReturningAssistantOptions"]()); + + /** Registers as the browser context's person; the request decodes through the contract. */ + const register = Effect.fnUntraced(function* ( + idempotencyKey: string, + data: typeof ReturningAssistantRegistrationInputSchema.Encoded, + ) { + const request = yield* Schema.decodeEffect(ReturningAssistantRegistrationInputSchema)(data); + + const headers = yield* sessionHeaders(); + + return yield* step(() => + native.call(headers, (client) => + client["admissions.registerReturningAssistant"]({ + idempotencyKey: IdempotencyKey.make(idempotencyKey), + request, + }), + ), + ); + }); + + const optionsAnswer = yield* step(() => readOptions({ origin: ui, cookie: cookieHeader })); + + const waitForDashboardAction = Effect.fnUntraced(function* ( + periodId: string, + trigger: () => Effect.Effect, + ) { const responsePromise = returning.waitForResponse( (response) => { const url = new URL(response.url()); @@ -738,9 +914,9 @@ export const runReturningAssistantBrowserJourney = async ({ { timeout: 30_000 }, ); - await trigger(); - const response = await responsePromise; - await response.finished(); + yield* trigger(); + const response = yield* step(() => responsePromise); + yield* step(() => response.finished()); assert.equal(response.status(), 200); const body = new URLSearchParams(response.request().postData() ?? ""); @@ -751,107 +927,109 @@ export const runReturningAssistantBrowserJourney = async ({ expectedRevision: body.get("expectedRevision"), commandId: body.get("commandId"), }; - }; + }); - const waitForActionReady = async (form: Locator) => { + const waitForActionReady = Effect.fnUntraced(function* (form: Locator) { for (let attempt = 0; attempt < 100; attempt += 1) { if ( - (await form.locator('button[type="submit"]').isEnabled()) && - (await form.getAttribute("data-pending")) === "false" + (yield* step(() => form.locator('button[type="submit"]').isEnabled())) === true && + (yield* step(() => form.getAttribute("data-pending"))) === "false" ) return; - await new Promise((resolve) => setTimeout(resolve, 100)); + yield* Effect.sleep("100 millis"); } throw new Error("returning form did not become ready for action"); - }; + }); - responses.push(`context.request options ${optionsResponse.status()}`); + responses.push(`context.request options ${optionsAnswer.status}`); - const negativeMutationSnapshot = async (personId: string) => - ( - await pool.query( + const negativeMutationSnapshot = Effect.fnUntraced(function* (personId: string) { + return (yield* step(() => + pool.query( `SELECT (SELECT count(*)::int FROM public.applicant_account_links WHERE person_id=$1) AS links, (SELECT count(*)::int FROM public.admission_applications a JOIN public.applicant_account_links l USING(applicant_id) WHERE l.person_id=$1) AS applications, (SELECT count(*)::int FROM public.admission_returning_registrations WHERE person_id=$1) AS registrations`, [personId], - ) - ).rows[0]; + ), + )).rows[0]; + }); - const probeNegativeOptions = async ( + const probeNegativeOptions = Effect.fnUntraced(function* ( gate: string, probePerson: (typeof negativeProbePersons)[number], expectedStatus: number, - ) => { - let signIn = await fetch(`${api}/api/auth/sign-in/email`, { - method: "POST", - headers: { origin: ui, "content-type": "application/json" }, - body: JSON.stringify({ email: probePerson.email, password: probePerson.password }), - }); + ) { + const signInRequest = HttpClientRequest.post(`${api}/api/auth/sign-in/email`).pipe( + HttpClientRequest.setHeaders({ origin: ui }), + HttpClientRequest.bodyText( + yield* jsonText({ email: probePerson.email, password: probePerson.password }), + "application/json", + ), + ); + + let signIn = yield* HttpClient.execute(signInRequest); for (let retry = 0; signIn.status === 429 && retry < 30; retry += 1) { - const cooldown = Promise.withResolvers(); - setTimeout(cooldown.resolve, 1_000); - await cooldown.promise; - signIn = await fetch(`${api}/api/auth/sign-in/email`, { - method: "POST", - headers: { origin: ui, "content-type": "application/json" }, - body: JSON.stringify({ email: probePerson.email, password: probePerson.password }), - }); + yield* Effect.sleep("1 second"); + signIn = yield* HttpClient.execute(signInRequest); } assert.equal(signIn.status, 200, `${gate} sign-in`); - const cookie = signIn.headers.get("set-cookie")?.match(/^([^=;]+=[^;]+)/u)?.[1]; + const cookie = firstSetCookie(signIn); assert.ok(cookie, `${gate} session cookie`); - const before = await negativeMutationSnapshot(probePerson.personId); + const before = yield* negativeMutationSnapshot(probePerson.personId); - const response = await fetch(`${api}/api/returning-assistant/options`, { - headers: { origin: ui, accept: "application/json", cookie }, - }); + const answer = yield* step(() => readOptions({ origin: ui, cookie })); + const body = yield* jsonText(answer); + assert.equal(answer.status, expectedStatus, `${gate} status body=${body}`); + const after = yield* negativeMutationSnapshot(probePerson.personId); - const body = await response.text(); - assert.equal(response.status, expectedStatus, `${gate} status body=${body}`); - const after = await negativeMutationSnapshot(probePerson.personId); - - if (JSON.stringify(after) !== JSON.stringify(before)) + if ((yield* jsonText(after)) !== (yield* jsonText(before))) throw new Error( - `${gate} must not mutate before=${JSON.stringify(before)} after=${JSON.stringify(after)}`, + `${gate} must not mutate before=${yield* jsonText(before)} after=${yield* jsonText(after)}`, ); - trace.push({ phase: "negative-gate", gate, status: response.status, body }); - }; + trace.push({ phase: "negative-gate", gate, status: answer.status, body }); + }); - await probeNegativeOptions("no-placement-despite-affiliation", negativeProbePersons[0], 404); - await probeNegativeOptions("missing-applicant-person-link", negativeProbePersons[1], 404); - await probeNegativeOptions("multiple-applicant-person-links", negativeProbePersons[2], 409); - await pool.query( - "UPDATE public.admission_period_fields_of_study SET active=false WHERE field_of_study_id=$1", - [fieldOfStudyId], + yield* probeNegativeOptions("no-placement-despite-affiliation", negativeProbePersons[0], 404); + yield* probeNegativeOptions("missing-applicant-person-link", negativeProbePersons[1], 404); + yield* probeNegativeOptions("multiple-applicant-person-links", negativeProbePersons[2], 409); + yield* step(() => + pool.query( + "UPDATE public.admission_period_fields_of_study SET active=false WHERE field_of_study_id=$1", + [fieldOfStudyId], + ), ); - try { - await probeNegativeOptions("inactive-study-mapping", negativeProbePersons[3], 409); - } finally { - await pool.query( - "UPDATE public.admission_period_fields_of_study SET active=true WHERE field_of_study_id=$1", - [fieldOfStudyId], - ); - } + yield* probeNegativeOptions("inactive-study-mapping", negativeProbePersons[3], 409).pipe( + Effect.ensuring( + step(() => + pool.query( + "UPDATE public.admission_period_fields_of_study SET active=true WHERE field_of_study_id=$1", + [fieldOfStudyId], + ), + ).pipe(Effect.orDie), + ), + ); - const mappingKey = await pool.query( - `SELECT to_jsonb(array_agg(a.attname::text ORDER BY k.ordinality)) AS columns + const mappingKey = yield* step(() => + pool.query( + `SELECT to_jsonb(array_agg(a.attname::text ORDER BY k.ordinality)) AS columns FROM pg_index i CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ordinality) JOIN pg_attribute a ON a.attrelid=i.indrelid AND a.attnum=k.attnum WHERE i.indrelid='public.admission_period_fields_of_study'::regclass AND i.indisprimary GROUP BY i.indexrelid`, + ), ); const expectedMappingKey = [{ columns: ["field_of_study_id"] }]; - if (JSON.stringify(mappingKey.rows) !== JSON.stringify(expectedMappingKey)) + if ((yield* jsonText(mappingKey.rows)) !== (yield* jsonText(expectedMappingKey))) throw new Error( - `ambiguous-study-mapping structural key mismatch actual=${JSON.stringify(mappingKey.rows)} expected=${JSON.stringify(expectedMappingKey)}`, + `ambiguous-study-mapping structural key mismatch actual=${yield* jsonText(mappingKey.rows)} expected=${yield* jsonText(expectedMappingKey)}`, ); trace.push({ phase: "negative-gate", @@ -859,20 +1037,41 @@ export const runReturningAssistantBrowserJourney = async ({ status: "proven", }); - const browserOptions = await returning.evaluate(async (endpoint) => { - const response = await fetch(endpoint, { - credentials: "include", - headers: { accept: "application/json" }, - }); - - return { status: response.status }; - }, `${api}/api/returning-assistant/options`); + // The browser calls the RPC with its own session cookie, across origins, as CORS admits it. + const browserOptions = yield* step(() => + returning.evaluate( + // Playwright serializes this callback and runs it in the browser page, whose fetch the + // probe exercises; no Effect service reaches that runtime. + ({ endpoint, message }) => + // oxlint-disable-next-line effecttsgo/global-fetch -- EX-0016: the callback runs in the browser page, where the probe exercises the page's own fetch. + fetch(endpoint, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify(message), + }).then((response) => + response.json().then((answer) => ({ status: response.status, answer })), + ), + { + endpoint: `${api}${nativeRpcPath}`, + message: WireRequest.make({ + id: "1", + tag: "admissions.readReturningAssistantOptions", + payload: null, + headers: [], + }), + }, + ), + ); assert.equal(browserOptions.status, 200); + // Decoding fails unless the answer is one successful exit. + yield* Schema.decodeUnknownEffect(WireSuccess)(browserOptions.answer); trace.push({ phase: "options-probe", status: browserOptions.status }); - const originalCustody = await pool.query( - `SELECT + const originalCustody = yield* step(() => + pool.query( + `SELECT to_jsonb(application) - 'year_of_study' - 'revision' AS application_immutable, to_jsonb(applicant) - 'activation_digest' AS applicant_profile, applicant.activation_digest, @@ -895,12 +1094,13 @@ export const runReturningAssistantBrowserJourney = async ({ FROM public.admission_applications application JOIN public.admission_applicants applicant USING(applicant_id) WHERE application.application_id=$1`, - [applicationId], + [applicationId], + ), ); assert.equal(originalCustody.rows.length, 1); - const originalCustodyRow = Schema.decodeUnknownSync( + const originalCustodyRow = yield* Schema.decodeUnknownEffect( Schema.Struct({ activation_digest: Schema.String, public_receipts: Schema.Array(Schema.Json), @@ -915,22 +1115,28 @@ export const runReturningAssistantBrowserJourney = async ({ let form = returning.getByRole("form", { name: "Registrer som tidligere assistent" }); stage?.("returning:form"); - try { - await form - .getByRole("combobox", { name: "Opptaksperiode" }) - .selectOption(nextAdmissionPeriodId); - await form.getByRole("combobox", { name: "Studieår" }).selectOption("4"); - await form.getByLabel("Mandag", { exact: true }).check(); - await form.getByLabel("Torsdag", { exact: true }).check(); - await form.getByRole("combobox", { name: "Stillingslengde" }).selectOption("8"); - await form.getByRole("combobox", { name: "Semesterblokk" }).selectOption("block-1"); - await form.getByRole("combobox", { name: "Språk" }).selectOption("Norsk og engelsk"); - await form.getByLabel("Ønsket skole (valgfritt)", { exact: true }).fill("Returning School"); - await form.getByLabel("Jeg er interessert i teamarbeid", { exact: true }).check(); - await form.locator(`input[name="teamIds"][value="${teamId}"]`).check(); - } catch (cause) { - await captureReturningFailure("form", cause); - } + yield* Effect.gen(function* () { + yield* step(() => + form.getByRole("combobox", { name: "Opptaksperiode" }).selectOption(nextAdmissionPeriodId), + ); + yield* step(() => form.getByRole("combobox", { name: "Studieår" }).selectOption("4")); + yield* step(() => form.getByLabel("Mandag", { exact: true }).check()); + yield* step(() => form.getByLabel("Torsdag", { exact: true }).check()); + yield* step(() => form.getByRole("combobox", { name: "Stillingslengde" }).selectOption("8")); + yield* step(() => + form.getByRole("combobox", { name: "Semesterblokk" }).selectOption("block-1"), + ); + yield* step(() => + form.getByRole("combobox", { name: "Språk" }).selectOption("Norsk og engelsk"), + ); + yield* step(() => + form.getByLabel("Ønsket skole (valgfritt)", { exact: true }).fill("Returning School"), + ); + yield* step(() => + form.getByLabel("Jeg er interessert i teamarbeid", { exact: true }).check(), + ); + yield* step(() => form.locator(`input[name="teamIds"][value="${teamId}"]`).check()); + }).pipe(capturedAs("form")); let submit = form.locator('button[type="submit"]'); let droppedResponse = false; @@ -956,78 +1162,56 @@ export const runReturningAssistantBrowserJourney = async ({ teamIds: [teamId], } as const; - const anonymousBefore = await negativeMutationSnapshot(person.personId); + const anonymousBefore = yield* negativeMutationSnapshot(person.personId); - const anonymous = await fetch(`${api}/api/returning-assistant/options`, { - headers: { origin: ui, accept: "application/json" }, - }); + const anonymous = yield* step(() => readOptions({ origin: ui })); assert.equal(anonymous.status, 401); - assert.deepEqual(await negativeMutationSnapshot(person.personId), anonymousBefore); + assert.deepEqual(yield* negativeMutationSnapshot(person.personId), anonymousBefore); trace.push({ phase: "negative-gate", gate: "anonymous-options", status: anonymous.status }); - const foreignTeam = await pool.query( - "SELECT team_id FROM public.organization_teams WHERE department_id<>$1 ORDER BY team_id LIMIT 1", - [departmentId], + const foreignTeam = yield* step(() => + pool.query( + "SELECT team_id FROM public.organization_teams WHERE department_id<>$1 ORDER BY team_id LIMIT 1", + [departmentId], + ), ); assert.equal(foreignTeam.rows.length, 1); - const wrongTeamBefore = await negativeMutationSnapshot(person.personId); + const wrongTeamBefore = yield* negativeMutationSnapshot(person.personId); - const wrongTeam = await context.request.post(`${api}/api/returning-assistant/registrations`, { - headers: { - "content-type": "application/json", - "idempotency-key": "returning-wrong-team-0104", - origin: ui, - }, - data: { - ...firstPayload, - commandId: "returning-wrong-team-0104", - teamInterest: true, - teamIds: [foreignTeam.rows[0].team_id], - }, + const wrongTeam = yield* register("returning-wrong-team-0104", { + ...firstPayload, + commandId: "returning-wrong-team-0104", + teamInterest: true, + teamIds: [foreignTeam.rows[0].team_id], }); - const wrongTeamBody = Schema.decodeUnknownSync(NativeProblem)(await wrongTeam.json()); - assert.equal(wrongTeam.status(), 403); - assert.equal(wrongTeamBody.code, "returning.team-scope-denied"); - assert.deepEqual(await negativeMutationSnapshot(person.personId), wrongTeamBefore); + assert.ok(!wrongTeam.ok); + assert.equal(wrongTeam.status, 403); + assert.equal(wrongTeam.code, "returning.team-scope-denied"); + assert.deepEqual(yield* negativeMutationSnapshot(person.personId), wrongTeamBefore); trace.push({ phase: "negative-gate", gate: "cross-department-team", - status: wrongTeam.status(), - code: wrongTeamBody.code, - }); - let resolveFirstAction!: () => void; - let rejectFirstAction!: (cause: unknown) => void; - let resolveSecondAction!: () => void; - let rejectSecondAction!: (cause: unknown) => void; - - const firstActionSettled = new Promise((resolve, reject) => { - resolveFirstAction = resolve; - rejectFirstAction = reject; + status: wrongTeam.status, + code: wrongTeam.code, }); + // The route handler below settles these; the journey awaits them as steps. + const firstActionSettled = Promise.withResolvers(); + const secondActionSettled = Promise.withResolvers(); - const secondActionSettled = new Promise((resolve, reject) => { - resolveSecondAction = resolve; - rejectSecondAction = reject; - }); + const intercept = (route: Route): Promise => { + if (route.request().method() !== "POST") return route.continue(); - await returning.route("**/dashboard/tidligere-assistenter*", async (route) => { - try { - if (route.request().method() !== "POST") { - await route.continue(); + interceptedActions += 1; + const formData = new URLSearchParams(route.request().postData() ?? ""); + const commandKey = formData.get("commandId"); + const expectedRevision = formData.get("expectedRevision"); + const phase = droppedResponse ? "retry" : "first"; + stage?.(`returning:mutation:${phase}:request`); - return; - } - - interceptedActions += 1; - const formData = new URLSearchParams(route.request().postData() ?? ""); - const commandKey = formData.get("commandId"); - const expectedRevision = formData.get("expectedRevision"); - const phase = droppedResponse ? "retry" : "first"; - stage?.(`returning:mutation:${phase}:request`); - const response = await route.fetch({ timeout: 30_000 }); + return route.fetch({ timeout: 30_000 }).then((response) => { stage?.(`returning:mutation:${phase}:response`); const status = response.status(); trace.push({ @@ -1049,40 +1233,50 @@ export const runReturningAssistantBrowserJourney = async ({ if (status < 200 || status >= 300) routeFailure = `first action status ${status}`; trace.push({ phase: "first-delivery", transport: "aborted", fetchedStatus: status }); - await response.body(); - await route.abort("failed"); - resolveFirstAction(); - return; + return response + .body() + .then(() => route.abort("failed")) + .then(() => firstActionSettled.resolve()); } if (commandKey !== firstCommandKey || expectedRevision !== firstExpectedRevision) routeFailure = "retry payload identity changed"; if (status < 200 || status >= 300) routeFailure = `retry action status ${status}`; - await route.fulfill({ response }); - resolveSecondAction(); - } catch (cause) { - routeFailure = `intercepted ${interceptedActions === 1 ? "first" : "retry"} action failed`; - (interceptedActions === 1 ? rejectFirstAction : rejectSecondAction)(cause); - } - }); + + return route.fulfill({ response }).then(() => secondActionSettled.resolve()); + }); + }; + + yield* step(() => + returning.route("**/dashboard/tidligere-assistenter*", (route) => + Promise.resolve() + .then(() => intercept(route)) + .catch((cause: unknown) => { + routeFailure = `intercepted ${interceptedActions === 1 ? "first" : "retry"} action failed`; + (interceptedActions === 1 ? firstActionSettled : secondActionSettled).reject(cause); + }), + ), + ); stage?.("returning:mutation"); let firstCommittedRow: unknown; - try { + yield* Effect.gen(function* () { stage?.("returning:mutation:first:click"); - await submit.click(); + yield* step(() => submit.click()); stage?.("returning:mutation:first:await"); - await firstActionSettled; + yield* step(() => firstActionSettled.promise); stage?.("returning:mutation:first:settled"); - const firstCommittedBeforeRetry = await pool.query( - `SELECT * + const firstCommittedBeforeRetry = yield* step(() => + pool.query( + `SELECT * FROM public.admission_returning_registrations WHERE person_id=$1 AND admission_period_id=$2 ORDER BY revision`, - [person.personId, nextAdmissionPeriodId], + [person.personId, nextAdmissionPeriodId], + ), ); assert.ok(firstCommandKey); @@ -1092,200 +1286,232 @@ export const runReturningAssistantBrowserJourney = async ({ stage?.("returning:mutation:recovery"); const recovery = returning.getByRole("button", { name: "Prøv igjen", exact: true }); - const hasRecoveryControl = await recovery - .waitFor({ state: "visible", timeout: 5_000 }) - .then(() => true) - .catch(() => false); + const hasRecoveryControl = yield* step(() => + recovery + .waitFor({ state: "visible", timeout: 5_000 }) + .then(() => true) + .catch(() => false), + ); const firstRequest = trace.find((entry) => entry.phase === "first"); assert.ok(firstRequest && Array.isArray(firstRequest.form)); - const assertRecoveredIntent = async () => { + const assertRecoveredIntent = Effect.fnUntraced(function* () { let lastError: unknown; for (let attempt = 0; attempt < 50; attempt += 1) { - try { - const restoredEntries = await form.evaluate((node) => { - if (!(node instanceof HTMLFormElement)) - throw new Error("Expected the returning-assistant form"); - - return [...new FormData(node)].map(([name, value]) => [name, String(value)]); - }, undefined); - - if (JSON.stringify(restoredEntries) !== JSON.stringify(firstRequest.form)) - throw new Error( - `recovered form intent mismatch actual=${JSON.stringify(restoredEntries)} expected=${JSON.stringify(firstRequest.form)}`, + const outcome = yield* Effect.exit( + Effect.gen(function* () { + const restoredEntries = yield* step(() => + form.evaluate((node) => { + if (!(node instanceof HTMLFormElement)) + throw new Error("Expected the returning-assistant form"); + + return [...new FormData(node)].map(([name, value]) => [name, String(value)]); + }, undefined), ); - const recoveredCommandId = await form.locator('input[name="commandId"]').inputValue(); - if (recoveredCommandId !== firstCommandKey) - throw new Error( - `recovered command id mismatch actual=${recoveredCommandId} expected=${firstCommandKey}`, + if ((yield* jsonText(restoredEntries)) !== (yield* jsonText(firstRequest.form))) + throw new Error( + `recovered form intent mismatch actual=${yield* jsonText(restoredEntries)} expected=${yield* jsonText(firstRequest.form)}`, + ); + + const recoveredCommandId = yield* step(() => + form.locator('input[name="commandId"]').inputValue(), ); - const recoveredRevision = await form - .locator('input[name="expectedRevision"]') - .inputValue(); + if (recoveredCommandId !== firstCommandKey) + throw new Error( + `recovered command id mismatch actual=${recoveredCommandId} expected=${firstCommandKey}`, + ); - if (recoveredRevision !== firstExpectedRevision) - throw new Error( - `recovered base revision mismatch actual=${recoveredRevision} expected=${firstExpectedRevision}`, + const recoveredRevision = yield* step(() => + form.locator('input[name="expectedRevision"]').inputValue(), ); - return; - } catch (cause) { - lastError = cause; - await new Promise((resolve) => setTimeout(resolve, 100)); - } + if (recoveredRevision !== firstExpectedRevision) + throw new Error( + `recovered base revision mismatch actual=${recoveredRevision} expected=${firstExpectedRevision}`, + ); + }), + ); + + if (Exit.isSuccess(outcome)) return; + lastError = thrownBy(outcome.cause); + yield* Effect.sleep("100 millis"); } throw lastError; - }; + }); - if (hasRecoveryControl) await recovery.click(); + if (hasRecoveryControl === true) yield* step(() => recovery.click()); form = returning.getByRole("form", { name: "Registrer som tidligere assistent" }); - await form.waitFor({ state: "visible" }); + yield* step(() => form.waitFor({ state: "visible" })); submit = form.locator('button[type="submit"]'); - await submit.waitFor({ state: "visible" }); - assert.equal(await submit.isEnabled(), true); - assert.equal(await form.getAttribute("data-pending"), "false"); - await assertRecoveredIntent(); + yield* step(() => submit.waitFor({ state: "visible" })); + assert.equal(yield* step(() => submit.isEnabled()), true); + assert.equal(yield* step(() => form.getAttribute("data-pending")), "false"); + yield* assertRecoveredIntent(); stage?.("returning:retry"); stage?.("returning:mutation:retry:click"); - await submit.click(); + yield* step(() => submit.click()); stage?.("returning:mutation:retry:await"); - await secondActionSettled; - } catch (cause) { - await captureReturningFailure("submit", cause); - } + yield* step(() => secondActionSettled.promise); + }).pipe(capturedAs("submit")); assert.equal(interceptedActions, 2); assert.equal(droppedResponse, true); assert.equal(routeFailure, undefined); - const captureCommitted = async (phase: string, periodId: string) => { - const committed = await pool.query( - `SELECT * + const captureCommitted = Effect.fnUntraced(function* (phase: string, periodId: string) { + const committed = yield* step(() => + pool.query( + `SELECT * FROM public.admission_returning_registrations WHERE person_id=$1 AND admission_period_id=$2 ORDER BY revision`, - [person.personId, periodId], + [person.personId, periodId], + ), ); trace.push({ phase, sqlCommitted: committed.rows }); - await writeFile( - join(artifacts, "returning-registration-trace.json"), - JSON.stringify(trace, null, 2), - ); - }; + yield* writeTrace; + }); - const afterRetryCommitted = await pool.query( - `SELECT * + const afterRetryCommitted = yield* step(() => + pool.query( + `SELECT * FROM public.admission_returning_registrations WHERE person_id=$1 AND admission_period_id=$2 ORDER BY revision`, - [person.personId, nextAdmissionPeriodId], + [person.personId, nextAdmissionPeriodId], + ), ); assert.equal(afterRetryCommitted.rows.length, 1); assert.deepEqual(afterRetryCommitted.rows[0], firstCommittedRow); - await returning.unroute("**/dashboard/tidligere-assistenter*"); - await returning.reload(); + yield* step(() => returning.unroute("**/dashboard/tidligere-assistenter*")); + yield* step(() => returning.reload()); const reloaded = returning.getByRole("form", { name: "Registrer som tidligere assistent" }); - await expectValue( + yield* expectValue( reloaded.getByRole("combobox", { name: "Opptaksperiode" }), nextAdmissionPeriodId, ); - await expectValue(reloaded.getByRole("combobox", { name: "Studieår" }), "4"); - await expectValue(reloaded.getByRole("combobox", { name: "Stillingslengde" }), "8"); - await expectValue(reloaded.getByRole("combobox", { name: "Semesterblokk" }), "block-1"); - await expectValue(reloaded.getByRole("combobox", { name: "Språk" }), "Norsk og engelsk"); - assert.equal(await reloaded.getByLabel("Mandag", { exact: true }).isChecked(), true); - assert.equal(await reloaded.getByLabel("Torsdag", { exact: true }).isChecked(), true); + yield* expectValue(reloaded.getByRole("combobox", { name: "Studieår" }), "4"); + yield* expectValue(reloaded.getByRole("combobox", { name: "Stillingslengde" }), "8"); + yield* expectValue(reloaded.getByRole("combobox", { name: "Semesterblokk" }), "block-1"); + yield* expectValue(reloaded.getByRole("combobox", { name: "Språk" }), "Norsk og engelsk"); + assert.equal( + yield* step(() => reloaded.getByLabel("Mandag", { exact: true }).isChecked()), + true, + ); assert.equal( - await reloaded.getByLabel("Ønsket skole (valgfritt)", { exact: true }).inputValue(), + yield* step(() => reloaded.getByLabel("Torsdag", { exact: true }).isChecked()), + true, + ); + assert.equal( + yield* step(() => + reloaded.getByLabel("Ønsket skole (valgfritt)", { exact: true }).inputValue(), + ), "Returning School", ); - await reloaded - .getByRole("combobox", { name: "Opptaksperiode" }) - .selectOption(admissionPeriodId); - await returning.waitForURL( - new RegExp(`/dashboard/tidligere-assistenter\\?admissionPeriodId=${admissionPeriodId}$`), + yield* step(() => + reloaded.getByRole("combobox", { name: "Opptaksperiode" }).selectOption(admissionPeriodId), + ); + yield* step(() => + returning.waitForURL( + new RegExp(`/dashboard/tidligere-assistenter\\?admissionPeriodId=${admissionPeriodId}$`), + ), ); for ( let attempt = 0; attempt < 100 && - !(await reloaded.getByRole("combobox", { name: "Opptaksperiode" }).isEnabled()); + (yield* step(() => + reloaded.getByRole("combobox", { name: "Opptaksperiode" }).isEnabled(), + )) !== true; attempt += 1 ) - await new Promise((resolve) => setTimeout(resolve, 100)); - await reloaded.getByRole("combobox", { name: "Studieår" }).selectOption("2"); - await reloaded.getByLabel("Torsdag", { exact: true }).uncheck(); - await reloaded.getByRole("combobox", { name: "Stillingslengde" }).selectOption("4"); - await reloaded.getByRole("combobox", { name: "Semesterblokk" }).selectOption("all"); - await reloaded.getByRole("combobox", { name: "Språk" }).selectOption("Norsk og engelsk"); - await reloaded.getByLabel("Ønsket skole (valgfritt)", { exact: true }).fill(""); - await reloaded.getByLabel("Jeg er interessert i teamarbeid", { exact: true }).uncheck(); - await reloaded.locator(`input[name="teamIds"][value="${teamId}"]`).uncheck(); - - const nativePost = await waitForDashboardAction(admissionPeriodId, async () => { - await waitForActionReady(reloaded); - await reloaded.locator('button[type="submit"]').click(); - }); + yield* Effect.sleep("100 millis"); + yield* step(() => reloaded.getByRole("combobox", { name: "Studieår" }).selectOption("2")); + yield* step(() => reloaded.getByLabel("Torsdag", { exact: true }).uncheck()); + yield* step(() => + reloaded.getByRole("combobox", { name: "Stillingslengde" }).selectOption("4"), + ); + yield* step(() => + reloaded.getByRole("combobox", { name: "Semesterblokk" }).selectOption("all"), + ); + yield* step(() => + reloaded.getByRole("combobox", { name: "Språk" }).selectOption("Norsk og engelsk"), + ); + yield* step(() => reloaded.getByLabel("Ønsket skole (valgfritt)", { exact: true }).fill("")); + yield* step(() => + reloaded.getByLabel("Jeg er interessert i teamarbeid", { exact: true }).uncheck(), + ); + yield* step(() => reloaded.locator(`input[name="teamIds"][value="${teamId}"]`).uncheck()); + + const nativePost = yield* waitForDashboardAction( + admissionPeriodId, + Effect.fnUntraced(function* () { + yield* waitForActionReady(reloaded); + yield* step(() => reloaded.locator('button[type="submit"]').click()); + }), + ); assert.equal(nativePost.admissionPeriodId, admissionPeriodId); assert.equal(nativePost.expectedRevision, "0"); - try { - await assertStatus(reloaded, "Registreringen er lagret."); - } catch (cause) { - await captureReturningFailure("existing-registration-status", cause); - } + yield* assertStatus(reloaded, "Registreringen er lagret.").pipe( + capturedAs("existing-registration-status"), + ); - await captureCommitted("existing-period-after-registration", admissionPeriodId); + yield* captureCommitted("existing-period-after-registration", admissionPeriodId); - await returning.reload(); + yield* step(() => returning.reload()); const existing = returning.getByRole("form", { name: "Registrer som tidligere assistent" }); - await expectValue( + yield* expectValue( existing.getByRole("combobox", { name: "Opptaksperiode" }), admissionPeriodId, ); - await expectValue(existing.getByRole("combobox", { name: "Studieår" }), "2"); - await expectValue(existing.getByRole("combobox", { name: "Stillingslengde" }), "4"); - await expectValue(existing.getByRole("combobox", { name: "Semesterblokk" }), "all"); - await expectValue(existing.getByRole("combobox", { name: "Språk" }), "Norsk og engelsk"); - await existing.getByRole("combobox", { name: "Studieår" }).selectOption("3"); - await existing.getByRole("combobox", { name: "Språk" }).selectOption("Engelsk"); + yield* expectValue(existing.getByRole("combobox", { name: "Studieår" }), "2"); + yield* expectValue(existing.getByRole("combobox", { name: "Stillingslengde" }), "4"); + yield* expectValue(existing.getByRole("combobox", { name: "Semesterblokk" }), "all"); + yield* expectValue(existing.getByRole("combobox", { name: "Språk" }), "Norsk og engelsk"); + yield* step(() => existing.getByRole("combobox", { name: "Studieår" }).selectOption("3")); + yield* step(() => existing.getByRole("combobox", { name: "Språk" }).selectOption("Engelsk")); - const updatePost = await waitForDashboardAction(admissionPeriodId, async () => { - await waitForActionReady(existing); - await existing.getByRole("button", { name: "Lagre endringer" }).click(); - }); + const updatePost = yield* waitForDashboardAction( + admissionPeriodId, + Effect.fnUntraced(function* () { + yield* waitForActionReady(existing); + yield* step(() => existing.getByRole("button", { name: "Lagre endringer" }).click()); + }), + ); assert.equal(updatePost.admissionPeriodId, admissionPeriodId); assert.equal(updatePost.expectedRevision, "1"); - try { - await assertStatus(existing, "Registreringen er lagret."); - } catch (cause) { - await captureReturningFailure("update-status", cause); - } + yield* assertStatus(existing, "Registreringen er lagret.").pipe(capturedAs("update-status")); - await captureCommitted("existing-period-after-update", admissionPeriodId); - await returning.reload(); + yield* captureCommitted("existing-period-after-update", admissionPeriodId); + yield* step(() => returning.reload()); const updated = returning.getByRole("form", { name: "Registrer som tidligere assistent" }); - await expectValue(updated.getByRole("combobox", { name: "Opptaksperiode" }), admissionPeriodId); + yield* expectValue( + updated.getByRole("combobox", { name: "Opptaksperiode" }), + admissionPeriodId, + ); stage?.("returning:period-history"); const periodSelector = updated.getByRole("combobox", { name: "Opptaksperiode" }); - const waitForPeriodUrl = async (periodId: string) => { - await returning.waitForURL( - new RegExp(`/dashboard/tidligere-assistenter\\?admissionPeriodId=${periodId}$`), + const waitForPeriodUrl = Effect.fnUntraced(function* (periodId: string) { + yield* step(() => + returning.waitForURL( + new RegExp(`/dashboard/tidligere-assistenter\\?admissionPeriodId=${periodId}$`), + ), ); - }; + }); - const waitForPeriodForm = async (periodId: string) => { + const waitForPeriodForm = Effect.fnUntraced(function* (periodId: string) { let actual = ""; for (let attempt = 0; attempt < 100; attempt += 1) { @@ -1294,82 +1520,92 @@ export const runReturningAssistantBrowserJourney = async ({ }); const selector = candidate.getByRole("combobox", { name: "Opptaksperiode" }); - actual = await selector.inputValue(); + actual = yield* step(() => selector.inputValue()); - if (actual === periodId && (await selector.isEnabled())) return candidate; - await new Promise((resolve) => setTimeout(resolve, 100)); + if (actual === periodId && (yield* step(() => selector.isEnabled())) === true) + return candidate; + yield* Effect.sleep("100 millis"); } throw new Error( - `period form did not settle actual=${JSON.stringify(actual)} expected=${JSON.stringify(periodId)}`, + `period form did not settle actual=${yield* jsonText(actual)} expected=${yield* jsonText(periodId)}`, ); - }; + }); - await periodSelector.selectOption(nextAdmissionPeriodId); - await waitForPeriodUrl(nextAdmissionPeriodId); - let periodForm = await waitForPeriodForm(nextAdmissionPeriodId); - await expectValue( + yield* step(() => periodSelector.selectOption(nextAdmissionPeriodId)); + yield* waitForPeriodUrl(nextAdmissionPeriodId); + let periodForm = yield* waitForPeriodForm(nextAdmissionPeriodId); + yield* expectValue( periodForm.getByRole("combobox", { name: "Opptaksperiode" }), nextAdmissionPeriodId, ); - await expectValue(periodForm.locator('input[name="expectedRevision"]'), "1"); - await expectValue(periodForm.getByRole("combobox", { name: "Studieår" }), "4"); - await expectValue(periodForm.getByRole("combobox", { name: "Stillingslengde" }), "8"); - await periodSelector.selectOption(admissionPeriodId); - await waitForPeriodUrl(admissionPeriodId); - periodForm = await waitForPeriodForm(admissionPeriodId); - await expectValue( + yield* expectValue(periodForm.locator('input[name="expectedRevision"]'), "1"); + yield* expectValue(periodForm.getByRole("combobox", { name: "Studieår" }), "4"); + yield* expectValue(periodForm.getByRole("combobox", { name: "Stillingslengde" }), "8"); + yield* step(() => periodSelector.selectOption(admissionPeriodId)); + yield* waitForPeriodUrl(admissionPeriodId); + periodForm = yield* waitForPeriodForm(admissionPeriodId); + yield* expectValue( periodForm.getByRole("combobox", { name: "Opptaksperiode" }), admissionPeriodId, ); - await expectValue(periodForm.locator('input[name="expectedRevision"]'), "2"); - await expectValue(periodForm.getByRole("combobox", { name: "Studieår" }), "3"); - await expectValue(periodForm.getByRole("combobox", { name: "Språk" }), "Engelsk"); - await returning.goBack(); - await waitForPeriodUrl(nextAdmissionPeriodId); - periodForm = await waitForPeriodForm(nextAdmissionPeriodId); - await expectValue( + yield* expectValue(periodForm.locator('input[name="expectedRevision"]'), "2"); + yield* expectValue(periodForm.getByRole("combobox", { name: "Studieår" }), "3"); + yield* expectValue(periodForm.getByRole("combobox", { name: "Språk" }), "Engelsk"); + yield* step(() => returning.goBack()); + yield* waitForPeriodUrl(nextAdmissionPeriodId); + periodForm = yield* waitForPeriodForm(nextAdmissionPeriodId); + yield* expectValue( periodForm.getByRole("combobox", { name: "Opptaksperiode" }), nextAdmissionPeriodId, ); - await expectValue(periodForm.locator('input[name="expectedRevision"]'), "1"); - await expectValue(periodForm.getByRole("combobox", { name: "Studieår" }), "4"); - await returning.goForward(); - await waitForPeriodUrl(admissionPeriodId); - periodForm = await waitForPeriodForm(admissionPeriodId); - await expectValue( + yield* expectValue(periodForm.locator('input[name="expectedRevision"]'), "1"); + yield* expectValue(periodForm.getByRole("combobox", { name: "Studieår" }), "4"); + yield* step(() => returning.goForward()); + yield* waitForPeriodUrl(admissionPeriodId); + periodForm = yield* waitForPeriodForm(admissionPeriodId); + yield* expectValue( periodForm.getByRole("combobox", { name: "Opptaksperiode" }), admissionPeriodId, ); - await expectValue(periodForm.locator('input[name="expectedRevision"]'), "2"); - await periodSelector.selectOption(nextAdmissionPeriodId); - await waitForPeriodUrl(nextAdmissionPeriodId); - periodForm = await waitForPeriodForm(nextAdmissionPeriodId); - await expectValue( + yield* expectValue(periodForm.locator('input[name="expectedRevision"]'), "2"); + yield* step(() => periodSelector.selectOption(nextAdmissionPeriodId)); + yield* waitForPeriodUrl(nextAdmissionPeriodId); + periodForm = yield* waitForPeriodForm(nextAdmissionPeriodId); + yield* expectValue( periodForm.getByRole("combobox", { name: "Opptaksperiode" }), nextAdmissionPeriodId, ); - await expectValue(periodForm.locator('input[name="expectedRevision"]'), "1"); - await expectValue(periodForm.getByRole("combobox", { name: "Studieår" }), "4"); + yield* expectValue(periodForm.locator('input[name="expectedRevision"]'), "1"); + yield* expectValue(periodForm.getByRole("combobox", { name: "Studieår" }), "4"); stage?.("returning:registration-conflict"); - assert.equal(await periodForm.locator('input[name="expectedRevision"]').inputValue(), "1"); - assert.equal(await periodForm.locator('input[name="commandId"]').inputValue(), ""); - await periodForm.getByRole("combobox", { name: "Studieår" }).selectOption("5"); - const staleContext = await browser.newContext({ storageState: await context.storageState() }); - const staleReturning = await staleContext.newPage(); - - try { - await staleReturning.goto( - `${ui}/dashboard/tidligere-assistenter?admissionPeriodId=${encodeURIComponent(nextAdmissionPeriodId)}`, + assert.equal( + yield* step(() => periodForm.locator('input[name="expectedRevision"]').inputValue()), + "1", + ); + assert.equal(yield* step(() => periodForm.locator('input[name="commandId"]').inputValue()), ""); + yield* step(() => periodForm.getByRole("combobox", { name: "Studieår" }).selectOption("5")); + const staleStorageState = yield* step(() => context.storageState()); + const staleContext = yield* step(() => browser.newContext({ storageState: staleStorageState })); + const staleReturning = yield* step(() => staleContext.newPage()); + + yield* Effect.gen(function* () { + yield* step(() => + staleReturning.goto( + `${ui}/dashboard/tidligere-assistenter?admissionPeriodId=${encodeURIComponent(nextAdmissionPeriodId)}`, + ), ); const staleForm = staleReturning.getByRole("form", { name: "Registrer som tidligere assistent", }); - await staleForm.waitFor({ state: "visible" }); - assert.equal(await staleForm.locator('input[name="expectedRevision"]').inputValue(), "1"); - await staleForm.getByRole("combobox", { name: "Studieår" }).selectOption("5"); + yield* step(() => staleForm.waitFor({ state: "visible" })); + assert.equal( + yield* step(() => staleForm.locator('input[name="expectedRevision"]').inputValue()), + "1", + ); + yield* step(() => staleForm.getByRole("combobox", { name: "Studieår" }).selectOption("5")); const staleSaveResponsePromise = staleReturning.waitForResponse( (response) => { @@ -1391,22 +1627,24 @@ export const runReturningAssistantBrowserJourney = async ({ { timeout: 30_000 }, ); - await staleForm.getByRole("button", { name: "Lagre endringer" }).click(); - const staleSaveResponse = await staleSaveResponsePromise; - await staleSaveResponse.finished(); + yield* step(() => staleForm.getByRole("button", { name: "Lagre endringer" }).click()); + const staleSaveResponse = yield* step(() => staleSaveResponsePromise); + yield* step(() => staleSaveResponse.finished()); assert.equal(staleSaveResponse.status(), 200); const staleSavePost = new URLSearchParams(staleSaveResponse.request().postData() ?? ""); assert.equal(staleSavePost.get("expectedRevision"), "1"); assert.ok(Predicate.isString(staleSavePost.get("commandId"))); assert.notEqual(staleSavePost.get("commandId"), ""); - const afterStaleSave = await pool.query( - `SELECT revision,year_of_study + const afterStaleSave = yield* step(() => + pool.query( + `SELECT revision,year_of_study FROM public.admission_returning_registrations WHERE person_id=$1 AND admission_period_id=$2 ORDER BY revision DESC LIMIT 1`, - [person.personId, nextAdmissionPeriodId], + [person.personId, nextAdmissionPeriodId], + ), ); assert.deepEqual(afterStaleSave.rows, [{ revision: 2, year_of_study: 5 }]); @@ -1431,10 +1669,10 @@ export const runReturningAssistantBrowserJourney = async ({ { timeout: 30_000 }, ); - await waitForActionReady(periodForm); - await periodForm.getByRole("button", { name: "Lagre endringer" }).click(); - const staleDraftResponse = await staleDraftResponsePromise; - await staleDraftResponse.finished(); + yield* waitForActionReady(periodForm); + yield* step(() => periodForm.getByRole("button", { name: "Lagre endringer" }).click()); + const staleDraftResponse = yield* step(() => staleDraftResponsePromise); + yield* step(() => staleDraftResponse.finished()); assert.equal(staleDraftResponse.status(), 412); const staleDraftPost = new URLSearchParams(staleDraftResponse.request().postData() ?? ""); const staleDraftCommandId = staleDraftPost.get("commandId"); @@ -1442,11 +1680,19 @@ export const runReturningAssistantBrowserJourney = async ({ assert.ok(Predicate.isString(staleDraftCommandId)); assert.notEqual(staleDraftCommandId, ""); assert.notEqual(staleDraftCommandId, staleSavePost.get("commandId")); - await periodForm.getByRole("alert").filter({ hasText: "Alternativene er endret." }).waitFor(); - assert.equal(await periodForm.locator('input[name="expectedRevision"]').inputValue(), "1"); - assert.equal(await periodForm.getByRole("combobox", { name: "Studieår" }).inputValue(), "5"); + yield* step(() => + periodForm.getByRole("alert").filter({ hasText: "Alternativene er endret." }).waitFor(), + ); assert.equal( - await periodForm.locator('input[name="commandId"]').inputValue(), + yield* step(() => periodForm.locator('input[name="expectedRevision"]').inputValue()), + "1", + ); + assert.equal( + yield* step(() => periodForm.getByRole("combobox", { name: "Studieår" }).inputValue()), + "5", + ); + assert.equal( + yield* step(() => periodForm.locator('input[name="commandId"]').inputValue()), staleDraftCommandId, ); trace.push({ @@ -1455,52 +1701,70 @@ export const runReturningAssistantBrowserJourney = async ({ status: 412, expectedRevision: staleDraftPost.get("expectedRevision"), commandId: staleDraftPost.get("commandId"), - draftYearOfStudy: await periodForm.getByRole("combobox", { name: "Studieår" }).inputValue(), + draftYearOfStudy: yield* step(() => + periodForm.getByRole("combobox", { name: "Studieår" }).inputValue(), + ), }); - await periodForm.getByRole("button", { name: "Forkast lagret utkast" }).click(); - await returning.waitForLoadState("domcontentloaded"); + yield* step(() => periodForm.getByRole("button", { name: "Forkast lagret utkast" }).click()); + yield* step(() => returning.waitForLoadState("domcontentloaded")); periodForm = returning.getByRole("form", { name: "Registrer som tidligere assistent" }); - await periodForm.waitFor({ state: "visible" }); - await expectValue(periodForm.locator('input[name="expectedRevision"]'), "2"); - await expectValue(periodForm.getByRole("combobox", { name: "Studieår" }), "5"); - const recoveredCommandId = await periodForm.locator('input[name="commandId"]').inputValue(); + yield* step(() => periodForm.waitFor({ state: "visible" })); + yield* expectValue(periodForm.locator('input[name="expectedRevision"]'), "2"); + yield* expectValue(periodForm.getByRole("combobox", { name: "Studieår" }), "5"); + + const recoveredCommandId = yield* step(() => + periodForm.locator('input[name="commandId"]').inputValue(), + ); + assert.equal(recoveredCommandId, ""); - const recoveredPost = await waitForDashboardAction(nextAdmissionPeriodId, async () => { - await waitForActionReady(periodForm); - await periodForm.getByRole("button", { name: "Lagre endringer" }).click(); - }); + const recoveredPost = yield* waitForDashboardAction( + nextAdmissionPeriodId, + Effect.fnUntraced(function* () { + yield* waitForActionReady(periodForm); + yield* step(() => periodForm.getByRole("button", { name: "Lagre endringer" }).click()); + }), + ); assert.equal(recoveredPost.expectedRevision, "2"); assert.notEqual(recoveredPost.commandId, staleDraftPost.get("commandId")); - await assertStatus(periodForm, "Registreringen er lagret."); - } finally { - await staleReturning.close(); - await staleContext.close(); - } + yield* assertStatus(periodForm, "Registreringen er lagret."); + }).pipe( + Effect.ensuring( + step(() => staleReturning.close()).pipe( + Effect.andThen(step(() => staleContext.close())), + Effect.orDie, + ), + ), + ); - const nextPeriodRevision = await pool.query( - `SELECT revision,year_of_study + const nextPeriodRevision = yield* step(() => + pool.query( + `SELECT revision,year_of_study FROM public.admission_returning_registrations WHERE person_id=$1 AND admission_period_id=$2 ORDER BY revision DESC LIMIT 1`, - [person.personId, nextAdmissionPeriodId], + [person.personId, nextAdmissionPeriodId], + ), ); assert.deepEqual(nextPeriodRevision.rows, [{ revision: 3, year_of_study: 5 }]); // Audit the settled form. The status appears while the fetcher still revalidates, so the // submit button is disabled and then fades in; Axe must not read its colors mid-transition. - await waitForActionReady(periodForm); - await periodForm - .locator('button[type="submit"]') - .evaluate((button) => - Promise.all(button.getAnimations().map((animation) => animation.finished)), - ); - await auditPage(returning, "returning-registration"); - - const finalCustody = await pool.query( - `SELECT + yield* waitForActionReady(periodForm); + yield* step(() => + periodForm + .locator('button[type="submit"]') + .evaluate((button) => + Promise.all(button.getAnimations().map((animation) => animation.finished)), + ), + ); + yield* auditPage(returning, "returning-registration"); + + const finalCustody = yield* step(() => + pool.query( + `SELECT to_jsonb(application) - 'year_of_study' - 'revision' AS application_immutable, to_jsonb(applicant) - 'activation_digest' AS applicant_profile, applicant.activation_digest, @@ -1523,18 +1787,21 @@ export const runReturningAssistantBrowserJourney = async ({ FROM public.admission_applications application JOIN public.admission_applicants applicant USING(applicant_id) WHERE application.application_id=$1`, - [applicationId], + [applicationId], + ), ); const finalCustodyRow = finalCustody.rows[0]; assert.ok(finalCustodyRow); assert.equal(finalCustodyRow.activation_digest, originalActivationDigest); assert.ok( - Schema.decodeUnknownSync(Schema.Array(Schema.Json))(finalCustodyRow.public_receipts).length > - 0, + (yield* Schema.decodeUnknownEffect(Schema.Array(Schema.Json))( + finalCustodyRow.public_receipts, + )).length > 0, ); assert.ok( - Schema.decodeUnknownSync(Schema.Array(Schema.Json))(finalCustodyRow.public_audit).length > 0, + (yield* Schema.decodeUnknownEffect(Schema.Array(Schema.Json))(finalCustodyRow.public_audit)) + .length > 0, ); assert.deepEqual(finalCustody.rows, originalCustody.rows); trace.push({ @@ -1543,54 +1810,78 @@ export const runReturningAssistantBrowserJourney = async ({ status: "observed", }); - const nextInterviews = await pool.query( - `SELECT count(*)::int AS count + const nextInterviews = yield* step(() => + pool.query( + `SELECT count(*)::int AS count FROM public.recruitment_interviews interview JOIN public.admission_applications application USING(application_id) WHERE application.admission_period_id=$1`, - [nextAdmissionPeriodId], + [nextAdmissionPeriodId], + ), ); assert.deepEqual(nextInterviews.rows, [{ count: 0 }]); trace.push({ phase: "negative-gate", gate: "new-period-no-new-interview", status: "observed" }); - const ordinaryConduct = await pool.query( - `SELECT c.recommendation,c.explanatory_power,c.role_model,c.suitability + const ordinaryConduct = yield* step(() => + pool.query( + `SELECT c.recommendation,c.explanatory_power,c.role_model,c.suitability FROM public.recruitment_interview_conducts c WHERE c.interview_id='interview-native-conduct-a-0063'`, + ), ); if (ordinaryConduct.rows.length === 0) { - await page.goto(`${ui}/dashboard/intervjuer`); - await page.getByRole("heading", { name: "Planlegg intervjuer", exact: true }).waitFor(); + yield* step(() => page.goto(`${ui}/dashboard/intervjuer`)); + yield* step(() => + page.getByRole("heading", { name: "Planlegg intervjuer", exact: true }).waitFor(), + ); const ordinaryCard = page.getByRole("article").filter({ hasText: "Sofie Gjennomfører" }); - await ordinaryCard.getByRole("button", { name: "Åpne intervju", exact: true }).click(); - await page - .getByRole("heading", { name: "Intervju med Sofie Gjennomfører", exact: true }) - .waitFor(); - await page - .locator("#question-interview-schema-native-conduct-0063-q0") - .fill("Jeg vil forklare matematikk tydelig."); - await page.locator("#question-interview-schema-native-conduct-0063-q1-1").check(); - await page.locator("#question-interview-schema-native-conduct-0063-q2-0").check(); - await page.locator("#question-interview-schema-native-conduct-0063-q3-0").check(); + yield* step(() => + ordinaryCard.getByRole("button", { name: "Åpne intervju", exact: true }).click(), + ); + yield* step(() => + page + .getByRole("heading", { name: "Intervju med Sofie Gjennomfører", exact: true }) + .waitFor(), + ); + yield* step(() => + page + .locator("#question-interview-schema-native-conduct-0063-q0") + .fill("Jeg vil forklare matematikk tydelig."), + ); + yield* step(() => + page.locator("#question-interview-schema-native-conduct-0063-q1-1").check(), + ); + yield* step(() => + page.locator("#question-interview-schema-native-conduct-0063-q2-0").check(), + ); + yield* step(() => + page.locator("#question-interview-schema-native-conduct-0063-q3-0").check(), + ); for (const axis of ["explanatoryPower", "roleModel", "suitability"]) - await page.locator(`#score-${axis}`).selectOption("8"); - await page.locator("#interviewer-recommendation").selectOption("Ja"); - await page.getByRole("button", { name: "Fullfør intervju", exact: true }).click(); - await page - .getByRole("dialog") - .getByRole("button", { name: "Fullfør intervju", exact: true }) - .press("Enter"); - await page.getByText("Intervjuet er fullført.", { exact: true }).waitFor(); - await page.reload(); + yield* step(() => page.locator(`#score-${axis}`).selectOption("8")); + yield* step(() => page.locator("#interviewer-recommendation").selectOption("Ja")); + yield* step(() => + page.getByRole("button", { name: "Fullfør intervju", exact: true }).click(), + ); + yield* step(() => + page + .getByRole("dialog") + .getByRole("button", { name: "Fullfør intervju", exact: true }) + .press("Enter"), + ); + yield* step(() => page.getByText("Intervjuet er fullført.", { exact: true }).waitFor()); + yield* step(() => page.reload()); } - const ordinaryAfter = await pool.query( - `SELECT c.recommendation,c.explanatory_power,c.role_model,c.suitability + const ordinaryAfter = yield* step(() => + pool.query( + `SELECT c.recommendation,c.explanatory_power,c.role_model,c.suitability FROM public.recruitment_interview_conducts c WHERE c.interview_id='interview-native-conduct-a-0063'`, + ), ); assert.deepEqual(ordinaryAfter.rows, [ @@ -1649,56 +1940,42 @@ export const runReturningAssistantBrowserJourney = async ({ }, ] as const; - const concurrentDetails = await Promise.all( - concurrentRevisions.map(async ({ idempotencyKey, payload }) => { - const response = await context.request.post( - `${api}/api/returning-assistant/registrations`, - { - headers: { - "content-type": "application/json", - "idempotency-key": idempotencyKey, - origin: ui, - }, - data: payload, - }, - ); - - return { idempotencyKey, payload, status: response.status(), body: await response.text() }; - }), + const concurrentDetails = yield* Effect.all( + concurrentRevisions.map(({ idempotencyKey, payload }) => + register(idempotencyKey, payload).pipe( + Effect.map((answer) => ({ idempotencyKey, payload, status: answer.status, answer })), + ), + ), + { concurrency: "unbounded" }, ); assert.deepEqual( concurrentDetails.map(({ status }) => status).sort((left, right) => left - right), - [201, 412], - JSON.stringify(concurrentDetails), + [200, 412], + yield* jsonText(concurrentDetails), ); - const concurrentWinner = concurrentDetails.find(({ status }) => status === 201); + const concurrentWinner = concurrentDetails.find(({ status }) => status === 200); const concurrentLoser = concurrentDetails.find(({ status }) => status === 412); - assert.ok(concurrentWinner); - assert.ok(concurrentLoser); + assert.ok(concurrentWinner?.answer.ok); + assert.ok(concurrentLoser && !concurrentLoser.answer.ok); - const concurrentRegistration = Schema.decodeUnknownSync( - ReturningAssistantRegistrationResponseSchema, - )(JSON.parse(concurrentWinner.body)); + const concurrentRegistration = concurrentWinner.answer.value; assert.equal(concurrentRegistration.observation.revision, 3); assert.equal(concurrentRegistration.replayed, false); - - const concurrentConflict = Schema.decodeUnknownSync( - AdmissionsRegisterReturningAssistantProblem, - )(JSON.parse(concurrentLoser.body)); - - assert.equal(concurrentConflict.code, "returning.revision-conflict"); + assert.equal(concurrentLoser.answer.code, "returning.revision-conflict"); trace.push({ phase: "concurrent-revision", winner: concurrentWinner.idempotencyKey, loser: concurrentLoser.idempotencyKey, }); - const concurrentRows = await pool.query( - "SELECT revision FROM public.admission_returning_registrations WHERE person_id=$1 AND admission_period_id=$2 ORDER BY revision", - [person.personId, admissionPeriodId], + const concurrentRows = yield* step(() => + pool.query( + "SELECT revision FROM public.admission_returning_registrations WHERE person_id=$1 AND admission_period_id=$2 ORDER BY revision", + [person.personId, admissionPeriodId], + ), ); assert.deepEqual(concurrentRows.rows, [{ revision: 1 }, { revision: 2 }, { revision: 3 }]); @@ -1724,19 +2001,22 @@ export const runReturningAssistantBrowserJourney = async ({ stage?.("returning:next-period-assignment"); - const nextApplication = await pool.query( - `SELECT application_id + const nextApplication = yield* step(() => + pool.query( + `SELECT application_id FROM public.admission_applications WHERE applicant_id=$1 AND admission_period_id=$2`, - [applicantId, nextAdmissionPeriodId], + [applicantId, nextAdmissionPeriodId], + ), ); - const nextApplicationId = Schema.decodeUnknownSync(Schema.String)( + const nextApplicationId = yield* Schema.decodeUnknownEffect(Schema.String)( nextApplication.rows[0].application_id, ); - const assignmentActorContextBefore = await pool.query( - `SELECT + const assignmentActorContextBefore = yield* step(() => + pool.query( + `SELECT membership.membership_id, membership.person_id, membership.team_id, @@ -1752,11 +2032,13 @@ export const runReturningAssistantBrowserJourney = async ({ JOIN public.organization_departments department USING (department_id) WHERE membership.person_id=$1 ORDER BY membership.membership_id`, - ["journey-conduct-leader-0063"], + ["journey-conduct-leader-0063"], + ), ); - const assignmentPeriodContextBefore = await pool.query( - `SELECT + const assignmentPeriodContextBefore = yield* step(() => + pool.query( + `SELECT p.admission_period_id, p.department_id, p.start_at, @@ -1769,7 +2051,8 @@ export const runReturningAssistantBrowserJourney = async ({ JOIN public.admission_period_semesters s USING (semester_id) WHERE p.department_id=$1 ORDER BY p.admission_period_id`, - [departmentId], + [departmentId], + ), ); assert.equal(nextApplication.rows.length, 1); @@ -1779,18 +2062,19 @@ export const runReturningAssistantBrowserJourney = async ({ interviewSchemaId: "interview-schema-native-conduct-0063", }; - const assignmentPath = `${api}/api/recruitment/applications/${encodeURIComponent(nextApplicationId)}/interviews`; + const assign = (assignmentPage: Page, idempotencyKey: string) => + pageRpc(assignmentPage, { + api, + ui, + tag: "recruitment.createApplicationInterview", + payload: { applicationId: nextApplicationId, idempotencyKey, request: assignmentPayload }, + }); - const ambiguousAssignment = await page.request.post(assignmentPath, { - headers: { - "content-type": "application/json", - "idempotency-key": "returning-next-assignment-ambiguous-0104", - origin: ui, - }, - data: assignmentPayload, - }); + const ambiguousAssignment = yield* step(() => + assign(page, "returning-next-assignment-ambiguous-0104"), + ); - const ambiguousBodyText = await ambiguousAssignment.text(); + const ambiguousBodyText = yield* step(() => ambiguousAssignment.text()); trace.push({ phase: "assignment-response", status: ambiguousAssignment.status(), @@ -1800,12 +2084,15 @@ export const runReturningAssistantBrowserJourney = async ({ }); assert.equal(ambiguousAssignment.status(), 403, ambiguousBodyText); - const ambiguousBody = Schema.decodeUnknownSync(NativeProblem)(JSON.parse(ambiguousBodyText)); + const ambiguousBody = yield* Schema.decodeUnknownEffect(NativeProblem)( + yield* decodeJsonText(ambiguousBodyText), + ); assert.equal(ambiguousBody.code, "authority.denied"); - const assignmentPeriodContext = await pool.query( - `SELECT + const assignmentPeriodContext = yield* step(() => + pool.query( + `SELECT p.admission_period_id, p.start_at, p.end_at, @@ -1817,7 +2104,8 @@ export const runReturningAssistantBrowserJourney = async ({ JOIN public.admission_period_semesters s USING (semester_id) WHERE p.department_id=$1 ORDER BY p.admission_period_id`, - [departmentId], + [departmentId], + ), ); trace.push({ @@ -1835,13 +2123,16 @@ export const runReturningAssistantBrowserJourney = async ({ }); // Model the legitimate semester transition: the old period ends at a valid // instant and remains closed while the next period becomes authoritative. - await pool.query("UPDATE public.admission_periods SET end_at=$1 WHERE admission_period_id=$2", [ - conductPeriodClosedEndAt, - admissionPeriodId, - ]); + yield* step(() => + pool.query("UPDATE public.admission_periods SET end_at=$1 WHERE admission_period_id=$2", [ + conductPeriodClosedEndAt, + admissionPeriodId, + ]), + ); - const postClosePeriodContext = await pool.query( - `SELECT + const postClosePeriodContext = yield* step(() => + pool.query( + `SELECT p.admission_period_id, p.start_at, p.end_at, @@ -1853,7 +2144,8 @@ export const runReturningAssistantBrowserJourney = async ({ JOIN public.admission_period_semesters s USING (semester_id) WHERE p.department_id=$1 ORDER BY p.admission_period_id`, - [departmentId], + [departmentId], + ), ); assert.deepEqual( @@ -1872,8 +2164,9 @@ export const runReturningAssistantBrowserJourney = async ({ // Department administration (O8-11): an active, unsuspended leadership of the department's // board (Styret) while the department is independent. An ordinary team leader acts only // within its own team. - const resolvedCoordinator = await pool.query( - `SELECT + const resolvedCoordinator = yield* step(() => + pool.query( + `SELECT membership.person_id, membership.team_id, membership.is_team_leader, @@ -1896,7 +2189,8 @@ export const runReturningAssistantBrowserJourney = async ({ AND department.independent AND department.active ORDER BY membership.membership_id`, - ["report-coordinator-0103"], + ["report-coordinator-0103"], + ), ); assert.deepEqual(resolvedCoordinator.rows, [ @@ -1922,37 +2216,38 @@ export const runReturningAssistantBrowserJourney = async ({ postClosePeriodContext: postClosePeriodContext.rows, }); const assignmentCommandId = "returning-next-assignment-0104"; - const coordinatorContext = await browser.newContext(); + const coordinatorContext = yield* step(() => browser.newContext()); let assignmentStatus!: number; let assignmentBodyText!: string; let assignmentETag!: string; let nextInterviewId!: string; - try { + yield* Effect.gen(function* () { stage?.("returning:next-period-assignment:coordinator-login"); - const coordinatorPage = await coordinatorContext.newPage(); - await coordinatorPage.goto(`${ui}/login`); - await coordinatorPage.getByLabel("E-post", { exact: true }).fill(coordinatorEmail); - await coordinatorPage.getByLabel("Passord", { exact: true }).fill(coordinatorPassword); - await coordinatorPage.getByRole("button", { name: "Logg inn", exact: true }).click(); - await coordinatorPage.waitForURL(/\/dashboard\/?$/); - - const assignmentResponse = await coordinatorPage.request.post(assignmentPath, { - headers: { - "content-type": "application/json", - "idempotency-key": assignmentCommandId, - origin: ui, - }, - data: assignmentPayload, - }); + const coordinatorPage = yield* step(() => coordinatorContext.newPage()); + yield* step(() => coordinatorPage.goto(`${ui}/login`)); + yield* step(() => + coordinatorPage.getByLabel("E-post", { exact: true }).fill(coordinatorEmail), + ); + yield* step(() => + coordinatorPage.getByLabel("Passord", { exact: true }).fill(coordinatorPassword), + ); + yield* step(() => + coordinatorPage.getByRole("button", { name: "Logg inn", exact: true }).click(), + ); + yield* step(() => coordinatorPage.waitForURL(/\/dashboard\/?$/)); + + const assignmentResponse = yield* step(() => assign(coordinatorPage, assignmentCommandId)); assignmentStatus = assignmentResponse.status(); - assignmentBodyText = await assignmentResponse.text(); + assignmentBodyText = yield* step(() => assignmentResponse.text()); assignmentETag = assignmentResponse.headers()["etag"] ?? ""; - if (assignmentStatus !== 201) { - const assignmentActorContext = await pool.query( - `SELECT + // An RPC command answers 200; the 201 was an HTTP transport fact. + if (assignmentStatus !== 200) { + const assignmentActorContext = yield* step(() => + pool.query( + `SELECT membership.person_id, membership.team_id, membership.start_at, @@ -1967,7 +2262,8 @@ export const runReturningAssistantBrowserJourney = async ({ JOIN public.organization_departments department USING (department_id) WHERE membership.person_id=$1 ORDER BY membership.membership_id`, - ["report-coordinator-0103"], + ["report-coordinator-0103"], + ), ); trace.push({ @@ -1981,19 +2277,21 @@ export const runReturningAssistantBrowserJourney = async ({ throw new Error(`next assignment failed ${assignmentStatus} ${assignmentBodyText}`); } - const assignmentBody = Schema.decodeUnknownSync(RecruitmentInterviewResource)( - JSON.parse(assignmentBodyText), + const assignmentBody = yield* Schema.decodeUnknownEffect(RecruitmentInterviewResource)( + yield* decodeJsonText(assignmentBodyText), ); assert.equal(assignmentBody.applicationId, nextApplicationId); assert.ok(Predicate.isString(assignmentBody.interviewId)); nextInterviewId = assignmentBody.interviewId!; - const assignmentRow = await pool.query( - `SELECT interview_id,application_id,interviewer_person_id,revision + const assignmentRow = yield* step(() => + pool.query( + `SELECT interview_id,application_id,interviewer_person_id,revision FROM public.recruitment_interviews WHERE interview_id=$1`, - [nextInterviewId], + [nextInterviewId], + ), ); assert.deepEqual(assignmentRow.rows, [ @@ -2008,26 +2306,27 @@ export const runReturningAssistantBrowserJourney = async ({ assert.ok(assignmentETag); const assignedETag = assignmentETag; - const scheduleResponse = await coordinatorPage.request.post( - `${api}/api/recruitment/interviews/${encodeURIComponent(nextInterviewId)}:schedule`, - { - headers: { - "content-type": "application/json", - "idempotency-key": "returning-next-schedule-0104", - "if-match": assignedETag, - origin: ui, - }, - data: { - scheduledAt: nextInterviewScheduledAt, - room: "Returning Room 0104", - campus: "Gløshaugen", - mapLink: "https://maps.example.invalid/returning-next-0104", - message: "Vi ser frem til intervjuet.", + const scheduleResponse = yield* step(() => + pageRpc(coordinatorPage, { + api, + ui, + tag: "recruitment.scheduleInterview", + payload: { + interviewId: nextInterviewId, + idempotencyKey: "returning-next-schedule-0104", + ifMatch: assignedETag, + request: { + scheduledAt: nextInterviewScheduledAt, + room: "Returning Room 0104", + campus: "Gløshaugen", + mapLink: "https://maps.example.invalid/returning-next-0104", + message: "Vi ser frem til intervjuet.", + }, }, - }, + }), ); - const scheduleBodyText = await scheduleResponse.text(); + const scheduleBodyText = yield* step(() => scheduleResponse.text()); if (scheduleResponse.status() !== 200) { throw new Error( @@ -2035,8 +2334,8 @@ export const runReturningAssistantBrowserJourney = async ({ ); } - const scheduleBody = Schema.decodeUnknownSync(ScheduleInterviewResponse)( - JSON.parse(scheduleBodyText), + const scheduleBody = yield* Schema.decodeUnknownEffect(ScheduleInterviewResponse)( + yield* decodeJsonText(scheduleBodyText), ); assert.equal(scheduleBody.interviewId, nextInterviewId); @@ -2053,15 +2352,13 @@ export const runReturningAssistantBrowserJourney = async ({ scheduledAt: scheduleBody.schedule.scheduledAt, responseState: scheduleBody.responseState, }); - } finally { - await coordinatorContext.close(); - } + }).pipe(Effect.ensuring(step(() => coordinatorContext.close()).pipe(Effect.orDie))); const deliverRecruitmentInvitationOnce = deliverRecruitmentInvitation; assert.ok(deliverRecruitmentInvitationOnce); stage?.("returning:next-period-invitation-delivery"); - const delivery = await deliverRecruitmentInvitationOnce( + const delivery = yield* deliverRecruitmentInvitationOnce( "returning-next-invitation-delivery-0104", ); @@ -2070,9 +2367,11 @@ export const runReturningAssistantBrowserJourney = async ({ if (!Predicate.isTagged(delivery, "Delivered") || delivery.claim === undefined) throw new Error(`next invitation delivery did not complete: ${delivery._tag}`); - const deliveredInvitationOutbox = await pool.query( - "SELECT status,attempts FROM public.recruitment_invitation_outbox WHERE effect_id=$1", - [delivery.claim.effectId], + const deliveredInvitationOutbox = yield* step(() => + pool.query( + "SELECT status,attempts FROM public.recruitment_invitation_outbox WHERE effect_id=$1", + [delivery.claim.effectId], + ), ); assert.deepEqual(deliveredInvitationOutbox.rows, [{ status: "Delivered", attempts: 1 }]); @@ -2090,25 +2389,25 @@ export const runReturningAssistantBrowserJourney = async ({ for (let attempt = 0; attempt < 120 && invitationCapability === undefined; attempt += 1) { invitationCapability = invitationCapabilityReader(nextInterviewId); - if (invitationCapability === undefined) - await new Promise((resolve) => setTimeout(resolve, 250)); + if (invitationCapability === undefined) yield* Effect.sleep("250 millis"); } assert.ok(invitationCapability); stage?.("returning:next-period-invitation"); - const invitationHeaders = { - "x-recruitment-invitation-capability": invitationCapability, - origin: ui, - }; + // The invitee presents only the capability, in the payload. The staff page's session cookie + // would be a second credential, so these requests go through fetch, which keeps no cookie jar. + const readInvitation = () => + replacedFetch({ + origin: api, + tag: "recruitment.readInvitationResponse", + payload: { capability: invitationCapability }, + headers: { origin: ui }, + }); - // The invitee presents only the capability. The staff page's session cookie would be a - // second credential, so these requests go through fetch, which keeps no cookie jar. - const invitationPendingResponse = await fetch(`${api}/api/recruitment/invitation-response`, { - headers: invitationHeaders, - }); + const invitationPendingResponse = yield* step(() => readInvitation()); - const invitationPendingText = await invitationPendingResponse.text(); + const invitationPendingText = yield* step(() => invitationPendingResponse.text()); if (invitationPendingResponse.status !== 200) { throw new Error( @@ -2116,9 +2415,9 @@ export const runReturningAssistantBrowserJourney = async ({ ); } - const invitationPending = Schema.decodeUnknownSync( + const invitationPending = yield* Schema.decodeUnknownEffect( RecruitmentInvitationResponseObservationSchema, - )(JSON.parse(invitationPendingText)); + )(yield* decodeJsonText(invitationPendingText)); const invitationETag = invitationPendingResponse.headers.get("etag"); assert.ok(invitationETag); @@ -2130,36 +2429,31 @@ export const runReturningAssistantBrowserJourney = async ({ responseMessage: null, }); - const invitationConfirmResponse = await fetch( - `${api}/api/recruitment/invitation-response:confirm`, - { - method: "POST", - headers: { - ...invitationHeaders, - "content-type": "application/json", - "if-match": invitationETag, - }, - body: "{}", - }, + const invitationConfirmResponse = yield* step(() => + replacedFetch({ + origin: api, + tag: "recruitment.confirmInvitation", + payload: { capability: invitationCapability, ifMatch: invitationETag }, + headers: { origin: ui }, + }), ); - const invitationConfirmText = await invitationConfirmResponse.text(); + const invitationConfirmText = yield* step(() => invitationConfirmResponse.text()); - if (invitationConfirmResponse.status !== 204) { + // The confirmation answers the invitation's new tag with 200, where HTTP answered 204. + if (invitationConfirmResponse.status !== 200) { throw new Error( `next invitation confirmation failed ${invitationConfirmResponse.status} ${invitationConfirmText}`, ); } - const invitationAcceptedResponse = await fetch(`${api}/api/recruitment/invitation-response`, { - headers: invitationHeaders, - }); + const invitationAcceptedResponse = yield* step(() => readInvitation()); assert.equal(invitationAcceptedResponse.status, 200); - const invitationAccepted = Schema.decodeUnknownSync( + const invitationAccepted = yield* Schema.decodeUnknownEffect( RecruitmentInvitationResponseObservationSchema, - )(JSON.parse(await invitationAcceptedResponse.text())); + )(yield* decodeJsonText(yield* step(() => invitationAcceptedResponse.text()))); assert.deepEqual(invitationAccepted, { scheduledAt: nextInterviewScheduledAt, @@ -2175,19 +2469,24 @@ export const runReturningAssistantBrowserJourney = async ({ confirmStatus: invitationConfirmResponse.status, }); stage?.("returning:next-period-finalization"); - const conductPath = `${api}/api/recruitment/interviews/${encodeURIComponent(nextInterviewId)}`; - const conductResponse = await page.request.get(conductPath, { - headers: { origin: ui }, - }); + const readConduct = () => + pageRpc(page, { + api, + ui, + tag: "recruitment.readInterviewConduct", + payload: { interviewId: nextInterviewId }, + }); + + const conductResponse = yield* step(() => readConduct()); assert.equal(conductResponse.status(), 200); const conductETag = conductResponse.headers()["etag"]; assert.ok(conductETag); - const conductBefore = Schema.decodeUnknownSync(RecruitmentInterviewConductObservationSchema)( - JSON.parse(await conductResponse.text()), - ); + const conductBefore = yield* Schema.decodeUnknownEffect( + RecruitmentInterviewConductObservationSchema, + )(yield* decodeJsonText(yield* step(() => conductResponse.text()))); assert.equal(conductBefore.interviewId, nextInterviewId); assert.equal(conductBefore.applicationId, nextApplicationId); @@ -2211,21 +2510,25 @@ export const runReturningAssistantBrowserJourney = async ({ const finalizeKey = "returning-native-finalize-0104"; - const finalizeResponse = await page.request.post(`${conductPath}:finalize`, { - headers: { - "content-type": "application/json", - "idempotency-key": finalizeKey, - "if-match": conductETag, - origin: ui, - }, - data: { - answers: finalizeAnswers, - score: { explanatoryPower: 9, roleModel: 9, suitability: 9 }, - recommendation: "Kanskje", - }, - }); + const finalizeResponse = yield* step(() => + pageRpc(page, { + api, + ui, + tag: "recruitment.finalizeInterview", + payload: { + interviewId: nextInterviewId, + idempotencyKey: finalizeKey, + ifMatch: conductETag, + request: { + answers: finalizeAnswers, + score: { explanatoryPower: 9, roleModel: 9, suitability: 9 }, + recommendation: "Kanskje", + }, + }, + }), + ); - const finalizeBodyText = await finalizeResponse.text(); + const finalizeBodyText = yield* step(() => finalizeResponse.text()); if (finalizeResponse.status() !== 200) { throw new Error( @@ -2233,8 +2536,8 @@ export const runReturningAssistantBrowserJourney = async ({ ); } - const finalizeBody = Schema.decodeUnknownSync(FinalizeInterviewResponse)( - JSON.parse(finalizeBodyText), + const finalizeBody = yield* Schema.decodeUnknownEffect(FinalizeInterviewResponse)( + yield* decodeJsonText(finalizeBodyText), ); assert.equal(finalizeBody.interviewId, nextInterviewId); @@ -2242,15 +2545,13 @@ export const runReturningAssistantBrowserJourney = async ({ assert.equal(finalizeBody.completionState, "Completed"); assert.equal(finalizeBody.cancellationState, "NotCancelled"); - const conductAfterResponse = await page.request.get(conductPath, { - headers: { origin: ui }, - }); + const conductAfterResponse = yield* step(() => readConduct()); assert.equal(conductAfterResponse.status(), 200); - const conductAfter = Schema.decodeUnknownSync(RecruitmentInterviewConductObservationSchema)( - JSON.parse(await conductAfterResponse.text()), - ); + const conductAfter = yield* Schema.decodeUnknownEffect( + RecruitmentInterviewConductObservationSchema, + )(yield* decodeJsonText(yield* step(() => conductAfterResponse.text()))); assert.equal(conductAfterResponse.headers()["etag"] !== conductETag, true); assert.equal(conductAfter.answers.length, 4); @@ -2270,30 +2571,36 @@ export const runReturningAssistantBrowserJourney = async ({ scoreTotal: 27, }); - const finalizedConduct = await pool.query( - `SELECT c.recommendation,c.explanatory_power,c.role_model,c.suitability + const finalizedConduct = yield* step(() => + pool.query( + `SELECT c.recommendation,c.explanatory_power,c.role_model,c.suitability FROM public.recruitment_interview_conducts c WHERE c.interview_id=$1`, - [nextInterviewId], + [nextInterviewId], + ), ); assert.deepEqual(finalizedConduct.rows, [ { recommendation: "Kanskje", explanatory_power: 9, role_model: 9, suitability: 9 }, ]); - const preservedConduct = await pool.query( - `SELECT c.recommendation,c.explanatory_power,c.role_model,c.suitability + const preservedConduct = yield* step(() => + pool.query( + `SELECT c.recommendation,c.explanatory_power,c.role_model,c.suitability FROM public.recruitment_interview_conducts c WHERE c.interview_id='interview-returning-0104'`, + ), ); assert.deepEqual(preservedConduct.rows, [ { recommendation: "Ja", explanatory_power: 8, role_model: 8, suitability: 8 }, ]); - const registrations = await pool.query( - "SELECT admission_period_id,revision,year_of_study,monday_unavailable,tuesday_unavailable,wednesday_unavailable,thursday_unavailable,friday_unavailable,position_weeks,preferred_group,language,preferred_school,team_interest,team_ids FROM public.admission_returning_registrations WHERE person_id=$1 ORDER BY admission_period_id,revision", - [person.personId], + const registrations = yield* step(() => + pool.query( + "SELECT admission_period_id,revision,year_of_study,monday_unavailable,tuesday_unavailable,wednesday_unavailable,thursday_unavailable,friday_unavailable,position_weeks,preferred_group,language,preferred_school,team_interest,team_ids FROM public.admission_returning_registrations WHERE person_id=$1 ORDER BY admission_period_id,revision", + [person.personId], + ), ); assert.deepEqual(registrations.rows, [ @@ -2380,8 +2687,9 @@ export const runReturningAssistantBrowserJourney = async ({ }, ]); - const provenance = await pool.query( - `SELECT DISTINCT + const provenance = yield* step(() => + pool.query( + `SELECT DISTINCT r.admission_period_id, r.department_id AS registration_department_id, r.semester_id AS registration_semester_id, @@ -2392,7 +2700,8 @@ export const runReturningAssistantBrowserJourney = async ({ JOIN public.assistant_placements p ON p.placement_id=r.placement_id WHERE r.person_id=$1 ORDER BY r.admission_period_id`, - [person.personId], + [person.personId], + ), ); assert.deepEqual(provenance.rows, [ @@ -2427,116 +2736,112 @@ export const runReturningAssistantBrowserJourney = async ({ expectedRevision: Number(firstExpectedRevision), }; - const nextRevisionBeforeReplay = await pool.query( - "SELECT count(*)::int AS count FROM public.admission_returning_registrations WHERE person_id=$1 AND admission_period_id=$2", - [person.personId, nextAdmissionPeriodId], + const nextRevisionBeforeReplay = yield* step(() => + pool.query( + "SELECT count(*)::int AS count FROM public.admission_returning_registrations WHERE person_id=$1 AND admission_period_id=$2", + [person.personId, nextAdmissionPeriodId], + ), ); - const exactReplay = await context.request.post(`${api}/api/returning-assistant/registrations`, { - headers: { - "content-type": "application/json", - "idempotency-key": firstCommandKey, - origin: ui, - }, - data: firstReplayPayload, - }); + const replayKey = firstCommandKey; + const exactReplay = yield* register(replayKey, firstReplayPayload); - assert.equal(exactReplay.status(), 201); + assert.equal(exactReplay.status, 200); - const nextRevisionAfterReplay = await pool.query( - "SELECT count(*)::int AS count FROM public.admission_returning_registrations WHERE person_id=$1 AND admission_period_id=$2", - [person.personId, nextAdmissionPeriodId], + const nextRevisionAfterReplay = yield* step(() => + pool.query( + "SELECT count(*)::int AS count FROM public.admission_returning_registrations WHERE person_id=$1 AND admission_period_id=$2", + [person.personId, nextAdmissionPeriodId], + ), ); assert.deepEqual(nextRevisionAfterReplay.rows, nextRevisionBeforeReplay.rows); - const closedBefore = await negativeMutationSnapshot(person.personId); - await pool.query("UPDATE public.admission_periods SET end_at=$1 WHERE admission_period_id=$2", [ - nextPeriodClosedEndAt, - nextAdmissionPeriodId, - ]); - - try { - const closedReplay = await context.request.post( - `${api}/api/returning-assistant/registrations`, - { - headers: { - "content-type": "application/json", - "idempotency-key": firstCommandKey, - origin: ui, - }, - data: firstReplayPayload, - }, - ); - - assert.equal(closedReplay.status(), 409); - assert.deepEqual(await negativeMutationSnapshot(person.personId), closedBefore); - trace.push({ phase: "negative-gate", gate: "closed-period", status: closedReplay.status() }); - } finally { - await pool.query( - "UPDATE public.admission_periods SET end_at=$1 WHERE admission_period_id=$2", - [nextPeriodEndAt, nextAdmissionPeriodId], - ); - } + const closedBefore = yield* negativeMutationSnapshot(person.personId); + yield* step(() => + pool.query("UPDATE public.admission_periods SET end_at=$1 WHERE admission_period_id=$2", [ + nextPeriodClosedEndAt, + nextAdmissionPeriodId, + ]), + ); + + yield* Effect.gen(function* () { + const closedReplay = yield* register(replayKey, firstReplayPayload); + + assert.equal(closedReplay.status, 409); + assert.deepEqual(yield* negativeMutationSnapshot(person.personId), closedBefore); + trace.push({ phase: "negative-gate", gate: "closed-period", status: closedReplay.status }); + }).pipe( + Effect.ensuring( + step(() => + pool.query("UPDATE public.admission_periods SET end_at=$1 WHERE admission_period_id=$2", [ + nextPeriodEndAt, + nextAdmissionPeriodId, + ]), + ).pipe(Effect.orDie), + ), + ); - const returningOutbox = await pool.query( - "SELECT effect_id,status,attempts FROM public.admission_application_outbox WHERE origin='ReturningAssistant' ORDER BY effect_id", + const returningOutbox = yield* step(() => + pool.query( + "SELECT effect_id,status,attempts FROM public.admission_application_outbox WHERE origin='ReturningAssistant' ORDER BY effect_id", + ), ); assert.equal(returningOutbox.rows.length, 18); - const revokedBefore = await negativeMutationSnapshot(person.personId); + const revokedBefore = yield* negativeMutationSnapshot(person.personId); - const session = await pool.query( - 'SELECT count(*)::int AS count FROM auth.session WHERE "userId"=$1', - [person.personId], + const session = yield* step(() => + pool.query('SELECT count(*)::int AS count FROM auth.session WHERE "userId"=$1', [ + person.personId, + ]), ); assert.equal(session.rows[0].count, 1); - await pool.query('DELETE FROM auth.session WHERE "userId"=$1', [person.personId]); + yield* step(() => pool.query('DELETE FROM auth.session WHERE "userId"=$1', [person.personId])); - const revokedReplay = await context.request.post( - `${api}/api/returning-assistant/registrations`, - { - headers: { - "content-type": "application/json", - "idempotency-key": firstCommandKey, - origin: ui, - }, - data: firstReplayPayload, - }, - ); + const revokedReplay = yield* register(replayKey, firstReplayPayload); - assert.equal(revokedReplay.status(), 401); - assert.deepEqual(await negativeMutationSnapshot(person.personId), revokedBefore); + assert.equal(revokedReplay.status, 401); + yield* step(() => native.dispose()); + assert.deepEqual(yield* negativeMutationSnapshot(person.personId), revokedBefore); trace.push({ phase: "negative-gate", gate: "stale-revoked-auth", - status: revokedReplay.status(), + status: revokedReplay.status, }); stage?.("returning:report"); - const reportContext = await browser.newContext(); - const reportPage = await reportContext.newPage(); - - try { - await reportPage.goto(`${ui}/login`); - await reportPage.getByLabel("E-post", { exact: true }).fill(coordinatorEmail); - await reportPage.getByLabel("Passord", { exact: true }).fill(coordinatorPassword); - await reportPage.getByRole("button", { name: "Logg inn", exact: true }).click(); - await reportPage.waitForURL(/\/dashboard\/?$/); - - const reportRows = async (periodId: string) => { - await reportPage.goto( - `${ui}/dashboard/intervjuer/rapport?admissionPeriodId=${encodeURIComponent(periodId)}`, + const reportContext = yield* step(() => browser.newContext()); + const reportPage = yield* step(() => reportContext.newPage()); + + yield* Effect.gen(function* () { + yield* step(() => reportPage.goto(`${ui}/login`)); + yield* step(() => reportPage.getByLabel("E-post", { exact: true }).fill(coordinatorEmail)); + yield* step(() => + reportPage.getByLabel("Passord", { exact: true }).fill(coordinatorPassword), + ); + yield* step(() => reportPage.getByRole("button", { name: "Logg inn", exact: true }).click()); + yield* step(() => reportPage.waitForURL(/\/dashboard\/?$/)); + + const reportRows = Effect.fnUntraced(function* (periodId: string) { + yield* step(() => + reportPage.goto( + `${ui}/dashboard/intervjuer/rapport?admissionPeriodId=${encodeURIComponent(periodId)}`, + ), + ); + yield* step(() => + reportPage.getByRole("heading", { level: 1, name: "Fullførte intervjuer" }).waitFor(), ); - await reportPage.getByRole("heading", { level: 1, name: "Fullførte intervjuer" }).waitFor(); - return reportPage - .locator("tbody tr") - .evaluateAll((rows) => - rows.map((row) => (row.textContent ?? "").replace(/\s+/gu, " ").trim()), - ); - }; + return yield* step(() => + reportPage + .locator("tbody tr") + .evaluateAll((rows) => + rows.map((row) => (row.textContent ?? "").replace(/\s+/gu, " ").trim()), + ), + ); + }); - const currentReportRows = await reportRows(admissionPeriodId); + const currentReportRows = yield* reportRows(admissionPeriodId); const currentRita = currentReportRows.find((row) => row.includes("Rita Tilbake")); assert.ok(currentRita); assert.match(currentRita, /Tilbakevendende/u); @@ -2553,23 +2858,25 @@ export const runReturningAssistantBrowserJourney = async ({ currentReportRows.some((row) => row.includes("Olav Konflikt")), false, ); - await reportPage.reload(); + yield* step(() => reportPage.reload()); assert.equal( - ( - await reportPage + (yield* step(() => + reportPage .locator("tbody tr") .evaluateAll((rows) => rows.map((row) => (row.textContent ?? "").replace(/\s+/gu, " ").trim()), - ) - ).find((row) => row.includes("Rita Tilbake")), + ), + )).find((row) => row.includes("Rita Tilbake")), currentRita, ); - await auditPage(reportPage, "returning-report-existing-period"); - await reportPage.screenshot({ - path: join(artifacts, "returning-report-existing-period.png"), - fullPage: true, - }); - const nextReportRows = await reportRows(nextAdmissionPeriodId); + yield* auditPage(reportPage, "returning-report-existing-period"); + yield* step(() => + reportPage.screenshot({ + path: path.join(artifacts, "returning-report-existing-period.png"), + fullPage: true, + }), + ); + const nextReportRows = yield* reportRows(nextAdmissionPeriodId); assert.equal(nextReportRows.length, 1); const nextRita = nextReportRows[0]!; assert.match(nextRita, /Rita Tilbake/u); @@ -2577,36 +2884,35 @@ export const runReturningAssistantBrowserJourney = async ({ assert.match(nextRita, /Kanskje/u); assert.match(nextRita, /9/u); assert.match(nextRita, /27/u); - await reportPage.reload(); + yield* step(() => reportPage.reload()); - const reloadedNextRows = await reportPage - .locator("tbody tr") - .evaluateAll((rows) => - rows.map((row) => (row.textContent ?? "").replace(/\s+/gu, " ").trim()), - ); + const reloadedNextRows = yield* step(() => + reportPage + .locator("tbody tr") + .evaluateAll((rows) => + rows.map((row) => (row.textContent ?? "").replace(/\s+/gu, " ").trim()), + ), + ); assert.deepEqual(reloadedNextRows, nextReportRows); - await auditPage(reportPage, "returning-report-next-period-finalized"); - await reportPage.screenshot({ - path: join(artifacts, "returning-report-next-period-finalized.png"), - fullPage: true, - }); - } finally { - await reportContext.close(); - } - } finally { + yield* auditPage(reportPage, "returning-report-next-period-finalized"); + yield* step(() => + reportPage.screenshot({ + path: path.join(artifacts, "returning-report-next-period-finalized.png"), + fullPage: true, + }), + ); + }).pipe(Effect.ensuring(step(() => reportContext.close()).pipe(Effect.orDie))); + }).pipe( // Retain the complete journey trace on both successful helper return and // failure, before the outer report/effect gates can run or fail. - await writeFile( - join(artifacts, "returning-registration-trace.json"), - JSON.stringify(trace, null, 2), - ); - } + Effect.ensuring(writeTrace.pipe(Effect.orDie)), + ); return { trace }; -}; +}); -export const runReturningAssistantLoginProbe = async ({ +export const runReturningAssistantLoginProbe = Effect.fnUntraced(function* ({ browser, pool, ui, @@ -2616,28 +2922,32 @@ export const runReturningAssistantLoginProbe = async ({ readonly pool: Pool; readonly ui: string; readonly artifacts: string; -}) => { - const context = await browser.newContext(); - const probe = await context.newPage(); +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const context = yield* step(() => browser.newContext()); + const probe = yield* step(() => context.newPage()); const events: string[] = []; - const dbSnapshot = async (label: string) => ({ - label, - activity: ( - await pool.query( - `SELECT pid,state,wait_event_type,wait_event,left(query,240) AS query + const dbSnapshot = Effect.fnUntraced(function* (label: string) { + return { + label, + activity: (yield* step(() => + pool.query( + `SELECT pid,state,wait_event_type,wait_event,left(query,240) AS query FROM pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid() ORDER BY pid`, - ) - ).rows, - locks: ( - await pool.query( - `SELECT a.pid,l.locktype,l.mode,l.granted,left(a.query,240) AS query + ), + )).rows, + locks: (yield* step(() => + pool.query( + `SELECT a.pid,l.locktype,l.mode,l.granted,left(a.query,240) AS query FROM pg_stat_activity a JOIN pg_locks l ON l.pid=a.pid WHERE a.datname=current_database() AND a.pid<>pg_backend_pid() ORDER BY a.pid,l.locktype,l.mode`, - ) - ).rows, + ), + )).rows, + }; }); probe.on("request", (request) => { @@ -2654,40 +2964,48 @@ export const runReturningAssistantLoginProbe = async ({ }); probe.on("console", (message) => events.push(`console ${message.type()}`)); probe.on("pageerror", (error) => events.push(`pageerror ${error.message}`)); - const before = await dbSnapshot("before-login"); - await probe.goto( - `${ui}/login?redirectTo=${encodeURIComponent("/dashboard/tidligere-assistenter")}`, + const before = yield* dbSnapshot("before-login"); + yield* step(() => + probe.goto(`${ui}/login?redirectTo=${encodeURIComponent("/dashboard/tidligere-assistenter")}`), + ); + yield* step(() => probe.getByLabel("E-post", { exact: true }).fill(person.email)); + yield* step(() => probe.getByLabel("Passord", { exact: true }).fill(person.password)); + yield* step(() => + probe.screenshot({ path: path.join(artifacts, "returning-login-before.png"), fullPage: true }), ); - await probe.getByLabel("E-post", { exact: true }).fill(person.email); - await probe.getByLabel("Passord", { exact: true }).fill(person.password); - await probe.screenshot({ path: join(artifacts, "returning-login-before.png"), fullPage: true }); let click = "not-started"; - try { - await probe.getByRole("button", { name: "Logg inn", exact: true }).click({ - timeout: 10_000, - noWaitAfter: true, - }); - click = "resolved"; - } catch (cause) { - click = `error:${cause instanceof Error ? cause.name : Object.prototype.toString.call(cause)}`; - } + const clickOutcome = yield* Effect.exit( + step(() => + probe.getByRole("button", { name: "Logg inn", exact: true }).click({ + timeout: 10_000, + noWaitAfter: true, + }), + ), + ); - const afterClick = await dbSnapshot("after-click"); + click = Exit.isSuccess(clickOutcome) + ? "resolved" + : `error:${clickOutcome.cause.pipe(thrownBy, errorName)}`; + + const afterClick = yield* dbSnapshot("after-click"); let navigation = "not-started"; - try { - await probe.waitForURL(/\/dashboard\/tidligere-assistenter$/, { timeout: 10_000 }); - navigation = "dashboard"; - } catch (cause) { - navigation = `error:${cause instanceof Error ? cause.name : Object.prototype.toString.call(cause)}`; - } + const navigationOutcome = yield* Effect.exit( + step(() => probe.waitForURL(/\/dashboard\/tidligere-assistenter$/, { timeout: 10_000 })), + ); - const afterNavigation = await dbSnapshot("after-navigation"); - await probe.screenshot({ path: join(artifacts, "returning-login-after.png"), fullPage: true }); - const html = await probe.content().catch(() => ""); - await writeFile( - join(artifacts, "returning-login-probe.html"), + navigation = Exit.isSuccess(navigationOutcome) + ? "dashboard" + : `error:${navigationOutcome.cause.pipe(thrownBy, errorName)}`; + + const afterNavigation = yield* dbSnapshot("after-navigation"); + yield* step(() => + probe.screenshot({ path: path.join(artifacts, "returning-login-after.png"), fullPage: true }), + ); + const html = yield* step(() => probe.content().catch(() => "")); + yield* fs.writeFileString( + path.join(artifacts, "returning-login-probe.html"), html.replaceAll(person.email, "[redacted]").replaceAll(person.password, "[redacted]"), ); @@ -2701,22 +3019,25 @@ export const runReturningAssistantLoginProbe = async ({ url: probe.url(), }; - await writeFile(join(artifacts, "returning-login-probe.json"), JSON.stringify(result, null, 2)); - await context.close(); + yield* fs.writeFileString( + path.join(artifacts, "returning-login-probe.json"), + yield* indentedJsonText(result), + ); + yield* step(() => context.close()); return result; -}; +}); -const assertStatus = async (form: Locator, expected: string) => { - await form.getByRole("status").filter({ hasText: expected }).waitFor(); -}; +const assertStatus = Effect.fnUntraced(function* (form: Locator, expected: string) { + yield* step(() => form.getByRole("status").filter({ hasText: expected }).waitFor()); +}); -const expectValue = async (field: Locator, expected: string) => { - const actual = await field.inputValue(); +const expectValue = Effect.fnUntraced(function* (field: Locator, expected: string) { + const actual = yield* step(() => field.inputValue()); if (actual !== expected) { throw new Error( - `field value mismatch actual=${JSON.stringify(actual)} expected=${JSON.stringify(expected)}`, + `field value mismatch actual=${yield* jsonText(actual)} expected=${yield* jsonText(expected)}`, ); } -}; +}); diff --git a/tools/acceptance/substitute-outcome-check.ts b/tools/acceptance/substitute-outcome-check.ts index b691f00e2..7552cb26b 100644 --- a/tools/acceptance/substitute-outcome-check.ts +++ b/tools/acceptance/substitute-outcome-check.ts @@ -4,33 +4,74 @@ * With --browser, a child runs the production dashboard and Playwright against the same backend. */ import assert from "node:assert/strict"; -import { type ChildProcess, execFileSync, spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { setTimeout as sleep } from "node:timers/promises"; -import { Predicate, Schema, Struct } from "effect"; -import { AdmissionOutcomeScope } from "../../packages/domain/src/admissions/outcome.js"; -import { PublicApplicationIdSchema } from "../../packages/domain/src/application/schema.js"; -import { AdmissionOutcomeBoardResource } from "../../packages/http-api/src/admission-outcomes.js"; +import process from "node:process"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; import { - IdempotencyIfMatchHeaders, - isProblem, - NativeProblem, - problemBody, -} from "../../packages/http-api/src/http-semantics.js"; -import { createPromiseClient } from "../../packages/sdk/src/promise.js"; + Console, + Data, + Effect, + FileSystem, + Layer, + Path, + Predicate, + Schedule, + Schema, + Struct, +} from "effect"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + type HttpClientResponse, +} from "effect/unstable/http"; +import { + AdmissionOutcomeBoardResource, + type AdmissionOutcomeCommand, + AdmissionOutcomeScope, +} from "../../packages/rpc/src/admission-outcomes.js"; +import { nativeRpcPath } from "../../packages/rpc/src/api.js"; +import { IdempotencyKey, type StrongETag } from "../../packages/rpc/src/problem.js"; +import { nativeScriptClient, type ScriptCallResult } from "../../packages/rpc/src/script-client.js"; +import { SubmitApplicationRequest } from "../../packages/rpc/src/v2-schemas.js"; import { localBackendEnvironment } from "../e2e/local-backend-environment.ts"; +import { reserveLoopbackPorts, startDisposablePostgres } from "../postgres/index.ts"; +import { jsonText } from "../../apps/backend/src/rpc/problem.js"; import { - type DisposablePostgres, - reserveLoopbackPorts, - startDisposablePostgres, -} from "../postgres/index.ts"; -import { stopOwnedProcess } from "./owned-process.js"; + answersOk, + commandOutput, + indentedJsonText, + ProbeFailure, + startOwnedProcess, + withheldVariables, +} from "./acceptance-process.ts"; + +/** One RPC request on the JSON wire, for a payload that the typed client would refuse to encode. */ +const WireRequest = Schema.TaggedStruct("Request", { + id: Schema.String, + tag: Schema.String, + payload: Schema.Unknown, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +}); + +/** The one answer of a request that the server rejected with a defect before any handler ran. */ +const RejectedBeforeHandler = Schema.Tuple([ + Schema.TaggedStruct("Exit", { + exit: Schema.TaggedStruct("Failure", { + cause: Schema.Tuple([Schema.TaggedStruct("Die", { defect: Schema.Unknown })]), + }), + }), +]); -const root = new URL("../../", import.meta.url).pathname; +/** A PostgreSQL client call, or the disposable cluster, that failed. */ +class DatabaseFailure extends Data.TaggedError("DatabaseFailure")<{ readonly cause: unknown }> {} + +/** The rows of one query; the probe reads the columns that its statement selects. */ +interface QueryResult { + readonly rows: Array; +} const requireDatabase = createRequire( new URL("../../packages/database/package.json", import.meta.url), @@ -38,669 +79,730 @@ const requireDatabase = createRequire( const { Client } = requireDatabase("pg"); -const run = (command: string, args: string[], env = process.env, timeout = 60_000) => - execFileSync(command, args, { cwd: root, env, encoding: "utf8", timeout }); +/** The name and value of the first cookie that a response sets, as a `Cookie` header sends it. */ +const firstCookie = (response: HttpClientResponse.HttpClientResponse): string | undefined => { + const cookie = Object.values(response.cookies.cookies)[0]; -const revision = run("git", ["rev-parse", "HEAD"]).trim(); + return cookie === undefined ? undefined : `${cookie.name}=${cookie.valueEncoded}`; +}; -assert.equal(run("git", ["status", "--porcelain"]).trim(), "", "requires committed clean artifact"); +/** The journey: its evidence and summary once every owned resource is released. */ +const journey = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.resolve(import.meta.dirname, "../.."); -const artifacts = await mkdtemp(join(tmpdir(), "vektor-substitutes-")); + const git = (args: ReadonlyArray) => + commandOutput({ command: "git", args, cwd: root, deadline: "60 seconds" }); -const children: ChildProcess[] = []; + const revision = (yield* git(["rev-parse", "HEAD"])).trim(); -const outputs: string[] = []; + assert.equal( + (yield* git(["status", "--porcelain"])).trim(), + "", + "requires committed clean artifact", + ); -const start = (command: string, args: string[], env = process.env) => { - const child = spawn(command, args, { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] }); - children.push(child); - child.stdout?.on("data", (chunk) => outputs.push(String(chunk))); - child.stderr?.on("data", (chunk) => outputs.push(String(chunk))); + const artifacts = yield* fs.makeTempDirectory({ prefix: "vektor-substitutes-" }); + const manifestPath = path.join(artifacts, "manifest.json"); + const outputs: string[] = []; - return child; -}; + const api = Effect.gen(function* () { + yield* Effect.addFinalizer(() => fs.remove(manifestPath, { force: true }).pipe(Effect.ignore)); + + const [pgPort, backendPort, dashboardPort] = yield* Effect.tryPromise({ + try: () => reserveLoopbackPorts(3), + catch: (cause) => new DatabaseFailure({ cause }), + }); -let postgres: DisposablePostgres | undefined; + const postgres = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => startDisposablePostgres({ port: pgPort }), + catch: (cause) => new DatabaseFailure({ cause }), + }), + (cluster) => Effect.promise(() => cluster.stop()), + ); -let database: InstanceType; + const postgresUrl = postgres.url; -let evidence: Schema.JsonObject | undefined; + const database = yield* Effect.acquireRelease( + Effect.gen(function* () { + const client = new Client({ connectionString: postgresUrl }); -let summary: { apiGates: number; browserGates: number; receiptCount: number } | undefined; + yield* Effect.tryPromise({ + try: () => client.connect(), + catch: (cause) => new DatabaseFailure({ cause }), + }); -try { - const [pgPort, backendPort, dashboardPort] = await reserveLoopbackPorts(3); - postgres = await startDisposablePostgres({ port: pgPort }); - const postgresUrl = postgres.url; - const client = new Client({ connectionString: postgresUrl }); - await client.connect(); - database = client; + return client; + }), + (client) => Effect.promise(() => client.end()), + ); - const backendOrigin = `http://127.0.0.1:${backendPort}`; - const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; + const query = (text: string, values?: ReadonlyArray) => + Effect.tryPromise({ + try: (): Promise => database.query(text, values), + catch: (cause) => new DatabaseFailure({ cause }), + }); + + const backendOrigin = `http://127.0.0.1:${backendPort}`; + const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; + + const environment = { + ...(yield* withheldVariables), + ...localBackendEnvironment({ + backendOrigin, + dashboardOrigin, + postgresUrl, + betterAuthSecret: randomBytes(32).toString("hex"), + }), + JOURNEY_SEED_PG_URL: postgresUrl, + }; + + yield* commandOutput({ + command: "bun", + args: ["apps/dashboard/e2e/native-recruitment-journey-seed.mjs"], + cwd: root, + env: environment, + deadline: "60 seconds", + }); - const environment = { - ...process.env, - ...localBackendEnvironment({ - backendOrigin, - dashboardOrigin, - postgresUrl, - betterAuthSecret: randomBytes(32).toString("hex"), - }), - JOURNEY_SEED_PG_URL: postgresUrl, - }; - - for (const key of Object.keys(environment)) - if ( - key.startsWith("CONTACT_") || - (key.startsWith("PUBLIC_APPLICATION_EFFECT_") && key !== "PUBLIC_APPLICATION_EFFECT_MODE") - ) - Reflect.deleteProperty(environment, key); - run("bun", ["apps/dashboard/e2e/native-recruitment-journey-seed.mjs"], environment); - - // The seed's department and open period hold the API cases; a historical period of the - // same department holds the browser cases, so neither run can change the other's facts. - const departmentId = "department-native-journey-0049", - openSemesterId = "semester-native-journey-0049", - openPeriodId = "admission-period-native-journey-0049", - historicalSemesterId = "semester-historical-substitutes", - historicalPeriodId = "period-historical-substitutes", - noPeriodSemesterId = "semester-no-period-substitutes", - otherDepartmentId = "department-other-substitutes", - browserApplicationId = "application-browser-substitutes", - undecidedBrowserApplicationId = "application-browser-undecided-substitutes", - leaderPersonId = "journey-rec-leader-0049"; - - await database.query(`INSERT INTO public.admission_period_semesters(semester_id,start_at,end_at) VALUES ('${historicalSemesterId}','2024-01-01','2024-07-01'),('${noPeriodSemesterId}','2023-01-01','2023-07-01'); + // The seed's department and open period hold the API cases; a historical period of the + // same department holds the browser cases, so neither run can change the other's facts. + const departmentId = "department-native-journey-0049", + openSemesterId = "semester-native-journey-0049", + openPeriodId = "admission-period-native-journey-0049", + historicalSemesterId = "semester-historical-substitutes", + historicalPeriodId = "period-historical-substitutes", + noPeriodSemesterId = "semester-no-period-substitutes", + otherDepartmentId = "department-other-substitutes", + browserApplicationId = "application-browser-substitutes", + undecidedBrowserApplicationId = "application-browser-undecided-substitutes", + leaderPersonId = "journey-rec-leader-0049"; + + yield* query(`INSERT INTO public.admission_period_semesters(semester_id,start_at,end_at) VALUES ('${historicalSemesterId}','2024-01-01','2024-07-01'),('${noPeriodSemesterId}','2023-01-01','2023-07-01'); INSERT INTO public.admission_periods(admission_period_id,department_id,semester_id,start_at,end_at,revision,last_command_id) VALUES ('${historicalPeriodId}','${departmentId}','${historicalSemesterId}','2024-01-01','2024-06-01',0,'fixture-substitutes'); INSERT INTO public.admission_applications(application_id,applicant_id,admission_period_id,department_id,field_of_study_id,year_of_study,submitted_at,revision) SELECT '${browserApplicationId}',applicant_id,'${historicalPeriodId}',department_id,field_of_study_id,year_of_study,'2024-01-02',0 FROM public.admission_applications WHERE application_id='application-native-journey-0049'; INSERT INTO public.organization_departments(department_id,name,short_name,email,city,active,revision) VALUES ('${otherDepartmentId}','Annen avdeling','Annen','annen@example.invalid','Annen',true,0); INSERT INTO public.organization_teams(team_id,department_id,name,active,revision) VALUES ('team-other-substitutes','${otherDepartmentId}','Annet team',true,0); UPDATE public.organization_memberships SET team_id='team-other-substitutes',is_team_leader=true WHERE person_id='journey-rec-interviewer-b-0049';`); - start("bun", ["run", "--cwd", "apps/backend", "start"], environment); - - for (let n = 0; ; n++) { - try { - if ((await fetch(`${backendOrigin}/health`)).ok) break; - } catch {} - - if (n > 150) throw Error("backend startup failed"); - await sleep(200); - } - - const persons = { - leader: { email: "lina.leader@example.invalid", password: "journey-secret-0123456789abcdef" }, - member: { - email: "irene.intervjuer@example.invalid", - password: "journey-secret-0123456789abcdef", - }, - otherDepartment: { - email: "ida.intervjuer@example.invalid", - password: "journey-secret-0123456789abcdef", - }, - }; - - const login = async (person: { email: string; password: string }) => { - const response = await fetch(`${backendOrigin}/api/auth/sign-in/email`, { - method: "POST", - headers: { "content-type": "application/json", origin: dashboardOrigin }, - body: JSON.stringify(person), - }); - - assert.equal(response.status, 200, `login: ${await response.text()}`); - const cookie = response.headers.get("set-cookie"); - assert.ok(cookie); - - return cookie.split(";")[0]!; - }; - - const leader = await login(persons.leader), - member = await login(persons.member), - other = await login(persons.otherDepartment); - - const request = async ( - path: string, - cookie?: string, - body?: Schema.Json, - etag?: string, - key = randomBytes(18).toString("base64url"), - ) => { - const nativeHeaders = new Headers(); - nativeHeaders.set("origin", dashboardOrigin); - - if (cookie) { - nativeHeaders.set("cookie", cookie); - } - if (!(body === undefined)) { - nativeHeaders.set("content-type", "application/json"); - nativeHeaders.set("idempotency-key", key); - - if (etag) { - nativeHeaders.set("if-match", etag); - } - } + yield* startOwnedProcess({ + command: "bun", + args: ["run", "--cwd", "apps/backend", "start"], + cwd: root, + env: environment, + output: (text) => outputs.push(text), + }); - const requestBody: Pick = - body === undefined ? {} : { body: JSON.stringify(body) }; + yield* answersOk(`${backendOrigin}/health`).pipe( + Effect.filterOrFail( + (ready) => ready, + () => new ProbeFailure({ message: "backend startup failed" }), + ), + Effect.retry({ schedule: Schedule.spaced("200 millis"), times: 151 }), + ); - return fetch(`${backendOrigin}${path}`, { - method: body === undefined ? "GET" : "POST", - headers: nativeHeaders, - ...requestBody, - }); - }; - - const read = async (path: string, cookie = leader) => { - const response = await request(path, cookie); - assert.equal(response.status, 200, `${path}: ${await response.clone().text()}`); - - return response.json(); - }; - - const expectProblem = async (response: Response, status: number, code: string, gate: string) => { - const body = await response.text(); - assert.equal(response.status, status, `${gate}: ${body}`); - assert.equal(JSON.parse(body).code, code, gate); - }; - - const outcomeHistory = async (applicationId: string) => - ( - await database.query( - `SELECT revision, outcome, decided_by_person_id AS "decidedBy" FROM public.admission_application_outcomes WHERE application_id=$1 ORDER BY revision`, - [applicationId], - ) - ).rows; + const persons = { + leader: { email: "lina.leader@example.invalid", password: "journey-secret-0123456789abcdef" }, + member: { + email: "irene.intervjuer@example.invalid", + password: "journey-secret-0123456789abcdef", + }, + otherDepartment: { + email: "ida.intervjuer@example.invalid", + password: "journey-secret-0123456789abcdef", + }, + }; + + const login = (person: { email: string; password: string }) => + Effect.gen(function* () { + const response = yield* HttpClient.execute( + HttpClientRequest.post(`${backendOrigin}/api/auth/sign-in/email`, { + headers: { origin: dashboardOrigin }, + }).pipe(HttpClientRequest.bodyText(yield* jsonText(person), "application/json")), + ); + + assert.equal(response.status, 200, `login: ${yield* response.text}`); + const cookie = firstCookie(response); + assert.ok(cookie); + + return cookie; + }); + + const leader = yield* login(persons.leader), + member = yield* login(persons.member), + other = yield* login(persons.otherDepartment); + + const native = yield* Effect.acquireRelease( + Effect.sync(() => nativeScriptClient(backendOrigin)), + (client) => Effect.promise(() => client.dispose()), + ); - const openScopeOutcomes = async () => - ( - await database.query( + /** One RPC call as the person with `headers`; the script client never rejects. */ + const call = (...args: Parameters>) => + Effect.promise(() => native.call(...args)); + + /** Headers of one call: the dashboard origin, and the person's session when there is one. */ + const as = (cookie?: string): Readonly> => + cookie === undefined ? { origin: dashboardOrigin } : { cookie, origin: dashboardOrigin }; + + const newKey = () => IdempotencyKey.make(randomBytes(18).toString("base64url")); + + const expectValue = (answer: ScriptCallResult, gate: string): A => { + if (!answer.ok) assert.fail(`${gate}: ${answer.code}`); + + return answer.value; + }; + + const expectProblem = ( + answer: ScriptCallResult, + status: number, + code: string, + gate: string, + ) => { + assert.equal(answer.ok, false, gate); + + if (answer.ok) return; + assert.equal(answer.status, status, `${gate}: ${answer.code}`); + assert.equal(answer.code, code, gate); + }; + + const outcomeHistory = (applicationId: string) => + Effect.map( + query( + `SELECT revision, outcome, decided_by_person_id AS "decidedBy" FROM public.admission_application_outcomes WHERE application_id=$1 ORDER BY revision`, + [applicationId], + ), + (result) => result.rows, + ); + + const openScopeOutcomes = Effect.map( + query( "SELECT outcome.* FROM public.admission_application_outcomes outcome INNER JOIN public.admission_applications application ON application.application_id=outcome.application_id WHERE application.admission_period_id=$1 ORDER BY outcome.application_id, outcome.revision", [openPeriodId], - ) - ).rows; + ), + (result) => result.rows, + ); - const gates: string[] = []; - const scopesPath = "/api/admission-outcomes/scopes"; + const gates: string[] = []; - const scopeDepartments = async (cookie: string) => { - const response = await request(scopesPath, cookie); - assert.equal(response.status, 200, await response.clone().text()); - assert.equal(response.headers.get("cache-control"), "private, no-store"); - const scopes = await response.json(); + const listScopes = (cookie?: string) => + call(as(cookie), (client) => client["admissionOutcomes.listScopes"]()); - return scopes.departments.map((item: { departmentId: string }) => item.departmentId); - }; + const scopeDepartments = (cookie: string) => + Effect.map(listScopes(cookie), (answer) => + expectValue(answer, "scopes").departments.map((item) => item.departmentId), + ); - const boardPath = (semesterId: string) => - `/api/admission-outcomes?${new URLSearchParams({ departmentId, semesterId }).toString()}`; + const scopeOf = (semesterId: string) => + Schema.decodeSync(AdmissionOutcomeScope)({ departmentId, semesterId }); - const sdk = createPromiseClient(backendOrigin, { cookie: leader, origin: dashboardOrigin }); - const leaderScopes = await read(scopesPath); - assert.deepEqual(await scopeDepartments(leader), [departmentId]); - assert.ok( - [historicalSemesterId, openSemesterId, noPeriodSemesterId].every((semesterId) => - leaderScopes.semesters.some((item: { semesterId: string }) => item.semesterId === semesterId), - ), - ); - assert.deepEqual(await scopeDepartments(member), [departmentId]); - assert.deepEqual(await scopeDepartments(other), [otherDepartmentId]); - assert.deepEqual( - (await sdk.admissionOutcomes.listScopes()).body.departments.map((item) => item.departmentId), - [departmentId], - ); - await database.query( - `INSERT INTO public.organization_memberships(membership_id,person_id,team_id,deleted_team_name,start_at,end_at,position_id,is_team_leader,is_suspended,revision) VALUES ('membership-other-substitutes','${leaderPersonId}','team-other-substitutes',NULL,'2026-01-01',NULL,NULL,false,false,0)`, - ); - assert.deepEqual( - await scopeDepartments(leader), - [otherDepartmentId, departmentId], - "every department membership is evaluated", - ); - gates.push("scopes list only the departments each person may read, from every membership"); - - const submission = { - departmentId, - firstName: "Anne", - lastName: "API", - phone: "90011223", - email: "anne.api@example.invalid", - gender: 1, - fieldOfStudyId: "field-native-journey-0049", - yearOfStudy: 3, - availability: { - mondayUnavailable: false, - tuesdayUnavailable: true, - wednesdayUnavailable: false, - thursdayUnavailable: false, - fridayUnavailable: false, - positionWeeks: 4, - preferredGroup: "all", - language: "Norsk", - }, - }; - - const submitted = await request("/api/applications", undefined, submission); - assert.equal(submitted.status, 201, await submitted.clone().text()); - const applicationId = (await submitted.json()).applicationId; - assert.ok(Predicate.isString(applicationId)); - await database.query( - `INSERT INTO public.admission_applications(application_id,applicant_id,admission_period_id,department_id,field_of_study_id,year_of_study,submitted_at,revision) - SELECT '${undecidedBrowserApplicationId}',applicant_id,'${historicalPeriodId}',department_id,field_of_study_id,year_of_study,'2024-01-03',0 FROM public.admission_applications WHERE application_id=$1`, - [applicationId], - ); + const readBoard = (semesterId: string, cookie?: string) => + call(as(cookie), (client) => client["admissionOutcomes.readOutcomes"](scopeOf(semesterId))); - const scopeQuery = Schema.decodeSync(AdmissionOutcomeScope)({ - departmentId, - semesterId: openSemesterId, - }); - - const board = await read(boardPath(openSemesterId)); - assert.equal(board._tag, "Decide"); - assert.equal(board.admissionPeriodId, openPeriodId); - - const periodApplications = ( - await database.query( - "SELECT application_id FROM public.admission_applications WHERE admission_period_id=$1 ORDER BY application_id", - [openPeriodId], - ) - ).rows.map((row: { application_id: string }) => row.application_id); - - assert.deepEqual( - periodApplications, - [applicationId, "application-native-journey-0049"].toSorted(), - ); - assert.deepEqual( - board.entries.map((entry: { applicationId: string }) => entry.applicationId).toSorted(), - periodApplications, - "admission management sees every application of the period", - ); + const board = (semesterId: string, cookie = leader) => + Effect.map(readBoard(semesterId, cookie), (answer) => + expectValue(answer, `board ${semesterId}`), + ); - const initial = board.entries.find( - (entry: { applicationId: string }) => entry.applicationId === applicationId, - ); + const leaderScopes = expectValue(yield* listScopes(leader), "leader scopes"); + assert.deepEqual(yield* scopeDepartments(leader), [departmentId]); + assert.ok( + [historicalSemesterId, openSemesterId, noPeriodSemesterId].every((semesterId) => + leaderScopes.semesters.some((item) => item.semesterId === semesterId), + ), + ); + assert.deepEqual(yield* scopeDepartments(member), [departmentId]); + assert.deepEqual(yield* scopeDepartments(other), [otherDepartmentId]); + yield* query( + `INSERT INTO public.organization_memberships(membership_id,person_id,team_id,deleted_team_name,start_at,end_at,position_id,is_team_leader,is_suspended,revision) VALUES ('membership-other-substitutes','${leaderPersonId}','team-other-substitutes',NULL,'2026-01-01',NULL,NULL,false,false,0)`, + ); + assert.deepEqual( + yield* scopeDepartments(leader), + [otherDepartmentId, departmentId], + "every department membership is evaluated", + ); + gates.push("scopes list only the departments each person may read, from every membership"); - assert.deepEqual( - { - firstName: initial.firstName, - lastName: initial.lastName, - email: initial.email, - phone: initial.phone, - yearOfStudy: initial.yearOfStudy, - outcome: initial.outcome, - revision: initial.revision, - }, - { + const submission = yield* Schema.decodeEffect(SubmitApplicationRequest)({ + departmentId, firstName: "Anne", lastName: "API", - email: submission.email, - phone: submission.phone, + phone: "90011223", + email: "anne.api@example.invalid", + gender: 1, + fieldOfStudyId: "field-native-journey-0049", yearOfStudy: 3, - outcome: null, - revision: 0, - }, - ); - assert.equal((await sdk.admissionOutcomes.readOutcomes({ query: scopeQuery })).body._tag, "Decide"); - const itemPath = `/api/admission-outcomes/${applicationId}`; - const recordPath = `${itemPath}:record`; - const itemResponse = await request(itemPath, leader); - assert.equal(itemResponse.status, 200, await itemResponse.clone().text()); - assert.equal(itemResponse.headers.get("cache-control"), "private, no-store"); - const item = await itemResponse.json(); - assert.equal(itemResponse.headers.get("etag"), item.etag); - assert.equal(item.etag, initial.etag); - gates.push("admission management reads every application of the period with its version"); - - await expectProblem( - await request(recordPath, leader, { outcome: "Substitute" }), - 428, - "precondition.required", - "missing If-Match", - ); + availability: { + mondayUnavailable: false, + tuesdayUnavailable: true, + wednesdayUnavailable: false, + thursdayUnavailable: false, + fridayUnavailable: false, + positionWeeks: 4, + preferredGroup: "all", + language: "Norsk", + }, + }); - const invalidCommands: ReadonlyArray = [ - {}, - { outcome: "Maybe" }, - { outcome: null }, - { outcome: "Substitute", departmentId }, - ]; + const { applicationId } = expectValue( + yield* call(as(), (client) => + client["admissions.submitApplication"]({ idempotencyKey: newKey(), request: submission }), + ), + "submission", + ); - for (const invalid of invalidCommands) - await expectProblem( - await request(recordPath, leader, invalid, item.etag), - 422, - "validation.failed", - `invalid ${JSON.stringify(invalid)}`, - ); - assert.deepEqual(await outcomeHistory(applicationId), [], "rejected commands record nothing"); - gates.push("a missing version (428) and invalid outcomes (422) record nothing"); - - const key = randomBytes(18).toString("base64url"); - const recorded = await request(recordPath, leader, { outcome: "Substitute" }, item.etag, key); - assert.equal(recorded.status, 200, await recorded.clone().text()); - const substitute = await recorded.json(); - assert.equal(recorded.headers.get("etag"), substitute.etag); - assert.notEqual(substitute.etag, item.etag); - assert.equal(substitute.outcome, "Substitute"); - assert.equal(substitute.revision, 1); - const substituteHistory = [{ revision: 1, outcome: "Substitute", decidedBy: leaderPersonId }]; - assert.deepEqual(await outcomeHistory(applicationId), substituteHistory); - const replay = await request(recordPath, leader, { outcome: "Substitute" }, item.etag, key); - assert.equal(replay.status, 200); - assert.deepEqual(await replay.json(), substitute, "an exact replay answers the stored result"); - assert.deepEqual(await outcomeHistory(applicationId), substituteHistory, "a replay adds no revision"); - await expectProblem( - await request(recordPath, leader, { outcome: "Rejected" }, item.etag, key), - 409, - "idempotency.digest-conflict", - "another command under a used key", - ); - const again = await request(recordPath, leader, { outcome: "Substitute" }, substitute.etag); - assert.equal(again.status, 200, await again.clone().text()); - assert.deepEqual(await again.json(), substitute, "recording the current outcome again changes nothing"); - assert.deepEqual(await outcomeHistory(applicationId), substituteHistory); - gates.push( - "Substitute is recorded once with If-Match and Idempotency-Key; replay and a repeated outcome add no revision", - ); + yield* query( + `INSERT INTO public.admission_applications(application_id,applicant_id,admission_period_id,department_id,field_of_study_id,year_of_study,submitted_at,revision) + SELECT '${undecidedBrowserApplicationId}',applicant_id,'${historicalPeriodId}',department_id,field_of_study_id,year_of_study,'2024-01-03',0 FROM public.admission_applications WHERE application_id=$1`, + [applicationId], + ); - await expectProblem( - await request(recordPath, leader, { outcome: "Rejected" }, item.etag), - 412, - "precondition.failed", - "stale version", - ); + const openBoard = yield* board(openSemesterId); - const staleCommand = { - params: { applicationId: PublicApplicationIdSchema.make(applicationId) }, - headers: Schema.decodeSync(IdempotencyIfMatchHeaders)({ - "if-match": item.etag, - "idempotency-key": randomBytes(18).toString("base64url"), - }), - payload: { outcome: "Rejected" as const }, - }; + if (!Predicate.isTagged(openBoard, "Decide")) + assert.fail("admission management decides the open board"); + assert.equal(openBoard.admissionPeriodId, openPeriodId); - for (const attempt of ["initial stale record", "unchanged rejected retry"]) { - await assert.rejects(sdk.admissionOutcomes.recordOutcome(staleCommand), (error) => { - assert.ok(isProblem(error), attempt); - const problem = Schema.decodeUnknownSync(NativeProblem)(problemBody(error)); - assert.equal(problem.code, "precondition.failed", attempt); - assert.equal(problem.status, 412, attempt); + const periodApplications = ( + yield* query( + "SELECT application_id FROM public.admission_applications WHERE admission_period_id=$1 ORDER BY application_id", + [openPeriodId], + ) + ).rows.map((row: { application_id: string }) => row.application_id); - return true; - }); - } + assert.deepEqual( + periodApplications, + [applicationId, "application-native-journey-0049"].toSorted(), + ); + assert.deepEqual( + openBoard.entries.map((entry) => entry.applicationId).toSorted(), + periodApplications, + "admission management sees every application of the period", + ); - assert.deepEqual(await outcomeHistory(applicationId), substituteHistory); - gates.push("a stale version is refused (412), also on an unchanged SDK retry, and records nothing"); + const initial = openBoard.entries.find((entry) => entry.applicationId === applicationId); - const memberBoard = await read(boardPath(openSemesterId), member); - assert.deepEqual( - memberBoard, - AdmissionOutcomeBoardResource.cases.ReadOnly.make({ - departmentId, - semesterId: openSemesterId, - admissionPeriodId: openPeriodId, - substitutes: [ - { + assert.ok(initial); + assert.deepEqual( + { + firstName: initial.firstName, + lastName: initial.lastName, + email: initial.email, + phone: initial.phone, + yearOfStudy: initial.yearOfStudy, + outcome: initial.outcome, + revision: initial.revision, + }, + { + firstName: "Anne", + lastName: "API", + email: submission.email, + phone: submission.phone, + yearOfStudy: 3, + outcome: null, + revision: 0, + }, + ); + + const readItem = (cookie?: string) => + call(as(cookie), (client) => client["admissionOutcomes.readOutcome"]({ applicationId })); + + const record = ( + cookie: string | undefined, + request: AdmissionOutcomeCommand, + ifMatch: StrongETag, + idempotencyKey = newKey(), + ) => + call(as(cookie), (client) => + client["admissionOutcomes.recordOutcome"]({ applicationId, - firstName: "Anne", - lastName: "API", - email: submission.email, - phone: submission.phone, - }, - ], - }), - "a member reads only who is on call, with name, e-mail and phone", - ); + idempotencyKey, + ifMatch, + request, + }), + ); + + const item = expectValue(yield* readItem(leader), "leader single read"); + assert.equal(item.etag, initial.etag); + gates.push("admission management reads every application of the period with its version"); + + // The payload schema requires ifMatch and one declared outcome, so the RPC server rejects an + // invalid command while decoding it, before the handler: it answers a defect, not a problem. + const invalidCommands: ReadonlyArray = [ + {}, + { outcome: "Maybe" }, + { outcome: null }, + ]; + + for (const invalid of invalidCommands) { + const response = yield* HttpClient.execute( + HttpClientRequest.post(`${backendOrigin}${nativeRpcPath}`, { + headers: { cookie: leader, origin: dashboardOrigin }, + }).pipe( + HttpClientRequest.bodyText( + yield* jsonText( + WireRequest.make({ + id: "1", + tag: "admissionOutcomes.recordOutcome", + payload: { + applicationId, + idempotencyKey: newKey(), + ifMatch: item.etag, + request: invalid, + }, + headers: [], + }), + ), + "application/json", + ), + ), + ); + + assert.equal(response.status, 200, `invalid ${yield* jsonText(invalid)}`); + yield* Schema.decodeUnknownEffect(RejectedBeforeHandler)(yield* response.json); + } - const memberSdk = createPromiseClient(backendOrigin, { cookie: member, origin: dashboardOrigin }); - assert.equal( - (await memberSdk.admissionOutcomes.readOutcomes({ query: scopeQuery })).body._tag, - "ReadOnly", - ); - await expectProblem(await request(itemPath, member), 403, "authority.denied", "member single read"); - await expectProblem( - await request(recordPath, member, { outcome: "Rejected" }, substitute.etag), - 403, - "authority.denied", - "member record", - ); - gates.push("a member reads the on-call list only; single reads and records are denied (403)"); - - for (const path of [boardPath(openSemesterId), itemPath]) - await expectProblem(await request(path, other), 403, "authority.denied", `other department ${path}`); - await expectProblem( - await request(recordPath, other, { outcome: "Rejected" }, substitute.etag), - 403, - "authority.denied", - "other department record", - ); + assert.deepEqual(yield* outcomeHistory(applicationId), [], "rejected commands record nothing"); + gates.push("invalid outcomes are rejected before the handler and record nothing"); - for (const path of [scopesPath, boardPath(openSemesterId), itemPath]) - assert.equal((await request(path)).status, 401, `anonymous ${path}`); - assert.equal( - (await request(recordPath, undefined, { outcome: "Rejected" }, substitute.etag)).status, - 401, - "anonymous record", - ); - assert.deepEqual(await outcomeHistory(applicationId), substituteHistory); - gates.push("another department (403) and anonymous callers (401) read and record nothing"); + const key = newKey(); - await database.query( - `INSERT INTO public.organization_global_administrator_grants(grant_id,person_id,start_at,end_at,revision) VALUES ('admin-substitutes','journey-rec-interviewer-b-0049','2026-01-01',NULL,0)`, - ); - assert.equal( - (await read(boardPath(openSemesterId), other))._tag, - "Decide", - "an active global administrator decides in every department", - ); - await database.query( - "DELETE FROM public.organization_global_administrator_grants WHERE grant_id='admin-substitutes'", - ); - assert.equal((await request(boardPath(openSemesterId), other)).status, 403); - gates.push("an active global administrator decides in every department"); - - const concurrent = await Promise.all([ - request(recordPath, leader, { outcome: "Rejected" }, substitute.etag), - request(recordPath, leader, { outcome: "Rejected" }, substitute.etag), - ]); - - const statuses = concurrent.map((response) => response.status); - assert.equal(statuses.filter((status) => status === 200).length, 1, `concurrent records ${statuses.join(",")}`); - assert.ok( - statuses.some((status) => status === 409 || status === 412), - `concurrent rejection ${statuses.join(",")}`, - ); + const substitute = expectValue( + yield* record(leader, { outcome: "Substitute" }, item.etag, key), + "record", + ); - const rejectedHistory = [ - ...substituteHistory, - { revision: 2, outcome: "Rejected", decidedBy: leaderPersonId }, - ]; + assert.notEqual(substitute.etag, item.etag); + assert.equal(substitute.outcome, "Substitute"); + assert.equal(substitute.revision, 1); + const substituteHistory = [{ revision: 1, outcome: "Substitute", decidedBy: leaderPersonId }]; + assert.deepEqual(yield* outcomeHistory(applicationId), substituteHistory); - assert.deepEqual(await outcomeHistory(applicationId), rejectedHistory); - assert.deepEqual( - (await read(boardPath(openSemesterId), member)).substitutes, - [], - "a rejected applicant is no longer on call", - ); - assert.equal( - (await read(boardPath(openSemesterId))).entries.find( - (entry: { applicationId: string }) => entry.applicationId === applicationId, - ).outcome, - "Rejected", - ); - gates.push("of two concurrent records on one version exactly one applies; Rejected leaves the on-call list"); + assert.deepEqual( + expectValue(yield* record(leader, { outcome: "Substitute" }, item.etag, key), "replay"), + substitute, + "an exact replay answers the stored result", + ); + assert.deepEqual( + yield* outcomeHistory(applicationId), + substituteHistory, + "a replay adds no revision", + ); + expectProblem( + yield* record(leader, { outcome: "Rejected" }, item.etag, key), + 409, + "idempotency.digest-conflict", + "another command under a used key", + ); + assert.deepEqual( + expectValue(yield* record(leader, { outcome: "Substitute" }, substitute.etag), "again"), + substitute, + "recording the current outcome again changes nothing", + ); + assert.deepEqual(yield* outcomeHistory(applicationId), substituteHistory); + gates.push( + "Substitute is recorded once with ifMatch and an idempotency key; replay and a repeated outcome add no revision", + ); - await database.query( - `UPDATE public.organization_memberships SET is_suspended=true WHERE person_id='${leaderPersonId}'`, - ); - await expectProblem( - await request(recordPath, leader, { outcome: "Substitute" }, item.etag, key), - 403, - "authority.denied", - "revoked authority cannot replay a stored result", - ); - await expectProblem( - await request(boardPath(openSemesterId), leader), - 403, - "authority.denied", - "revoked authority cannot read", - ); - await database.query( - `UPDATE public.organization_memberships SET is_suspended=false WHERE person_id='${leaderPersonId}'`, - ); - gates.push("revoked authority cannot replay a stored result or read"); + expectProblem( + yield* record(leader, { outcome: "Rejected" }, item.etag), + 412, + "precondition.failed", + "stale version", + ); - assert.deepEqual( - await read(boardPath(noPeriodSemesterId)), - AdmissionOutcomeBoardResource.cases.Decide.make({ - departmentId, - semesterId: noPeriodSemesterId, - admissionPeriodId: null, - entries: [], - }), - ); - assert.deepEqual( - await read(boardPath(noPeriodSemesterId), member), - AdmissionOutcomeBoardResource.cases.ReadOnly.make({ - departmentId, - semesterId: noPeriodSemesterId, - admissionPeriodId: null, - substitutes: [], - }), - ); - await expectProblem( - await request(boardPath("semester-missing-substitutes"), leader), - 422, - "scope.invalid", - "unknown semester", - ); - gates.push("a scope without an admission period is empty with admissionPeriodId null; an unknown semester is invalid"); + const staleKey = newKey(); - for (const statement of [ - "UPDATE public.admission_application_outcomes SET outcome='Admitted' WHERE application_id=$1", - "DELETE FROM public.admission_application_outcomes WHERE application_id=$1", - ]) - await assert.rejects(database.query(statement, [applicationId]), (error) => { - assert.ok(Predicate.hasProperty(error, "message"), statement); - assert.match(String(error.message), /append-only/u, statement); + for (const attempt of ["initial stale record", "unchanged rejected retry"]) + expectProblem( + yield* record(leader, { outcome: "Rejected" }, item.etag, staleKey), + 412, + "precondition.failed", + attempt, + ); - return true; - }); - assert.deepEqual(await outcomeHistory(applicationId), rejectedHistory); - - const receiptCount = ( - await database.query( - "SELECT count(*)::integer AS count FROM public.native_http_idempotency_receipts WHERE operation_id='admissionOutcomes.recordOutcome'", - ) - ).rows[0].count; - - // Receipts: the first Substitute, the repeated Substitute under a new key, and the concurrent winner. - assert.equal(receiptCount, 3, "only executed records keep a receipt, not replays or refusals"); - gates.push("the outcome history is append-only and only executed records keep a receipt"); - - const manifest = { - revision, - backendOrigin, - dashboardOrigin, - artifacts, - departmentId, - semesterId: historicalSemesterId, - noPeriodSemesterId, - applicationId: browserApplicationId, - leaderPersonId, - onCall: "Sofie Søker, sofie.soker@example.invalid, 90000049", - persons, - }; - - const manifestPath = join(artifacts, "manifest.json"); - await writeFile(manifestPath, JSON.stringify(manifest), { mode: 0o600 }); - const openScopeBefore = await openScopeOutcomes(); - let browserEvidence: Schema.JsonObject | null = null; - - if (process.argv.includes("--browser")) { - run( - "bun", - ["apps/dashboard/e2e/run-real-native-substitutes.mjs"], - { - ...environment, - SUBSTITUTE_JOURNEY_MANIFEST: manifestPath, - }, - 300_000, + assert.deepEqual(yield* outcomeHistory(applicationId), substituteHistory); + gates.push("a stale version is refused (412), also on an unchanged retry, and records nothing"); + + assert.deepEqual( + yield* board(openSemesterId, member), + AdmissionOutcomeBoardResource.cases.ReadOnly.make({ + ...scopeOf(openSemesterId), + admissionPeriodId: openBoard.admissionPeriodId, + substitutes: [ + { + applicationId, + firstName: submission.firstName, + lastName: submission.lastName, + email: submission.email, + phone: submission.phone, + }, + ], + }), + "a member reads only who is on call, with name, e-mail and phone", ); - browserEvidence = Schema.decodeSync(Schema.fromJsonString(Schema.JsonObject))( - await readFile(join(artifacts, "browser-evidence.json"), "utf8"), + expectProblem(yield* readItem(member), 403, "authority.denied", "member single read"); + expectProblem( + yield* record(member, { outcome: "Rejected" }, substitute.etag), + 403, + "authority.denied", + "member record", ); - assert.equal(browserEvidence.passed, true); - assert.equal(browserEvidence.revision, revision); - assert.deepEqual( - await outcomeHistory(browserApplicationId), - browserEvidence.finalHistory, - "browser outcomes independently observed in PostgreSQL", + gates.push("a member reads the on-call list only; single reads and records are denied (403)"); + + expectProblem( + yield* readBoard(openSemesterId, other), + 403, + "authority.denied", + "other department board", + ); + expectProblem(yield* readItem(other), 403, "authority.denied", "other department item"); + expectProblem( + yield* record(other, { outcome: "Rejected" }, substitute.etag), + 403, + "authority.denied", + "other department record", + ); + + expectProblem(yield* listScopes(), 401, "credential.missing", "anonymous scopes"); + expectProblem(yield* readBoard(openSemesterId), 401, "credential.missing", "anonymous board"); + expectProblem(yield* readItem(), 401, "credential.missing", "anonymous item"); + expectProblem( + yield* record(undefined, { outcome: "Rejected" }, substitute.etag), + 401, + "credential.missing", + "anonymous record", + ); + assert.deepEqual(yield* outcomeHistory(applicationId), substituteHistory); + gates.push("another department (403) and anonymous callers (401) read and record nothing"); + + yield* query( + `INSERT INTO public.organization_global_administrator_grants(grant_id,person_id,start_at,end_at,revision) VALUES ('admin-substitutes','journey-rec-interviewer-b-0049','2026-01-01',NULL,0)`, + ); + assert.equal( + (yield* board(openSemesterId, other))._tag, + "Decide", + "an active global administrator decides in every department", + ); + yield* query( + "DELETE FROM public.organization_global_administrator_grants WHERE grant_id='admin-substitutes'", ); + expectProblem(yield* readBoard(openSemesterId, other), 403, "authority.denied", "ended grant"); + gates.push("an active global administrator decides in every department"); + + const concurrent = yield* Effect.all( + [ + record(leader, { outcome: "Rejected" }, substitute.etag), + record(leader, { outcome: "Rejected" }, substitute.etag), + ], + { concurrency: "unbounded" }, + ); + + const statuses = concurrent.map((answer) => answer.status); + assert.equal( + statuses.filter((status) => status === 200).length, + 1, + `concurrent records ${statuses.join(",")}`, + ); + assert.ok( + statuses.some((status) => status === 409 || status === 412), + `concurrent rejection ${statuses.join(",")}`, + ); + + const rejectedHistory = [ + ...substituteHistory, + { revision: 2, outcome: "Rejected", decidedBy: leaderPersonId }, + ]; + + assert.deepEqual(yield* outcomeHistory(applicationId), rejectedHistory); + const memberAfterReject = yield* board(openSemesterId, member); + assert.deepEqual( - await outcomeHistory(undecidedBrowserApplicationId), + Predicate.isTagged(memberAfterReject, "ReadOnly") ? memberAfterReject.substitutes : null, [], - "an application nobody decided keeps no outcome", + "a rejected applicant is no longer on call", + ); + + const leaderAfterReject = yield* board(openSemesterId); + + assert.equal( + Predicate.isTagged(leaderAfterReject, "Decide") + ? leaderAfterReject.entries.find((entry) => entry.applicationId === applicationId)?.outcome + : null, + "Rejected", + ); + gates.push( + "of two concurrent records on one version exactly one applies; Rejected leaves the on-call list", + ); + + yield* query( + `UPDATE public.organization_memberships SET is_suspended=true WHERE person_id='${leaderPersonId}'`, + ); + expectProblem( + yield* record(leader, { outcome: "Substitute" }, item.etag, key), + 403, + "authority.denied", + "revoked authority cannot replay a stored result", + ); + expectProblem( + yield* readBoard(openSemesterId, leader), + 403, + "authority.denied", + "revoked authority cannot read", ); + yield* query( + `UPDATE public.organization_memberships SET is_suspended=false WHERE person_id='${leaderPersonId}'`, + ); + gates.push("revoked authority cannot replay a stored result or read"); + assert.deepEqual( - await openScopeOutcomes(), - openScopeBefore, - "browser commands do not change another semester", + yield* board(noPeriodSemesterId), + AdmissionOutcomeBoardResource.cases.Decide.make({ + ...scopeOf(noPeriodSemesterId), + admissionPeriodId: null, + entries: [], + }), + ); + assert.deepEqual( + yield* board(noPeriodSemesterId, member), + AdmissionOutcomeBoardResource.cases.ReadOnly.make({ + ...scopeOf(noPeriodSemesterId), + admissionPeriodId: null, + substitutes: [], + }), + ); + expectProblem( + yield* readBoard("semester-missing-substitutes", leader), + 422, + "scope.invalid", + "unknown semester", + ); + gates.push( + "a scope without an admission period is empty with admissionPeriodId null; an unknown semester is invalid", ); - } - const bunVersion = process.versions.bun; + for (const statement of [ + "UPDATE public.admission_application_outcomes SET outcome='Admitted' WHERE application_id=$1", + "DELETE FROM public.admission_application_outcomes WHERE application_id=$1", + ]) { + const refusal = yield* Effect.flip(query(statement, [applicationId])); + assert.ok(Predicate.hasProperty(refusal.cause, "message"), statement); + assert.match(String(refusal.cause.message), /append-only/u, statement); + } - const postgresVersion = Schema.decodeUnknownSync(Schema.String)( - (await database.query("SELECT version() AS version")).rows[0].version, - ); + assert.deepEqual(yield* outcomeHistory(applicationId), rejectedHistory); - const runtime: { readonly postgres: string; readonly bun?: string } = - bunVersion === undefined - ? { postgres: postgresVersion } - : { bun: bunVersion, postgres: postgresVersion }; - - evidence = { - revision, - runtime, - apiPassed: true, - apiGates: gates, - receiptCount, - browserEvidence, - scope: "owned loopback synthetic runtime; no production/provider effects", - }; - summary = { - apiGates: gates.length, - browserGates: - browserEvidence !== null && Array.isArray(browserEvidence.gates) - ? browserEvidence.gates.length - : 0, - receiptCount, - }; -} catch (error) { - process.stderr.write(outputs.join("").slice(-12000)); - throw error; -} finally { - if (database) await database.end(); - - for (const child of children.reverse()) await stopOwnedProcess(child); - await postgres?.stop(); - await rm(join(artifacts, "manifest.json"), { force: true }); - - if (evidence && summary) { - const evidencePath = join(artifacts, "evidence.json"); - await writeFile( - evidencePath, - JSON.stringify( - Struct.assign(evidence, { - cleanup: "owned processes exited; disposable PostgreSQL and credential manifest removed", - }), - null, - 2, - ), + const receiptCount = ( + yield* query( + "SELECT count(*)::integer AS count FROM public.native_http_idempotency_receipts WHERE operation_id='admissionOutcomes.recordOutcome'", + ) + ).rows[0].count; + + // Receipts: the first Substitute, the repeated Substitute under a new key, and the concurrent winner. + assert.equal(receiptCount, 3, "only executed records keep a receipt, not replays or refusals"); + gates.push("the outcome history is append-only and only executed records keep a receipt"); + + const manifest = { + revision, + backendOrigin, + dashboardOrigin, + artifacts, + departmentId, + semesterId: historicalSemesterId, + noPeriodSemesterId, + applicationId: browserApplicationId, + leaderPersonId, + onCall: "Sofie Søker, sofie.soker@example.invalid, 90000049", + persons, + }; + + yield* fs.writeFileString(manifestPath, yield* jsonText(manifest), { mode: 0o600 }); + const openScopeBefore = yield* openScopeOutcomes; + let browserEvidence: Schema.JsonObject | null = null; + + if (process.argv.includes("--browser")) { + yield* commandOutput({ + command: "bun", + args: ["apps/dashboard/e2e/run-real-native-substitutes.mjs"], + cwd: root, + env: { ...environment, SUBSTITUTE_JOURNEY_MANIFEST: manifestPath }, + deadline: "300 seconds", + }); + browserEvidence = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.JsonObject))( + yield* fs.readFileString(path.join(artifacts, "browser-evidence.json")), + ); + assert.equal(browserEvidence.passed, true); + assert.equal(browserEvidence.revision, revision); + assert.deepEqual( + yield* outcomeHistory(browserApplicationId), + browserEvidence.finalHistory, + "browser outcomes independently observed in PostgreSQL", + ); + assert.deepEqual( + yield* outcomeHistory(undecidedBrowserApplicationId), + [], + "an application nobody decided keeps no outcome", + ); + assert.deepEqual( + yield* openScopeOutcomes, + openScopeBefore, + "browser commands do not change another semester", + ); + } + + const bunVersion = process.versions.bun; + + const postgresVersion = yield* Schema.decodeUnknownEffect(Schema.String)( + (yield* query("SELECT version() AS version")).rows[0].version, ); - process.stdout.write(`${JSON.stringify({ passed: true, evidencePath, ...summary })}\n`); - } -} + + const runtime: { readonly postgres: string; readonly bun?: string } = + bunVersion === undefined + ? { postgres: postgresVersion } + : { bun: bunVersion, postgres: postgresVersion }; + + const evidence: Schema.JsonObject = { + revision, + runtime, + apiPassed: true, + apiGates: gates, + receiptCount, + browserEvidence, + scope: "owned loopback synthetic runtime; no production/provider effects", + }; + + const summary = { + apiGates: gates.length, + browserGates: + browserEvidence !== null && Array.isArray(browserEvidence.gates) + ? browserEvidence.gates.length + : 0, + receiptCount, + }; + + return { evidence, summary }; + }).pipe( + Effect.tapCause(() => Console.error(outputs.join("").slice(-12000))), + Effect.scoped, + ); + + const { evidence, summary } = yield* api; + const evidencePath = path.join(artifacts, "evidence.json"); + + yield* fs.writeFileString( + evidencePath, + yield* indentedJsonText( + Struct.assign(evidence, { + cleanup: "owned processes exited; disposable PostgreSQL and credential manifest removed", + }), + ), + ); + yield* Console.log(yield* jsonText({ passed: true, evidencePath, ...summary })); +}); + +BunRuntime.runMain( + journey.pipe(Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer))), +); diff --git a/tools/conventions/package.json b/tools/conventions/package.json index 87dff0ccd..8469c45af 100644 --- a/tools/conventions/package.json +++ b/tools/conventions/package.json @@ -8,6 +8,7 @@ "test": "bun test tests" }, "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", "effect": "catalog:effect-v4", "rolldown": "1.2.6" }, diff --git a/tools/conventions/src/check.ts b/tools/conventions/src/check.ts index ebe1d6a48..635ffd154 100644 --- a/tools/conventions/src/check.ts +++ b/tools/conventions/src/check.ts @@ -2,7 +2,8 @@ * The layout rules. Each finding names a path and what to change; the declarations in `layout.ts` * and `journeys.ts` are the only places to allow an exception. */ -import { posix } from "node:path"; +import { dual } from "effect/Function"; +import { repositoryPath } from "./repository-path.js"; import { Schema } from "effect"; import { boundedContextNames, contextFolderName } from "./cml.js"; import { checkJourneys, readWorkflow, testsWorkflow, type Workflow } from "./journeys.js"; @@ -242,7 +243,9 @@ const toolImportFindings = (repository: Repository): ReadonlyArray => { const imported = match[1] ?? match[2] ?? ""; const tool = imported.startsWith(".") - ? /^(tools\/[^/]+)(?:\/|$)/u.exec(posix.join(posix.dirname(path), imported))?.[1] + ? /^(tools\/[^/]+)(?:\/|$)/u.exec( + repositoryPath.join(repositoryPath.dirname(path), imported), + )?.[1] : toolByPackage.get( imported .split("/") @@ -324,11 +327,12 @@ const vitestConfigFindings = (repository: Repository): ReadonlyArray => if (!Object.values(scripts).some((script) => vitestScript.test(script))) return []; - const directory = posix.dirname(path); + const directory = repositoryPath.dirname(path); const configs = repository.paths.filter( (candidate) => - posix.dirname(candidate) === directory && vitestConfigName.test(posix.basename(candidate)), + repositoryPath.dirname(candidate) === directory && + vitestConfigName.test(repositoryPath.basename(candidate)), ); if (configs.length === 0) @@ -349,7 +353,7 @@ const vitestConfigFindings = (repository: Repository): ReadonlyArray => return ( target.startsWith(".") && - posix.join(directory, target).replace(sourceExtension, "") === base + repositoryPath.join(directory, target).replace(sourceExtension, "") === base ); }); @@ -430,7 +434,10 @@ const sectionFindings = ( ]); /** Every layout finding of the repository, sorted by path. */ -export const checkLayout = (repository: Repository, justfile: Justfile): ReadonlyArray => { +export const checkLayout: { + (justfile: Justfile): (repository: Repository) => ReadonlyArray; + (repository: Repository, justfile: Justfile): ReadonlyArray; +} = dual(2, (repository: Repository, justfile: Justfile): ReadonlyArray => { const workflow = readWorkflow(repository.read(testsWorkflow)); return [ @@ -444,4 +451,4 @@ export const checkLayout = (repository: Repository, justfile: Justfile): Readonl ...checkJourneys(repository, justfile, workflow), ...sectionFindings(repository, justfile, workflow), ].sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); -}; +}); diff --git a/tools/conventions/src/cli.ts b/tools/conventions/src/cli.ts index ffa791da9..350d4c328 100644 --- a/tools/conventions/src/cli.ts +++ b/tools/conventions/src/cli.ts @@ -6,8 +6,11 @@ * files do not count. `just constructs consumers [name]` prints the modules that import a * construct, which no generated page lists. */ -import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import process from "node:process"; +import { Console, Effect, Exit, FileSystem, Option, Path, Predicate } from "effect"; +import * as Runtime from "effect/Runtime"; import { checkLayout, type Finding } from "./check.js"; import { readContextModel } from "./cml.js"; import { @@ -53,190 +56,208 @@ const staged = options.includes("--staged"); const [command = "check", ...rest] = options.filter((option) => option !== "--staged"); -const root = repositoryRoot(process.cwd()); +const program = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* repositoryRoot(process.cwd()); -const rewrite = (path: string, text: string) => { - const file = join(root, path); - let current: string | undefined; + const readText = (file: string) => Effect.option(fileSystem.readFileString(file)); - try { - current = readFileSync(file, "utf8"); - } catch { - current = undefined; - } - - if (current === text) return; + const rewrite = Effect.fnUntraced(function* (relative: string, text: string) { + const file = path.join(root, relative); + const current = yield* readText(file); - mkdirSync(dirname(file), { recursive: true }); - writeFileSync(file, text); - process.stdout.write(`${topic}: rewrote ${path}\n`); -}; + if (Option.isSome(current) && current.value === text) return; -const layout = (): Outcome => { - const justfile = readJustfile(join(root, "justfile")); + yield* fileSystem.makeDirectory(path.dirname(file), { recursive: true }); + yield* fileSystem.writeFileString(file, text); + yield* Console.log(`${topic}: rewrote ${relative}`); + }); - if (command === "write") { - const read = (path: string) => readFileSync(join(root, path), "utf8"); + const layout = Effect.gen(function* () { + const justfile = yield* readJustfile(path.join(root, "justfile")); - for (const { path, text } of spliceFiles(read, justfile, readWorkflow(read(testsWorkflow)))) - rewrite(path, text); - } + if (command === "write") { + const tree = yield* readRepository(root, false); - const repository = readRepository(root, staged); + for (const { path: relative, text } of spliceFiles( + tree.read, + justfile, + readWorkflow(tree.read(testsWorkflow)), + )) + yield* rewrite(relative, text); + } - return { - findings: checkLayout(repository, justfile), - warnings: [], - summary: `${repository.paths.length} ${staged ? "staged" : "working tree"} files`, - }; -}; + const repository = yield* readRepository(root, staged); -const constructs = (): Outcome => { - if (command === "write") { - const tree = readRepository(root, false); - const pages = renderPages(readConstructs(tree).constructs); + return { + findings: checkLayout(repository, justfile), + warnings: [], + summary: `${repository.paths.length} ${staged ? "staged" : "working tree"} files`, + } satisfies Outcome; + }); - for (const [path, text] of pages) rewrite(path, text); + const constructs = Effect.gen(function* () { + if (command === "write") { + const tree = yield* readRepository(root, false); + const pages = renderPages(readConstructs(tree).constructs); - for (const path of tree.paths) - if (path.startsWith(`${constructPages.contracts}/`) && !pages.has(path)) { - rmSync(join(root, path)); - process.stdout.write(`${topic}: removed ${path}\n`); - } - } + for (const [relative, text] of pages) yield* rewrite(relative, text); - const check = checkConstructs(readRepository(root, staged)); + for (const relative of tree.paths) + if (relative.startsWith(`${constructPages.contracts}/`) && !pages.has(relative)) { + yield* fileSystem.remove(path.join(root, relative)); + yield* Console.log(`${topic}: removed ${relative}`); + } + } - return { - findings: check.findings, - warnings: check.candidates.map( - (candidate) => - `${candidate.path}:${candidate.line}: ${candidate.name} has no @construct tag, and ${candidate.importers.length} modules outside ${packageOf(candidate.path) ?? "its package"} import it, in ${[...new Set(candidate.importers.map((importer) => packageOf(importer) ?? importer))].join(", ")}`, - ), - summary: `${check.modules} modules, ${check.constructs.length} constructs, ${check.consumers.reduce((sum, { importers }) => sum + importers.length, 0)} consumers, ${check.candidates.length} untagged candidates`, - }; -}; + const check = checkConstructs(yield* readRepository(root, staged)); -/** Prints the consumers of each construct called `name`, or the count of every construct. */ -const printConsumers = (name: string | undefined): number => { - const repository = readRepository(root, false); - const all = readConstructs(repository).constructs; - const selected = name === undefined ? all : all.filter((construct) => construct.name === name); + return { + findings: check.findings, + warnings: check.candidates.map( + (candidate) => + `${candidate.path}:${candidate.line}: ${candidate.name} has no @construct tag, and ${candidate.importers.length} modules outside ${packageOf(candidate.path) ?? "its package"} import it, in ${[...new Set(candidate.importers.map((importer) => packageOf(importer) ?? importer))].join(", ")}`, + ), + summary: `${check.modules} modules, ${check.constructs.length} constructs, ${check.consumers.reduce((sum, { importers }) => sum + importers.length, 0)} consumers, ${check.candidates.length} untagged candidates`, + } satisfies Outcome; + }); - if (selected.length === 0) { - process.stderr.write(`constructs: no construct is called ${name ?? ""}\n`); + /** Prints the consumers of each construct called `name`, or the count of every construct. */ + const printConsumers = Effect.fnUntraced(function* (name: string | undefined) { + const repository = yield* readRepository(root, false); + const all = readConstructs(repository).constructs; + const selected = name === undefined ? all : all.filter((construct) => construct.name === name); - return 1; - } + if (selected.length === 0) { + yield* Console.error(`constructs: no construct is called ${name ?? ""}`); - for (const { construct, importers, counted } of readConsumers( - selected, - readModuleGraph(repository), - )) { - process.stdout.write( - `${construct.path}:${construct.line} ${construct.name}: ${importers.length} ${importers.length === 1 ? "consumer" : "consumers"}, ${counted.length} outside the tests of ${packageOf(construct.path) ?? "its app or package"}\n`, - ); + return 1; + } - if (name !== undefined) - for (const importer of importers) process.stdout.write(` ${importer}\n`); - } + for (const { construct, importers, counted } of readConsumers( + selected, + readModuleGraph(repository), + )) { + yield* Console.log( + `${construct.path}:${construct.line} ${construct.name}: ${importers.length} ${importers.length === 1 ? "consumer" : "consumers"}, ${counted.length} outside the tests of ${packageOf(construct.path) ?? "its app or package"}`, + ); - return 0; -}; + if (name !== undefined) for (const importer of importers) yield* Console.log(` ${importer}`); + } -const guideSet = (repository: Repository) => { - const model = readContextModel(repository.read(contextMap)); - const { constructs } = readConstructs(repository); + return 0; + }); - return { model, set: renderGuides(repository, model, constructs) }; -}; + const guideSet = (repository: Repository) => { + const model = readContextModel(repository.read(contextMap)); + const { constructs: found } = readConstructs(repository); -const guides = (): Outcome => { - if (command === "write") { - const repository = readRepository(root, false); - const { set } = guideSet(repository); - - for (const guide of set.guides) { - const agents = `${guide.directory}/${guideFile}`; - const section = set.sections.get(guide.directory); - - if (section !== undefined) - rewrite( - agents, - guideText( - repository.paths.includes(agents) ? repository.read(agents) : undefined, - section, - ), - ); - - const link = join(root, guide.directory, linkFile); - let current: string | undefined; - - try { - current = lstatSync(link).isSymbolicLink() ? undefined : readFileSync(link, "utf8"); - } catch { - current = undefined; - } + return { model, set: renderGuides(repository, model, found) }; + }; - if (current !== linkText) { - rmSync(link, { force: true }); - writeFileSync(link, linkText); - process.stdout.write( - `guides: wrote ${guide.directory}/${linkFile}, which imports ${guideFile}\n`, - ); + const guides = Effect.gen(function* () { + if (command === "write") { + const repository = yield* readRepository(root, false); + const { set } = guideSet(repository); + + for (const guide of set.guides) { + const agents = `${guide.directory}/${guideFile}`; + const section = set.sections.get(guide.directory); + + if (section !== undefined) + yield* rewrite( + agents, + guideText( + repository.paths.includes(agents) ? repository.read(agents) : undefined, + section, + ), + ); + + const link = path.join(root, guide.directory, linkFile); + + // A symbolic link does not count as the link file, whatever its target holds. + const current = Option.isSome(yield* Effect.option(fileSystem.readLink(link))) + ? Option.none() + : yield* readText(link); + + if (Option.isNone(current) || current.value !== linkText) { + yield* fileSystem.remove(link, { force: true }); + yield* fileSystem.writeFileString(link, linkText); + yield* Console.log( + `guides: wrote ${guide.directory}/${linkFile}, which imports ${guideFile}`, + ); + } } } - } - const repository = readRepository(root, staged); - const { model, set } = guideSet(repository); + const repository = yield* readRepository(root, staged); + const { model, set } = guideSet(repository); + + return { + findings: [ + ...model.errors.map((message) => ({ path: contextMap, message })), + ...checkGuides(repository, set), + ], + warnings: [], + summary: `${set.guides.length} guides`, + } satisfies Outcome; + }); + + const exceptions = Effect.gen(function* () { + const report = checkExceptions(yield* readRepository(root, staged)); + + return { + findings: report.findings, + warnings: [], + summary: `${report.exceptions.length} exceptions, ${report.suppressions.length} suppressions, ${report.references.length} references`, + } satisfies Outcome; + }); + + const run = Object.entries({ layout, constructs, guides, exceptions }).find( + ([name]) => name === topic, + )?.[1]; + + const consumersOf = + topic === "constructs" && command === "consumers" && !staged && rest.length <= 1; + + if ( + run === undefined || + !(command === "check" || command === "write" || consumersOf) || + (rest.length > 0 && !consumersOf) || + (staged && command === "write") || + (topic === "exceptions" && command === "write") + ) { + yield* Console.error(usage.trimEnd()); + + return 2; + } - return { - findings: [ - ...model.errors.map((message) => ({ path: contextMap, message })), - ...checkGuides(repository, set), - ], - warnings: [], - summary: `${set.guides.length} guides`, - }; -}; + if (consumersOf) return yield* printConsumers(rest[0]); -const exceptions = (): Outcome => { - const report = checkExceptions(readRepository(root, staged)); + const outcome: Outcome = yield* run; - return { - findings: report.findings, - warnings: [], - summary: `${report.exceptions.length} exceptions, ${report.suppressions.length} suppressions, ${report.references.length} references`, - }; -}; + for (const { path: file, message } of outcome.findings) + yield* Console.error(`${file}: ${message}`); -const run = Object.entries({ layout, constructs, guides, exceptions }).find( - ([name]) => name === topic, -)?.[1]; - -const consumersOf = - topic === "constructs" && command === "consumers" && !staged && rest.length <= 1; - -if ( - run === undefined || - !(command === "check" || command === "write" || consumersOf) || - (rest.length > 0 && !consumersOf) || - (staged && command === "write") || - (topic === "exceptions" && command === "write") -) { - process.stderr.write(usage); - process.exit(2); -} + for (const warning of outcome.warnings) yield* Console.error(`warning: ${warning}`); -if (consumersOf) process.exit(printConsumers(rest[0])); + yield* Console.log(`${topic}: ${outcome.summary}, ${outcome.findings.length} findings`); -const outcome = run(); + return outcome.findings.length === 0 ? 0 : 1; +}); -for (const { path, message } of outcome.findings) process.stderr.write(`${path}: ${message}\n`); +/** The platform of the checks, which their tests take too. */ +export const ConventionsPlatform = BunServices.layer; -for (const warning of outcome.warnings) process.stderr.write(`warning: ${warning}\n`); +/** The services of `ConventionsPlatform`. */ +export type ConventionsPlatform = BunServices.BunServices; -process.stdout.write(`${topic}: ${outcome.summary}, ${outcome.findings.length} findings\n`); +// A program that succeeds with a number exits with that code. +const teardown: Runtime.Teardown = (exit, onExit) => { + if (Exit.isSuccess(exit) && Predicate.isNumber(exit.value)) onExit(exit.value); + else Runtime.defaultTeardown(exit, onExit); +}; -process.exitCode = outcome.findings.length === 0 ? 0 : 1; +if (import.meta.main) + BunRuntime.runMain(program.pipe(Effect.provide(ConventionsPlatform)), { teardown }); diff --git a/tools/conventions/src/constructs.ts b/tools/conventions/src/constructs.ts index ed8b733ab..e43fef4e9 100644 --- a/tools/conventions/src/constructs.ts +++ b/tools/conventions/src/constructs.ts @@ -13,7 +13,8 @@ * modules outside its own module and the tests of its app or package import it. It warns about * functions that three or more modules outside their app or package import untagged. */ -import { posix } from "node:path"; +import { dual } from "effect/Function"; +import { repositoryPath } from "./repository-path.js"; import type { Finding } from "./check.js"; import { contractGaps, @@ -40,9 +41,13 @@ import type { Repository } from "./repository.js"; */ export const constructCategories = { "http-transport": - "Reads native HTTP requests and writes their representations: bounded JSON, preconditions, idempotency keys, entity tags, and cache headers.", - "http-problem": - "Answers a native HTTP request with a declared problem: failure mapping, credential classification, authorization, and decoding.", + "Derives the transport facts that commands keep across the cutover from HTTP: command identities, request digests, preconditions, and entity tags.", + "authority-evidence": + "Checks a resolved authority and returns the evidence that a command which needs that authority takes; nothing else constructs it.", + "rpc-transport": + "Addresses the native RPC endpoints over HTTP, as the ingress serves them and every recorder matches them.", + "rpc-problem": + "Answers a native RPC with a declared problem: failure mapping, credential classification, authorization, command outcomes, and the defect boundary.", "sql-lock": "Transaction-scoped PostgreSQL advisory locks under registered keys.", "sql-lifecycle": "Claim-fenced row lifecycles in PostgreSQL, such as outbox claims and account access.", @@ -52,6 +57,8 @@ export const constructCategories = { digest: "Canonical JSON and SHA-256 digests that evidence and idempotency identities hash.", "test-harness": "Starts and drives disposable infrastructure for tests, proofs, and journeys: PostgreSQL clusters, loopback ports, and the local backend.", + "browser-audit": + "Audits the pages that browser journeys render once they have settled: accessibility with axe.", "request-ledger": "Classifies the requests that journey recorders observe by whole path segments: native contract operations and legacy routes.", } satisfies Readonly>; @@ -221,25 +228,28 @@ const parseFindings = (graph: ModuleGraph): ReadonlyArray => ); /** The importers of each construct, which only this reads from the import graph. */ -export const readConsumers = ( - constructs: ReadonlyArray, - graph: ModuleGraph, -): ReadonlyArray => - constructs.map((construct) => { - const importers = [ - ...(graph.importers.get(bindingKey({ path: construct.path, name: construct.name })) ?? []), - ].sort(); - - const owner = packageOf(construct.path); - - return { - construct, - importers, - counted: importers.filter( - (importer) => !testFile.test(importer) || packageOf(importer) !== owner, - ), - }; - }); +export const readConsumers: { + (graph: ModuleGraph): (constructs: ReadonlyArray) => ReadonlyArray; + (constructs: ReadonlyArray, graph: ModuleGraph): ReadonlyArray; +} = dual( + 2, + (constructs: ReadonlyArray, graph: ModuleGraph): ReadonlyArray => + constructs.map((construct) => { + const importers = [ + ...(graph.importers.get(bindingKey({ path: construct.path, name: construct.name })) ?? []), + ].sort(); + + const owner = packageOf(construct.path); + + return { + construct, + importers, + counted: importers.filter( + (importer) => !testFile.test(importer) || packageOf(importer) !== owner, + ), + }; + }), +); /** A construct that fewer than two modules share is no shared construct. */ export const consumerFindings = (consumers: ReadonlyArray): ReadonlyArray => @@ -255,40 +265,53 @@ export const consumerFindings = (consumers: ReadonlyArray): ReadonlyA ); /** The untagged functions that three or more modules outside their app or package import. */ -export const readCandidates = ( - repository: Repository, - graph: ModuleGraph, - constructs: ReadonlyArray, -): ReadonlyArray => { - const tagged = new Set( - constructs.map((construct) => bindingKey({ path: construct.path, name: construct.name })), - ); +export const readCandidates: { + ( + graph: ModuleGraph, + constructs: ReadonlyArray, + ): (repository: Repository) => ReadonlyArray; + ( + repository: Repository, + graph: ModuleGraph, + constructs: ReadonlyArray, + ): ReadonlyArray; +} = dual( + 3, + ( + repository: Repository, + graph: ModuleGraph, + constructs: ReadonlyArray, + ): ReadonlyArray => { + const tagged = new Set( + constructs.map((construct) => bindingKey({ path: construct.path, name: construct.name })), + ); - const candidates: Array = []; + const candidates: Array = []; - for (const [key, importers] of graph.importers) { - if (tagged.has(key)) continue; + for (const [key, importers] of graph.importers) { + if (tagged.has(key)) continue; - const hash = key.lastIndexOf("#"); - const path = key.slice(0, hash); - const owner = packageOf(path); - const outside = [...importers].filter((importer) => packageOf(importer) !== owner); + const hash = key.lastIndexOf("#"); + const path = key.slice(0, hash); + const owner = packageOf(path); + const outside = [...importers].filter((importer) => packageOf(importer) !== owner); - if (outside.length < candidateImporters) continue; + if (outside.length < candidateImporters) continue; - const name = key.slice(hash + 1); - const local = graph.modules.get(path)?.locals.get(name); + const name = key.slice(hash + 1); + const local = graph.modules.get(path)?.locals.get(name); - const found = readDeclarations(path, repository.read(path)).declarations.find( - (candidate) => candidate.local === local, - ); + const found = readDeclarations(path, repository.read(path)).declarations.find( + (candidate) => candidate.local === local, + ); - if (found?.callable === true) - candidates.push({ path, name, line: found.line, importers: outside.sort() }); - } + if (found?.callable === true) + candidates.push({ path, name, line: found.line, importers: outside.sort() }); + } - return candidates.sort(byLocation); -}; + return candidates.sort(byLocation); + }, +); // --------------------------------------------------------------------------------------------- // Pages @@ -416,7 +439,7 @@ const renderIndex = ( categories: ReadonlyArray, links: ReadonlyMap, ): string => { - const from = posix.dirname(constructPages.index); + const from = repositoryPath.dirname(constructPages.index); return [ "# Shared constructs", @@ -435,10 +458,10 @@ const renderIndex = ( `\`just constructs\` fails when these pages differ from the tags, when a construct lacks one of these tags or annotations, and when fewer modules import it. It lists untagged functions that ${candidateImporters} or more modules outside their app or package import.`, "", ...categories.flatMap(({ category, holds, members }) => [ - `- [${category}](${posix.relative(from, pageOf(category))}): ${holds}`, + `- [${category}](${repositoryPath.relative(from, pageOf(category))}): ${holds}`, ...members.map( (construct) => - ` - [${code(construct.name)}](${posix.relative(from, links.get(construct) ?? "")}): ${construct.summary}`, + ` - [${code(construct.name)}](${repositoryPath.relative(from, links.get(construct) ?? "")}): ${construct.summary}`, ), ]), "", @@ -451,7 +474,7 @@ const renderCategory = ({ category, holds, members }: Category): string => "", marker, "", - `${holds} The [index](${posix.relative(constructPages.contracts, constructPages.index)}) lists every category.`, + `${holds} The [index](${repositoryPath.relative(constructPages.contracts, constructPages.index)}) lists every category.`, "", ...members.flatMap(renderContract), ].join("\n"); @@ -470,40 +493,43 @@ export const renderPages = (constructs: ReadonlyArray): ReadonlyMap, -): ReadonlyArray => { - const pages = renderPages(constructs); - const present = new Set(repository.paths); - - return [ - ...[...pages].flatMap(([path, text]) => - !present.has(path) - ? [{ path, message: "is missing; run just constructs write" }] - : repository.read(path) === text - ? [] - : [ +export const checkPages: { + (constructs: ReadonlyArray): (repository: Repository) => ReadonlyArray; + (repository: Repository, constructs: ReadonlyArray): ReadonlyArray; +} = dual( + 2, + (repository: Repository, constructs: ReadonlyArray): ReadonlyArray => { + const pages = renderPages(constructs); + const present = new Set(repository.paths); + + return [ + ...[...pages].flatMap(([path, text]) => + !present.has(path) + ? [{ path, message: "is missing; run just constructs write" }] + : repository.read(path) === text + ? [] + : [ + { + path, + message: + "differs from the @construct tags and their JSDoc; run just constructs write", + }, + ], + ), + ...repository.paths.flatMap((path) => + path.startsWith(`${constructPages.contracts}/`) && !pages.has(path) + ? [ { path, message: - "differs from the @construct tags and their JSDoc; run just constructs write", + "is the page of no construct category; run just constructs write to remove it", }, - ], - ), - ...repository.paths.flatMap((path) => - path.startsWith(`${constructPages.contracts}/`) && !pages.has(path) - ? [ - { - path, - message: - "is the page of no construct category; run just constructs write to remove it", - }, - ] - : [], - ), - ]; -}; + ] + : [], + ), + ]; + }, +); /** What `just constructs` reports: its findings fail the check, and its candidates do not. */ interface ConstructCheck { diff --git a/tools/conventions/src/contracts.ts b/tools/conventions/src/contracts.ts index 6a2e9636a..54666ee8e 100644 --- a/tools/conventions/src/contracts.ts +++ b/tools/conventions/src/contracts.ts @@ -7,10 +7,13 @@ * and what to do instead. The optional `@throws` names a failure that the types do not carry. * The signature is the parameter and return type annotations as written, and the errors and * requirements are split out of the Effect, Stream, Layer, Result, or Option that it returns. + * A value typed as an overload set, such as a `dual` function, takes the parameters and return + * type of its data-first overload, the one with the most parameters. * * Every function here takes any top-level declaration. What is particular to a shared construct, * its category and its consumers, lives in `constructs.ts`. */ +import { dual } from "effect/Function"; import { parseSync, type ESTree, type ParseResult } from "rolldown/utils"; // Rolldown exports the parse result but not its comment type. @@ -41,7 +44,10 @@ export interface Signature extends Channels { export interface Declaration { /** The local name, or `default` for an anonymous default export. */ readonly local: string; - /** A function declaration, or a variable whose initializer is a function. */ + /** + * A function declaration, a variable whose initializer is a function, or a variable whose type + * is an overload set, such as a `dual` function. + */ readonly callable: boolean; readonly line: number; /** The JSDoc lines without their leading `*`. */ @@ -283,6 +289,44 @@ const isFunction = (expression: ESTree.Expression | null): boolean => { return inner?.type === "ArrowFunctionExpression" || inner?.type === "FunctionExpression"; }; +/** + * The call signatures of a type that holds nothing else, such as the overload pair that `dual` + * declares; `undefined` for any other type. + */ +const callSignatures = ( + annotation: ESTree.TSType | undefined, +): ReadonlyArray | undefined => { + if (annotation?.type !== "TSTypeLiteral" || annotation.members.length === 0) return undefined; + + const signatures = annotation.members.flatMap((member) => + member.type === "TSCallSignatureDeclaration" ? [member] : [], + ); + + return signatures.length === annotation.members.length ? signatures : undefined; +}; + +/** + * An overload set, described by its data-first overload, the first with the most parameters: its + * parameters are the inputs and its return type the output. The text lists every overload. + */ +const overloadSignature = ( + name: string, + overloads: ReadonlyArray, + inline: Inline, +): Signature => { + const signatures = overloads.map((overload) => functionSignature(name, overload, inline)); + + const dataFirst = signatures.reduce((widest, signature) => + signature.inputs.length > widest.inputs.length ? signature : widest, + ); + + return { + ...dataFirst, + text: signatures.map((signature) => signature.text).join("\n"), + unannotated: [...new Set(signatures.flatMap((signature) => signature.unannotated))], + }; +}; + const variableSignature = ( keyword: string, name: string, @@ -291,9 +335,12 @@ const variableSignature = ( ): Signature => { const annotation = declarator.id.typeAnnotation?.typeAnnotation; const init = unwrap(declarator.init); + const overloads = callSignatures(annotation); if (annotation?.type === "TSFunctionType") return functionSignature(name, annotation, inline); + if (overloads !== undefined) return overloadSignature(name, overloads, inline); + if ( annotation === undefined && (init?.type === "ArrowFunctionExpression" || init?.type === "FunctionExpression") @@ -443,7 +490,9 @@ const declared = ( return node.declarations.flatMap((declarator) => bindingNames(declarator.id).map((local) => ({ local, - callable: isFunction(declarator.init), + callable: + isFunction(declarator.init) || + callSignatures(declarator.id.typeAnnotation?.typeAnnotation) !== undefined, signature: variableSignature(node.kind, local, declarator, inline), })), ); @@ -526,7 +575,10 @@ const declared = ( }; /** The top-level declarations of a module, with the line, JSDoc, and signature of each. */ -export const readDeclarations = (path: string, text: string): ModuleDeclarations => { +export const readDeclarations: { + (text: string): (path: string) => ModuleDeclarations; + (path: string, text: string): ModuleDeclarations; +} = dual(2, (path: string, text: string): ModuleDeclarations => { const parsed = parseSync(path, text); const lineStarts = [0, ...[...text.matchAll(/\n/gu)].map((match) => match.index + 1)]; const lineOf = (offset: number) => lineStarts.findLastIndex((start) => start <= offset) + 1; @@ -565,7 +617,7 @@ export const readDeclarations = (path: string, text: string): ModuleDeclarations : [], ), }; -}; +}); // --------------------------------------------------------------------------------------------- // Documentation @@ -635,13 +687,16 @@ export const readDocumentation = (doc: ReadonlyArray): Documentation => }; /** The text of the tags named `name`, joined as paragraphs; `undefined` when none has text. */ -export const tagText = (documentation: Documentation, name: string): string | undefined => { +export const tagText: { + (name: string): (documentation: Documentation) => string | undefined; + (documentation: Documentation, name: string): string | undefined; +} = dual(2, (documentation: Documentation, name: string): string | undefined => { const texts = documentation.tags.flatMap((tag) => tag.name === name && tag.text !== "" ? [tag.text] : [], ); return texts.length === 0 ? undefined : texts.join("\n\n"); -}; +}); /** The block tags that a contract requires, with what each holds. */ export const contractTags = { diff --git a/tools/conventions/src/exceptions.ts b/tools/conventions/src/exceptions.ts index a059ce660..95a0fd52f 100644 --- a/tools/conventions/src/exceptions.ts +++ b/tools/conventions/src/exceptions.ts @@ -14,6 +14,7 @@ * does not list for its file, and on an entry whose rules, files, symbols, owner, or examined * versions no longer match the tree. An upgrade of an examined package reopens its entries. */ +import { dual } from "effect/Function"; import { Result, Schema } from "effect"; import { parseSync } from "rolldown/utils"; import type { Finding } from "./check.js"; @@ -246,7 +247,10 @@ const diagnosticsSuppressions = (comment: Comment, line: number): ReadonlyArray< }); /** The suppressions of Effect rules and the exception references in the comments of one file. */ -export const readSites = (path: string, text: string): Sites => { +export const readSites: { + (text: string): (path: string) => Sites; + (path: string, text: string): Sites; +} = dual(2, (path: string, text: string): Sites => { const { comments } = parseSync(path, text); const lineStarts = [0, ...[...text.matchAll(/\n/gu)].map((match) => match.index + 1)]; const lineOf = (offset: number) => lineStarts.findLastIndex((start) => start <= offset) + 1; @@ -272,7 +276,7 @@ export const readSites = (path: string, text: string): Sites => { ); return { suppressions, references }; -}; +}); const sourceFile = /\.(?:[cm]?[jt]sx?)$/u; diff --git a/tools/conventions/src/guides.ts b/tools/conventions/src/guides.ts index eb3bb362e..c8b55a6e6 100644 --- a/tools/conventions/src/guides.ts +++ b/tools/conventions/src/guides.ts @@ -11,7 +11,8 @@ * by hand below the end marker, and `just guides write` keeps them. A folder's README stays the * human guide; the generated part links to it. */ -import { posix } from "node:path"; +import { dual } from "effect/Function"; +import { repositoryPath } from "./repository-path.js"; import type { Finding } from "./check.js"; import { type BoundedContext, @@ -62,7 +63,9 @@ const guideFiles = new Set([guideFile, linkFile]); /** Every folder that needs a guide: each app, package, and tool, and each context folder. */ const readGuides = (repository: Repository, model: ContextModel): ReadonlyArray => { // A folder that holds nothing but its guide needs none. - const contents = repository.paths.filter((path) => !guideFiles.has(posix.basename(path))); + const contents = repository.paths.filter( + (path) => !guideFiles.has(repositoryPath.basename(path)), + ); const contexts = new Map( model.contexts.map((context) => [contextFolderName(context.name), context.name]), @@ -164,7 +167,7 @@ const contextLink = (rendering: Rendering, context: string): string => { return target === undefined ? context - : `[${context}](${posix.relative(rendering.guide.directory, `${target.directory}/${guideFile}`)})`; + : `[${context}](${repositoryPath.relative(rendering.guide.directory, `${target.directory}/${guideFile}`)})`; }; const contextSection = (rendering: Rendering, context: BoundedContext): ReadonlyArray => { @@ -196,7 +199,7 @@ const contextSection = (rendering: Rendering, context: BoundedContext): Readonly })), ].filter((item) => item.aggregates.length > 0); - const cml = posix.relative(rendering.guide.directory, contextMap); + const cml = repositoryPath.relative(rendering.guide.directory, contextMap); return [ `## Bounded context: ${context.name}`, @@ -279,8 +282,8 @@ const entryPointSection = (rendering: Rendering): ReadonlyArray => { name === undefined || manifest?.exports === undefined ? [] : entryPoints(manifest.exports).flatMap(({ subpath, target }) => { - const path = posix.join(owner, target); - const module = posix.relative(directory, path); + const path = repositoryPath.join(owner, target); + const module = repositoryPath.relative(directory, path); return path.startsWith(`${directory}/`) ? [ @@ -292,7 +295,7 @@ const entryPointSection = (rendering: Rendering): ReadonlyArray => { : []; }); - const manifestLink = posix.relative(directory, `${owner}/package.json`); + const manifestLink = repositoryPath.relative(directory, `${owner}/package.json`); return [ "## Entry points", @@ -324,11 +327,11 @@ const constructSection = (rendering: Rendering): ReadonlyArray => { return [ "## Constructs", "", - `The shared constructs defined here. Each name links to its contract; [${constructPages.index}](${posix.relative(directory, constructPages.index)}) indexes them all.`, + `The shared constructs defined here. Each name links to its contract; [${constructPages.index}](${repositoryPath.relative(directory, constructPages.index)}) indexes them all.`, "", ...owned.map( (construct) => - `- [${code(construct.name)}](${posix.relative(directory, rendering.contracts.get(construct) ?? constructPages.index)}) (${construct.category}): ${construct.summary}`, + `- [${code(construct.name)}](${repositoryPath.relative(directory, rendering.contracts.get(construct) ?? constructPages.index)}) (${construct.category}): ${construct.summary}`, ), "", ]; @@ -368,7 +371,7 @@ const folderIntro = (rendering: Rendering, folder: ContextFolder): ReadonlyArray "", ...others.map( (guide) => - `- [${guide.directory}](${posix.relative(rendering.guide.directory, `${guide.directory}/${guideFile}`)})`, + `- [${guide.directory}](${repositoryPath.relative(rendering.guide.directory, `${guide.directory}/${guideFile}`)})`, ), ]), ]; @@ -394,7 +397,7 @@ const contextFolderSection = (rendering: Rendering): ReadonlyArray => { ? [ "## Context folders", "", - `Each folder of ${code(posix.relative(directory, layer))} holds a bounded context of [${contextMap}](${posix.relative(directory, contextMap)}), the shared kernel, or code that the layout declaration excepts. Each has its own guide.`, + `Each folder of ${code(repositoryPath.relative(directory, layer))} holds a bounded context of [${contextMap}](${repositoryPath.relative(directory, contextMap)}), the shared kernel, or code that the layout declaration excepts. Each has its own guide.`, "", table( ["Folder", "Bounded context", "Guide"], @@ -404,7 +407,7 @@ const contextFolderSection = (rendering: Rendering): ReadonlyArray => { [ code(folder.name), folder.name === sharedKernel ? "shared kernel" : (folder.context ?? "none"), - `[${guideFile}](${posix.relative(directory, `${folderDirectory}/${guideFile}`)})`, + `[${guideFile}](${repositoryPath.relative(directory, `${folderDirectory}/${guideFile}`)})`, ], ] : [], @@ -447,7 +450,10 @@ const renderGuide = (rendering: Rendering): Section => { }; /** The complete text of a guide: the generated part, then what is written by hand. */ -export const guideText = (current: string | undefined, section: Section): string => { +export const guideText: { + (section: Section): (current: string | undefined) => string; + (current: string | undefined, section: Section): string; +} = dual(2, (current: string | undefined, section: Section): string => { const fresh = spliceSections(emptySection(section), [section]).text; if (current === undefined) return fresh; @@ -455,7 +461,7 @@ export const guideText = (current: string | undefined, section: Section): string const spliced = spliceSections(current, [section]); return spliced.missing.length === 0 ? spliced.text : `${fresh}\n${current}`; -}; +}); export interface GuideSet { readonly guides: ReadonlyArray; @@ -463,27 +469,32 @@ export interface GuideSet { } /** Every guide and its generated part. */ -export const renderGuides = ( - repository: Repository, - model: ContextModel, - constructs: ReadonlyArray, -): GuideSet => { - const guides = readGuides(repository, model); - const contracts = contractLinks(constructs); - - return { - guides, - sections: new Map( - guides.map((guide) => [ - guide.directory, - renderGuide({ guide, guides, model, constructs, contracts, repository }), - ]), - ), - }; -}; +export const renderGuides: { + (model: ContextModel, constructs: ReadonlyArray): (repository: Repository) => GuideSet; + (repository: Repository, model: ContextModel, constructs: ReadonlyArray): GuideSet; +} = dual( + 3, + (repository: Repository, model: ContextModel, constructs: ReadonlyArray): GuideSet => { + const guides = readGuides(repository, model); + const contracts = contractLinks(constructs); + + return { + guides, + sections: new Map( + guides.map((guide) => [ + guide.directory, + renderGuide({ guide, guides, model, constructs, contracts, repository }), + ]), + ), + }; + }, +); /** Missing guides and links, stale generated parts, and guides of folders that need none. */ -export const checkGuides = (repository: Repository, set: GuideSet): ReadonlyArray => { +export const checkGuides: { + (set: GuideSet): (repository: Repository) => ReadonlyArray; + (repository: Repository, set: GuideSet): ReadonlyArray; +} = dual(2, (repository: Repository, set: GuideSet): ReadonlyArray => { const paths = new Set(repository.paths); const findings: Array = []; @@ -524,8 +535,8 @@ export const checkGuides = (repository: Repository, set: GuideSet): ReadonlyArra for (const path of repository.paths) if ( - posix.basename(path) === guideFile && - !directories.has(posix.dirname(path)) && + repositoryPath.basename(path) === guideFile && + !directories.has(repositoryPath.dirname(path)) && !repository.links.has(path) && repository.read(path).startsWith('[//]: # "guide: generated ') ) @@ -535,4 +546,4 @@ export const checkGuides = (repository: Repository, set: GuideSet): ReadonlyArra }); return findings; -}; +}); diff --git a/tools/conventions/src/journeys.ts b/tools/conventions/src/journeys.ts index 7a3abf360..8d4976882 100644 --- a/tools/conventions/src/journeys.ts +++ b/tools/conventions/src/journeys.ts @@ -11,7 +11,8 @@ * apps and every file of the acceptance probes runs under some name, or this declaration excludes * it with its reason, so no check exists that nothing runs. */ -import { posix } from "node:path"; +import { dual } from "effect/Function"; +import { repositoryPath } from "./repository-path.js"; import { Schema } from "effect"; import type { Finding } from "./check.js"; import type { CaseStatement, Justfile, Recipe } from "./justfile.js"; @@ -258,7 +259,9 @@ const readRuns = (repository: Repository): ((command: string, directory?: string : []; return bases.flatMap((base) => { - const found = moduleCandidates(posix.join(base, path)).find((file) => files.has(file)); + const found = moduleCandidates(repositoryPath.join(base, path)).find((file) => + files.has(file), + ); return found === undefined ? [] : [found]; }); @@ -270,7 +273,7 @@ const readRuns = (repository: Repository): ((command: string, directory?: string if (cached !== undefined) return cached; const found = resolve( - posix.dirname(file), + repositoryPath.dirname(file), [...repository.read(file).matchAll(quotedPath)].map(([, , text = ""]) => text), ); @@ -287,7 +290,7 @@ const readRuns = (repository: Repository): ((command: string, directory?: string for (const file of resolve(from, text.split(/[\s;&|()]+/u).map(unquote))) seeds.add(file); for (const [, where = "", script = ""] of text.matchAll(packageScript)) { - const at = posix.join(from, unquote(where)).replace(/\/$/u, ""); + const at = repositoryPath.join(from, unquote(where)).replace(/\/$/u, ""); const name = unquote(script); if (scripts.has(`${at} ${name}`)) continue; @@ -328,200 +331,205 @@ const commandOf = (dispatch: CaseStatement, name: string): string => { }; /** Every hosting finding: the journey sets, this declaration, the workflow, and the app scripts. */ -export const checkJourneys = ( - repository: Repository, - justfile: Justfile, - workflow: Workflow, -): ReadonlyArray => { - const findings: Array = []; - const sets = readJourneySets(justfile); - const members = journeys(justfile); - - for (const { recipe, found, dispatch } of sets) { - if (found === undefined) { - findings.push({ - path: journeysDeclaration, - message: `names the journey recipe ${recipe}, which the justfile lacks; remove it from journeyRecipes`, - }); - - continue; - } - - if (dispatch === undefined) { - findings.push({ - path: "justfile", - message: `the ${recipe} recipe needs exactly one case statement with a *) branch; the journey check reads the names of the recipe from its patterns`, - }); - - continue; - } - - const names = [...new Set(dispatch.branches.flatMap((branch) => branch.patterns))]; - - for (const name of names) - if (!kebabCase.test(name)) +export const checkJourneys: { + (justfile: Justfile, workflow: Workflow): (repository: Repository) => ReadonlyArray; + (repository: Repository, justfile: Justfile, workflow: Workflow): ReadonlyArray; +} = dual( + 3, + (repository: Repository, justfile: Justfile, workflow: Workflow): ReadonlyArray => { + const findings: Array = []; + const sets = readJourneySets(justfile); + const members = journeys(justfile); + + for (const { recipe, found, dispatch } of sets) { + if (found === undefined) { findings.push({ - path: "justfile", - message: `the ${recipe} recipe accepts ${name}, which is not a kebab-case name; a journey set lists names, not patterns`, + path: journeysDeclaration, + message: `names the journey recipe ${recipe}, which the justfile lacks; remove it from journeyRecipes`, }); - // Both list the names in English, such as `a, b, or c`. - for (const [place, list = ""] of [ - ["doc comment", /:\s+([^:]*?)\.?$/u.exec(found.doc)?.[1]], - ["unknown-name message", /\bUse\s+([^."]*)\./u.exec(dispatch.otherwise ?? "")?.[1]], - ] as const) { - const listed = list - .split(/,\s*(?:or\s+)?|\s+or\s+/u) - .map((name) => name.trim()) - .filter((name) => name !== ""); - - const lacking = names.filter((name) => !listed.includes(name)); - const extra = listed.filter((name) => !names.includes(name)); + continue; + } - if (lacking.length > 0 || extra.length > 0) + if (dispatch === undefined) { findings.push({ path: "justfile", - message: `the ${place} of the ${recipe} recipe lists other names than its case statement${lacking.length > 0 ? `; add ${lacking.join(", ")}` : ""}${extra.length > 0 ? `; remove ${extra.join(", ")}` : ""}`, - }); - } - } - - for (const entry of ownJobs) { - const command = `just ${entry.recipe} ${entry.name}`; - - if (!members.some((member) => sameJourney(member, entry))) - findings.push({ - path: journeysDeclaration, - message: `gives ${command} the job ${entry.job}, but the ${entry.recipe} recipe does not accept ${entry.name}; remove the entry`, - }); - - if (!workflow.jobs.has(entry.job)) - findings.push({ - path: journeysDeclaration, - message: `gives ${command} the job ${entry.job}, which ${testsWorkflow} lacks`, - }); - - if (exclusionOf(entry) !== undefined) - findings.push({ - path: journeysDeclaration, - message: `gives ${command} a job of its own and also excludes it; remove one entry`, - }); - } - - if (!workflow.jobs.has(matrixJob)) - findings.push({ - path: journeysDeclaration, - message: `names the matrix job ${matrixJob}, which ${testsWorkflow} lacks`, - }); - else if (workflow.legs === undefined) - findings.push({ - path: testsWorkflow, - message: `the ${matrixJob} job has no matrix include list of recipe and suite legs`, - }); - else - for (const journey of legs(justfile)) - if (!workflow.legs.some((leg) => leg.recipe === journey.recipe && leg.suite === journey.name)) - findings.push({ - path: testsWorkflow, - message: `does not run just ${journey.recipe} ${journey.name}: it is not a ${matrixJob} leg, and ${journeysDeclaration} neither gives it a job of its own nor excludes it. Run just layout write, or declare it there`, + message: `the ${recipe} recipe needs exactly one case statement with a *) branch; the journey check reads the names of the recipe from its patterns`, }); - const runOf = readRuns(repository); - const evidence = new Set(); - // The first journey, in leg order, that runs each file. - const runners = new Map(); - - for (const journey of members) { - const dispatch = sets.find((set) => set.recipe === journey.recipe)?.dispatch; - - if (dispatch === undefined) continue; - - const run = runOf(commandOf(dispatch, journey.name)); - - if (journey.recipe === evidenceRecipe) for (const script of run.scripts) evidence.add(script); + continue; + } - for (const file of run.files) if (!runners.has(file)) runners.set(file, journey); - } + const names = [...new Set(dispatch.branches.flatMap((branch) => branch.patterns))]; + + for (const name of names) + if (!kebabCase.test(name)) + findings.push({ + path: "justfile", + message: `the ${recipe} recipe accepts ${name}, which is not a kebab-case name; a journey set lists names, not patterns`, + }); + + // Both list the names in English, such as `a, b, or c`. + for (const [place, list = ""] of [ + ["doc comment", /:\s+([^:]*?)\.?$/u.exec(found.doc)?.[1]], + ["unknown-name message", /\bUse\s+([^."]*)\./u.exec(dispatch.otherwise ?? "")?.[1]], + ] as const) { + const listed = list + .split(/,\s*(?:or\s+)?|\s+or\s+/u) + .map((name) => name.trim()) + .filter((name) => name !== ""); + + const lacking = names.filter((name) => !listed.includes(name)); + const extra = listed.filter((name) => !names.includes(name)); + + if (lacking.length > 0 || extra.length > 0) + findings.push({ + path: "justfile", + message: `the ${place} of the ${recipe} recipe lists other names than its case statement${lacking.length > 0 ? `; add ${lacking.join(", ")}` : ""}${extra.length > 0 ? `; remove ${extra.join(", ")}` : ""}`, + }); + } + } - // The files that an excluded script or file would run. - const excluded = new Set(); + for (const entry of ownJobs) { + const command = `just ${entry.recipe} ${entry.name}`; - for (const entry of exclusions) - if ("recipe" in entry) { if (!members.some((member) => sameJourney(member, entry))) findings.push({ path: journeysDeclaration, - message: `excludes just ${entry.recipe} ${entry.name}, but the ${entry.recipe} recipe does not accept ${entry.name}; remove the entry`, + message: `gives ${command} the job ${entry.job}, but the ${entry.recipe} recipe does not accept ${entry.name}; remove the entry`, }); - } else if ("script" in entry) { - const script = `the script ${entry.script} of ${entry.directory}`; - if (!Object.hasOwn(readManifest(repository, entry.directory)?.scripts ?? {}, entry.script)) + if (!workflow.jobs.has(entry.job)) findings.push({ path: journeysDeclaration, - message: `excludes ${script}, which does not exist; remove the entry`, + message: `gives ${command} the job ${entry.job}, which ${testsWorkflow} lacks`, }); - else if (evidence.has(`${entry.directory} ${entry.script}`)) + + if (exclusionOf(entry) !== undefined) findings.push({ path: journeysDeclaration, - message: `excludes ${script}, which just ${evidenceRecipe} runs; remove the entry`, + message: `gives ${command} a job of its own and also excludes it; remove one entry`, }); + } - for (const file of runOf(`bun run --cwd ${entry.directory} ${entry.script}`).files) - excluded.add(file); - } else { - const journey = runners.get(entry.file); + if (!workflow.jobs.has(matrixJob)) + findings.push({ + path: journeysDeclaration, + message: `names the matrix job ${matrixJob}, which ${testsWorkflow} lacks`, + }); + else if (workflow.legs === undefined) + findings.push({ + path: testsWorkflow, + message: `the ${matrixJob} job has no matrix include list of recipe and suite legs`, + }); + else + for (const journey of legs(justfile)) + if ( + !workflow.legs.some((leg) => leg.recipe === journey.recipe && leg.suite === journey.name) + ) + findings.push({ + path: testsWorkflow, + message: `does not run just ${journey.recipe} ${journey.name}: it is not a ${matrixJob} leg, and ${journeysDeclaration} neither gives it a job of its own nor excludes it. Run just layout write, or declare it there`, + }); - if (!repository.paths.includes(entry.file)) - findings.push({ - path: journeysDeclaration, - message: `excludes ${entry.file}, which does not exist; remove the entry`, - }); - else if (journey !== undefined) - findings.push({ - path: journeysDeclaration, - message: `excludes ${entry.file}, which just ${journey.recipe} ${journey.name} runs; remove the entry`, - }); + const runOf = readRuns(repository); + const evidence = new Set(); + // The first journey, in leg order, that runs each file. + const runners = new Map(); + + for (const journey of members) { + const dispatch = sets.find((set) => set.recipe === journey.recipe)?.dispatch; + + if (dispatch === undefined) continue; + + const run = runOf(commandOf(dispatch, journey.name)); - for (const file of runOf(entry.file).files) excluded.add(file); + if (journey.recipe === evidenceRecipe) for (const script of run.scripts) evidence.add(script); + + for (const file of run.files) if (!runners.has(file)) runners.set(file, journey); } - const globs = evidenceScripts.map((pattern) => new Bun.Glob(pattern)); + // The files that an excluded script or file would run. + const excluded = new Set(); + + for (const entry of exclusions) + if ("recipe" in entry) { + if (!members.some((member) => sameJourney(member, entry))) + findings.push({ + path: journeysDeclaration, + message: `excludes just ${entry.recipe} ${entry.name}, but the ${entry.recipe} recipe does not accept ${entry.name}; remove the entry`, + }); + } else if ("script" in entry) { + const script = `the script ${entry.script} of ${entry.directory}`; + + if (!Object.hasOwn(readManifest(repository, entry.directory)?.scripts ?? {}, entry.script)) + findings.push({ + path: journeysDeclaration, + message: `excludes ${script}, which does not exist; remove the entry`, + }); + else if (evidence.has(`${entry.directory} ${entry.script}`)) + findings.push({ + path: journeysDeclaration, + message: `excludes ${script}, which just ${evidenceRecipe} runs; remove the entry`, + }); + + for (const file of runOf(`bun run --cwd ${entry.directory} ${entry.script}`).files) + excluded.add(file); + } else { + const journey = runners.get(entry.file); + + if (!repository.paths.includes(entry.file)) + findings.push({ + path: journeysDeclaration, + message: `excludes ${entry.file}, which does not exist; remove the entry`, + }); + else if (journey !== undefined) + findings.push({ + path: journeysDeclaration, + message: `excludes ${entry.file}, which just ${journey.recipe} ${journey.name} runs; remove the entry`, + }); + + for (const file of runOf(entry.file).files) excluded.add(file); + } + + const globs = evidenceScripts.map((pattern) => new Bun.Glob(pattern)); - for (const path of repository.paths) { - const directory = /^(apps\/[^/]+)\/package\.json$/u.exec(path)?.[1]; + for (const path of repository.paths) { + const directory = /^(apps\/[^/]+)\/package\.json$/u.exec(path)?.[1]; - if (directory === undefined) continue; + if (directory === undefined) continue; - for (const script of Object.keys(readManifest(repository, directory)?.scripts ?? {})) - if ( - globs.some((glob) => glob.match(script)) && - !evidence.has(`${directory} ${script}`) && - !exclusions.some( - (entry) => "script" in entry && entry.directory === directory && entry.script === script, + for (const script of Object.keys(readManifest(repository, directory)?.scripts ?? {})) + if ( + globs.some((glob) => glob.match(script)) && + !evidence.has(`${directory} ${script}`) && + !exclusions.some( + (entry) => + "script" in entry && entry.directory === directory && entry.script === script, + ) ) - ) - findings.push({ - path, - message: `has the browser evidence script ${script}, which just ${evidenceRecipe} does not run. Add its suite to the ${evidenceRecipe} recipe, or exclude it with its reason in ${journeysDeclaration}`, - }); - } + findings.push({ + path, + message: `has the browser evidence script ${script}, which just ${evidenceRecipe} does not run. Add its suite to the ${evidenceRecipe} recipe, or exclude it with its reason in ${journeysDeclaration}`, + }); + } - const kinds = runFiles.map(({ kind, glob }) => ({ kind, glob: new Bun.Glob(glob) })); - const tests = new Bun.Glob(runFileTests); - const recipes = journeyRecipes.map((recipe) => `just ${recipe}`); + const kinds = runFiles.map(({ kind, glob }) => ({ kind, glob: new Bun.Glob(glob) })); + const tests = new Bun.Glob(runFileTests); + const recipes = journeyRecipes.map((recipe) => `just ${recipe}`); - for (const path of repository.paths) { - const kind = kinds.find(({ glob }) => glob.match(path))?.kind; + for (const path of repository.paths) { + const kind = kinds.find(({ glob }) => glob.match(path))?.kind; - if (kind === undefined || tests.match(path) || runners.has(path) || excluded.has(path)) - continue; + if (kind === undefined || tests.match(path) || runners.has(path) || excluded.has(path)) + continue; - findings.push({ - path, - message: `is a ${kind} that no journey runs: no name of ${recipes.slice(0, -1).join(", ")}, or ${recipes.at(-1) ?? ""} runs it, directly or through a file that it runs. Run it from a journey, or exclude it with its reason in ${journeysDeclaration}`, - }); - } + findings.push({ + path, + message: `is a ${kind} that no journey runs: no name of ${recipes.slice(0, -1).join(", ")}, or ${recipes.at(-1) ?? ""} runs it, directly or through a file that it runs. Run it from a journey, or exclude it with its reason in ${journeysDeclaration}`, + }); + } - return findings; -}; + return findings; + }, +); diff --git a/tools/conventions/src/justfile.ts b/tools/conventions/src/justfile.ts index 41700ca13..f9f49e344 100644 --- a/tools/conventions/src/justfile.ts +++ b/tools/conventions/src/justfile.ts @@ -2,8 +2,8 @@ * Reads the recipes of the root justfile through `just --dump`, so the command table, the command * mentions, and the journey sets follow the parser that runs the recipes. */ -import { spawnSync } from "node:child_process"; -import { Predicate, Schema } from "effect"; +import { Data, Effect, Predicate, Schema, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; // A body line is text and `{{…}}` interpolations; the dump gives an interpolation as an array. const Fragment = Schema.Union([Schema.String, Schema.Array(Schema.Unknown)]); @@ -114,19 +114,49 @@ export interface Justfile { const byCodeUnits = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0; +/** `just` could not run, or could not dump the justfile. */ +export class JustfileDumpFailure extends Data.TaggedError("JustfileDumpFailure")<{ + readonly message: string; +}> {} + /** The JSON dump of the justfile at `path`, as `just --dump --dump-format json` prints it. */ -export const dumpJustfile = (path: string): string => { - const result = spawnSync("just", ["--justfile", path, "--dump", "--dump-format", "json"], { - encoding: "utf8", - }); +export const dumpJustfile = ( + path: string, +): Effect.Effect => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const [stdout, stderr, status] = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn( + ChildProcess.make("just", ["--justfile", path, "--dump", "--dump-format", "json"], { + stdin: "ignore", + }), + ); - if (result.error !== undefined) - throw new Error(`just could not run (${result.error.message}); run inside devenv shell`); + return yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); + }), + ).pipe( + Effect.mapError( + (error) => + new JustfileDumpFailure({ + message: `just could not run (${error.message}); run inside devenv shell`, + }), + ), + ); - if (result.status !== 0) throw new Error(`just --dump failed: ${result.stderr.trim()}`); + if (status !== 0) + return yield* new JustfileDumpFailure({ message: `just --dump failed: ${stderr.trim()}` }); - return result.stdout; -}; + return stdout; + }); /** The recipes of a justfile dump. */ export const decodeJustfile = (text: string): Justfile => { @@ -179,4 +209,7 @@ export const decodeJustfile = (text: string): Justfile => { }; }; -export const readJustfile = (path: string): Justfile => decodeJustfile(dumpJustfile(path)); +export const readJustfile = ( + path: string, +): Effect.Effect => + Effect.map(dumpJustfile(path), decodeJustfile); diff --git a/tools/conventions/src/layout.ts b/tools/conventions/src/layout.ts index 545d10396..6cefca627 100644 --- a/tools/conventions/src/layout.ts +++ b/tools/conventions/src/layout.ts @@ -11,7 +11,6 @@ export const topLevelDirectories = { tools: "Development, verification, and migration tools", infra: "Worker preview deployment configuration", docs: "Intended system, architecture, operations, and active specifications", - patches: "Dependency patches that `patchedDependencies` in package.json applies", ".github": "Checks, Tests, Docs, and preview workflows and their actions", ".claude": "Claude Code settings and project rules", ".agents": "Agent skills of the repository: the Effect house overlay", @@ -64,8 +63,7 @@ export const packageDirectories = { "apps/homepage": "Public React application", "packages/domain": "Business values, transitions, failures, and authority", "packages/database": "PostgreSQL schema, persistence, locks, audit, and outbox", - "packages/http-api": "HTTP contracts, middleware declarations, and OpenAPI", - "packages/sdk": "Generated native API client", + "packages/rpc": "The native RPC contract, its credential middlewares, problems, and client", "tools/acceptance": "Local API and browser acceptance probes of single journeys", "tools/conventions": "Layout, guide, construct, and Effect exception checks and their generated files", @@ -149,21 +147,21 @@ export const contextLayers = { "apps/backend/src": { admission: { context: "Admissions", - reason: "HTTP handlers of admission periods and applications.", + reason: "RPC handlers of admission periods and applications.", }, application: { context: "Admissions", reason: "Application effects and their worker." }, directory: { context: "People", reason: "The people directory read." }, - "http-api": { + rpc: { reason: - "Transport shared by every context: problems, rate limits, JSON reading, receipt transactions.", + "Transport shared by every context: credential middlewares, problems, rate limits, command receipts.", }, mail: { context: "Delivery", reason: "Mail provider adapters behind the Delivery mail port." }, onboarding, "password-recovery": { context: "Identity", reason: "The password recovery worker." }, - profile: { context: "People", reason: "Profile HTTP handlers under their older name." }, - receipt: { context: "Economy", reason: "Expense claim HTTP handlers and private files." }, + profile: { context: "People", reason: "Profile RPC handlers under their older name." }, + receipt: { context: "Economy", reason: "Expense claim RPC handlers and private files." }, "team-application": teamApplication, - test: { reason: "Native HTTP composition for backend tests." }, + test: { reason: "Native RPC composition for backend tests." }, }, "apps/dashboard/app/foldkit": { dashboard: { reason: "The application shell: navigation and the routes to each context page." }, @@ -187,9 +185,9 @@ export const contextLayerRoles = { "packages/database/src": "The persistence layer holds PostgreSQL adapters and service Layers. They keep state, revision, command receipts, audit, and outbox writes in the caller's transaction, and own SQL projections, joins, ordering, scope, and storage codecs.", "apps/backend/src": - "The backend layer holds HTTP handlers, delivery workers, and provider adapters that the native process composes. It keeps response receipts and preconditions in the transport, and provider I/O after commit.", + "The backend layer holds RPC handlers, delivery workers, and provider adapters that the native process composes. It keeps command receipts and preconditions in the transport, and provider I/O after commit.", "apps/dashboard/app/foldkit": - "The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the generated SDK.", + "The dashboard layer holds authenticated journeys: one Foldkit Model per workflow renders server-owned facts and submits commands through the RPC client.", } satisfies Readonly>; /** diff --git a/tools/conventions/src/modules.ts b/tools/conventions/src/modules.ts index 5b36abdee..2d350e294 100644 --- a/tools/conventions/src/modules.ts +++ b/tools/conventions/src/modules.ts @@ -7,7 +7,8 @@ * resolves to the module that declares the binding, through any chain of re-exports, so a barrel * never counts as the owner of what it forwards. Dynamic imports and `require` calls do not count. */ -import { posix } from "node:path"; +import { dual } from "effect/Function"; +import { repositoryPath } from "./repository-path.js"; import { Predicate, Schema } from "effect"; import { parseSync } from "rolldown/utils"; import { packageDirectories } from "./layout.js"; @@ -62,7 +63,10 @@ export const packageOf = (path: string): string | undefined => { }; /** The imports, exports, and re-exports of one module. */ -export const parseModule = (path: string, text: string): Module => { +export const parseModule: { + (text: string): (path: string) => Module; + (path: string, text: string): Module; +} = dual(2, (path: string, text: string): Module => { const parsed = parseSync(path, text); const imports: Array = []; const namespaces = new Map(); @@ -149,7 +153,7 @@ export const parseModule = (path: string, text: string): Module => { imports, errors: parsed.errors.map((error) => error.message), }; -}; +}); // --------------------------------------------------------------------------------------------- // Resolution @@ -165,13 +169,14 @@ const Manifest = Schema.fromJsonString( export type PackageManifest = typeof Manifest.Type; /** The `package.json` of an app, package, or tool directory, if it has one. */ -export const readManifest = ( - repository: Repository, - directory: string, -): PackageManifest | undefined => +export const readManifest: { + (directory: string): (repository: Repository) => PackageManifest | undefined; + (repository: Repository, directory: string): PackageManifest | undefined; +} = dual(2, (repository: Repository, directory: string): PackageManifest | undefined => repository.paths.includes(`${directory}/package.json`) ? Schema.decodeSync(Manifest)(repository.read(`${directory}/package.json`)) - : undefined; + : undefined, +); const TsConfig = Schema.fromJsonString( Schema.Struct({ @@ -259,7 +264,7 @@ export const entryPoints = (exports: Schema.Json): ReadonlyArray => Object.entries(subpaths(exports)).flatMap(([subpath, value]) => { const target = exportTarget(value); - return target === undefined ? [] : [{ subpath, target: posix.normalize(target) }]; + return target === undefined ? [] : [{ subpath, target: repositoryPath.normalize(target) }]; }); const subpathTarget = (exports: Schema.Json, subpath: string): string | undefined => { @@ -343,13 +348,13 @@ const readResolver = ( .replaceAll(jsonWithComments, (match) => (match.startsWith('"') ? match : "")), ).compilerOptions; - const base = posix.join(directory, options?.baseUrl ?? "."); + const base = repositoryPath.join(directory, options?.baseUrl ?? "."); aliases.set( directory, Object.entries(options?.paths ?? {}).map(([pattern, targets]) => ({ pattern, - targets: targets.map((target) => posix.join(base, target)), + targets: targets.map((target) => repositoryPath.join(base, target)), })), ); } @@ -368,11 +373,13 @@ const readResolver = ( const rest = segments.slice(length).join("/"); if (workspace.exports === undefined) - return file(posix.join(workspace.directory, rest === "" ? "index" : rest)); + return file(repositoryPath.join(workspace.directory, rest === "" ? "index" : rest)); const target = subpathTarget(workspace.exports, rest === "" ? "." : `./${rest}`); - return target === undefined ? undefined : file(posix.join(workspace.directory, target)); + return target === undefined + ? undefined + : file(repositoryPath.join(workspace.directory, target)); }; const aliasTarget = (importer: string, specifier: string): string | undefined => { @@ -397,7 +404,10 @@ const readResolver = ( return (importer, specifier) => { const relative = specifier.startsWith("./") || specifier.startsWith("../"); - const key = relative ? posix.join(posix.dirname(importer), specifier) : specifier; + + const key = relative + ? repositoryPath.join(repositoryPath.dirname(importer), specifier) + : specifier; if (cache.has(key) && relative) return cache.get(key); diff --git a/tools/conventions/src/repository-path.ts b/tools/conventions/src/repository-path.ts new file mode 100644 index 000000000..a183335e2 --- /dev/null +++ b/tools/conventions/src/repository-path.ts @@ -0,0 +1,76 @@ +/** + * POSIX path arithmetic on the repository-relative paths that the checks compare: forward + * slashes, no drive, and inside the repository root. It touches no file system and reads no + * working directory, so the checks stay plain functions of the repository view; the file system + * itself is read only through the `FileSystem` and `Path` services of the command line entry. + */ + +const segmentsOf = (path: string): ReadonlyArray => { + const segments: Array = []; + + for (const segment of path.split("/")) { + if (segment === "" || segment === ".") continue; + + if (segment === ".." && segments.length > 0 && segments.at(-1) !== "..") segments.pop(); + else if (segment !== ".." || !path.startsWith("/")) segments.push(segment); + } + + return segments; +}; + +/** The path with `.` and `..` segments resolved and repeated slashes collapsed, as `path.posix`. */ +const normalize = (path: string): string => { + if (path === "") return "."; + + const absolute = path.startsWith("/"); + const body = segmentsOf(path).join("/"); + const trailing = path.endsWith("/") && body !== "" ? "/" : ""; + + if (absolute) return `/${body}${trailing}`; + + return body === "" ? "." : `${body}${trailing}`; +}; + +/** The segments joined by slashes and normalized; empty segments are skipped. */ +const join = (...paths: ReadonlyArray): string => + normalize(paths.filter((path) => path !== "").join("/")); + +const withoutTrailingSlashes = (path: string): string => { + let end = path.length; + + while (end > 1 && path[end - 1] === "/") end -= 1; + + return path.slice(0, end); +}; + +/** The directory part of a path, `.` for a bare name. */ +const dirname = (path: string): string => { + const trimmed = withoutTrailingSlashes(path); + const slash = trimmed.lastIndexOf("/"); + + if (slash === -1) return "."; + + return slash === 0 ? "/" : trimmed.slice(0, slash); +}; + +/** The last segment of a path. */ +const basename = (path: string): string => { + const trimmed = withoutTrailingSlashes(path); + + return trimmed.slice(trimmed.lastIndexOf("/") + 1); +}; + +/** The relative path from one repository-relative path to another; empty when they are equal. */ +const relative = (from: string, to: string): string => { + const source = segmentsOf(from); + const target = segmentsOf(to); + let shared = 0; + + while (shared < source.length && shared < target.length && source[shared] === target[shared]) + shared += 1; + + return [...source.slice(shared).map(() => ".."), ...target.slice(shared)].join("/"); +}; + +/** The POSIX path functions of the checks, over repository-relative paths. */ +export const repositoryPath = { basename, dirname, join, normalize, relative } as const; diff --git a/tools/conventions/src/repository.ts b/tools/conventions/src/repository.ts index 5b105a49e..6c090e6f1 100644 --- a/tools/conventions/src/repository.ts +++ b/tools/conventions/src/repository.ts @@ -2,10 +2,14 @@ * The files that the layout check reads. The working tree view lists tracked and untracked files * that Git does not ignore. The staged view lists the Git index, which is the tree that a commit * records; the hook runner sets unstaged changes aside, so the files on disk hold the staged content. + * + * Reading a repository loads every listed file through `FileSystem` once, so the checks read the + * files synchronously. A file that could not be loaded fails when a check reads it. */ -import { spawnSync } from "node:child_process"; -import { lstatSync, readFileSync, readlinkSync, type Stats } from "node:fs"; -import { join } from "node:path"; +import { Data, Effect, FileSystem, Option, Path, Result, Stream } from "effect"; +import { dual } from "effect/Function"; +import type { PlatformError } from "effect/PlatformError"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export interface Repository { readonly root: string; @@ -18,59 +22,145 @@ export interface Repository { readonly readLink: (path: string) => string; } -const git = (root: string, args: ReadonlyArray): string => { - const result = spawnSync("git", ["-C", root, ...args], { - encoding: "utf8", - maxBuffer: 2 ** 28, - }); +/** Git could not list the repository. */ +export class RepositoryGitFailure extends Data.TaggedError("RepositoryGitFailure")<{ + readonly message: string; +}> {} - if (result.error !== undefined) throw result.error; +/** A file that a check read but that the repository could not load. */ +export class RepositoryFileUnreadable extends Data.TaggedError("RepositoryFileUnreadable")<{ + readonly message: string; +}> {} - if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim()}`); +type RepositoryServices = + | FileSystem.FileSystem + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner; - return result.stdout; -}; +const git = Effect.fnUntraced(function* (root: string, args: ReadonlyArray) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const failure = (detail: string) => + new RepositoryGitFailure({ message: `git ${args.join(" ")} failed: ${detail}` }); + + const [stdout, stderr, status] = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn( + ChildProcess.make("git", ["-C", root, ...args], { stdin: "ignore" }), + ); + + return yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); + }), + ).pipe(Effect.mapError((error) => failure(error.message))); + + if (status !== 0) return yield* failure(stderr.trim()); + + return stdout; +}); /** The root of the Git working tree that contains `directory`. */ -export const repositoryRoot = (directory: string): string => - git(directory, ["rev-parse", "--show-toplevel"]).trim(); +export const repositoryRoot = ( + directory: string, +): Effect.Effect => + Effect.map(git(directory, ["rev-parse", "--show-toplevel"]), (text) => text.trim()); -/** The staged view with `staged`, the working tree view otherwise. */ -export const readRepository = (root: string, staged: boolean): Repository => { - const listed = git(root, [ +// Decoding keeps a byte order mark, as `readFileSync(path, "utf8")` did. +const utf8 = new TextDecoder("utf-8", { ignoreBOM: true }); + +interface Entry { + readonly path: string; + readonly link: Option.Option; + readonly present: boolean; + readonly bytes: Result.Result | undefined; +} + +const loadRepository = Effect.fnUntraced(function* (root: string, staged: boolean) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const listed = yield* git(root, [ "ls-files", "-z", "--cached", ...(staged ? [] : ["--others", "--exclude-standard"]), ]); - const paths: Array = []; - const links = new Set(); + // `readLink` succeeds only on a symbolic link, which is present even when its target is not. + const entries: ReadonlyArray = yield* Effect.forEach( + [...new Set(listed.split("\0"))].filter((listedPath) => listedPath !== ""), + (listedPath) => + Effect.gen(function* () { + const file = path.join(root, listedPath); + const link = yield* Effect.option(fileSystem.readLink(file)); - for (const path of new Set(listed.split("\0"))) { - if (path === "") continue; + const present = + Option.isSome(link) || + (yield* Effect.orElseSucceed(fileSystem.exists(file), () => false)); - let stats: Stats | undefined; + const bytes = present ? yield* Effect.result(fileSystem.readFile(file)) : undefined; - try { - stats = lstatSync(join(root, path)); - } catch { - stats = undefined; - } + return { path: listedPath, link, present, bytes }; + }), + { concurrency: 16 }, + ); + const paths: Array = []; + const links = new Map(); + const contents = new Map>(); + + for (const entry of entries) { // A tracked file that the working tree deleted is not part of the working tree view. - if (stats === undefined && !staged) continue; + if (!entry.present && !staged) continue; + + paths.push(entry.path); - paths.push(path); + if (Option.isSome(entry.link)) links.set(entry.path, entry.link.value); - if (stats?.isSymbolicLink() === true) links.add(path); + if (entry.bytes !== undefined) contents.set(entry.path, entry.bytes); } - return { + const repository: Repository = { root, paths: paths.sort(), - links, - read: (path) => readFileSync(join(root, path), "utf8"), - readLink: (path) => readlinkSync(join(root, path)), + links: new Set(links.keys()), + read: (file) => { + const loaded = contents.get(file); + + if (loaded === undefined) + throw new RepositoryFileUnreadable({ message: `${file}: no such file` }); + + if (Result.isFailure(loaded)) + throw new RepositoryFileUnreadable({ message: `${file}: ${loaded.failure.message}` }); + + return utf8.decode(loaded.success); + }, + readLink: (file) => { + const target = links.get(file); + + if (target === undefined) + throw new RepositoryFileUnreadable({ message: `${file}: not a symbolic link` }); + + return target; + }, }; -}; + + return repository; +}); + +/** The staged view with `staged`, the working tree view otherwise. */ +export const readRepository: { + ( + staged: boolean, + ): (root: string) => Effect.Effect; + ( + root: string, + staged: boolean, + ): Effect.Effect; +} = dual(2, (root: string, staged: boolean) => loadRepository(root, staged)); diff --git a/tools/conventions/src/sections.ts b/tools/conventions/src/sections.ts index a89f60b26..1bcea8c6b 100644 --- a/tools/conventions/src/sections.ts +++ b/tools/conventions/src/sections.ts @@ -5,6 +5,8 @@ * hosted journeys of the Tests workflow and of its document, and `guides.ts` renders the module * guides. The tables use the column alignment that Oxfmt writes, so formatting never changes them. */ +import { Predicate } from "effect"; +import { dual } from "effect/Function"; import { constructPages } from "./constructs.js"; import { exclusionOf, @@ -50,10 +52,10 @@ const generatedFiles = { } satisfies Readonly>>; /** A Markdown table with the column alignment that Oxfmt writes. */ -export const table = ( - header: ReadonlyArray, - rows: ReadonlyArray>, -): string => { +export const table: { + (rows: ReadonlyArray>): (header: ReadonlyArray) => string; + (header: ReadonlyArray, rows: ReadonlyArray>): string; +} = dual(2, (header: ReadonlyArray, rows: ReadonlyArray>): string => { const cells = [header, ...rows].map((row) => row.map((cell) => cell.replaceAll("|", "\\|"))); const widths = header.map((_, column) => @@ -66,7 +68,7 @@ export const table = ( const [head = [], ...body] = cells; return [line(head), line(widths.map((width) => "-".repeat(width))), ...body.map(line)].join("\n"); -}; +}); const code = (text: string) => `\`${text}\``; @@ -213,53 +215,55 @@ export interface Spliced { * Replaces each section of `text` with its rendered body, between its begin and end markers. The * markers and the body take the indentation of the begin marker, which a YAML block needs. */ -export const spliceSections = ( - text: string, - sections: ReadonlyArray

, - comment: Comment = markdown, -): Spliced => { - const lines = text.split("\n"); - const missing: Array = []; - - for (const section of sections) { - const starts = lines.flatMap((line, index) => - line.trimStart().startsWith(`${comment.open}${section.id}: generated `) ? [index] : [], - ); - - const ends = lines.flatMap((line, index) => - line.trim() === end(comment, section.id) ? [index] : [], - ); - - const [start] = starts; - const [stop] = ends; - - if ( - start === undefined || - stop === undefined || - starts.length > 1 || - ends.length > 1 || - stop < start - ) { - missing.push(section.id); - - continue; +export const spliceSections: { + (sections: ReadonlyArray
, comment?: Comment): (text: string) => Spliced; + (text: string, sections: ReadonlyArray
, comment?: Comment): Spliced; +} = dual( + (args) => Predicate.isString(args[0]), + (text: string, sections: ReadonlyArray
, comment: Comment = markdown): Spliced => { + const lines = text.split("\n"); + const missing: Array = []; + + for (const section of sections) { + const starts = lines.flatMap((line, index) => + line.trimStart().startsWith(`${comment.open}${section.id}: generated `) ? [index] : [], + ); + + const ends = lines.flatMap((line, index) => + line.trim() === end(comment, section.id) ? [index] : [], + ); + + const [start] = starts; + const [stop] = ends; + + if ( + start === undefined || + stop === undefined || + starts.length > 1 || + ends.length > 1 || + stop < start + ) { + missing.push(section.id); + + continue; + } + + const indent = /^\s*/u.exec(lines[start] ?? "")?.[0] ?? ""; + + lines.splice( + start, + stop - start + 1, + indent + begin(comment, section), + ...comment.padding, + ...section.body.split("\n").map((line) => (line === "" ? line : indent + line)), + ...comment.padding, + indent + end(comment, section.id), + ); } - const indent = /^\s*/u.exec(lines[start] ?? "")?.[0] ?? ""; - - lines.splice( - start, - stop - start + 1, - indent + begin(comment, section), - ...comment.padding, - ...section.body.split("\n").map((line) => (line === "" ? line : indent + line)), - ...comment.padding, - indent + end(comment, section.id), - ); - } - - return { text: lines.join("\n"), missing }; -}; + return { text: lines.join("\n"), missing }; + }, +); /** The markers of `section` with nothing between them, for a Markdown file that has none yet. */ export const emptySection = (section: Section): string => @@ -272,24 +276,37 @@ export interface SplicedFile extends Spliced { } /** Each file with generated sections, spliced with the sections that its sources render. */ -export const spliceFiles = ( - read: (path: string) => string, - justfile: Justfile, - workflow: Workflow, -): ReadonlyArray => { - const sections = renderSections(justfile, workflow); - - return Object.entries(generatedFiles).map(([path, ids]) => { - const current = read(path); - - return { - path, - current, - ...spliceSections( +export const spliceFiles: { + ( + justfile: Justfile, + workflow: Workflow, + ): (read: (path: string) => string) => ReadonlyArray; + ( + read: (path: string) => string, + justfile: Justfile, + workflow: Workflow, + ): ReadonlyArray; +} = dual( + 3, + ( + read: (path: string) => string, + justfile: Justfile, + workflow: Workflow, + ): ReadonlyArray => { + const sections = renderSections(justfile, workflow); + + return Object.entries(generatedFiles).map(([path, ids]) => { + const current = read(path); + + return { + path, current, - ids.map((id) => sections[id]), - /\.ya?ml$/u.test(path) ? yaml : markdown, - ), - }; - }); -}; + ...spliceSections( + current, + ids.map((id) => sections[id]), + /\.ya?ml$/u.test(path) ? yaml : markdown, + ), + }; + }); + }, +); diff --git a/tools/conventions/tests/constructs.test.ts b/tools/conventions/tests/constructs.test.ts index 4e19ac98f..61c15830e 100644 --- a/tools/conventions/tests/constructs.test.ts +++ b/tools/conventions/tests/constructs.test.ts @@ -1,6 +1,8 @@ // Negative controls run against the real repository with a few in-memory files, so they exercise // the real resolver, package exports, and pages. import { describe, expect, test } from "bun:test"; +import { Effect } from "effect"; +import { ConventionsPlatform } from "../src/cli.js"; import { checkConstructs, checkPages, @@ -15,9 +17,12 @@ import { channelsOf } from "../src/contracts.js"; import { readModuleGraph } from "../src/modules.js"; import { readRepository, repositoryRoot, type Repository } from "../src/repository.js"; -const root = repositoryRoot(import.meta.dir); +const run = (effect: Effect.Effect): Promise => + Effect.runPromise(effect.pipe(Effect.provide(ConventionsPlatform))); -const base = readRepository(root, false); +const root = await run(repositoryRoot(import.meta.dir)); + +const base = await run(readRepository(root, false)); const withFiles = (files: Readonly>): Repository => ({ root, @@ -159,6 +164,85 @@ describe("construct pages", () => { ); }); + test("describes a dual construct by its data-first overload, as a plain function", () => { + const plain = "(value: number, factor: number): number => value * factor"; + + const dual = [ + "{", + " (factor: number): (value: number) => number;", + " (value: number, factor: number): number;", + `} = dual(2, ${plain})`, + ].join("\n"); + + // `probe` writes `export const probeDouble = `; a dual construct annotates the name. + const annotated = (doc: string, declaration: string) => { + const files = probe(doc, declaration); + const path = `${kernel}/probe.ts`; + const text = files[path] ?? ""; + + return { + ...files, + [path]: `import { dual } from "effect/Function";\n\n${text.replace("probeDouble = {", "probeDouble: {")}`, + }; + }; + + const contractOf = (files: Readonly>) => { + const page = + renderPages(readConstructs(withFiles(files)).constructs).get( + `${constructPages.contracts}/digest.md`, + ) ?? ""; + + const section = page.slice(page.indexOf("## `probeDouble`")); + + return { + section, + channels: section + .slice(section.indexOf("- Inputs"), section.indexOf("- Side effects")) + .trimEnd(), + }; + }; + + const fromPlain = contractOf(probe(tagged(contract), plain)); + const fromDual = contractOf(annotated(tagged(contract), dual)); + + expect(fromPlain.channels).toBe( + [ + "- Inputs:", + " - `value: number`", + " - `factor: number`", + "- Output: `number`", + "- Errors: none", + "- Requirements: none", + ].join("\n"), + ); + expect(fromDual.channels).toBe(fromPlain.channels); + expect(fromDual.section).toContain( + "probeDouble(factor: number): (value: number) => number\nprobeDouble(value: number, factor: number): number\n", + ); + + const outside = { + "apps/backend/src/probe-a.ts": fromKernel, + "tools/e2e/probe-b.ts": fromKernel, + "packages/database/src/probe-c.ts": fromKernel, + }; + + const candidates = (files: Readonly>) => { + const repository = withFiles({ ...files, ...outside }); + + return readCandidates( + repository, + readModuleGraph(repository), + readConstructs(repository).constructs, + ).filter((candidate) => candidate.name === "probeDouble"); + }; + + expect(candidates(annotated("", dual))).toHaveLength(1); + // A type with a member beside its call signature is no overload set, and no function. + expect( + candidates(annotated("", dual.replace("number;\n}", "number;\n readonly arity: 2;\n}"))), + ).toHaveLength(0); + }); + test("reports a missing contract tag and a missing annotation", () => { const gapCount = (doc: string, declaration?: string) => readConstructs(withFiles(probe(doc, declaration))).findings.filter((finding) => diff --git a/tools/conventions/tests/exceptions.test.ts b/tools/conventions/tests/exceptions.test.ts index 77189cbc7..f07d7966e 100644 --- a/tools/conventions/tests/exceptions.test.ts +++ b/tools/conventions/tests/exceptions.test.ts @@ -1,7 +1,8 @@ // Negative controls run against the real repository with a few in-memory files, so they exercise // the real registry, package.json, and module guides. import { describe, expect, test } from "bun:test"; -import { Schema } from "effect"; +import { Effect, Schema } from "effect"; +import { ConventionsPlatform } from "../src/cli.js"; import type { Finding } from "../src/check.js"; import { checkExceptions, @@ -11,9 +12,12 @@ import { } from "../src/exceptions.js"; import { readRepository, repositoryRoot, type Repository } from "../src/repository.js"; -const root = repositoryRoot(import.meta.dir); +const run = (effect: Effect.Effect): Promise => + Effect.runPromise(effect.pipe(Effect.provide(ConventionsPlatform))); -const base = readRepository(root, false); +const root = await run(repositoryRoot(import.meta.dir)); + +const base = await run(readRepository(root, false)); const withFiles = (files: Readonly>): Repository => ({ root, diff --git a/tools/conventions/tests/guides.test.ts b/tools/conventions/tests/guides.test.ts index 21b772a2d..48968a05f 100644 --- a/tools/conventions/tests/guides.test.ts +++ b/tools/conventions/tests/guides.test.ts @@ -1,6 +1,8 @@ // Negative controls run against the real repository with one change each, so they exercise the // real context map, layout declaration, construct tags, and package exports. import { describe, expect, test } from "bun:test"; +import { Effect } from "effect"; +import { ConventionsPlatform } from "../src/cli.js"; import type { Finding } from "../src/check.js"; import { readContextModel } from "../src/cml.js"; import { readConstructs } from "../src/constructs.js"; @@ -9,9 +11,12 @@ import { contextMap } from "../src/layout.js"; import { readModuleGraph } from "../src/modules.js"; import { readRepository, repositoryRoot, type Repository } from "../src/repository.js"; -const root = repositoryRoot(import.meta.dir); +const run = (effect: Effect.Effect): Promise => + Effect.runPromise(effect.pipe(Effect.provide(ConventionsPlatform))); -const base = readRepository(root, false); +const root = await run(repositoryRoot(import.meta.dir)); + +const base = await run(readRepository(root, false)); const withChanges = ( files: Readonly>, diff --git a/tools/conventions/tests/journeys.test.ts b/tools/conventions/tests/journeys.test.ts index a291d8044..309b0f152 100644 --- a/tools/conventions/tests/journeys.test.ts +++ b/tools/conventions/tests/journeys.test.ts @@ -2,18 +2,22 @@ // e2e suite in the justfile dump, one more browser evidence script, a renamed workflow job, or one // more Playwright spec or acceptance probe that no journey runs. import { describe, expect, test } from "bun:test"; -import { join } from "node:path"; +import { Effect } from "effect"; +import { ConventionsPlatform } from "../src/cli.js"; import { checkLayout } from "../src/check.js"; import { journeysDeclaration, readWorkflow, testsWorkflow } from "../src/journeys.js"; import { decodeJustfile, dumpJustfile, type Justfile } from "../src/justfile.js"; import { readRepository, repositoryRoot, type Repository } from "../src/repository.js"; import { spliceFiles } from "../src/sections.js"; -const root = repositoryRoot(import.meta.dir); +const run = (effect: Effect.Effect): Promise => + Effect.runPromise(effect.pipe(Effect.provide(ConventionsPlatform))); -const base = readRepository(root, false); +const root = await run(repositoryRoot(import.meta.dir)); -const dump = dumpJustfile(join(root, "justfile")); +const base = await run(readRepository(root, false)); + +const dump = await run(dumpJustfile(`${root}/justfile`)); const justfile = decodeJustfile(dump); diff --git a/tools/conventions/tests/layout.test.ts b/tools/conventions/tests/layout.test.ts index 74ac4d3df..4892102ce 100644 --- a/tools/conventions/tests/layout.test.ts +++ b/tools/conventions/tests/layout.test.ts @@ -1,17 +1,21 @@ // Negative controls run against the real repository with one change each, so they exercise the // real declaration, context map, and justfile. import { describe, expect, test } from "bun:test"; -import { join } from "node:path"; +import { Effect } from "effect"; +import { ConventionsPlatform } from "../src/cli.js"; import { checkLayout, type Finding } from "../src/check.js"; import { boundedContextNames, contextFolderName, readContextModel } from "../src/cml.js"; import { readJustfile } from "../src/justfile.js"; import { readRepository, repositoryRoot, type Repository } from "../src/repository.js"; -const root = repositoryRoot(import.meta.dir); +const run = (effect: Effect.Effect): Promise => + Effect.runPromise(effect.pipe(Effect.provide(ConventionsPlatform))); -const base = readRepository(root, false); +const root = await run(repositoryRoot(import.meta.dir)); -const justfile = readJustfile(join(root, "justfile")); +const base = await run(readRepository(root, false)); + +const justfile = await run(readJustfile(`${root}/justfile`)); const withFiles = ( files: Readonly>, diff --git a/tools/conventions/tests/oxlint-groups.test.ts b/tools/conventions/tests/oxlint-groups.test.ts index 792962bb7..0d3c06372 100644 --- a/tools/conventions/tests/oxlint-groups.test.ts +++ b/tools/conventions/tests/oxlint-groups.test.ts @@ -1,16 +1,21 @@ // Oxlint override globs do not support extglob, and an override that matches no file fails // silently: the Effect domain groups once used `!(…)` patterns and no core rule ever ran. -import { correctness, effectNative, recommended } from "@effect/tsgo/oxlint-presets"; +import { correctness, effectNative, presets, recommended } from "@effect/tsgo/oxlint-presets"; import { describe, expect, test } from "bun:test"; -import { execFileSync } from "node:child_process"; +import { Effect } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import config from "../../../oxlint.config.ts"; +import { ConventionsPlatform } from "../src/cli.js"; import { repositoryRoot } from "../src/repository.js"; -const root = repositoryRoot(import.meta.dir); +const tracked = await Effect.runPromise( + Effect.gen(function* () { + const root = yield* repositoryRoot(import.meta.dir); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; -const tracked = execFileSync("git", ["ls-files", "-z"], { cwd: root, encoding: "utf8" }) - .split("\0") - .filter((path) => path.length > 0); + return yield* spawner.string(ChildProcess.make("git", ["ls-files", "-z"], { cwd: root })); + }).pipe(Effect.provide(ConventionsPlatform)), +).then((listed) => listed.split("\0").filter((path) => path.length > 0)); const overrides = config.overrides ?? []; @@ -57,14 +62,14 @@ describe("oxlint override globs", () => { describe("Effect domain groups", () => { test("core sources receive their role's rules", () => { expect(effectRulesFor("packages/domain/src/probe.ts")).toContain("effect/no-ambient-authority"); - expect(effectRulesFor("packages/sdk/src/probe.ts")).toContain("effect/no-ambient-authority"); + expect(effectRulesFor("packages/rpc/src/client.ts")).toContain("effect/no-ambient-authority"); expect(effectRulesFor("packages/database/src/probe.ts")).toContain("effect/no-ambient-console"); expect(effectRulesFor("apps/backend/src/probe.ts")).toContain("effect/no-ambient-console"); }); test("core sources run the strict rules, and the adapters decode external data", () => { expect(effectRulesFor("packages/domain/src/probe.ts")).toContain("effect/no-untyped-throw"); - expect(effectRulesFor("packages/sdk/src/probe.ts")).toContain( + expect(effectRulesFor("packages/rpc/src/client.ts")).toContain( "effect/no-native-promise-control-flow", ); expect(effectRulesFor("packages/database/src/probe.ts")).toContain( @@ -124,48 +129,100 @@ const settingsFor = (path: string) => ); describe("Effect language-service rules", () => { - const presetRules = [ - ...new Set([recommended, correctness].flatMap((preset) => Object.keys(preset.rules ?? {}))), - ]; - - const nativeRules = Object.keys(effectNative.rules ?? {}).sort(); + // Every rule of every preset the package ships, so an upgrade that adds a rule or a category + // is an error at once, not a rule that never runs. + const shippedRules = [ + ...new Set(Object.values(presets).flatMap((preset) => Object.keys(preset.rules ?? {}))), + ].sort(); + + // The React applications keep the recommended and correctness rules, less effectNative. + const reactApplicationRules = new Set( + [recommended, correctness] + .flatMap((preset) => Object.keys(preset.rules ?? {})) + .filter((rule) => !Object.hasOwn(effectNative.rules ?? {}, rule)), + ); const notErrorsAt = (path: string) => { const settings = settingsFor(path); - return presetRules - .filter((rule) => { - const setting = settings.get(rule); + return shippedRules.filter((rule) => { + const setting = settings.get(rule); - return (Array.isArray(setting) ? setting[0] : setting) !== "error"; - }) - .sort(); + return (Array.isArray(setting) ? setting[0] : setting) !== "error"; + }); }; - test("are errors in core Effect code, the effectNative rules included", () => { - expect(notErrorsAt("packages/domain/src/probe.ts")).toEqual([]); - expect(notErrorsAt("packages/database/src/probe.test.ts")).toEqual([]); - expect(notErrorsAt("packages/http-api/src/probe.ts")).toEqual([]); - expect(notErrorsAt("apps/backend/src/probe.ts")).toEqual([]); + test("the package ships the presets the configuration names", () => { + expect(Object.keys(presets).sort()).toEqual([ + "antipattern", + "correctness", + "effectNative", + "recommended", + "style", + ]); }); - test("are errors elsewhere, except the effectNative rules", () => { - expect(notErrorsAt("apps/dashboard/app/probe.tsx")).toEqual(nativeRules); - expect(notErrorsAt("packages/sdk/src/probe.ts")).toEqual(nativeRules); - expect(notErrorsAt("tools/e2e/probe.ts")).toEqual(nativeRules); + test("are all errors outside the React applications and the entry points", () => { + for (const path of [ + "packages/domain/src/probe.ts", + "packages/database/src/probe.ts", + "packages/rpc/src/probe.ts", + "apps/backend/src/probe.ts", + "apps/docs/src/probe.tsx", + "tools/scripts/probe.ts", + "tools/conventions/src/probe.ts", + ]) { + expect({ path, notErrors: notErrorsAt(path) }).toEqual({ path, notErrors: [] }); + } }); - test("are relaxed by no override", () => { - const relaxed = overrides.flatMap((override) => - Object.entries(override.rules ?? {}).some( - ([rule, setting]) => - rule.startsWith("effecttsgo/") && - (Array.isArray(setting) ? setting[0] : setting) !== "error", - ) - ? override.files - : [], - ); + test("leave only Effect.provide to the entry points", () => { + for (const path of [ + "packages/database/src/probe.test.ts", + "packages/database/src/probe-main.ts", + "apps/backend/src/main.ts", + "apps/backend/src/test/probe.ts", + "tools/acceptance/probe.ts", + "tools/verification/probe.ts", + "tools/e2e/probe.ts", + ]) { + expect({ path, notErrors: notErrorsAt(path) }).toEqual({ + path, + notErrors: ["effecttsgo/strict-effect-provide"], + }); + } + }); - expect(relaxed).toEqual([]); + test("keep the recommended and correctness rules in the React applications", () => { + const off = shippedRules.filter((rule) => !reactApplicationRules.has(rule)); + + for (const path of [ + "apps/dashboard/app/probe.tsx", + "apps/dashboard/e2e/probe.spec.ts", + "apps/homepage/src/probe.tsx", + ]) { + expect({ path, notErrors: notErrorsAt(path) }).toEqual({ path, notErrors: off }); + } + }); + + test("are relaxed only for the React applications, and Effect.provide for the entry points", () => { + const relaxations = overrides.flatMap((override) => { + const rules = Object.entries(override.rules ?? {}) + .filter( + ([rule, setting]) => + rule.startsWith("effecttsgo/") && + (Array.isArray(setting) ? setting[0] : setting) !== "error", + ) + .map(([rule]) => rule); + + return rules.length === 0 ? [] : [{ files: override.files, rules }]; + }); + + expect(relaxations.map(({ files }) => files.includes("apps/dashboard/**"))).toEqual([ + false, + true, + ]); + expect(relaxations[0]?.rules).toEqual(["effecttsgo/strict-effect-provide"]); + expect(relaxations[1]?.files).toEqual(["apps/homepage/**", "apps/dashboard/**"]); }); }); diff --git a/tools/e2e/golden-harness-self-test.ts b/tools/e2e/golden-harness-self-test.ts index 03562a3b8..93eb1ca95 100644 --- a/tools/e2e/golden-harness-self-test.ts +++ b/tools/e2e/golden-harness-self-test.ts @@ -14,14 +14,14 @@ */ import assert from "node:assert/strict"; import { createServer } from "node:net"; -import { join } from "node:path"; import { fileURLToPath } from "node:url"; import process from "node:process"; // The package index pulls Bun's global types into this program; the subpath does not. import * as BunRuntime from "@effect/platform-bun/BunRuntime"; import * as BunServices from "@effect/platform-bun/BunServices"; import { sha256Hex, canonicalJsonBytes } from "@vektorprogrammet/domain/shared-kernel"; -import { Effect, FileSystem, Schema, Stream } from "effect"; +import { Clock, Config, Effect, FileSystem, Option, Path, Schema, Stream } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess } from "effect/unstable/process"; import { eventually, @@ -42,23 +42,33 @@ const entry = fileURLToPath(import.meta.url); const ProbeRow = Schema.Struct({ id: Schema.Int, label: Schema.String }); const deliveryStatus = (url: string, token: string) => - Effect.tryPromise({ - try: () => - fetch(url, { - method: "POST", - headers: { - authorization: `Bearer ${token}`, - "content-type": "application/json", - "idempotency-key": "probe-delivery", - }, - body: JSON.stringify({ deliveryId: "probe-delivery", recipient: "probe@example.invalid" }), - }).then(async (response) => { - await response.body?.cancel(); + Effect.flatMap(HttpClient.HttpClient, (client) => + client.execute( + HttpClientRequest.post(url, { + headers: { authorization: `Bearer ${token}`, "idempotency-key": "probe-delivery" }, + }).pipe( + HttpClientRequest.bodyJsonUnsafe({ + deliveryId: "probe-delivery", + recipient: "probe@example.invalid", + }), + ), + ), + ).pipe( + Effect.flatMap((response) => Effect.as(Effect.ignore(response.arrayBuffer), response.status)), + Effect.mapError((cause) => + HarnessFailure.make({ stage: "probe-delivery", message: String(cause) }), + ), + Effect.provide(FetchHttpClient.layer), + ); - return response.status; - }), - catch: (cause) => new HarnessFailure({ stage: "probe-delivery", message: String(cause) }), - }); +/** Whether the runner set the probe variable `name` to "1". */ +const probeFlag = (name: string) => + Config.option(Config.String(name)).pipe( + Effect.map((value) => Option.getOrUndefined(value) === "1"), + Effect.mapError((cause) => + HarnessFailure.make({ stage: "probe-config", message: String(cause) }), + ), + ); const probeJourney: GoldenJourney = { id: "golden-harness-probe", @@ -79,7 +89,7 @@ const probeJourney: GoldenJourney = { database.pool.query( "CREATE TABLE probe_events(id integer PRIMARY KEY, label text NOT NULL); INSERT INTO probe_events VALUES (1, 'booted')", ), - catch: (cause) => new HarnessFailure({ stage: "probe-sql", message: String(cause) }), + catch: (cause) => HarnessFailure.make({ stage: "probe-sql", message: String(cause) }), }); const provider = yield* context.provider({ @@ -145,7 +155,9 @@ const probeJourney: GoldenJourney = { "10 seconds", ); - if (process.env.GOLDEN_HARNESS_PROBE_CRASH === "1") + const crash = yield* probeFlag("GOLDEN_HARNESS_PROBE_CRASH"); + + if (crash) yield* context.spawn({ label: "crasher", command: "sh", @@ -177,13 +189,13 @@ const probeJourney: GoldenJourney = { yield* context.checkpoint("booted", observe); yield* context.faultPoint("after-boot"); - if (process.env.GOLDEN_HARNESS_PROBE_HOLD === "1") { + if (yield* probeFlag("GOLDEN_HARNESS_PROBE_HOLD")) { yield* Effect.sync(() => process.stdout.write("probe: holding\n")); return yield* Effect.never; } - if (process.env.GOLDEN_HARNESS_PROBE_CRASH === "1") yield* Effect.sleep("30 seconds"); + if (crash) yield* Effect.sleep("30 seconds"); yield* context.checkpoint("finished", observe); }), @@ -329,10 +341,11 @@ const runProbe = (probe: ProbeCase) => Effect.scoped( Effect.gen(function* () { const lines: Array = []; + const environment = yield* safeEnvironment; const handle = yield* ChildProcess.make("bun", ["--no-env-file", entry], { cwd: root, - env: { ...safeEnvironment(), GOLDEN_HARNESS_PROBE: "1", ...probe.env }, + env: { ...environment, GOLDEN_HARNESS_PROBE: "1", ...probe.env }, extendEnv: false, detached: true, stdin: "ignore", @@ -366,7 +379,8 @@ const runProbe = (probe: ProbeCase) => const verifyProbe = (probe: ProbeCase) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const started = Date.now(); + const { join } = yield* Path.Path; + const started = yield* Clock.currentTimeMillis; const outcome = yield* runProbe(probe); const report = `${probe.name}\nstdout:\n${outcome.stdout.join("\n")}\nstderr:\n${outcome.stderr}`; @@ -452,13 +466,15 @@ const verifyProbe = (probe: ProbeCase) => assert.equal(yield* fs.exists(evidence.cleanup.privateRoot), false, "private root remains"); yield* fs.remove(artifacts, { recursive: true }); - return `${probe.name}: ok (exit ${outcome.exitCode}, ${evidence.cleanup.processes.length} groups gone, ${Date.now() - started} ms)`; + return `${probe.name}: ok (exit ${outcome.exitCode}, ${evidence.cleanup.processes.length} groups gone, ${(yield* Clock.currentTimeMillis) - started} ms)`; }); // The guard must reject any uncommitted change and digest exactly the tracked bytes. const verifySourceGuard = Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const { join } = yield* Path.Path; + const environment = yield* safeEnvironment; const repository = yield* fs.makeTempDirectoryScoped({ prefix: "golden-harness-guard-" }); const git = (...args: ReadonlyArray) => @@ -475,7 +491,7 @@ const verifySourceGuard = Effect.scoped( ], { cwd: repository, - env: { ...safeEnvironment(), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }, + env: { ...environment, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }, extendEnv: false, }, ).pipe( @@ -518,5 +534,12 @@ const selfTest = Effect.gen(function* () { yield* Effect.sync(() => process.stdout.write(`self-test passed: ${lines.length} checks\n`)); }); -if (process.env.GOLDEN_HARNESS_PROBE === "1") runGoldenJourney(probeJourney); -else BunRuntime.runMain(selfTest.pipe(Effect.provide(BunServices.layer))); +// A probe runner hands the process to the harness, which runs the journey and exits with its code. +const program = Effect.gen(function* () { + if (yield* probeFlag("GOLDEN_HARNESS_PROBE")) + return yield* Effect.sync(() => runGoldenJourney(probeJourney)); + + return yield* selfTest; +}); + +BunRuntime.runMain(program.pipe(Effect.provide(BunServices.layer))); diff --git a/tools/e2e/golden-harness.ts b/tools/e2e/golden-harness.ts index cdd60b1b2..e96e2e5ff 100644 --- a/tools/e2e/golden-harness.ts +++ b/tools/e2e/golden-harness.ts @@ -11,15 +11,8 @@ * harness verifies that nothing it started survives. */ import { randomBytes } from "node:crypto"; -import { readFile } from "node:fs/promises"; -import { - createServer as createHttpServer, - request as httpRequest, - type IncomingMessage, - type Server, - type ServerResponse, -} from "node:http"; -import { join } from "node:path"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0012: the loopback provider listens through node:http. +import * as NodeHttp from "node:http"; import { fileURLToPath } from "node:url"; import { Buffer } from "node:buffer"; import process from "node:process"; @@ -33,6 +26,7 @@ import { import { canonicalJsonBytes, sha256Hex } from "@vektorprogrammet/domain/shared-kernel"; import { Cause, + Config, DateTime, Deferred, type Duration, @@ -40,13 +34,17 @@ import { Exit, Fiber, FileSystem, + Layer, Option, + Path, Predicate, Schedule, Schema, type Scope, Stream, } from "effect"; +import { dual } from "effect/Function"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { ChildProcess, type ChildProcessSpawner } from "effect/unstable/process"; import { Pool, type PoolClient } from "pg"; @@ -79,9 +77,9 @@ export const describeCause = (cause: unknown): string => export const failure = (stage: string) => (cause: unknown): HarnessFailure => - cause instanceof HarnessFailure + Schema.is(HarnessFailure)(cause) ? cause - : new HarnessFailure({ stage, message: describeCause(cause) }); + : HarnessFailure.make({ stage, message: describeCause(cause) }); const isoNow = Effect.map(DateTime.now, DateTime.formatIso); @@ -275,7 +273,7 @@ const spawnOwned = (ledger: Ledger) => if (spec.supervised === true && !entry.stopping) yield* Deferred.succeed( ledger.supervision, - new HarnessFailure({ + HarnessFailure.make({ stage: spec.label, message: `exited unexpectedly (${entry.exit}): ${ledger.redactor.apply(tail(entry.log.read(), failureLogCharacters))}`, }), @@ -316,14 +314,14 @@ const runToCompletion = Effect.timeoutOrElse({ duration: deadline, orElse: () => - Effect.fail(new HarnessFailure({ stage: spec.label, message: "deadline exceeded" })), + Effect.fail(HarnessFailure.make({ stage: spec.label, message: "deadline exceeded" })), }), ); entry.exit = `code ${code}`; if (code !== 0) - return yield* new HarnessFailure({ + return yield* HarnessFailure.make({ stage: spec.label, message: `exited with code ${code}: ${ledger.redactor.apply(tail(stdout + stderr, failureLogCharacters))}`, }); @@ -351,25 +349,29 @@ const captureSource = Effect.fnUntraced(function* ( pathspecs: ReadonlyArray, ) { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const git = (args: ReadonlyArray) => run({ label: "git", command: "git", args, cwd: root, env: environment }, "60 seconds"); if ((yield* git(["status", "--porcelain"])).trim() !== "") - return yield* new HarnessFailure({ + return yield* HarnessFailure.make({ stage: "source", message: "requires committed clean source", }); const revision = (yield* git(["rev-parse", "HEAD"])).trim(); const tree = (yield* git(["rev-parse", "HEAD^{tree}"])).trim(); - const paths = (yield* git(["ls-files", "-z", "--", ...pathspecs])).split("\0").filter(Boolean); + + const paths = (yield* git(["ls-files", "-z", "--", ...pathspecs])) + .split("\0") + .filter((entry) => entry.length > 0); const files = yield* Effect.forEach( paths, - (path) => - fs.readFile(join(root, path)).pipe( - Effect.map((bytes): SourceFile => ({ path, sha256: sha256Hex(bytes) })), + (file) => + fs.readFile(path.join(root, file)).pipe( + Effect.map((bytes): SourceFile => ({ path: file, sha256: sha256Hex(bytes) })), Effect.mapError(failure("source")), ), { concurrency: 16 }, @@ -391,32 +393,34 @@ const verifySource = Effect.fnUntraced(function* ( observed.revision !== expected.revision || sha256Hex(canonicalJsonBytes(observed.files)) !== sha256Hex(canonicalJsonBytes(expected.files)) ) - return yield* new HarnessFailure({ + return yield* HarnessFailure.make({ stage: "source", message: "source changed during acceptance", }); }); +const safeEnvironmentKeys = [ + "PATH", + "HOME", + "TMPDIR", + "LANG", + "LC_ALL", + "TZ", + "LD_LIBRARY_PATH", + "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", + "PLAYWRIGHT_NODE_EXECUTABLE", + "PLAYWRIGHT_BROWSERS_PATH", +] as const; + /** Environment passed to children; nothing else from the operator shell leaks in. */ -export const safeEnvironment = (): Readonly> => - Object.fromEntries( - [ - "PATH", - "HOME", - "TMPDIR", - "LANG", - "LC_ALL", - "TZ", - "LD_LIBRARY_PATH", - "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", - "PLAYWRIGHT_NODE_EXECUTABLE", - "PLAYWRIGHT_BROWSERS_PATH", - ].flatMap((key) => { - const value = process.env[key]; - - return value === undefined ? [] : [[key, value]]; - }), - ); +export const safeEnvironment: Effect.Effect< + Readonly>, + Config.ConfigError +> = Effect.forEach(safeEnvironmentKeys, (key) => + Config.option(Config.String(key)).pipe( + Effect.map(Option.match({ onNone: () => [], onSome: (value) => [[key, value] as const] })), + ), +).pipe(Effect.map((entries) => Object.fromEntries(entries.flat()))); const makeLedger = Effect.map( Deferred.make(), @@ -430,29 +434,52 @@ const makeLedger = Effect.map( ); /** Clean-source guard and manifest for tools that do not run a journey. */ -export const inspectSource = (root: string, pathspecs: ReadonlyArray) => +export const inspectSource: { + ( + pathspecs: ReadonlyArray, + ): (root: string) => Effect.Effect; + ( + root: string, + pathspecs: ReadonlyArray, + ): Effect.Effect; +} = dual(2, (root: string, pathspecs: ReadonlyArray) => Effect.gen(function* () { const ledger = yield* makeLedger; + const environment = yield* safeEnvironment.pipe(Effect.mapError(failure("environment"))); - return yield* captureSource(runToCompletion(ledger), root, safeEnvironment(), pathspecs); - }); + return yield* captureSource(runToCompletion(ledger), root, environment, pathspecs); + }), +); /** Polls a condition until it holds; a typed failure from the check ends polling at once. */ -export const eventually = ( - label: string, - check: Effect.Effect, - deadline: Duration.Input, -): Effect.Effect => - check.pipe( - Effect.flatMap((ready) => (ready ? Effect.void : Effect.fail(new NotReady()))), - Effect.retry({ - while: (error) => Predicate.isTagged(error, "NotReady"), - schedule: Schedule.spaced("100 millis").pipe(Schedule.upTo({ duration: deadline })), - }), - Effect.catchTag("NotReady", () => - Effect.fail(new HarnessFailure({ stage: label, message: `Timed out: ${label}` })), +export const eventually: { + ( + check: Effect.Effect, + deadline: Duration.Input, + ): (label: string) => Effect.Effect; + ( + label: string, + check: Effect.Effect, + deadline: Duration.Input, + ): Effect.Effect; +} = dual( + 3, + ( + label: string, + check: Effect.Effect, + deadline: Duration.Input, + ): Effect.Effect => + check.pipe( + Effect.flatMap((ready) => (ready ? Effect.void : Effect.fail(NotReady.make({})))), + Effect.retry({ + while: (error) => Predicate.isTagged(error, "NotReady"), + schedule: Schedule.spaced("100 millis").pipe(Schedule.upTo({ duration: deadline })), + }), + Effect.catchTag("NotReady", () => + Effect.fail(HarnessFailure.make({ stage: label, message: `Timed out: ${label}` })), + ), ), - ); +); export interface HttpProbe { readonly label: string; @@ -464,46 +491,48 @@ export interface HttpProbe { readonly deadline: Duration.Input; } -/** Waits for a 2xx response over node:http, which honours an explicit Host header. */ -export const waitForHttp = (probe: HttpProbe) => - eventually( - probe.label, - Effect.gen(function* () { - if (probe.process !== undefined && !(yield* probe.process.isRunning)) - return yield* new HarnessFailure({ - stage: probe.label, - message: `exited before ready: ${tail(yield* probe.process.logTail, failureLogCharacters)}`, - }); +/** + * Waits for a 2xx response. Each attempt sends one GET with an optional Host header, follows no + * redirect, and counts as not ready after two seconds or on a transport failure. + */ +export const waitForHttp = (probe: HttpProbe): Effect.Effect => + Effect.gen(function* () { + const request = HttpClientRequest.get(probe.url, { + headers: probe.host === undefined ? {} : { host: probe.host }, + }); - const url = new URL(probe.url); - - const status = yield* Effect.callback((resume) => { - const request = httpRequest( - { - hostname: url.hostname, - port: url.port, - path: `${url.pathname}${url.search}`, - method: "GET", - headers: probe.host === undefined ? {} : { host: probe.host }, - timeout: 2_000, - }, - (response) => { - response.resume(); - resume(Effect.succeed(response.statusCode ?? 0)); - }, - ); + const attempt = Effect.flatMap(HttpClient.HttpClient, (client) => client.execute(request)).pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.timeout("2 seconds"), + // The body is drained, bounded, so the connection returns to the pool. + Effect.flatMap((response) => + response.arrayBuffer.pipe( + Effect.timeout("2 seconds"), + Effect.ignore, + Effect.as(response.status), + ), + ), + Effect.orElseSucceed(() => 0), + // The harness selects the client itself, so journeys keep their requirements. + Effect.provide(FetchHttpClient.layer), + ); - request.once("timeout", () => request.destroy()); - request.once("error", () => resume(Effect.succeed(0))); - request.end(); + return yield* eventually( + probe.label, + Effect.gen(function* () { + if (probe.process !== undefined && !(yield* probe.process.isRunning)) + return yield* HarnessFailure.make({ + stage: probe.label, + message: `exited before ready: ${tail(yield* probe.process.logTail, failureLogCharacters)}`, + }); - return Effect.sync(() => request.destroy()); - }); + const status = yield* attempt; - return status >= 200 && status <= 299; - }), - probe.deadline, - ); + return status >= 200 && status <= 299; + }), + probe.deadline, + ); + }); export interface JourneyPostgres { readonly url: string; @@ -520,36 +549,64 @@ const statement = (client: PoolClient, text: string) => ); /** Runs one read-only REPEATABLE READ transaction, so every read shares one snapshot. */ -export const readOnlySnapshot = ( - pool: Pool, - read: (client: PoolClient) => Effect.Effect, -): Effect.Effect => - Effect.acquireUseRelease( - Effect.tryPromise({ try: () => pool.connect(), catch: failure("snapshot") }), - (client) => - statement(client, "BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY").pipe( - Effect.andThen(read(client)), - Effect.tap(() => statement(client, "COMMIT")), - ), - (client, exit) => - (Exit.isSuccess(exit) ? Effect.void : Effect.ignore(statement(client, "ROLLBACK"))).pipe( - Effect.ensuring(Effect.sync(() => client.release())), - ), - ); +export const readOnlySnapshot: { + ( + read: (client: PoolClient) => Effect.Effect, + ): (pool: Pool) => Effect.Effect; + ( + pool: Pool, + read: (client: PoolClient) => Effect.Effect, + ): Effect.Effect; +} = dual( + 2, + ( + pool: Pool, + read: (client: PoolClient) => Effect.Effect, + ): Effect.Effect => + Effect.acquireUseRelease( + Effect.tryPromise({ try: () => pool.connect(), catch: failure("snapshot") }), + (client) => + statement(client, "BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY").pipe( + Effect.andThen(read(client)), + Effect.tap(() => statement(client, "COMMIT")), + ), + (client, exit) => + (Exit.isSuccess(exit) ? Effect.void : Effect.ignore(statement(client, "ROLLBACK"))).pipe( + Effect.ensuring(Effect.sync(() => client.release())), + ), + ), +); /** Queries one relation and decodes every row at the persistence boundary. */ -export const selectRows = ( - client: PoolClient, - label: string, - text: string, - values: ReadonlyArray, - row: Schema.Decoder, -) => - Effect.tryPromise({ try: () => client.query(text, [...values]), catch: failure(label) }).pipe( - Effect.flatMap((result) => - Schema.decodeEffect(Schema.Array(row))(result.rows).pipe(Effect.mapError(failure(label))), +export const selectRows: { + ( + label: string, + text: string, + values: ReadonlyArray, + row: Schema.Decoder, + ): (client: PoolClient) => Effect.Effect, HarnessFailure>; + ( + client: PoolClient, + label: string, + text: string, + values: ReadonlyArray, + row: Schema.Decoder, + ): Effect.Effect, HarnessFailure>; +} = dual( + 5, + ( + client: PoolClient, + label: string, + text: string, + values: ReadonlyArray, + row: Schema.Decoder, + ): Effect.Effect, HarnessFailure> => + Effect.tryPromise({ try: () => client.query(text, [...values]), catch: failure(label) }).pipe( + Effect.flatMap((result) => + Schema.decodeEffect(Schema.Array(row))(result.rows).pipe(Effect.mapError(failure(label))), + ), ), - ); +); export interface ProviderSpec { readonly port: number; @@ -566,11 +623,14 @@ export interface LoopbackProvider { const JsonBody = Schema.fromJsonString(Schema.Json); +/** The indented JSON text of an artifact, as `JSON.stringify(value, null, 2)` writes it. */ +const prettyJsonText = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown, { space: 2 })); + const respond = ( state: ProviderState, spec: ProviderSpec, - request: IncomingMessage, - response: ServerResponse, + request: NodeHttp.IncomingMessage, + response: NodeHttp.ServerResponse, bytes: Buffer, ) => { const idempotencyKey = request.headers["idempotency-key"]; @@ -602,7 +662,8 @@ const respond = ( state.attempts.push({ sequence: state.attempts.length + 1, - receivedAt: new Date().toISOString(), + // The Node request callback runs outside any fiber, so it reads the clock directly. + receivedAt: DateTime.formatIso(DateTime.nowUnsafe()), mode: state.mode, idempotencyKey: Array.isArray(idempotencyKey) ? null : (idempotencyKey ?? null), status, @@ -624,7 +685,7 @@ const startProvider = (ledger: Ledger) => (spec: ProviderSpec) => attempts: [], }; - const handle = (request: IncomingMessage, response: ServerResponse) => { + const handle = (request: NodeHttp.IncomingMessage, response: NodeHttp.ServerResponse) => { const chunks: Array = []; let size = 0; @@ -643,8 +704,8 @@ const startProvider = (ledger: Ledger) => (spec: ProviderSpec) => }; yield* Effect.acquireRelease( - Effect.callback((resume) => { - const server = createHttpServer(handle); + Effect.callback((resume) => { + const server = NodeHttp.createServer(handle); server.once("error", (cause) => resume(Effect.fail(failure("provider")(cause)))); server.listen(spec.port, "127.0.0.1", () => resume(Effect.succeed(server))); @@ -677,12 +738,15 @@ const startPostgres = (ledger: Ledger, privateRoot: string, environment: Readonly>) => (port: number, applicationName: string) => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { cluster, entry } = yield* Effect.acquireRelease( Effect.tryPromise({ try: () => startDisposablePostgres({ port, - directory: join(privateRoot, "postgres"), + directory: path.join(privateRoot, "postgres"), maxConnections: 40, environment, }), @@ -703,12 +767,15 @@ const startPostgres = }), ), (started) => - Effect.promise(async () => { + Effect.gen(function* () { started.entry.stopping = true; - started.entry.log.append( - await readFile(started.cluster.logFile, "utf8").catch(() => ""), - ); - await started.cluster.stop(); + + const log = yield* fs + .readFileString(started.cluster.logFile) + .pipe(Effect.orElseSucceed(() => "")); + + started.entry.log.append(log); + yield* Effect.promise(() => started.cluster.stop()); started.entry.exit ??= "fast shutdown"; }), ); @@ -719,7 +786,7 @@ const startPostgres = return Deferred.succeed( ledger.supervision, - new HarnessFailure({ + HarnessFailure.make({ stage: "postgres", message: ledger.redactor.apply(tail(cause.message, failureLogCharacters)), }), @@ -1025,14 +1092,20 @@ const interceptSignals = ( const main = (journey: GoldenJourney, root: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const environment = safeEnvironment(); - const fault = process.env[journey.faultVariable]; + const path = yield* Path.Path; + const join = path.join; + const environment = yield* safeEnvironment.pipe(Effect.mapError(failure("preflight"))); + + const fault = yield* Config.option(Config.String(journey.faultVariable)).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError(failure("preflight")), + ); if ( fault !== undefined && !journey.faultPoints.some((point) => fault === point || fault === `interrupt-${point}`) ) - return yield* new HarnessFailure({ + return yield* HarnessFailure.make({ stage: "preflight", message: `unknown journey fault: ${fault}`, }); @@ -1103,7 +1176,7 @@ const main = (journey: GoldenJourney, root: string) => const expected = journey.steps[observations.length]; if (step !== expected) - return yield* new HarnessFailure({ + return yield* HarnessFailure.make({ stage: "checkpoint", message: `checkpoint order: expected ${expected ?? "none"}, observed ${step}`, }); @@ -1122,7 +1195,7 @@ const main = (journey: GoldenJourney, root: string) => faultPoint: (point) => fault === point ? Effect.fail( - new HarnessFailure({ stage: "fault", message: `Injected journey failure ${point}` }), + HarnessFailure.make({ stage: "fault", message: `Injected journey failure ${point}` }), ) : fault === `interrupt-${point}` ? Effect.sync(() => process.kill(process.pid, "SIGTERM")).pipe( @@ -1141,7 +1214,7 @@ const main = (journey: GoldenJourney, root: string) => const interruption = Effect.raceFirst( Deferred.await(signals), Effect.sleep(journey.deadline).pipe(Effect.as("deadline")), - ).pipe(Effect.flatMap((reason) => Effect.fail(new JourneyInterrupted({ reason })))); + ).pipe(Effect.flatMap((reason) => Effect.fail(JourneyInterrupted.make({ reason })))); const supervision = Deferred.await(ledger.supervision).pipe( Effect.flatMap((error) => Effect.fail(error)), @@ -1152,7 +1225,7 @@ const main = (journey: GoldenJourney, root: string) => observations.length === journey.steps.length ? Effect.void : Effect.fail( - new HarnessFailure({ + HarnessFailure.make({ stage: "checkpoint", message: `observed ${observations.length} of ${journey.steps.length} required steps`, }), @@ -1169,10 +1242,9 @@ const main = (journey: GoldenJourney, root: string) => return Effect.succeed({ kind: "failed", - failure: - error instanceof HarnessFailure - ? `${error.stage}: ${error.message}` - : Cause.pretty(cause), + failure: Schema.is(HarnessFailure)(error) + ? `${error.stage}: ${error.message}` + : Cause.pretty(cause), }); }), ); @@ -1245,14 +1317,14 @@ const main = (journey: GoldenJourney, root: string) => .writeFileString(join(artifacts, name), text, { mode: 0o600 }) .pipe(Effect.mapError(failure("artifacts"))); - yield* write("evidence.json", ledger.redactor.apply(JSON.stringify(evidence, null, 2))); + yield* write("evidence.json", ledger.redactor.apply(prettyJsonText(evidence))); yield* write( "source-manifest.json", - JSON.stringify( - { revision: source.revision, sourceTree: source.tree, sources: source.files }, - null, - 2, - ), + prettyJsonText({ + revision: source.revision, + sourceTree: source.tree, + sources: source.files, + }), ); if (primary !== undefined) yield* write("failure.log", ledger.redactor.apply(logs)); @@ -1265,11 +1337,11 @@ const main = (journey: GoldenJourney, root: string) => names.sort().filter((name) => name !== "receipt.json" && !name.startsWith("private")), (name) => Effect.gen(function* () { - const path = join(artifacts, name); + const file = join(artifacts, name); - if ((yield* fs.stat(path)).type !== "File") return []; + if ((yield* fs.stat(file)).type !== "File") return []; - const bytes = yield* fs.readFile(path); + const bytes = yield* fs.readFile(file); return [ { @@ -1320,7 +1392,7 @@ const main = (journey: GoldenJourney, root: string) => if (leaked.length > 0) yield* write("credential-leak.txt", leaked.join("\n")); - yield* write("receipt.json", JSON.stringify(receipt, null, 2)); + yield* write("receipt.json", prettyJsonText(receipt)); yield* Effect.sync(() => process.stdout.write( `${passed ? "" : `failure: ${tail(ledger.redactor.apply(primary ?? `credential leaked into ${leaked.join(", ")}`), failureLogCharacters)}\n`}` + @@ -1349,7 +1421,7 @@ export const runGoldenJourney = (journey: GoldenJourney): void => { return 1; }), ), - Effect.provide(BunServices.layer), + Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer)), ), ).then( (code) => process.stdout.write("", () => process.exit(code)), @@ -1365,22 +1437,35 @@ export const runGoldenJourney = (journey: GoldenJourney): void => { * signal and waits (bounded) for the driver to settle, so cleanup verification never * races a browser that is still closing. */ -export const fromAbortable = ( - label: string, - start: (signal: AbortSignal) => Promise, - grace: Duration.Input, -) => - Effect.callback((resume) => { - const controller = new AbortController(); - - const settled = start(controller.signal).then( - (value) => resume(Effect.succeed(value)), - (cause: unknown) => resume(Effect.fail(failure(label)(cause))), - ); +export const fromAbortable: { + ( + start: (signal: AbortSignal) => Promise, + grace: Duration.Input, + ): (label: string) => Effect.Effect; + ( + label: string, + start: (signal: AbortSignal) => Promise, + grace: Duration.Input, + ): Effect.Effect; +} = dual( + 3, + ( + label: string, + start: (signal: AbortSignal) => Promise, + grace: Duration.Input, + ): Effect.Effect => + Effect.callback((resume) => { + const controller = new AbortController(); + + const settled = start(controller.signal).then( + (value) => resume(Effect.succeed(value)), + (cause: unknown) => resume(Effect.fail(failure(label)(cause))), + ); - return Effect.sync(() => controller.abort(new Error(`${label} interrupted`))).pipe( - Effect.andThen(Effect.promise(() => settled)), - Effect.timeout(grace), - Effect.ignore, - ); - }); + return Effect.sync(() => controller.abort(new Error(`${label} interrupted`))).pipe( + Effect.andThen(Effect.promise(() => settled)), + Effect.timeout(grace), + Effect.ignore, + ); + }), +); diff --git a/tools/e2e/golden-http-diagnostics.mjs b/tools/e2e/golden-http-diagnostics.mjs index d9254d3f8..b910e5ff9 100644 --- a/tools/e2e/golden-http-diagnostics.mjs +++ b/tools/e2e/golden-http-diagnostics.mjs @@ -1,4 +1,9 @@ // Golden-only preload. Record transport metadata, never headers, query strings, or bodies. +import { Console, Data, Effect } from "effect"; + +// The wrapped handler failed; Bun receives its original error. +class HandlerFailure extends Data.TaggedError("HandlerFailure") {} + const serve = Bun.serve.bind(Bun); let sequence = 0; @@ -8,7 +13,8 @@ Bun.serve = (options) => { return serve({ ...options, - fetch: async (request, server) => { + // Bun calls the handler for a Response; each request runs its record program to completion. + fetch: (request, server) => { const started = performance.now(); const identity = { @@ -20,7 +26,7 @@ Bun.serve = (options) => { }; const record = (event, fields = {}) => - console.log( + Console.log( JSON.stringify({ ...identity, event, @@ -29,21 +35,23 @@ Bun.serve = (options) => { }), ); - const abort = () => record("aborted"); + const abort = () => Effect.runSync(record("aborted")); request.signal.addEventListener("abort", abort, { once: true }); - record("started"); - - try { - const response = await fetch(request, server); - record("response", { status: response.status }); - - return response; - } catch (error) { - record("failed"); - throw error; - } finally { - request.signal.removeEventListener("abort", abort); - } + + return Effect.runPromise( + record("started").pipe( + Effect.andThen( + Effect.tryPromise({ + try: () => Promise.resolve(fetch(request, server)), + catch: (cause) => new HandlerFailure({ cause }), + }), + ), + Effect.tap((response) => record("response", { status: response.status })), + Effect.tapError(() => record("failed")), + Effect.catchTag("HandlerFailure", (failure) => Effect.die(failure.cause)), + Effect.ensuring(Effect.sync(() => request.signal.removeEventListener("abort", abort))), + ), + ); }, }); }; diff --git a/tools/e2e/golden-recruitment.mjs b/tools/e2e/golden-recruitment.ts similarity index 76% rename from tools/e2e/golden-recruitment.mjs rename to tools/e2e/golden-recruitment.ts index 81eae1220..1e4845e43 100644 --- a/tools/e2e/golden-recruitment.mjs +++ b/tools/e2e/golden-recruitment.ts @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; +import { Effect, Schema } from "effect"; +import type { Pool } from "pg"; +import { step as journeyStep } from "../acceptance/journey-step"; export const recruitmentSteps = [ "initial", @@ -25,10 +28,10 @@ export const recruitmentSteps = [ export const recruitmentRunnerPaths = [ "tools/e2e/placement-check.ts", - "tools/e2e/golden-recruitment.mjs", + "tools/e2e/golden-recruitment.ts", "apps/dashboard/e2e/run-real-native-placement.mjs", "apps/dashboard/e2e/native-recruitment-first-placement.spec.ts", - "tools/e2e/golden-school-service-evidence.mjs", + "tools/e2e/golden-school-service-evidence.ts", ]; export const recruitmentPeople = { @@ -70,8 +73,9 @@ export const recruitmentFixture = { persons: recruitmentPeople, }; -export const seedRecruitment = async (pool) => { - await pool.query(` +export const seedRecruitment = (pool: Pool) => + journeyStep(() => + pool.query(` INSERT INTO admission_period_departments(department_id,name) VALUES ('recruitment-department','Trondheim'),('recruitment-wrong-department','Annen'); INSERT INTO admission_period_semesters(semester_id,start_at,end_at) VALUES ('recruitment-semester',date_trunc('milliseconds',now(),'UTC')-interval '720 hours',date_trunc('milliseconds',now(),'UTC')+interval '2160 hours'); INSERT INTO organization_departments(department_id,name,short_name,email,city,active,independent,revision) VALUES @@ -91,12 +95,26 @@ export const seedRecruitment = async (pool) => { INSERT INTO admission_periods(admission_period_id,department_id,semester_id,start_at,end_at,last_command_id) VALUES('recruitment-period','recruitment-department','recruitment-semester',date_trunc('milliseconds',now(),'UTC')-interval '24 hours',date_trunc('milliseconds',now(),'UTC')+interval '720 hours','prerequisite'); INSERT INTO recruitment_interview_schemas(interview_schema_id,name,question_count,active,revision) VALUES('recruitment-schema','Rekruttintervju',1,true,0); INSERT INTO recruitment_interview_schema_questions(interview_schema_id,question_id,ordinal,prompt,help_text,kind,alternatives) VALUES('recruitment-schema','recruitment-motivation',0,'Hvorfor vil du bidra?',NULL,'text','[]'); - `); -}; + `), + ).pipe(Effect.asVoid); // Private provider bodies stay in memory. Only their digest and delivery status enter evidence. +// The mailbox and the observer read untyped JSON bodies and pg rows. +// oxlint-disable-next-line typescript/no-explicit-any -- pg types an untyped row as any, as the calls did +type Row = any; + +/** One delivery attempt that the loopback provider answered. */ +interface MailDelivery { + readonly body: Row; + readonly status: number; + readonly key: string | undefined; + readonly digest: string; +} + +const UnknownJson = Schema.fromJsonString(Schema.Unknown); + export const createRecruitmentMailbox = () => { - const deliveries = []; + const deliveries: Array = []; let fails = true; return { @@ -104,39 +122,48 @@ export const createRecruitmentMailbox = () => { recover: () => { fails = false; }, - handle: async (request, response) => { - if (request.url === "/mail" && request.method === "GET") { - response.setHeader("content-type", "application/json"); - response.end( - JSON.stringify(deliveries.flatMap((item) => (item.status === 204 ? [item.body] : []))), + // Answers one request of the loopback provider: the delivered mail on GET /mail, and a + // recorded delivery attempt on any other path. + handle: Effect.fnUntraced(function* (request: Request) { + const url = new URL(request.url); + const target = url.pathname + url.search; + + if (target === "/mail" && request.method === "GET") + return new Response( + yield* Schema.encodeEffect(UnknownJson)( + deliveries.flatMap((item: Row) => (item.status === 204 ? [item.body] : [])), + ), + { headers: { "content-type": "application/json" } }, ); - return; - } - - const chunks = []; - - for await (const chunk of request) chunks.push(Buffer.from(chunk)); - const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + const text = yield* journeyStep(() => request.text()); + const body: Row = yield* Schema.decodeEffect(UnknownJson)(text); const status = - request.url === "/onboarding" && body.to === recruitmentPeople.applicant.email && fails + target === "/onboarding" && body.to === recruitmentPeople.applicant.email && fails ? 503 : 204; - assert.equal(request.headers.authorization, "Bearer " + "synthetic-recruitment-provider"); + assert.equal( + request.headers.get("authorization"), + "Bearer " + "synthetic-recruitment-provider", + ); deliveries.push({ body, status, - key: request.headers["idempotency-key"], - digest: createHash("sha256").update(JSON.stringify(body)).digest("hex"), + key: request.headers.get("idempotency-key") ?? undefined, + digest: createHash("sha256") + .update(yield* Schema.encodeEffect(UnknownJson)(body)) + .digest("hex"), }); - response.statusCode = status; - response.end(); - }, + + return new Response(null, { status }); + }), }; }; +export type RecruitmentMailbox = ReturnType; + const tables = [ "admission_applications", "admission_application_command_receipts", @@ -162,15 +189,25 @@ const tables = [ "assistant_placement_audit", ]; -export const createRecruitmentObserver = (pool, mailbox) => { - const observations = []; - let previous; +export interface RecruitmentObserverOptions { + readonly pool: Pool; + readonly mailbox: RecruitmentMailbox; +} + +/** + * The independent observer of the golden recruitment journey: each checkpoint reads one snapshot + * of the recruitment facts and asserts the facts of its step. + */ +export const createRecruitmentObserver = ({ pool, mailbox }: RecruitmentObserverOptions) => { + const observations: Array = []; + let previous: Row = undefined; - const observe = async (step) => { + const observe = Effect.fnUntraced(function* (step: string) { assert.equal(step, recruitmentSteps[observations.length], "checkpoint order"); - const result = await pool.query(`SELECT json_build_object( - 'counts', json_build_object(${tables.map((table) => `'${table}',(SELECT count(*)::int FROM public.${table})`).join(",")}), + const result = yield* journeyStep(() => + pool.query(`SELECT json_build_object( + 'counts', json_build_object(${tables.map((table: Row) => `'${table}',(SELECT count(*)::int FROM public.${table})`).join(",")}), 'applications',(SELECT coalesce(json_agg(row_to_json(a) ORDER BY a.email),'[]') FROM (SELECT p.email,a.application_id,a.applicant_id FROM admission_applications a JOIN admission_applicants p USING(applicant_id)) a), 'interviews',(SELECT coalesce(json_agg(row_to_json(i)),'[]') FROM (SELECT interview_id,application_id,interviewer_person_id,revision FROM recruitment_interviews) i), 'responses',(SELECT coalesce(json_agg(response_state),'[]') FROM recruitment_invitations), @@ -186,15 +223,19 @@ export const createRecruitmentObserver = (pool, mailbox) => { 'applicationOutbox',(SELECT coalesce(json_agg(status ORDER BY effect_id),'[]') FROM admission_application_outbox), 'interviewDelivery',(SELECT coalesce(json_agg(status),'[]') FROM recruitment_invitation_outbox), 'completionDelivery',(SELECT coalesce(json_agg(status),'[]') FROM recruitment_interview_completion_outbox) - ) AS facts`); + ) AS facts`), + ); const facts = result.rows[0].facts; - const at = (name) => recruitmentSteps.indexOf(step) >= recruitmentSteps.indexOf(name); - const n = (table, expected) => assert.equal(facts.counts[table], expected, `${step}: ${table}`); + const at = (name: string) => recruitmentSteps.indexOf(step) >= recruitmentSteps.indexOf(name); + + const n = (table: string, expected: number) => + assert.equal(facts.counts[table], expected, `${step}: ${table}`); + n("admission_applications", at("applications") ? 2 : 0); n("admission_application_command_receipts", at("applications") ? 2 : 0); n("admission_application_outbox", at("applications") ? 6 : 0); - assert.ok(facts.applicationOutbox.every((status) => status === "Pending")); + assert.ok(facts.applicationOutbox.every((status: Row) => status === "Pending")); assert.deepEqual( facts.history.assignment, at("assigned") ? [recruitmentPeople.leader.personId] : [], @@ -249,7 +290,7 @@ export const createRecruitmentObserver = (pool, mailbox) => { if (at("assigned")) { const application = facts.applications.find( - (a) => a.email === recruitmentPeople.applicant.email, + (a: Row) => a.email === recruitmentPeople.applicant.email, ); assert.equal(facts.interviews[0].application_id, application.application_id); @@ -263,7 +304,7 @@ export const createRecruitmentObserver = (pool, mailbox) => { if (at("recommended")) assert.deepEqual( - facts.conducts.map((c) => [ + facts.conducts.map((c: Row) => [ c.recommendation, c.explanatory_power, c.role_model, @@ -275,7 +316,7 @@ export const createRecruitmentObserver = (pool, mailbox) => { if (step === "invitation-failed") { assert.deepEqual( - facts.delivery.map((d) => [d.state, d.attempts, d.secret_cleared]), + facts.delivery.map((d: Row) => [d.state, d.attempts, d.secret_cleared]), [["Pending", 1, false]], ); mailbox.recover(); @@ -283,20 +324,20 @@ export const createRecruitmentObserver = (pool, mailbox) => { if (step === "invitation-delivered") { assert.deepEqual( - facts.delivery.map((d) => [d.state, d.attempts, d.secret_cleared, d.envelope_cleared]), + facts.delivery.map((d: Row) => [d.state, d.attempts, d.secret_cleared, d.envelope_cleared]), [["Delivered", 2, true, true]], ); const attempts = mailbox.deliveries.filter( - (d) => d.body.to === recruitmentPeople.applicant.email, + (d: Row) => d.body.to === recruitmentPeople.applicant.email, ); assert.deepEqual( - attempts.map((d) => d.status), + attempts.map((d: Row) => d.status), [503, 204], ); - assert.equal(attempts[0].key, attempts[1].key); - assert.equal(attempts[0].digest, attempts[1].digest); + assert.equal(attempts[0]?.key, attempts[1]?.key); + assert.equal(attempts[0]?.digest, attempts[1]?.digest); } if ( @@ -314,20 +355,23 @@ export const createRecruitmentObserver = (pool, mailbox) => { interviewDelivery: _interviewDelivery, completionDelivery: _completionDelivery, ...rest - }) => rest; + }: Row) => rest; assert.deepEqual(stable(facts), stable(previous), `${step}: business facts must not change`); } if (at("affiliation-requested")) { - const main = facts.applications.find((a) => a.email === recruitmentPeople.applicant.email); - const link = facts.links.find((l) => l.applicant_id === main.applicant_id); + const main = facts.applications.find( + (a: Row) => a.email === recruitmentPeople.applicant.email, + ); + + const link = facts.links.find((l: Row) => l.applicant_id === main.applicant_id); assert.equal(facts.affiliations[0].person_id, link.person_id); assert.equal(facts.affiliations[0].status, at("affiliation-approved") ? "Active" : "Pending"); if (at("placed")) assert.deepEqual( - facts.placements.map((p) => [ + facts.placements.map((p: Row) => [ p.person_id, p.school_id, p.day, @@ -343,21 +387,25 @@ export const createRecruitmentObserver = (pool, mailbox) => { observations.push({ step, ...facts }); return facts; - }; + }); return { observations, observe, finish: () => { assert.deepEqual( - observations.map((o) => o.step), + observations.map((o: Row) => o.step), recruitmentSteps, ); assert.deepEqual(previous.interviewDelivery, ["Delivered"]); assert.deepEqual(previous.completionDelivery, ["Delivered"]); return { - deliveries: mailbox.deliveries.map(({ status, key, digest }) => ({ status, key, digest })), + deliveries: mailbox.deliveries.map(({ status, key, digest }: Row) => ({ + status, + key, + digest, + })), }; }, }; diff --git a/tools/e2e/golden-reimbursement-evidence.mjs b/tools/e2e/golden-reimbursement-evidence.mjs deleted file mode 100644 index d14f50b7e..000000000 --- a/tools/e2e/golden-reimbursement-evidence.mjs +++ /dev/null @@ -1,268 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile, stat } from "node:fs/promises"; -import { join, relative, isAbsolute } from "node:path"; -import { sha256 } from "./golden-school-service-evidence.mjs"; - -export const reimbursementSteps = [ - "initial", - "submitted", - "submission-replay", - "private-denials", - "restart-custody", - "approval-failed-delivery", - "approval-replay-stale", - "settlement-authority-denial", - "unattended-recovery", - "settled", - "settlement-replay-stale", - "fresh-owner", -]; - -export const receiptBytes = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", - "base64", -); - -export const fixture = { - departmentId: "reimbursement-department", - foreignDepartmentId: "reimbursement-foreign", - description: "Synthetic bus ticket reimbursement", - amountOre: 12550, - receiptDate: "2026-09-20", - externalAuthority: "Synthetic external ledger", - externalReference: "reimbursement-external-001", - settledAt: "2026-09-21T10:00:00.000Z", -}; - -export const people = (password) => - Object.fromEntries( - ["owner", "other", "approver", "finance", "wrongScope"].map((role) => [ - role, - { - personId: `reimbursement-${role}`, - firstName: "Synthetic", - lastName: role, - email: `reimbursement.${role.toLowerCase()}@example.invalid`, - password, - }, - ]), - ); - -// Prerequisites only. Every receipt, decision, settlement and delivered notification is runtime work. -export const seedReimbursement = async (pool, persons) => { - for (const [department, label] of [ - [fixture.departmentId, "R"], - [fixture.foreignDepartmentId, "F"], - ]) { - await pool.query( - "INSERT INTO organization_departments(department_id,name,short_name,email,city,active,revision) VALUES($1,$1,$2,$3,'Trondheim',true,0)", - [department, label, `${label.toLowerCase()}@example.invalid`], - ); - await pool.query( - "INSERT INTO organization_teams(team_id,department_id,name,active,revision) VALUES($1,$2,'Synthetic team',true,0)", - [`${department}-team`, department], - ); - } - - for (const [role, person] of Object.entries(persons)) { - await pool.query( - "INSERT INTO person_contact_profiles(person_id,email,phone,revision) VALUES($1,$2,'90000000',0)", - [person.personId, person.email], - ); - const department = role === "wrongScope" ? fixture.foreignDepartmentId : fixture.departmentId; - await pool.query( - "INSERT INTO organization_memberships(membership_id,person_id,team_id,start_at,is_team_leader,is_suspended,revision) VALUES($1,$2,$3,'2020-01-01',false,false,0)", - [`${person.personId}-membership`, person.personId, `${department}-team`], - ); - } - - await pool.query( - "INSERT INTO economy_payment_authorities(payment_authority_id,person_id,department_id,payment_account_ciphertext,start_at,revision) VALUES('reimbursement-payment',$1,$2,'synthetic:private-destination','2020-01-01',0)", - [persons.owner.personId, fixture.departmentId], - ); - - for (const role of ["approver", "wrongScope"]) { - await pool.query( - "INSERT INTO economy_receipt_approval_grants(approval_grant_id,person_id,scope,department_id,start_at,revision) VALUES($1,$2,'Department',$3,'2020-01-01',0)", - [ - `${role}-approval`, - persons[role].personId, - role === "approver" ? fixture.departmentId : fixture.foreignDepartmentId, - ], - ); - } - - await pool.query( - "INSERT INTO economy_receipt_settlement_grants(settlement_grant_id,person_id,scope,department_id,start_at,revision) VALUES('reimbursement-settlement',$1,'Department',$2,'2020-01-01',0)", - [persons.finance.personId, fixture.departmentId], - ); -}; - -export const createReimbursementObserver = (pool, committedRoot, persons) => { - const observations = []; - let receiptId; - let prior; - - const read = async () => { - const connection = await pool.connect(); - - try { - await connection.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY"); - - const receipts = ( - await connection.query( - `SELECT receipt_id, visual_id, owner_person_id, department_id, amount_ore::text, currency, description, receipt_date::text, status, revision, file_ref, file_object_key, file_content_type, file_byte_length::int, file_sha256 FROM economy_receipts ORDER BY receipt_id`, - ) - ).rows; - - const audit = ( - await connection.query( - "SELECT command_id,receipt_id,actor_person_id,action,receipt_revision FROM economy_receipt_audit ORDER BY receipt_revision,command_id", - ) - ).rows; - - const commands = ( - await connection.query( - "SELECT command_id,receipt_id,command_sha256 FROM economy_receipt_command_receipts ORDER BY command_id", - ) - ).rows; - - const settlements = ( - await connection.query( - "SELECT settlement_id,receipt_id,amount_ore::text,currency,external_authority,external_reference,to_char(settled_at AT TIME ZONE 'UTC','YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"') AS settled_at,recorded_by_person_id,receipt_revision,payment_destination_fingerprint FROM economy_receipt_settlements ORDER BY settlement_id", - ) - ).rows; - - const outbox = ( - await connection.query( - "SELECT effect_id,effect_type,receipt_id,command_id,ordinal,payload_json,status,attempts,last_failure_tag,delivery_envelope FROM economy_receipt_outbox ORDER BY command_id,ordinal", - ) - ).rows; - - await connection.query("COMMIT"); - - return { receipts, audit, commands, settlements, outbox }; - } catch (error) { - await connection.query("ROLLBACK"); - throw error; - } finally { - connection.release(); - } - }; - - const immutable = (facts) => ({ - ...facts, - outbox: facts.outbox.map( - ({ - status: _status, - attempts: _attempts, - last_failure_tag: _lastFailure, - delivery_envelope: _envelope, - ...row - }) => row, - ), - }); - - const checkpoint = async (step, binding = {}) => { - assert.equal(step, reimbursementSteps[observations.length], "checkpoint order"); - const facts = await read(); - const index = reimbursementSteps.indexOf(step); - - if (index === 0) { - assert.deepEqual(facts, { - receipts: [], - audit: [], - commands: [], - settlements: [], - outbox: [], - }); - } else { - assert.equal(facts.receipts.length, 1); - const row = facts.receipts[0]; - receiptId ??= binding.receiptId; - assert.equal(row.receipt_id, receiptId); - assert.equal(row.owner_person_id, persons.owner.personId); - assert.equal(row.department_id, fixture.departmentId); - assert.equal(row.description, fixture.description); - assert.equal(row.amount_ore, String(fixture.amountOre)); - assert.equal(row.currency, "NOK"); - assert.equal(row.receipt_date, fixture.receiptDate); - const approved = index >= reimbursementSteps.indexOf("approval-failed-delivery"); - const settled = index >= reimbursementSteps.indexOf("settled"); - assert.equal(row.status, approved ? "Approved" : "Pending"); - assert.equal(row.revision, settled ? 2 : approved ? 1 : 0); - assert.equal(facts.commands.length, settled ? 3 : approved ? 2 : 1); - assert.deepEqual( - facts.audit.map(({ actor_person_id, action }) => [actor_person_id, action]), - [ - [persons.owner.personId, "ReceiptSubmitted"], - ...(approved ? [[persons.approver.personId, "ReceiptApproved"]] : []), - ...(settled ? [[persons.finance.personId, "ReceiptSettled"]] : []), - ], - ); - assert.equal(facts.settlements.length, settled ? 1 : 0); - - if (settled) { - assert.deepEqual(facts.settlements[0], { - settlement_id: binding.settlementId ?? prior.settlements[0]?.settlement_id, - receipt_id: receiptId, - amount_ore: String(fixture.amountOre), - currency: "NOK", - external_authority: fixture.externalAuthority, - external_reference: fixture.externalReference, - settled_at: fixture.settledAt, - recorded_by_person_id: persons.finance.personId, - receipt_revision: 2, - payment_destination_fingerprint: sha256("synthetic:private-destination"), - }); - } - - if (!["submitted", "approval-failed-delivery", "settled"].includes(step)) - assert.deepEqual( - immutable(facts), - immutable(prior), - `${step} cannot change committed business facts`, - ); - const path = join(committedRoot, row.file_object_key); - assert.ok( - !isAbsolute(relative(committedRoot, path)) && - !relative(committedRoot, path).startsWith(".."), - ); - const bytes = await readFile(path); - assert.deepEqual(bytes, receiptBytes); - assert.equal(row.file_sha256, sha256(bytes)); - assert.equal(row.file_byte_length, bytes.length); - assert.equal((await stat(path)).isFile(), true); - } - - observations.push({ - step, - facts: { - ...facts, - outbox: facts.outbox.map(({ payload_json, delivery_envelope, ...row }) => ({ - ...row, - payloadSha256: sha256(JSON.stringify(payload_json)), - envelopeSha256: - delivery_envelope === null ? null : sha256(JSON.stringify(delivery_envelope)), - })), - }, - }); - prior = facts; - - return facts; - }; - - return { - read, - checkpoint, - observations, - finish: () => { - assert.deepEqual( - observations.map(({ step }) => step), - reimbursementSteps, - ); - - return observations; - }, - }; -}; diff --git a/tools/e2e/golden-reimbursement-evidence.ts b/tools/e2e/golden-reimbursement-evidence.ts new file mode 100644 index 000000000..107b9494e --- /dev/null +++ b/tools/e2e/golden-reimbursement-evidence.ts @@ -0,0 +1,367 @@ +/** + * Fixture, seed, and independent PostgreSQL observer of the golden reimbursement journey. + * + * Every read runs in one REPEATABLE READ snapshot through a pool that the runner owns, and each + * checkpoint asserts the committed business facts that its step allows. + */ +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import { sha256Hex } from "@vektorprogrammet/domain/shared-kernel"; +import { Effect, FileSystem, Path, Schema } from "effect"; +import type { Pool } from "pg"; +import { failure, type HarnessFailure, readOnlySnapshot, selectRows } from "./golden-harness"; + +export const reimbursementSteps: ReadonlyArray = [ + "initial", + "submitted", + "submission-replay", + "private-denials", + "restart-custody", + "approval-failed-delivery", + "approval-replay-stale", + "settlement-authority-denial", + "unattended-recovery", + "settled", + "settlement-replay-stale", + "fresh-owner", +]; + +export const receiptBytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", +); + +export const fixture = { + departmentId: "reimbursement-department", + foreignDepartmentId: "reimbursement-foreign", + description: "Synthetic bus ticket reimbursement", + amountOre: 12550, + receiptDate: "2026-09-20", + externalAuthority: "Synthetic external ledger", + externalReference: "reimbursement-external-001", + settledAt: "2026-09-21T10:00:00.000Z", +} as const; + +export type Role = "owner" | "other" | "approver" | "finance" | "wrongScope"; + +export interface Person { + readonly personId: string; + readonly firstName: string; + readonly lastName: string; + readonly email: string; + readonly password: string; +} + +export type Persons = { readonly [role in Role]: Person }; + +const person = (role: Role, password: string): Person => ({ + personId: `reimbursement-${role}`, + firstName: "Synthetic", + lastName: role, + email: `reimbursement.${role.toLowerCase()}@example.invalid`, + password, +}); + +export const people = (password: string): Persons => ({ + owner: person("owner", password), + other: person("other", password), + approver: person("approver", password), + finance: person("finance", password), + wrongScope: person("wrongScope", password), +}); + +/** The SHA-256 digest of a text's UTF-8 bytes. */ +export const digestText = (text: string): string => sha256Hex(new TextEncoder().encode(text)); + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +/** The JSON text of a value, byte for byte what `JSON.stringify` writes. */ +export const jsonText = (value: A): string => encodeJson(value); + +const Row = Schema.Record(Schema.String, Schema.Json); + +export type Row = typeof Row.Type; + +export interface ReimbursementFacts { + readonly receipts: ReadonlyArray; + readonly audit: ReadonlyArray; + readonly commands: ReadonlyArray; + readonly settlements: ReadonlyArray; + readonly outbox: ReadonlyArray; +} + +export interface Binding { + readonly receiptId?: string | undefined; + readonly settlementId?: string | undefined; +} + +const query = (pool: Pool, text: string, values: ReadonlyArray) => + Effect.tryPromise({ try: () => pool.query(text, [...values]), catch: failure("seed") }); + +/** Prerequisites only. Every receipt, decision, settlement and delivered notification is runtime work. */ +export const seedReimbursement = ({ + pool, + persons, +}: { + readonly pool: Pool; + readonly persons: Persons; +}): Effect.Effect => + Effect.gen(function* () { + for (const [department, label] of [ + [fixture.departmentId, "R"], + [fixture.foreignDepartmentId, "F"], + ] as const) { + yield* query( + pool, + "INSERT INTO organization_departments(department_id,name,short_name,email,city,active,revision) VALUES($1,$1,$2,$3,'Trondheim',true,0)", + [department, label, `${label.toLowerCase()}@example.invalid`], + ); + yield* query( + pool, + "INSERT INTO organization_teams(team_id,department_id,name,active,revision) VALUES($1,$2,'Synthetic team',true,0)", + [`${department}-team`, department], + ); + } + + for (const [role, member] of Object.entries(persons)) { + yield* query( + pool, + "INSERT INTO person_contact_profiles(person_id,email,phone,revision) VALUES($1,$2,'90000000',0)", + [member.personId, member.email], + ); + + const department = role === "wrongScope" ? fixture.foreignDepartmentId : fixture.departmentId; + + yield* query( + pool, + "INSERT INTO organization_memberships(membership_id,person_id,team_id,start_at,is_team_leader,is_suspended,revision) VALUES($1,$2,$3,'2020-01-01',false,false,0)", + [`${member.personId}-membership`, member.personId, `${department}-team`], + ); + } + + yield* query( + pool, + "INSERT INTO economy_payment_authorities(payment_authority_id,person_id,department_id,payment_account_ciphertext,start_at,revision) VALUES('reimbursement-payment',$1,$2,'synthetic:private-destination','2020-01-01',0)", + [persons.owner.personId, fixture.departmentId], + ); + + for (const role of ["approver", "wrongScope"] as const) { + yield* query( + pool, + "INSERT INTO economy_receipt_approval_grants(approval_grant_id,person_id,scope,department_id,start_at,revision) VALUES($1,$2,'Department',$3,'2020-01-01',0)", + [ + `${role}-approval`, + persons[role].personId, + role === "approver" ? fixture.departmentId : fixture.foreignDepartmentId, + ], + ); + } + + yield* query( + pool, + "INSERT INTO economy_receipt_settlement_grants(settlement_grant_id,person_id,scope,department_id,start_at,revision) VALUES('reimbursement-settlement',$1,'Department',$2,'2020-01-01',0)", + [persons.finance.personId, fixture.departmentId], + ); + }); + +export interface Observation { + readonly step: string; + readonly facts: ReimbursementFacts; +} + +export interface ReimbursementObserver { + readonly read: Effect.Effect; + readonly checkpoint: ( + step: string, + binding?: Binding, + ) => Effect.Effect; + readonly observations: ReadonlyArray; + readonly finish: Effect.Effect>; +} + +const immutable = (facts: ReimbursementFacts) => ({ + ...facts, + outbox: facts.outbox.map( + ({ + status: _status, + attempts: _attempts, + last_failure_tag: _lastFailure, + delivery_envelope: _envelope, + ...row + }) => row, + ), +}); + +/** The outbox rows of an observation, with their payload and envelope digested. */ +export const digestOutbox = (outbox: ReadonlyArray): ReadonlyArray => + outbox.map(({ payload_json, delivery_envelope, ...row }) => ({ + ...row, + payloadSha256: digestText(jsonText(payload_json)), + envelopeSha256: + delivery_envelope === null || delivery_envelope === undefined + ? null + : digestText(jsonText(delivery_envelope)), + })); + +export const createReimbursementObserver = ({ + pool, + committedRoot, + persons, +}: { + readonly pool: Pool; + readonly committedRoot: string; + readonly persons: Persons; +}): ReimbursementObserver => { + const observations: Array = []; + let receiptId: string | undefined; + let prior: ReimbursementFacts | undefined; + + const read = readOnlySnapshot(pool, (client) => + Effect.all({ + receipts: selectRows( + client, + "receipts", + `SELECT receipt_id, visual_id, owner_person_id, department_id, amount_ore::text, currency, description, receipt_date::text, status, revision, file_ref, file_object_key, file_content_type, file_byte_length::int, file_sha256 FROM economy_receipts ORDER BY receipt_id`, + [], + Row, + ), + audit: selectRows( + client, + "audit", + "SELECT command_id,receipt_id,actor_person_id,action,receipt_revision FROM economy_receipt_audit ORDER BY receipt_revision,command_id", + [], + Row, + ), + commands: selectRows( + client, + "commands", + "SELECT command_id,receipt_id,command_sha256 FROM economy_receipt_command_receipts ORDER BY command_id", + [], + Row, + ), + settlements: selectRows( + client, + "settlements", + "SELECT settlement_id,receipt_id,amount_ore::text,currency,external_authority,external_reference,to_char(settled_at AT TIME ZONE 'UTC','YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"') AS settled_at,recorded_by_person_id,receipt_revision,payment_destination_fingerprint FROM economy_receipt_settlements ORDER BY settlement_id", + [], + Row, + ), + outbox: selectRows( + client, + "outbox", + "SELECT effect_id,effect_type,receipt_id,command_id,ordinal,payload_json,status,attempts,last_failure_tag,delivery_envelope FROM economy_receipt_outbox ORDER BY command_id,ordinal", + [], + Row, + ), + }), + ); + + const checkpoint = Effect.fnUntraced(function* (step: string, binding: Binding = {}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + assert.equal(step, reimbursementSteps[observations.length], "checkpoint order"); + + const facts = yield* read; + const index = reimbursementSteps.indexOf(step); + + if (index === 0) { + assert.deepEqual(facts, { + receipts: [], + audit: [], + commands: [], + settlements: [], + outbox: [], + }); + } else { + assert.equal(facts.receipts.length, 1); + + const row = facts.receipts[0]; + + assert.ok(row !== undefined && prior !== undefined); + receiptId ??= binding.receiptId; + assert.equal(row.receipt_id, receiptId); + assert.equal(row.owner_person_id, persons.owner.personId); + assert.equal(row.department_id, fixture.departmentId); + assert.equal(row.description, fixture.description); + assert.equal(row.amount_ore, String(fixture.amountOre)); + assert.equal(row.currency, "NOK"); + assert.equal(row.receipt_date, fixture.receiptDate); + + const approved = index >= reimbursementSteps.indexOf("approval-failed-delivery"); + const settled = index >= reimbursementSteps.indexOf("settled"); + + assert.equal(row.status, approved ? "Approved" : "Pending"); + assert.equal(row.revision, settled ? 2 : approved ? 1 : 0); + assert.equal(facts.commands.length, settled ? 3 : approved ? 2 : 1); + assert.deepEqual( + facts.audit.map(({ actor_person_id, action }) => [actor_person_id, action]), + [ + [persons.owner.personId, "ReceiptSubmitted"], + ...(approved ? [[persons.approver.personId, "ReceiptApproved"]] : []), + ...(settled ? [[persons.finance.personId, "ReceiptSettled"]] : []), + ], + ); + assert.equal(facts.settlements.length, settled ? 1 : 0); + + if (settled) { + assert.deepEqual(facts.settlements[0], { + settlement_id: binding.settlementId ?? prior.settlements[0]?.settlement_id, + receipt_id: receiptId, + amount_ore: String(fixture.amountOre), + currency: "NOK", + external_authority: fixture.externalAuthority, + external_reference: fixture.externalReference, + settled_at: fixture.settledAt, + recorded_by_person_id: persons.finance.personId, + receipt_revision: 2, + payment_destination_fingerprint: digestText("synthetic:private-destination"), + }); + } + + if (!["submitted", "approval-failed-delivery", "settled"].includes(step)) + assert.deepEqual( + immutable(facts), + immutable(prior), + `${step} cannot change committed business facts`, + ); + + const objectKey = yield* Schema.decodeUnknownEffect(Schema.String)(row.file_object_key).pipe( + Effect.mapError(failure(step)), + ); + + const file = path.join(committedRoot, objectKey); + + assert.ok( + !path.isAbsolute(path.relative(committedRoot, file)) && + !path.relative(committedRoot, file).startsWith(".."), + ); + + const bytes = Buffer.from(yield* fs.readFile(file).pipe(Effect.mapError(failure(step)))); + + assert.deepEqual(bytes, receiptBytes); + assert.equal(row.file_sha256, sha256Hex(bytes)); + assert.equal(row.file_byte_length, bytes.length); + assert.equal((yield* fs.stat(file).pipe(Effect.mapError(failure(step)))).type, "File"); + } + + observations.push({ step, facts: { ...facts, outbox: digestOutbox(facts.outbox) } }); + prior = facts; + + return facts; + }); + + return { + read, + checkpoint, + observations, + finish: Effect.sync(() => { + assert.deepEqual( + observations.map(({ step }) => step), + reimbursementSteps, + ); + + return observations; + }), + }; +}; diff --git a/tools/e2e/golden-reimbursement.mjs b/tools/e2e/golden-reimbursement.mjs deleted file mode 100644 index f37a884b2..000000000 --- a/tools/e2e/golden-reimbursement.mjs +++ /dev/null @@ -1,727 +0,0 @@ -import assert from "node:assert/strict"; -import { spawn, execFileSync } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { mkdtemp, mkdir, readFile, writeFile, rm, readdir } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; -import { createRequire } from "node:module"; -import { setTimeout as pause } from "node:timers/promises"; -import { - loopbackPortFree, - postgresVersion, - reserveLoopbackPorts, - startDisposablePostgres, -} from "@monoweb/postgres"; -import { dashboardBuildInventory, sha256 } from "./golden-school-service-evidence.mjs"; -import { - people, - fixture, - seedReimbursement, - createReimbursementObserver, - reimbursementSteps, -} from "./golden-reimbursement-evidence.mjs"; -import { runReimbursementBrowser } from "../../apps/dashboard/e2e/golden-reimbursement-browser.mjs"; - -const root = new URL("../../", import.meta.url).pathname; - -const requireDatabase = createRequire( - new URL("../../packages/database/package.json", import.meta.url), -); - -const { Pool } = requireDatabase("pg"); - -const safeEnvironment = Object.fromEntries( - [ - "PATH", - "HOME", - "TMPDIR", - "LANG", - "LC_ALL", - "TZ", - "LD_LIBRARY_PATH", - "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", - "PLAYWRIGHT_NODE_EXECUTABLE", - "PLAYWRIGHT_BROWSERS_PATH", - ].flatMap((key) => (process.env[key] === undefined ? [] : [[key, process.env[key]]])), -); - -const command = (binary, args) => - execFileSync(binary, args, { - cwd: root, - env: safeEnvironment, - encoding: "utf8", - timeout: 60_000, - stdio: ["ignore", "pipe", "pipe"], - }); - -assert.equal(process.argv.length, 2, "Usage: bun --no-env-file tools/e2e/golden-reimbursement.mjs"); - -assert.equal( - command("git", ["status", "--porcelain"]).trim(), - "", - "requires committed clean source", -); - -const revision = command("git", ["rev-parse", "HEAD"]).trim(); - -const sourceTree = command("git", ["rev-parse", "HEAD^{tree}"]).trim(); - -const artifactRoot = await mkdtemp(join(tmpdir(), "vektor-reimbursement-")); - -const privateRoot = join(artifactRoot, "private"); - -await mkdir(privateRoot, { mode: 0o700 }); - -process.stdout.write(`artifacts: ${artifactRoot}\nrunner-pid: ${process.pid}\n`); - -const password = randomBytes(24).toString("hex"); - -const token = randomBytes(24).toString("hex"); - -const secret = randomBytes(32).toString("hex"); - -const persons = people(password); - -const secrets = [password, token, secret]; - -const sanitize = (value) => - secrets - .reduce((text, secret) => text.replaceAll(secret, "[REDACTED]"), String(value)) - .replace(/(authorization|cookie|set-cookie)([\s"':=]+)[^\r\n,}]+/gi, "$1$2[REDACTED]"); - -const abort = new AbortController(); - -const children = []; - -const ports = []; - -const resourceSnapshots = []; - -const providerAttempts = []; - -let provider; - -let postgres; - -let pool; - -let browser; - -let backend; - -let observer; - -let browserEvidence; - -let failure; - -let interruption; - -let deliveryMode = "accept"; - -let activeDeliveries = 0; - -let maxActiveDeliveries = 0; - -let cleanupPromise; - -const fault = process.env.GOLDEN_REIMBURSEMENT_FAULT; - -assert.ok( - fault === undefined || ["after-submitted", "interrupt-after-submitted"].includes(fault), - "unknown journey fault", -); - -const eventually = async (label, inspect, timeout = 30_000) => { - const end = Date.now() + timeout; - - while (Date.now() < end) { - abort.signal.throwIfAborted(); - - if (await inspect()) return; - await pause(50, undefined, { signal: abort.signal }); - } - - throw new Error(`Timed out: ${label}`); -}; - -const start = (binary, args, env = safeEnvironment, cwd = root) => { - abort.signal.throwIfAborted(); - - const child = spawn(binary, args, { - cwd, - env, - detached: true, - stdio: ["ignore", "pipe", "pipe"], - }); - - const owned = { - child, - label: args.includes("apps/backend/src/main.ts") ? "backend" : basename(binary), - log: "", - }; - - children.push(owned); - - for (const stream of [child.stdout, child.stderr]) - stream.on("data", (bytes) => { - owned.log = (owned.log + sanitize(bytes)).slice(-131_072); - }); - owned.exit = Promise.withResolvers(); - child.once("error", owned.exit.reject); - child.once("close", (code, signal) => owned.exit.resolve({ code, signal })); - - return owned; -}; - -const groupAlive = (owned) => { - if (!owned.child.pid) return false; - - try { - process.kill(-owned.child.pid, 0); - - return true; - } catch (error) { - if (error.code === "ESRCH") return false; - throw error; - } -}; - -const signalGroup = (owned, signal) => { - if (!owned.child.pid) return; - - try { - process.kill(-owned.child.pid, signal); - } catch (error) { - if (error.code !== "ESRCH") throw error; - } -}; - -// Drain the owned group even when its leader exited, as the school-service CI wrapper does. -const stop = async (owned) => { - if (!owned) return; - signalGroup(owned, "SIGTERM"); - - for (let count = 0; count < 50 && groupAlive(owned); count++) await pause(100); - - if (groupAlive(owned)) signalGroup(owned, "SIGKILL"); - - for (let count = 0; count < 50 && groupAlive(owned); count++) await pause(100); - assert.equal(groupAlive(owned), false, "owned process group survived cleanup"); -}; - -const run = async (binary, args, env = safeEnvironment, cwd = root, timeout = 120_000) => { - const owned = start(binary, args, env, cwd); - const expired = Promise.withResolvers(); - const timer = setTimeout(() => expired.reject(new Error(`${binary} deadline exceeded`)), timeout); - - try { - const result = await Promise.race([owned.exit.promise, expired.promise]); - assert.equal(result.code, 0, `${binary} failed: ${owned.log.slice(-6000)}`); - - return owned.log; - } finally { - clearTimeout(timer); - await stop(owned); - } -}; - -const sampleResources = (step) => { - const rows = command("ps", ["-eo", "pid=,ppid=,pgid=,rss=,stat=,comm="]) - .trim() - .split("\n") - .map((line) => { - const [pid, ppid, pgid, rssKiB, state, ...name] = line.trim().split(/\s+/); - - return { - pid: Number(pid), - ppid: Number(ppid), - pgid: Number(pgid), - rssKiB: Number(rssKiB), - state, - name: name.join(" "), - }; - }); - - const ownedIds = new Set([process.pid]); - - for (let count = 0; count < 16; count++) { - const before = ownedIds.size; - - for (const row of rows) if (ownedIds.has(row.ppid)) ownedIds.add(row.pid); - - if (before === ownedIds.size) break; - } - - const processes = rows.filter((row) => ownedIds.has(row.pid) && row.name !== "ps"); - resourceSnapshots.push({ - step, - observedAt: new Date().toISOString(), - scope: "runner and live descendants; point-in-time RSS, not a peak", - processes, - processCount: processes.length, - totalRssKiB: processes.reduce((sum, row) => sum + row.rssKiB, 0), - }); -}; - -const waitHttp = (url, owned) => - eventually( - `HTTP ${new URL(url).pathname}`, - async () => { - if (owned.child.exitCode !== null || owned.child.signalCode !== null) - throw new Error(`runtime exited: ${owned.log.slice(-6000)}`); - - try { - const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); - await response.body?.cancel(); - - return response.ok; - } catch { - return false; - } - }, - 60_000, - ); - -const onSignal = (signal) => { - interruption ??= signal; - failure ??= `Interrupted: ${signal}`; - abort.abort(new Error(failure)); - void browser?.close().catch(() => {}); - - for (const owned of children) signalGroup(owned, "SIGTERM"); -}; - -process.on("SIGINT", () => onSignal("SIGINT")); - -process.on("SIGTERM", () => onSignal("SIGTERM")); - -const deadline = setTimeout(() => onSignal("15-minute journey deadline"), 900_000); - -const cleanup = () => - (cleanupPromise ??= (async () => { - const errors = []; - - try { - await browser?.close(); - } catch (error) { - errors.push(sanitize(error)); - } - - try { - await provider?.stop(true); - } catch (error) { - errors.push(sanitize(error)); - } - - try { - await pool?.end(); - } catch (error) { - errors.push(sanitize(error)); - } - - for (const owned of [...children].reverse()) { - try { - await stop(owned); - } catch (error) { - errors.push(sanitize(error)); - } - } - - try { - await postgres?.stop(); - } catch (error) { - errors.push(sanitize(error)); - } - - for (const port of ports) - if (!(await loopbackPortFree(port))) errors.push(`owned listener ${port} remains`); - - const groupsDrained = children.every((owned) => !groupAlive(owned)); - - if (groupsDrained && errors.length === 0) - await rm(privateRoot, { recursive: true, force: true }); - sampleResources("after-cleanup"); - clearTimeout(deadline); - - return { - groupsDrained, - listenersReleased: errors.length === 0, - privateResourcesRemoved: groupsDrained && errors.length === 0, - ports: [...ports], - processes: children.map(({ child, label }) => ({ - pid: child.pid, - label, - exitCode: child.exitCode, - signal: child.signalCode, - })), - errors, - }; - })()); - -let sources = []; - -let build; - -try { - sampleResources("baseline"); - - const sourcePaths = command("git", [ - "ls-files", - "-z", - "apps/backend", - "apps/dashboard", - "packages/domain", - "packages/database", - "packages/http-api", - "packages/sdk", - "tools/e2e", - ]) - .split("\0") - .filter(Boolean); - - for (const path of sourcePaths) - sources.push({ path, sha256: sha256(await readFile(join(root, path))) }); - - const [pgPort, apiPort, dashboardPort, providerPort] = await reserveLoopbackPorts(4); - - ports.push(pgPort, apiPort, dashboardPort, providerPort); - - const origins = { - backend: `http://127.0.0.1:${apiPort}`, - dashboard: `http://127.0.0.1:${dashboardPort}`, - }; - - const postgresUrl = `postgres://postgres@127.0.0.1:${pgPort}/postgres`; - postgres = await startDisposablePostgres({ - port: pgPort, - maxConnections: 16, - environment: safeEnvironment, - }); - pool = new Pool({ - connectionString: postgresUrl, - max: 2, - connectionTimeoutMillis: 1000, - statement_timeout: 10_000, - application_name: "reimbursement-independent-observer", - }); - provider = Bun.serve({ - hostname: "127.0.0.1", - port: providerPort, - maxRequestBodySize: 65_536, - async fetch(request) { - activeDeliveries++; - maxActiveDeliveries = Math.max(maxActiveDeliveries, activeDeliveries); - const startedAt = Date.now(); - let attempt; - - try { - assert.equal(request.headers.get("authorization"), `Bearer ${token}`); - const body = Buffer.from(await request.arrayBuffer()); - assert.ok(body.length <= 65_536, "bounded synthetic provider envelope"); - const envelope = JSON.parse(body); - assert.equal(request.headers.get("idempotency-key"), envelope.deliveryId); - attempt = { - deliveryId: envelope.deliveryId, - envelopeSha256: sha256(body), - startedAt: new Date(startedAt).toISOString(), - abortedAt: null, - finishedAt: null, - status: null, - elapsedMs: null, - }; - providerAttempts.push(attempt); - request.signal.addEventListener( - "abort", - () => { - if (attempt.status === null) attempt.abortedAt = new Date().toISOString(); - }, - { once: true }, - ); - - if (deliveryMode === "timeout") await pause(2000, undefined, { signal: request.signal }); - attempt.status = 204; - - return new Response(null, { status: 204 }); - } catch (error) { - if (attempt) attempt.failure = sanitize(error); - - return new Response(null, { status: 503 }); - } finally { - if (attempt) { - attempt.elapsedMs = Date.now() - startedAt; - attempt.finishedAt = new Date().toISOString(); - } - - activeDeliveries--; - } - }, - }); - - const backendEnvironment = { - ...safeEnvironment, - BACKEND_HOST: "127.0.0.1", - BACKEND_PORT: String(apiPort), - BACKEND_PG_URL: postgresUrl, - BETTER_AUTH_SECRET: secret, - NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify([origins.dashboard]), - OAUTH_CANONICAL_ORIGIN: origins.backend, - OAUTH_DASHBOARD_ORIGIN: origins.dashboard, - OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - PUBLIC_APPLICATION_EFFECT_MODE: "disabled", - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_MODE: "http", - RECEIPT_DELIVERY_POLL_MS: "250", - RECEIPT_STAGING_ROOT: join(privateRoot, "staging"), - RECEIPT_COMMITTED_ROOT: join(privateRoot, "committed"), - RECEIPT_MAX_FILE_BYTES: "10485760", - RECEIPT_DELIVERY_URL: `http://127.0.0.1:${providerPort}/receipt`, - RECEIPT_DELIVERY_TOKEN: token, - RECEIPT_DELIVERY_TIMEOUT_MS: "500", - RECEIPT_DELIVERY_SENDER: "finance@example.invalid", - RECEIPT_DELIVERY_ECONOMY_RECIPIENTS: JSON.stringify({ - [fixture.departmentId]: "finance@example.invalid", - }), - }; - - await run("bun", ["--no-env-file", "run", "--cwd", "packages/database", "identity:seed"], { - ...backendEnvironment, - IDENTITY_SEED_PG_URL: postgresUrl, - IDENTITY_SEED_PERSONS: JSON.stringify(Object.values(persons)), - }); - await seedReimbursement(pool, persons); - - const boot = async () => { - const environment = { ...backendEnvironment }; - - if (environment.RECEIPT_DELIVERY_MODE === "disabled") { - for (const key of [ - "RECEIPT_DELIVERY_URL", - "RECEIPT_DELIVERY_TOKEN", - "RECEIPT_DELIVERY_TIMEOUT_MS", - "RECEIPT_DELIVERY_SENDER", - "RECEIPT_DELIVERY_ECONOMY_RECIPIENTS", - ]) - delete environment[key]; - } - - backend = start("bun", ["--no-env-file", "apps/backend/src/main.ts"], environment); - await waitHttp(origins.backend + "/health", backend); - }; - - await boot(); - - const dashboardEnvironment = { - ...safeEnvironment, - API_URL: origins.backend, - VITE_API_URL: origins.dashboard, - DASHBOARD_ORIGIN: origins.dashboard, - DASHBOARD_MOUNT: "/dashboard/", - HOST: "127.0.0.1", - PORT: String(dashboardPort), - NODE_ENV: "production", - REAL_NATIVE_IDENTITY_E2E: "1", - }; - - await run("bun", ["--no-env-file", "run", "--cwd", "packages/sdk", "build"]); - const dashboardRoot = join(root, "apps/dashboard"); - await run("bun", ["--no-env-file", "run", "build"], dashboardEnvironment, dashboardRoot, 300_000); - const files = await dashboardBuildInventory(root); - build = { revision, sourceTree, digest: "sha256:" + sha256(JSON.stringify(files)), files }; - - const dashboard = start( - "bun", - ["--no-env-file", "server.mjs"], - dashboardEnvironment, - dashboardRoot, - ); - - await waitHttp(origins.dashboard + "/dashboard/login", dashboard); - observer = createReimbursementObserver(pool, backendEnvironment.RECEIPT_COMMITTED_ROOT, persons); - - const checkpoint = async (step, binding) => { - const facts = await observer.checkpoint(step, binding); - sampleResources(step); - process.stdout.write(`checkpoint: ${step}\n`); - - return facts; - }; - - await checkpoint("initial"); - browserEvidence = await runReimbursementBrowser({ - origins, - persons, - artifacts: artifactRoot, - checkpoint, - eventually, - readFacts: observer.read, - restart: async (mode = "http") => { - await stop(backend); - backendEnvironment.RECEIPT_DELIVERY_MODE = mode; - await boot(); - sampleResources("native-restart"); - }, - providerMode: (mode) => { - deliveryMode = mode; - }, - fault, - browserReady: (value) => { - browser = value; - }, - }); - observer.finish(); - sampleResources("bounds-complete"); - const postProbeFacts = await observer.read(); - await writeFile( - join(artifactRoot, "bounds-evidence.json"), - JSON.stringify( - { - checks: browserEvidence.checks.filter(({ kind }) => - [ - "intake", - "concurrent-disabled-worker", - "bounded-collection", - "disabled-worker-recovery", - "pagination-ui", - ].includes(kind), - ), - facts: { - ...postProbeFacts, - outbox: postProbeFacts.outbox.map(({ payload_json, delivery_envelope, ...row }) => ({ - ...row, - payloadSha256: sha256(JSON.stringify(payload_json)), - envelopeSha256: - delivery_envelope === null ? null : sha256(JSON.stringify(delivery_envelope)), - })), - }, - }, - null, - 2, - ), - { mode: 0o600 }, - ); - assert.equal(maxActiveDeliveries, 1, "fixed active loopback request concurrency"); - const retried = providerAttempts.filter(({ status }) => status === null); - assert.ok(retried.length > 0, "notification provider deadline observed"); - - for (const attempt of retried) { - assert.notEqual(attempt.abortedAt, null, "timed-out request cancelled the fixture transport"); - assert.ok( - attempt.elapsedMs >= 250 && attempt.elapsedMs < 2000, - "bounded provider deadline interrupted the blocked transport", - ); - assert.ok( - providerAttempts.some( - (candidate) => - candidate.deliveryId === attempt.deliveryId && - candidate.envelopeSha256 === attempt.envelopeSha256 && - candidate.status === 204, - ), - "failed immutable envelope recovered unattended", - ); - } - - assert.equal(command("git", ["rev-parse", "HEAD"]).trim(), revision); - assert.equal( - command("git", ["status", "--porcelain"]).trim(), - "", - "source changed during acceptance", - ); - - for (const source of sources) - assert.equal( - sha256(await readFile(join(root, source.path))), - source.sha256, - "source bytes changed during acceptance", - ); -} catch (error) { - failure ??= sanitize(error instanceof Error ? (error.stack ?? error.message) : error); -} - -const cleaned = await cleanup(); - -if (cleaned.errors.length) failure ??= "Resource cleanup failed"; - -const evidence = { - passed: failure === undefined, - revision, - sourceTree, - cleanSource: true, - environment: "local_disposable", - failure: failure ?? null, - interruption: interruption ?? null, - fault: fault ?? null, - observations: observer?.observations ?? [], - browser: browserEvidence ?? null, - delivery: { - scope: - "Active loopback requests; this fixture cancels work on request abort. No remote execution bound is implied.", - maxActive: maxActiveDeliveries, - attempts: providerAttempts, - }, - resources: resourceSnapshots, - cleanup: cleaned, -}; - -await writeFile(join(artifactRoot, "evidence.json"), sanitize(JSON.stringify(evidence, null, 2)), { - mode: 0o600, -}); - -await writeFile( - join(artifactRoot, "source-manifest.json"), - JSON.stringify({ revision, sourceTree, sources }, null, 2), - { mode: 0o600 }, -); - -if (build) - await writeFile(join(artifactRoot, "browser-build.json"), JSON.stringify(build, null, 2), { - mode: 0o600, - }); - -if (failure) - await writeFile( - join(artifactRoot, "failure.log"), - sanitize(children.map(({ label, log }) => `${label}\n${log}`).join("\n")), - { mode: 0o600 }, - ); - -const artifacts = []; - -for (const name of (await readdir(artifactRoot)).sort()) { - if (name === "private") continue; - const bytes = await readFile(join(artifactRoot, name)); - artifacts.push({ path: name, bytes: bytes.length, sha256: sha256(bytes) }); -} - -const receipt = { - schema_version: "native-functional-journey/v1", - journey_ref_id: "intent://golden-reimbursement", - mono_revision_ref_id: "rev-" + revision, - source_tree: sourceTree, - clean_source: true, - environment_kind: "local_disposable", - result: failure === undefined ? "passed" : "failed", - exit_code: failure === undefined ? 0 : 1, - termination_signal: interruption ?? null, - required_browser: true, - step_ids: observer?.observations.map(({ step }) => step) ?? [], - required_step_ids: reimbursementSteps, - artifact_digest: "sha256:" + sha256(JSON.stringify(artifacts)), - artifacts, - runtime: { - bun: process.versions.bun, - node: command("node", ["--version"]).trim(), - postgres: postgresVersion(), - }, -}; - -await writeFile(join(artifactRoot, "receipt.json"), JSON.stringify(receipt, null, 2), { - mode: 0o600, -}); - -process.stdout.write( - `result: ${receipt.result}\nevidence: ${join(artifactRoot, "receipt.json")}\n`, -); - -process.exitCode = receipt.exit_code; diff --git a/tools/e2e/golden-reimbursement.ts b/tools/e2e/golden-reimbursement.ts new file mode 100644 index 000000000..2f6c2aeae --- /dev/null +++ b/tools/e2e/golden-reimbursement.ts @@ -0,0 +1,998 @@ +/** + * Golden reimbursement journey: submission, private denials, restart custody, approval with a + * failed notification delivery, settlement, unattended recovery, and intake bounds. + * + * Boots a private PostgreSQL cluster, a loopback notification provider, the native Bun backend, + * and the built dashboard on private loopback ports, then drives one browser journey whose + * checkpoints an independent REPEATABLE READ observer asserts. The runner writes the evidence, + * source manifest, and `native-functional-journey/v1` receipt, and exits 0 when it passed and 1 + * otherwise, also after SIGINT, SIGTERM, or the 15-minute deadline. + * + * Usage: bun --no-env-file tools/e2e/golden-reimbursement.ts + * Faults: GOLDEN_REIMBURSEMENT_FAULT=after-submitted|interrupt-after-submitted + */ +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import { randomBytes } from "node:crypto"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import { + type DisposablePostgres, + loopbackPortFree, + postgresVersion, + reserveLoopbackPorts, + startDisposablePostgres, +} from "@monoweb/postgres"; +import { sha256Hex } from "@vektorprogrammet/domain/shared-kernel"; +import { + Cause, + Clock, + Config, + DateTime, + Deferred, + Duration, + Effect, + Exit, + FiberSet, + FileSystem, + Layer, + Option, + Path, + type PlatformError, + Schema, + Scope, + Stream, +} from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import { ChildProcess, type ChildProcessSpawner } from "effect/unstable/process"; +import { Pool } from "pg"; +import { + type ReimbursementBrowserEvidence, + runReimbursementBrowser, +} from "../../apps/dashboard/e2e/golden-reimbursement-browser.mjs"; +import { + createReimbursementObserver, + digestOutbox, + digestText, + fixture, + jsonText, + people, + type Binding, + type ReimbursementObserver, + reimbursementSteps, + seedReimbursement, +} from "./golden-reimbursement-evidence"; +import { + describeCause, + eventually, + failure, + HarnessFailure, + inspectSource, + safeEnvironment, + type SourceSnapshot, + waitForHttp, +} from "./golden-harness"; + +const faults = ["after-submitted", "interrupt-after-submitted"]; + +const sourcePaths = [ + "apps/backend", + "apps/dashboard", + "packages/domain", + "packages/database", + "packages/rpc", + "tools/e2e", +]; + +/** The indented JSON text of an artifact, as `JSON.stringify(value, null, 2)` writes it. */ +const prettyJsonText = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown, { space: 2 })); + +const DeliveryEnvelope = Schema.fromJsonString(Schema.Struct({ deliveryId: Schema.String })); + +type Environment = Readonly>; + +type ProcessExit = { readonly code: number | null; readonly signal: string | null }; + +interface OwnedProcess { + readonly label: string; + readonly pid: number; + readonly scope: Scope.Closeable; + readonly exited: Deferred.Deferred; + readonly isRunning: Effect.Effect; + readonly logTail: Effect.Effect; + log: string; + exit: ProcessExit | undefined; +} + +interface DeliveryAttempt { + readonly deliveryId: string; + readonly envelopeSha256: string; + readonly startedAt: string; + abortedAt: string | null; + finishedAt: string | null; + status: number | null; + elapsedMs: number | null; + failure?: string; +} + +interface ProcessRow { + readonly pid: number; + readonly ppid: number; + readonly pgid: number; + readonly rssKiB: number; + readonly state: string | undefined; + readonly name: string; +} + +const isoNow = Effect.map(DateTime.now, DateTime.formatIso); + +const signalOf = (exit: Exit.Exit): ProcessExit => { + if (Exit.isSuccess(exit)) return { code: exit.value, signal: null }; + + const error = Cause.squash(exit.cause); + const message = error instanceof Error && error.cause instanceof Error ? error.cause.message : ""; + + return { code: null, signal: /signal: '(\w+)'/u.exec(message)?.[1] ?? null }; +}; + +// Signal 0 probes the whole group; ESRCH is the only proof that no member remains. +const groupAlive = (pid: number) => + Effect.sync(() => { + try { + process.kill(-pid, 0); + + return true; + } catch (cause) { + return !(cause instanceof Error && "code" in cause && cause.code === "ESRCH"); + } + }); + +const program = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = fileURLToPath(new URL("../../", import.meta.url)); + + assert.equal( + process.argv.length, + 2, + "Usage: bun --no-env-file tools/e2e/golden-reimbursement.ts", + ); + + const environment = yield* safeEnvironment; + + const fault = Option.getOrUndefined( + yield* Config.option(Config.String("GOLDEN_REIMBURSEMENT_FAULT")), + ); + + assert.ok(fault === undefined || faults.includes(fault), "unknown journey fault"); + + const command = (binary: string, args: ReadonlyArray) => + Effect.scoped( + Effect.gen(function* () { + const handle = yield* ChildProcess.make(binary, args, { + cwd: root, + env: environment, + extendEnv: false, + stdin: "ignore", + }); + + const [stdout, stderr, code] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); + + if (code !== 0) + return yield* HarnessFailure.make({ + stage: binary, + message: `${binary} exited with code ${code}: ${stderr}`, + }); + + return stdout; + }), + ).pipe(Effect.mapError(failure(binary)), Effect.timeout("60 seconds")); + + const source: SourceSnapshot = yield* inspectSource(root, sourcePaths); + const { revision, tree: sourceTree } = source; + const artifactRoot = yield* fs.makeTempDirectory({ prefix: "vektor-reimbursement-" }); + const privateRoot = path.join(artifactRoot, "private"); + + yield* fs.makeDirectory(privateRoot, { mode: 0o700 }); + yield* Effect.sync(() => + process.stdout.write(`artifacts: ${artifactRoot}\nrunner-pid: ${process.pid}\n`), + ); + + const password = randomBytes(24).toString("hex"); + const token = randomBytes(24).toString("hex"); + const secret = randomBytes(32).toString("hex"); + const persons = people(password); + const secrets = [password, token, secret]; + + const sanitize = (value: string) => + secrets + .reduce((text, known) => text.replaceAll(known, "[REDACTED]"), value) + .replace(/(authorization|cookie|set-cookie)([\s"':=]+)[^\r\n,}]+/giu, "$1$2[REDACTED]"); + + const children: Array = []; + const ports: Array = []; + const resourceSnapshots: Array = []; + const providerAttempts: Array = []; + const interrupted = yield* Deferred.make(); + let provider: Bun.Server | undefined; + let postgres: DisposablePostgres | undefined; + let pool: Pool | undefined; + let browser: { close: () => Promise } | undefined; + let observer: ReimbursementObserver | undefined; + let browserEvidence: ReimbursementBrowserEvidence | undefined; + let failureText: string | undefined; + let interruption: string | undefined; + let deliveryMode = "accept"; + let activeDeliveries = 0; + let maxActiveDeliveries = 0; + + let build: + | { + readonly revision: string; + readonly sourceTree: string; + readonly digest: string; + readonly files: ReadonlyArray<{ path: string; sha256: string; bytes: number }>; + } + | undefined; + + const onSignal = (signal: string) => { + interruption ??= signal; + failureText ??= `Interrupted: ${signal}`; + Deferred.doneUnsafe(interrupted, Effect.void); + }; + + const start = ( + binary: string, + args: ReadonlyArray, + env: Environment = environment, + cwd = root, + ) => + Effect.gen(function* () { + const scope = yield* Scope.make(); + + const handle = yield* ChildProcess.make(binary, args, { + cwd, + env, + extendEnv: false, + detached: true, + stdin: "ignore", + killSignal: "SIGTERM", + forceKillAfter: "5 seconds", + }).pipe(Scope.provide(scope), Effect.mapError(failure(binary))); + + const exited = yield* Deferred.make(); + + const owned: OwnedProcess = { + label: args.includes("apps/backend/src/main.ts") ? "backend" : path.basename(binary), + pid: handle.pid, + scope, + exited, + isRunning: Effect.map(Deferred.isDone(exited), (done) => !done), + logTail: Effect.sync(() => owned.log), + log: "", + exit: undefined, + }; + + children.push(owned); + + yield* handle.all.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Effect.sync(() => { + owned.log = (owned.log + sanitize(chunk)).slice(-131_072); + }), + ), + Effect.ignore, + Effect.forkDetach, + ); + yield* handle.exitCode.pipe( + Effect.exit, + Effect.flatMap((exit) => { + owned.exit = signalOf(exit); + + return Deferred.succeed(exited, owned.exit); + }), + Effect.forkDetach, + ); + + return owned; + }); + + // Drain the owned group even when its leader exited, as the school-service CI wrapper does. + const stop = (owned: OwnedProcess | undefined) => + owned === undefined + ? Effect.void + : Scope.close(owned.scope, Exit.void).pipe( + Effect.andThen( + eventually( + "owned process group survived cleanup", + Effect.map(groupAlive(owned.pid), (alive) => !alive), + "5 seconds", + ), + ), + Effect.mapError(() => + HarnessFailure.make({ + stage: owned.label, + message: "owned process group survived cleanup", + }), + ), + ); + + const run = ( + binary: string, + args: ReadonlyArray, + env: Environment = environment, + cwd = root, + timeout: Duration.Input = "120 seconds", + ) => + Effect.gen(function* () { + const owned = yield* start(binary, args, env, cwd); + + return yield* Deferred.await(owned.exited).pipe( + Effect.timeoutOrElse({ + duration: timeout, + orElse: () => + Effect.fail( + HarnessFailure.make({ stage: binary, message: `${binary} deadline exceeded` }), + ), + }), + Effect.flatMap((exit) => + exit.code === 0 + ? Effect.succeed(owned.log) + : Effect.fail( + HarnessFailure.make({ + stage: binary, + message: `${binary} failed: ${owned.log.slice(-6000)}`, + }), + ), + ), + Effect.ensuring(Effect.ignore(stop(owned))), + ); + }); + + const sampleResources = (step: string) => + Effect.gen(function* () { + const rows = (yield* command("ps", ["-eo", "pid=,ppid=,pgid=,rss=,stat=,comm="])) + .trim() + .split("\n") + .map((line): ProcessRow => { + const [pid, ppid, pgid, rssKiB, state, ...name] = line.trim().split(/\s+/u); + + return { + pid: Number(pid), + ppid: Number(ppid), + pgid: Number(pgid), + rssKiB: Number(rssKiB), + state, + name: name.join(" "), + }; + }); + + const ownedIds = new Set([process.pid]); + + for (let count = 0; count < 16; count++) { + const before = ownedIds.size; + + for (const row of rows) if (ownedIds.has(row.ppid)) ownedIds.add(row.pid); + + if (before === ownedIds.size) break; + } + + const processes = rows.filter((row) => ownedIds.has(row.pid) && row.name !== "ps"); + + resourceSnapshots.push({ + step, + observedAt: yield* isoNow, + scope: "runner and live descendants; point-in-time RSS, not a peak", + processes, + processCount: processes.length, + totalRssKiB: processes.reduce((sum, row) => sum + row.rssKiB, 0), + }); + }); + + const waitHttp = (url: string, owned: OwnedProcess) => + waitForHttp({ + label: `HTTP ${new URL(url).pathname}`, + url, + process: owned, + deadline: "60 seconds", + }); + + /** Digests every file below the dashboard build for `browser-build.json`. */ + const dashboardBuildInventory = Effect.gen(function* () { + const files: Array<{ path: string; sha256: string; bytes: number }> = []; + + const visit = (relative: string): Effect.Effect => + Effect.gen(function* () { + const file = path.join(root, "apps/dashboard/build", relative); + const info = yield* fs.stat(file); + + assert.ok(info.type !== "SymbolicLink", "build must not contain symlinks"); + + if (info.type === "Directory") { + for (const name of (yield* fs.readDirectory(file)).sort()) + yield* visit(path.join(relative, name)); + } else { + assert.ok(info.type === "File", "build contains a non-file"); + + const bytes = yield* fs.readFile(file); + + files.push({ path: relative, sha256: sha256Hex(bytes), bytes: bytes.length }); + } + }); + + yield* visit(""); + + return files; + }); + + const aborted = (signal: AbortSignal) => + Effect.callback((resume) => { + const listener = () => + resume( + Effect.fail(HarnessFailure.make({ stage: "provider", message: String(signal.reason) })), + ); + + signal.addEventListener("abort", listener, { once: true }); + + return Effect.sync(() => signal.removeEventListener("abort", listener)); + }); + + const deliver = (request: Request) => + Effect.gen(function* () { + activeDeliveries++; + maxActiveDeliveries = Math.max(maxActiveDeliveries, activeDeliveries); + + const startedAt = yield* Clock.currentTimeMillis; + let attempt: DeliveryAttempt | undefined; + + return yield* Effect.gen(function* () { + assert.equal(request.headers.get("authorization"), `Bearer ${token}`); + + const body = Buffer.from(yield* Effect.promise(() => request.arrayBuffer())); + + assert.ok(body.length <= 65_536, "bounded synthetic provider envelope"); + + const envelope = yield* Schema.decodeEffect(DeliveryEnvelope)(body.toString("utf8")); + + assert.equal(request.headers.get("idempotency-key"), envelope.deliveryId); + + const current: DeliveryAttempt = { + deliveryId: envelope.deliveryId, + envelopeSha256: sha256Hex(body), + startedAt: DateTime.formatIso(DateTime.makeUnsafe(startedAt)), + abortedAt: null, + finishedAt: null, + status: null, + elapsedMs: null, + }; + + attempt = current; + providerAttempts.push(current); + request.signal.addEventListener( + "abort", + () => { + // The abort listener runs outside any fiber, so it reads the clock directly. + if (current.status === null) + current.abortedAt = DateTime.formatIso(DateTime.nowUnsafe()); + }, + { once: true }, + ); + + if (deliveryMode === "timeout") + yield* Effect.sleep("2 seconds").pipe(Effect.raceFirst(aborted(request.signal))); + + current.status = 204; + + return new Response(null, { status: 204 }); + }).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + const error = Cause.squash(cause); + + if (attempt !== undefined) + attempt.failure = sanitize( + Schema.is(HarnessFailure)(error) ? error.message : String(error), + ); + + return new Response(null, { status: 503 }); + }), + ), + Effect.ensuring( + Effect.gen(function* () { + if (attempt !== undefined) { + attempt.elapsedMs = (yield* Clock.currentTimeMillis) - startedAt; + attempt.finishedAt = yield* isoNow; + } + + activeDeliveries--; + }), + ), + ); + }); + + const cleanup = Effect.gen(function* () { + const errors: Array = []; + + const attempt = (effect: Effect.Effect) => + effect.pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + const error = Cause.squash(cause); + + errors.push(sanitize(Schema.is(HarnessFailure)(error) ? error.message : String(error))); + }), + ), + ); + + const current = browser; + + if (current !== undefined) yield* attempt(Effect.tryPromise(() => current.close())); + + const server = provider; + + if (server !== undefined) yield* attempt(Effect.tryPromise(() => server.stop(true))); + + const observerPool = pool; + + if (observerPool !== undefined) yield* attempt(Effect.tryPromise(() => observerPool.end())); + + for (const owned of [...children].reverse()) yield* attempt(stop(owned)); + + const cluster = postgres; + + if (cluster !== undefined) yield* attempt(Effect.tryPromise(() => cluster.stop())); + + for (const port of ports) + if (!(yield* Effect.promise(() => loopbackPortFree(port)))) + errors.push(`owned listener ${port} remains`); + + const alive = yield* Effect.forEach(children, (owned) => groupAlive(owned.pid)); + const groupsDrained = alive.every((value) => !value); + + if (groupsDrained && errors.length === 0) + yield* fs.remove(privateRoot, { recursive: true, force: true }); + yield* sampleResources("after-cleanup"); + + return { + groupsDrained, + listenersReleased: errors.length === 0, + privateResourcesRemoved: groupsDrained && errors.length === 0, + ports: [...ports], + processes: children.map(({ pid, label, exit }) => ({ + pid, + label, + exitCode: exit?.code ?? null, + signal: exit?.signal ?? null, + })), + errors, + }; + }); + + const journey = Effect.gen(function* () { + yield* sampleResources("baseline"); + + const reserved = yield* Effect.tryPromise({ + try: () => reserveLoopbackPorts(4), + catch: failure("ports"), + }); + + const [pgPort, apiPort, dashboardPort, providerPort] = reserved; + + assert.ok( + pgPort !== undefined && + apiPort !== undefined && + dashboardPort !== undefined && + providerPort !== undefined, + ); + ports.push(pgPort, apiPort, dashboardPort, providerPort); + + const origins = { + backend: `http://127.0.0.1:${apiPort}`, + dashboard: `http://127.0.0.1:${dashboardPort}`, + }; + + const postgresUrl = `postgres://postgres@127.0.0.1:${pgPort}/postgres`; + + postgres = yield* Effect.tryPromise({ + try: () => startDisposablePostgres({ port: pgPort, maxConnections: 16, environment }), + catch: failure("postgres"), + }); + + const observerPool = new Pool({ + connectionString: postgresUrl, + max: 2, + connectionTimeoutMillis: 1000, + statement_timeout: 10_000, + application_name: "reimbursement-independent-observer", + }); + + pool = observerPool; + + const runProvider = yield* FiberSet.makeRuntimePromise(); + + provider = Bun.serve({ + hostname: "127.0.0.1", + port: providerPort, + maxRequestBodySize: 65_536, + fetch: (request) => runProvider(deliver(request)), + }); + + const backendEnvironment = { + ...environment, + BACKEND_HOST: "127.0.0.1", + BACKEND_PORT: String(apiPort), + BACKEND_PG_URL: postgresUrl, + BETTER_AUTH_SECRET: secret, + NATIVE_IDENTITY_DEPLOYMENT: "local", + NATIVE_IDENTITY_TRUSTED_ORIGINS: jsonText([origins.dashboard]), + OAUTH_CANONICAL_ORIGIN: origins.backend, + OAUTH_DASHBOARD_ORIGIN: origins.dashboard, + OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", + PUBLIC_APPLICATION_EFFECT_MODE: "disabled", + PASSWORD_RESET_DELIVERY_MODE: "disabled", + RECEIPT_DELIVERY_MODE: "http", + RECEIPT_DELIVERY_POLL_MS: "250", + RECEIPT_STAGING_ROOT: path.join(privateRoot, "staging"), + RECEIPT_COMMITTED_ROOT: path.join(privateRoot, "committed"), + RECEIPT_MAX_FILE_BYTES: "10485760", + RECEIPT_DELIVERY_URL: `http://127.0.0.1:${providerPort}/receipt`, + RECEIPT_DELIVERY_TOKEN: token, + RECEIPT_DELIVERY_TIMEOUT_MS: "500", + RECEIPT_DELIVERY_SENDER: "finance@example.invalid", + RECEIPT_DELIVERY_ECONOMY_RECIPIENTS: jsonText({ + [fixture.departmentId]: "finance@example.invalid", + }), + }; + + yield* run("bun", ["--no-env-file", "run", "--cwd", "packages/database", "identity:seed"], { + ...backendEnvironment, + IDENTITY_SEED_PG_URL: postgresUrl, + IDENTITY_SEED_PERSONS: jsonText(Object.values(persons)), + }); + yield* seedReimbursement({ pool: observerPool, persons }); + + let backend: OwnedProcess | undefined; + + const boot = Effect.gen(function* () { + const { + RECEIPT_DELIVERY_URL: _url, + RECEIPT_DELIVERY_TOKEN: _token, + RECEIPT_DELIVERY_TIMEOUT_MS: _timeout, + RECEIPT_DELIVERY_SENDER: _sender, + RECEIPT_DELIVERY_ECONOMY_RECIPIENTS: _recipients, + ...withoutDelivery + } = backendEnvironment; + + const bootEnvironment = + backendEnvironment.RECEIPT_DELIVERY_MODE === "disabled" + ? withoutDelivery + : { ...backendEnvironment }; + + const started = yield* start( + "bun", + ["--no-env-file", "apps/backend/src/main.ts"], + bootEnvironment, + ); + + backend = started; + yield* waitHttp(origins.backend + "/health", started); + }); + + yield* boot; + + const dashboardEnvironment = { + ...environment, + API_URL: origins.backend, + VITE_API_URL: origins.dashboard, + DASHBOARD_ORIGIN: origins.dashboard, + DASHBOARD_MOUNT: "/dashboard/", + HOST: "127.0.0.1", + PORT: String(dashboardPort), + NODE_ENV: "production", + REAL_NATIVE_IDENTITY_E2E: "1", + }; + + const dashboardRoot = path.join(root, "apps/dashboard"); + + yield* run( + "bun", + ["--no-env-file", "run", "build"], + dashboardEnvironment, + dashboardRoot, + "300 seconds", + ); + + const files = yield* dashboardBuildInventory.pipe(Effect.mapError(failure("build"))); + + build = { revision, sourceTree, digest: "sha256:" + digestText(jsonText(files)), files }; + + const dashboard = yield* start( + "bun", + ["--no-env-file", "server.mjs"], + dashboardEnvironment, + dashboardRoot, + ); + + yield* waitHttp(origins.dashboard + "/dashboard/login", dashboard); + + const currentObserver = createReimbursementObserver({ + pool: observerPool, + committedRoot: backendEnvironment.RECEIPT_COMMITTED_ROOT, + persons, + }); + + observer = currentObserver; + + const checkpoint = (step: string, binding?: Binding) => + Effect.gen(function* () { + const facts = yield* currentObserver.checkpoint(step, binding); + + yield* sampleResources(step); + yield* Effect.sync(() => process.stdout.write(`checkpoint: ${step}\n`)); + + return facts; + }); + + yield* checkpoint("initial"); + + const runCallback = yield* FiberSet.makeRuntimePromise< + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >(); + + const hooks = { + origins, + persons, + artifacts: artifactRoot, + checkpoint: (step: string, binding?: Binding) => runCallback(checkpoint(step, binding)), + eventually: (label: string, inspect: () => Promise, timeout = 30_000) => + runCallback( + eventually( + label, + Effect.tryPromise({ try: inspect, catch: failure(label) }), + Duration.millis(timeout), + ), + ), + readFacts: () => runCallback(currentObserver.read), + restart: (mode = "http") => + runCallback( + Effect.gen(function* () { + yield* stop(backend); + backendEnvironment.RECEIPT_DELIVERY_MODE = mode; + yield* boot; + yield* sampleResources("native-restart"); + }), + ), + providerMode: (mode: string) => { + deliveryMode = mode; + }, + fault, + browserReady: (value: { close: () => Promise }) => { + browser = value; + }, + }; + + // Interruption closes the browser, so the driver settles before cleanup verification. + const evidence = yield* Effect.callback( + (resume) => { + const settled = runReimbursementBrowser(hooks).then( + (value) => resume(Effect.succeed(value)), + (cause: unknown) => resume(Effect.fail(failure("browser")(cause))), + ); + + return Effect.tryPromise({ + try: () => browser?.close() ?? Promise.resolve(), + catch: failure("browser"), + }).pipe( + Effect.ignore, + Effect.andThen(Effect.promise(() => settled)), + Effect.timeout("30 seconds"), + Effect.ignore, + ); + }, + ); + + browserEvidence = evidence; + yield* currentObserver.finish; + yield* sampleResources("bounds-complete"); + + const postProbeFacts = yield* currentObserver.read; + + yield* fs.writeFileString( + path.join(artifactRoot, "bounds-evidence.json"), + prettyJsonText({ + checks: evidence.checks.filter(({ kind }) => + [ + "intake", + "concurrent-disabled-worker", + "bounded-collection", + "disabled-worker-recovery", + "pagination-ui", + ].includes(kind), + ), + facts: { ...postProbeFacts, outbox: digestOutbox(postProbeFacts.outbox) }, + }), + { mode: 0o600 }, + ); + assert.equal(maxActiveDeliveries, 1, "fixed active loopback request concurrency"); + + const retried = providerAttempts.filter(({ status }) => status === null); + + assert.ok(retried.length > 0, "notification provider deadline observed"); + + for (const retry of retried) { + assert.notEqual(retry.abortedAt, null, "timed-out request cancelled the fixture transport"); + assert.ok( + retry.elapsedMs !== null && retry.elapsedMs >= 250 && retry.elapsedMs < 2000, + "bounded provider deadline interrupted the blocked transport", + ); + assert.ok( + providerAttempts.some( + (candidate) => + candidate.deliveryId === retry.deliveryId && + candidate.envelopeSha256 === retry.envelopeSha256 && + candidate.status === 204, + ), + "failed immutable envelope recovered unattended", + ); + } + + const after = yield* inspectSource(root, sourcePaths).pipe( + Effect.mapError(() => + HarnessFailure.make({ stage: "source", message: "source changed during acceptance" }), + ), + ); + + assert.equal(after.revision, revision); + assert.equal( + jsonText(after.files), + jsonText(source.files), + "source bytes changed during acceptance", + ); + }); + + yield* Effect.scoped( + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => + (["SIGINT", "SIGTERM"] as const).map((signal) => { + const listener = () => onSignal(signal); + + process.on(signal, listener); + + return [signal, listener] as const; + }), + ), + (listeners) => + Effect.sync(() => { + for (const [signal, listener] of listeners) process.removeListener(signal, listener); + }), + ); + + const deadline = Effect.sleep("15 minutes").pipe( + Effect.andThen(Effect.sync(() => onSignal("15-minute journey deadline"))), + ); + + const outcome = yield* Effect.scoped(journey).pipe( + Effect.raceFirst(Deferred.await(interrupted)), + Effect.raceFirst(deadline), + Effect.exit, + ); + + if (Exit.isFailure(outcome)) { + const error = Cause.squash(outcome.cause); + + failureText ??= sanitize( + Schema.is(HarnessFailure)(error) ? error.message : describeCause(error), + ); + } + }), + ); + + const cleaned = yield* cleanup; + + if (cleaned.errors.length > 0) failureText ??= "Resource cleanup failed"; + + const currentObservations = observer?.observations ?? []; + + const evidence = { + passed: failureText === undefined, + revision, + sourceTree, + cleanSource: true, + environment: "local_disposable", + failure: failureText ?? null, + interruption: interruption ?? null, + fault: fault ?? null, + observations: currentObservations, + browser: browserEvidence ?? null, + delivery: { + scope: + "Active loopback requests; this fixture cancels work on request abort. No remote execution bound is implied.", + maxActive: maxActiveDeliveries, + attempts: providerAttempts, + }, + resources: resourceSnapshots, + cleanup: cleaned, + }; + + const write = (name: string, text: string) => + fs.writeFileString(path.join(artifactRoot, name), text, { mode: 0o600 }); + + yield* write("evidence.json", sanitize(prettyJsonText(evidence))); + yield* write( + "source-manifest.json", + prettyJsonText({ revision, sourceTree, sources: source.files }), + ); + + if (build !== undefined) yield* write("browser-build.json", prettyJsonText(build)); + + if (failureText !== undefined) + yield* write( + "failure.log", + sanitize(children.map(({ label, log }) => `${label}\n${log}`).join("\n")), + ); + + const artifacts: Array<{ path: string; bytes: number; sha256: string }> = []; + + for (const name of (yield* fs.readDirectory(artifactRoot)).sort()) { + if (name === "private") continue; + + const bytes = yield* fs.readFile(path.join(artifactRoot, name)); + + artifacts.push({ path: name, bytes: bytes.length, sha256: sha256Hex(bytes) }); + } + + const passed = failureText === undefined; + + const receipt = { + schema_version: "native-functional-journey/v1", + journey_ref_id: "intent://golden-reimbursement", + mono_revision_ref_id: "rev-" + revision, + source_tree: sourceTree, + clean_source: true, + environment_kind: "local_disposable", + result: passed ? "passed" : "failed", + exit_code: passed ? 0 : 1, + termination_signal: interruption ?? null, + required_browser: true, + step_ids: currentObservations.map(({ step }) => step), + required_step_ids: reimbursementSteps, + artifact_digest: "sha256:" + digestText(jsonText(artifacts)), + artifacts, + runtime: { + bun: process.versions.bun, + node: (yield* command("node", ["--version"])).trim(), + postgres: postgresVersion(), + }, + }; + + yield* write("receipt.json", prettyJsonText(receipt)); + yield* Effect.sync(() => + process.stdout.write( + `result: ${receipt.result}\nevidence: ${path.join(artifactRoot, "receipt.json")}\n`, + ), + ); + + return receipt.exit_code; +}); + +Effect.runPromise( + program.pipe(Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer))), +).then( + (code) => { + process.exitCode = code; + }, + (cause: unknown) => { + process.stderr.write(`${describeCause(cause)}\n`); + process.exitCode = 1; + }, +); diff --git a/tools/e2e/golden-school-service-evidence.mjs b/tools/e2e/golden-school-service-evidence.mjs index de69f91f8..bf981cdaa 100644 --- a/tools/e2e/golden-school-service-evidence.mjs +++ b/tools/e2e/golden-school-service-evidence.mjs @@ -1,303 +1,4 @@ -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { lstat, readFile, readdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { Predicate } from "effect"; -import { goldenSteps } from "./golden-school-service.mjs"; - -export const goldenArtifactName = - /^(?:evidence\.json|failure\.log|browser-(?:evidence|network|trace-sanitized|cleanup|active|build)\.json|playwright-evidence\.json|dashboard-(?:runtime|command-[0-9]+)\.log)$/; - -export const goldenRunnerPaths = [ - "tools/e2e/placement-check.ts", - "tools/e2e/golden-school-service.mjs", - "apps/dashboard/e2e/run-real-native-placement.mjs", - "apps/dashboard/e2e/native-placement.spec.ts", - "tools/e2e/golden-school-service-evidence.mjs", - "tools/e2e/golden-http-diagnostics.mjs", -]; - -export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex"); - -const credentialField = /^(?:authorization|cookie|set-cookie)$/i; - -/** Redacts the known secrets and every credential header value in one diagnostic text. */ -export const redactDiagnostic = (secrets, text) => - secrets - .reduce((redacted, secret) => redacted.replaceAll(secret, "[REDACTED]"), text) - .replace(/(authorization|cookie|set-cookie)([\s"':=]+)[^\r\n,}]+/gi, "$1$2[REDACTED]"); - -/** - * Serializes evidence with every string redacted and every credential field's value replaced. - * Redacting the values, not the serialized text, keeps the file valid JSON. - */ -export const redactedEvidenceJson = (secrets, evidence) => - JSON.stringify( - evidence, - (key, value) => { - if (credentialField.test(key) && value !== null) return "[REDACTED]"; - - return Predicate.isString(value) ? redactDiagnostic(secrets, value) : value; - }, - 2, - ); - -export const dashboardBuildInventory = async (root) => { - const files = []; - - const visit = async (relative) => { - const path = join(root, "apps/dashboard/build", relative); - const info = await lstat(path); - assert.ok(!info.isSymbolicLink(), "build must not contain symlinks"); - - if (info.isDirectory()) { - for (const name of (await readdir(path)).sort()) await visit(join(relative, name)); - } else { - assert.ok(info.isFile(), "build contains a non-file"); - const bytes = await readFile(path); - files.push({ path: relative, sha256: sha256(bytes), bytes: bytes.length }); - } - }; - - await visit(""); - assert.ok( - files.some(({ path }) => path === "server/index.js"), - "server build absent", - ); - assert.ok( - files.some(({ path }) => path.startsWith("client/")), - "client build absent", - ); - - return files; -}; - -const safeBytes = (bytes) => { - const text = bytes.toString("utf8"); - assert.ok(!text.includes("\u0000"), "binary diagnostic rejected"); - assert.ok( - !/journey-secret-0123456789abcdef|synthetic-school-service-token/.test(text), - "synthetic secret rejected", - ); - assert.ok( - !/"(?:authorization|set-cookie|cookie)"\s*:\s*"(?!\[REDACTED\])[^"\s]+|^(?:authorization|set-cookie|cookie):\s*(?!\[REDACTED\])\S+/im.test( - text, - ), - "credential diagnostic rejected", - ); - assert.ok( - !/-----BEGIN [A-Z ]*PRIVATE KEY-----|"(?:password|access_token|refresh_token)"\s*:\s*"(?!\[REDACTED\])[^"\s]+/i.test( - text, - ), - "private diagnostic rejected", - ); - - for (const match of text.matchAll( - /(?:^|\s)(?:[A-Z0-9_]*(?:PASSWORD|TOKEN|SECRET)|AUTHORIZATION|COOKIE|SET-COOKIE)\s*[:=]\s*([^\r\n]*)/gim, - )) - assert.ok(match[1].trim() === "[REDACTED]", "credential diagnostic rejected"); -}; - -const safeJson = (value) => { - if (Predicate.isString(value)) { - safeBytes(Buffer.from(value)); - } else if (Array.isArray(value)) { - for (const item of value) safeJson(item); - } else if (Predicate.isObject(value)) { - for (const [key, item] of Object.entries(value)) { - if (/^(?:authorization|cookie|set-cookie)$|(?:password|token|secret)$/i.test(key)) - assert.ok(item === "[REDACTED]" || item === null, "credential diagnostic rejected"); - safeJson(item); - } - } -}; - -// Only receipt-bound, allowlisted regular files can enter the upload directory. -// Errors deliberately omit artifact contents and supplied values. -export const inspectGoldenEvidence = async ({ directory, root, revision, sourceTree }) => { - assert.ok((await lstat(directory)).isDirectory(), "evidence directory absent"); - assert.ok(!(await lstat(directory)).isSymbolicLink(), "evidence directory symlink rejected"); - - const read = async (name) => { - const info = await lstat(join(directory, name)); - assert.ok(info.isFile() && !info.isSymbolicLink(), "diagnostic must be a regular file"); - assert.ok(info.size <= 16 * 1024 * 1024, "diagnostic exceeds size limit"); - const bytes = await readFile(join(directory, name)); - safeBytes(bytes); - - if (name.endsWith(".json")) safeJson(JSON.parse(bytes)); - - return bytes; - }; - - const receiptBytes = await read("receipt.json"); - const receipt = JSON.parse(receiptBytes); - assert.ok( - receipt.schema_version === "native-functional-journey/v1", - "unsupported receipt schema", - ); - assert.ok(receipt.journey_ref_id === "intent://golden-school-service", "wrong journey"); - assert.ok(receipt.mono_revision_ref_id === "rev-" + revision, "wrong receipt revision"); - assert.ok( - receipt.source_tree === sourceTree && receipt.clean_source === true, - "wrong receipt source tree", - ); - assert.ok( - receipt.environment_kind === "local_disposable" && receipt.required_browser === true, - "wrong execution environment", - ); - assert.deepEqual( - receipt.runner_sources.map(({ path }) => path), - goldenRunnerPaths, - "runner inventory differs", - ); - - for (const item of receipt.runner_sources) - assert.ok( - sha256(await readFile(join(root, item.path))) === item.sha256, - "runner digest differs", - ); - assert.ok( - receipt.fixture_digest === "sha256:" + receipt.runner_sources[0].sha256, - "fixture digest differs", - ); - assert.ok( - Array.isArray(receipt.artifacts) && receipt.artifacts.length <= 32, - "invalid artifact inventory", - ); - assert.ok( - receipt.artifact_digest === "sha256:" + sha256(JSON.stringify(receipt.artifacts)), - "artifact inventory digest differs", - ); - const files = new Map([["receipt.json", receiptBytes]]); - const documents = new Map(); - - for (const item of receipt.artifacts) { - assert.ok( - Predicate.isString(item.path) && goldenArtifactName.test(item.path), - "artifact path outside allowlist", - ); - assert.ok(!files.has(item.path), "duplicate artifact path"); - const bytes = await read(item.path); - assert.ok( - bytes.length === item.bytes && sha256(bytes) === item.sha256, - "artifact bytes differ", - ); - files.set(item.path, bytes); - - if (item.path.endsWith(".json")) documents.set(item.path, JSON.parse(bytes)); - } - - assert.deepEqual( - (await readdir(directory)).sort(), - [...files.keys()].sort(), - "unlisted runtime artifact remains", - ); - const build = documents.get("browser-build.json"); - - if (build) { - assert.ok(build.revision === revision && build.sourceTree === sourceTree, "wrong build source"); - assert.ok( - build.digest === "sha256:" + sha256(JSON.stringify(build.files)), - "build inventory digest differs", - ); - assert.deepEqual( - build.files, - await dashboardBuildInventory(root), - "built dashboard bytes differ", - ); - } - - return { receipt, files, documents }; -}; - -export const stageGoldenEvidence = async (evidence, destination) => { - for (const [name, bytes] of evidence.files) - await writeFile(join(destination, name), bytes, { mode: 0o600, flag: "wx" }); -}; - -export const requireGoldenSuccess = ({ receipt, documents }) => { - assert.ok( - receipt.result === "passed" && receipt.exit_code === 0 && receipt.termination_signal === null, - "journey did not pass", - ); - assert.deepEqual(receipt.step_ids, goldenSteps, "required steps missing or duplicated"); - - for (const name of [ - "evidence.json", - "browser-build.json", - "browser-evidence.json", - "browser-network.json", - "browser-cleanup.json", - "browser-active.json", - "playwright-evidence.json", - ]) - assert.ok(documents.has(name), "required artifact absent: " + name); - const evidence = documents.get("evidence.json"); - assert.ok( - evidence.passed === true && evidence.failure === null && evidence.fault === null, - "parent did not pass", - ); - assert.ok( - evidence.revision === receipt.mono_revision_ref_id.slice(4) && - evidence.sourceTree === receipt.source_tree, - "parent source differs", - ); - assert.ok( - evidence.mode === "--golden-school-service" && evidence.cleanSource === true, - "wrong parent mode", - ); - assert.deepEqual( - evidence.observations.map(({ step }) => step), - goldenSteps, - "parent observations incomplete", - ); - - for (const key of [ - "processesExited", - "portsReleased", - "postgresRemoved", - "credentialManifestRemoved", - "receiverClosed", - ]) - assert.ok(evidence.cleanup[key] === true, "parent cleanup incomplete: " + key); - assert.deepEqual(evidence.cleanup.errors, [], "parent cleanup failed"); - assert.ok( - evidence.cleanup.processes.every(({ exited }) => exited === true), - "parent process remains", - ); - const cleanup = documents.get("browser-cleanup.json"); - assert.ok( - cleanup.processesExited === true && - cleanup.privateTracesRemoved === true && - cleanup.privateResultsRemoved === true, - "browser cleanup incomplete", - ); - assert.deepEqual(cleanup.cleanupErrors, [], "browser cleanup failed"); - assert.ok( - cleanup.failure === null && cleanup.processes.every(({ exited }) => exited === true), - "browser process failed", - ); - const browser = documents.get("browser-evidence.json"); - assert.ok( - browser.passed === true && browser.revision === evidence.revision, - "browser evidence failed or stale", - ); - assert.deepEqual(browser.steps, goldenSteps.slice(1), "browser steps incomplete"); - const network = documents.get("browser-network.json"); - assert.ok(network.passed === true, "network evidence failed"); - assert.deepEqual(network.steps, goldenSteps.slice(1), "network steps incomplete"); - assert.deepEqual( - documents.get("playwright-evidence.json"), - { - tests: [ - { - title: "golden school-service continuous functional journey", - ok: true, - tests: [{ expectedStatus: "passed", resultStatuses: ["passed"] }], - }, - ], - }, - "exactly one first-attempt browser pass is required", - ); -}; +// The evidence reader of the golden school-service journey is golden-school-service-evidence.ts. +// This module keeps its path for the importers that are no TypeScript: the CI wrapper and the +// browser drivers of apps/dashboard/e2e. +export * from "./golden-school-service-evidence.ts"; diff --git a/tools/e2e/golden-school-service-evidence.test.mjs b/tools/e2e/golden-school-service-evidence.test.mjs deleted file mode 100644 index fbca1108d..000000000 --- a/tools/e2e/golden-school-service-evidence.test.mjs +++ /dev/null @@ -1,143 +0,0 @@ -import { expect, test } from "bun:test"; -import { spawnSync } from "node:child_process"; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { - goldenRunnerPaths, - inspectGoldenEvidence, - redactedEvidenceJson, - sha256, - stageGoldenEvidence, -} from "./golden-school-service-evidence.mjs"; - -const root = fileURLToPath(new URL("../../", import.meta.url)); - -// A partial failed-run receipt exercises diagnostic custody, not journey acceptance. -const diagnostic = async (directory, path, text) => { - const bytes = Buffer.from(text); - - const sources = await Promise.all( - goldenRunnerPaths.map(async (path) => ({ - path, - sha256: sha256(await readFile(join(root, path))), - })), - ); - - const artifacts = [{ path, sha256: sha256(bytes), bytes: bytes.length }]; - await writeFile(join(directory, path), bytes); - await writeFile( - join(directory, "receipt.json"), - JSON.stringify({ - schema_version: "native-functional-journey/v1", - journey_ref_id: "intent://golden-school-service", - mono_revision_ref_id: "rev-diagnostic-fixture", - source_tree: "diagnostic-fixture", - clean_source: true, - environment_kind: "local_disposable", - required_browser: true, - result: "failed", - exit_code: 1, - runner_sources: sources, - fixture_digest: "sha256:" + sources[0].sha256, - artifacts, - artifact_digest: "sha256:" + sha256(JSON.stringify(artifacts)), - }), - ); -}; - -const stage = async (directory, destination) => - stageGoldenEvidence( - await inspectGoldenEvidence({ - directory, - root, - revision: "diagnostic-fixture", - sourceTree: "diagnostic-fixture", - }), - destination, - ); - -test("diagnostic custody rejects encoded credentials before any upload file exists", async () => { - const temporary = await mkdtemp(join(tmpdir(), "golden-diagnostic-custody-")); - - try { - const cases = [ - ["evidence.json", '{"authoriz\\u0061tion":"Bearer private-value"}'], - ["evidence.json", '{"message":"synthetic-school-service-\\u0074oken"}'], - ["failure.log", "Authorization: [REDACTED]Bearer private-value"], - ["evidence.json", '{"cookie":" private-value"}'], - ["failure.log", "DATABASE_PASSWORD=private-value"], - ["private-trace-1.zip", "unlisted raw trace"], - ]; - - for (const [index, [path, text]] of cases.entries()) { - const directory = join(temporary, String(index)); - const destination = join(temporary, String(index) + "-upload"); - await mkdir(directory); - await mkdir(destination); - await diagnostic(directory, path, text); - await expect(stage(directory, destination)).rejects.toThrow(); - expect(await readdir(destination)).toEqual([]); - } - - const directory = join(temporary, "safe"); - const destination = join(temporary, "safe-upload"); - await mkdir(directory); - await mkdir(destination); - const redacted = '{"authorization":"[REDACTED]"}'; - await diagnostic(directory, "evidence.json", redacted); - await stage(directory, destination); - expect(await readFile(join(destination, "evidence.json"), "utf8")).toBe(redacted); - } finally { - await rm(temporary, { recursive: true, force: true }); - } -}); - -test("a setup failure publishes only its failed summary for upload", async () => { - const temporary = await mkdtemp(join(tmpdir(), "golden-output-custody-")); - - try { - const destination = join(temporary, "upload"); - const output = join(temporary, "github-output"); - - const result = spawnSync( - process.execPath, - ["--no-env-file", join(root, "tools/e2e/golden-school-service-ci.mjs"), destination], - { - env: { - ...process.env, - GOLDEN_EXPECTED_REVISION: "not-this-checkout", - GITHUB_OUTPUT: output, - }, - encoding: "utf8", - timeout: 10_000, - }, - ); - - expect(result.status).toBe(1); - const [header, ...lines] = (await readFile(output, "utf8")).trimEnd().split("\n"); - expect(header.startsWith("artifact_paths<<")).toBe(true); - expect(lines.pop()).toBe(header.slice("artifact_paths<<".length)); - expect(lines).toEqual([join(destination, "ci-summary.json")]); - expect(JSON.parse(await readFile(lines[0], "utf8")).passed).toBe(false); - } finally { - await rm(temporary, { recursive: true, force: true }); - } -}); - -test("evidence redaction keeps credential fields and diagnostic text valid JSON", () => { - const evidence = { - deliveries: [{ authorization: "Bearer private-value", idempotencyKey: "effect:a,b" }], - failure: "request failed: cookie=private-value, retrying", - note: "sent journey-secret-0123456789abcdef", - absent: { cookie: null }, - }; - - expect(JSON.parse(redactedEvidenceJson(["journey-secret-0123456789abcdef"], evidence))).toEqual({ - deliveries: [{ authorization: "[REDACTED]", idempotencyKey: "effect:a,b" }], - failure: "request failed: cookie=[REDACTED], retrying", - note: "sent [REDACTED]", - absent: { cookie: null }, - }); -}); diff --git a/tools/e2e/golden-school-service-evidence.test.ts b/tools/e2e/golden-school-service-evidence.test.ts new file mode 100644 index 000000000..6cb5fea1c --- /dev/null +++ b/tools/e2e/golden-school-service-evidence.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from "bun:test"; +import { Buffer } from "node:buffer"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import { Effect, Exit, FileSystem, Path, Schema, type Scope, Stream } from "effect"; +import { ChildProcess } from "effect/unstable/process"; +import { + goldenRunnerPaths, + readGoldenEvidence, + redactedEvidenceJson, + sha256, + stageEvidence, +} from "./golden-school-service-evidence"; + +const UnknownJson = Schema.fromJsonString(Schema.Unknown); + +const jsonText = Schema.encodeEffect(UnknownJson); + +const Summary = Schema.fromJsonString(Schema.Struct({ passed: Schema.Boolean })); + +const root = fileURLToPath(new URL("../../", import.meta.url)); + +const run = ( + effect: Effect.Effect, +): Promise => Effect.runPromise(effect.pipe(Effect.scoped, Effect.provide(BunServices.layer))); + +// A partial failed-run receipt exercises diagnostic custody, not journey acceptance. +const diagnostic = Effect.fnUntraced(function* (directory: string, name: string, text: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const bytes = Buffer.from(text); + + const sources = yield* Effect.forEach(goldenRunnerPaths, (source) => + Effect.map(fs.readFile(path.join(root, source)), (content) => ({ + path: source, + sha256: sha256(content), + })), + ); + + const artifacts = [{ path: name, sha256: sha256(bytes), bytes: bytes.length }]; + yield* fs.writeFile(path.join(directory, name), bytes); + yield* fs.writeFileString( + path.join(directory, "receipt.json"), + yield* jsonText({ + schema_version: "native-functional-journey/v1", + journey_ref_id: "intent://golden-school-service", + mono_revision_ref_id: "rev-diagnostic-fixture", + source_tree: "diagnostic-fixture", + clean_source: true, + environment_kind: "local_disposable", + required_browser: true, + result: "failed", + exit_code: 1, + runner_sources: sources, + fixture_digest: "sha256:" + sources[0]!.sha256, + artifacts, + artifact_digest: "sha256:" + sha256(yield* jsonText(artifacts)), + }), + ); +}); + +const stage = (directory: string, destination: string) => + readGoldenEvidence({ + directory, + root, + revision: "diagnostic-fixture", + sourceTree: "diagnostic-fixture", + }).pipe(Effect.flatMap((evidence) => stageEvidence(evidence, destination))); + +test("diagnostic custody rejects encoded credentials before any upload file exists", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporary = yield* fs.makeTempDirectoryScoped({ prefix: "golden-diagnostic-custody-" }); + + const cases: ReadonlyArray = [ + ["evidence.json", '{"authoriz\\u0061tion":"Bearer private-value"}'], + ["evidence.json", '{"message":"synthetic-school-service-\\u0074oken"}'], + ["failure.log", "Authorization: [REDACTED]Bearer private-value"], + ["evidence.json", '{"cookie":" private-value"}'], + ["failure.log", "DATABASE_PASSWORD=private-value"], + ["private-trace-1.zip", "unlisted raw trace"], + ]; + + for (const [index, [name, text]] of cases.entries()) { + const directory = path.join(temporary, String(index)); + const destination = path.join(temporary, String(index) + "-upload"); + yield* fs.makeDirectory(directory); + yield* fs.makeDirectory(destination); + yield* diagnostic(directory, name, text); + expect(Exit.isFailure(yield* Effect.exit(stage(directory, destination)))).toBe(true); + expect(yield* fs.readDirectory(destination)).toEqual([]); + } + + const directory = path.join(temporary, "safe"); + const destination = path.join(temporary, "safe-upload"); + yield* fs.makeDirectory(directory); + yield* fs.makeDirectory(destination); + const redacted = '{"authorization":"[REDACTED]"}'; + yield* diagnostic(directory, "evidence.json", redacted); + yield* stage(directory, destination); + expect(yield* fs.readFileString(path.join(destination, "evidence.json"))).toBe(redacted); + }), + )); + +test("a setup failure publishes only its failed summary for upload", () => + run( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporary = yield* fs.makeTempDirectoryScoped({ prefix: "golden-output-custody-" }); + const destination = path.join(temporary, "upload"); + const output = path.join(temporary, "github-output"); + + const handle = yield* ChildProcess.make( + process.execPath, + ["--no-env-file", path.join(root, "tools/e2e/golden-school-service-ci.mjs"), destination], + { + env: { GOLDEN_EXPECTED_REVISION: "not-this-checkout", GITHUB_OUTPUT: output }, + extendEnv: true, + stdin: "ignore", + }, + ); + + const [, , status] = yield* Effect.all( + [Stream.runDrain(handle.stdout), Stream.runDrain(handle.stderr), handle.exitCode], + { concurrency: "unbounded" }, + ).pipe(Effect.timeout("10 seconds")); + + expect(Number(status)).toBe(1); + const [header = "", ...lines] = (yield* fs.readFileString(output)).trimEnd().split("\n"); + expect(header.startsWith("artifact_paths<<")).toBe(true); + expect(lines.pop()).toBe(header.slice("artifact_paths<<".length)); + expect(lines).toEqual([path.join(destination, "ci-summary.json")]); + expect( + (yield* Schema.decodeEffect(Summary)(yield* fs.readFileString(lines[0]!))).passed, + ).toBe(false); + }), + )); + +test("evidence redaction keeps credential fields and diagnostic text valid JSON", () => { + const evidence = { + deliveries: [{ authorization: "Bearer private-value", idempotencyKey: "effect:a,b" }], + failure: "request failed: cookie=private-value, retrying", + note: "sent journey-secret-0123456789abcdef", + absent: { cookie: null }, + }; + + expect( + Schema.decodeSync(UnknownJson)( + redactedEvidenceJson(["journey-secret-0123456789abcdef"], evidence), + ), + ).toEqual({ + deliveries: [{ authorization: "[REDACTED]", idempotencyKey: "effect:a,b" }], + failure: "request failed: cookie=[REDACTED], retrying", + note: "sent [REDACTED]", + absent: { cookie: null }, + }); +}); diff --git a/tools/e2e/golden-school-service-evidence.ts b/tools/e2e/golden-school-service-evidence.ts new file mode 100644 index 000000000..5c4d83f64 --- /dev/null +++ b/tools/e2e/golden-school-service-evidence.ts @@ -0,0 +1,407 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { Buffer } from "node:buffer"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import { Effect, FileSystem, Path, type PlatformError, Predicate, Schema } from "effect"; +import { dual } from "effect/Function"; +import { goldenSteps } from "./golden-school-service"; + +export const goldenArtifactName = + /^(?:evidence\.json|failure\.log|browser-(?:evidence|network|trace-sanitized|cleanup|active|build)\.json|playwright-evidence\.json|dashboard-(?:runtime|command-[0-9]+)\.log)$/; + +export const goldenRunnerPaths = [ + "tools/e2e/placement-check.ts", + "tools/e2e/golden-school-service.ts", + "apps/dashboard/e2e/run-real-native-placement.mjs", + "apps/dashboard/e2e/native-placement.spec.ts", + "tools/e2e/golden-school-service-evidence.ts", + "tools/e2e/golden-school-service-evidence.mjs", + "tools/e2e/golden-http-diagnostics.mjs", +]; + +// Receipts and diagnostics are untyped JSON documents; each check reads the fields it asserts. +// oxlint-disable-next-line typescript/no-explicit-any -- a parsed receipt is untyped JSON, as the reads did +type Document = any; + +export const sha256 = (bytes: string | Uint8Array): string => + createHash("sha256").update(bytes).digest("hex"); + +const credentialField = /^(?:authorization|cookie|set-cookie)$/i; + +/** Redacts the known secrets and every credential header value in one diagnostic text. */ +export const redactDiagnostic: { + (text: string): (secrets: ReadonlyArray) => string; + (secrets: ReadonlyArray, text: string): string; +} = dual(2, (secrets: ReadonlyArray, text: string): string => + secrets + .reduce((redacted, secret) => redacted.replaceAll(secret, "[REDACTED]"), text) + .replace(/(authorization|cookie|set-cookie)([\s"':=]+)[^\r\n,}]+/gi, "$1$2[REDACTED]"), +); + +/** + * Serializes evidence with every string redacted and every credential field's value replaced. + * Redacting the values, not the serialized text, keeps the file valid JSON. + */ +export const redactedEvidenceJson: { + (evidence: Schema.Json): (secrets: ReadonlyArray) => string; + (secrets: ReadonlyArray, evidence: Schema.Json): string; +} = dual(2, (secrets: ReadonlyArray, evidence: Schema.Json): string => + JSON.stringify( + evidence, + (key: string, value: Document) => { + if (credentialField.test(key) && value !== null) return "[REDACTED]"; + + return Predicate.isString(value) ? redactDiagnostic(secrets, value) : value; + }, + 2, + ), +); + +/** Runs an evidence program for a Promise caller outside the Effect program (EX-0017). */ +const runForPromiseCaller = ( + effect: Effect.Effect, +): Promise => Effect.runPromise(effect.pipe(Effect.provide(BunServices.layer))); + +/** Fails when `path` names a symbolic link; `stat` follows links, so `readLink` detects one. */ +const isSymbolicLink = (fs: FileSystem.FileSystem, path: string): Effect.Effect => + fs.readLink(path).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + +/** Digests every file below the dashboard build, refusing symbolic links and non-files. */ +export interface BuildFile { + readonly path: string; + readonly sha256: string; + readonly bytes: number; +} + +export const readDashboardBuild = Effect.fnUntraced(function* (root: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const files: Array = []; + + const visit = (relative: string): Effect.Effect => + Effect.gen(function* () { + const file = path.join(root, "apps/dashboard/build", relative); + const info = yield* fs.stat(file); + assert.ok((yield* isSymbolicLink(fs, file)) !== true, "build must not contain symlinks"); + + if (info.type === "Directory") { + for (const name of (yield* fs.readDirectory(file)).sort()) + yield* visit(path.join(relative, name)); + } else { + assert.ok(info.type === "File", "build contains a non-file"); + const bytes = yield* fs.readFile(file); + files.push({ path: relative, sha256: sha256(bytes), bytes: bytes.length }); + } + }); + + yield* visit(""); + assert.ok( + files.some(({ path }) => path === "server/index.js"), + "server build absent", + ); + assert.ok( + files.some(({ path }) => path.startsWith("client/")), + "client build absent", + ); + + return files; +}); + +/** The Promise form of `readDashboardBuild`, for the browser driver of the placement journey. */ +export const dashboardBuildInventory = (root: string): Promise> => + runForPromiseCaller(readDashboardBuild(root)); + +const safeBytes = (bytes: Buffer) => { + const text = bytes.toString("utf8"); + assert.ok(text.includes("\u0000") !== true, "binary diagnostic rejected"); + assert.ok( + !/journey-secret-0123456789abcdef|synthetic-school-service-token/.test(text), + "synthetic secret rejected", + ); + assert.ok( + !/"(?:authorization|set-cookie|cookie)"\s*:\s*"(?!\[REDACTED\])[^"\s]+|^(?:authorization|set-cookie|cookie):\s*(?!\[REDACTED\])\S+/im.test( + text, + ), + "credential diagnostic rejected", + ); + assert.ok( + !/-----BEGIN [A-Z ]*PRIVATE KEY-----|"(?:password|access_token|refresh_token)"\s*:\s*"(?!\[REDACTED\])[^"\s]+/i.test( + text, + ), + "private diagnostic rejected", + ); + + for (const match of text.matchAll( + /(?:^|\s)(?:[A-Z0-9_]*(?:PASSWORD|TOKEN|SECRET)|AUTHORIZATION|COOKIE|SET-COOKIE)\s*[:=]\s*([^\r\n]*)/gim, + )) + assert.ok(match[1]?.trim() === "[REDACTED]", "credential diagnostic rejected"); +}; + +const isJsonObject = Schema.is(Schema.Record(Schema.String, Schema.Json)); + +const safeJson = (value: Schema.Json): void => { + if (Predicate.isString(value)) { + safeBytes(Buffer.from(value)); + } else if (Array.isArray(value)) { + for (const item of value) safeJson(item); + } else if (isJsonObject(value)) { + for (const [key, item] of Object.entries(value)) { + if (/^(?:authorization|cookie|set-cookie)$|(?:password|token|secret)$/i.test(key)) + assert.ok(item === "[REDACTED]" || item === null, "credential diagnostic rejected"); + safeJson(item); + } + } +}; + +// Only receipt-bound, allowlisted regular files can enter the upload directory. +// Errors deliberately omit artifact contents and supplied values. +export interface GoldenEvidenceSource { + /** The artifact directory that the runner reported. */ + readonly directory: string; + /** The checkout whose runner sources and dashboard build the receipt binds. */ + readonly root: string; + readonly revision: string; + readonly sourceTree: string; +} + +/** The inspected receipt, the bytes of every listed file, and the parsed JSON documents. */ +export interface GoldenEvidence { + readonly receipt: Document; + readonly files: Map; + readonly documents: Map; +} + +const UnknownJson = Schema.fromJsonString(Schema.Unknown); + +const parseDocument = (bytes: Buffer): Effect.Effect => + Schema.decodeEffect(UnknownJson)(bytes.toString("utf8")); + +const documentText = (value: Schema.Json): Effect.Effect => + Schema.encodeEffect(UnknownJson)(value); + +export const readGoldenEvidence = Effect.fnUntraced(function* ({ + directory, + root, + revision, + sourceTree, +}: GoldenEvidenceSource) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + assert.ok((yield* fs.stat(directory)).type === "Directory", "evidence directory absent"); + assert.ok((yield* isSymbolicLink(fs, directory)) !== true, "evidence directory symlink rejected"); + + const read = Effect.fnUntraced(function* (name: string) { + const file = path.join(directory, name); + const info = yield* fs.stat(file); + assert.ok( + info.type === "File" && (yield* isSymbolicLink(fs, file)) !== true, + "diagnostic must be a regular file", + ); + assert.ok(Number(info.size) <= 16 * 1024 * 1024, "diagnostic exceeds size limit"); + const bytes = Buffer.from(yield* fs.readFile(file)); + safeBytes(bytes); + + if (name.endsWith(".json") === true) safeJson(yield* parseDocument(bytes)); + + return bytes; + }); + + const receiptBytes = yield* read("receipt.json"); + const receipt = yield* parseDocument(receiptBytes); + assert.ok( + receipt.schema_version === "native-functional-journey/v1", + "unsupported receipt schema", + ); + assert.ok(receipt.journey_ref_id === "intent://golden-school-service", "wrong journey"); + assert.ok(receipt.mono_revision_ref_id === "rev-" + revision, "wrong receipt revision"); + assert.ok( + receipt.source_tree === sourceTree && receipt.clean_source === true, + "wrong receipt source tree", + ); + assert.ok( + receipt.environment_kind === "local_disposable" && receipt.required_browser === true, + "wrong execution environment", + ); + assert.deepEqual( + receipt.runner_sources.map(({ path }: { path: string }) => path), + goldenRunnerPaths, + "runner inventory differs", + ); + + for (const item of receipt.runner_sources) + assert.ok( + sha256(yield* fs.readFile(path.join(root, item.path))) === item.sha256, + "runner digest differs", + ); + assert.ok( + receipt.fixture_digest === "sha256:" + receipt.runner_sources[0].sha256, + "fixture digest differs", + ); + assert.ok( + Array.isArray(receipt.artifacts) && receipt.artifacts.length <= 32, + "invalid artifact inventory", + ); + assert.ok( + receipt.artifact_digest === "sha256:" + sha256(yield* documentText(receipt.artifacts)), + "artifact inventory digest differs", + ); + const files = new Map([["receipt.json", receiptBytes]]); + const documents = new Map(); + + for (const item of receipt.artifacts) { + assert.ok( + Predicate.isString(item.path) && goldenArtifactName.test(item.path), + "artifact path outside allowlist", + ); + assert.ok(!files.has(item.path), "duplicate artifact path"); + const bytes = yield* read(item.path); + assert.ok( + bytes.length === item.bytes && sha256(bytes) === item.sha256, + "artifact bytes differ", + ); + files.set(item.path, bytes); + + if (item.path.endsWith(".json") === true) documents.set(item.path, yield* parseDocument(bytes)); + } + + assert.deepEqual( + (yield* fs.readDirectory(directory)).sort(), + [...files.keys()].sort(), + "unlisted runtime artifact remains", + ); + const build = documents.get("browser-build.json"); + + if (Predicate.isTruthy(build)) { + assert.ok(build.revision === revision && build.sourceTree === sourceTree, "wrong build source"); + assert.ok( + build.digest === "sha256:" + sha256(yield* documentText(build.files)), + "build inventory digest differs", + ); + assert.deepEqual(build.files, yield* readDashboardBuild(root), "built dashboard bytes differ"); + } + + return { receipt, files, documents } satisfies GoldenEvidence; +}); + +/** The Promise form of `readGoldenEvidence`, for the CI wrapper of the golden journey. */ +export const inspectGoldenEvidence = (source: GoldenEvidenceSource): Promise => + runForPromiseCaller(readGoldenEvidence(source)); + +/** Writes each inspected file into the upload directory, refusing to replace an existing one. */ +export const stageEvidence: { + ( + destination: string, + ): ( + evidence: GoldenEvidence, + ) => Effect.Effect; + ( + evidence: GoldenEvidence, + destination: string, + ): Effect.Effect; +} = dual(2, (evidence: GoldenEvidence, destination: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + for (const [name, bytes] of evidence.files) + yield* fs.writeFile(path.join(destination, name), bytes, { mode: 0o600, flag: "wx" }); + }), +); + +/** The Promise form of `stageEvidence`, for the CI wrapper of the golden journey. */ +export const stageGoldenEvidence: { + (destination: string): (evidence: GoldenEvidence) => Promise; + (evidence: GoldenEvidence, destination: string): Promise; +} = dual(2, (evidence: GoldenEvidence, destination: string) => + runForPromiseCaller(stageEvidence(evidence, destination)), +); + +export const requireGoldenSuccess = ({ receipt, documents }: GoldenEvidence) => { + assert.ok( + receipt.result === "passed" && receipt.exit_code === 0 && receipt.termination_signal === null, + "journey did not pass", + ); + assert.deepEqual(receipt.step_ids, goldenSteps, "required steps missing or duplicated"); + + for (const name of [ + "evidence.json", + "browser-build.json", + "browser-evidence.json", + "browser-network.json", + "browser-cleanup.json", + "browser-active.json", + "playwright-evidence.json", + ]) + assert.ok(documents.has(name), "required artifact absent: " + name); + const evidence = documents.get("evidence.json"); + assert.ok( + evidence.passed === true && evidence.failure === null && evidence.fault === null, + "parent did not pass", + ); + assert.ok( + evidence.revision === receipt.mono_revision_ref_id.slice(4) && + evidence.sourceTree === receipt.source_tree, + "parent source differs", + ); + assert.ok( + evidence.mode === "--golden-school-service" && evidence.cleanSource === true, + "wrong parent mode", + ); + assert.deepEqual( + evidence.observations.map(({ step }: { step: string }) => step), + goldenSteps, + "parent observations incomplete", + ); + + for (const key of [ + "processesExited", + "portsReleased", + "postgresRemoved", + "credentialManifestRemoved", + "receiverClosed", + ]) + assert.ok(evidence.cleanup[key] === true, "parent cleanup incomplete: " + key); + assert.deepEqual(evidence.cleanup.errors, [], "parent cleanup failed"); + assert.ok( + evidence.cleanup.processes.every(({ exited }: { exited: boolean }) => exited === true), + "parent process remains", + ); + const cleanup = documents.get("browser-cleanup.json"); + assert.ok( + cleanup.processesExited === true && + cleanup.privateTracesRemoved === true && + cleanup.privateResultsRemoved === true, + "browser cleanup incomplete", + ); + assert.deepEqual(cleanup.cleanupErrors, [], "browser cleanup failed"); + assert.ok( + cleanup.failure === null && + cleanup.processes.every(({ exited }: { exited: boolean }) => exited === true), + "browser process failed", + ); + const browser = documents.get("browser-evidence.json"); + assert.ok( + browser.passed === true && browser.revision === evidence.revision, + "browser evidence failed or stale", + ); + assert.deepEqual(browser.steps, goldenSteps.slice(1), "browser steps incomplete"); + const network = documents.get("browser-network.json"); + assert.ok(network.passed === true, "network evidence failed"); + assert.deepEqual(network.steps, goldenSteps.slice(1), "network steps incomplete"); + assert.deepEqual( + documents.get("playwright-evidence.json"), + { + tests: [ + { + title: "golden school-service continuous functional journey", + ok: true, + tests: [{ expectedStatus: "passed", resultStatuses: ["passed"] }], + }, + ], + }, + "exactly one first-attempt browser pass is required", + ); +}; diff --git a/tools/e2e/golden-school-service.mjs b/tools/e2e/golden-school-service.ts similarity index 71% rename from tools/e2e/golden-school-service.mjs rename to tools/e2e/golden-school-service.ts index aa9da0682..402768043 100644 --- a/tools/e2e/golden-school-service.mjs +++ b/tools/e2e/golden-school-service.ts @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; +import { Clock, Effect, Schema } from "effect"; +import type { Pool, PoolClient } from "pg"; +import { step as journeyStep } from "../acceptance/journey-step"; export const goldenSteps = [ "initial", @@ -33,11 +36,19 @@ export const goldenSteps = [ /** Test-driver faults. Each must make the journey fail; none changes product behavior. */ export const goldenFaults = ["omit-coverage", "absent-browser-evidence"]; -const bySource = (left, right) => +// The observer reads untyped pg rows and compares them with the rows that each checkpoint expects. +// oxlint-disable-next-line typescript/no-explicit-any -- pg types an untyped row as any, as the calls did +type Row = any; + +type Facts = Row; + +const UnknownJson = Schema.fromJsonString(Schema.Unknown); + +const bySource = (left: Row, right: Row) => left.source_id < right.source_id ? -1 : left.source_id > right.source_id ? 1 : 0; /** A rostered person holds a Scheduled reservation; a current coverage record holds a Coverage one. */ -const reservation = (commitmentId, personId, coverageId = null) => ({ +const reservation = (commitmentId: string, personId: string, coverageId: string | null = null) => ({ source_id: coverageId ?? commitmentId + ":" + personId, source_kind: coverageId === null ? "Scheduled" : "Coverage", commitment_id: commitmentId, @@ -45,7 +56,7 @@ const reservation = (commitmentId, personId, coverageId = null) => ({ person_id: personId, }); -const assertReservations = (step, facts, expected) => +const assertReservations = (step: string, facts: Facts, expected: ReadonlyArray) => assert.deepEqual( [...facts.reservations].sort(bySource), [...expected].sort(bySource), @@ -53,20 +64,26 @@ const assertReservations = (step, facts, expected) => ); // Receipts that the executed writes of each checkpoint commit. Rejections and reads commit none. -const substituteWrites = { - "substitute-commitment": ["placements.commandBoard"], - "substitute-absence": ["placements.commandOwnCoverage"], - "outcome-recorded": ["admissionOutcomes.recordOutcome", "admissionOutcomes.recordOutcome"], - "coverage-recorded": ["placements.commandOwnCoverage"], - "coverage-replaced": ["placements.commandCoverageBoard"], - "coverage-withdrawn": ["placements.commandCoverageBoard"], - "coverage-rerecorded": ["placements.commandOwnCoverage"], - "substitute-completed": ["placements.commandCoverageBoard"], -}; +const substituteWrites = new Map>([ + ["substitute-commitment", ["placements.commandBoard"]], + ["substitute-absence", ["placements.commandOwnCoverage"]], + ["outcome-recorded", ["admissionOutcomes.recordOutcome", "admissionOutcomes.recordOutcome"]], + ["coverage-recorded", ["placements.commandOwnCoverage"]], + ["coverage-replaced", ["placements.commandCoverageBoard"]], + ["coverage-withdrawn", ["placements.commandCoverageBoard"]], + ["coverage-rerecorded", ["placements.commandOwnCoverage"]], + ["substitute-completed", ["placements.commandCoverageBoard"]], +]); // A substitute is an admission outcome. The system records the absence and who covered it; // the people involved agree on cover outside the system. -const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { +const assertSubstituteFacts = ( + step: string, + facts: Facts, + previous: Facts, + baseline: Facts, + fixture: GoldenFixture, +) => { const { volunteerId, leaderId, @@ -80,7 +97,7 @@ const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { substituteServiceDate, } = fixture; - const reached = (name) => goldenSteps.indexOf(step) >= goldenSteps.indexOf(name); + const reached = (name: string) => goldenSteps.indexOf(step) >= goldenSteps.indexOf(name); for (const name of [ "affiliations", @@ -94,7 +111,7 @@ const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { assert.deepEqual(facts[name], baseline[name], `${step} must not change ${name}`); // The candidate needs no affiliation or placement: the admission outcome alone puts them on call. - assert.ok(!facts.affiliations.some((row) => row.person_id === candidateId)); + assert.ok(facts.affiliations.some((row: Row) => row.person_id === candidateId) !== true); const [scheduled] = baseline.commitments; assert.equal(facts.commitments.length, 2); assert.deepEqual(facts.commitments[0], scheduled); @@ -143,7 +160,7 @@ const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { assert.equal(facts.absences.length, reached("substitute-absence") ? 1 : 0); const [absence] = facts.absences; - if (absence) + if (absence !== undefined) assert.deepEqual( [ absence.commitment_id, @@ -169,12 +186,14 @@ const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { // Admission management records the outcome; the highest revision of each application rules. assert.deepEqual( - facts.admissionOutcomes.map(({ application_id, revision, outcome, decided_by_person_id }) => [ - application_id, - revision, - outcome, - decided_by_person_id, - ]), + facts.admissionOutcomes.map( + ({ application_id, revision, outcome, decided_by_person_id }: Row) => [ + application_id, + revision, + outcome, + decided_by_person_id, + ], + ), reached("outcome-recorded") ? [ [applicationId, 1, "Substitute", leaderId], @@ -197,7 +216,12 @@ const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { ); const [recorded, replaced, rerecorded] = facts.coverage; - const assertRecord = (row, coveringPersonId, recordedBy, withdrawnBy) => { + const assertRecord = ( + row: Row, + coveringPersonId: string, + recordedBy: string, + withdrawnBy: string | null, + ) => { assert.match(row.coverage_id, /^school-service-coverage-[a-f0-9]{64}$/); assert.deepEqual( [ @@ -208,14 +232,26 @@ const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { row.withdrawn_by_person_id, row.withdrawn_at === null, ], - [absence.absence_id, coveringPersonId, "Substitute", recordedBy, withdrawnBy, withdrawnBy === null], + [ + absence.absence_id, + coveringPersonId, + "Substitute", + recordedBy, + withdrawnBy, + withdrawnBy === null, + ], ); }; - if (recorded) - assertRecord(recorded, candidateId, volunteerId, reached("coverage-replaced") ? leaderId : null); + if (recorded !== undefined) + assertRecord( + recorded, + candidateId, + volunteerId, + reached("coverage-replaced") ? leaderId : null, + ); - if (replaced) { + if (replaced !== undefined) { assertRecord( replaced, secondSubstituteId, @@ -229,12 +265,12 @@ const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { ); } - if (rerecorded) assertRecord(rerecorded, candidateId, volunteerId, null); - const current = facts.coverage.filter((row) => row.withdrawn_at === null); + if (rerecorded !== undefined) assertRecord(rerecorded, candidateId, volunteerId, null); + const current = facts.coverage.filter((row: Row) => row.withdrawn_at === null); assertReservations(step, facts, [ reservation(scheduled.commitment_id, volunteerId), reservation(commitment.commitment_id, volunteerId), - ...current.map((row) => + ...current.map((row: Row) => reservation(commitment.commitment_id, row.covering_person_id, row.coverage_id), ), ]); @@ -379,8 +415,8 @@ const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { assert.deepEqual( facts.coverageHistory .slice(baseline.coverageHistory.length) - .map(({ action, actor_person_id, snapshot }) => [action, actor_person_id, snapshot]), - coverageAudit.flatMap(([checkpoint, action, actor, snapshot]) => + .map(({ action, actor_person_id, snapshot }: Row) => [action, actor_person_id, snapshot]), + coverageAudit.flatMap(([checkpoint, action, actor, snapshot]: Row) => reached(checkpoint) ? [[action, actor, snapshot()]] : [], ), ); @@ -390,17 +426,19 @@ const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { assert.equal(entry.semester_id, semesterId); } - const writes = substituteWrites[step] ?? []; + const writes = substituteWrites.get(step) ?? []; - if (writes.length === 0) assert.deepEqual(facts, previous, step + " must not change persisted facts"); + if (writes.length === 0) + assert.deepEqual(facts, previous, step + " must not change persisted facts"); const added = facts.receipts.filter( - (row) => !previous.receipts.some((old) => old.identity_sha256 === row.identity_sha256), + (row: Row) => + previous.receipts.some((old: Row) => old.identity_sha256 === row.identity_sha256) !== true, ); assert.deepEqual( - added.map((row) => row.operation_id).sort(), - [...writes].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), + added.map((row: Row) => row.operation_id).sort(), + [...writes].sort((a: Row, b: Row) => (a < b ? -1 : a > b ? 1 : 0)), step + " must commit one successful receipt per executed write", ); @@ -413,93 +451,146 @@ const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { if (receipt.operation_id === "admissionOutcomes.recordOutcome") assert.deepEqual([body.outcome, body.revision], ["Substitute", 1]); else if (receipt.operation_id === "placements.commandBoard") - assert.ok(body.commitments.some((row) => row.commitmentId === commitment.commitment_id)); + assert.ok(body.commitments.some((row: Row) => row.commitmentId === commitment.commitment_id)); else assert.deepEqual( - body.coverage.map(({ coverageId, coveringPersonId }) => [coverageId, coveringPersonId]), - current.map((row) => [row.coverage_id, row.covering_person_id]), + body.coverage.map(({ coverageId, coveringPersonId }: Row) => [ + coverageId, + coveringPersonId, + ]), + current.map((row: Row) => [row.coverage_id, row.covering_person_id]), ); } if (step === "outcome-recorded") assert.deepEqual( - added.map((row) => JSON.parse(row.body).applicationId).sort(), - [applicationId, secondApplicationId].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), + added.map((row: Row) => JSON.parse(row.body).applicationId).sort(), + [applicationId, secondApplicationId].sort((a: Row, b: Row) => (a < b ? -1 : a > b ? 1 : 0)), ); }; -export const createGoldenObserver = (pool, fixture, deliveries) => { - const observations = []; - let previous; - let baseline; +/** + * Runs the reads of one checkpoint in a read-only REPEATABLE READ transaction of the connection, + * so that they see one snapshot; the transaction commits after the reads and rolls back when one + * fails, which then fails with its original cause. + */ +const readOnlySnapshot = (connection: PoolClient, reads: Effect.Effect) => + journeyStep(() => connection.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")).pipe( + Effect.andThen( + reads.pipe( + Effect.tap(() => journeyStep(() => connection.query("COMMIT"))), + Effect.onError(() => journeyStep(() => connection.query("ROLLBACK")).pipe(Effect.ignore)), + ), + ), + ); + +/** The manifest facts of the golden journey that the observer checks rows against. */ +export interface GoldenFixture { + readonly volunteerId: string; + readonly leaderId: string; + readonly candidateId: string; + readonly applicationId: string; + readonly secondSubstituteId: string; + readonly secondApplicationId: string; + readonly departmentId: string; + readonly semesterId: string; + readonly schoolId: number; + readonly serviceDate: string; + readonly substituteServiceDate: string; +} + +/** One request that the loopback receiver captured from the school-service notification sender. */ +export interface NotificationDelivery { + readonly authorization?: string; + readonly idempotencyKey?: string; + readonly body: Row; +} + +export interface GoldenObserverOptions { + readonly pool: Pool; + readonly fixture: GoldenFixture; + /** The receiver's captures, which the observer reads when the journey finishes. */ + readonly deliveries: ReadonlyArray; +} + +/** + * The independent observer of the golden school-service journey: each checkpoint reads one + * read-only snapshot and asserts the facts of its step, and `finish` asserts the delivered + * notification. + */ +export const createGoldenObserver = ({ pool, fixture, deliveries }: GoldenObserverOptions) => { + const observations: Array = []; + let previous: Row = undefined; + let baseline: Row = undefined; const { volunteerId, leaderId, departmentId, semesterId, schoolId, serviceDate } = fixture; - const observe = async (step) => { + const observe = Effect.fnUntraced(function* (step: string) { assert.equal( step, goldenSteps[observations.length], "every required checkpoint must run in order", ); - let facts; - const connection = await pool.connect(); - - try { - await connection.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY"); - const rows = async (sql) => (await connection.query(sql)).rows; - facts = { - affiliations: await rows( - "SELECT * FROM organization_volunteer_affiliations ORDER BY person_id", - ), - affiliationHistory: await rows( - "SELECT * FROM organization_volunteer_affiliation_audit ORDER BY revision", - ), - placements: await rows("SELECT * FROM assistant_placements ORDER BY placement_id"), - placementHistory: await rows("SELECT * FROM assistant_placement_audit ORDER BY revision"), - demands: await rows("SELECT * FROM school_service_demand ORDER BY school_id,day,block"), - proposals: await rows("SELECT * FROM school_service_proposals ORDER BY created_at"), - commitments: await rows( - "SELECT *,service_date::text AS service_date,start_time::text AS start_time,end_time::text AS end_time FROM school_service_commitments ORDER BY created_at,commitment_id", - ), - decisions: await rows( - "SELECT * FROM school_service_decisions ORDER BY decided_at,commitment_id", - ), - occurrences: await rows( - "SELECT *,occurred_on::text AS occurred_on FROM school_service_occurrences ORDER BY recorded_at,occurrence_id", - ), - serviceHistory: await rows("SELECT * FROM school_service_audit ORDER BY audit_id"), - coverageHistory: await rows( - "SELECT * FROM school_service_coverage_audit ORDER BY audit_id", - ), - notifications: await rows( - "SELECT effect_id,proposal_id,person_id,payload_json FROM school_service_notification_outbox ORDER BY effect_id", - ), - absences: await rows( - "SELECT *,service_date::text AS service_date FROM school_service_absences ORDER BY absence_id", - ), - coverage: await rows( - "SELECT * FROM school_service_coverage_records ORDER BY recorded_at,coverage_id", - ), - reservations: await rows( - "SELECT source_id,source_kind,commitment_id,coverage_id,person_id FROM school_service_person_reservations ORDER BY source_id", - ), - closures: await rows("SELECT * FROM school_service_closures ORDER BY closure_id"), - admissionOutcomes: await rows( - "SELECT * FROM admission_application_outcomes ORDER BY application_id,revision", - ), - receipts: await rows( - "SELECT identity_sha256,operation_id,state,status,convert_from(body_bytes,'UTF8') AS body FROM native_http_idempotency_receipts WHERE status BETWEEN 200 AND 299 ORDER BY identity_sha256", - ), - volunteerAuthority: await rows( - `SELECT person_id FROM organization_memberships WHERE person_id='${volunteerId}' UNION ALL SELECT person_id FROM organization_global_administrator_grants WHERE person_id='${volunteerId}'`, - ), - }; - await connection.query("COMMIT"); - } catch (error) { - await connection.query("ROLLBACK"); - throw error; - } finally { - connection.release(); - } + + const facts: Facts = yield* Effect.acquireUseRelease( + journeyStep(() => pool.connect()), + (connection) => { + const rows = (sql: string) => + Effect.map( + journeyStep(() => connection.query(sql)), + ({ rows }: Row) => rows, + ); + + return readOnlySnapshot( + connection, + Effect.all({ + affiliations: rows( + "SELECT * FROM organization_volunteer_affiliations ORDER BY person_id", + ), + affiliationHistory: rows( + "SELECT * FROM organization_volunteer_affiliation_audit ORDER BY revision", + ), + placements: rows("SELECT * FROM assistant_placements ORDER BY placement_id"), + placementHistory: rows("SELECT * FROM assistant_placement_audit ORDER BY revision"), + demands: rows("SELECT * FROM school_service_demand ORDER BY school_id,day,block"), + proposals: rows("SELECT * FROM school_service_proposals ORDER BY created_at"), + commitments: rows( + "SELECT *,service_date::text AS service_date,start_time::text AS start_time,end_time::text AS end_time FROM school_service_commitments ORDER BY created_at,commitment_id", + ), + decisions: rows( + "SELECT * FROM school_service_decisions ORDER BY decided_at,commitment_id", + ), + occurrences: rows( + "SELECT *,occurred_on::text AS occurred_on FROM school_service_occurrences ORDER BY recorded_at,occurrence_id", + ), + serviceHistory: rows("SELECT * FROM school_service_audit ORDER BY audit_id"), + coverageHistory: rows("SELECT * FROM school_service_coverage_audit ORDER BY audit_id"), + notifications: rows( + "SELECT effect_id,proposal_id,person_id,payload_json FROM school_service_notification_outbox ORDER BY effect_id", + ), + absences: rows( + "SELECT *,service_date::text AS service_date FROM school_service_absences ORDER BY absence_id", + ), + coverage: rows( + "SELECT * FROM school_service_coverage_records ORDER BY recorded_at,coverage_id", + ), + reservations: rows( + "SELECT source_id,source_kind,commitment_id,coverage_id,person_id FROM school_service_person_reservations ORDER BY source_id", + ), + closures: rows("SELECT * FROM school_service_closures ORDER BY closure_id"), + admissionOutcomes: rows( + "SELECT * FROM admission_application_outcomes ORDER BY application_id,revision", + ), + receipts: rows( + "SELECT identity_sha256,operation_id,state,status,convert_from(body_bytes,'UTF8') AS body FROM native_http_idempotency_receipts WHERE status BETWEEN 200 AND 299 ORDER BY identity_sha256", + ), + volunteerAuthority: rows( + `SELECT person_id FROM organization_memberships WHERE person_id='${volunteerId}' UNION ALL SELECT person_id FROM organization_global_administrator_grants WHERE person_id='${volunteerId}'`, + ), + }), + ); + }, + (connection) => Effect.sync(() => connection.release()), + ); assert.deepEqual( facts.volunteerAuthority, @@ -531,7 +622,10 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { } assert.deepEqual( - facts.affiliationHistory.map(({ action, actor_person_id }) => [action, actor_person_id]), + facts.affiliationHistory.map(({ action, actor_person_id }: Row) => [ + action, + actor_person_id, + ]), index >= 2 ? [ ["Request", volunteerId], @@ -557,7 +651,7 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { [volunteerId, departmentId, semesterId, schoolId, "Monday", "2", 4, true], ); assert.deepEqual( - facts.placementHistory.map(({ action, actor_person_id, placement_id }) => [ + facts.placementHistory.map(({ action, actor_person_id, placement_id }: Row) => [ action, actor_person_id, placement_id, @@ -601,7 +695,7 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { assert.equal(proposal.demand_snapshot.length, 1); assert.equal(proposal.assignment_snapshot.length, 1); assert.deepEqual( - proposal.demand_snapshot.map(({ schoolId, day, block, requiredVolunteers }) => [ + proposal.demand_snapshot.map(({ schoolId, day, block, requiredVolunteers }: Row) => [ schoolId, day, block, @@ -610,13 +704,15 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { [[schoolId, "Monday", "2", 1]], ); assert.deepEqual( - proposal.assignment_snapshot.map(({ personId, placementId, schoolId, day, block }) => [ - personId, - placementId, - schoolId, - day, - block, - ]), + proposal.assignment_snapshot.map( + ({ personId, placementId, schoolId, day, block }: Row) => [ + personId, + placementId, + schoolId, + day, + block, + ], + ), [[volunteerId, facts.placements[0].placement_id, schoolId, "Monday", "2"]], ); @@ -724,13 +820,13 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { } assert.deepEqual( - facts.serviceHistory.map(({ action, actor_person_id }) => [action, actor_person_id]), + facts.serviceHistory.map(({ action, actor_person_id }: Row) => [action, actor_person_id]), ["SetDemand", "GenerateProposal", "ConfirmProposal", "ScheduleService"] .slice(0, Math.max(0, Math.min(4, index - 4))) - .map((action) => [action, leaderId]), + .map((action: Row) => [action, leaderId]), ); assert.deepEqual( - facts.coverageHistory.map(({ action, actor_person_id }) => [action, actor_person_id]), + facts.coverageHistory.map(({ action, actor_person_id }: Row) => [action, actor_person_id]), index >= 10 ? [["CompleteService", leaderId]] : [], ); @@ -740,7 +836,7 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { assert.ok(entry.occurred_at); } - const snapshots = facts.serviceHistory.map((entry) => entry.snapshot); + const snapshots = facts.serviceHistory.map((entry: Row) => entry.snapshot); if (index >= 5) assert.deepEqual(snapshots[0], { @@ -799,7 +895,9 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { ); } else { const added = facts.receipts.filter( - (row) => !previous.receipts.some((old) => old.identity_sha256 === row.identity_sha256), + (row: Row) => + previous.receipts.some((old: Row) => old.identity_sha256 === row.identity_sha256) !== + true, ); assert.equal( @@ -808,7 +906,7 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { `${step} must commit one successful command receipt with its decision`, ); assert.equal(added[0].state, "Complete"); - const body = JSON.parse(added[0].body); + const body: Row = yield* Schema.decodeEffect(UnknownJson)(added[0].body); assert.equal( added[0].operation_id, { @@ -829,7 +927,7 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { if (step !== "completed") { assert.deepEqual( - body.affiliations.map(({ personId, status, revision }) => [ + body.affiliations.map(({ personId, status, revision }: Row) => [ personId, status, revision, @@ -838,7 +936,7 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { ); assert.deepEqual( body.placements.map( - ({ placementId, personId, schoolId, day, block, workdays, active }) => [ + ({ placementId, personId, schoolId, day, block, workdays, active }: Row) => [ placementId, personId, schoolId, @@ -848,7 +946,7 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { active, ], ), - facts.placements.map((p) => [ + facts.placements.map((p: Row) => [ p.placement_id, p.person_id, Number(p.school_id), @@ -859,13 +957,13 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { ]), ); assert.deepEqual( - body.demands.map(({ schoolId, day, block, requiredVolunteers }) => [ + body.demands.map(({ schoolId, day, block, requiredVolunteers }: Row) => [ schoolId, day, block, requiredVolunteers, ]), - facts.demands.map((d) => [ + facts.demands.map((d: Row) => [ Number(d.school_id), d.day, d.block, @@ -873,7 +971,7 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { ]), ); - if (facts.proposals.length) { + if (facts.proposals.length > 0) { assert.equal(body.proposal.proposalId, facts.proposals[0].proposal_id); assert.equal(body.proposal.status, facts.proposals[0].status); assert.deepEqual(body.proposal.assignments, facts.proposals[0].assignment_snapshot); @@ -883,7 +981,7 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { assert.equal(body.commitments.length, facts.commitments.length); - if (facts.commitments.length) { + if (facts.commitments.length > 0) { const returned = body.commitments[0]; assert.deepEqual( [ @@ -931,7 +1029,11 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { } if (step === "independent-read") baseline = facts; - const digest = createHash("sha256").update(JSON.stringify(facts)).digest("hex"); + + const digest = createHash("sha256") + .update(yield* Schema.encodeEffect(UnknownJson)(facts)) + .digest("hex"); + observations.push({ step, boundary: "independent-postgresql", @@ -946,8 +1048,8 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { ...facts.placementHistory, ...facts.serviceHistory, ...facts.coverageHistory, - ].map((row) => ({ action: row.action, actor: row.actor_person_id })), - successfulReceipts: facts.receipts.map(({ identity_sha256, operation_id, status }) => ({ + ].map((row: Row) => ({ action: row.action, actor: row.actor_person_id })), + successfulReceipts: facts.receipts.map(({ identity_sha256, operation_id, status }: Row) => ({ identity: identity_sha256, operation: operation_id, status, @@ -961,7 +1063,7 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { occurrences: facts.occurrences, closures: facts.closures, }, - effects: facts.notifications.map(({ effect_id, person_id }) => ({ + effects: facts.notifications.map(({ effect_id, person_id }: Row) => ({ effectId: effect_id, personId: person_id, })), @@ -969,32 +1071,32 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { previous = facts; return observations.at(-1); - }; + }); - const finish = async () => { + const finish = Effect.fnUntraced(function* () { assert.deepEqual( - observations.map(({ step }) => step), + observations.map(({ step }: Row) => step), goldenSteps, ); - const deadline = Date.now() + 15000; + const deadline = (yield* Clock.currentTimeMillis) + 15000; let notification; do { - notification = ( - await pool.query( + notification = (yield* journeyStep(() => + pool.query( "SELECT effect_id,proposal_id,person_id,status,attempts,payload_json FROM school_service_notification_outbox", - ) - ).rows[0]; + ), + )).rows[0]; if (notification?.status === "Delivered") break; - await new Promise((resolve) => setTimeout(resolve, 50)); - } while (Date.now() < deadline); + yield* Effect.sleep("50 millis"); + } while ((yield* Clock.currentTimeMillis) < deadline); assert.equal(notification?.status, "Delivered"); const captures = deliveries; assert.ok( captures.every( - (item) => + (item: Row) => item.idempotencyKey === notification.effect_id && item.body.effectId === notification.effect_id, ), @@ -1022,7 +1124,7 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { realProviderAcceptance: false, }, }; - }; + }); return { observe, observations, finish }; }; diff --git a/tools/e2e/golden-team-application-evidence.ts b/tools/e2e/golden-team-application-evidence.ts index 483402513..6da34e0b9 100644 --- a/tools/e2e/golden-team-application-evidence.ts +++ b/tools/e2e/golden-team-application-evidence.ts @@ -10,6 +10,7 @@ import assert from "node:assert/strict"; import { canonicalJsonBytes, sha256Hex } from "@vektorprogrammet/domain/shared-kernel"; import { Effect, Option, Schema } from "effect"; +import { dual } from "effect/Function"; import type { Pool, PoolClient } from "pg"; import type { ApplicantInput, @@ -21,7 +22,13 @@ import type { TeamApplicationFixture, TeamApplicationStep, } from "../../apps/dashboard/e2e/golden-team-application-browser"; -import { failure, type ProviderAttempt, readOnlySnapshot, selectRows } from "./golden-harness"; +import { + failure, + type HarnessFailure, + type ProviderAttempt, + readOnlySnapshot, + selectRows, +} from "./golden-harness"; export const mailSender = "noreply.syntetisk@example.invalid"; @@ -126,37 +133,43 @@ const memberships = (fixture: TeamApplicationFixture): ReadonlyArray ]; /** Prerequisites only: no application, intake setting, deletion, or delivery outcome. */ -export const seedTeamApplicationFixture = (pool: Pool, fixture: TeamApplicationFixture) => - Effect.forEach( - [ - { - text: "INSERT INTO organization_departments(department_id,name,short_name,email,city,active,revision) VALUES($1,'Vektorprogrammet Syntetisk','Syntetisk',$2,'Trondheim',true,0)", - values: [departmentId, fixture.departmentEmail], - }, - ...[fixture.alfa, fixture.beta, fixture.gamma, fixture.inactive].map((team) => ({ - text: "INSERT INTO organization_teams(team_id,department_id,name,email,active,revision) VALUES($1,$2,$3,$4,$5,0)", - values: [team.teamId, departmentId, team.name, team.email, team !== fixture.inactive], - })), - ...Object.values(fixture.persons).map((staff) => ({ - text: "INSERT INTO person_contact_profiles(person_id,email,phone,revision) VALUES($1,$2,'90000000',0)", - values: [staff.personId, staff.email], - })), - ...memberships(fixture).map((membership) => ({ - text: "INSERT INTO organization_memberships(membership_id,person_id,team_id,start_at,end_at,is_team_leader,is_suspended,revision) VALUES($1,$2,$3,'2020-01-01T00:00:00Z',$4,$5,$6,0)", - values: [ - fixture.persons[membership.role].membershipId, - fixture.persons[membership.role].personId, - membership.team.teamId, - membership.endAt, - membership.leader, - membership.suspended, - ], - })), - ], - ({ text, values }) => - Effect.tryPromise({ try: () => pool.query(text, values), catch: failure("fixture") }), - { discard: true }, - ); +export const seedTeamApplicationFixture: { + (fixture: TeamApplicationFixture): (pool: Pool) => Effect.Effect; + (pool: Pool, fixture: TeamApplicationFixture): Effect.Effect; +} = dual( + 2, + (pool: Pool, fixture: TeamApplicationFixture): Effect.Effect => + Effect.forEach( + [ + { + text: "INSERT INTO organization_departments(department_id,name,short_name,email,city,active,revision) VALUES($1,'Vektorprogrammet Syntetisk','Syntetisk',$2,'Trondheim',true,0)", + values: [departmentId, fixture.departmentEmail], + }, + ...[fixture.alfa, fixture.beta, fixture.gamma, fixture.inactive].map((team) => ({ + text: "INSERT INTO organization_teams(team_id,department_id,name,email,active,revision) VALUES($1,$2,$3,$4,$5,0)", + values: [team.teamId, departmentId, team.name, team.email, team !== fixture.inactive], + })), + ...Object.values(fixture.persons).map((staff) => ({ + text: "INSERT INTO person_contact_profiles(person_id,email,phone,revision) VALUES($1,$2,'90000000',0)", + values: [staff.personId, staff.email], + })), + ...memberships(fixture).map((membership) => ({ + text: "INSERT INTO organization_memberships(membership_id,person_id,team_id,start_at,end_at,is_team_leader,is_suspended,revision) VALUES($1,$2,$3,'2020-01-01T00:00:00Z',$4,$5,$6,0)", + values: [ + fixture.persons[membership.role].membershipId, + fixture.persons[membership.role].personId, + membership.team.teamId, + membership.endAt, + membership.leader, + membership.suspended, + ], + })), + ], + ({ text, values }) => + Effect.tryPromise({ try: () => pool.query(text, values), catch: failure("fixture") }), + { discard: true }, + ), +); const utc = (column: string) => `to_char((${column}) AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`; @@ -328,16 +341,32 @@ const readFacts = (client: PoolClient) => export const snapshotFacts = (pool: Pool) => readOnlySnapshot(pool, readFacts); /** Delivery states of one application's notifications, for delivery waits. */ -export const notificationStates = (pool: Pool, applicationId: string) => - readOnlySnapshot(pool, (client) => - selectRows( - client, - "notification states", - "SELECT status, attempts FROM team_application_delivery_state WHERE application_id=$1 ORDER BY ordinal", - [applicationId], - Schema.Struct({ status: Schema.String, attempts: Schema.Int }), +type NotificationState = { readonly status: string; readonly attempts: number }; + +export const notificationStates: { + ( + applicationId: string, + ): (pool: Pool) => Effect.Effect, HarnessFailure>; + ( + pool: Pool, + applicationId: string, + ): Effect.Effect, HarnessFailure>; +} = dual( + 2, + ( + pool: Pool, + applicationId: string, + ): Effect.Effect, HarnessFailure> => + readOnlySnapshot(pool, (client) => + selectRows( + client, + "notification states", + "SELECT status, attempts FROM team_application_delivery_state WHERE application_id=$1 ORDER BY ordinal", + [applicationId], + Schema.Struct({ status: Schema.String, attempts: Schema.Int }), + ), ), - ); +); /** Total delivery attempts of undelivered notifications; a restarted worker must raise it. */ export const retainedAttempts = (pool: Pool) => diff --git a/tools/e2e/golden-team-application.ts b/tools/e2e/golden-team-application.ts index a62e36cd2..817d7c2b8 100644 --- a/tools/e2e/golden-team-application.ts +++ b/tools/e2e/golden-team-application.ts @@ -10,9 +10,8 @@ * Usage: bun --no-env-file tools/e2e/golden-team-application.ts * Faults: GOLDEN_TEAM_APPLICATION_FAULT=after-submitted|interrupt-after-submitted */ -import { join } from "node:path"; import { canonicalJsonBytes, sha256Hex } from "@vektorprogrammet/domain/shared-kernel"; -import { Crypto, Effect, Exit, FileSystem, Path, Scope, Stdio, Terminal } from "effect"; +import { Crypto, Effect, Exit, FileSystem, Path, Schema, Scope, Stdio, Terminal } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process"; import { runTeamApplicationBrowser, @@ -41,20 +40,27 @@ const homepageHost = "p000.vektor.phibkro.org"; const steps = teamApplicationCheckpoints.map(({ step }) => step); +/** The JSON text of an environment value. */ +const jsonText = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +/** The indented JSON text of an artifact. */ +const prettyJsonText = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown, { space: 2 })); + /** Digests every file below one build output directory for `browser-build.json`. */ const buildInventory = (context: GoldenContext, directory: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const root = join(context.root, directory); + const path = yield* Path.Path; + const root = path.join(context.root, directory); const names = (yield* fs.readDirectory(root, { recursive: true })).sort(); const files = yield* Effect.forEach(names, (name) => Effect.gen(function* () { - const path = join(root, name); + const file = path.join(root, name); - if ((yield* fs.stat(path)).type !== "File") return []; + if ((yield* fs.stat(file)).type !== "File") return []; - const bytes = yield* fs.readFile(path); + const bytes = yield* fs.readFile(file); return [{ bytes: bytes.length, path: name, sha256: sha256Hex(bytes) }]; }), @@ -77,8 +83,7 @@ const journey: GoldenJourney = { "apps/homepage", "packages/domain", "packages/database", - "packages/http-api", - "packages/sdk", + "packages/rpc", "tools/e2e", ], steps, @@ -89,6 +94,7 @@ const journey: GoldenJourney = { body: (context) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const [postgresPort = 0, apiPort = 0, dashboardPort = 0, homepagePort = 0, providerPort = 0] = yield* context.reservePorts(5); @@ -118,7 +124,7 @@ const journey: GoldenJourney = { for (const directory of ["receipt-staging", "receipt-committed"]) yield* fs - .makeDirectory(join(context.privateRoot, directory), { mode: 0o700 }) + .makeDirectory(path.join(context.privateRoot, directory), { mode: 0o700 }) .pipe(Effect.mapError(failure("receipt roots"))); const backendEnvironment = { @@ -129,12 +135,12 @@ const journey: GoldenJourney = { BACKEND_PG_URL: database.url, BETTER_AUTH_SECRET: authSecret, NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify([origins.dashboard]), + NATIVE_IDENTITY_TRUSTED_ORIGINS: jsonText([origins.dashboard]), OAUTH_CANONICAL_ORIGIN: origins.backend, OAUTH_DASHBOARD_ORIGIN: origins.dashboard, OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - RECEIPT_STAGING_ROOT: join(context.privateRoot, "receipt-staging"), - RECEIPT_COMMITTED_ROOT: join(context.privateRoot, "receipt-committed"), + RECEIPT_STAGING_ROOT: path.join(context.privateRoot, "receipt-staging"), + RECEIPT_COMMITTED_ROOT: path.join(context.privateRoot, "receipt-committed"), PUBLIC_APPLICATION_EFFECT_MODE: "disabled", PASSWORD_RESET_DELIVERY_MODE: "disabled", RECEIPT_DELIVERY_MODE: "disabled", @@ -166,7 +172,7 @@ const journey: GoldenJourney = { env: { ...backendEnvironment, IDENTITY_SEED_PG_URL: database.url, - IDENTITY_SEED_PERSONS: JSON.stringify(identitySeedPersons(fixture)), + IDENTITY_SEED_PERSONS: jsonText(identitySeedPersons(fixture)), }, }, "180 seconds", @@ -203,18 +209,7 @@ const journey: GoldenJourney = { let backendScope = yield* bootBackend; - yield* context.run( - { - label: "sdk-build", - command: "bun", - args: ["--no-env-file", "run", "--cwd", "packages/sdk", "build"], - cwd: context.root, - env: context.environment, - }, - "180 seconds", - ); - - const dashboardRoot = join(context.root, "apps/dashboard"); + const dashboardRoot = path.join(context.root, "apps/dashboard"); const dashboardEnvironment = { ...context.environment, @@ -239,7 +234,7 @@ const journey: GoldenJourney = { "600 seconds", ); - const homepageRoot = join(context.root, "apps/homepage"); + const homepageRoot = path.join(context.root, "apps/homepage"); // The Cloudflare plugin serializes preview variables at build time; process // variables are included only on request. The inherited set is the allow-list. @@ -267,12 +262,12 @@ const journey: GoldenJourney = { yield* fs .writeFileString( - join(context.artifacts, "browser-build.json"), - JSON.stringify( - { revision: context.revision, sourceTree: context.sourceTree, builds }, - null, - 2, - ), + path.join(context.artifacts, "browser-build.json"), + prettyJsonText({ + revision: context.revision, + sourceTree: context.sourceTree, + builds, + }), { mode: 0o600 }, ) .pipe(Effect.mapError(failure("browser-build"))); diff --git a/tools/e2e/journey-clock.ts b/tools/e2e/journey-clock.ts index 7e4c08d20..ca2583bdf 100644 --- a/tools/e2e/journey-clock.ts +++ b/tools/e2e/journey-clock.ts @@ -13,8 +13,9 @@ * itself, such as a runner's fixed clock. The Oxlint rule `anti-slop/no-literal-window-instant` * rejects literal window bounds and accepts a literal instant as the argument of `journeyClock`. * - * The module has no dependencies, so Node and Bun runners can both import it. + * The module depends only on Effect, so Node and Bun runners, seeds, and specs can all import it. */ +import { DateTime, Option } from "effect"; /** Instants relative to one reference instant. */ export interface JourneyClock { @@ -32,7 +33,7 @@ export interface JourneyClock { * adds whole days and minutes to it, or subtracts them for negative offsets. Seeds, drivers, and * specs that take their instants from one clock agree with each other on any date. * - * @throws An `Error` when `reference` is not an instant that `Date.parse` reads. + * @throws An `Error` when `reference` is not an instant that `DateTime.make` reads. * * @sideEffects none * @@ -48,16 +49,18 @@ export interface JourneyClock { * @construct test-harness */ export const journeyClock = (reference: string): JourneyClock => { - const referenceTime = Date.parse(reference); + const parsed = DateTime.make(reference); - if (!Number.isFinite(referenceTime)) { + if (Option.isNone(parsed)) { throw new Error(`A journey clock reference must be an RFC 3339 instant: ${reference}`); } + const referenceTime = DateTime.toEpochMillis(parsed.value); + return { - now: new Date(referenceTime).toISOString(), + now: DateTime.formatIso(parsed.value), fromNow: (days, minutes = 0) => - new Date(referenceTime + days * 86_400_000 + minutes * 60_000).toISOString(), + DateTime.formatIso(DateTime.makeUnsafe(referenceTime + days * 86_400_000 + minutes * 60_000)), }; }; @@ -70,7 +73,7 @@ export const journeyClock = (reference: string): JourneyClock => { * reads, and gives a `journeyClock` at that instant, or at the current time when it is unset. So * the fixture instants of a journey lie where the backend it starts sees them. * - * @throws An `Error` when `ADMISSION_FIXED_NOW` is set to text that `Date.parse` does not read. + * @throws An `Error` when `ADMISSION_FIXED_NOW` is set to text that `DateTime.make` does not read. * * @sideEffects Reads `ADMISSION_FIXED_NOW` from the environment, and the current time when it is * unset. @@ -87,11 +90,12 @@ export const journeyClock = (reference: string): JourneyClock => { * @construct test-harness */ export const admissionJourneyClock = (): JourneyClock => { + // oxlint-disable-next-line effecttsgo/process-env -- EX-0019: seeds, specs, and drivers outside an Effect runtime call this clock synchronously, so it reads the variable at each call const fixedNow = process.env.ADMISSION_FIXED_NOW; - if (fixedNow !== undefined && !Number.isFinite(Date.parse(fixedNow))) { + if (fixedNow !== undefined && Option.isNone(DateTime.make(fixedNow))) { throw new Error("ADMISSION_FIXED_NOW must be an RFC 3339 instant"); } - return journeyClock(fixedNow ?? new Date().toISOString()); + return journeyClock(fixedNow ?? DateTime.formatIso(DateTime.nowUnsafe())); }; diff --git a/tools/e2e/legacy-candidate-native-journey.ts b/tools/e2e/legacy-candidate-native-journey.ts index ba4ab172f..885ab9dc1 100644 --- a/tools/e2e/legacy-candidate-native-journey.ts +++ b/tools/e2e/legacy-candidate-native-journey.ts @@ -20,19 +20,25 @@ import { OwnAffiliationResource, PlacementBoardResource, PlacementScopes, + nativeRpcPath, + OwnProfileResource, + ReceiptFileContent, ReceiptListResponse, - UserProfileResponse, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Effect, Layer, ManagedRuntime, Redacted, Schema } from "effect"; import { Etag, FetchHttpClient, HttpRouter } from "effect/unstable/http"; import { ReceiptDeliveryLive } from "@vektorprogrammet/backend/receipt/delivery"; import { backendHttpHandler, decodeBackendConfig, - ExternalNativeApiRouterLive, - nativeHttpRouterConfig, + ExternalNativeRpcRouterLive, nativeRouterWebHandler, } from "@vektorprogrammet/backend"; +import { + nativeRpcRequestBody, + nativeRpcStatus, + nativeRpcValue, +} from "../../apps/dashboard/e2e/native-operations.js"; import type { RehearsalTarget } from "./legacy-organization-rehearsal-runtime.js"; export interface CandidateNativeIdentity { @@ -124,16 +130,9 @@ export const observeLegacyCandidateNativeJourney = async ( AuthLive(config.auth).pipe(Layer.provide(database)), ); - const http = Layer.mergeAll( - platform, - BunHttpPlatform.layer, - Etag.layer, - HttpRouter.layer.pipe( - Layer.provide(Layer.succeed(HttpRouter.RouterConfig)(nativeHttpRouterConfig)), - ), - ); + const http = Layer.mergeAll(platform, BunHttpPlatform.layer, Etag.layer, HttpRouter.layer); - const nativeApi = ExternalNativeApiRouterLive(config, { now: () => input.asOf }).pipe( + const nativeApi = ExternalNativeRpcRouterLive({ config, now: () => input.asOf }).pipe( HttpRouter.provideRequest(Layer.merge(services, platform)), Layer.provide(services), Layer.provide(http), @@ -182,20 +181,116 @@ export const observeLegacyCandidateNativeJourney = async ( checks.push(name); }; - const json = async >( + /** Reads the caller's own profile over RPC and answers its status under the HTTP contract. */ + const readOwnProfile = async (name: string, cookie?: string) => { + phase = name; + const headers = new Headers({ origin: dashboardOrigin, "content-type": "application/json" }); + + if (cookie !== undefined) headers.set("cookie", cookie); + + const response = await runtime.runPromise( + api( + new Request(backendOrigin + nativeRpcPath, { + method: "POST", + headers, + body: nativeRpcRequestBody("profile.readOwnProfile"), + }), + ), + ); + + const answer = await response.text(); + + return { status: nativeRpcStatus(answer), value: nativeRpcValue(answer) }; + }; + + /** + * One receipt RPC as a browser posts it, with its credential and origin as HTTP headers, + * answered with its status under the HTTP contract and its value. + */ + const receiptRpc = ( name: string, - path: string, - schema: S, - cookie: string, + tag: "receipts.listReceipts" | "receipts.readReceiptFile", + payload: Schema.Json, + cookie?: string, + authorization?: string, ) => { phase = name; - const response = await request(path, cookie); - assert.equal(response.status, 200); - const result = Schema.decodeUnknownSync(schema)(await response.json()); + const headers = new Headers({ origin: dashboardOrigin, "content-type": "application/json" }); + + if (cookie !== undefined) headers.set("cookie", cookie); - return result; + if (authorization !== undefined) headers.set("authorization", authorization); + + const request = new Request(backendOrigin + nativeRpcPath, { + method: "POST", + headers, + body: nativeRpcRequestBody(tag, payload), + }); + + return runtime + .runPromise(api(request)) + .then((response) => response.text()) + .then((answer) => ({ status: nativeRpcStatus(answer), value: nativeRpcValue(answer) })); }; + const ownProfile = async (name: string, cookie: string) => { + const answer = await readOwnProfile(name, cookie); + assert.equal(answer.status, 200); + + return Schema.decodeUnknownSync(OwnProfileResource)(answer.value).profile; + }; + + const ownProfileStatus = async (name: string, expected: number, cookie?: string) => { + assert.equal((await readOwnProfile(name, cookie)).status, expected); + checks.push(name); + }; + + /** Calls one RPC as a browser would, and answers its status under the HTTP contract. */ + const callRpc = (name: string, tag: string, payload: Schema.Json, cookie?: string) => { + phase = name; + const headers = new Headers({ origin: dashboardOrigin, "content-type": "application/json" }); + + if (cookie !== undefined) headers.set("cookie", cookie); + + return runtime + .runPromise( + api( + new Request(backendOrigin + nativeRpcPath, { + method: "POST", + headers, + body: nativeRpcRequestBody(tag, payload), + }), + ), + ) + .then((response) => response.text()) + .then((answer) => ({ status: nativeRpcStatus(answer), value: nativeRpcValue(answer) })); + }; + + const rpcJson = >( + name: string, + tag: string, + payload: Schema.Json, + schema: S, + cookie: string, + ) => + callRpc(name, tag, payload, cookie).then((answer) => { + assert.equal(answer.status, 200); + + return Schema.decodeSync(schema)(answer.value); + }); + + const rpcStatus = ( + name: string, + tag: string, + payload: Schema.Json, + expected: number, + cookie?: string, + ) => + callRpc(name, tag, payload, cookie).then((answer) => { + assert.equal(answer.status, expected); + checks.push(name); + }); + const signIn = async (label: string, identity: CandidateNativeIdentity) => { phase = `${label}-native-sign-in`; @@ -232,12 +327,7 @@ export const observeLegacyCandidateNativeJourney = async ( }; for (const label of ["leader", "member", "otherDepartment"] as const) { - const result = await json( - `${label}-own-profile`, - "/api/profile", - UserProfileResponse, - cookies[label], - ); + const result = await ownProfile(`${label}-own-profile`, cookies[label]); const identity = input.identities[label]; assert.equal(result.personId, identity.personId); @@ -248,18 +338,14 @@ export const observeLegacyCandidateNativeJourney = async ( checks.push(phase); } - await status( - "historical-profile-authority-denied", - "/api/profile", - 403, - cookies.historicalLeader, - ); - await status("anonymous-profile-denied", "/api/profile", 401); + await ownProfileStatus("historical-profile-authority-denied", 403, cookies.historicalLeader); + await ownProfileStatus("anonymous-profile-denied", 401); for (const label of ["leader", "member", "historicalLeader", "otherDepartment"] as const) { - const scopes = await json( + const scopes = await rpcJson( `${label}-native-scope`, - "/api/placements/scopes", + "placements.listScopes", + null, PlacementScopes, cookies[label], ); @@ -302,11 +388,12 @@ export const observeLegacyCandidateNativeJourney = async ( ); } - const affiliationPath = `/api/placements/affiliation?${new URLSearchParams({ departmentId: input.scope.departmentId }).toString()}`; + const affiliationScope = { departmentId: input.scope.departmentId }; - const ownAffiliation = await json( + const ownAffiliation = await rpcJson( "member-imported-affiliation", - affiliationPath, + "placements.readOwnAffiliation", + affiliationScope, OwnAffiliationResource, cookies.member, ); @@ -315,9 +402,10 @@ export const observeLegacyCandidateNativeJourney = async ( assert.equal(ownAffiliation.status, "Active"); checks.push(phase); - const foreignAffiliation = await json( + const foreignAffiliation = await rpcJson( "other-affiliation-isolation", - affiliationPath, + "placements.readOwnAffiliation", + affiliationScope, OwnAffiliationResource, cookies.otherDepartment, ); @@ -325,19 +413,29 @@ export const observeLegacyCandidateNativeJourney = async ( assert.equal(foreignAffiliation.personId, input.identities.otherDepartment.personId); assert.equal(foreignAffiliation.status, "Absent"); checks.push(phase); - await status( - "affiliation-person-override-rejected", - `${affiliationPath}&personId=${encodeURIComponent(input.identities.member.personId)}`, - 400, + + // The RPC payload has no person selector: a forged one is dropped, and the caller still reads + // only their own affiliation. + const overridden = await rpcJson( + "affiliation-person-override-ignored", + "placements.readOwnAffiliation", + { ...affiliationScope, personId: input.identities.member.personId }, + OwnAffiliationResource, cookies.otherDepartment, ); - const boardPath = (departmentId: string) => - `/api/placements?${new URLSearchParams({ departmentId, semesterId: input.scope.semesterId }).toString()}`; + assert.equal(overridden.personId, input.identities.otherDepartment.personId); + checks.push(phase); + + const boardScope = (departmentId: string) => ({ + departmentId, + semesterId: input.scope.semesterId, + }); - const board = await json( + const board = await rpcJson( "leader-imported-placement", - boardPath(input.scope.departmentId), + "placements.readBoard", + boardScope(input.scope.departmentId), PlacementBoardResource, cookies.leader, ); @@ -350,34 +448,44 @@ export const observeLegacyCandidateNativeJourney = async ( ), ); checks.push(phase); - await status( + await rpcStatus( "leader-other-placement-scope-denied", - boardPath(input.scope.otherDepartmentId), + "placements.readBoard", + boardScope(input.scope.otherDepartmentId), 403, cookies.leader, ); for (const label of ["member", "historicalLeader", "otherDepartment"] as const) { - await status( + await rpcStatus( `${label}-placement-board-denied`, - boardPath(input.scope.departmentId), + "placements.readBoard", + boardScope(input.scope.departmentId), 403, cookies[label], ); } - await status("anonymous-placement-denied", boardPath(input.scope.departmentId), 401); + await rpcStatus( + "anonymous-placement-denied", + "placements.readBoard", + boardScope(input.scope.departmentId), + 401, + ); assert.equal(input.receipt.ownerPersonId, input.identities.member.personId); for (const label of ["leader", "member", "historicalLeader", "otherDepartment"] as const) { - const receipts = await json( + const listed = await receiptRpc( `${label}-receipt-owner-isolation`, - "/api/receipts", - ReceiptListResponse, + "receipts.listReceipts", + {}, cookies[label], ); + assert.equal(listed.status, 200); + const receipts = Schema.decodeUnknownSync(ReceiptListResponse)(listed.value); + assert.ok( receipts.items.every((item) => item.ownerPersonId === input.identities[label].personId), ); @@ -388,34 +496,54 @@ export const observeLegacyCandidateNativeJourney = async ( checks.push(phase); } - const filePath = `/api/receipts/${encodeURIComponent(input.receipt.receiptId)}/file`; - phase = "owner-private-receipt-bytes"; - const downloaded = await request(filePath, cookies.member); + const fileRead = { receiptId: input.receipt.receiptId }; + + const downloaded = await receiptRpc( + "owner-private-receipt-bytes", + "receipts.readReceiptFile", + fileRead, + cookies.member, + ); + assert.equal(downloaded.status, 200); assert.equal( createHash("sha256") - .update(Buffer.from(await downloaded.arrayBuffer())) + .update(Buffer.from(Schema.decodeUnknownSync(ReceiptFileContent)(downloaded.value).bytes)) .digest("hex"), input.receipt.sha256, ); checks.push(phase); for (const label of ["leader", "historicalLeader", "otherDepartment"] as const) { - await status(`${label}-private-receipt-denied`, filePath, 404, cookies[label]); + const denied = await receiptRpc( + `${label}-private-receipt-denied`, + "receipts.readReceiptFile", + fileRead, + cookies[label], + ); + + assert.equal(denied.status, 404); + checks.push(phase); } - await status("anonymous-private-receipt-denied", filePath, 401); - phase = "invalid-bearer-no-cookie-fallback"; + const anonymous = await receiptRpc( + "anonymous-private-receipt-denied", + "receipts.readReceiptFile", + fileRead, + ); + + assert.equal(anonymous.status, 401); + checks.push(phase); - const invalidBearer = await request( - filePath, + const invalidBearer = await receiptRpc( + "invalid-bearer-no-cookie-fallback", + "receipts.readReceiptFile", + fileRead, cookies.member, - undefined, "Bearer candidate-invalid", ); assert.equal(invalidBearer.status, 401); - await invalidBearer.body?.cancel(); checks.push(phase); if (input.changeMemberPasswordTo !== undefined) { @@ -435,12 +563,7 @@ export const observeLegacyCandidateNativeJourney = async ( password: input.changeMemberPasswordTo, }); - const afterChange = await json( - "changed-password-same-person", - "/api/profile", - UserProfileResponse, - cookies.member, - ); + const afterChange = await ownProfile("changed-password-same-person", cookies.member); assert.equal(afterChange.personId, input.identities.member.personId); checks.push(phase); diff --git a/tools/e2e/package.json b/tools/e2e/package.json index 8ee4c5a4e..e1245f150 100644 --- a/tools/e2e/package.json +++ b/tools/e2e/package.json @@ -16,7 +16,7 @@ "@vektorprogrammet/backend": "workspace:*", "@vektorprogrammet/database": "workspace:*", "@vektorprogrammet/domain": "workspace:*", - "@vektorprogrammet/http-api": "workspace:*", + "@vektorprogrammet/rpc": "workspace:*", "effect": "catalog:effect-v4", "mysql2": "^3.15.3", "pg": "8" diff --git a/tools/e2e/placement-check.ts b/tools/e2e/placement-check.ts index d282fd91d..b931a4c13 100644 --- a/tools/e2e/placement-check.ts +++ b/tools/e2e/placement-check.ts @@ -1,239 +1,392 @@ -import { createPromiseClient } from "../../packages/sdk/src/promise.js"; +/** + * Placement journeys with an owned process lifecycle (0096/0110/0111): the broad placement API + * (`--api-only`) and its browser acceptance (`--browser`), the golden school-service journey + * (`--golden-school-service`), and the golden recruitment journey (`--golden-recruitment`). + * + * Boots a private PostgreSQL cluster, a loopback notification receiver and checkpoint observer, + * and the native Bun backend on reserved loopback ports, drives the journey, and writes the + * evidence and the `native-functional-journey/v1` receipt. It exits 0 when the journey passed, + * 130 after SIGINT, 143 after SIGTERM, and 1 otherwise, also when cleanup fails. + * + * Usage: bun --no-env-file tools/e2e/placement-check.ts --browser | --api-only | --golden-school-service | --golden-recruitment + * Faults: GOLDEN_SCHOOL_SERVICE_FAULT, which the browser driver of the golden journey reads. + */ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import { + type DisposablePostgres, + loopbackPortFree, + postgresVersion, + reserveLoopbackPorts, + startDisposablePostgres, +} from "@monoweb/postgres"; import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; import { + AffiliationScope, CoverageCommand, + OwnAffiliationCommand, OwnCoverageCommand, PlacementCommand, PlacementScope, SchoolServiceNotificationRequest, } from "@vektorprogrammet/domain/placements"; -import { IdempotencyIfMatchHeaders } from "@vektorprogrammet/http-api/http-semantics"; -/** 0096/0110/0111 real local API + browser acceptance with an owned process lifecycle. */ -import assert from "node:assert/strict"; -import { execFileSync, spawn, type ChildProcess } from "node:child_process"; -import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; -import { randomBytes, createHash } from "node:crypto"; -import { mkdtemp, writeFile, readFile, readdir, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { createRequire } from "node:module"; +import { IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { nativeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; import { - loopbackPortFree, - postgresVersion, - reserveLoopbackPorts, - startDisposablePostgres, - type DisposablePostgres, -} from "@monoweb/postgres"; -import { Schema, Record as Rec, Struct } from "effect"; -import { createGoldenObserver, goldenFaults, goldenSteps } from "./golden-school-service.mjs"; + Config, + Data, + Deferred, + Effect, + Exit, + FiberSet, + FileSystem, + Layer, + Option, + Path, + Record as Rec, + Schema, + Scope, + Stream, + Struct, +} from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { ChildProcess } from "effect/unstable/process"; +import { Pool } from "pg"; +import { answersOk, indentedJsonText } from "../acceptance/acceptance-process"; +import { + committed, + firstSetCookie, + jsonText, + type JourneyStepFailed, + step, + thrownBy, +} from "../acceptance/journey-step"; +import { createGoldenObserver, goldenFaults, goldenSteps } from "./golden-school-service"; import { goldenArtifactName, goldenRunnerPaths, redactDiagnostic, redactedEvidenceJson, -} from "./golden-school-service-evidence.mjs"; + sha256, +} from "./golden-school-service-evidence"; import { - recruitmentSteps, - recruitmentRunnerPaths, + createRecruitmentMailbox, + createRecruitmentObserver, recruitmentFixture, recruitmentPeople, + recruitmentRunnerPaths, + recruitmentSteps, seedRecruitment, - createRecruitmentMailbox, - createRecruitmentObserver, -} from "./golden-recruitment.mjs"; -import { admissionJourneyClock } from "./journey-clock.ts"; +} from "./golden-recruitment"; +import { admissionJourneyClock } from "./journey-clock"; + +const modes = ["--browser", "--api-only", "--golden-school-service", "--golden-recruitment"]; + +/** The variables that the journey passes from the operator shell to its children. */ +const safeEnvironmentKeys = [ + "PATH", + "HOME", + "TMPDIR", + "LANG", + "LC_ALL", + "TZ", + "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", + "PLAYWRIGHT_NODE_EXECUTABLE", + "PLAYWRIGHT_BROWSERS_PATH", + "GOLDEN_PROCESS_GROUPS_PATH", +]; -const root = new URL("../../", import.meta.url).pathname; +type Environment = Record; -// Claimed invitations expire after the run. Nothing reads the expiry of a claimed invitation, -// but it must follow the issue instant. -const claimExpiresAt = admissionJourneyClock().fromNow(120); +type NotificationCapture = { + authorization?: string; + idempotencyKey?: string; + readonly body: Payload; +}; -const requireDatabase = createRequire( - new URL("../../packages/database/package.json", import.meta.url), -); +/** A step of the journey that failed: a command, a child, or a readiness wait. */ +class PlacementCheckFailure extends Data.TaggedError("PlacementCheckFailure")<{ + readonly message: string; +}> {} + +/** One child process that the journey owns: its release stops it, escalating to SIGKILL. */ +interface OwnedChild { + readonly pid: number; + readonly scope: Scope.Closeable; + /** The exit code, or null when a signal ended the process. */ + readonly exit: Deferred.Deferred; +} -const { Pool } = requireDatabase("pg"); +const DiagnosticLine = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Json)); -const run = (command: string, args: string[], env = process.env, timeout = 60_000) => - execFileSync(command, args, { cwd: root, env, encoding: "utf8", timeout }); +const BrowserEvidenceJson = Schema.fromJsonString(Schema.JsonObject); -const runAsync = async (command: string, args: string[], env = process.env, timeout = 60_000) => { - const child = start(command, args, env); - let output = ""; - child.stdout?.on("data", (chunk) => { - output += String(chunk); - }); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - void stopChild(child).then(() => reject(Error(command + " timed out")), reject); - }, timeout); - - child.once("error", (error) => { - clearTimeout(timer); - reject(error); - }); - child.once("exit", (code) => { - clearTimeout(timer); +/** The JSON text of a document; decoding it gives the plain JSON value that the text writes. */ +const JsonDocument = Schema.fromJsonString(Schema.Json); - if (code === 0) resolve(); - else reject(Error(command + " exited " + code)); - }); - }); +const NotificationRequestJson = Schema.fromJsonString(SchoolServiceNotificationRequest); - return output; -}; +const program = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = fileURLToPath(new URL("../../", import.meta.url)); -const mode = process.argv[2]; + // Claimed invitations expire after the run. Nothing reads the expiry of a claimed invitation, + // but it must follow the issue instant. + const claimExpiresAt = admissionJourneyClock().fromNow(120); -assert.ok( - process.argv.length === 3 && - ["--browser", "--api-only", "--golden-school-service", "--golden-recruitment"].includes( - mode ?? "", - ), - "Usage: bun run tools/e2e/placement-check.ts --browser | --api-only | --golden-school-service | --golden-recruitment", -); + const mode = process.argv[2] ?? ""; -const recruitment = mode === "--golden-recruitment"; + assert.ok( + process.argv.length === 3 && modes.includes(mode), + "Usage: bun run tools/e2e/placement-check.ts --browser | --api-only | --golden-school-service | --golden-recruitment", + ); -const recruitmentMailbox = createRecruitmentMailbox(); + const recruitment = mode === "--golden-recruitment"; -const revision = run("git", ["rev-parse", "HEAD"]).trim(); + const recruitmentMailbox = createRecruitmentMailbox(); -const sourceTree = run("git", ["rev-parse", "HEAD^{tree}"]).trim(); + const children: Array = []; -assert.equal(run("git", ["status", "--porcelain"]).trim(), "", "requires committed clean artifact"); + const outputs: Array = []; -const artifacts = await mkdtemp(join(tmpdir(), "vektor-placements-0096-")); + /** + * Runs one command to its end in the checkout and answers its standard output. Without `env` + * the command inherits the environment of the runner. + */ + const run = ( + command: string, + args: ReadonlyArray, + env?: Environment, + timeout: `${number} seconds` = "60 seconds", + ) => + Effect.scoped( + Effect.gen(function* () { + const handle = yield* env === undefined + ? ChildProcess.make(command, args, { cwd: root, stdin: "ignore" }) + : ChildProcess.make(command, args, { cwd: root, env, extendEnv: false, stdin: "ignore" }); -process.stdout.write("artifacts: " + artifacts + "\nrunner-pid: " + process.pid + "\n"); + const [stdout, stderr, code] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); -const safeEnvironment: NodeJS.ProcessEnv = Object.fromEntries( - [ - "PATH", - "HOME", - "TMPDIR", - "LANG", - "LC_ALL", - "TZ", - "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", - "PLAYWRIGHT_NODE_EXECUTABLE", - "PLAYWRIGHT_BROWSERS_PATH", - "GOLDEN_PROCESS_GROUPS_PATH", - ].flatMap((key) => (process.env[key] === undefined ? [] : [[key, process.env[key]!]])), -); + if (code !== 0) + return yield* new PlacementCheckFailure({ + message: `Command failed: ${[command, ...args].join(" ")}\n${stderr}`, + }); + + return stdout; + }), + ).pipe( + Effect.timeoutOrElse({ + duration: timeout, + orElse: () => Effect.fail(new PlacementCheckFailure({ message: `${command} timed out` })), + }), + ); -const fault = process.env.GOLDEN_SCHOOL_SERVICE_FAULT; + /** Starts one owned child in the checkout; its output joins the diagnostic outputs. */ + const start = Effect.fnUntraced(function* ( + command: string, + args: ReadonlyArray, + env: Environment, + ) { + const scope = yield* Scope.make(); + + const handle = yield* ChildProcess.make(command, args, { + cwd: root, + env, + extendEnv: false, + detached: false, + stdin: "ignore", + killSignal: "SIGTERM", + forceKillAfter: "5 seconds", + }).pipe(Scope.provide(scope)); + + const exit = yield* Deferred.make(); + const child: OwnedChild = { pid: handle.pid, scope, exit }; + + children.push(child); + + for (const stream of [handle.stdout, handle.stderr]) + yield* stream.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => Effect.sync(() => outputs.push(chunk))), + Effect.ignore, + Effect.forkDetach, + ); -assert.ok(fault === undefined || goldenFaults.includes(fault), "unknown test-driver fault"); + yield* handle.exitCode.pipe( + Effect.exit, + Effect.flatMap((result) => + Deferred.succeed(exit, Exit.isSuccess(result) ? result.value : null), + ), + Effect.forkDetach, + ); -const secrets = [ - "synthetic-recruitment-provider", - "journey-secret-0123456789abcdef", - "synthetic-school-service-token", -]; + return child; + }); -const sanitize = (value: string) => redactDiagnostic(secrets, value); + /** Sends SIGTERM, escalates to SIGKILL after five seconds, and ends on the observed exit. */ + const stopChild = (child: OwnedChild) => Scope.close(child.scope, Exit.void); + + const childExited = (child: OwnedChild) => Deferred.isDone(child.exit); + + /** + * Runs one owned child to its end within `timeout`. It fails when the child exits with another + * code than 0, and stops the child when the timeout passes. + */ + const runChild = Effect.fnUntraced(function* ( + command: string, + args: ReadonlyArray, + env: Environment, + timeout: `${number} seconds`, + ) { + const child = yield* start(command, args, env); + + const code = yield* Deferred.await(child.exit).pipe( + Effect.timeoutOrElse({ + duration: timeout, + orElse: () => + stopChild(child).pipe( + Effect.andThen( + Effect.fail(new PlacementCheckFailure({ message: command + " timed out" })), + ), + ), + }), + ); -const children: ChildProcess[] = []; + if (code !== 0) + return yield* new PlacementCheckFailure({ message: command + " exited " + String(code) }); + }); -let postgres: DisposablePostgres | undefined; + const revision = (yield* run("git", ["rev-parse", "HEAD"])).trim(); -const outputs: string[] = []; + const sourceTree = (yield* run("git", ["rev-parse", "HEAD^{tree}"])).trim(); -const start = (command: string, args: string[], env = process.env) => { - const child = spawn(command, args, { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] }); - children.push(child); - child.stdout?.on("data", (chunk) => outputs.push(String(chunk))); - child.stderr?.on("data", (chunk) => outputs.push(String(chunk))); + assert.equal( + (yield* run("git", ["status", "--porcelain"])).trim(), + "", + "requires committed clean artifact", + ); - return child; -}; + const artifacts = yield* fs.makeTempDirectory({ prefix: "vektor-placements-0096-" }); -/** Sends SIGTERM, escalates to SIGKILL after five seconds, and resolves on the observed exit. */ -const stopChild = async (child: ChildProcess): Promise => { - if (child.exitCode !== null || child.signalCode !== null) return; - await new Promise((resolve) => { - const timer = setTimeout(() => child.kill("SIGKILL"), 5_000); - child.once("exit", () => { - clearTimeout(timer); - resolve(); - }); - child.kill("SIGTERM"); - }); -}; + yield* Effect.sync(() => + process.stdout.write("artifacts: " + artifacts + "\nrunner-pid: " + process.pid + "\n"), + ); -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const safeEnvironment: Environment = Object.fromEntries( + yield* Effect.forEach(safeEnvironmentKeys, (key) => + Effect.map(Config.option(Config.String(key)), (value) => + Option.match(value, { onNone: () => [], onSome: (text) => [[key, text] as const] }), + ), + ).pipe(Effect.map((entries) => entries.flat())), + ); -let pool: InstanceType; + const fault = Option.getOrUndefined( + yield* Config.option(Config.String("GOLDEN_SCHOOL_SERVICE_FAULT")), + ); -let evidence: Schema.JsonObject | undefined; + assert.ok(fault === undefined || goldenFaults.includes(fault), "unknown test-driver fault"); -let notificationServer: HttpServer | undefined; + const secrets = [ + "synthetic-recruitment-provider", + "journey-secret-0123456789abcdef", + "synthetic-school-service-token", + ]; -let checkpoint: ((step: string) => Promise) | undefined; + const sanitize = (value: string) => redactDiagnostic(secrets, value); -let observations: Array<{ step: string }> = []; + let postgres: DisposablePostgres | undefined; -let ownedPorts: number[] = []; + let pool: Pool | undefined; -let failure: string | undefined; + /** What the journey recorded; each journey sets it only when it passed. */ + let evidence: object | undefined; -let interruptedSignal: "SIGINT" | "SIGTERM" | undefined; + let journeyPassed = false; -let cleanupPromise: Promise | undefined; + let notificationServer: Bun.Server | undefined; -const cleanup = () => - (cleanupPromise ??= (async () => { - const errors: string[] = []; + let receiverOpen = false; - for (const child of [...children].reverse()) { - try { - await stopChild(child); - } catch (error) { - errors.push(sanitize(String(error))); - } - } + let checkpoint: + | ((step: string) => Effect.Effect) + | undefined; - try { - if (pool) await pool.end(); - } catch (error) { - errors.push(sanitize(String(error))); - } + let observations: Array<{ step: string }> = []; - try { - await postgres?.stop(); - } catch (error) { - errors.push(sanitize(String(error))); - } + let ownedPorts: Array = []; - try { - if (notificationServer?.listening) - await new Promise((resolve, reject) => - notificationServer!.close((error) => (error ? reject(error) : resolve())), - ); - } catch (error) { - errors.push(sanitize(String(error))); - } + let failure: string | undefined; - const removed: string[] = []; + let interruptedSignal: "SIGINT" | "SIGTERM" | undefined; - for (const name of ["postgres", "manifest.json"]) { - try { - await rm(join(artifacts, name), { recursive: true, force: true }); - removed.push(name); - } catch (error) { - errors.push(sanitize(String(error))); - } - } + const interrupted = yield* Deferred.make(); + + /** Stops every owned resource, then writes the evidence and the receipt; answers the errors. */ + const cleanup = Effect.gen(function* () { + const errors: Array = []; + + const attempt = (effect: Effect.Effect) => + effect.pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + errors.push(sanitize(String(thrownBy(cause)))); + }), + ), + ); + + for (const child of [...children].reverse()) yield* attempt(stopChild(child)); + + const observerPool = pool; + + if (observerPool !== undefined) yield* attempt(step(() => observerPool.end())); + + const cluster = postgres; + + if (cluster !== undefined) yield* attempt(step(() => cluster.stop())); + + const server = notificationServer; + + if (server !== undefined && receiverOpen) + yield* attempt( + step(() => server.stop(true)).pipe( + Effect.andThen( + Effect.sync(() => { + receiverOpen = false; + }), + ), + ), + ); + + const removed: Array = []; + + for (const name of ["postgres", "manifest.json"]) + yield* attempt( + fs + .remove(path.join(artifacts, name), { recursive: true, force: true }) + .pipe(Effect.andThen(Effect.sync(() => removed.push(name)))), + ); for (const number of ownedPorts) - if (!(await loopbackPortFree(number))) errors.push("owned port remains occupied: " + number); + if (!(yield* Effect.promise(() => loopbackPortFree(number)))) + errors.push("owned port remains occupied: " + number); + + if (errors.length > 0 && failure === undefined) failure = "Resource cleanup failed"; - if (errors.length && failure === undefined) failure = "Resource cleanup failed"; + const exited = yield* Effect.forEach(children, childExited); const result = Struct.assign(evidence ?? {}, { - passed: (evidence?.passed === true || evidence?.apiPassed === true) && failure === undefined, + passed: journeyPassed && failure === undefined, revision, sourceTree, cleanSource: true, @@ -247,60 +400,60 @@ const cleanup = () => .flatMap((line) => { if (!line.startsWith('{"diagnostic":"golden-http"')) return []; - try { - const { pid, sequence, method, path, event, elapsed_ms, status } = JSON.parse(line); - - return status === undefined - ? [{ pid, sequence, method, path, event, elapsed_ms }] - : [{ pid, sequence, method, path, event, elapsed_ms, status }]; - } catch { - return []; - } + return Option.match(Schema.decodeOption(DiagnosticLine)(line), { + onNone: () => [], + onSome: ({ pid, sequence, method, path, event, elapsed_ms, status }) => + status === undefined + ? [{ pid, sequence, method, path, event, elapsed_ms }] + : [{ pid, sequence, method, path, event, elapsed_ms, status }], + }); }), cleanup: { - processes: children.map((child) => ({ + processes: children.map((child, index) => ({ pid: child.pid, - exited: child.exitCode !== null || child.signalCode !== null, + exited: exited[index] === true, })), - processesExited: children.every( - (child) => child.exitCode !== null || child.signalCode !== null, - ), + processesExited: exited.every((done) => done), ports: ownedPorts, portsReleased: errors.length === 0, postgresRemoved: removed.includes("postgres"), credentialManifestRemoved: removed.includes("manifest.json"), - receiverClosed: !notificationServer?.listening, + receiverClosed: !receiverOpen, errors, }, }); - await writeFile(join(artifacts, "evidence.json"), redactedEvidenceJson(secrets, result), { - mode: 0o600, - }); + yield* fs.writeFileString( + path.join(artifacts, "evidence.json"), + redactedEvidenceJson( + secrets, + yield* Schema.decodeEffect(JsonDocument)(yield* jsonText(result)), + ), + { mode: 0o600 }, + ); if (failure !== undefined) - await writeFile(join(artifacts, "failure.log"), sanitize(outputs.join("").slice(-24000)), { - mode: 0o600, - }); + yield* fs.writeFileString( + path.join(artifacts, "failure.log"), + sanitize(outputs.join("").slice(-24000)), + { mode: 0o600 }, + ); const retained = []; - for (const name of (await readdir(artifacts)).sort()) { - if (!goldenArtifactName.test(name)) continue; - const bytes = await readFile(join(artifacts, name)); - retained.push({ - path: name, - sha256: createHash("sha256").update(bytes).digest("hex"), - bytes: bytes.length, - }); + for (const name of (yield* fs.readDirectory(artifacts)).sort()) { + if (goldenArtifactName.test(name) !== true) continue; + const bytes = yield* fs.readFile(path.join(artifacts, name)); + retained.push({ path: name, sha256: sha256(bytes), bytes: bytes.length }); } - const runnerSources = await Promise.all( - (recruitment ? recruitmentRunnerPaths : goldenRunnerPaths).map(async (path) => ({ - path, - sha256: createHash("sha256") - .update(await readFile(join(root, path))) - .digest("hex"), - })), + const runnerSources = yield* Effect.forEach( + recruitment ? recruitmentRunnerPaths : goldenRunnerPaths, + (source) => + Effect.map(fs.readFile(path.join(root, source)), (bytes) => ({ + path: source, + sha256: sha256(bytes), + })), + { concurrency: "unbounded" }, ); const receipt = { @@ -317,7 +470,7 @@ const cleanup = () => result: result.passed ? "passed" : "failed", exit_code: result.passed ? 0 - : errors.length + : errors.length > 0 ? 1 : interruptedSignal === "SIGINT" ? 130 @@ -329,8 +482,7 @@ const cleanup = () => step_ids: observations.map((item) => item.step), runner_sources: runnerSources, fixture_digest: "sha256:" + runnerSources[0]!.sha256, - artifact_digest: - "sha256:" + createHash("sha256").update(JSON.stringify(retained)).digest("hex"), + artifact_digest: "sha256:" + sha256(yield* jsonText(retained)), artifacts: retained, runtime: { bun: process.versions.bun, @@ -338,96 +490,85 @@ const cleanup = () => }, }; - await writeFile(join(artifacts, "receipt.json"), JSON.stringify(receipt, null, 2), { - mode: 0o600, - }); - process.stdout.write(artifacts + "/receipt.json\n"); - - if (errors.length) throw Error(errors.join("; ")); - })()); - -for (const signal of ["SIGTERM", "SIGINT"] as const) - process.once(signal, () => { - interruptedSignal = signal; - process.exitCode = signal === "SIGINT" ? 130 : 143; - failure ??= "Interrupted by " + signal; - void cleanup().then( - () => process.exit(signal === "SIGINT" ? 130 : 143), - () => process.exit(1), + yield* fs.writeFileString( + path.join(artifacts, "receipt.json"), + yield* indentedJsonText(receipt), + { mode: 0o600 }, ); - }); + yield* Effect.sync(() => process.stdout.write(artifacts + "/receipt.json\n")); -type NotificationCapture = { - authorization?: string; - idempotencyKey?: string; - readonly body: Payload; -}; + return errors; + }); -const notificationRequests: Array> = []; + const notificationRequests: Array> = []; -try { - journey: { - ownedPorts = [...(await reserveLoopbackPorts(recruitment ? 5 : 4))]; - const [pgPort, backendPort, dashboardPort, notificationPort, homepagePort] = ownedPorts; + /** Answers one request of the loopback receiver: an observer checkpoint or a provider call. */ + const receive = (request: Request) => + Effect.gen(function* () { + const url = new URL(request.url); + const target = url.pathname + url.search; - const server = createHttpServer(async (request, response) => { - if (request.method === "POST" && request.url?.startsWith("/observe/")) { - try { + if (request.method === "POST" && target.startsWith("/observe/")) { + return yield* Effect.gen(function* () { assert.ok(checkpoint, "observer not ready"); - const step = request.url.slice("/observe/".length); - const result = await checkpoint(step); - - response.setHeader("content-type", "application/json"); - response.end(JSON.stringify(result)); - } catch (error) { - response.statusCode = 500; - response.end(sanitize(String(error))); - } - - return; - } - - if (recruitment) { - await recruitmentMailbox.handle(request, response); - - return; + const result = yield* checkpoint(target.slice("/observe/".length)); + + return new Response(yield* jsonText(result), { + headers: { "content-type": "application/json" }, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.succeed(new Response(sanitize(String(thrownBy(cause))), { status: 500 })), + ), + ); } - const chunks: Buffer[] = []; + if (recruitment) return yield* recruitmentMailbox.handle(request); - for await (const chunk of request) chunks.push(Buffer.from(chunk)); - const idempotencyKeyHeader = request.headers["idempotency-key"]; - - const idempotencyKey = Array.isArray(idempotencyKeyHeader) - ? idempotencyKeyHeader[0] - : idempotencyKeyHeader; - - const payload = JSON.parse(Buffer.concat(chunks).toString("utf8")); + const idempotencyKey = request.headers.get("idempotency-key"); + const authorization = request.headers.get("authorization"); const observed: NotificationCapture = { - body: Schema.decodeUnknownSync(SchoolServiceNotificationRequest)(payload), + body: yield* Schema.decodeEffect(NotificationRequestJson)( + yield* step(() => request.text()), + ), }; - if (request.headers.authorization !== undefined) - observed.authorization = request.headers.authorization; + if (authorization !== null) observed.authorization = authorization; - if (idempotencyKey !== undefined) observed.idempotencyKey = idempotencyKey; + if (idempotencyKey !== null) observed.idempotencyKey = idempotencyKey; notificationRequests.push(observed); - response.statusCode = 204; - response.end(); - }); - notificationServer = server; - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(notificationPort, "127.0.0.1", resolve); - }); - postgres = await startDisposablePostgres({ - port: pgPort, - directory: join(artifacts, "postgres"), - }); + return new Response(null, { status: 204 }); + }).pipe( + Effect.catchCause((cause) => + Effect.succeed(new Response(sanitize(String(thrownBy(cause))), { status: 500 })), + ), + ); + + const serveReceiver = yield* FiberSet.makeRuntimePromise(); + + const journey = Effect.gen(function* () { + ownedPorts = [...(yield* step(() => reserveLoopbackPorts(recruitment ? 5 : 4)))]; + const [pgPort, backendPort, dashboardPort, notificationPort, homepagePort] = ownedPorts; + + notificationServer = yield* Effect.try(() => + Bun.serve({ + hostname: "127.0.0.1", + port: notificationPort, + fetch: (request) => serveReceiver(receive(request)), + }), + ); + receiverOpen = true; + postgres = yield* step(() => + startDisposablePostgres({ + port: pgPort!, + directory: path.join(artifacts, "postgres"), + }), + ); const postgresUrl = `postgres://postgres@127.0.0.1:${pgPort}/postgres`; - pool = new Pool({ connectionString: postgresUrl }); + const journeyPool = new Pool({ connectionString: postgresUrl }); + pool = journeyPool; const backendOrigin = `http://127.0.0.1:${backendPort}`; const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; @@ -439,7 +580,7 @@ try { BACKEND_PG_URL: postgresUrl, BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify([dashboardOrigin]), + NATIVE_IDENTITY_TRUSTED_ORIGINS: yield* jsonText([dashboardOrigin]), OAUTH_CANONICAL_ORIGIN: backendOrigin, OAUTH_DASHBOARD_ORIGIN: dashboardOrigin, OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", @@ -513,20 +654,20 @@ try { const wrongId = "journey-rec-interviewer-b-0049"; if (recruitment) { - run("bun", ["--no-env-file", "packages/database/runtime/identity-seed-main.ts"], { + yield* run("bun", ["--no-env-file", "packages/database/runtime/identity-seed-main.ts"], { ...environment, IDENTITY_SEED_PG_URL: postgresUrl, - IDENTITY_SEED_PERSONS: JSON.stringify([ + IDENTITY_SEED_PERSONS: yield* jsonText([ recruitmentPeople.leader, recruitmentPeople.wrongDepartment, ]), }); - await seedRecruitment(pool); + yield* seedRecruitment(journeyPool); } else if (mode === "--golden-school-service") { - run("bun", ["--no-env-file", "packages/database/runtime/identity-seed-main.ts"], { + yield* run("bun", ["--no-env-file", "packages/database/runtime/identity-seed-main.ts"], { ...environment, IDENTITY_SEED_PG_URL: postgresUrl, - IDENTITY_SEED_PERSONS: JSON.stringify([ + IDENTITY_SEED_PERSONS: yield* jsonText([ substitute, secondSubstitute, member, @@ -553,7 +694,8 @@ try { }, ]), }); - await pool.query(` + yield* step(() => + journeyPool.query(` INSERT INTO admission_period_departments(department_id,name) VALUES ('${departmentId}','Trondheim'),('${wrongDepartmentId}','Annen'); INSERT INTO admission_period_semesters(semester_id,start_at,end_at) VALUES ('${semesterId}','2024-01-01','2024-07-01'); INSERT INTO organization_departments(department_id,name,short_name,email,city,active,independent,revision) VALUES @@ -592,15 +734,17 @@ try { INSERT INTO person_contact_profiles(person_id,email,phone) VALUES ('${substitute.personId}','${substitute.email}','90000111'), ('${secondSubstitute.personId}','${secondSubstitute.email}','90000222'); - `); + `), + ); } else { - run("bun", ["apps/dashboard/e2e/native-recruitment-journey-seed.mjs"], environment); - run("bun", ["run", "--cwd", "packages/database", "identity:seed"], { + yield* run("bun", ["apps/dashboard/e2e/native-recruitment-journey-seed.mjs"], environment); + yield* run("bun", ["run", "--cwd", "packages/database", "identity:seed"], { ...environment, IDENTITY_SEED_PG_URL: postgresUrl, - IDENTITY_SEED_PERSONS: JSON.stringify([substitute]), + IDENTITY_SEED_PERSONS: yield* jsonText([substitute]), }); - await pool.query(` + yield* step(() => + journeyPool.query(` INSERT INTO admission_period_semesters(semester_id,start_at,end_at) VALUES ('${semesterId}','2024-01-01','2024-07-01'); INSERT INTO admission_periods(admission_period_id,department_id,semester_id,start_at,end_at,revision,last_command_id) VALUES ('admission-period-coverage-0111','${departmentId}','${semesterId}','2024-01-01','2024-07-01',0,'coverage-seed-0111'); @@ -625,28 +769,24 @@ try { (963,'Skole Feil avdeling','Kontakt','wrong@example.invalid','synthetic','Norwegian',true,0), (964,'Skole Inaktiv','Kontakt','inactive@example.invalid','synthetic','Norwegian',false,0); INSERT INTO schools_directory_departments(school_id,department_id,revision) VALUES (961,'${departmentId}',0),(962,'${departmentId}',0),(963,'${wrongDepartmentId}',0),(964,'${departmentId}',0); - `); + `), + ); } - const credentialSnapshot = async () => - createHash("sha256") - .update( - JSON.stringify( - ( - await pool.query( - 'SELECT id,"userId","providerId",password FROM auth.account ORDER BY id', - ) - ).rows, - ), - ) - .digest("hex"); + const credentialSnapshot = Effect.map( + step(() => + journeyPool.query('SELECT id,"userId","providerId",password FROM auth.account ORDER BY id'), + ), + ({ rows }) => sha256(JSON.stringify(rows)), + ); - const credentialsBefore = await credentialSnapshot(); + const credentialsBefore = yield* credentialSnapshot; - const peopleBefore = (await pool.query("SELECT * FROM person_profiles ORDER BY person_id")) - .rows; + const peopleBefore = (yield* step(() => + journeyPool.query("SELECT * FROM person_profiles ORDER BY person_id"), + )).rows; - start( + yield* start( "bun", [ "--no-env-file", @@ -659,12 +799,10 @@ try { ); for (let n = 0; ; n++) { - try { - if ((await fetch(`${backendOrigin}/health`)).ok) break; - } catch {} + if (yield* answersOk(`${backendOrigin}/health`)) break; - if (n > 150) throw Error("backend startup failed"); - await delay(200); + if (n > 150) return yield* new PlacementCheckFailure({ message: "backend startup failed" }); + yield* Effect.sleep("200 millis"); } const persons = { @@ -692,34 +830,40 @@ try { observerOrigin: `http://127.0.0.1:${notificationPort}`, }; - const observer = createRecruitmentObserver(pool, recruitmentMailbox); + const observer = createRecruitmentObserver({ + pool: journeyPool, + mailbox: recruitmentMailbox, + }); + observations = observer.observations; checkpoint = observer.observe; - await checkpoint("initial"); - const manifestPath = join(artifacts, "manifest.json"); - await writeFile(manifestPath, JSON.stringify(manifest), { mode: 0o600 }); - await runAsync( + yield* observer.observe("initial"); + const manifestPath = path.join(artifacts, "manifest.json"); + yield* fs.writeFileString(manifestPath, yield* jsonText(manifest), { mode: 0o600 }); + yield* runChild( "bun", ["--no-env-file", "apps/dashboard/e2e/run-real-native-placement.mjs"], { ...safeEnvironment, PLACEMENT_JOURNEY_MANIFEST: manifestPath }, - 300_000, + "300 seconds", ); - const browserEvidence = JSON.parse( - await readFile(join(artifacts, "browser-evidence.json"), "utf8"), + const browserEvidence = yield* Schema.decodeEffect(BrowserEvidenceJson)( + yield* fs.readFileString(path.join(artifacts, "browser-evidence.json")), ); assert.equal(browserEvidence.passed, true); assert.equal(browserEvidence.revision, revision); assert.deepEqual(browserEvidence.steps, recruitmentSteps.slice(1)); - assert.equal(run("git", ["rev-parse", "HEAD"]).trim(), revision); + assert.equal((yield* run("git", ["rev-parse", "HEAD"])).trim(), revision); assert.equal( - run("git", ["status", "--porcelain"]).trim(), + (yield* run("git", ["status", "--porcelain"])).trim(), "", "source changed during acceptance", ); evidence = { passed: true, browser: browserEvidence, ...observer.finish() }; - break journey; + journeyPassed = true; + + return; } if (mode === "--golden-school-service") { @@ -746,261 +890,338 @@ try { observerOrigin: "http://127.0.0.1:" + notificationPort, }; - const observer = createGoldenObserver(pool, manifest, notificationRequests); + const observer = createGoldenObserver({ + pool: journeyPool, + fixture: manifest, + deliveries: notificationRequests, + }); observations = observer.observations; checkpoint = observer.observe; - await checkpoint("initial"); - const manifestPath = join(artifacts, "manifest.json"); - await writeFile(manifestPath, JSON.stringify(manifest), { mode: 0o600 }); + yield* observer.observe("initial"); + const manifestPath = path.join(artifacts, "manifest.json"); + yield* fs.writeFileString(manifestPath, yield* jsonText(manifest), { mode: 0o600 }); - const child = start( + const child = yield* start( "bun", ["--no-env-file", "apps/dashboard/e2e/run-real-native-placement.mjs"], { ...safeEnvironment, PLACEMENT_JOURNEY_MANIFEST: manifestPath }, ); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - child.kill("SIGTERM"); - reject(Error("browser acceptance timed out")); - }, 300_000); - - child.once("error", reject); - child.once("exit", (code) => { - clearTimeout(timer); + const code = yield* Deferred.await(child.exit).pipe( + Effect.timeoutOrElse({ + duration: "300 seconds", + orElse: () => + Effect.fail(new PlacementCheckFailure({ message: "browser acceptance timed out" })), + }), + ); - if (code === 0) resolve(); - else reject(Error("browser child exited " + code)); + if (code !== 0) + return yield* new PlacementCheckFailure({ + message: "browser child exited " + String(code), }); - }); - const browserEvidence = JSON.parse( - await readFile(join(artifacts, "browser-evidence.json"), "utf8"), + const browserEvidence = yield* Schema.decodeEffect(BrowserEvidenceJson)( + yield* fs.readFileString(path.join(artifacts, "browser-evidence.json")), ); assert.equal(browserEvidence.passed, true, "required browser evidence absent or failed"); assert.equal(browserEvidence.revision, revision); assert.deepEqual(browserEvidence.steps, goldenSteps.slice(1)); - const facts = await observer.finish(); - assert.equal(run("git", ["rev-parse", "HEAD"]).trim(), revision); + const facts = yield* observer.finish(); + assert.equal((yield* run("git", ["rev-parse", "HEAD"])).trim(), revision); assert.equal( - run("git", ["status", "--porcelain"]).trim(), + (yield* run("git", ["status", "--porcelain"])).trim(), "", "source changed during acceptance", ); evidence = { passed: true, browser: browserEvidence, ...facts }; - break journey; + journeyPassed = true; + + return; } - const login = async (person: { email: string; password: string }) => { - const response = await fetch(`${backendOrigin}/api/auth/sign-in/email`, { - method: "POST", - headers: { "content-type": "application/json", origin: dashboardOrigin }, - body: JSON.stringify(person), - }); + const login = Effect.fnUntraced(function* (person: { email: string; password: string }) { + const response = yield* HttpClient.execute( + HttpClientRequest.post(`${backendOrigin}/api/auth/sign-in/email`).pipe( + HttpClientRequest.setHeader("origin", dashboardOrigin), + HttpClientRequest.bodyJsonUnsafe(person), + ), + ); assert.equal(response.status, 200); - const cookie = response.headers.get("set-cookie")?.split(";")[0]; - assert.ok(cookie); + const cookie = firstSetCookie(response); + assert.ok(cookie !== undefined); return cookie; - }; - - const leader = await login(persons.leader), - volunteer = await login(persons.volunteer), - wrong = await login(persons.wrongDepartment), - candidate = await login(persons.candidate); - - const sdk = createPromiseClient(backendOrigin, { cookie: leader, origin: dashboardOrigin }); - - const volunteerSdk = createPromiseClient(backendOrigin, { - cookie: volunteer, - origin: dashboardOrigin, }); - const wrongSdk = createPromiseClient(backendOrigin, { cookie: wrong, origin: dashboardOrigin }); + const leader = yield* login(persons.leader), + volunteer = yield* login(persons.volunteer), + wrong = yield* login(persons.wrongDepartment), + candidate = yield* login(persons.candidate); - const candidateSdk = createPromiseClient(backendOrigin, { - cookie: candidate, - origin: dashboardOrigin, - }); + // Placements are served as RPCs. Each call names the person whose session it forwards. + const native = nativeScriptClient(backendOrigin); + const session = (cookie: string) => ({ cookie, origin: dashboardOrigin }); + + const valueOf = (result: ScriptCallResult): A => + result.ok ? result.value : assert.fail(JSON.stringify(result)); - const query = Schema.decodeSync(PlacementScope)({ departmentId, semesterId }); + const query = yield* Schema.decodeEffect(PlacementScope)({ departmentId, semesterId }); + // Each path names the RPC and scope of the HTTP route it replaced; `request` dispatches on it. const boardPath = `/api/placements?${new URLSearchParams(query).toString()}`; const ownPath = `/api/placements/affiliation?departmentId=${departmentId}`; const coverageBoardPath = `/api/placements/coverage?${new URLSearchParams(query).toString()}`; const ownCoveragePath = `/api/placements/coverage/own?${new URLSearchParams(query).toString()}`; - const request = async ( + const commandKeys = (etag: string | undefined, key: string) => ({ + idempotencyKey: Schema.decodeSync(IdempotencyKey)(key), + ifMatch: Schema.decodeUnknownSync(StrongETag)(etag), + }); + + /** + * Calls the placement RPC that replaced the route of `path`: its read without a body, its + * command with one. The answer keeps the registry status of a problem. + */ + const request = ( path: string, cookie?: string, body?: Schema.Json, etag?: string, key = randomBytes(18).toString("base64url"), - ) => { - const nativeHeaders = new Headers(); - nativeHeaders.set("origin", dashboardOrigin); - - if (cookie) { - nativeHeaders.set("cookie", cookie); - } - - if (!(body === undefined)) { - nativeHeaders.set("content-type", "application/json"); - nativeHeaders.set("idempotency-key", key); - - if (etag) { - nativeHeaders.set("if-match", etag); + ): Effect.Effect, JourneyStepFailed> => + step((): Promise> => { + const url = new URL(path, backendOrigin); + const parameters = Object.fromEntries(url.searchParams); + const headers = cookie === undefined ? { origin: dashboardOrigin } : session(cookie); + + if (url.pathname === "/api/placements/affiliation") { + const scope = Schema.decodeUnknownSync(AffiliationScope)(parameters); + + return body === undefined + ? native.call(headers, (client) => client["placements.readOwnAffiliation"](scope)) + : native.call(headers, (client) => + client["placements.commandOwnAffiliation"]({ + ...scope, + ...commandKeys(etag, key), + request: Schema.decodeUnknownSync(OwnAffiliationCommand)(body), + }), + ); } - } - - const requestBody: Pick = - body === undefined ? {} : { body: JSON.stringify(body) }; - return fetch(`${backendOrigin}${path}`, { - method: body === undefined ? "GET" : "POST", - headers: nativeHeaders, - ...requestBody, + const scope = Schema.decodeUnknownSync(PlacementScope)(parameters); + + switch (url.pathname) { + case "/api/placements": + return body === undefined + ? native.call(headers, (client) => client["placements.readBoard"](scope)) + : native.call(headers, (client) => + client["placements.commandBoard"]({ + ...scope, + ...commandKeys(etag, key), + request: Schema.decodeUnknownSync(PlacementCommand)(body), + }), + ); + case "/api/placements/coverage": + return body === undefined + ? native.call(headers, (client) => client["placements.readCoverageBoard"](scope)) + : native.call(headers, (client) => + client["placements.commandCoverageBoard"]({ + ...scope, + ...commandKeys(etag, key), + request: Schema.decodeUnknownSync(CoverageCommand)(body), + }), + ); + case "/api/placements/coverage/own": + return body === undefined + ? native.call(headers, (client) => client["placements.readOwnCoverage"](scope)) + : native.call(headers, (client) => + client["placements.commandOwnCoverage"]({ + ...scope, + ...commandKeys(etag, key), + request: Schema.decodeUnknownSync(OwnCoverageCommand)(body), + }), + ); + default: + return assert.fail(`no placement RPC replaces ${url.pathname}`); + } }); - }; - const expectStatus = async (response: Response, status: number, code?: string) => { - const body = await response.json(); - assert.equal(response.status, status, JSON.stringify(body)); + // The journey reads answers as the untyped JSON bodies it read over HTTP. + const expectStatus = ( + response: ScriptCallResult, + status: number, + code?: string, + ): any => { + assert.equal(response.status, status, JSON.stringify(response)); - if (code) assert.equal(body.code, code); + if (code !== undefined) assert.equal(response.ok ? undefined : response.code, code); - return body; + return response.ok ? response.value : "problem" in response ? response.problem : response; }; - const idempotencyHeaders = (etag: string, key = randomBytes(18).toString("base64url")) => - Schema.decodeSync(IdempotencyIfMatchHeaders)({ - "if-match": etag, - "idempotency-key": key, - }); - - const readBoard = async () => (await sdk.placements.readBoard({ query })).body; - - const command = async (payload: Schema.Json) => { - const board = await readBoard(); + const readBoard = Effect.fnUntraced(function* () { + return valueOf( + yield* step(() => + native.call(session(leader), (client) => client["placements.readBoard"](query)), + ), + ); + }); - return ( - await sdk.placements.commandBoard({ - query, - headers: idempotencyHeaders(board.etag), - payload: Schema.decodeUnknownSync(PlacementCommand)(payload), - }) - ).body; - }; + const command = Effect.fnUntraced(function* (payload: Schema.Json) { + const board = yield* readBoard(); + + return valueOf( + yield* step(() => + native.call(session(leader), (client) => + client["placements.commandBoard"]({ + ...query, + ...commandKeys(board.etag, randomBytes(18).toString("base64url")), + request: Schema.decodeUnknownSync(PlacementCommand)(payload), + }), + ), + ), + ); + }); - const readOwnCoverage = async (client: typeof sdk) => - (await client.placements.readOwnCoverage({ query })).body; + const readOwnCoverage = Effect.fnUntraced(function* (cookie: string) { + return valueOf( + yield* step(() => + native.call(session(cookie), (client) => client["placements.readOwnCoverage"](query)), + ), + ); + }); - const readCoverageBoard = async () => (await sdk.placements.readCoverageBoard({ query })).body; + const readCoverageBoard = Effect.fnUntraced(function* () { + return valueOf( + yield* step(() => + native.call(session(leader), (client) => client["placements.readCoverageBoard"](query)), + ), + ); + }); - const commandOwnCoverage = async ( - client: typeof sdk, + const commandOwnCoverage = Effect.fnUntraced(function* ( + cookie: string, payload: Schema.Json, etag?: string, - key?: string, - ) => { - const resource = etag === undefined ? await readOwnCoverage(client) : undefined; - - return ( - await client.placements.commandOwnCoverage({ - query, - headers: idempotencyHeaders(etag ?? resource!.etag, key), - payload: Schema.decodeUnknownSync(OwnCoverageCommand)(payload), - }) - ).body; - }; - - const commandCoverage = async (payload: Schema.Json, etag?: string, key?: string) => { - const resource = etag === undefined ? await readCoverageBoard() : undefined; + key = randomBytes(18).toString("base64url"), + ) { + const current = etag ?? (yield* readOwnCoverage(cookie)).etag; + + return valueOf( + yield* step(() => + native.call(session(cookie), (client) => + client["placements.commandOwnCoverage"]({ + ...query, + ...commandKeys(current, key), + request: Schema.decodeUnknownSync(OwnCoverageCommand)(payload), + }), + ), + ), + ); + }); - return ( - await sdk.placements.commandCoverageBoard({ - query, - headers: idempotencyHeaders(etag ?? resource!.etag, key), - payload: Schema.decodeUnknownSync(CoverageCommand)(payload), - }) - ).body; - }; + const commandCoverage = Effect.fnUntraced(function* ( + payload: Schema.Json, + etag?: string, + key = randomBytes(18).toString("base64url"), + ) { + const current = etag ?? (yield* readCoverageBoard()).etag; + + return valueOf( + yield* step(() => + native.call(session(leader), (client) => + client["placements.commandCoverageBoard"]({ + ...query, + ...commandKeys(current, key), + request: Schema.decodeUnknownSync(CoverageCommand)(payload), + }), + ), + ), + ); + }); - const boardResponse = await request(boardPath, leader); - assert.equal(boardResponse.headers.get("cache-control"), "private, no-store"); - await expectStatus(boardResponse, 200); + // An RPC answer carries no Cache-Control; the backend answers every RPC uncached. + expectStatus(yield* request(boardPath, leader), 200); assert.deepEqual( - (await readBoard()).schools.map((school) => school.schoolId), + (yield* readBoard()).schools.map((school) => school.schoolId), [961, 962], ); assert.ok( - (await sdk.placements.listScopes()).body.semesters.some( - (semester) => semester.semesterId === semesterId, + valueOf( + yield* step(() => + native.call(session(leader), (client) => client["placements.listScopes"]()), + ), + ).semesters.some((semester) => semester.semesterId === semesterId), + ); + expectStatus(yield* request(boardPath), 401); + expectStatus(yield* request(boardPath, volunteer), 403, "authority.denied"); + expectStatus(yield* request(boardPath, wrong), 403, "authority.denied"); + assert.equal(expectStatus(yield* request(ownPath, volunteer), 200).personId, volunteerId); + assert.ok( + !(yield* jsonText(expectStatus(yield* request(ownPath, volunteer), 200))).includes(leaderId), + ); + // A real member still has no coordinator authority; the browser cohort has no team at all. + yield* step(() => + journeyPool.query( + "INSERT INTO organization_memberships(membership_id,person_id,team_id,start_at,end_at,is_team_leader,is_suspended,revision) VALUES ('member-0096',$1,'team-native-journey-0049','2026-01-01',NULL,false,false,0)", + [volunteerId], ), ); - await expectStatus(await request(boardPath), 401); - await expectStatus(await request(boardPath, volunteer), 403, "authority.denied"); - await expectStatus(await request(boardPath, wrong), 403, "authority.denied"); - assert.equal( - (await expectStatus(await request(ownPath, volunteer), 200)).personId, - volunteerId, + expectStatus(yield* request(boardPath, volunteer), 403, "authority.denied"); + yield* step(() => + journeyPool.query("DELETE FROM organization_memberships WHERE membership_id='member-0096'"), ); - assert.ok( - !JSON.stringify(await expectStatus(await request(ownPath, volunteer), 200)).includes( - leaderId, + yield* step(() => + journeyPool.query( + "UPDATE organization_memberships SET is_suspended=true WHERE person_id=$1", + [leaderId], ), ); - // A real member still has no coordinator authority; the browser cohort has no team at all. - await pool.query( - "INSERT INTO organization_memberships(membership_id,person_id,team_id,start_at,end_at,is_team_leader,is_suspended,revision) VALUES ('member-0096',$1,'team-native-journey-0049','2026-01-01',NULL,false,false,0)", - [volunteerId], + expectStatus(yield* request(boardPath, leader), 403, "authority.denied"); + yield* step(() => + journeyPool.query( + "UPDATE organization_memberships SET is_suspended=false WHERE person_id=$1", + [leaderId], + ), ); - await expectStatus(await request(boardPath, volunteer), 403, "authority.denied"); - await pool.query("DELETE FROM organization_memberships WHERE membership_id='member-0096'"); - await pool.query("UPDATE organization_memberships SET is_suspended=true WHERE person_id=$1", [ - leaderId, - ]); - await expectStatus(await request(boardPath, leader), 403, "authority.denied"); - await pool.query("UPDATE organization_memberships SET is_suspended=false WHERE person_id=$1", [ - leaderId, - ]); - await pool.query( - "INSERT INTO organization_global_administrator_grants(grant_id,person_id,start_at,end_at,revision) VALUES ('admin-0096',$1,'2026-01-01',NULL,0)", - [wrongId], + yield* step(() => + journeyPool.query( + "INSERT INTO organization_global_administrator_grants(grant_id,person_id,start_at,end_at,revision) VALUES ('admin-0096',$1,'2026-01-01',NULL,0)", + [wrongId], + ), ); - await expectStatus(await request(boardPath, wrong), 200); - await pool.query( - "DELETE FROM organization_global_administrator_grants WHERE grant_id='admin-0096'", + expectStatus(yield* request(boardPath, wrong), 200); + yield* step(() => + journeyPool.query( + "DELETE FROM organization_global_administrator_grants WHERE grant_id='admin-0096'", + ), ); // API cohort uses the coordinator's own Person; browser starts with an untouched no-team volunteer. - let own = await expectStatus(await request(ownPath, leader), 200); + let own = expectStatus(yield* request(ownPath, leader), 200); const ownKey = randomBytes(18).toString("base64url"); - const pending = await expectStatus( - await request(ownPath, leader, { action: "Request" }, own.etag, ownKey), + const pending = expectStatus( + yield* request(ownPath, leader, { action: "Request" }, own.etag, ownKey), 200, ); assert.equal(pending.status, "Pending"); assert.deepEqual( - await expectStatus( - await request(ownPath, leader, { action: "Request" }, own.etag, ownKey), - 200, - ), + expectStatus(yield* request(ownPath, leader, { action: "Request" }, own.etag, ownKey), 200), pending, ); - await expectStatus( - await request(ownPath, leader, { action: "Withdraw" }, own.etag, ownKey), + expectStatus( + yield* request(ownPath, leader, { action: "Withdraw" }, own.etag, ownKey), 409, "idempotency.digest-conflict", ); - await expectStatus(await request(ownPath, leader, { action: "Withdraw" }, pending.etag), 200); - own = await expectStatus(await request(ownPath, leader), 200); - await expectStatus(await request(ownPath, leader, { action: "Request" }, own.etag), 200); - await command({ action: "Affiliation", personId: leaderId, transition: "Establish" }); + expectStatus(yield* request(ownPath, leader, { action: "Withdraw" }, pending.etag), 200); + own = expectStatus(yield* request(ownPath, leader), 200); + expectStatus(yield* request(ownPath, leader, { action: "Request" }, own.etag), 200); + yield* command({ action: "Affiliation", personId: leaderId, transition: "Establish" }); const create = { action: "Create", @@ -1011,33 +1232,34 @@ try { block: "1", }; - let board = await readBoard(); - await expectStatus(await request(boardPath, leader, create), 428, "precondition.required"); + let board = yield* readBoard(); + // The payload requires `ifMatch`, and the command schema rejects these before any call; the + // RPC server decodes with the same schema and fails such a payload before the handler. for (const invalid of [ { ...create, workdays: 0 }, { ...create, workdays: 9 }, { ...create, day: "Sunday" }, { ...create, block: "3" }, ]) - await expectStatus(await request(boardPath, leader, invalid, board.etag), 422); + assert.ok(Option.isNone(Schema.decodeUnknownOption(PlacementCommand)(invalid))); for (const schoolId of [963, 964]) - await expectStatus( - await request(boardPath, leader, { ...create, schoolId }, board.etag), + expectStatus( + yield* request(boardPath, leader, { ...create, schoolId }, board.etag), 422, "scope.invalid", ); - await expectStatus( - await request(boardPath, leader, { ...create, personId: volunteerId }, board.etag), + expectStatus( + yield* request(boardPath, leader, { ...create, personId: volunteerId }, board.etag), 422, "affiliation.inactive", ); const createKey = randomBytes(18).toString("base64url"); const createEtag = board.etag; - const created = await expectStatus( - await request(boardPath, leader, create, board.etag, createKey), + const created = expectStatus( + yield* request(boardPath, leader, create, board.etag, createKey), 200, ); @@ -1046,38 +1268,34 @@ try { ).placementId; assert.deepEqual( - await expectStatus(await request(boardPath, leader, create, board.etag, createKey), 200), + expectStatus(yield* request(boardPath, leader, create, board.etag, createKey), 200), created, ); - await expectStatus( - await request(boardPath, leader, { ...create, workdays: 5 }, board.etag, createKey), + expectStatus( + yield* request(boardPath, leader, { ...create, workdays: 5 }, board.etag, createKey), 409, "idempotency.digest-conflict", ); - await expectStatus( - await request(boardPath, leader, { ...create, block: "2" }, board.etag), + expectStatus( + yield* request(boardPath, leader, { ...create, block: "2" }, board.etag), 412, "precondition.failed", ); - board = await readBoard(); - await expectStatus( - await request(boardPath, leader, create, board.etag), - 409, - "placement.overlap", - ); - await command({ ...create, block: "2" }); - await command({ ...create, block: "Both" }); + board = yield* readBoard(); + expectStatus(yield* request(boardPath, leader, create, board.etag), 409, "placement.overlap"); + yield* command({ ...create, block: "2" }); + yield* command({ ...create, block: "Both" }); // Forging board scope never changes the persisted item's canonical semester. const otherPath = `/api/placements?${new URLSearchParams({ departmentId, semesterId: secondSemesterId }).toString()}`; - const other = await expectStatus(await request(otherPath, leader), 200); - await expectStatus( - await request(otherPath, leader, { action: "Remove", placementId }, other.etag), + const other = expectStatus(yield* request(otherPath, leader), 200); + expectStatus( + yield* request(otherPath, leader, { action: "Remove", placementId }, other.etag), 404, "resource.not-found", ); - board = await readBoard(); + board = yield* readBoard(); - const edits = await Promise.all( + const edits = yield* Effect.all( [6, 7].map((workdays) => request( boardPath, @@ -1086,235 +1304,247 @@ try { board.etag, ), ), + { concurrency: "unbounded" }, ); assert.equal(edits.filter((r) => r.status === 200).length, 1); for (const response of edits.filter((r) => r.status !== 200)) - await expectStatus( + expectStatus( response, response.status === 409 ? 409 : 412, response.status === 409 ? "transaction.conflict" : "precondition.failed", ); - await command({ action: "Remove", placementId }); + yield* command({ action: "Remove", placementId }); - const retained = await pool.query( - "SELECT active,revision FROM assistant_placements WHERE placement_id=$1", - [placementId], + const retained = yield* step(() => + journeyPool.query("SELECT active,revision FROM assistant_placements WHERE placement_id=$1", [ + placementId, + ]), ); assert.deepEqual(retained.rows, [{ active: false, revision: 3 }]); assert.deepEqual( - ( - await pool.query( + (yield* step(() => + journeyPool.query( "SELECT action FROM assistant_placement_audit WHERE placement_id=$1 ORDER BY revision", [placementId], - ) - ).rows.map((r: { action: string }) => r.action), + ), + )).rows.map((r: { action: string }) => r.action), ["Create", "Edit", "Remove"], ); - board = await readBoard(); + board = yield* readBoard(); - const concurrentCreates = await Promise.all( + const concurrentCreates = yield* Effect.all( [0, 1].map(() => request(boardPath, leader, create, board.etag)), + { concurrency: "unbounded" }, ); assert.equal(concurrentCreates.filter((r) => r.status === 200).length, 1); for (const response of concurrentCreates.filter((r) => r.status !== 200)) - await expectStatus( + expectStatus( response, response.status === 409 ? 409 : 412, response.status === 409 ? "transaction.conflict" : "precondition.failed", ); - const replacement = (await readBoard()).placements.find( + const replacement = (yield* readBoard()).placements.find( (p) => p.personId === leaderId && p.block === "1" && p.active, ); assert.ok(replacement); - await command({ action: "Remove", placementId: replacement.placementId }); - await command({ action: "Affiliation", personId: leaderId, transition: "Revoke" }); - board = await readBoard(); + yield* command({ action: "Remove", placementId: replacement.placementId }); + yield* command({ action: "Affiliation", personId: leaderId, transition: "Revoke" }); + board = yield* readBoard(); assert.equal( board.placements.filter((p) => p.active).length, 2, "affiliation revocation preserves historical placements", ); - await expectStatus( - await request(boardPath, leader, create, board.etag), + expectStatus( + yield* request(boardPath, leader, create, board.etag), 422, "affiliation.inactive", ); - await pool.query("UPDATE organization_memberships SET is_suspended=true WHERE person_id=$1", [ - leaderId, - ]); - await expectStatus( - await request(boardPath, leader, create, createEtag, createKey), + yield* step(() => + journeyPool.query( + "UPDATE organization_memberships SET is_suspended=true WHERE person_id=$1", + [leaderId], + ), + ); + expectStatus( + yield* request(boardPath, leader, create, createEtag, createKey), 403, "authority.denied", ); - await pool.query("UPDATE organization_memberships SET is_suspended=false WHERE person_id=$1", [ - leaderId, - ]); + yield* step(() => + journeyPool.query( + "UPDATE organization_memberships SET is_suspended=false WHERE person_id=$1", + [leaderId], + ), + ); - for (const placement of (await readBoard()).placements.filter( + for (const placement of (yield* readBoard()).placements.filter( (candidate) => candidate.personId === leaderId && candidate.active, )) - await command({ action: "Remove", placementId: placement.placementId }); - assert.equal((await expectStatus(await request(otherPath, leader), 200)).placements.length, 0); + yield* command({ action: "Remove", placementId: placement.placementId }); + assert.equal(expectStatus(yield* request(otherPath, leader), 200).placements.length, 0); // The API cohort begins from a confirmed roster; the browser independently // confirms a two-person roster through the placement/service journey. // The substitute requests no affiliation: her admission outcome alone puts her on call. - const wrongAffiliation = await expectStatus(await request(ownPath, wrong), 200); + const wrongAffiliation = expectStatus(yield* request(ownPath, wrong), 200); assert.equal(wrongAffiliation.status, "Absent"); assert.equal( - ( - await expectStatus( - await request(ownPath, wrong, { action: "Request" }, wrongAffiliation.etag), - 200, - ) + expectStatus( + yield* request(ownPath, wrong, { action: "Request" }, wrongAffiliation.etag), + 200, ).status, "Pending", ); - const leaderAffiliation = await expectStatus(await request(ownPath, leader), 200); + const leaderAffiliation = expectStatus(yield* request(ownPath, leader), 200); assert.equal(leaderAffiliation.status, "Inactive"); assert.equal( - ( - await expectStatus( - await request(ownPath, leader, { action: "Request" }, leaderAffiliation.etag), - 200, - ) + expectStatus( + yield* request(ownPath, leader, { action: "Request" }, leaderAffiliation.etag), + 200, ).status, "Pending", ); - await command({ action: "Affiliation", personId: wrongId, transition: "Establish" }); - await command({ action: "Affiliation", personId: leaderId, transition: "Establish" }); + yield* command({ action: "Affiliation", personId: wrongId, transition: "Establish" }); + yield* command({ action: "Affiliation", personId: leaderId, transition: "Establish" }); const apiCoverageProposalId = `school-service-proposal-${"a".repeat(64)}`; - await pool.query( - `INSERT INTO school_service_proposals( + yield* step(() => + journeyPool.query( + `INSERT INTO school_service_proposals( proposal_id,department_id,semester_id,status,revision,created_at,created_by_person_id, confirmed_at,confirmed_by_person_id,demand_snapshot,assignment_snapshot,exception_snapshot, reviewed_exception_ids ) VALUES($1,$2,$3,'Confirmed',2,'2024-02-01T10:00:00.000Z',$4, '2024-02-01T10:00:00.000Z',$4,$5::jsonb,$6::jsonb,'[]'::jsonb,'[]'::jsonb)`, - [ - apiCoverageProposalId, - departmentId, - semesterId, - leaderId, - JSON.stringify([ - { schoolId: 961, day: "Monday", block: "1", requiredVolunteers: 1, revision: 1 }, - { schoolId: 961, day: "Monday", block: "2", requiredVolunteers: 1, revision: 1 }, - { schoolId: 961, day: "Tuesday", block: "1", requiredVolunteers: 1, revision: 1 }, - { schoolId: 961, day: "Wednesday", block: "1", requiredVolunteers: 2, revision: 1 }, - { schoolId: 961, day: "Thursday", block: "1", requiredVolunteers: 1, revision: 1 }, - { schoolId: 961, day: "Friday", block: "1", requiredVolunteers: 1, revision: 1 }, - ]), - JSON.stringify([ - { - placementId: `placement-${"b".repeat(64)}`, - personId: volunteerId, - firstName: "Irene", - lastName: "Intervjuer", - schoolId: 961, - schoolName: "Skole Alfa", - day: "Monday", - block: "1", - }, - { - placementId: `placement-${"4".repeat(64)}`, - personId: volunteerId, - firstName: "Irene", - lastName: "Intervjuer", - schoolId: 961, - schoolName: "Skole Alfa", - day: "Monday", - block: "2", - }, - { - placementId: `placement-${"d".repeat(64)}`, - personId: leaderId, - firstName: "Lina", - lastName: "Lagleder", - schoolId: 961, - schoolName: "Skole Alfa", - day: "Tuesday", - block: "1", - }, - ...[ - { - day: "Wednesday", - personId: leaderId, - firstName: "Lina", - lastName: "Lagleder", - suffix: "e", - }, + [ + apiCoverageProposalId, + departmentId, + semesterId, + leaderId, + JSON.stringify([ + { schoolId: 961, day: "Monday", block: "1", requiredVolunteers: 1, revision: 1 }, + { schoolId: 961, day: "Monday", block: "2", requiredVolunteers: 1, revision: 1 }, + { schoolId: 961, day: "Tuesday", block: "1", requiredVolunteers: 1, revision: 1 }, + { schoolId: 961, day: "Wednesday", block: "1", requiredVolunteers: 2, revision: 1 }, + { schoolId: 961, day: "Thursday", block: "1", requiredVolunteers: 1, revision: 1 }, + { schoolId: 961, day: "Friday", block: "1", requiredVolunteers: 1, revision: 1 }, + ]), + JSON.stringify([ { - day: "Wednesday", + placementId: `placement-${"b".repeat(64)}`, personId: volunteerId, firstName: "Irene", lastName: "Intervjuer", - suffix: "f", + schoolId: 961, + schoolName: "Skole Alfa", + day: "Monday", + block: "1", }, { - day: "Thursday", - personId: leaderId, - firstName: "Lina", - lastName: "Lagleder", - suffix: "1", + placementId: `placement-${"4".repeat(64)}`, + personId: volunteerId, + firstName: "Irene", + lastName: "Intervjuer", + schoolId: 961, + schoolName: "Skole Alfa", + day: "Monday", + block: "2", }, { - day: "Friday", + placementId: `placement-${"d".repeat(64)}`, personId: leaderId, firstName: "Lina", lastName: "Lagleder", - suffix: "2", + schoolId: 961, + schoolName: "Skole Alfa", + day: "Tuesday", + block: "1", }, - ].map(({ day, personId, firstName, lastName, suffix }) => ({ - placementId: `placement-${suffix.repeat(64)}`, - personId, - firstName, - lastName, - schoolId: 961, - schoolName: "Skole Alfa", - day, - block: "1", - })), - ]), - ], + ...[ + { + day: "Wednesday", + personId: leaderId, + firstName: "Lina", + lastName: "Lagleder", + suffix: "e", + }, + { + day: "Wednesday", + personId: volunteerId, + firstName: "Irene", + lastName: "Intervjuer", + suffix: "f", + }, + { + day: "Thursday", + personId: leaderId, + firstName: "Lina", + lastName: "Lagleder", + suffix: "1", + }, + { + day: "Friday", + personId: leaderId, + firstName: "Lina", + lastName: "Lagleder", + suffix: "2", + }, + ].map(({ day, personId, firstName, lastName, suffix }) => ({ + placementId: `placement-${suffix.repeat(64)}`, + personId, + firstName, + lastName, + schoolId: 961, + schoolName: "Skole Alfa", + day, + block: "1", + })), + ]), + ], + ), ); const historicalOccurrenceId = `school-service-occurrence-${"3".repeat(64)}`; // Simulate a pre-migration row in the disposable database. Product writes cannot bypass this guard. - const fixtureClient = await pool.connect(); - - try { - await fixtureClient.query("BEGIN"); - await fixtureClient.query( - "ALTER TABLE school_service_occurrences DISABLE TRIGGER school_service_occurrence_insert_guard", - ); - await fixtureClient.query( - `INSERT INTO school_service_occurrences(occurrence_id,proposal_id,department_id,semester_id,school_id,day,block,occurred_on,attended_person_ids,recorded_at,recorded_by_person_id) + yield* Effect.acquireUseRelease( + step(() => journeyPool.connect()), + (fixtureClient) => + committed( + fixtureClient, + Effect.gen(function* () { + yield* step(() => + fixtureClient.query( + "ALTER TABLE school_service_occurrences DISABLE TRIGGER school_service_occurrence_insert_guard", + ), + ); + yield* step(() => + fixtureClient.query( + `INSERT INTO school_service_occurrences(occurrence_id,proposal_id,department_id,semester_id,school_id,day,block,occurred_on,attended_person_ids,recorded_at,recorded_by_person_id) VALUES($1,$2,$3,$4,961,'Friday','1','2024-03-08',$5::jsonb,'2024-03-08T11:30:00.000Z',$6)`, - [ - historicalOccurrenceId, - apiCoverageProposalId, - departmentId, - semesterId, - JSON.stringify([leaderId]), - leaderId, - ], - ); - await fixtureClient.query( - "ALTER TABLE school_service_occurrences ENABLE TRIGGER school_service_occurrence_insert_guard", - ); - await fixtureClient.query("COMMIT"); - } catch (error) { - await fixtureClient.query("ROLLBACK"); - throw error; - } finally { - fixtureClient.release(); - } + [ + historicalOccurrenceId, + apiCoverageProposalId, + departmentId, + semesterId, + JSON.stringify([leaderId]), + leaderId, + ], + ), + ); + yield* step(() => + fixtureClient.query( + "ALTER TABLE school_service_occurrences ENABLE TRIGGER school_service_occurrence_insert_guard", + ), + ); + }), + ), + (fixtureClient) => Effect.sync(() => fixtureClient.release()), + ); const schedule = ( day: "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday", @@ -1341,8 +1571,8 @@ try { for (const day of Rec.keys(serviceDates)) { const serviceDate = serviceDates[day]; - const before = await readBoard(); - const result = await command(schedule(day, serviceDate)); + const before = yield* readBoard(); + const result = yield* command(schedule(day, serviceDate)); const commitment = result.commitments.find( (item) => item.schoolId === 961 && item.serviceDate === serviceDate && item.block === "1", @@ -1354,77 +1584,82 @@ try { commitmentIds.set(day, commitment.commitmentId); if (day === "Monday") { - await expectStatus( - await request(boardPath, leader, schedule(day, serviceDate), before.etag), + expectStatus( + yield* request(boardPath, leader, schedule(day, serviceDate), before.etag), 412, "precondition.failed", ); - await expectStatus( - await request(boardPath, leader, schedule(day, serviceDate), result.etag), + expectStatus( + yield* request(boardPath, leader, schedule(day, serviceDate), result.etag), 409, ); } } - const commitments = Schema.decodeUnknownSync( + const commitments = yield* Schema.decodeUnknownEffect( Schema.Struct(Rec.map(serviceDates, () => Schema.String)), )(Object.fromEntries(commitmentIds)); assert.equal( - (await readCoverageBoard()).commitments.filter( + (yield* readCoverageBoard()).commitments.filter( (item) => item.proposalId === apiCoverageProposalId, ).length, 4, ); assert.equal( - (await readBoard()).commitments.filter((item) => item.proposalId === apiCoverageProposalId) + (yield* readBoard()).commitments.filter((item) => item.proposalId === apiCoverageProposalId) .length, 4, ); - await expectStatus( - await request( + expectStatus( + yield* request( boardPath, volunteer, schedule("Monday", serviceDates.Monday), - (await readBoard()).etag, + (yield* readBoard()).etag, ), 403, "authority.denied", ); - await expectStatus( - await request( + expectStatus( + yield* request( boardPath, leader, { ...schedule("Monday", serviceDates.Monday), serviceDate: "2024-03-05" }, - (await readBoard()).etag, + (yield* readBoard()).etag, ), 422, ); - await expectStatus( - await request( + expectStatus( + yield* request( boardPath, leader, { ...schedule("Monday", serviceDates.Monday), startTime: "11:00", endTime: "09:00" }, - (await readBoard()).etag, + (yield* readBoard()).etag, ), 422, ); - await expectStatus( - await request(boardPath, leader, schedule("Friday", "2024-03-08"), (await readBoard()).etag), + expectStatus( + yield* request( + boardPath, + leader, + schedule("Friday", "2024-03-08"), + (yield* readBoard()).etag, + ), 409, ); assert.equal( - ( - await pool.query( + (yield* step(() => + journeyPool.query( "SELECT commitment_id FROM school_service_occurrences WHERE occurrence_id=$1", [historicalOccurrenceId], - ) - ).rows[0].commitment_id, + ), + )).rows[0].commitment_id, null, ); - const beforeOverlap = await readBoard(); - await expectStatus( - await request( + const beforeOverlap = yield* readBoard(); + expectStatus( + yield* request( boardPath, leader, { @@ -1439,16 +1674,16 @@ try { "commitment.duplicate", ); assert.equal( - (await readBoard()).etag, + (yield* readBoard()).etag, beforeOverlap.etag, "overlapping person appointment has no write", ); - assert.equal((await request(ownCoveragePath)).status, 401); - assert.equal((await request(coverageBoardPath)).status, 401); - await expectStatus(await request(coverageBoardPath, volunteer), 403, "authority.denied"); - await expectStatus(await request(coverageBoardPath, wrong), 403, "authority.denied"); - await expectStatus( - await request( + assert.equal((yield* request(ownCoveragePath)).status, 401); + assert.equal((yield* request(coverageBoardPath)).status, 401); + expectStatus(yield* request(coverageBoardPath, volunteer), 403, "authority.denied"); + expectStatus(yield* request(coverageBoardPath, wrong), 403, "authority.denied"); + expectStatus( + yield* request( `/api/placements/coverage?${new URLSearchParams({ departmentId: wrongDepartmentId, semesterId, @@ -1458,10 +1693,8 @@ try { 403, "authority.denied", ); - const ownCoverageResponse = await request(ownCoveragePath, volunteer); - assert.equal(ownCoverageResponse.headers.get("cache-control"), "private, no-store"); - await expectStatus(ownCoverageResponse, 200); - const initialOwnCoverage = await readOwnCoverage(volunteerSdk); + expectStatus(yield* request(ownCoveragePath, volunteer), 200); + const initialOwnCoverage = yield* readOwnCoverage(volunteer); assert.deepEqual( initialOwnCoverage.commitments .filter((item) => item.proposalId === apiCoverageProposalId) @@ -1470,19 +1703,19 @@ try { [commitments.Monday, commitments.Wednesday].sort(), ); assert.deepEqual( - (await readOwnCoverage(wrongSdk)).commitments.filter( + (yield* readOwnCoverage(wrong)).commitments.filter( (item) => item.proposalId === apiCoverageProposalId, ), [], ); assert.deepEqual( - (await readOwnCoverage(candidateSdk)).commitments.filter( + (yield* readOwnCoverage(candidate)).commitments.filter( (item) => item.proposalId === apiCoverageProposalId, ), [], ); - await expectStatus( - await request( + expectStatus( + yield* request( ownCoveragePath, volunteer, { action: "ReportAbsence", commitmentId: commitments.Tuesday }, @@ -1493,8 +1726,8 @@ try { const coveredAbsenceCommand = { action: "ReportAbsence", commitmentId: commitments.Monday }; const reportAbsenceKey = randomBytes(18).toString("base64url"); - const reportedOwnCoverage = await commandOwnCoverage( - volunteerSdk, + const reportedOwnCoverage = yield* commandOwnCoverage( + volunteer, coveredAbsenceCommand, initialOwnCoverage.etag, reportAbsenceKey, @@ -1509,8 +1742,8 @@ try { assert.ok(coveredAbsence); assert.deepEqual( - await expectStatus( - await request( + expectStatus( + yield* request( ownCoveragePath, volunteer, coveredAbsenceCommand, @@ -1521,8 +1754,8 @@ try { ), reportedOwnCoverage, ); - await expectStatus( - await request( + expectStatus( + yield* request( ownCoveragePath, volunteer, { ...coveredAbsenceCommand, commitmentId: commitments.Wednesday }, @@ -1534,9 +1767,9 @@ try { ); // A rejected coverage command keeps the board version and every coverage fact. - const coverageFacts = async () => - ( - await pool.query( + const coverageFacts = Effect.fnUntraced(function* () { + return (yield* step(() => + journeyPool.query( `SELECT (SELECT count(*)::integer FROM school_service_absences) AS absences, (SELECT count(*)::integer FROM school_service_coverage_records) AS records, @@ -1546,25 +1779,26 @@ try { (SELECT count(*)::integer FROM school_service_decisions) AS decisions, (SELECT count(*)::integer FROM school_service_closures) AS closures, (SELECT count(*)::integer FROM school_service_coverage_audit) AS audit`, - ) - ).rows[0]; + ), + )).rows[0]; + }); - const rejectedCoverage = async ( + const rejectedCoverage = Effect.fnUntraced(function* ( path: string, cookie: string, payload: Schema.Json, etag: string, status: number, code: string, - ) => { - const before = { board: (await readCoverageBoard()).etag, facts: await coverageFacts() }; - await expectStatus(await request(path, cookie, payload, etag), status, code); + ) { + const before = { board: (yield* readCoverageBoard()).etag, facts: yield* coverageFacts() }; + expectStatus(yield* request(path, cookie, payload, etag), status, code); assert.deepEqual( - { board: (await readCoverageBoard()).etag, facts: await coverageFacts() }, + { board: (yield* readCoverageBoard()).etag, facts: yield* coverageFacts() }, before, `${code} must leave coverage unchanged`, ); - }; + }); const recordCoverage = (absenceId: string, coveringPersonId: string) => ({ action: "RecordCoverage", @@ -1574,25 +1808,26 @@ try { const withdrawCoverage = (absenceId: string) => ({ action: "WithdrawCoverage", absenceId }); - const reservationsOf = async (commitmentId: string) => - ( - await pool.query( + const reservationsOf = Effect.fnUntraced(function* (commitmentId: string) { + return (yield* step(() => + journeyPool.query( `SELECT source_kind AS "sourceKind",person_id AS "personId",coverage_id AS "coverageId" FROM school_service_person_reservations WHERE commitment_id=$1 ORDER BY source_kind,person_id`, [commitmentId], - ) - ).rows; + ), + )).rows; + }); const audit = (action: string, actorPersonId: string) => ({ action, actorPersonId }); // Before an admission outcome nobody can cover: not the applicant, not the absent volunteer, // and not a person with an active affiliation but no placement. - let coverageBoard = await readCoverageBoard(); + let coverageBoard = yield* readCoverageBoard(); assert.deepEqual(coverageBoard.coverers, []); - assert.deepEqual((await readOwnCoverage(volunteerSdk)).coverers, []); + assert.deepEqual((yield* readOwnCoverage(volunteer)).coverers, []); for (const personId of [substitute.personId, volunteerId, wrongId]) - await rejectedCoverage( + yield* rejectedCoverage( coverageBoardPath, leader, recordCoverage(coveredAbsence.absenceId, personId), @@ -1604,28 +1839,36 @@ try { // Admission management records the Substitute outcome; the applicant is then on call. const coverageApplicationId = "application-coverage-0111"; - const outcomeEntry = Schema.decodeUnknownSync( - Schema.Struct({ - outcome: Schema.NullOr(Schema.String), - revision: Schema.Int, - etag: Schema.String, - }), - )( - await expectStatus( - await request(`/api/admission-outcomes/${coverageApplicationId}`, leader), - 200, + // Admission outcomes are served as RPCs; the leader calls them with the leader's session. + const outcomeClient = nativeScriptClient(backendOrigin); + const leaderSession = { cookie: leader, origin: dashboardOrigin }; + const outcomeApplicationId = PublicApplicationIdSchema.make(coverageApplicationId); + + const outcomeAnswer = yield* step(() => + outcomeClient.call(leaderSession, (client) => + client["admissionOutcomes.readOutcome"]({ applicationId: outcomeApplicationId }), ), ); + assert.ok(outcomeAnswer.ok, yield* jsonText(outcomeAnswer)); + const outcomeEntry = outcomeAnswer.value; + assert.deepEqual([outcomeEntry.outcome, outcomeEntry.revision], [null, 0]); - const onCall = ( - await sdk.admissionOutcomes.recordOutcome({ - params: { applicationId: PublicApplicationIdSchema.make(coverageApplicationId) }, - headers: idempotencyHeaders(outcomeEntry.etag), - payload: { outcome: "Substitute" }, - }) - ).body; + const onCallAnswer = yield* step(() => + outcomeClient.call(leaderSession, (client) => + client["admissionOutcomes.recordOutcome"]({ + applicationId: outcomeApplicationId, + idempotencyKey: IdempotencyKey.make(randomBytes(18).toString("base64url")), + ifMatch: outcomeEntry.etag, + request: { outcome: "Substitute" }, + }), + ), + ); + + yield* step(() => outcomeClient.dispose()); + assert.ok(onCallAnswer.ok, yield* jsonText(onCallAnswer)); + const onCall = onCallAnswer.value; assert.deepEqual([onCall.outcome, onCall.revision], ["Substitute", 1]); @@ -1636,39 +1879,36 @@ try { kind: "Substitute", }; - coverageBoard = await readCoverageBoard(); + coverageBoard = yield* readCoverageBoard(); assert.deepEqual(coverageBoard.coverers, [onCallCoverer]); - assert.deepEqual((await readOwnCoverage(volunteerSdk)).coverers, [onCallCoverer]); + assert.deepEqual((yield* readOwnCoverage(volunteer)).coverers, [onCallCoverer]); assert.deepEqual( - (await readOwnCoverage(candidateSdk)).coverers, + (yield* readOwnCoverage(candidate)).coverers, [], "only a person with an open absence sees the names of possible coverers", ); // The own endpoint serves only the absent volunteer. - for (const [client, cookie] of [ - [candidateSdk, candidate], - [wrongSdk, wrong], - ] as const) + for (const cookie of [candidate, wrong]) for (const payload of [ recordCoverage(coveredAbsence.absenceId, substitute.personId), withdrawCoverage(coveredAbsence.absenceId), ]) - await rejectedCoverage( + yield* rejectedCoverage( ownCoveragePath, cookie, payload, - (await readOwnCoverage(client)).etag, + (yield* readOwnCoverage(cookie)).etag, 403, "coverage.owner-invalid", ); // The absent volunteer records who agreed to cover; the record reserves that person. - const ownBeforeRecord = await readOwnCoverage(volunteerSdk); + const ownBeforeRecord = yield* readOwnCoverage(volunteer); const recordKey = randomBytes(18).toString("base64url"); - const recordedOwn = await commandOwnCoverage( - volunteerSdk, + const recordedOwn = yield* commandOwnCoverage( + volunteer, recordCoverage(coveredAbsence.absenceId, substitute.personId), ownBeforeRecord.etag, recordKey, @@ -1689,8 +1929,8 @@ try { [substitute.personId, substitute.firstName, "Substitute", volunteerId], ); assert.deepEqual( - await expectStatus( - await request( + expectStatus( + yield* request( ownCoveragePath, volunteer, recordCoverage(coveredAbsence.absenceId, substitute.personId), @@ -1701,8 +1941,8 @@ try { ), recordedOwn, ); - await expectStatus( - await request( + expectStatus( + yield* request( ownCoveragePath, volunteer, withdrawCoverage(coveredAbsence.absenceId), @@ -1712,8 +1952,8 @@ try { 409, "idempotency.digest-conflict", ); - await expectStatus( - await request( + expectStatus( + yield* request( ownCoveragePath, volunteer, withdrawCoverage(coveredAbsence.absenceId), @@ -1722,14 +1962,14 @@ try { 412, "precondition.failed", ); - assert.deepEqual(await reservationsOf(commitments.Monday), [ + assert.deepEqual(yield* reservationsOf(commitments.Monday), [ { sourceKind: "Coverage", personId: substitute.personId, coverageId: firstRecord.coverageId }, { sourceKind: "Scheduled", personId: volunteerId, coverageId: null }, ]); // An active placement makes the coordinator an assistant who can cover. Recording her // replaces the current record: withdrawal and new record share one transaction. - const assistantBoard = await command({ + const assistantBoard = yield* command({ action: "Create", personId: leaderId, schoolId: 961, @@ -1748,14 +1988,16 @@ try { assert.ok(assistantPlacement); assert.deepEqual( - (await readCoverageBoard()).coverers.map((coverer) => [coverer.personId, coverer.kind]), + (yield* readCoverageBoard()).coverers.map((coverer) => [coverer.personId, coverer.kind]), [ [substitute.personId, "Substitute"], [leaderId, "Assistant"], ], ); - const replacedBoard = await commandCoverage(recordCoverage(coveredAbsence.absenceId, leaderId)); + const replacedBoard = yield* commandCoverage( + recordCoverage(coveredAbsence.absenceId, leaderId), + ); const replacingRecord = replacedBoard.coverage.find( (item) => item.absenceId === coveredAbsence.absenceId, @@ -1771,32 +2013,32 @@ try { [leaderId, "Assistant", leaderId], ); assert.deepEqual( - ( - await pool.query( + (yield* step(() => + journeyPool.query( `SELECT withdrawn_by_person_id AS "withdrawnBy", withdrawn_at=(SELECT recorded_at FROM school_service_coverage_records WHERE coverage_id=$2) AS "sameTransaction" FROM school_service_coverage_records WHERE coverage_id=$1`, [firstRecord.coverageId, replacingRecord.coverageId], - ) - ).rows, + ), + )).rows, [{ withdrawnBy: leaderId, sameTransaction: true }], ); - assert.deepEqual(await reservationsOf(commitments.Monday), [ + assert.deepEqual(yield* reservationsOf(commitments.Monday), [ { sourceKind: "Coverage", personId: leaderId, coverageId: replacingRecord.coverageId }, { sourceKind: "Scheduled", personId: volunteerId, coverageId: null }, ]); // Withdrawal leaves the absence uncovered and releases the covering person. - const withdrawnBoard = await commandCoverage(withdrawCoverage(coveredAbsence.absenceId)); + const withdrawnBoard = yield* commandCoverage(withdrawCoverage(coveredAbsence.absenceId)); assert.deepEqual( withdrawnBoard.coverage.filter((item) => item.absenceId === coveredAbsence.absenceId), [], ); - assert.deepEqual(await reservationsOf(commitments.Monday), [ + assert.deepEqual(yield* reservationsOf(commitments.Monday), [ { sourceKind: "Scheduled", personId: volunteerId, coverageId: null }, ]); - await rejectedCoverage( + yield* rejectedCoverage( coverageBoardPath, leader, withdrawCoverage(coveredAbsence.absenceId), @@ -1813,7 +2055,7 @@ try { evidenceSource: "Skole Alfa kontakt, telefon 2024-03-04", }; - await rejectedCoverage( + yield* rejectedCoverage( coverageBoardPath, leader, completeCovered, @@ -1822,7 +2064,7 @@ try { "commitment.outcome-invalid", ); - let closeCoverageBoard = await commandCoverage( + let closeCoverageBoard = yield* commandCoverage( recordCoverage(coveredAbsence.absenceId, substitute.personId), withdrawnBoard.etag, ); @@ -1837,13 +2079,13 @@ try { [substitute.personId, leaderId], ); assert.deepEqual( - (await readOwnCoverage(candidateSdk)).commitments + (yield* readOwnCoverage(candidate)).commitments .filter((item) => item.proposalId === apiCoverageProposalId) .map((item) => item.commitmentId), [commitments.Monday], "the covering person reads the service she covers", ); - await rejectedCoverage( + yield* rejectedCoverage( coverageBoardPath, leader, { @@ -1858,7 +2100,7 @@ try { ); const completeKey = randomBytes(18).toString("base64url"); - const coveredClosure = await commandCoverage( + const coveredClosure = yield* commandCoverage( completeCovered, closeCoverageBoard.etag, completeKey, @@ -1891,8 +2133,8 @@ try { ], ); assert.deepEqual( - await expectStatus( - await request( + expectStatus( + yield* request( coverageBoardPath, leader, completeCovered, @@ -1903,7 +2145,7 @@ try { ), coveredClosure, ); - closeCoverageBoard = await readCoverageBoard(); + closeCoverageBoard = yield* readCoverageBoard(); // A decided service accepts no second decision, absence or coverage change. for (const [path, cookie, payload, etag] of [ @@ -1931,22 +2173,17 @@ try { recordCoverage(coveredAbsence.absenceId, leaderId), closeCoverageBoard.etag, ], - [ - ownCoveragePath, - volunteer, - coveredAbsenceCommand, - (await readOwnCoverage(volunteerSdk)).etag, - ], + [ownCoveragePath, volunteer, coveredAbsenceCommand, (yield* readOwnCoverage(volunteer)).etag], ] as const) - await expectStatus(await request(path, cookie, payload, etag), 409, "commitment.closed"); + expectStatus(yield* request(path, cookie, payload, etag), 409, "commitment.closed"); assert.equal( - (await readCoverageBoard()).etag, + (yield* readCoverageBoard()).etag, closeCoverageBoard.etag, "post-terminal commands write no second fact", ); // Tuesday: the coordinator reports an absence nobody covers; zero attendance is Unfulfilled. - const coordinatorAbsenceBoard = await commandCoverage( + const coordinatorAbsenceBoard = yield* commandCoverage( { action: "ReportAbsenceForVolunteer", personId: leaderId, @@ -1971,7 +2208,7 @@ try { evidenceSource: "Skole Alfa kontakt, telefon 2024-03-05", }; - await rejectedCoverage( + yield* rejectedCoverage( coverageBoardPath, leader, { @@ -1983,7 +2220,7 @@ try { 422, "commitment.outcome-invalid", ); - const uncoveredClosure = await commandCoverage(zeroUnfulfilled, coordinatorAbsenceBoard.etag); + const uncoveredClosure = yield* commandCoverage(zeroUnfulfilled, coordinatorAbsenceBoard.etag); const tuesdayDecision = uncoveredClosure.commitments.find( (item) => item.commitmentId === commitments.Tuesday, @@ -1999,8 +2236,8 @@ try { .map((closure) => [closure.outcome, closure.occurrenceId, closure.coverageId]), [["Uncovered", null, null]], ); - await expectStatus( - await request( + expectStatus( + yield* request( coverageBoardPath, leader, recordCoverage(uncoveredAbsence.absenceId, substitute.personId), @@ -2012,7 +2249,7 @@ try { // Wednesday needs two. A person scheduled on the service, or already covering another absence // in the same interval, is unavailable. - const wednesdayOwn = await commandOwnCoverage(volunteerSdk, { + const wednesdayOwn = yield* commandOwnCoverage(volunteer, { action: "ReportAbsence", commitmentId: commitments.Wednesday, }); @@ -2022,16 +2259,16 @@ try { ); assert.ok(volunteerWednesday); - await rejectedCoverage( + yield* rejectedCoverage( coverageBoardPath, leader, recordCoverage(volunteerWednesday.absenceId, leaderId), - (await readCoverageBoard()).etag, + (yield* readCoverageBoard()).etag, 409, "coverage.coverer-unavailable", ); - const wednesdayCovered = await commandCoverage( + const wednesdayCovered = yield* commandCoverage( recordCoverage(volunteerWednesday.absenceId, substitute.personId), ); @@ -2041,7 +2278,7 @@ try { assert.ok(wednesdayRecord); - const bothAbsent = await commandCoverage( + const bothAbsent = yield* commandCoverage( { action: "ReportAbsenceForVolunteer", personId: leaderId, @@ -2055,7 +2292,7 @@ try { ); assert.ok(leaderWednesday); - await rejectedCoverage( + yield* rejectedCoverage( coverageBoardPath, leader, recordCoverage(leaderWednesday.absenceId, substitute.personId), @@ -2071,7 +2308,7 @@ try { evidenceSource: "Skole Alfa kontakt, telefon 2024-03-06", }; - await rejectedCoverage( + yield* rejectedCoverage( coverageBoardPath, leader, { @@ -2084,18 +2321,17 @@ try { "commitment.outcome-invalid", ); - const competingPartial = await Promise.all( + const competingPartial = yield* Effect.all( [0, 1].map(() => request(coverageBoardPath, leader, partialUnfulfilled, bothAbsent.etag)), + { concurrency: "unbounded" }, ); assert.equal(competingPartial.filter((response) => response.status === 200).length, 1); - for (const response of competingPartial.filter((result) => result.status !== 200)) { + for (const response of competingPartial.filter((result) => result.status !== 200)) assert.ok([409, 412].includes(response.status)); - await response.json(); - } - const partialDecision = (await readCoverageBoard()).commitments.find( + const partialDecision = (yield* readCoverageBoard()).commitments.find( (item) => item.commitmentId === commitments.Wednesday, )?.decision; @@ -2104,7 +2340,7 @@ try { assert.ok(partialDecision?.occurrenceId); // Thursday: cancellation records no attendance or closure and releases every reservation. - const thursdayAbsenceBoard = await commandCoverage({ + const thursdayAbsenceBoard = yield* commandCoverage({ action: "ReportAbsenceForVolunteer", personId: leaderId, commitmentId: commitments.Thursday, @@ -2116,7 +2352,7 @@ try { assert.ok(cancelledAbsenceId); - const thursdayCovered = await commandCoverage( + const thursdayCovered = yield* commandCoverage( recordCoverage(cancelledAbsenceId, substitute.personId), thursdayAbsenceBoard.etag, ); @@ -2126,7 +2362,7 @@ try { ); assert.ok(thursdayRecord); - assert.deepEqual(await reservationsOf(commitments.Thursday), [ + assert.deepEqual(yield* reservationsOf(commitments.Thursday), [ { sourceKind: "Coverage", personId: substitute.personId, @@ -2142,7 +2378,7 @@ try { evidenceSource: "Skole Alfa kontakt, telefon 2024-03-07", }; - const cancelled = await commandCoverage(cancel, thursdayCovered.etag); + const cancelled = yield* commandCoverage(cancel, thursdayCovered.etag); assert.equal( cancelled.commitments.find((item) => item.commitmentId === commitments.Thursday)?.decision ?.outcome, @@ -2161,20 +2397,20 @@ try { cancelled.closures.filter((item) => item.absenceId === cancelledAbsenceId).length, 0, ); - assert.deepEqual(await reservationsOf(commitments.Thursday), []); + assert.deepEqual(yield* reservationsOf(commitments.Thursday), []); for (const payload of [ { ...partialUnfulfilled, commitmentId: commitments.Thursday }, withdrawCoverage(cancelledAbsenceId), ]) - await expectStatus( - await request(coverageBoardPath, leader, payload, cancelled.etag), + expectStatus( + yield* request(coverageBoardPath, leader, payload, cancelled.etag), 409, "commitment.closed", ); // The assistant placement served only as a coverer; the browser roster must not include it. - await command({ action: "Remove", placementId: assistantPlacement.placementId }); - const finalApiCoverage = await readCoverageBoard(); + yield* command({ action: "Remove", placementId: assistantPlacement.placementId }); + const finalApiCoverage = yield* readCoverageBoard(); assert.deepEqual( finalApiCoverage.commitments .filter((item) => item.proposalId === apiCoverageProposalId) @@ -2201,8 +2437,8 @@ try { attendedPersonIds: string[]; occurrenceId: string | null; decidedBy: string; - }> = ( - await pool.query( + }> = (yield* step(() => + journeyPool.query( `SELECT commitment.commitment_id AS "commitmentId",commitment.service_date::text AS "serviceDate", commitment.required_volunteers AS "requiredVolunteers",commitment.start_time::text AS "startTime", commitment.end_time::text AS "endTime",decision.outcome,decision.evidence_source AS "evidenceSource", @@ -2212,8 +2448,8 @@ try { JOIN school_service_decisions AS decision USING(commitment_id) WHERE commitment.proposal_id=$1 ORDER BY commitment.service_date`, [apiCoverageProposalId], - ) - ).rows; + ), + )).rows; assert.deepEqual( durableDecisions.map((item) => [ @@ -2274,13 +2510,13 @@ try { assert.ok(durableDecisions[2]?.occurrenceId); assert.equal(durableDecisions[3]?.occurrenceId, null); assert.deepEqual( - ( - await pool.query( + (yield* step(() => + journeyPool.query( `SELECT commitment_id AS "commitmentId",attended_person_ids AS "attendedPersonIds" FROM school_service_occurrences WHERE commitment_id=ANY($1) ORDER BY occurred_on`, [Object.values(commitments)], - ) - ).rows, + ), + )).rows, [ { commitmentId: commitments.Monday, attendedPersonIds: [substitute.personId] }, { commitmentId: commitments.Wednesday, attendedPersonIds: [substitute.personId] }, @@ -2290,8 +2526,8 @@ try { // Each absence of a decided, noncancelled service closes Covered by its current record or // Uncovered; the cancelled service closes none. assert.deepEqual( - ( - await pool.query( + (yield* step(() => + journeyPool.query( `SELECT closure.absence_id AS "absenceId",closure.outcome,closure.coverage_id AS "coverageId", closure.covering_person_id AS "coveringPersonId", closure.scheduled_person_id AS "scheduledPersonId",closure.occurrence_id AS "occurrenceId" @@ -2299,8 +2535,8 @@ try { JOIN school_service_absences AS absence USING(absence_id) WHERE absence.proposal_id=$1 ORDER BY absence.service_date,absence.person_id`, [apiCoverageProposalId], - ) - ).rows, + ), + )).rows, [ { absenceId: coveredAbsence.absenceId, @@ -2337,8 +2573,8 @@ try { ], ); assert.deepEqual( - ( - await pool.query( + (yield* step(() => + journeyPool.query( `SELECT coverage.absence_id AS "absenceId",coverage.covering_person_id AS "coveringPersonId", coverage.coverer_kind AS "covererKind",coverage.recorded_by_person_id AS "recordedBy", coverage.withdrawn_by_person_id AS "withdrawnBy" @@ -2346,8 +2582,8 @@ try { JOIN school_service_absences AS absence USING(absence_id) WHERE absence.proposal_id=$1 ORDER BY coverage.recorded_at,coverage.coverage_id`, [apiCoverageProposalId], - ) - ).rows, + ), + )).rows, [ [coveredAbsence.absenceId, substitute.personId, "Substitute", volunteerId, leaderId], [coveredAbsence.absenceId, leaderId, "Assistant", leaderId, leaderId], @@ -2390,14 +2626,14 @@ try { ], ["Thursday", []], ] as const) - assert.deepEqual(await reservationsOf(commitments[day]), expected, `${day} reservations`); + assert.deepEqual(yield* reservationsOf(commitments[day]), expected, `${day} reservations`); - const apiCoverageAudit = ( - await pool.query( + const apiCoverageAudit = (yield* step(() => + journeyPool.query( `SELECT action,actor_person_id AS "actorPersonId" FROM school_service_coverage_audit ORDER BY audit_id`, - ) - ).rows; + ), + )).rows; assert.deepEqual(apiCoverageAudit, [ audit("ReportAbsence", volunteerId), @@ -2428,7 +2664,7 @@ try { auditActions: apiCoverageAudit.map((entry) => entry.action), }; - const browserLeaderBoard = await command({ + const browserLeaderBoard = yield* command({ action: "Create", personId: leaderId, schoolId: 962, @@ -2448,12 +2684,12 @@ try { ), ); - const apiHistoryBeforeBrowser = ( - await pool.query( + const apiHistoryBeforeBrowser = (yield* step(() => + journeyPool.query( "SELECT * FROM assistant_placements WHERE person_id=$1 ORDER BY placement_id", [leaderId], - ) - ).rows; + ), + )).rows; const manifest = { revision, @@ -2479,24 +2715,24 @@ try { }, }; - const manifestPath = join(artifacts, "manifest.json"); - await writeFile(manifestPath, JSON.stringify(manifest), { mode: 0o600 }); + const manifestPath = path.join(artifacts, "manifest.json"); + yield* fs.writeFileString(manifestPath, yield* jsonText(manifest), { mode: 0o600 }); let browserEvidence: Schema.JsonObject | null = null; if (mode === "--browser") { - await runAsync( + yield* runChild( "bun", ["apps/dashboard/e2e/run-real-native-placement.mjs"], { ...environment, PLACEMENT_JOURNEY_MANIFEST: manifestPath }, - 300_000, + "300 seconds", ); - browserEvidence = Schema.decodeSync(Schema.fromJsonString(Schema.JsonObject))( - await readFile(join(artifacts, "browser-evidence.json"), "utf8"), + browserEvidence = yield* Schema.decodeEffect(BrowserEvidenceJson)( + yield* fs.readFileString(path.join(artifacts, "browser-evidence.json")), ); assert.equal(browserEvidence?.passed, true); assert.equal(browserEvidence?.revision, revision); - const browserCoverageExpected = Schema.decodeUnknownSync( + const browserCoverageExpected = yield* Schema.decodeUnknownEffect( Schema.Struct({ absencePosts: Schema.Int, proposalId: Schema.String, @@ -2515,12 +2751,12 @@ try { assert.ok(browserCoverageExpected); assert.equal(browserCoverageExpected.absencePosts, 1); - const actual = ( - await pool.query( + const actual = (yield* step(() => + journeyPool.query( 'SELECT placement_id AS "placementId",person_id AS "personId",school_id::integer AS "schoolId",semester_id AS "semesterId",day,workdays,block,active,revision FROM assistant_placements WHERE person_id=$1 ORDER BY block,placement_id', [volunteerId], - ) - ).rows; + ), + )).rows; assert.deepEqual( actual, @@ -2528,21 +2764,21 @@ try { "browser expectations independently read from PostgreSQL", ); assert.deepEqual( - ( - await pool.query( + (yield* step(() => + journeyPool.query( "SELECT * FROM assistant_placements WHERE person_id=$1 ORDER BY placement_id", [leaderId], - ) - ).rows, + ), + )).rows, apiHistoryBeforeBrowser, ); assert.deepEqual( - ( - await pool.query( + (yield* step(() => + journeyPool.query( "SELECT action,actor_person_id FROM organization_volunteer_affiliation_audit WHERE person_id=$1 ORDER BY revision", [volunteerId], - ) - ).rows, + ), + )).rows, [ { action: "Request", actor_person_id: volunteerId }, { action: "Establish", actor_person_id: leaderId }, @@ -2550,8 +2786,8 @@ try { ], ); - const serviceProposal = ( - await pool.query( + const serviceProposal = (yield* step(() => + journeyPool.query( `SELECT proposal_id AS "proposalId",status,revision, jsonb_array_length(exception_snapshot) AS "exceptionCount", jsonb_array_length(assignment_snapshot) AS "assignmentCount" @@ -2559,8 +2795,8 @@ try { WHERE department_id=$1 AND semester_id=$2 ORDER BY created_at DESC LIMIT 1`, [departmentId, semesterId], - ) - ).rows[0]; + ), + )).rows[0]; assert.deepEqual(serviceProposal, { proposalId: browserEvidence?.serviceProposalId, @@ -2571,26 +2807,26 @@ try { }); assert.equal(browserCoverageExpected.proposalId, serviceProposal.proposalId); assert.deepEqual( - ( - await pool.query( + (yield* step(() => + journeyPool.query( `SELECT required_volunteers AS "requiredVolunteers" FROM school_service_demand WHERE department_id=$1 AND semester_id=$2 AND school_id=$3 AND day='Monday' AND block='2'`, [departmentId, semesterId, 962], - ) - ).rows, + ), + )).rows, [{ requiredVolunteers: 2 }], ); assert.deepEqual( - ( - await pool.query( + (yield* step(() => + journeyPool.query( `SELECT person_id AS "personId",status,attempts FROM school_service_notification_outbox WHERE proposal_id=$1 ORDER BY person_id`, [serviceProposal.proposalId], - ) - ).rows, + ), + )).rows, [ { personId: volunteerId, status: "Delivered", attempts: 1 }, { personId: leaderId, status: "Delivered", attempts: 1 }, @@ -2606,8 +2842,8 @@ try { reason: string | null; evidenceSource: string; occurrenceId: string | null; - }> = ( - await pool.query( + }> = (yield* step(() => + journeyPool.query( `SELECT commitment.commitment_id AS "commitmentId",commitment.required_volunteers AS "requiredVolunteers", commitment.service_date::text AS "serviceDate",decision.outcome, decision.attended_person_ids AS "attendedPersonIds",decision.reason, @@ -2615,8 +2851,8 @@ try { FROM school_service_commitments AS commitment JOIN school_service_decisions AS decision USING(commitment_id) WHERE commitment.proposal_id=$1 ORDER BY commitment.service_date`, [browserCoverageExpected.proposalId], - ) - ).rows; + ), + )).rows; assert.deepEqual( browserDecisions.map((item) => [ @@ -2666,15 +2902,15 @@ try { assert.ok(browserDecisions[1]?.occurrenceId); assert.equal(browserDecisions[2]?.occurrenceId, null); - const browserAbsences = ( - await pool.query( + const browserAbsences = (yield* step(() => + journeyPool.query( `SELECT absence_id AS "absenceId",person_id AS "personId", reporter_person_id AS "reporterPersonId",service_date::text AS "serviceDate" FROM school_service_absences WHERE proposal_id=$1 ORDER BY service_date,absence_id`, [browserCoverageExpected.proposalId], - ) - ).rows; + ), + )).rows; assert.deepEqual(browserAbsences, [ { @@ -2694,8 +2930,8 @@ try { // The volunteer's record covers the first service; the coordinator's record on the second // absence was withdrawn before the decision. assert.deepEqual( - ( - await pool.query( + (yield* step(() => + journeyPool.query( `SELECT coverage.coverage_id AS "coverageId",coverage.absence_id AS "absenceId", coverage.covering_person_id AS "coveringPersonId",coverage.coverer_kind AS "covererKind", coverage.recorded_by_person_id AS "recordedBy", @@ -2704,8 +2940,8 @@ try { JOIN school_service_absences AS absence USING(absence_id) WHERE absence.proposal_id=$1 ORDER BY coverage.recorded_at,coverage.coverage_id`, [browserCoverageExpected.proposalId], - ) - ).rows, + ), + )).rows, [ { coverageId: browserCoverageExpected.coverageId, @@ -2748,17 +2984,17 @@ try { ], [browserCoverageExpected.cancelledCommitmentId, []], ] as const) - assert.deepEqual(await reservationsOf(commitmentId), expected); + assert.deepEqual(yield* reservationsOf(commitmentId), expected); - const browserOccurrences = ( - await pool.query( + const browserOccurrences = (yield* step(() => + journeyPool.query( `SELECT occurrence_id AS "occurrenceId",occurred_on::text AS "occurredOn", attended_person_ids AS "attendedPersonIds",recorded_by_person_id AS "recordedByPersonId" FROM school_service_occurrences WHERE proposal_id=$1 ORDER BY occurred_on,occurrence_id`, [browserCoverageExpected.proposalId], - ) - ).rows; + ), + )).rows; assert.deepEqual( browserOccurrences.map( @@ -2788,8 +3024,8 @@ try { ], ); assert.deepEqual( - ( - await pool.query( + (yield* step(() => + journeyPool.query( `SELECT closure.absence_id AS "absenceId",closure.outcome, closure.coverage_id AS "coverageId", closure.covering_person_id AS "coveringPersonId", @@ -2799,8 +3035,8 @@ try { JOIN school_service_absences AS absence USING(absence_id) WHERE absence.proposal_id=$1 ORDER BY absence.service_date,closure.closure_id`, [browserCoverageExpected.proposalId], - ) - ).rows, + ), + )).rows, [ { absenceId: browserCoverageExpected.coveredAbsenceId, @@ -2821,14 +3057,14 @@ try { ], ); assert.deepEqual( - ( - await pool.query( + (yield* step(() => + journeyPool.query( `SELECT action,actor_person_id AS "actorPersonId" FROM school_service_coverage_audit ORDER BY audit_id OFFSET $1`, [apiCoverageAuditCount], - ) - ).rows, + ), + )).rows, [ audit("ReportAbsence", volunteerId), audit("RecordCoverage", volunteerId), @@ -2849,19 +3085,21 @@ try { } assert.deepEqual( - await credentialSnapshot(), + yield* credentialSnapshot, credentialsBefore, "placement does not mutate account credentials", ); assert.deepEqual( - (await pool.query("SELECT * FROM person_profiles ORDER BY person_id")).rows, + (yield* step(() => journeyPool.query("SELECT * FROM person_profiles ORDER BY person_id"))) + .rows, peopleBefore, "canonical Person unchanged", ); + yield* step(() => native.dispose()); const bunVersion = process.versions.bun; - const postgresVersion = Schema.decodeUnknownSync(Schema.String)( - (await pool.query("SELECT version() AS version")).rows[0].version, + const postgresVersion = yield* Schema.decodeUnknownEffect(Schema.String)( + (yield* step(() => journeyPool.query("SELECT version() AS version"))).rows[0].version, ); const runtime: { readonly postgres: string; readonly bun?: string } = @@ -2900,7 +3138,7 @@ try { productionBoundary: "No production data, provider, credentials, deployment, or cutover is contacted or changed.", apiGates: [ - "real generated SDK read/write decoding", + "real RPC client read/write decoding", "private self discovery and no-team/regular-member privacy", "wrong-department/inactive/anonymous denies and global admin", "explicit historical scope and active associated schools", @@ -2916,14 +3154,70 @@ try { "canonical Person and account credentials unchanged", ], }; + + journeyPassed = true; + }); + + // The listeners stay until the runner has written its receipt, so that a signal during cleanup + // is recorded, not fatal. + yield* Effect.acquireRelease( + Effect.sync(() => + (["SIGTERM", "SIGINT"] as const).map((signal) => { + const listener = () => { + interruptedSignal ??= signal; + failure ??= "Interrupted by " + signal; + Deferred.doneUnsafe(interrupted, Exit.void); + }; + + process.once(signal, listener); + + return [signal, listener] as const; + }), + ), + (listeners) => + Effect.sync(() => { + for (const [signal, listener] of listeners) process.removeListener(signal, listener); + }), + ); + + const outcome = yield* journey.pipe(Effect.raceFirst(Deferred.await(interrupted)), Effect.exit); + + if (Exit.isFailure(outcome)) { + const error = sanitize(String(thrownBy(outcome.cause))); + + if (interruptedSignal === undefined) failure = error; + + yield* Effect.sync(() => process.stderr.write(error + "\n")); } -} catch (error) { - if (interruptedSignal === undefined) { - failure = sanitize(String(error)); - process.exitCode = 1; + + const errors = yield* cleanup; + + if (errors.length > 0) { + yield* Effect.sync(() => process.stderr.write(errors.join("; ") + "\n")); + + return 1; } - process.stderr.write(sanitize(String(error)) + "\n"); -} finally { - await cleanup(); -} + return interruptedSignal === "SIGINT" + ? 130 + : interruptedSignal === "SIGTERM" + ? 143 + : failure === undefined + ? 0 + : 1; +}); + +Effect.runPromise( + program.pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer)), + ), +).then( + (code) => { + process.exitCode = code; + }, + (cause: unknown) => { + process.stderr.write(String(cause) + "\n"); + process.exitCode = 1; + }, +); diff --git a/tools/e2e/public-application-outbox-driver.ts b/tools/e2e/public-application-outbox-driver.ts index 8e08e9561..005e8aeb5 100644 --- a/tools/e2e/public-application-outbox-driver.ts +++ b/tools/e2e/public-application-outbox-driver.ts @@ -1,31 +1,47 @@ -import process from "node:process"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; import * as BunServices from "@effect/platform-bun/BunServices"; import { Database } from "@vektorprogrammet/database"; import { DatabaseLive } from "@vektorprogrammet/database/live"; import { deliverNextPublicApplicationOutbox } from "@vektorprogrammet/database/application"; import { makeRecordingPublicApplicationEffectInterpreter } from "@vektorprogrammet/domain/application"; -import { Predicate, Effect, Layer, Redacted } from "effect"; +import { Cause, Config, Console, Data, Effect, Layer, Predicate, Schema } from "effect"; -const postgresUrl = process.env.PUBLIC_APPLICATION_OUTBOX_PG_URL; +class OutboxDriverFailure extends Data.TaggedError("OutboxDriverFailure")<{ + readonly message: string; +}> {} -if (!postgresUrl) { - throw new Error("Missing PUBLIC_APPLICATION_OUTBOX_PG_URL"); -} +// The JSON text of the evidence, byte for byte what `JSON.stringify` writes, as `jsonText` of the +// backend's RPC problem module does. +const jsonText = (value: A): Effect.Effect => + Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(value).pipe(Effect.orDie); + +const postgresUrl = Config.schema( + Schema.Redacted(Schema.NonEmptyString), + "PUBLIC_APPLICATION_OUTBOX_PG_URL", +).pipe( + Effect.mapError( + () => new OutboxDriverFailure({ message: "Missing PUBLIC_APPLICATION_OUTBOX_PG_URL" }), + ), +); + +type DeliveryResult = Effect.Success>; const claimedAt = "2031-09-15T12:00:01.000Z"; const interpreter = makeRecordingPublicApplicationEffectInterpreter(); -const databaseLayer = DatabaseLive({ - url: Redacted.make(postgresUrl), - applicationName: "public-application-recording-outbox-0039", - maxConnections: 2, -}).pipe(Layer.provide(BunServices.layer)); +const databaseLayer = Layer.unwrap( + Effect.map(postgresUrl, (url) => + DatabaseLive({ + url, + applicationName: "public-application-recording-outbox-0039", + maxConnections: 2, + }), + ), +); -const failProof = (message: string): Effect.Effect => - Effect.sync(() => { - throw new Error(message); - }); +const failProof = (message: string): Effect.Effect => + Effect.fail(new OutboxDriverFailure({ message })); const program = Effect.gen(function* () { const rows = yield* Database.use( @@ -61,7 +77,7 @@ const program = Effect.gen(function* () { // The failed effect returns to the retry queue; the claim order serves other commands' // effects fairly before it, so the retry is found in the drain instead of the next claim. - const delivered = []; + const delivered: Array> = []; while (true) { const result = yield* deliverNextPublicApplicationOutbox( @@ -104,15 +120,17 @@ const program = Effect.gen(function* () { }; }); -try { - const evidence = await Effect.runPromise( - Effect.scoped(program.pipe(Effect.provide(databaseLayer))), - ); +const main = Effect.scoped(program.pipe(Effect.provide(databaseLayer))).pipe( + Effect.flatMap(jsonText), + Effect.flatMap((evidenceText) => Console.log(evidenceText)), + Effect.tapCause((cause) => { + const error = Cause.squash(cause); - process.stdout.write(`${JSON.stringify(evidence)}\n`); -} catch (error) { - process.stderr.write( - `Public-application recording outbox driver failed: ${error instanceof Error ? error.message : "unknown failure"}\n`, - ); - process.exitCode = 1; -} + return Console.error( + `Public-application recording outbox driver failed: ${error instanceof Error ? error.message : "unknown failure"}`, + ); + }), + Effect.provide(BunServices.layer), +); + +BunRuntime.runMain(main, { disableErrorReporting: true }); diff --git a/tools/e2e/receipt-delivery-sink.ts b/tools/e2e/receipt-delivery-sink.ts index 1e5a189d0..02f5260dd 100644 --- a/tools/e2e/receipt-delivery-sink.ts +++ b/tools/e2e/receipt-delivery-sink.ts @@ -3,13 +3,17 @@ * * The outbox delivers the effects of one receipt in order. If a notification has no * delivery target, it keeps failing, and every later effect of that receipt waits - * behind it. A runner whose journey submits receipts starts this sink and spreads - * `environment` into the backend settings. + * behind it. A runner whose journey submits receipts builds `ReceiptDeliverySinkLive` on a + * loopback `HttpServer` and spreads `environment` into the backend settings; closing the + * layer's scope stops the sink. `startReceiptDeliverySink` is the runtime bridge of the Promise + * runners: it builds the sink on a loopback Bun server in its own runtime. */ +import { Buffer } from "node:buffer"; import { randomBytes } from "node:crypto"; -import { createServer } from "node:http"; +import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; import { ReceiptDeliveryEnvelope } from "@vektorprogrammet/backend/receipt/delivery"; -import { Predicate, Schema } from "effect"; +import { Context, Data, Effect, Exit, Layer, Predicate, Schema, Scope, Stream } from "effect"; +import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; export interface ReceiptDeliverySinkOptions { /** Sender address of the economy notifications. */ @@ -18,105 +22,144 @@ export interface ReceiptDeliverySinkOptions { readonly economyRecipients: Readonly>; } -const maxEnvelopeBytes = 65_536; +export class ReceiptDeliverySink extends Context.Service< + ReceiptDeliverySink, + { + /** Backend settings that point the receipt outbox at this sink. */ + readonly environment: Readonly>; + /** Acknowledged envelopes in delivery id order. */ + readonly evidence: () => ReadonlyArray; + } +>()("@monoweb/e2e/receipt-delivery-sink/ReceiptDeliverySink") {} -const decodeEnvelope = Schema.decodeUnknownSync(Schema.fromJsonString(ReceiptDeliveryEnvelope)); +class ReceiptDeliveryRejected extends Data.TaggedError("ReceiptDeliveryRejected")<{ + readonly message: string; +}> {} -/** - * Accepts bearer-authenticated JSON envelopes at `/receipts`. A replay of a delivery id - * must repeat its envelope exactly, else the sink answers 409. - */ -export const startReceiptDeliverySink = async (options: ReceiptDeliverySinkOptions) => { - const token = randomBytes(32).toString("hex"); - const deliveries = new Map(); - - const server = createServer(async (request, response) => { - try { - if ( - request.method !== "POST" || - request.url !== "/receipts" || - request.headers.authorization !== `Bearer ${token}` || - request.headers["content-type"] !== "application/json" - ) { - response.writeHead(401).end(); - - return; - } +class ReceiptDeliverySinkUnbound extends Data.TaggedError("ReceiptDeliverySinkUnbound")<{ + readonly message: string; +}> {} - const chunks: Array = []; - let byteLength = 0; +const maxEnvelopeBytes = 65_536; - for await (const chunk of request) { - const bytes = Buffer.from(chunk); - byteLength += bytes.byteLength; +const decodeEnvelope = Schema.decodeUnknownEffect(Schema.fromJsonString(ReceiptDeliveryEnvelope)); - if (byteLength > maxEnvelopeBytes) - throw new Error("Receipt delivery envelope is too large"); - chunks.push(bytes); - } +const envelopeText = Schema.encodeEffect(Schema.fromJsonString(ReceiptDeliveryEnvelope)); - const envelope = decodeEnvelope(Buffer.concat(chunks).toString("utf8"), { - onExcessProperty: "error", - }); +const recipientsText = Schema.encodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)), +); - if (request.headers["idempotency-key"] !== envelope.deliveryId) { - throw new Error("Receipt delivery idempotency key does not name the envelope"); - } +const tooLarge = new ReceiptDeliveryRejected({ message: "Receipt delivery envelope is too large" }); - const previous = deliveries.get(envelope.deliveryId); +// Stops reading at the first chunk past the limit, as the envelope can never be accepted. +const readBoundedBody = (stream: Stream.Stream) => + stream.pipe( + Stream.runFoldEffect( + () => ({ chunks: new Array(), byteLength: 0 }), + (body, chunk) => { + const byteLength = body.byteLength + chunk.byteLength; - if (previous !== undefined && JSON.stringify(previous) !== JSON.stringify(envelope)) { - response.writeHead(409).end(); + return byteLength > maxEnvelopeBytes + ? Effect.fail(tooLarge) + : Effect.succeed({ chunks: [...body.chunks, chunk], byteLength }); + }, + ), + Effect.map((body) => Buffer.concat(body.chunks).toString("utf8")), + ); - return; - } - - deliveries.set(envelope.deliveryId, envelope); - response.writeHead(204).end(); - } catch { - response.writeHead(400).end(); - } - }); - - const listening = Promise.withResolvers(); - server.once("error", listening.reject); - server.listen(0, "127.0.0.1", listening.resolve); - await listening.promise; - - const address = server.address(); - - const close = () => { - const closed = Promise.withResolvers(); - server.closeAllConnections(); - server.close((error) => { - if (error === undefined || ("code" in error && error.code === "ERR_SERVER_NOT_RUNNING")) { - closed.resolve(); - } else { - closed.reject(error); +/** + * Accepts bearer-authenticated JSON envelopes at `/receipts`. A replay of a delivery id + * must repeat its envelope exactly, else the sink answers 409. + */ +export const ReceiptDeliverySinkLive = ( + options: ReceiptDeliverySinkOptions, +): Layer.Layer => + Layer.effect( + ReceiptDeliverySink, + Effect.gen(function* () { + const token = randomBytes(32).toString("hex"); + const deliveries = new Map(); + + const accept = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + + if ( + request.method !== "POST" || + request.url !== "/receipts" || + request.headers["authorization"] !== `Bearer ${token}` || + request.headers["content-type"] !== "application/json" + ) { + return HttpServerResponse.empty({ status: 401 }); + } + + const text = yield* readBoundedBody(request.stream); + const envelope = yield* decodeEnvelope(text, { onExcessProperty: "error" }); + + if (request.headers["idempotency-key"] !== envelope.deliveryId) { + return yield* new ReceiptDeliveryRejected({ + message: "Receipt delivery idempotency key does not name the envelope", + }); + } + + const previous = deliveries.get(envelope.deliveryId); + + if ( + previous !== undefined && + (yield* envelopeText(previous)) !== (yield* envelopeText(envelope)) + ) { + return HttpServerResponse.empty({ status: 409 }); + } + + deliveries.set(envelope.deliveryId, envelope); + + return HttpServerResponse.empty({ status: 204 }); + }).pipe(Effect.orElseSucceed(() => HttpServerResponse.empty({ status: 400 }))); + + yield* HttpServer.serveEffect(accept); + + const { address } = yield* HttpServer.HttpServer; + + if (Predicate.isTagged(address, "UnixPathAddress")) { + return yield* new ReceiptDeliverySinkUnbound({ + message: "Receipt delivery sink did not bind a TCP port", + }); } - }); - return closed.promise; - }; - - if (address === null || Predicate.isString(address)) { - await close(); - throw new Error("Receipt delivery sink did not bind a TCP port"); - } - - return { - environment: { - RECEIPT_DELIVERY_URL: `http://127.0.0.1:${address.port}/receipts`, - RECEIPT_DELIVERY_TOKEN: token, - RECEIPT_DELIVERY_TIMEOUT_MS: "2000", - RECEIPT_DELIVERY_SENDER: options.sender, - RECEIPT_DELIVERY_ECONOMY_RECIPIENTS: JSON.stringify(options.economyRecipients), - }, - /** Acknowledged envelopes in delivery id order. */ - evidence: (): ReadonlyArray => - [...deliveries.values()].sort((left, right) => - left.deliveryId.localeCompare(right.deliveryId), - ), - close, - }; -}; + const economyRecipients = yield* recipientsText(options.economyRecipients).pipe(Effect.orDie); + + return ReceiptDeliverySink.of({ + environment: { + RECEIPT_DELIVERY_URL: `http://127.0.0.1:${address.port}/receipts`, + RECEIPT_DELIVERY_TOKEN: token, + RECEIPT_DELIVERY_TIMEOUT_MS: "2000", + RECEIPT_DELIVERY_SENDER: options.sender, + RECEIPT_DELIVERY_ECONOMY_RECIPIENTS: economyRecipients, + }, + evidence: () => + [...deliveries.values()].sort((left, right) => + left.deliveryId.localeCompare(right.deliveryId), + ), + }); + }), + ); + +/** Starts the sink on an ephemeral loopback port; `close` stops it and releases the port. */ +export const startReceiptDeliverySink = (options: ReceiptDeliverySinkOptions) => + Effect.runPromise( + Effect.gen(function* () { + const scope = yield* Scope.make(); + + const context = yield* Layer.buildWithScope( + ReceiptDeliverySinkLive(options).pipe( + Layer.provide(BunHttpServer.layer({ port: 0, hostname: "127.0.0.1" })), + ), + scope, + ).pipe(Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause)))); + + return { sink: Context.get(context, ReceiptDeliverySink), scope }; + }), + ).then(({ sink, scope }) => ({ + ...sink, + close: () => Effect.runPromise(Scope.close(scope, Exit.void)), + })); diff --git a/tools/e2e/record-native-recruitment-invitation-response.ts b/tools/e2e/record-native-recruitment-invitation-response.ts index 6e2282b2e..f79e68691 100644 --- a/tools/e2e/record-native-recruitment-invitation-response.ts +++ b/tools/e2e/record-native-recruitment-invitation-response.ts @@ -1,4 +1,4 @@ -import process from "node:process"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; import * as BunServices from "@effect/platform-bun/BunServices"; import { DatabaseLive } from "@vektorprogrammet/database/live"; import { AdmissionsLive } from "@vektorprogrammet/database/admissions"; @@ -10,23 +10,42 @@ import { } from "@vektorprogrammet/database/recruitment"; import { makeRecordingNotificationGateway } from "@vektorprogrammet/domain/notification"; import { RecruitmentInvitationResponseOutboxRequestSchema } from "@vektorprogrammet/domain/recruitment"; -import { Predicate, Effect, Layer, Redacted, Schema } from "effect"; - -const databaseUrl = process.env.BACKEND_PG_URL; - -if (databaseUrl === undefined || databaseUrl.length === 0) { - throw new Error("BACKEND_PG_URL is required for response recording evidence"); -} +import { Config, Console, Data, Effect, Layer, Predicate, Schema } from "effect"; + +class RecordingFailure extends Data.TaggedError("RecordingFailure")<{ + readonly message: string; +}> {} + +// The JSON text of the evidence, byte for byte what `JSON.stringify` writes, as `jsonText` of the +// backend's RPC problem module does. +const jsonText = (value: A): Effect.Effect => + Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(value).pipe(Effect.orDie); + +const fail = (message: string): Effect.Effect => + Effect.fail(new RecordingFailure({ message })); + +const databaseUrl = Config.schema(Schema.Redacted(Schema.NonEmptyString), "BACKEND_PG_URL").pipe( + Effect.mapError( + () => + new RecordingFailure({ + message: "BACKEND_PG_URL is required for response recording evidence", + }), + ), +); const deliveredAt = "2031-09-15T12:01:00.000Z"; const recording = makeRecordingNotificationGateway(deliveredAt); -const databaseLayer = DatabaseLive({ - url: Redacted.make(databaseUrl), - applicationName: "native-invitation-response-recording-evidence", - maxConnections: 1, -}).pipe(Layer.provide(BunServices.layer)); +const databaseLayer = Layer.unwrap( + Effect.map(databaseUrl, (url) => + DatabaseLive({ + url, + applicationName: "native-invitation-response-recording-evidence", + maxConnections: 1, + }), + ), +); const admissionsLayer = AdmissionsLive.pipe(Layer.provide(databaseLayer)); @@ -41,42 +60,51 @@ const authorityLayers = Layer.mergeAll( profileLayer, ); -let providerNetworkRequests = 0; +// The recording gateway must not reach the network: the trap counts and rejects every attempt +// while the deliveries run, and restores the platform fetch when the scope closes. +const networkTrap = Effect.acquireRelease( + Effect.sync(() => { + const originalFetch = globalThis.fetch; + const trap = { originalFetch, providerNetworkRequests: 0 }; -const originalFetch = globalThis.fetch; + globalThis.fetch = Object.assign( + (..._arguments: Parameters): ReturnType => { + trap.providerNetworkRequests += 1; -globalThis.fetch = Object.assign( - (..._arguments: Parameters): ReturnType => { - providerNetworkRequests += 1; + return Promise.reject( + new Error("The recording NotificationGateway attempted network access"), + ); + }, + { + preconnect: () => { + trap.providerNetworkRequests += 1; + throw new Error("The recording NotificationGateway attempted network access"); + }, + }, + ); - return Promise.reject(new Error("The recording NotificationGateway attempted network access")); - }, - { - preconnect: () => { - providerNetworkRequests += 1; - throw new Error("The recording NotificationGateway attempted network access"); - }, - }, + return trap; + }), + (trap) => + Effect.sync(() => { + globalThis.fetch = trap.originalFetch; + }), ); -try { - const results = []; - - for (let index = 0; index < 2; index += 1) { - const result = await Effect.runPromise( - Effect.scoped( - deliverNextRecruitmentInvitationResponse( - "native-invitation-response-recording-claim-" + String(index + 1), - "2031-09-15T12:00:0" + String(index + 1) + ".000Z", - ).pipe(Effect.provide(Layer.mergeAll(recording.layer, authorityLayers))), - ), +const deliverOnce = (index: number) => + Effect.gen(function* () { + const result = yield* Effect.scoped( + deliverNextRecruitmentInvitationResponse( + "native-invitation-response-recording-claim-" + String(index + 1), + "2031-09-15T12:00:0" + String(index + 1) + ".000Z", + ).pipe(Effect.provide(Layer.mergeAll(recording.layer, authorityLayers))), ); if (!Predicate.isTagged(result, "Delivered")) { - throw new Error("Expected a recorded invitation-response delivery"); + return yield* fail("Expected a recorded invitation-response delivery"); } - results.push({ + return { result: result._tag, claim: { effectId: result.claim.effectId, @@ -84,26 +112,35 @@ try { attempts: result.claim.attempts, }, notificationEvidence: result.evidence, - }); - } + }; + }); + +const program = Effect.gen(function* () { + const trap = yield* networkTrap; + + const results = yield* Effect.forEach([0, 1], deliverOnce); if (recording.responseRequests.length !== 2) { - throw new Error("Expected exactly two approved response requests"); + return yield* fail("Expected exactly two approved response requests"); } - if (providerNetworkRequests !== 0) { - throw new Error("The recording NotificationGateway performed network access"); + if (trap.providerNetworkRequests !== 0) { + return yield* fail("The recording NotificationGateway performed network access"); } - const responseRequests = recording.responseRequests.map((request) => - Schema.decodeSync(Schema.fromJsonString(RecruitmentInvitationResponseOutboxRequestSchema))( + const responseRequests = yield* Effect.forEach(recording.responseRequests, (request) => + Schema.decodeEffect(Schema.fromJsonString(RecruitmentInvitationResponseOutboxRequestSchema))( invitationResponsePayloadForEvidence(request), ), ); - process.stdout.write( - JSON.stringify({ results, responseRequests, providerNetworkRequests }) + "\n", - ); -} finally { - globalThis.fetch = originalFetch; -} + const evidenceText = yield* jsonText({ + results, + responseRequests, + providerNetworkRequests: trap.providerNetworkRequests, + }); + + yield* Console.log(evidenceText); +}); + +BunRuntime.runMain(Effect.scoped(program).pipe(Effect.provide(BunServices.layer))); diff --git a/tools/e2e/record-native-recruitment-invitation.ts b/tools/e2e/record-native-recruitment-invitation.ts index 5096305e5..d75732b04 100644 --- a/tools/e2e/record-native-recruitment-invitation.ts +++ b/tools/e2e/record-native-recruitment-invitation.ts @@ -1,7 +1,5 @@ +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; import * as BunServices from "@effect/platform-bun/BunServices"; -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname } from "node:path"; -import process from "node:process"; import { DatabaseLive } from "@vektorprogrammet/database/live"; import { AdmissionsLive } from "@vektorprogrammet/database/admissions"; import { OrganizationLive } from "@vektorprogrammet/database/organization"; @@ -12,17 +10,29 @@ import { } from "@vektorprogrammet/database/recruitment"; import { makeRecordingNotificationGateway } from "@vektorprogrammet/domain/notification"; import { RecruitmentInvitationOutboxRequestSchema } from "@vektorprogrammet/domain/recruitment"; -import { Predicate, Effect, Layer, Redacted, Schema } from "effect"; - -const requiredEnvironment = (name: string): string => { - const value = process.env[name]; - - if (value === undefined || value.length === 0) { - throw new Error(`${name} is required for the recording notification driver`); - } +import { Config, Console, Data, Effect, FileSystem, Layer, Path, Predicate, Schema } from "effect"; + +class RecordingFailure extends Data.TaggedError("RecordingFailure")<{ + readonly message: string; +}> {} + +// The JSON text of the evidence, byte for byte what `JSON.stringify` writes, as `jsonText` of the +// backend's RPC problem module does. +const jsonText = (value: A): Effect.Effect => + Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(value).pipe(Effect.orDie); + +const requiredEnvironment = (schema: Schema.ConstraintCodec, name: string) => + Config.schema(schema, name).pipe( + Effect.mapError( + () => + new RecordingFailure({ + message: `${name} is required for the recording notification driver`, + }), + ), + ); - return value; -}; +const fail = (message: string): Effect.Effect => + Effect.fail(new RecordingFailure({ message })); const claimedAt = "2031-09-20T13:31:00.000Z"; @@ -30,11 +40,15 @@ const deliveredAt = "2031-09-20T13:31:01.000Z"; const recording = makeRecordingNotificationGateway(deliveredAt); -const databaseLayer = DatabaseLive({ - url: Redacted.make(requiredEnvironment("BACKEND_PG_URL")), - applicationName: "native-scheduling-recording-evidence", - maxConnections: 1, -}).pipe(Layer.provide(BunServices.layer)); +const databaseLayer = Layer.unwrap( + Effect.map(requiredEnvironment(Schema.Redacted(Schema.NonEmptyString), "BACKEND_PG_URL"), (url) => + DatabaseLive({ + url, + applicationName: "native-scheduling-recording-evidence", + maxConnections: 1, + }), + ), +); const admissionsLayer = AdmissionsLive.pipe(Layer.provide(databaseLayer)); @@ -49,53 +63,65 @@ const authorityLayers = Layer.mergeAll( profileLayer, ); -let providerNetworkRequests = 0; - -const originalFetch = globalThis.fetch; - -globalThis.fetch = Object.assign( - (..._arguments: Parameters): ReturnType => { - providerNetworkRequests += 1; +// The recording gateway must not reach the network: the trap counts and rejects every attempt +// while the delivery runs, and restores the platform fetch when the scope closes. +const networkTrap = Effect.acquireRelease( + Effect.sync(() => { + const originalFetch = globalThis.fetch; + const trap = { originalFetch, providerNetworkRequests: 0 }; + + globalThis.fetch = Object.assign( + (..._arguments: Parameters): ReturnType => { + trap.providerNetworkRequests += 1; + + return Promise.reject( + new Error("The recording NotificationGateway attempted network access"), + ); + }, + { + preconnect: () => { + trap.providerNetworkRequests += 1; + throw new Error("The recording NotificationGateway attempted network access"); + }, + }, + ); - return Promise.reject(new Error("The recording NotificationGateway attempted network access")); - }, - { - preconnect: () => { - providerNetworkRequests += 1; - throw new Error("The recording NotificationGateway attempted network access"); - }, - }, + return trap; + }), + (trap) => + Effect.sync(() => { + globalThis.fetch = trap.originalFetch; + }), ); -try { - const result = await Effect.runPromise( - Effect.scoped( - deliverNextRecruitmentInvitation("native-scheduling-recording-claim", claimedAt).pipe( - Effect.provide(Layer.mergeAll(recording.layer, authorityLayers)), - ), - ), - ); +const program = Effect.gen(function* () { + const trap = yield* networkTrap; + + const result = yield* deliverNextRecruitmentInvitation( + "native-scheduling-recording-claim", + claimedAt, + ).pipe(Effect.provide(Layer.mergeAll(recording.layer, authorityLayers))); if (!Predicate.isTagged(result, "Delivered")) { - throw new Error(`Expected one recorded invitation delivery, received ${result._tag}`); + return yield* fail(`Expected one recorded invitation delivery, received ${result._tag}`); } if (recording.requests.length !== 1) { - throw new Error( + return yield* fail( `Expected one canonical notification request, received ${recording.requests.length}`, ); } - if (providerNetworkRequests !== 0) { - throw new Error("The recording NotificationGateway performed a network request"); + if (trap.providerNetworkRequests !== 0) { + return yield* fail("The recording NotificationGateway performed a network request"); } if (result.evidence.effectId !== recording.requests[0]?.effectId) { - throw new Error("Recording evidence does not identify the claimed notification request"); + return yield* fail("Recording evidence does not identify the claimed notification request"); } - const requests = recording.requests.map((request) => - Schema.decodeSync(Schema.fromJsonString(RecruitmentInvitationOutboxRequestSchema))( + const requests = yield* Effect.forEach(recording.requests, (request) => + Schema.decodeEffect(Schema.fromJsonString(RecruitmentInvitationOutboxRequestSchema))( invitationPayloadForEvidence(request), ), ); @@ -109,14 +135,22 @@ try { }, notificationEvidence: result.evidence, requests, - providerNetworkRequests, + providerNetworkRequests: trap.providerNetworkRequests, responseCapabilityRedacted: true, }; - const evidencePath = requiredEnvironment("SCHEDULING_RECORDING_EVIDENCE_PATH"); - await mkdir(dirname(evidencePath), { recursive: true }); - await writeFile(evidencePath, `${JSON.stringify(evidence)}\n`, "utf8"); - process.stdout.write(`${JSON.stringify(evidence)}\n`); -} finally { - globalThis.fetch = originalFetch; -} + const evidencePath = yield* requiredEnvironment( + Schema.NonEmptyString, + "SCHEDULING_RECORDING_EVIDENCE_PATH", + ); + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const evidenceText = yield* jsonText(evidence); + + yield* fileSystem.makeDirectory(path.dirname(evidencePath), { recursive: true }); + yield* fileSystem.writeFileString(evidencePath, `${evidenceText}\n`); + yield* Console.log(evidenceText); +}); + +BunRuntime.runMain(Effect.scoped(program).pipe(Effect.provide(BunServices.layer))); diff --git a/tools/e2e/run-real-native-recruitment-assignment.mjs b/tools/e2e/run-real-native-recruitment-assignment.mjs deleted file mode 100644 index e127929ce..000000000 --- a/tools/e2e/run-real-native-recruitment-assignment.mjs +++ /dev/null @@ -1,1066 +0,0 @@ -import { randomBytes } from "node:crypto"; -import { spawn } from "node:child_process"; -import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { createServer } from "node:http"; -import { createConnection } from "node:net"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { postgresProgram, reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; -import { - databaseMigrationDefinitions, - databaseSchemaRevision, -} from "@vektorprogrammet/database/migrations"; -import { Predicate } from "effect"; - -const repositoryRoot = fileURLToPath(new URL("../../", import.meta.url)); - -const dashboardRoot = fileURLToPath(new URL("../../apps/dashboard/", import.meta.url)); - -const sdkRoot = fileURLToPath(new URL("../../packages/sdk/", import.meta.url)); - -const recruitmentAssignmentMigration = { - id: 10, - name: "native-recruitment-applicant-assignment", -}; - -const parseMigrationDefinition = (definition, index) => { - if ( - definition === null || - !(definition === null || Predicate.isObjectOrArray(definition)) || - !Predicate.isString(definition.id) || - !Predicate.isString(definition.name) - ) { - throw new Error(`canonical migration registry entry ${index} is malformed`); - } - - const match = /^([1-9]\d*)_(.+)$/.exec(definition.id); - - if (match === null) { - throw new Error( - `canonical migration registry entry ${index} has malformed id ${JSON.stringify(definition.id)}`, - ); - } - - const numericId = Number(match[1]); - - if (!Number.isSafeInteger(numericId)) { - throw new Error( - `canonical migration registry entry ${index} has unsafe numeric id ${JSON.stringify(definition.id)}`, - ); - } - - if (match[2] !== definition.name) { - throw new Error( - `canonical migration registry entry ${index} id/name disagree: ${JSON.stringify({ - id: definition.id, - name: definition.name, - })}`, - ); - } - - return { id: numericId, name: definition.name, revision: definition.id }; -}; - -const deriveCanonicalMigrationExpectation = (definitions, schemaRevision) => { - if (!Array.isArray(definitions) || definitions.length === 0) { - throw new Error("canonical migration registry must not be empty"); - } - - let minimumId = Number.POSITIVE_INFINITY; - let maximumId = Number.NEGATIVE_INFINITY; - let previousId; - let contiguous = true; - let head; - const seenIds = new Set(); - - for (const [index, definition] of definitions.entries()) { - const parsed = parseMigrationDefinition(definition, index); - - if (seenIds.has(parsed.id)) { - throw new Error(`canonical migration registry duplicates numeric id ${parsed.id}`); - } - - seenIds.add(parsed.id); - minimumId = Math.min(minimumId, parsed.id); - maximumId = Math.max(maximumId, parsed.id); - contiguous &&= previousId === undefined || parsed.id === previousId + 1; - previousId = parsed.id; - head = parsed; - } - - if (head.id !== maximumId) { - throw new Error( - `canonical migration registry head ${head.revision} does not have maximum numeric id ${maximumId}`, - ); - } - - if (schemaRevision !== head.revision.replaceAll("-", "_")) { - throw new Error( - `canonical migration registry/revision disagreement: ${JSON.stringify({ - registryHead: head.revision, - databaseSchemaRevision: schemaRevision, - })}`, - ); - } - - return { - count: definitions.length, - minimumId, - maximumId, - contiguous, - head: { id: head.id, name: head.name }, - }; -}; - -const canonicalMigrationExpectation = deriveCanonicalMigrationExpectation( - databaseMigrationDefinitions, - databaseSchemaRevision, -); - -const expectedMigrationEvidence = { - ...canonicalMigrationExpectation, - recruitmentAssignment: recruitmentAssignmentMigration, -}; - -const postgresPort = configuredLoopbackPort("RECRUITMENT_E2E_POSTGRES_PORT", 55446); - -const backendPort = configuredLoopbackPort("RECRUITMENT_E2E_BACKEND_PORT", 8800); - -function configuredLoopbackPort(name, fallback) { - const value = process.env[name] ?? String(fallback); - - if (!/^\d+$/.test(value)) throw new Error(`${name} must be an integer`); - - const port = Number(value); - - if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { - throw new Error(`${name} must be between 1 and 65535`); - } - - return port; -} - -const dashboardPort = configuredLoopbackPort("RECRUITMENT_E2E_DASHBOARD_PORT", 5174); - -const postgresUrl = `postgres://postgres@127.0.0.1:${postgresPort}/postgres`; - -const backendOrigin = `http://127.0.0.1:${backendPort}`; - -const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; - -const fixedClock = "2026-09-15T12:00:00.000Z"; - -const applicationId = "application-native-journey-0049"; - -const leaderPersonId = "journey-rec-leader-0049"; - -const interviewerPersonId = "journey-rec-interviewer-a-0049"; - -const interviewSchemaId = "interview-schema-native-journey-0049"; - -const betterAuthSecret = randomBytes(32).toString("base64url"); - -const commandTimeoutMs = 300_000; - -const shutdownTimeoutMs = 5_000; - -const seedPath = join(dashboardRoot, "e2e/native-recruitment-journey-seed.mjs"); - -const sleep = (milliseconds) => - new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds)); - -const errorDetail = (error) => - error instanceof AggregateError - ? `${error.message}: ${error.errors.map(errorDetail).join("; ")}` - : error instanceof Error - ? error.message - : String(error); - -const assertEqual = (actual, expected, label) => { - if (JSON.stringify(actual) !== JSON.stringify(expected)) { - throw new Error( - `${label} mismatch\nexpected: ${JSON.stringify(expected)}\nactual: ${JSON.stringify(actual)}`, - ); - } -}; - -const assertPortAvailable = (port) => - new Promise((resolveAvailable, rejectAvailable) => { - const socket = createConnection({ host: "127.0.0.1", port }); - socket.once("connect", () => { - socket.destroy(); - rejectAvailable(new Error(`loopback port ${port} is already in use`)); - }); - socket.once("error", (error) => { - socket.destroy(); - - if (error?.code === "ECONNREFUSED") resolveAvailable(); - else - rejectAvailable( - new Error(`could not inspect loopback port ${port}: ${errorDetail(error)}`), - ); - }); - }); - -const waitForPortRelease = async (port) => { - const deadline = Date.now() + shutdownTimeoutMs; - - while (Date.now() < deadline) { - try { - await assertPortAvailable(port); - - return; - } catch { - await sleep(100); - } - } - - throw new Error(`loopback port ${port} was not released`); -}; - -const run = (command, args, options) => - new Promise((resolveRun, rejectRun) => { - const child = spawn(command, args, { - cwd: options.cwd, - env: options.env, - stdio: options.capture ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"], - detached: true, - }); - - const stdout = []; - const stderr = []; - - if (options.capture) { - child.stdout.on("data", (chunk) => stdout.push(chunk)); - child.stderr.on("data", (chunk) => stderr.push(chunk)); - } - - let settled = false; - - const timer = setTimeout(() => { - if (child.pid !== undefined) process.kill(-child.pid, "SIGTERM"); - - if (!settled) { - settled = true; - rejectRun(new Error(`${options.label} timed out`)); - } - }, commandTimeoutMs); - - timer.unref(); - child.once("error", (error) => { - if (settled) return; - settled = true; - clearTimeout(timer); - rejectRun(new Error(`${options.label} could not start: ${errorDetail(error)}`)); - }); - child.once("close", (code, signal) => { - if (settled) return; - settled = true; - clearTimeout(timer); - - const output = { - stdout: Buffer.concat(stdout).toString("utf8"), - stderr: Buffer.concat(stderr).toString("utf8"), - }; - - if (code === 0) resolveRun(options.capture ? output : undefined); - else { - const detail = [output.stdout.trim(), output.stderr.trim()].filter(Boolean).join("\n"); - rejectRun( - new Error( - `${options.label} exited with ${signal ?? `code ${code}`}${detail ? `:\n${detail}` : ""}`, - ), - ); - } - }); - }); - -const start = (command, args, environment, cwd) => { - const child = spawn(command, args, { - cwd, - env: environment, - stdio: ["ignore", "inherit", "inherit"], - detached: true, - }); - - child.once("error", () => undefined); - - return child; -}; - -const stop = async (child) => { - if ( - child === undefined || - child.exitCode !== null || - child.signalCode !== null || - child.pid === undefined - ) { - return; - } - - process.kill(-child.pid, "SIGTERM"); - await Promise.race([ - new Promise((resolveExit) => child.once("exit", resolveExit)), - sleep(shutdownTimeoutMs), - ]); - - if (child.exitCode === null && child.signalCode === null) process.kill(-child.pid, "SIGKILL"); -}; - -const waitForHttp = async (url, child, label) => { - const deadline = Date.now() + commandTimeoutMs; - - while (Date.now() < deadline) { - if (child.exitCode !== null || child.signalCode !== null) { - throw new Error(`${label} exited before readiness`); - } - - try { - const response = await fetch(url, { redirect: "manual" }); - - if (response.status < 500) return; - } catch { - // Retry until the bounded deadline. - } - - await sleep(250); - } - - throw new Error(`${label} did not become ready`); -}; - -const runPsql = async (sql, environment, label) => { - const output = await run( - postgresProgram("psql"), - [ - "-h", - "127.0.0.1", - "-p", - String(postgresPort), - "-U", - "postgres", - "-d", - "postgres", - "-At", - "-v", - "ON_ERROR_STOP=1", - "-c", - sql, - ], - { cwd: repositoryRoot, env: environment, capture: true, label }, - ); - - return output.stdout.trim(); -}; - -const parseJsonBody = (bytes) => { - if (bytes.byteLength === 0) return undefined; - - try { - return JSON.parse(bytes.toString("utf8")); - } catch { - return undefined; - } -}; - -const sessionCookieNames = new Set([ - "better-auth.session_token", - "__Secure-better-auth.session_token", -]); - -const hasNamedCookie = (cookieHeader, names) => - Predicate.isString(cookieHeader) && - cookieHeader.split(";").some((pair) => { - const separator = pair.indexOf("="); - - return separator > 0 && names.has(pair.slice(0, separator).trim()); - }); - -const startRecordingProxy = async (targetOrigin) => { - const records = []; - - const server = createServer(async (request, response) => { - const method = request.method ?? "GET"; - const requestUrl = new URL(request.url ?? "/", targetOrigin); - const path = requestUrl.pathname; - const pathAndQuery = `${path}${requestUrl.search}`; - const chunks = []; - - for await (const chunk of request) chunks.push(Buffer.from(chunk)); - const requestBytes = Buffer.concat(chunks); - const requestJson = parseJsonBody(requestBytes); - - const record = { - method, - path, - pathAndQuery, - status: 0, - sessionCookieAuth: hasNamedCookie(request.headers.cookie, sessionCookieNames), - jwtCookieAuth: hasNamedCookie(request.headers.cookie, new Set(["jwt_token"])), - authorizationHeaderPresent: request.headers.authorization !== undefined, - requestJson, - idempotencyKey: Predicate.isString(request.headers["idempotency-key"]) - ? request.headers["idempotency-key"] - : null, - ifMatch: Predicate.isString(request.headers["if-match"]) ? request.headers["if-match"] : null, - responseJson: null, - responseEtag: null, - }; - - records.push(record); - - try { - const headers = new Headers(); - - for (const [name, value] of Object.entries(request.headers)) { - if ( - value === undefined || - ["connection", "content-length", "host", "transfer-encoding"].includes(name) - ) { - continue; - } - - if (Array.isArray(value)) { - for (const item of value) headers.append(name, item); - } else { - headers.set(name, value); - } - } - - const upstream = await fetch(requestUrl, { - method, - headers, - body: method === "GET" || method === "HEAD" ? undefined : requestBytes, - redirect: "manual", - }); - - const responseBytes = Buffer.from(await upstream.arrayBuffer()); - record.status = upstream.status; - - if (upstream.status >= 400) record.responseFailure = parseJsonBody(responseBytes); - record.responseJson = parseJsonBody(responseBytes) ?? null; - record.responseEtag = upstream.headers.get("etag"); - response.statusCode = upstream.status; - - for (const [name, value] of upstream.headers.entries()) { - if ( - ["content-encoding", "content-length", "set-cookie", "transfer-encoding"].includes(name) - ) { - continue; - } - - response.setHeader(name, value); - } - - const setCookie = upstream.headers.getSetCookie(); - - if (setCookie.length > 0) response.setHeader("set-cookie", setCookie); - response.setHeader("content-length", String(responseBytes.byteLength)); - response.end(responseBytes); - } catch (error) { - record.status = 502; - response.statusCode = 502; - response.setHeader("content-type", "application/json"); - response.end(JSON.stringify({ error: "native recruitment evidence proxy failed" })); - process.stderr.write(`Native recruitment proxy failure: ${errorDetail(error)}\n`); - } - }); - - const [port] = await reserveLoopbackPorts(1); - - await new Promise((resolveListen, rejectListen) => { - server.once("error", rejectListen); - server.listen(port, "127.0.0.1", () => { - server.removeListener("error", rejectListen); - resolveListen(); - }); - }); - - let closed = false; - - return { - origin: `http://127.0.0.1:${port}`, - records, - close: async () => { - if (closed) return; - closed = true; - server.closeAllConnections?.(); - await new Promise((resolveClose, rejectClose) => { - server.close((error) => - error === undefined || error?.code === "ERR_SERVER_NOT_RUNNING" - ? resolveClose() - : rejectClose(error), - ); - }); - }, - }; -}; - -const pathExists = async (path) => { - try { - await stat(path); - - return true; - } catch (error) { - if (error?.code === "ENOENT") return false; - throw error; - } -}; - -const readJsonFile = async (path, label) => { - try { - return JSON.parse(await readFile(path, "utf8")); - } catch (error) { - throw new Error(`${label} was not valid JSON: ${errorDetail(error)}`); - } -}; - -const readMigrationEvidence = async (environment) => - JSON.parse( - await runPsql( - `SELECT json_build_object( - 'count', (SELECT count(*)::int FROM vektorprogrammet_schema_migrations), - 'minimumId', (SELECT min(migration_id)::int FROM vektorprogrammet_schema_migrations), - 'maximumId', (SELECT max(migration_id)::int FROM vektorprogrammet_schema_migrations), - 'contiguous', NOT EXISTS ( - SELECT 1 FROM generate_series( - (SELECT min(migration_id) FROM vektorprogrammet_schema_migrations), - (SELECT max(migration_id) FROM vektorprogrammet_schema_migrations) - ) AS expected(id) - LEFT JOIN vektorprogrammet_schema_migrations actual ON actual.migration_id = expected.id - WHERE actual.migration_id IS NULL - ), - 'recruitmentAssignment', (SELECT json_build_object('id', migration_id, 'name', name) FROM vektorprogrammet_schema_migrations WHERE migration_id = ${recruitmentAssignmentMigration.id}), - 'head', (SELECT json_build_object('id', migration_id, 'name', name) FROM vektorprogrammet_schema_migrations ORDER BY migration_id DESC LIMIT 1) - )`, - environment, - "canonical migration evidence", - ), - ); - -const readPersistenceEvidence = async (environment) => - JSON.parse( - await runPsql( - `SELECT json_build_object( - 'interviewCount', (SELECT count(*)::int FROM recruitment_interviews WHERE application_id = '${applicationId}'), - 'scheduleCount', (SELECT count(*)::int FROM recruitment_interview_schedules schedule INNER JOIN recruitment_interviews interview ON interview.interview_id = schedule.interview_id WHERE interview.application_id = '${applicationId}'), - 'receiptCount', (SELECT count(*)::int FROM recruitment_assignment_command_receipts WHERE application_id = '${applicationId}'), - 'auditCount', (SELECT count(*)::int FROM recruitment_assignment_audit WHERE application_id = '${applicationId}'), - 'interview', (SELECT row_to_json(value) FROM ( - SELECT interview_id AS "interviewId", application_id AS "applicationId", department_id AS "departmentId", - interviewer_person_id AS "interviewerPersonId", interview_schema_id AS "interviewSchemaId", - assigned_by_person_id AS "assignedByPersonId", to_char(assigned_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "assignedAt", - revision - FROM recruitment_interviews WHERE application_id = '${applicationId}' - ) value), - 'receipt', (SELECT row_to_json(value) FROM ( - SELECT command_id AS "commandId", application_id AS "applicationId", interview_id AS "interviewId", - command_json->>'applicationId' AS "commandApplicationId", - command_json->>'interviewerPersonId' AS "commandInterviewerPersonId", - command_json->>'interviewSchemaId' AS "commandInterviewSchemaId", - to_char(committed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "committedAt" - FROM recruitment_assignment_command_receipts WHERE application_id = '${applicationId}' - ) value), - 'audit', (SELECT row_to_json(value) FROM ( - SELECT command_id AS "commandId", interview_id AS "interviewId", application_id AS "applicationId", - department_id AS "departmentId", actor_person_id AS "actorPersonId", action, - interview_revision AS "interviewRevision", - to_char(occurred_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "occurredAt" - FROM recruitment_assignment_audit WHERE application_id = '${applicationId}' - ) value) - )`, - environment, - "native recruitment persistence evidence", - ), - ); - -const assertMigrationEvidence = (evidence) => { - if ( - evidence.count !== expectedMigrationEvidence.count || - evidence.minimumId !== expectedMigrationEvidence.minimumId || - evidence.maximumId !== expectedMigrationEvidence.maximumId || - evidence.contiguous !== expectedMigrationEvidence.contiguous || - evidence.recruitmentAssignment?.id !== expectedMigrationEvidence.recruitmentAssignment.id || - evidence.recruitmentAssignment?.name !== expectedMigrationEvidence.recruitmentAssignment.name || - evidence.head?.id !== expectedMigrationEvidence.head.id || - evidence.head?.name !== expectedMigrationEvidence.head.name - ) { - throw new Error( - `canonical migration evidence failed: ${JSON.stringify({ - expected: expectedMigrationEvidence, - actual: evidence, - })}`, - ); - } -}; - -const assertPersistenceEvidence = (evidence) => { - const { interview, receipt, audit } = evidence; - - if ( - evidence.interviewCount !== 1 || - evidence.scheduleCount !== 0 || - evidence.receiptCount !== 1 || - evidence.auditCount !== 1 || - interview?.applicationId !== applicationId || - interview?.interviewerPersonId !== interviewerPersonId || - interview?.interviewSchemaId !== interviewSchemaId || - interview?.assignedByPersonId !== leaderPersonId || - interview?.assignedAt !== fixedClock || - interview?.revision !== 0 || - receipt?.applicationId !== applicationId || - receipt?.interviewId !== interview?.interviewId || - receipt?.commandApplicationId !== applicationId || - receipt?.commandInterviewerPersonId !== interviewerPersonId || - receipt?.commandInterviewSchemaId !== interviewSchemaId || - receipt?.committedAt !== fixedClock || - audit?.commandId !== receipt?.commandId || - audit?.interviewId !== interview?.interviewId || - audit?.applicationId !== applicationId || - audit?.departmentId !== interview?.departmentId || - audit?.actorPersonId !== leaderPersonId || - audit?.actorPersonId !== interview?.assignedByPersonId || - audit?.action !== "ApplicantAssigned" || - audit?.interviewRevision !== 0 || - audit?.occurredAt !== fixedClock - ) { - throw new Error(`native recruitment persistence evidence failed: ${JSON.stringify(evidence)}`); - } -}; - -const main = async () => { - await Promise.all([ - assertPortAvailable(postgresPort), - assertPortAvailable(backendPort), - assertPortAvailable(dashboardPort), - ]); - const temporaryRoot = await mkdtemp(join(tmpdir(), "mono-web-native-recruitment-0049-1-")); - const postgresRoot = join(temporaryRoot, "postgres"); - const browserEvidencePath = join(temporaryRoot, "browser-evidence.json"); - const receiptStagingRoot = join(temporaryRoot, "receipt-staging"); - const receiptCommittedRoot = join(temporaryRoot, "receipt-committed"); - const baseEnvironment = { ...process.env }; - - for (const name of [ - "API_MODE", - "VITE_API_MODE", - "ADMISSION_AUTH_TOKENS", - "ORGANIZATION_AUTH_TOKENS", - "RECEIPT_AUTH_TOKENS", - "JWT_SECRET", - "SYMFONY_API_URL", - ]) { - delete baseEnvironment[name]; - } - - const processEnvironment = { - ...baseEnvironment, - BACKEND_HOST: "127.0.0.1", - BACKEND_PORT: String(backendPort), - BACKEND_PG_URL: postgresUrl, - BETTER_AUTH_SECRET: betterAuthSecret, - NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify([dashboardOrigin]), - OAUTH_CANONICAL_ORIGIN: backendOrigin, - OAUTH_DASHBOARD_ORIGIN: dashboardOrigin, - OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - ADMISSION_FIXED_NOW: fixedClock, - PUBLIC_APPLICATION_EFFECT_MODE: "disabled", - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_MODE: "disabled", - RECEIPT_STAGING_ROOT: receiptStagingRoot, - RECEIPT_COMMITTED_ROOT: receiptCommittedRoot, - RECEIPT_MAX_FILE_BYTES: "10485760", - RECEIPT_E2E_TEST_MODE: "1", - }; - - let postgres; - let backend; - let dashboard; - let proxy; - let evidence; - let cleaned = false; - - const cleanup = async () => { - if (cleaned) return; - cleaned = true; - const errors = []; - - try { - await stop(dashboard); - } catch (error) { - errors.push(error); - } - - try { - await proxy?.close(); - } catch (error) { - errors.push(error); - } - - try { - await stop(backend); - } catch (error) { - errors.push(error); - } - - try { - await postgres?.stop(); - } catch (error) { - errors.push(error); - } - - try { - await rm(temporaryRoot, { recursive: true, force: true }); - } catch (error) { - errors.push(error); - } - - if (errors.length > 0) throw new AggregateError(errors, "native recruitment cleanup failed"); - }; - - const handleSignal = (signal) => { - void cleanup().finally(() => process.exit(signal === "SIGINT" ? 130 : 143)); - }; - - const handleInterrupt = () => handleSignal("SIGINT"); - const handleTermination = () => handleSignal("SIGTERM"); - process.once("SIGINT", handleInterrupt); - process.once("SIGTERM", handleTermination); - - let primaryError; - - try { - postgres = await startDisposablePostgres({ - port: postgresPort, - directory: postgresRoot, - environment: baseEnvironment, - }); - - await run("bun", [seedPath], { - cwd: repositoryRoot, - env: { - ...processEnvironment, - JOURNEY_SEED_PG_URL: postgresUrl, - }, - label: "existing native recruitment fixture and Identity seed", - }); - const migrations = await readMigrationEvidence(baseEnvironment); - assertMigrationEvidence(migrations); - - backend = start( - "bun", - ["run", "--cwd", "apps/backend", "start"], - processEnvironment, - repositoryRoot, - ); - await waitForHttp(`${backendOrigin}/health`, backend, "unified native backend"); - proxy = await startRecordingProxy(backendOrigin); - - const journeyEnvironment = { - ...processEnvironment, - API_URL: proxy.origin, - VITE_API_URL: proxy.origin, - DASHBOARD_ORIGIN: dashboardOrigin, - REAL_NATIVE_IDENTITY_E2E: "1", - REAL_NATIVE_CONDUCT_E2E: "1", - RECRUITMENT_E2E_BROWSER_EVIDENCE_PATH: browserEvidencePath, - RECRUITMENT_E2E_LEADER_PERSON_ID: leaderPersonId, - }; - - await run("bun", ["run", "build"], { - cwd: sdkRoot, - env: journeyEnvironment, - label: "native recruitment SDK build", - }); - - // The dev server optimizes newly discovered dependencies and reloads the page, which - // aborts in-flight module imports under the browser. The journey serves the build. - const dashboardEnvironment = { - ...journeyEnvironment, - HOST: "127.0.0.1", - PORT: String(dashboardPort), - NODE_ENV: "production", - }; - - await run("bun", ["run", "build"], { - cwd: dashboardRoot, - env: dashboardEnvironment, - label: "native recruitment dashboard production build", - }); - dashboard = start("bun", ["server.mjs"], dashboardEnvironment, dashboardRoot); - await waitForHttp(`${dashboardOrigin}/login`, dashboard, "native dashboard"); - - const playwrightArgs = [ - "./node_modules/@playwright/test/cli.js", - "test", - "e2e/native-recruitment-session-journey.spec.ts", - "--project=chromium", - "--workers=1", - "--retries=0", - ]; - - await run(process.env.PLAYWRIGHT_NODE_EXECUTABLE ?? "node", playwrightArgs, { - cwd: dashboardRoot, - env: journeyEnvironment, - capture: true, - label: "real native recruitment Chromium journey", - }); - - const browser = await readJsonFile(browserEvidencePath, "native recruitment browser evidence"); - - const expectedBridge = [ - { operation: "readAssignmentBoard", status: 200, authorizationHeaderPresent: false }, - { operation: "createApplicationInterview", status: 200, authorizationHeaderPresent: false }, - { operation: "readAssignmentBoard", status: 200, authorizationHeaderPresent: false }, - { operation: "readAssignmentBoard", status: 200, authorizationHeaderPresent: false }, - ]; - - assertEqual(browser.bridgeResponses, expectedBridge, "browser bridge sequence"); - - if ( - browser.renderedNativeLogin !== true || - browser.sessionCookieName !== "better-auth.session_token" || - browser.sessionPersonId !== leaderPersonId || - browser.rawAuthenticationLeak !== false || - browser.accessibilityViolations !== 0 || - !Array.isArray(browser.pageErrors) || - browser.pageErrors.length !== 0 || - !Array.isArray(browser.legacyBrowserRequests) || - browser.legacyBrowserRequests.length !== 0 || - !Array.isArray(browser.externalBrowserRequests) || - browser.externalBrowserRequests.length !== 0 - ) { - throw new Error(`browser evidence failed: ${JSON.stringify(browser)}`); - } - - const boardPath = "/api/recruitment/application-assignments"; - const createPath = `/api/recruitment/applications/${encodeURIComponent(applicationId)}/interviews`; - - const recruitmentRequests = proxy.records.filter( - ({ path }) => path === boardPath || path === createPath, - ); - - const requiredTransportTail = [ - ["GET", `${boardPath}?status=new`, 200], - ["POST", createPath, 201], - ["GET", `${boardPath}?status=new`, 200], - ["GET", `${boardPath}?status=all`, 200], - ]; - - const leadingInitialAllFilterReadCount = - recruitmentRequests.length - requiredTransportTail.length; - - if (leadingInitialAllFilterReadCount !== 1 && leadingInitialAllFilterReadCount !== 2) { - throw new Error("native recruitment transport had an unexpected request count"); - } - - const duplicateInitialAllFilterReadObserved = leadingInitialAllFilterReadCount === 2; - - const expectedTransport = [ - ...Array.from({ length: leadingInitialAllFilterReadCount }, () => [ - "GET", - `${boardPath}?status=all`, - 200, - ]), - ...requiredTransportTail, - ].map(([method, pathAndQuery, status]) => ({ - method, - pathAndQuery, - status, - sessionCookieAuth: true, - authorizationHeaderPresent: false, - jwtCookieAuth: false, - idempotencyKeyPresent: method === "POST", - ifMatchPresent: false, - requestBodyKeys: method === "POST" ? ["interviewSchemaId", "interviewerPersonId"] : [], - })); - - assertEqual( - recruitmentRequests.map( - ({ - method, - pathAndQuery, - status, - sessionCookieAuth, - authorizationHeaderPresent, - jwtCookieAuth, - idempotencyKey, - ifMatch, - requestJson, - }) => ({ - method, - pathAndQuery, - status, - sessionCookieAuth, - authorizationHeaderPresent, - jwtCookieAuth, - idempotencyKeyPresent: Predicate.isString(idempotencyKey), - ifMatchPresent: Predicate.isString(ifMatch), - requestBodyKeys: - requestJson !== null && (requestJson === null || Predicate.isObjectOrArray(requestJson)) - ? Object.keys(requestJson).sort() - : [], - }), - ), - expectedTransport, - "exact native recruitment transport", - ); - const createRequest = recruitmentRequests.find(({ method }) => method === "POST"); - - if ( - JSON.stringify(Object.keys(createRequest?.responseJson ?? {}).sort()) !== - JSON.stringify([ - "applicationId", - "assignedAt", - "assignedByPersonId", - "coInterviewerPersonId", - "departmentId", - "interviewId", - "interviewSchemaId", - "interviewerPersonId", - "revision", - ]) || - createRequest?.requestJson?.interviewerPersonId !== interviewerPersonId || - createRequest.requestJson.interviewSchemaId !== interviewSchemaId || - !/^"vkr2\.[A-Za-z0-9_-]{43}"$/u.test(createRequest.responseEtag ?? "") || - createRequest.responseJson?.applicationId !== applicationId || - createRequest.responseJson?.coInterviewerPersonId !== null || - createRequest.responseJson?.interviewerPersonId !== interviewerPersonId || - createRequest.responseJson?.interviewSchemaId !== interviewSchemaId || - createRequest.responseJson?.assignedByPersonId !== leaderPersonId || - createRequest.responseJson?.revision !== 0 - ) { - throw new Error("Application interview creation did not use the generated v0.2 contract"); - } - - const browserEvidence = { - ...browser, - duplicateInitialAllFilterReadObserved, - }; - - await writeFile(browserEvidencePath, `${JSON.stringify(browserEvidence)}\n`, "utf8"); - - const legacyPaths = proxy.records.filter(({ path }) => - [ - "/api/admin/applications", - "/api/admin/users", - "/api/admin/interviews/schemas", - "/api/admin/interviews/assign", - "/api/admin/recruitment/assignment-board", - "/api/admin/recruitment/interviews/assign", - ].some((legacyPath) => path === legacyPath || path.startsWith(`${legacyPath}/`)), - ); - - if ( - proxy.records.some( - ({ authorizationHeaderPresent, jwtCookieAuth }) => - authorizationHeaderPresent || jwtCookieAuth, - ) || - legacyPaths.length > 0 - ) { - throw new Error( - `legacy or bearer authentication transport observed: ${JSON.stringify({ legacyPaths })}`, - ); - } - - const persisted = await readPersistenceEvidence(baseEnvironment); - assertPersistenceEvidence(persisted); - - evidence = { - topology: { - database: "disposable-loopback-postgresql", - migrations: `canonical-${canonicalMigrationExpectation.minimumId}-through-${canonicalMigrationExpectation.maximumId}`, - backend: "unified-native-effect-backend", - dashboard: "loopback-react-router-dashboard", - browser: "real-chromium", - fixedClock, - externalEffects: "disabled", - }, - authentication: { - renderedNativeLogin: true, - cookieName: browser.sessionCookieName, - personId: browser.sessionPersonId, - processScopedBetterAuthConfiguration: true, - bearerInjected: false, - jwtCookieUsed: false, - tokenMapConfigured: false, - rawAuthenticationLeak: false, - }, - browser: browserEvidence, - nativeTransport: { - initialAllFilterReadCount: leadingInitialAllFilterReadCount, - duplicateInitialAllFilterReadObserved, - requests: recruitmentRequests.map( - ({ method, pathAndQuery, status, sessionCookieAuth, authorizationHeaderPresent }) => ({ - method, - pathAndQuery, - status, - sessionCookieAuth, - authorizationHeaderPresent, - }), - ), - }, - postgres: { migrations, persisted }, - }; - } catch (error) { - const postgresLog = - postgres === undefined - ? "" - : await readFile(postgres.logFile, "utf8").catch(() => ""); - - primaryError = new Error( - `${errorDetail(error)}\nrecorded native transport: ${JSON.stringify(proxy?.records ?? [])}\nPostgreSQL log:\n${postgresLog}`, - { cause: error }, - ); - } - - let cleanupError; - - try { - await cleanup(); - - if (await pathExists(temporaryRoot)) { - throw new Error("native recruitment cleanup left the temporary root behind"); - } - - await Promise.all([ - waitForPortRelease(postgresPort), - waitForPortRelease(backendPort), - waitForPortRelease(dashboardPort), - ]); - } catch (error) { - cleanupError = error; - } finally { - process.removeListener("SIGINT", handleInterrupt); - process.removeListener("SIGTERM", handleTermination); - } - - if (primaryError !== undefined && cleanupError !== undefined) { - throw new AggregateError( - [primaryError, cleanupError], - "native recruitment journey and cleanup failed", - ); - } - - if (primaryError !== undefined) throw primaryError; - - if (cleanupError !== undefined) throw cleanupError; - - process.stdout.write( - `${JSON.stringify({ - ...evidence, - cleanup: { - postgresRemoved: true, - temporaryRootRemoved: true, - portsReleased: [postgresPort, backendPort, dashboardPort], - }, - })}\n`, - ); -}; - -main().catch((error) => { - process.stderr.write(`Real native recruitment assignment runner failed: ${errorDetail(error)}\n`); - process.exitCode = 1; -}); diff --git a/tools/e2e/run-real-native-recruitment-assignment.ts b/tools/e2e/run-real-native-recruitment-assignment.ts new file mode 100644 index 000000000..9e817e484 --- /dev/null +++ b/tools/e2e/run-real-native-recruitment-assignment.ts @@ -0,0 +1,1312 @@ +/** + * Real native recruitment assignment journey: a disposable PostgreSQL cluster with the canonical + * migrations, the unified native backend behind a recording proxy, the built dashboard, and one + * Chromium journey, followed by the persistence evidence of the assignment. + * + * Usage: bun run --cwd apps/dashboard e2e:real-recruitment + * Ports: RECRUITMENT_E2E_POSTGRES_PORT, RECRUITMENT_E2E_BACKEND_PORT, RECRUITMENT_E2E_DASHBOARD_PORT + */ +import { Buffer } from "node:buffer"; +import { randomBytes } from "node:crypto"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import { + type DisposablePostgres, + loopbackPortFree, + postgresProgram, + reserveLoopbackPorts, + startDisposablePostgres, +} from "@monoweb/postgres"; +import { + databaseMigrationDefinitions, + databaseSchemaRevision, +} from "@vektorprogrammet/database/migrations"; +import { + Cause, + Config, + Data, + Deferred, + Effect, + Exit, + FiberSet, + FileSystem, + Layer, + Option, + Path, + Predicate, + Schedule, + Schema, + Scope, + Stream, +} from "effect"; +import { + Cookies, + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpMethod, +} from "effect/unstable/http"; +import { flow } from "effect/Function"; +import { ChildProcess } from "effect/unstable/process"; +import { isNativeRpcPath } from "../../apps/dashboard/e2e/native-operations"; +import { replacedAnswer, replacedRequest } from "../../apps/dashboard/e2e/native-rpc-ledger"; +import { HarnessFailure } from "./golden-harness"; + +const repositoryRoot = fileURLToPath(new URL("../../", import.meta.url)); + +const dashboardRoot = fileURLToPath(new URL("../../apps/dashboard/", import.meta.url)); + +const recruitmentAssignmentMigration = { + id: 10, + name: "native-recruitment-applicant-assignment", +}; + +/** The JSON text of a value, byte for byte what `JSON.stringify` writes. */ +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const jsonText = (value: A): string => encodeJson(value); + +const journeyFailure = (message: string) => HarnessFailure.make({ stage: "recruitment", message }); + +class NotReady extends Data.TaggedError("NotReady")<{}> {} + +interface MigrationEntry { + readonly id: number; + readonly name: string; + readonly revision: string; +} + +const parseMigrationDefinition = ( + definition: { readonly id: string; readonly name: string }, + index: number, +): MigrationEntry => { + const match = /^([1-9]\d*)_(.+)$/u.exec(definition.id); + + if (match === null) { + throw new Error( + `canonical migration registry entry ${index} has malformed id ${jsonText(definition.id)}`, + ); + } + + const numericId = Number(match[1]); + + if (!Number.isSafeInteger(numericId)) { + throw new Error( + `canonical migration registry entry ${index} has unsafe numeric id ${jsonText(definition.id)}`, + ); + } + + if (match[2] !== definition.name) { + throw new Error( + `canonical migration registry entry ${index} id/name disagree: ${jsonText({ + id: definition.id, + name: definition.name, + })}`, + ); + } + + return { id: numericId, name: definition.name, revision: definition.id }; +}; + +const deriveCanonicalMigrationExpectation = ( + definitions: ReadonlyArray<{ readonly id: string; readonly name: string }>, + schemaRevision: string, +) => { + let minimumId = Number.POSITIVE_INFINITY; + let maximumId = Number.NEGATIVE_INFINITY; + let previousId: number | undefined; + let contiguous = true; + let head: MigrationEntry | undefined; + const seenIds = new Set(); + + for (const [index, definition] of definitions.entries()) { + const parsed = parseMigrationDefinition(definition, index); + + if (seenIds.has(parsed.id)) { + throw new Error(`canonical migration registry duplicates numeric id ${parsed.id}`); + } + + seenIds.add(parsed.id); + minimumId = Math.min(minimumId, parsed.id); + maximumId = Math.max(maximumId, parsed.id); + contiguous &&= previousId === undefined || parsed.id === previousId + 1; + previousId = parsed.id; + head = parsed; + } + + if (head === undefined) { + throw new Error("canonical migration registry must not be empty"); + } + + if (head.id !== maximumId) { + throw new Error( + `canonical migration registry head ${head.revision} does not have maximum numeric id ${maximumId}`, + ); + } + + if (schemaRevision !== head.revision.replaceAll("-", "_")) { + throw new Error( + `canonical migration registry/revision disagreement: ${jsonText({ + registryHead: head.revision, + databaseSchemaRevision: schemaRevision, + })}`, + ); + } + + return { + count: definitions.length, + minimumId, + maximumId, + contiguous, + head: { id: head.id, name: head.name }, + }; +}; + +const canonicalMigrationExpectation = deriveCanonicalMigrationExpectation( + databaseMigrationDefinitions, + databaseSchemaRevision, +); + +const expectedMigrationEvidence = { + ...canonicalMigrationExpectation, + recruitmentAssignment: recruitmentAssignmentMigration, +}; + +const fixedClock = "2026-09-15T12:00:00.000Z"; + +const applicationId = "application-native-journey-0049"; + +const leaderPersonId = "journey-rec-leader-0049"; + +const interviewerPersonId = "journey-rec-interviewer-a-0049"; + +const interviewSchemaId = "interview-schema-native-journey-0049"; + +const betterAuthSecret = randomBytes(32).toString("base64url"); + +const commandTimeout = "300 seconds"; + +const shutdownTimeout = "5 seconds"; + +// The runner removes these from the environment it passes to every child. +const removedEnvironment = { + API_MODE: undefined, + VITE_API_MODE: undefined, + ADMISSION_AUTH_TOKENS: undefined, + ORGANIZATION_AUTH_TOKENS: undefined, + RECEIPT_AUTH_TOKENS: undefined, + JWT_SECRET: undefined, + SYMFONY_API_URL: undefined, +}; + +type ChildEnvironment = Readonly>; + +const configuredLoopbackPort = (name: string, fallback: number) => + Effect.gen(function* () { + const value = yield* Config.withDefault(Config.String(name), String(fallback)); + + if (!/^\d+$/u.test(value)) return yield* journeyFailure(`${name} must be an integer`); + + const port = Number(value); + + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) + return yield* journeyFailure(`${name} must be between 1 and 65535`); + + return port; + }); + +const errorDetail = (cause: unknown): string => + Schema.is(HarnessFailure)(cause) + ? cause.message + : cause instanceof Error + ? cause.message + : String(cause); + +const assertEqual = (actual: A, expected: A, label: string) => + jsonText(actual) === jsonText(expected) + ? Effect.void + : Effect.fail( + journeyFailure( + `${label} mismatch\nexpected: ${jsonText(expected)}\nactual: ${jsonText(actual)}`, + ), + ); + +const assertPortAvailable = (port: number) => + Effect.tryPromise({ + try: () => loopbackPortFree(port), + catch: (cause) => + journeyFailure(`could not inspect loopback port ${port}: ${errorDetail(cause)}`), + }).pipe( + Effect.flatMap((free) => + free ? Effect.void : Effect.fail(journeyFailure(`loopback port ${port} is already in use`)), + ), + ); + +const waitForPortRelease = (port: number) => + assertPortAvailable(port).pipe( + Effect.retry(Schedule.spaced("100 millis").pipe(Schedule.upTo({ duration: shutdownTimeout }))), + Effect.mapError(() => journeyFailure(`loopback port ${port} was not released`)), + ); + +interface RunOptions { + readonly cwd: string; + readonly env: ChildEnvironment; + readonly label: string; + readonly capture?: boolean | undefined; +} + +type ProcessExit = { readonly code: number | null; readonly signal: string | null }; + +const exitOf = (exit: Exit.Exit): ProcessExit => { + if (Exit.isSuccess(exit)) return { code: exit.value, signal: null }; + + const error = Cause.squash(exit.cause); + const message = error instanceof Error && error.cause instanceof Error ? error.cause.message : ""; + + return { code: null, signal: /signal: '(\w+)'/u.exec(message)?.[1] ?? null }; +}; + +/** Runs one command in its own process group and returns its output when it captures it. */ +const run = (command: string, args: ReadonlyArray, options: RunOptions) => + Effect.scoped( + Effect.gen(function* () { + const capture = options.capture === true; + + const handle = yield* ChildProcess.make(command, args, { + cwd: options.cwd, + env: options.env, + extendEnv: true, + detached: true, + stdin: "ignore", + stdout: capture ? "pipe" : "inherit", + stderr: capture ? "pipe" : "inherit", + killSignal: "SIGTERM", + }).pipe( + Effect.mapError((cause) => + journeyFailure(`${options.label} could not start: ${errorDetail(cause)}`), + ), + ); + + const [stdout, stderr, exit] = yield* Effect.all( + [ + capture ? Stream.mkString(Stream.decodeText(handle.stdout)) : Effect.succeed(""), + capture ? Stream.mkString(Stream.decodeText(handle.stderr)) : Effect.succeed(""), + Effect.map(Effect.exit(handle.exitCode), exitOf), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => journeyFailure(`${options.label}: ${errorDetail(cause)}`)), + Effect.timeoutOrElse({ + duration: commandTimeout, + orElse: () => Effect.fail(journeyFailure(`${options.label} timed out`)), + }), + ); + + if (exit.code === 0) return { stdout, stderr }; + + const detail = [stdout.trim(), stderr.trim()].filter((part) => part.length > 0).join("\n"); + + return yield* journeyFailure( + `${options.label} exited with ${exit.signal ?? `code ${exit.code}`}${detail.length > 0 ? `:\n${detail}` : ""}`, + ); + }), + ); + +interface OwnedChild { + readonly scope: Scope.Closeable; + readonly exited: Deferred.Deferred; +} + +const start = ( + command: string, + args: ReadonlyArray, + environment: ChildEnvironment, + cwd: string, +) => + Effect.gen(function* () { + const scope = yield* Scope.make(); + + const handle = yield* ChildProcess.make(command, args, { + cwd, + env: environment, + extendEnv: true, + detached: true, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + killSignal: "SIGTERM", + forceKillAfter: shutdownTimeout, + }).pipe( + Scope.provide(scope), + Effect.mapError((cause) => + journeyFailure(`${command} could not start: ${errorDetail(cause)}`), + ), + ); + + const exited = yield* Deferred.make(); + + yield* handle.exitCode.pipe( + Effect.exit, + Effect.flatMap((exit) => Deferred.succeed(exited, exitOf(exit))), + Effect.forkDetach, + ); + + return { scope, exited } satisfies OwnedChild; + }); + +/** Signals the group with SIGTERM, then SIGKILL after the shutdown timeout. */ +const stop = (child: OwnedChild | undefined) => + child === undefined ? Effect.void : Scope.close(child.scope, Exit.void); + +const waitForHttp = (url: string, child: OwnedChild, label: string) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + + const attempt = client.execute(HttpClientRequest.get(url)).pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.provideService(HttpClient.TracerPropagationEnabled, false), + Effect.map((response) => response.status < 500), + Effect.orElseSucceed(() => false), + ); + + yield* Effect.gen(function* () { + if (yield* Deferred.isDone(child.exited)) + return yield* journeyFailure(`${label} exited before readiness`); + + if (!(yield* attempt)) return yield* new NotReady(); + }).pipe( + Effect.retry({ + while: (error) => Predicate.isTagged(error, "NotReady"), + schedule: Schedule.spaced("250 millis").pipe(Schedule.upTo({ duration: commandTimeout })), + }), + Effect.catchTag("NotReady", () => + Effect.fail(journeyFailure(`${label} did not become ready`)), + ), + ); + }); + +const JsonText = Schema.fromJsonString(Schema.Json); + +const JsonObject = Schema.Record(Schema.String, Schema.Json); + +type JsonObject = typeof JsonObject.Type; + +const parseJsonBody = (bytes: Buffer): Schema.Json | undefined => + bytes.byteLength === 0 + ? undefined + : Option.getOrUndefined(Schema.decodeOption(JsonText)(bytes.toString("utf8"))); + +/** One member of a JSON object, or `undefined` for anything else. */ +const member = (value: Schema.Json | null | undefined, key: string): Schema.Json | undefined => + Option.getOrUndefined( + Option.flatMap(Schema.decodeUnknownOption(JsonObject)(value), (object) => + Option.fromNullishOr(object[key]), + ), + ); + +const sessionCookieNames = new Set([ + "better-auth.session_token", + "__Secure-better-auth.session_token", +]); + +const hasNamedCookie = (cookieHeader: string | null | undefined, names: ReadonlySet) => + Predicate.isString(cookieHeader) && + cookieHeader.split(";").some((pair) => { + const separator = pair.indexOf("="); + + return separator > 0 && names.has(pair.slice(0, separator).trim()); + }); + +interface ProxyRecord { + method: string; + path: string; + pathAndQuery: string; + status: number; + sessionCookieAuth: boolean; + jwtCookieAuth: boolean; + authorizationHeaderPresent: boolean; + requestJson: Schema.Json | undefined; + idempotencyKey: string | null; + ifMatch: string | null; + responseJson: Schema.Json | null; + responseEtag: string | null; + rpcTag?: string; + responseFailure?: Schema.Json | undefined; +} + +interface RecordingProxy { + readonly origin: string; + readonly records: ReadonlyArray; + readonly close: Effect.Effect; +} + +const skippedRequestHeaders = ["connection", "content-length", "host", "transfer-encoding"]; + +const skippedResponseHeaders = [ + "content-encoding", + "content-length", + "set-cookie", + "transfer-encoding", +]; + +const startRecordingProxy = (targetOrigin: string) => + Effect.gen(function* () { + const records: Array = []; + const scope = yield* Scope.make(); + const client = yield* HttpClient.HttpClient; + const runHandler = yield* FiberSet.makeRuntimePromise().pipe(Scope.provide(scope)); + + const forward = (request: Request) => + Effect.gen(function* () { + const incoming = new URL(request.url); + const method = request.method; + const requestUrl = new URL(`${incoming.pathname}${incoming.search}`, targetOrigin); + const path = requestUrl.pathname; + const requestBytes = Buffer.from(yield* Effect.promise(() => request.arrayBuffer())); + const requestJson = parseJsonBody(requestBytes); + const idempotencyKey = request.headers.get("idempotency-key"); + const ifMatch = request.headers.get("if-match"); + + const record: ProxyRecord = { + method, + path, + pathAndQuery: `${path}${requestUrl.search}`, + status: 0, + sessionCookieAuth: hasNamedCookie(request.headers.get("cookie"), sessionCookieNames), + jwtCookieAuth: hasNamedCookie(request.headers.get("cookie"), new Set(["jwt_token"])), + authorizationHeaderPresent: request.headers.has("authorization"), + requestJson, + idempotencyKey, + ifMatch, + responseJson: null, + responseEtag: null, + }; + + const rpc = isNativeRpcPath(path) ? replacedRequest(requestJson) : undefined; + + // A native RPC is recorded as the HTTP route it replaced, with the facts of its message. + if (rpc !== undefined) { + const status = rpc.payload.status; + + record.rpcTag = rpc.tag; + record.method = rpc.method; + record.path = rpc.path; + record.pathAndQuery = + rpc.tag === "recruitment.readAssignmentBoard" && Predicate.isString(status) + ? `${rpc.path}?status=${status}` + : rpc.path; + record.idempotencyKey = rpc.idempotencyKey; + record.ifMatch = rpc.ifMatch; + record.requestJson = rpc.requestJson; + record.sessionCookieAuth ||= hasNamedCookie( + rpc.messageHeaders.cookie, + sessionCookieNames, + ); + record.jwtCookieAuth ||= hasNamedCookie( + rpc.messageHeaders.cookie, + new Set(["jwt_token"]), + ); + record.authorizationHeaderPresent ||= rpc.messageHeaders.authorization !== undefined; + } + + records.push(record); + + return yield* Effect.gen(function* () { + const headers = [...request.headers.entries()].filter( + ([name]) => !skippedRequestHeaders.includes(name), + ); + + const outgoing = HttpClientRequest.make(HttpMethod.isHttpMethod(method) ? method : "GET")( + requestUrl, + { headers: Object.fromEntries(headers) }, + ); + + const upstream = yield* client.execute( + method === "GET" || method === "HEAD" + ? outgoing + : HttpClientRequest.bodyUint8Array( + outgoing, + requestBytes, + request.headers.get("content-type") ?? undefined, + ), + ); + + const responseBytes = Buffer.from(yield* upstream.arrayBuffer); + + record.status = upstream.status; + + if (upstream.status >= 400) record.responseFailure = parseJsonBody(responseBytes); + record.responseJson = parseJsonBody(responseBytes) ?? null; + record.responseEtag = upstream.headers.etag ?? null; + + const answer = rpc === undefined ? undefined : replacedAnswer(record.responseJson); + + if (answer !== undefined) { + record.status = answer.status; + record.responseJson = answer.responseJson; + record.responseEtag = answer.responseEtag; + + if (answer.status >= 400) record.responseFailure = answer.responseJson; + } + + const responseHeaders = new Headers(); + + for (const [name, value] of Object.entries(upstream.headers)) + if (!skippedResponseHeaders.includes(name)) responseHeaders.set(name, value); + + for (const cookie of Cookies.toSetCookieHeaders(upstream.cookies)) + responseHeaders.append("set-cookie", cookie); + + return new Response(responseBytes.byteLength === 0 ? null : responseBytes, { + status: upstream.status, + headers: responseHeaders, + }); + }).pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.provideService(HttpClient.TracerPropagationEnabled, false), + Effect.catchCause((cause) => + Effect.sync(() => { + record.status = 502; + process.stderr.write( + `Native recruitment proxy failure: ${errorDetail(Cause.squash(cause))}\n`, + ); + + return new Response(jsonText({ error: "native recruitment evidence proxy failed" }), { + status: 502, + headers: { "content-type": "application/json" }, + }); + }), + ), + ); + }); + + const [port] = yield* Effect.tryPromise({ + try: () => reserveLoopbackPorts(1), + catch: (cause) => journeyFailure(`proxy port: ${errorDetail(cause)}`), + }); + + const server = yield* Effect.try({ + try: () => + Bun.serve({ + hostname: "127.0.0.1", + port, + // Node's server, which this proxy replaced, closed no idle request. + idleTimeout: 0, + fetch: (request) => runHandler(forward(request)), + }), + catch: (cause) => journeyFailure(`proxy listen: ${errorDetail(cause)}`), + }); + + let closed = false; + + return { + origin: `http://127.0.0.1:${port}`, + records, + close: Effect.suspend(() => { + if (closed) return Effect.void; + closed = true; + + return Effect.tryPromise({ + try: () => server.stop(true), + catch: (cause) => journeyFailure(`proxy close: ${errorDetail(cause)}`), + }).pipe(Effect.ensuring(Scope.close(scope, Exit.void))); + }), + } satisfies RecordingProxy; + }); + +const MigrationEvidence = Schema.Struct({ + count: Schema.Finite, + minimumId: Schema.NullOr(Schema.Finite), + maximumId: Schema.NullOr(Schema.Finite), + contiguous: Schema.Boolean, + recruitmentAssignment: Schema.NullOr(Schema.Struct({ id: Schema.Finite, name: Schema.String })), + head: Schema.NullOr(Schema.Struct({ id: Schema.Finite, name: Schema.String })), +}); + +const PersistenceEvidence = Schema.Struct({ + interviewCount: Schema.Finite, + scheduleCount: Schema.Finite, + receiptCount: Schema.Finite, + auditCount: Schema.Finite, + interview: Schema.NullOr( + Schema.Struct({ + interviewId: Schema.String, + applicationId: Schema.String, + departmentId: Schema.String, + interviewerPersonId: Schema.NullOr(Schema.String), + interviewSchemaId: Schema.NullOr(Schema.String), + assignedByPersonId: Schema.NullOr(Schema.String), + assignedAt: Schema.NullOr(Schema.String), + revision: Schema.Finite, + }), + ), + receipt: Schema.NullOr( + Schema.Struct({ + commandId: Schema.String, + applicationId: Schema.String, + interviewId: Schema.NullOr(Schema.String), + commandApplicationId: Schema.NullOr(Schema.String), + commandInterviewerPersonId: Schema.NullOr(Schema.String), + commandInterviewSchemaId: Schema.NullOr(Schema.String), + committedAt: Schema.NullOr(Schema.String), + }), + ), + audit: Schema.NullOr( + Schema.Struct({ + commandId: Schema.String, + interviewId: Schema.NullOr(Schema.String), + applicationId: Schema.String, + departmentId: Schema.NullOr(Schema.String), + actorPersonId: Schema.NullOr(Schema.String), + action: Schema.String, + interviewRevision: Schema.NullOr(Schema.Finite), + occurredAt: Schema.NullOr(Schema.String), + }), + ), +}); + +type MigrationEvidence = typeof MigrationEvidence.Type; + +type PersistenceEvidence = typeof PersistenceEvidence.Type; + +const assertMigrationEvidence = (evidence: MigrationEvidence) => + evidence.count !== expectedMigrationEvidence.count || + evidence.minimumId !== expectedMigrationEvidence.minimumId || + evidence.maximumId !== expectedMigrationEvidence.maximumId || + evidence.contiguous !== expectedMigrationEvidence.contiguous || + evidence.recruitmentAssignment?.id !== expectedMigrationEvidence.recruitmentAssignment.id || + evidence.recruitmentAssignment.name !== expectedMigrationEvidence.recruitmentAssignment.name || + evidence.head?.id !== expectedMigrationEvidence.head.id || + evidence.head.name !== expectedMigrationEvidence.head.name + ? Effect.fail( + journeyFailure( + `canonical migration evidence failed: ${jsonText({ + expected: expectedMigrationEvidence, + actual: evidence, + })}`, + ), + ) + : Effect.void; + +const assertPersistenceEvidence = (evidence: PersistenceEvidence) => { + const { interview, receipt, audit } = evidence; + + return evidence.interviewCount !== 1 || + evidence.scheduleCount !== 0 || + evidence.receiptCount !== 1 || + evidence.auditCount !== 1 || + interview?.applicationId !== applicationId || + interview.interviewerPersonId !== interviewerPersonId || + interview.interviewSchemaId !== interviewSchemaId || + interview.assignedByPersonId !== leaderPersonId || + interview.assignedAt !== fixedClock || + interview.revision !== 0 || + receipt?.applicationId !== applicationId || + receipt.interviewId !== interview.interviewId || + receipt.commandApplicationId !== applicationId || + receipt.commandInterviewerPersonId !== interviewerPersonId || + receipt.commandInterviewSchemaId !== interviewSchemaId || + receipt.committedAt !== fixedClock || + audit?.commandId !== receipt.commandId || + audit.interviewId !== interview.interviewId || + audit.applicationId !== applicationId || + audit.departmentId !== interview.departmentId || + audit.actorPersonId !== leaderPersonId || + audit.actorPersonId !== interview.assignedByPersonId || + audit.action !== "ApplicantAssigned" || + audit.interviewRevision !== 0 || + audit.occurredAt !== fixedClock + ? Effect.fail( + journeyFailure(`native recruitment persistence evidence failed: ${jsonText(evidence)}`), + ) + : Effect.void; +}; + +const signalExitCodes = { SIGINT: 130, SIGTERM: 143 } as const; + +type Signal = keyof typeof signalExitCodes; + +const program = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const postgresPort = yield* configuredLoopbackPort("RECRUITMENT_E2E_POSTGRES_PORT", 55446); + const backendPort = yield* configuredLoopbackPort("RECRUITMENT_E2E_BACKEND_PORT", 8800); + const dashboardPort = yield* configuredLoopbackPort("RECRUITMENT_E2E_DASHBOARD_PORT", 5174); + + const playwrightNode = yield* Config.withDefault( + Config.String("PLAYWRIGHT_NODE_EXECUTABLE"), + "node", + ); + + const postgresUrl = `postgres://postgres@127.0.0.1:${postgresPort}/postgres`; + const backendOrigin = `http://127.0.0.1:${backendPort}`; + const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; + const seedPath = path.join(dashboardRoot, "e2e/native-recruitment-journey-seed.mjs"); + + yield* Effect.all( + [ + assertPortAvailable(postgresPort), + assertPortAvailable(backendPort), + assertPortAvailable(dashboardPort), + ], + { concurrency: "unbounded" }, + ); + + const temporaryRoot = yield* fs.makeTempDirectory({ + prefix: "mono-web-native-recruitment-0049-1-", + }); + + const postgresRoot = path.join(temporaryRoot, "postgres"); + const browserEvidencePath = path.join(temporaryRoot, "browser-evidence.json"); + const receiptStagingRoot = path.join(temporaryRoot, "receipt-staging"); + const receiptCommittedRoot = path.join(temporaryRoot, "receipt-committed"); + + const processEnvironment = { + ...removedEnvironment, + BACKEND_HOST: "127.0.0.1", + BACKEND_PORT: String(backendPort), + BACKEND_PG_URL: postgresUrl, + BETTER_AUTH_SECRET: betterAuthSecret, + NATIVE_IDENTITY_DEPLOYMENT: "local", + NATIVE_IDENTITY_TRUSTED_ORIGINS: jsonText([dashboardOrigin]), + OAUTH_CANONICAL_ORIGIN: backendOrigin, + OAUTH_DASHBOARD_ORIGIN: dashboardOrigin, + OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", + ADMISSION_FIXED_NOW: fixedClock, + PUBLIC_APPLICATION_EFFECT_MODE: "disabled", + PASSWORD_RESET_DELIVERY_MODE: "disabled", + RECEIPT_DELIVERY_MODE: "disabled", + RECEIPT_STAGING_ROOT: receiptStagingRoot, + RECEIPT_COMMITTED_ROOT: receiptCommittedRoot, + RECEIPT_MAX_FILE_BYTES: "10485760", + RECEIPT_E2E_TEST_MODE: "1", + }; + + let postgres: DisposablePostgres | undefined; + let backend: OwnedChild | undefined; + let dashboard: OwnedChild | undefined; + let proxy: RecordingProxy | undefined; + let cleaned = false; + + const runPsql = (sql: string, label: string) => + run( + postgresProgram("psql"), + [ + "-h", + "127.0.0.1", + "-p", + String(postgresPort), + "-U", + "postgres", + "-d", + "postgres", + "-At", + "-v", + "ON_ERROR_STOP=1", + "-c", + sql, + ], + { cwd: repositoryRoot, env: removedEnvironment, capture: true, label }, + ).pipe(Effect.map(({ stdout }) => stdout.trim())); + + const readMigrationEvidence = runPsql( + `SELECT json_build_object( + 'count', (SELECT count(*)::int FROM vektorprogrammet_schema_migrations), + 'minimumId', (SELECT min(migration_id)::int FROM vektorprogrammet_schema_migrations), + 'maximumId', (SELECT max(migration_id)::int FROM vektorprogrammet_schema_migrations), + 'contiguous', NOT EXISTS ( + SELECT 1 FROM generate_series( + (SELECT min(migration_id) FROM vektorprogrammet_schema_migrations), + (SELECT max(migration_id) FROM vektorprogrammet_schema_migrations) + ) AS expected(id) + LEFT JOIN vektorprogrammet_schema_migrations actual ON actual.migration_id = expected.id + WHERE actual.migration_id IS NULL + ), + 'recruitmentAssignment', (SELECT json_build_object('id', migration_id, 'name', name) FROM vektorprogrammet_schema_migrations WHERE migration_id = ${recruitmentAssignmentMigration.id}), + 'head', (SELECT json_build_object('id', migration_id, 'name', name) FROM vektorprogrammet_schema_migrations ORDER BY migration_id DESC LIMIT 1) + )`, + "canonical migration evidence", + ).pipe( + Effect.flatMap((text) => + Schema.decodeEffect(Schema.fromJsonString(MigrationEvidence))(text).pipe( + Effect.mapError((cause) => + journeyFailure(`canonical migration evidence failed: ${errorDetail(cause)}`), + ), + ), + ), + ); + + const readPersistenceEvidence = runPsql( + `SELECT json_build_object( + 'interviewCount', (SELECT count(*)::int FROM recruitment_interviews WHERE application_id = '${applicationId}'), + 'scheduleCount', (SELECT count(*)::int FROM recruitment_interview_schedules schedule INNER JOIN recruitment_interviews interview ON interview.interview_id = schedule.interview_id WHERE interview.application_id = '${applicationId}'), + 'receiptCount', (SELECT count(*)::int FROM recruitment_assignment_command_receipts WHERE application_id = '${applicationId}'), + 'auditCount', (SELECT count(*)::int FROM recruitment_assignment_audit WHERE application_id = '${applicationId}'), + 'interview', (SELECT row_to_json(value) FROM ( + SELECT interview_id AS "interviewId", application_id AS "applicationId", department_id AS "departmentId", + interviewer_person_id AS "interviewerPersonId", interview_schema_id AS "interviewSchemaId", + assigned_by_person_id AS "assignedByPersonId", to_char(assigned_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "assignedAt", + revision + FROM recruitment_interviews WHERE application_id = '${applicationId}' + ) value), + 'receipt', (SELECT row_to_json(value) FROM ( + SELECT command_id AS "commandId", application_id AS "applicationId", interview_id AS "interviewId", + command_json->>'applicationId' AS "commandApplicationId", + command_json->>'interviewerPersonId' AS "commandInterviewerPersonId", + command_json->>'interviewSchemaId' AS "commandInterviewSchemaId", + to_char(committed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "committedAt" + FROM recruitment_assignment_command_receipts WHERE application_id = '${applicationId}' + ) value), + 'audit', (SELECT row_to_json(value) FROM ( + SELECT command_id AS "commandId", interview_id AS "interviewId", application_id AS "applicationId", + department_id AS "departmentId", actor_person_id AS "actorPersonId", action, + interview_revision AS "interviewRevision", + to_char(occurred_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "occurredAt" + FROM recruitment_assignment_audit WHERE application_id = '${applicationId}' + ) value) + )`, + "native recruitment persistence evidence", + ).pipe( + Effect.flatMap((text) => + Schema.decodeEffect(Schema.fromJsonString(PersistenceEvidence))(text).pipe( + Effect.mapError((cause) => + journeyFailure(`native recruitment persistence evidence failed: ${errorDetail(cause)}`), + ), + ), + ), + ); + + const cleanup = Effect.gen(function* () { + if (cleaned) return; + cleaned = true; + + const errors: Array = []; + + const attempt = (effect: Effect.Effect) => + effect.pipe( + Effect.catchCause((cause) => + Effect.sync(() => void errors.push(errorDetail(Cause.squash(cause)))), + ), + ); + + yield* attempt(stop(dashboard)); + + if (proxy !== undefined) yield* attempt(proxy.close); + yield* attempt(stop(backend)); + + const cluster = postgres; + + if (cluster !== undefined) yield* attempt(Effect.tryPromise(() => cluster.stop())); + yield* attempt(fs.remove(temporaryRoot, { recursive: true, force: true })); + + if (errors.length > 0) + return yield* journeyFailure(`native recruitment cleanup failed: ${errors.join("; ")}`); + }); + + const journey = Effect.gen(function* () { + postgres = yield* Effect.tryPromise({ + try: () => startDisposablePostgres({ port: postgresPort, directory: postgresRoot }), + catch: (cause) => journeyFailure(errorDetail(cause)), + }); + yield* run("bun", [seedPath], { + cwd: repositoryRoot, + env: { ...processEnvironment, JOURNEY_SEED_PG_URL: postgresUrl }, + label: "existing native recruitment fixture and Identity seed", + }); + + const migrations = yield* readMigrationEvidence; + + yield* assertMigrationEvidence(migrations); + + const startedBackend = yield* start( + "bun", + ["run", "--cwd", "apps/backend", "start"], + processEnvironment, + repositoryRoot, + ); + + backend = startedBackend; + yield* waitForHttp(`${backendOrigin}/health`, startedBackend, "unified native backend"); + + const recording = yield* startRecordingProxy(backendOrigin); + + proxy = recording; + + const journeyEnvironment = { + ...processEnvironment, + API_URL: recording.origin, + VITE_API_URL: recording.origin, + DASHBOARD_ORIGIN: dashboardOrigin, + REAL_NATIVE_IDENTITY_E2E: "1", + REAL_NATIVE_CONDUCT_E2E: "1", + RECRUITMENT_E2E_BROWSER_EVIDENCE_PATH: browserEvidencePath, + RECRUITMENT_E2E_LEADER_PERSON_ID: leaderPersonId, + }; + + // The dev server optimizes newly discovered dependencies and reloads the page, which + // aborts in-flight module imports under the browser. The journey serves the build. + const dashboardEnvironment = { + ...journeyEnvironment, + HOST: "127.0.0.1", + PORT: String(dashboardPort), + NODE_ENV: "production", + }; + + yield* run("bun", ["run", "build"], { + cwd: dashboardRoot, + env: dashboardEnvironment, + label: "native recruitment dashboard production build", + }); + + const startedDashboard = yield* start( + "bun", + ["server.mjs"], + dashboardEnvironment, + dashboardRoot, + ); + + dashboard = startedDashboard; + yield* waitForHttp(`${dashboardOrigin}/login`, startedDashboard, "native dashboard"); + yield* run( + playwrightNode, + [ + "./node_modules/@playwright/test/cli.js", + "test", + "e2e/native-recruitment-session-journey.spec.ts", + "--project=chromium", + "--workers=1", + "--retries=0", + ], + { + cwd: dashboardRoot, + env: journeyEnvironment, + capture: true, + label: "real native recruitment Chromium journey", + }, + ); + + const browser = yield* fs.readFileString(browserEvidencePath).pipe( + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(JsonObject))), + Effect.mapError((cause) => + journeyFailure( + `native recruitment browser evidence was not valid JSON: ${errorDetail(cause)}`, + ), + ), + ); + + yield* assertEqual( + browser.bridgeResponses, + [ + { operation: "readAssignmentBoard", status: 200, authorizationHeaderPresent: false }, + { operation: "createApplicationInterview", status: 200, authorizationHeaderPresent: false }, + { operation: "readAssignmentBoard", status: 200, authorizationHeaderPresent: false }, + { operation: "readAssignmentBoard", status: 200, authorizationHeaderPresent: false }, + ], + "browser bridge sequence", + ); + + const emptyList = (value: Schema.Json | undefined) => + Array.isArray(value) && value.length === 0; + + if ( + browser.renderedNativeLogin !== true || + browser.sessionCookieName !== "better-auth.session_token" || + browser.sessionPersonId !== leaderPersonId || + browser.rawAuthenticationLeak !== false || + browser.accessibilityViolations !== 0 || + !emptyList(browser.pageErrors) || + !emptyList(browser.legacyBrowserRequests) || + !emptyList(browser.externalBrowserRequests) + ) + return yield* journeyFailure(`browser evidence failed: ${jsonText(browser)}`); + + const boardPath = "/api/recruitment/application-assignments"; + const createPath = `/api/recruitment/applications/${encodeURIComponent(applicationId)}/interviews`; + + const recruitmentRequests = recording.records.filter( + ({ path: requestPath }) => requestPath === boardPath || requestPath === createPath, + ); + + const requiredTransportTail: ReadonlyArray = [ + ["GET", `${boardPath}?status=new`, 200], + // An RPC command answers 200; the 201 and Location were HTTP transport facts. + ["POST", createPath, 200], + ["GET", `${boardPath}?status=new`, 200], + ["GET", `${boardPath}?status=all`, 200], + ]; + + const leadingInitialAllFilterReadCount = + recruitmentRequests.length - requiredTransportTail.length; + + if (leadingInitialAllFilterReadCount !== 1 && leadingInitialAllFilterReadCount !== 2) + return yield* journeyFailure("native recruitment transport had an unexpected request count"); + + const duplicateInitialAllFilterReadObserved = leadingInitialAllFilterReadCount === 2; + + const expectedTransport = [ + ...Array.from( + { length: leadingInitialAllFilterReadCount }, + () => ["GET", `${boardPath}?status=all`, 200] as const, + ), + ...requiredTransportTail, + ].map(([method, pathAndQuery, status]) => ({ + method, + pathAndQuery, + status, + sessionCookieAuth: true, + authorizationHeaderPresent: false, + jwtCookieAuth: false, + idempotencyKeyPresent: method === "POST", + ifMatchPresent: false, + requestBodyKeys: method === "POST" ? ["interviewSchemaId", "interviewerPersonId"] : [], + })); + + yield* assertEqual( + recruitmentRequests.map( + ({ + method, + pathAndQuery, + status, + sessionCookieAuth, + authorizationHeaderPresent, + jwtCookieAuth, + idempotencyKey, + ifMatch, + requestJson, + }) => ({ + method, + pathAndQuery, + status, + sessionCookieAuth, + authorizationHeaderPresent, + jwtCookieAuth, + idempotencyKeyPresent: Predicate.isString(idempotencyKey), + ifMatchPresent: Predicate.isString(ifMatch), + requestBodyKeys: Predicate.isObject(requestJson) ? Object.keys(requestJson).sort() : [], + }), + ), + expectedTransport, + "exact native recruitment transport", + ); + + const createRequest = recruitmentRequests.find(({ method }) => method === "POST"); + const responseJson = createRequest?.responseJson; + const requestJson = createRequest?.requestJson; + + if ( + jsonText(Predicate.isObject(responseJson) ? Object.keys(responseJson).sort() : []) !== + jsonText([ + "applicationId", + "assignedAt", + "assignedByPersonId", + "coInterviewerPersonId", + "departmentId", + "interviewId", + "interviewSchemaId", + "interviewerPersonId", + "revision", + ]) || + member(requestJson, "interviewerPersonId") !== interviewerPersonId || + member(requestJson, "interviewSchemaId") !== interviewSchemaId || + !/^"vkr2\.[A-Za-z0-9_-]{43}"$/u.test(createRequest?.responseEtag ?? "") || + member(responseJson, "applicationId") !== applicationId || + Option.getOrUndefined( + Option.map( + Schema.decodeUnknownOption(JsonObject)(responseJson), + (object) => object.coInterviewerPersonId, + ), + ) !== null || + member(responseJson, "interviewerPersonId") !== interviewerPersonId || + member(responseJson, "interviewSchemaId") !== interviewSchemaId || + member(responseJson, "assignedByPersonId") !== leaderPersonId || + member(responseJson, "revision") !== 0 + ) + return yield* journeyFailure( + "Application interview creation did not use the generated v0.2 contract", + ); + + const browserEvidence = { ...browser, duplicateInitialAllFilterReadObserved }; + + yield* fs + .writeFileString(browserEvidencePath, `${jsonText(browserEvidence)}\n`) + .pipe(Effect.mapError(flow(errorDetail, journeyFailure))); + + const legacyPaths = recording.records.filter(({ path: requestPath }) => + [ + "/api/admin/applications", + "/api/admin/users", + "/api/admin/interviews/schemas", + "/api/admin/interviews/assign", + "/api/admin/recruitment/assignment-board", + "/api/admin/recruitment/interviews/assign", + ].some( + (legacyPath) => requestPath === legacyPath || requestPath.startsWith(`${legacyPath}/`), + ), + ); + + if ( + recording.records.some( + ({ authorizationHeaderPresent, jwtCookieAuth }) => + authorizationHeaderPresent || jwtCookieAuth, + ) || + legacyPaths.length > 0 + ) + return yield* journeyFailure( + `legacy or bearer authentication transport observed: ${jsonText({ legacyPaths })}`, + ); + + const persisted = yield* readPersistenceEvidence; + + yield* assertPersistenceEvidence(persisted); + + return { + topology: { + database: "disposable-loopback-postgresql", + migrations: `canonical-${canonicalMigrationExpectation.minimumId}-through-${canonicalMigrationExpectation.maximumId}`, + backend: "unified-native-effect-backend", + dashboard: "loopback-react-router-dashboard", + browser: "real-chromium", + fixedClock, + externalEffects: "disabled", + }, + authentication: { + renderedNativeLogin: true, + cookieName: browser.sessionCookieName, + personId: browser.sessionPersonId, + processScopedBetterAuthConfiguration: true, + bearerInjected: false, + jwtCookieUsed: false, + tokenMapConfigured: false, + rawAuthenticationLeak: false, + }, + browser: browserEvidence, + nativeTransport: { + initialAllFilterReadCount: leadingInitialAllFilterReadCount, + duplicateInitialAllFilterReadObserved, + requests: recruitmentRequests.map( + ({ method, pathAndQuery, status, sessionCookieAuth, authorizationHeaderPresent }) => ({ + method, + pathAndQuery, + status, + sessionCookieAuth, + authorizationHeaderPresent, + }), + ), + }, + postgres: { migrations, persisted }, + }; + }); + + const signals = yield* Deferred.make(); + + return yield* Effect.scoped( + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => + (["SIGINT", "SIGTERM"] as const).map((signal) => { + const listener = () => Deferred.doneUnsafe(signals, Effect.succeed(signal)); + + process.once(signal, listener); + + return [signal, listener] as const; + }), + ), + (listeners) => + Effect.sync(() => { + for (const [signal, listener] of listeners) process.removeListener(signal, listener); + }), + ); + + const outcome = yield* journey.pipe( + Effect.map((evidence) => ({ kind: "passed" as const, evidence })), + Effect.raceFirst( + Effect.map(Deferred.await(signals), (signal) => ({ kind: "signal" as const, signal })), + ), + Effect.exit, + ); + + if (Exit.isSuccess(outcome) && outcome.value.kind === "signal") { + yield* Effect.ignore(cleanup); + + return signalExitCodes[outcome.value.signal]; + } + + let primaryError: string | undefined; + + if (Exit.isFailure(outcome)) { + const cluster = postgres; + + const postgresLog = + cluster === undefined + ? "" + : yield* fs + .readFileString(cluster.logFile) + .pipe(Effect.orElseSucceed(() => "")); + + primaryError = `${errorDetail(Cause.squash(outcome.cause))}\nrecorded native transport: ${jsonText(proxy?.records ?? [])}\nPostgreSQL log:\n${postgresLog}`; + } + + const cleanupError = yield* Effect.gen(function* () { + yield* cleanup; + + if (yield* fs.exists(temporaryRoot)) + return yield* journeyFailure("native recruitment cleanup left the temporary root behind"); + + yield* Effect.all( + [ + waitForPortRelease(postgresPort), + waitForPortRelease(backendPort), + waitForPortRelease(dashboardPort), + ], + { concurrency: "unbounded" }, + ); + }).pipe( + Effect.as(undefined), + Effect.catchCause((cause) => Effect.succeed(errorDetail(Cause.squash(cause)))), + ); + + if (primaryError !== undefined && cleanupError !== undefined) + return yield* journeyFailure( + `native recruitment journey and cleanup failed: ${primaryError}; ${cleanupError}`, + ); + + if (primaryError !== undefined) return yield* journeyFailure(primaryError); + + if (cleanupError !== undefined) return yield* journeyFailure(cleanupError); + + const passed = Exit.isSuccess(outcome) ? outcome.value : undefined; + + if (passed?.kind === "passed") + yield* Effect.sync(() => + process.stdout.write( + `${jsonText({ + ...passed.evidence, + cleanup: { + postgresRemoved: true, + temporaryRootRemoved: true, + portsReleased: [postgresPort, backendPort, dashboardPort], + }, + })}\n`, + ), + ); + + return 0; + }), + ); +}); + +Effect.runPromise( + program.pipe(Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer))), +).then( + (code) => { + if (code === 0) process.exitCode = 0; + else process.exit(code); + }, + (cause: unknown) => { + process.stderr.write( + `Real native recruitment assignment runner failed: ${errorDetail(cause)}\n`, + ); + process.exitCode = 1; + }, +); diff --git a/tools/e2e/safe-file-io.ts b/tools/e2e/safe-file-io.ts index 04479c1a5..f85818eb8 100644 --- a/tools/e2e/safe-file-io.ts +++ b/tools/e2e/safe-file-io.ts @@ -1,17 +1,12 @@ import { dlopen, FFIType } from "bun:ffi"; import { createHash } from "node:crypto"; -import { - closeSync, - constants, - fstatSync, - ftruncateSync, - mkdirSync, - openSync, - writeFileSync, -} from "node:fs"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0018: O_NOFOLLOW opens, /proc/self/fd walks, fstat link counts, and flock need raw descriptors, which a FileSystem File does not expose +import * as fs from "node:fs"; import { tmpdir } from "node:os"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0018: the descriptor walk resolves paths synchronously, where the Path service answers through an Effect import { basename, dirname, join, resolve } from "node:path"; -import { Predicate } from "effect"; +import { Effect, Predicate, Semaphore } from "effect"; +import { dual } from "effect/Function"; const lockLibrary = dlopen("libc.so.6", { flock: { @@ -26,25 +21,38 @@ const LOCK_EXCLUSIVE = 2; const LOCK_RELEASE = 8; -const projectionLockQueues = new Map>(); +/** One in-process queue per lock file: callers of this process take the lock in turn. */ +const projectionLockQueues = new Map(); + +const projectionLockQueue = (path: string): Semaphore.Semaphore => { + const known = projectionLockQueues.get(path); + + if (known !== undefined) return known; + + const created = Semaphore.makeUnsafe(1); + + projectionLockQueues.set(path, created); + + return created; +}; const acquireFileLock = (path: string, mode: "shared" | "exclusive"): (() => void) => { - const descriptor = openSync( + const descriptor = fs.openSync( path, - constants.O_RDWR | constants.O_CREAT | constants.O_NOFOLLOW, + fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600, ); if ( lockLibrary.symbols.flock(descriptor, mode === "shared" ? LOCK_SHARED : LOCK_EXCLUSIVE) !== 0 ) { - closeSync(descriptor); + fs.closeSync(descriptor); throw new Error(`cannot acquire projection lock: ${path}`); } return () => { const releaseFailed = lockLibrary.symbols.flock(descriptor, LOCK_RELEASE) !== 0; - closeSync(descriptor); + fs.closeSync(descriptor); if (releaseFailed) throw new Error(`cannot release projection lock: ${path}`); }; @@ -60,36 +68,65 @@ const projectionLockPath = (projectionDirectory: string): string => * Runs `operation` while holding an advisory `flock` on a per-directory lock file. * Callers in one process queue in order; other processes serialize through the kernel lock. */ -export const withProjectionFileLock = async ( - projectionDirectory: string, - mode: "shared" | "exclusive", - operation: () => Promise, -): Promise => { - const path = projectionLockPath(projectionDirectory); - const previous = projectionLockQueues.get(path) ?? Promise.resolve(); - const { promise: turn, resolve: advanceQueue } = Promise.withResolvers(); - - const queued = previous.then(() => turn); - projectionLockQueues.set(path, queued); - await previous; - let release: (() => void) | null = null; - - try { - release = acquireFileLock(path, mode); - - return await operation(); - } finally { - try { - release?.(); - } finally { - advanceQueue(); - - if (projectionLockQueues.get(path) === queued) projectionLockQueues.delete(path); - } - } -}; +export const projectionFileLock: { + ( + mode: "shared" | "exclusive", + operation: Effect.Effect, + ): (projectionDirectory: string) => Effect.Effect; + ( + projectionDirectory: string, + mode: "shared" | "exclusive", + operation: Effect.Effect, + ): Effect.Effect; +} = dual( + 3, + ( + projectionDirectory: string, + mode: "shared" | "exclusive", + operation: Effect.Effect, + ): Effect.Effect => { + const path = projectionLockPath(projectionDirectory); + + return Effect.acquireUseRelease( + Effect.sync(() => acquireFileLock(path, mode)), + () => operation, + (release) => Effect.sync(release), + ).pipe(projectionLockQueue(path).withPermits(1)); + }, +); -const noFollowReadFlags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; +/** + * The Promise form of `projectionFileLock`, for the browser drivers that are no Effect programs + * yet (EX-0017). + */ +export const withProjectionFileLock: { + ( + mode: "shared" | "exclusive", + operation: () => Promise, + ): (projectionDirectory: string) => Promise; + ( + projectionDirectory: string, + mode: "shared" | "exclusive", + operation: () => Promise, + ): Promise; +} = dual( + 3, + ( + projectionDirectory: string, + mode: "shared" | "exclusive", + operation: () => Promise, + ): Promise => + Effect.runPromise( + projectionFileLock( + projectionDirectory, + mode, + // A rejection of the operation dies with its original error, which the Promise rejects with. + Effect.promise(operation), + ), + ), +); + +const noFollowReadFlags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK; const isErrorWithCode = (cause: unknown, code: string): boolean => cause !== null && Predicate.isObjectOrArray(cause) && "code" in cause && cause.code === code; @@ -99,7 +136,7 @@ const isErrorWithCode = (cause: unknown, code: string): boolean => * With `create`, missing components become directories; a racing creator is tolerated. */ const openDirectoryPathNoFollow = (path: string, create = false): number => { - let descriptor = openSync("/", noFollowReadFlags | constants.O_DIRECTORY); + let descriptor = fs.openSync("/", noFollowReadFlags | fs.constants.O_DIRECTORY); const components = resolve(path) .split("/") @@ -111,26 +148,26 @@ const openDirectoryPathNoFollow = (path: string, create = false): number => { let next: number; try { - next = openSync(childPath, noFollowReadFlags | constants.O_DIRECTORY); + next = fs.openSync(childPath, noFollowReadFlags | fs.constants.O_DIRECTORY); } catch (cause) { if (!create || !isErrorWithCode(cause, "ENOENT")) throw cause; try { - mkdirSync(childPath); + fs.mkdirSync(childPath); } catch (mkdirCause) { if (!isErrorWithCode(mkdirCause, "EEXIST")) throw mkdirCause; } - next = openSync(childPath, noFollowReadFlags | constants.O_DIRECTORY); + next = fs.openSync(childPath, noFollowReadFlags | fs.constants.O_DIRECTORY); } - closeSync(descriptor); + fs.closeSync(descriptor); descriptor = next; } return descriptor; } catch (cause) { - closeSync(descriptor); + fs.closeSync(descriptor); throw new Error( `cannot open projection directory without following links: ${path}: ${ cause instanceof Error ? cause.message : "unknown filesystem error" @@ -144,27 +181,33 @@ const openDirectoryPathNoFollow = (path: string, create = false): number => { * Replaces the contents of `path` without following symlinks in any path component or the * final entry. Missing parent directories are created; hard-linked targets are refused. */ -export const writeFilePathNoFollow = (path: string, contents: string | Uint8Array): void => { +export const writeFilePathNoFollow: { + (contents: string | Uint8Array): (path: string) => void; + (path: string, contents: string | Uint8Array): void; +} = dual(2, (path: string, contents: string | Uint8Array): void => { const parent = openDirectoryPathNoFollow(dirname(path), true); try { - const descriptor = openSync( + const descriptor = fs.openSync( `/proc/self/fd/${parent}/${basename(path)}`, - constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_NOFOLLOW | + fs.constants.O_NONBLOCK, 0o600, ); try { - const metadata = fstatSync(descriptor); + const metadata = fs.fstatSync(descriptor); if (!metadata.isFile() || metadata.nlink !== 1) throw new Error(`unsupported or aliased projection evidence entry: ${path}`); - ftruncateSync(descriptor, 0); - writeFileSync(descriptor, contents); + fs.ftruncateSync(descriptor, 0); + fs.writeFileSync(descriptor, contents); } finally { - closeSync(descriptor); + fs.closeSync(descriptor); } } finally { - closeSync(parent); + fs.closeSync(parent); } -}; +}); diff --git a/tools/e2e/tsconfig.bun.json b/tools/e2e/tsconfig.bun.json index 6e06a074c..91e602228 100644 --- a/tools/e2e/tsconfig.bun.json +++ b/tools/e2e/tsconfig.bun.json @@ -3,6 +3,12 @@ "compilerOptions": { "types": ["node", "bun"] }, - "files": ["safe-file-io.ts"], + "files": [ + "safe-file-io.ts", + "golden-reimbursement.ts", + "golden-school-service-evidence.test.ts", + "placement-check.ts", + "run-real-native-recruitment-assignment.ts" + ], "include": [] } diff --git a/tools/e2e/tsconfig.json b/tools/e2e/tsconfig.json index 697535820..7dbe09585 100644 --- a/tools/e2e/tsconfig.json +++ b/tools/e2e/tsconfig.json @@ -17,6 +17,7 @@ "golden-team-application.ts", "golden-team-application-evidence.ts", "local-backend-environment.ts", - "journey-clock.ts" + "journey-clock.ts", + "golden-reimbursement-evidence.ts" ] } diff --git a/tools/oxlint/anti-slop/UPSTREAM.txt b/tools/oxlint/anti-slop/UPSTREAM.txt index 7d37b515f..cb2365dbd 100644 --- a/tools/oxlint/anti-slop/UPSTREAM.txt +++ b/tools/oxlint/anti-slop/UPSTREAM.txt @@ -13,3 +13,5 @@ These files are ours now. Change a rule when it stops matching how rat-stack wor - `rules/no-hand-rolled-postgres.ts` is ours and has no upstream counterpart. It rejects running the PostgreSQL cluster programs (`initdb`, `postgres`, `pg_ctl`, `createdb`) through `postgresProgram(...)`, by name or path in a spawn call or argument array, in a shell command line, or as a path joined for them, so every cluster starts through `startDisposablePostgres` in `tools/postgres`, which waits until the server accepts connections. `oxlint.config.ts` enables it everywhere. `rules/no-hand-rolled-postgres.test.ts` runs it through the Oxlint `RuleTester` under `node --test`, with negative controls. - `rules/no-git-history.ts` is ours and has no upstream counterpart. It rejects Git commands that read history beyond HEAD: a command that walks the commit graph (`merge-base`, `log`, `rev-list`, `describe`, and the like), an ancestor or reflog revision (`HEAD~1`, `HEAD^`, `@{1}`), a range (`main...HEAD`), or a full commit name, whether the program runs through a spawn call, an argument array, a shell command line, or a Git wrapper such as `readGitValue`. Hosted journeys run in a depth-1 checkout, where such a command fails (CI run 36233895862). `oxlint.config.ts` enables it for journey code: `apps/*/e2e/**`, `tools/e2e/**`, `tools/acceptance/**`, and `tools/verification/**`. `rules/no-git-history.test.ts` runs it through the Oxlint `RuleTester` under `node --test`, with negative controls. - `rules/no-port-probe.ts` is ours and has no upstream counterpart. It rejects a server that listens only to learn a free port and then closes: one server identifier that calls `listen`, reads `address()`, and calls `close` outside a `finally` block in one flow, where a function passed to `new Promise`, to a method of that server, or to `Effect.callback` or `Effect.async` belongs to the flow that passes it. The closed port is free to any later bind before the caller binds it, as in a batch run where the backend met EADDRINUSE on the port its runner had probed. Ports come from `reserveLoopbackPorts` in `tools/postgres` instead. A fixed-port availability check reads no address, and teardown in `finally` closes a server that served, so neither is reported. `oxlint.config.ts` enables it for journey code and its port construct: `apps/*/e2e/**`, `tools/e2e/**`, `tools/acceptance/**`, `tools/verification/**`, and `tools/postgres/**`. `rules/no-port-probe.test.ts` runs it through the Oxlint `RuleTester` under `node --test`, with negative controls. +- `rules/no-rpc-path-comparison.ts` is ours and has no upstream counterpart. It rejects comparing a value, with an equality operator or a `switch` case, with one spelling of an RPC endpoint path: `nativeRpcPath`, `internalNativeRpcPath`, `` `${nativeRpcPath}/` ``, or the literal paths with or without their trailing slash. The RPC client posts to the endpoint with one trailing slash and a probe without it, so such a check misses one of them, as five journey recorders did in the RPC migration. Code asks `isNativeRpcPath` or `isInternalNativeRpcPath` of `packages/rpc/src/api.ts`, which `oxlint.config.ts` exempts as their definition. `oxlint.config.ts` enables the rule everywhere. `rules/no-rpc-path-comparison.test.ts` runs it through the Oxlint `RuleTester` under `node --test`, with negative controls. +- `rules/no-unsettled-axe.ts` is ours and has no upstream counterpart. It rejects running axe outside `auditSettledPage` of `apps/dashboard/e2e/settled-axe.ts`: a `new AxeBuilder(...)`, and loading `@axe-core/playwright` by an import, a dynamic import, or a require call such as `requireDashboard("@axe-core/playwright")`. Axe reads computed colours, so a transition that still runs right after an interaction reads as its midpoint; the shared button is half transparent for 150 ms after it leaves the disabled state, and the onboarding and golden reimbursement journeys failed on `color-contrast` at random (2026-09-28). The construct waits for every finite animation before it runs axe. `oxlint.config.ts` enables the rule everywhere and exempts the construct's module. `rules/no-unsettled-axe.test.ts` runs it through the Oxlint `RuleTester` under `node --test`, with negative controls. diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts index 69a54e98f..16f3bb6f5 100644 --- a/tools/oxlint/anti-slop/index.ts +++ b/tools/oxlint/anti-slop/index.ts @@ -5,6 +5,7 @@ import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions. import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts"; import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts"; import { noDevServerRule } from "./rules/no-dev-server.ts"; +import { noRpcPathComparisonRule } from "./rules/no-rpc-path-comparison.ts"; import { noGitHistoryRule } from "./rules/no-git-history.ts"; import { noHandRolledPostgresRule } from "./rules/no-hand-rolled-postgres.ts"; import { noLeadershipReachRule } from "./rules/no-leadership-reach.ts"; @@ -22,6 +23,7 @@ import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-nam import { noUnknownParametersRule } from "./rules/no-unknown-parameters.ts"; import { noUnknownReturnsRule } from "./rules/no-unknown-returns.ts"; import { noUnkeyedCommandRowRule } from "./rules/no-unkeyed-command-row.ts"; +import { noUnsettledAxeRule } from "./rules/no-unsettled-axe.ts"; import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts"; import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts"; import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts"; @@ -38,6 +40,7 @@ const antiSlopPlugin = eslintCompatPlugin({ "no-chained-type-assertions": noChainedTypeAssertionsRule, "no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule, "no-dev-server": noDevServerRule, + "no-rpc-path-comparison": noRpcPathComparisonRule, "no-json-text-parameter": noJsonTextParameterRule, "no-git-history": noGitHistoryRule, "no-hand-rolled-postgres": noHandRolledPostgresRule, @@ -55,6 +58,7 @@ const antiSlopPlugin = eslintCompatPlugin({ "no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule, "no-unknown-parameters": noUnknownParametersRule, "no-unkeyed-command-row": noUnkeyedCommandRowRule, + "no-unsettled-axe": noUnsettledAxeRule, "no-unknown-returns": noUnknownReturnsRule, "no-unknown-type-aliases": noUnknownTypeAliasesRule, "no-widen-then-assert": noWidenThenAssertRule, diff --git a/tools/oxlint/anti-slop/provenance.json b/tools/oxlint/anti-slop/provenance.json index 72481d795..0ce800be5 100644 --- a/tools/oxlint/anti-slop/provenance.json +++ b/tools/oxlint/anti-slop/provenance.json @@ -15,6 +15,8 @@ "Added the repository-specific rule rules/no-leadership-reach.ts, its RuleTester test, and its registration in index.ts; UPSTREAM.txt records it.", "Added the repository-specific rule rules/no-hand-rolled-postgres.ts, its RuleTester test, and its registration in index.ts; UPSTREAM.txt records it.", "Added the repository-specific rule rules/no-git-history.ts, its RuleTester test, and its registration in index.ts; UPSTREAM.txt records it.", - "Added the repository-specific rule rules/no-port-probe.ts, its RuleTester test, and its registration in index.ts; UPSTREAM.txt records it." + "Added the repository-specific rule rules/no-port-probe.ts, its RuleTester test, and its registration in index.ts; UPSTREAM.txt records it.", + "Added the repository-specific rule rules/no-rpc-path-comparison.ts, its RuleTester test, and its registration in index.ts; UPSTREAM.txt records it.", + "Added the repository-specific rule rules/no-unsettled-axe.ts, its RuleTester test, and its registration in index.ts; UPSTREAM.txt records it." ] } diff --git a/tools/oxlint/anti-slop/rules/no-rpc-path-comparison.test.ts b/tools/oxlint/anti-slop/rules/no-rpc-path-comparison.test.ts new file mode 100644 index 000000000..f83e425dc --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-rpc-path-comparison.test.ts @@ -0,0 +1,54 @@ +// Run with `node --test`: the Oxlint RuleTester parses through raw transfer, which Bun lacks. +import { describe, it } from "node:test"; +import { RuleTester } from "oxlint/plugins-dev"; + +import { noRpcPathComparisonRule } from "./no-rpc-path-comparison.ts"; + +RuleTester.describe = describe; +RuleTester.it = it; + +const comparison = (endpoint: string) => [{ messageId: "rpcPathComparison", data: { endpoint } }]; + +new RuleTester().run("no-rpc-path-comparison", noRpcPathComparisonRule, { + valid: [ + // Negative controls: the predicates, building a URL from the constant, and other paths. + 'if (request.method === "POST" && isNativeRpcPath(url.pathname)) record(body)', + "const endpoint = new URL(nativeRpcPath, origin).href", + "const url = `${api}${nativeRpcPath}`", + 'if (pathname === "/health") return health()', + 'if (url.pathname === "/api/auth/sign-in/email") signIn()', + 'const rpc = pathname.startsWith("/api/")', + 'if (method === "POST") send()', + ], + invalid: [ + // The recorders of the RPC migration: the dashboard server posted to /api/rpc/. + { + code: 'const isRpc = method === "POST" && url.pathname === "/api/rpc"', + errors: comparison('"/api/rpc"'), + }, + { + code: "if (url.pathname === nativeRpcPath) record(body)", + errors: comparison("nativeRpcPath"), + }, + { + code: "if (nativeRpcPath !== url.pathname) return", + errors: comparison("nativeRpcPath"), + }, + { + code: "const internal = pathname === internalNativeRpcPath", + errors: comparison("internalNativeRpcPath"), + }, + { + code: 'if (path == "/api/rpc/") record(body)', + errors: comparison('"/api/rpc/"'), + }, + { + code: "const slashed = pathname === `${nativeRpcPath}/`", + errors: comparison("`${nativeRpcPath}/`"), + }, + { + code: "switch (url.pathname) { case nativeRpcPath: record(body); }", + errors: comparison("nativeRpcPath"), + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-rpc-path-comparison.ts b/tools/oxlint/anti-slop/rules/no-rpc-path-comparison.ts new file mode 100644 index 000000000..aabac4481 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-rpc-path-comparison.ts @@ -0,0 +1,72 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +// The constants of the RPC endpoint paths in `packages/rpc/src/api.ts`. +const endpointConstants = new Set(["nativeRpcPath", "internalNativeRpcPath"]); + +// The endpoint paths, with and without the trailing slash of the RPC client. +const endpointPaths = new Set(["/api/rpc", "/api/rpc/", "/internal/rpc", "/internal/rpc/"]); + +const equality = new Set(["===", "!==", "==", "!="]); + +function staticText(node: ESTree.Node): string | null { + if (node.type === "Literal") return typeof node.value === "string" ? node.value : null; + if (node.type === "TemplateLiteral" && node.expressions.length === 0) { + return node.quasis[0]?.value.cooked ?? null; + } + return null; +} + +/** The endpoint that one operand names: an endpoint constant, its path, or `${constant}/`. */ +function endpointOperand(node: ESTree.Node): string | null { + if (node.type === "Identifier" && endpointConstants.has(node.name)) return node.name; + const text = staticText(node); + if (text !== null && endpointPaths.has(text)) return JSON.stringify(text); + if ( + node.type === "TemplateLiteral" && + node.expressions.length === 1 && + node.expressions[0]?.type === "Identifier" && + endpointConstants.has(node.expressions[0].name) + ) { + return `\`\${${node.expressions[0].name}}/\``; + } + return null; +} + +/** + * Reject comparing a request path with one spelling of an RPC endpoint path. The RPC client posts + * to the endpoint with one trailing slash (`/api/rpc/`), and a probe posts without it, so a check + * that matches one spelling silently misses the other: five journey recorders recorded no RPC of + * the dashboard server and failed or proved nothing (the RPC migration, 2026-09-28). Ask + * `isNativeRpcPath` or `isInternalNativeRpcPath` of `@vektorprogrammet/rpc`. + */ +export const noRpcPathComparisonRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow comparing a path with one spelling of an RPC endpoint path; the RPC client adds a trailing slash.", + }, + messages: { + rpcPathComparison: + "Comparing a path with {{endpoint}} misses the other spelling of the RPC endpoint: the RPC client posts with a trailing slash, a probe without. Ask `isNativeRpcPath` or `isInternalNativeRpcPath` of `@vektorprogrammet/rpc`.", + }, + }, + createOnce(context) { + return { + BinaryExpression(node) { + if (!equality.has(node.operator)) return; + const endpoint = endpointOperand(node.left) ?? endpointOperand(node.right); + if (endpoint === null) return; + context.report({ node, messageId: "rpcPathComparison", data: { endpoint } }); + }, + SwitchCase(node) { + // case nativeRpcPath: compares the discriminant with one spelling. + if (node.test === null) return; + const endpoint = endpointOperand(node.test); + if (endpoint === null) return; + context.report({ node, messageId: "rpcPathComparison", data: { endpoint } }); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unsettled-axe.test.ts b/tools/oxlint/anti-slop/rules/no-unsettled-axe.test.ts new file mode 100644 index 000000000..da9e0a0e1 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsettled-axe.test.ts @@ -0,0 +1,57 @@ +// Run with `node --test`: the Oxlint RuleTester parses through raw transfer, which Bun lacks. +import { describe, it } from "node:test"; +import { RuleTester } from "oxlint/plugins-dev"; + +import { noUnsettledAxeRule } from "./no-unsettled-axe.ts"; + +RuleTester.describe = describe; +RuleTester.it = it; + +const unsettled = [{ messageId: "unsettledAxe" }]; + +new RuleTester().run("no-unsettled-axe", noUnsettledAxeRule, { + valid: [ + // Negative controls: the construct, other builders, other packages, and the name as text. + 'import { auditSettledPage } from "./settled-axe.ts"', + "expect(await auditSettledPage(page)).toEqual([])", + 'const violations = await auditSettledPage(page, { tags: ["wcag2a", "wcag2aa"] })', + 'const { chromium } = requireDashboard("@playwright/test")', + 'import { expect, test } from "@playwright/test"', + "const builder = new StringBuilder()", + "const audit = new Audit({ page })", + 'const label = "AxeBuilder"', + 'const name = "@axe-core/playwright is audited by auditSettledPage"', + ], + invalid: [ + // The onboarding spec of 2026-09-28: axe right after the submit button left its disabled state. + { + code: 'import AxeBuilder from "@axe-core/playwright"', + errors: unsettled, + }, + { + code: "const result = await new AxeBuilder({ page }).analyze()", + errors: unsettled, + }, + { + code: 'const audit = await new AxeBuilder({ page }).withTags(["wcag2a"]).include("main").analyze()', + errors: unsettled, + }, + // The acceptance tools load the package through a require of the dashboard. + { + code: 'const AxeBuilder = requireDashboard("@axe-core/playwright").default', + errors: unsettled, + }, + { + code: 'const Axe = require(`@axe-core/playwright`).default; const r = await new Axe({ page }).analyze()', + errors: unsettled, + }, + { + code: 'const { default: Axe } = await import("@axe-core/playwright")', + errors: unsettled, + }, + { + code: "const result = await new axe.AxeBuilder({ page }).analyze()", + errors: unsettled, + }, + ], +}); diff --git a/tools/oxlint/anti-slop/rules/no-unsettled-axe.ts b/tools/oxlint/anti-slop/rules/no-unsettled-axe.ts new file mode 100644 index 000000000..0d219e42b --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsettled-axe.ts @@ -0,0 +1,64 @@ +import { defineRule } from "@oxlint/plugins"; +import type { ESTree } from "@oxlint/plugins"; + +// The axe integration of Playwright, which only the settled-audit construct loads. +const axePackage = "@axe-core/playwright"; + +function isAxePackage(node: ESTree.Node | null | undefined): boolean { + if (node === null || node === undefined) return false; + if (node.type === "Literal") return node.value === axePackage; + if (node.type === "TemplateLiteral" && node.expressions.length === 0) { + return node.quasis[0]?.value.cooked === axePackage; + } + return false; +} + +/** Whether a constructed callee names the axe builder: `AxeBuilder` or `module.AxeBuilder`. */ +function isAxeBuilder(node: ESTree.Node): boolean { + if (node.type === "Identifier") return node.name === "AxeBuilder"; + if (node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier") { + return node.property.name === "AxeBuilder"; + } + return false; +} + +/** + * Reject running axe outside `auditSettledPage` of `apps/dashboard/e2e/settled-axe.ts`: constructing + * `AxeBuilder`, and loading `@axe-core/playwright` by import, dynamic import, or a require call. + * Axe reads computed colours, so a transition that still runs right after an interaction reads as + * its midpoint: the shared button is half transparent for 150 ms after it leaves the disabled + * state, and the onboarding and golden reimbursement journeys failed on `color-contrast` at random + * (2026-09-28). The construct waits for every finite animation before it runs axe. + */ +export const noUnsettledAxeRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow running axe outside auditSettledPage, which audits a page once its animations have settled.", + }, + messages: { + unsettledAxe: + "An axe audit right after an interaction reads a running transition as its midpoint colours. Audit through `auditSettledPage` of `apps/dashboard/e2e/settled-axe.ts`, which waits for every animation first.", + }, + }, + createOnce(context) { + return { + NewExpression(node) { + if (isAxeBuilder(node.callee)) context.report({ node, messageId: "unsettledAxe" }); + }, + ImportDeclaration(node) { + if (isAxePackage(node.source)) context.report({ node, messageId: "unsettledAxe" }); + }, + ImportExpression(node) { + if (isAxePackage(node.source)) context.report({ node, messageId: "unsettledAxe" }); + }, + CallExpression(node) { + // require("@axe-core/playwright"), or a createRequire function of another workspace. + if (node.arguments.some((argument) => isAxePackage(argument))) { + context.report({ node, messageId: "unsettledAxe" }); + } + }, + }; + }, +}); diff --git a/tools/placements-docs/package.json b/tools/placements-docs/package.json index 88440d981..4022e7c52 100644 --- a/tools/placements-docs/package.json +++ b/tools/placements-docs/package.json @@ -11,6 +11,7 @@ "docs:accept": "bun --no-env-file placements.ts accept" }, "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", "effect": "catalog:effect-v4" }, "devDependencies": { diff --git a/tools/placements-docs/placements-artifact.mjs b/tools/placements-docs/placements-artifact.mjs deleted file mode 100644 index faa14178c..000000000 --- a/tools/placements-docs/placements-artifact.mjs +++ /dev/null @@ -1,99 +0,0 @@ -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { lstatSync, realpathSync } from "node:fs"; -import { lstat, readFile, readdir } from "node:fs/promises"; -import { isAbsolute, relative, resolve, sep } from "node:path"; - -export const receiptName = "source-receipt.json"; - -export function git(root, ...args) { - return execFileSync("git", ["-C", root, ...args], { encoding: "utf8" }).trim(); -} - -export function cleanRevision(root, expected) { - assert(/^[a-f0-9]{40}$/.test(expected ?? ""), "An exact expected Git revision is required"); - assert.equal(git(root, "rev-parse", "HEAD"), expected, "Source revision does not match"); - assert.equal( - git(root, "status", "--porcelain", "--untracked-files=all"), - "", - "Source checkout must be clean", - ); - - return expected; -} - -export function publicFile(root, tracked, file) { - const absolute = resolve(file); - const path = relative(root, absolute); - assert( - tracked.has(path) && !path.split(sep).some((part) => part.startsWith(".")), - "Documentation input must be tracked public source: " + path, - ); - assert( - lstatSync(absolute).isFile() && realpathSync(absolute) === absolute, - "Documentation input must not traverse a symlink: " + path, - ); - - return path; -} - -export async function outsideOutput(root, destination) { - const output = resolve(destination); - // Resolve the parent even when the output does not exist yet. - const canonical = resolve(realpathSync(resolve(output, "..")), output.split(sep).at(-1)); - const fromRoot = relative(realpathSync(root), canonical); - assert( - fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot), - "Documentation output must stay outside the repository and its parent", - ); - assert.equal(canonical, output, "Documentation output must not traverse a symlink"); - - return output; -} - -export async function inventory(directory) { - assert( - (await lstat(directory)).isDirectory(), - "Documentation output must be a regular directory", - ); - const result = Object.create(null); - - for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => - a.name.localeCompare(b.name), - )) { - const path = resolve(directory, entry.name); - - if (entry.isDirectory()) { - for (const [child, digest] of Object.entries(await inventory(path))) - result[`${entry.name}/${child}`] = digest; - } else { - assert(entry.isFile(), "Documentation output must contain only regular files"); - result[entry.name] = createHash("sha256") - .update(await readFile(path)) - .digest("hex"); - } - } - - return { ...result }; -} - -export async function acceptArtifact(root, output, expected) { - cleanRevision(root, expected); - const actual = await inventory(output); - assert(actual[receiptName] && actual["index.html"], "Complete documentation output is missing"); - const receipt = JSON.parse(await readFile(resolve(output, receiptName), "utf8")); - assert.equal(receipt.format, 1, "Unknown documentation receipt format"); - assert.equal(receipt.status, "complete", "Documentation did not complete"); - assert.equal(receipt.revision, expected, "Retained documentation has a stale source revision"); - assert.equal( - receipt.sourceUrl, - `https://github.com/vektorprogrammet/mono-web/blob/${expected}/{path}#L{line}`, - "Documentation source URL is not revision-bound", - ); - delete actual[receiptName]; - assert.deepEqual(receipt.files, actual, "Retained documentation inventory or content changed"); - cleanRevision(root, expected); - - return receipt; -} diff --git a/tools/placements-docs/placements-artifact.test.mjs b/tools/placements-docs/placements-artifact.test.mjs deleted file mode 100644 index d0fd19d89..000000000 --- a/tools/placements-docs/placements-artifact.test.mjs +++ /dev/null @@ -1,248 +0,0 @@ -import { test } from "bun:test"; -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { resolve } from "node:path"; -import { - acceptArtifact, - git, - inventory, - outsideOutput, - publicFile, - receiptName, -} from "./placements-artifact.mjs"; -import { runDocumentationCommand } from "./placements-process.mjs"; -import { setTimeout } from "node:timers/promises"; - -async function waitForPid(path) { - const deadline = Date.now() + 3000; - - do { - try { - return Number(await readFile(path, "utf8")); - } catch (error) { - if (error.code !== "ENOENT") throw error; - } - - await setTimeout(20); - } while (Date.now() < deadline); - - throw new Error("Documentation process fixture did not start"); -} - -test("interruption stops a subprocess that ignores TERM", async () => { - const temporary = await mkdtemp(resolve(tmpdir(), "placements-process-test-")); - const controller = new AbortController(); - const pidFile = resolve(temporary, "pid"); - const reason = new Error("requested interruption"); - const program = `process.on("SIGTERM", () => {}); require("node:fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); setInterval(() => {}, 1000);`; - let pid; - const running = runDocumentationCommand("node", ["-e", program], temporary, controller.signal); - - const result = running.then( - () => undefined, - (error) => error, - ); - - try { - pid = await waitForPid(pidFile); - controller.abort(reason); - assert.equal(await result, reason); - assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }); - } finally { - controller.abort(reason); - await result; - - if (pid) { - try { - process.kill(pid, "SIGKILL"); - } catch {} - } - - await rm(temporary, { recursive: true, force: true }); - } -}, 8000); - -test("leader failure preserves its exit code and stops TERM-ignoring descendants", async () => { - const temporary = await mkdtemp(resolve(tmpdir(), "placements-process-test-")); - const pidFile = resolve(temporary, "pid"); - const descendant = `process.on("SIGTERM", () => {}); require("node:fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); setInterval(() => {}, 1000);`; - const leader = `require("node:child_process").spawn(process.execPath, ["-e", ${JSON.stringify(descendant)}], {stdio: "ignore"}).unref(); const timer = setInterval(() => { if(require("node:fs").existsSync(${JSON.stringify(pidFile)})) process.exit(7); }, 10);`; - let pid; - - try { - const result = await runDocumentationCommand( - "node", - ["-e", leader], - temporary, - new AbortController().signal, - ).then( - () => undefined, - (error) => error, - ); - - assert.equal(result.actual, 7); - pid = await waitForPid(pidFile); - assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }); - } finally { - if (pid) { - try { - process.kill(pid, "SIGKILL"); - } catch {} - } - - await rm(temporary, { recursive: true, force: true }); - } -}, 8000); - -async function fixture(run) { - const temporary = await mkdtemp(resolve(tmpdir(), "placements-artifact-test-")); - const root = resolve(temporary, "source"); - const output = resolve(temporary, "output"); - - try { - await mkdir(root); - await mkdir(output); - git(root, "init", "--quiet"); - await writeFile(resolve(root, "guide.md"), "Public guide\n"); - git(root, "add", "guide.md"); - git( - root, - "-c", - "user.name=Documentation test", - "-c", - "user.email=docs@example.invalid", - "commit", - "--quiet", - "-m", - "source", - ); - const revision = git(root, "rev-parse", "HEAD"); - await writeFile(resolve(output, "index.html"), "

Public reference

"); - await mkdir(resolve(output, "media")); - await writeFile(resolve(output, "media/guide.md"), "Public guide\n"); - await writeFile( - resolve(output, receiptName), - JSON.stringify({ - format: 1, - status: "complete", - revision, - sourceUrl: `https://github.com/vektorprogrammet/mono-web/blob/${revision}/{path}#L{line}`, - files: await inventory(output), - }), - ); - await run({ root, output, revision, temporary }); - } finally { - await rm(temporary, { recursive: true, force: true }); - } -} - -test("a retained artifact rejects changed copied media, missing output, and extra files", () => - fixture(async ({ root, output, revision }) => { - await acceptArtifact(root, output, revision); - await writeFile(resolve(output, "media/guide.md"), "Changed public guide\n"); - await assert.rejects(acceptArtifact(root, output, revision)); - await writeFile(resolve(output, "media/guide.md"), "Public guide\n"); - await writeFile(resolve(output, "__proto__"), "Unreviewed content"); - await assert.rejects(acceptArtifact(root, output, revision)); - await rm(resolve(output, "__proto__")); - await rm(resolve(output, "index.html")); - await assert.rejects(acceptArtifact(root, output, revision)); - })); - -test("a retained artifact rejects dirty source and a newer clean public guide revision", () => - fixture(async ({ root, output, revision }) => { - await writeFile(resolve(root, "guide.md"), "Changed public guide\n"); - await assert.rejects(acceptArtifact(root, output, revision)); - git(root, "add", "guide.md"); - git( - root, - "-c", - "user.name=Documentation test", - "-c", - "user.email=docs@example.invalid", - "commit", - "--quiet", - "-m", - "changed guide", - ); - const changed = git(root, "rev-parse", "HEAD"); - await assert.rejects(acceptArtifact(root, output, changed)); - await assert.rejects(acceptArtifact(root, output, revision)); - })); - -test("missing and incomplete receipts cannot authorize consumption", () => - fixture(async ({ root, output, revision }) => { - const receipt = JSON.parse(await readFile(resolve(output, receiptName), "utf8")); - await writeFile( - resolve(output, receiptName), - JSON.stringify({ ...receipt, status: "interrupted" }), - ); - await assert.rejects(acceptArtifact(root, output, revision)); - await rm(resolve(output, receiptName)); - await assert.rejects(acceptArtifact(root, output, revision)); - await assert.rejects(acceptArtifact(root, output, undefined)); - })); - -test("symlinks cannot substitute artifact files, public inputs, or output parents", () => - fixture(async ({ root, output, revision, temporary }) => { - await rm(resolve(output, "media/guide.md")); - await symlink(resolve(root, "guide.md"), resolve(output, "media/guide.md")); - await assert.rejects(acceptArtifact(root, output, revision)); - await symlink(resolve(root, "guide.md"), resolve(root, "linked.md")); - assert.throws(() => publicFile(root, new Set(["linked.md"]), resolve(root, "linked.md"))); - await symlink(root, resolve(temporary, "alias")); - await assert.rejects(outsideOutput(root, resolve(temporary, "alias/generated"))); - await assert.rejects(outsideOutput(root, temporary)); - })); - -test("generated API source links resolve to actual repository source", async () => { - const root = fileURLToPath(new URL("../../", import.meta.url)); - const temporary = await mkdtemp(resolve(tmpdir(), "placements-source-link-test-")); - const output = resolve(temporary, "guide"); - - try { - const rendered = spawnSync( - process.execPath, - ["--no-env-file", "tools/placements-docs/placements.ts", "generate", output], - { cwd: root, encoding: "utf8", timeout: 30000 }, - ); - - assert.equal(rendered.status, 0, rendered.stdout + rendered.stderr); - const links = new Set(); - - for (const path of Object.keys(await inventory(output)).filter((path) => - path.endsWith(".html"), - )) { - await new HTMLRewriter() - .on("a[href]", { - element(element) { - const href = element.getAttribute("href"); - - if (href?.startsWith("file:")) links.add(href); - }, - }) - .transform(new Response(await readFile(resolve(output, path)))) - .arrayBuffer(); - } - - assert( - [...links].some((href) => href.includes("/packages/domain/src/placements/service.ts#L")), - "The public service has no source link", - ); - - for (const href of links) { - const url = new URL(href); - const line = Number(url.hash.slice(2)); - const source = await readFile(fileURLToPath(url), "utf8"); - assert( - Number.isInteger(line) && line > 0 && line <= source.split("\n").length, - "Source link points outside its file", - ); - } - } finally { - await rm(temporary, { recursive: true, force: true }); - } -}, 40000); diff --git a/tools/placements-docs/placements-artifact.test.ts b/tools/placements-docs/placements-artifact.test.ts new file mode 100644 index 000000000..32c352a83 --- /dev/null +++ b/tools/placements-docs/placements-artifact.test.ts @@ -0,0 +1,301 @@ +import { expect, test } from "bun:test"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { Clock, Duration, Effect, Exit, Fiber, FileSystem, Option, Path, Schema, Scope } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { + acceptArtifact, + git, + inventory, + outsideOutput, + publicFile, + readPublicSource, + ReceiptJson, + receiptName, +} from "./placements-artifact.js"; +import { runDocumentationCommand } from "./placements-process.js"; +import { PlacementsDocsPlatform } from "./placements.js"; + +const run = (effect: Effect.Effect): Promise
=> + Effect.runPromise(effect.pipe(Effect.scoped, Effect.provide(PlacementsDocsPlatform))); + +const temporaryDirectory = Effect.fnUntraced(function* (prefix: string) { + const fileSystem = yield* FileSystem.FileSystem; + + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); +}); + +// Whether a process with this id exists; ESRCH means that it does not. +const alive = (pid: number) => { + try { + process.kill(pid, 0); + + return true; + } catch { + return false; + } +}; + +const killQuietly = (pid: number | undefined) => + Effect.sync(() => { + if (pid === undefined) return; + + try { + process.kill(pid, "SIGKILL"); + } catch { + // It has exited. + } + }); + +const waitForPid = Effect.fnUntraced(function* (file: string) { + const fileSystem = yield* FileSystem.FileSystem; + const deadline = (yield* Clock.currentTimeMillis) + 3000; + + do { + const text = yield* Effect.option(fileSystem.readFileString(file)); + + if (Option.isSome(text) && text.value !== "") return Number(text.value); + + yield* Effect.sleep(Duration.millis(20)); + } while ((yield* Clock.currentTimeMillis) < deadline); + + return yield* Effect.die("Documentation process fixture did not start"); +}); + +const jsonString = Schema.encodeSync(Schema.fromJsonString(Schema.String)); + +test("interruption stops a subprocess that ignores TERM", () => + run( + Effect.gen(function* () { + const path = yield* Path.Path; + const temporary = yield* temporaryDirectory("placements-process-test-"); + const pidFile = path.resolve(temporary, "pid"); + const program = `process.on("SIGTERM", () => {}); require("node:fs").writeFileSync(${jsonString(pidFile)}, String(process.pid)); setInterval(() => {}, 1000);`; + + const running = yield* Effect.forkChild( + runDocumentationCommand({ command: "node", args: ["-e", program], cwd: temporary }), + ); + + const pid = yield* waitForPid(pidFile); + + yield* Effect.addFinalizer(() => killQuietly(pid)); + + const exit = yield* Fiber.interrupt(running).pipe(Effect.andThen(Fiber.await(running))); + + expect(Exit.hasInterrupts(exit)).toBe(true); + expect(alive(pid)).toBe(false); + }), + ), 8000); + +test("leader failure preserves its exit code and stops TERM-ignoring descendants", () => + run( + Effect.gen(function* () { + const path = yield* Path.Path; + const temporary = yield* temporaryDirectory("placements-process-test-"); + const pidFile = path.resolve(temporary, "pid"); + const descendant = `process.on("SIGTERM", () => {}); require("node:fs").writeFileSync(${jsonString(pidFile)}, String(process.pid)); setInterval(() => {}, 1000);`; + const leader = `require("node:child_process").spawn(process.execPath, ["-e", ${jsonString(descendant)}], {stdio: "ignore"}).unref(); const timer = setInterval(() => { if(require("node:fs").existsSync(${jsonString(pidFile)})) process.exit(7); }, 10);`; + + const failure = yield* Effect.flip( + runDocumentationCommand({ command: "node", args: ["-e", leader], cwd: temporary }), + ); + + expect(failure.exitCode).toBe(7); + + const pid = yield* waitForPid(pidFile); + + yield* Effect.addFinalizer(() => killQuietly(pid)); + + expect(alive(pid)).toBe(false); + }), + ), 8000); + +interface Fixture { + readonly root: string; + readonly output: string; + readonly revision: string; + readonly temporary: string; +} + +const commitAs = ["-c", "user.name=Documentation test", "-c", "user.email=docs@example.invalid"]; + +const fixture = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporary = yield* temporaryDirectory("placements-artifact-test-"); + const root = path.resolve(temporary, "source"); + const output = path.resolve(temporary, "output"); + + yield* fileSystem.makeDirectory(root); + yield* fileSystem.makeDirectory(output); + yield* git(root, "init", "--quiet"); + yield* fileSystem.writeFileString(path.resolve(root, "guide.md"), "Public guide\n"); + yield* git(root, "add", "guide.md"); + yield* git(root, ...commitAs, "commit", "--quiet", "-m", "source"); + + const revision = yield* git(root, "rev-parse", "HEAD"); + + yield* fileSystem.writeFileString(path.resolve(output, "index.html"), "

Public reference

"); + yield* fileSystem.makeDirectory(path.resolve(output, "media")); + yield* fileSystem.writeFileString(path.resolve(output, "media/guide.md"), "Public guide\n"); + + yield* fileSystem.writeFileString( + path.resolve(output, receiptName), + yield* Schema.encodeEffect(ReceiptJson)({ + format: 1, + status: "complete", + revision, + sourceUrl: `https://github.com/vektorprogrammet/mono-web/blob/${revision}/{path}#L{line}`, + files: yield* inventory(output), + }), + ); + + return { root, output, revision, temporary } satisfies Fixture; +}); + +const rejects = (effect: Effect.Effect) => + Effect.map(Effect.exit(effect), (exit) => expect(Exit.isFailure(exit)).toBe(true)); + +test("a retained artifact rejects changed copied media, missing output, and extra files", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { root, output, revision } = yield* fixture; + const accept = acceptArtifact({ root, output, expected: revision }); + + yield* accept; + yield* fileSystem.writeFileString(path.resolve(output, "media/guide.md"), "Changed public guide\n"); + yield* rejects(accept); + yield* fileSystem.writeFileString(path.resolve(output, "media/guide.md"), "Public guide\n"); + yield* fileSystem.writeFileString(path.resolve(output, "__proto__"), "Unreviewed content"); + yield* rejects(accept); + yield* fileSystem.remove(path.resolve(output, "__proto__")); + yield* fileSystem.remove(path.resolve(output, "index.html")); + yield* rejects(accept); + }), + )); + +test("a retained artifact rejects dirty source and a newer clean public guide revision", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { root, output, revision } = yield* fixture; + + yield* fileSystem.writeFileString(path.resolve(root, "guide.md"), "Changed public guide\n"); + yield* rejects(acceptArtifact({ root, output, expected: revision })); + yield* git(root, "add", "guide.md"); + yield* git(root, ...commitAs, "commit", "--quiet", "-m", "changed guide"); + + const changed = yield* git(root, "rev-parse", "HEAD"); + + yield* rejects(acceptArtifact({ root, output, expected: changed })); + yield* rejects(acceptArtifact({ root, output, expected: revision })); + }), + )); + +test("missing and incomplete receipts cannot authorize consumption", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { root, output, revision } = yield* fixture; + const receiptFile = path.resolve(output, receiptName); + + const receipt = yield* fileSystem + .readFileString(receiptFile) + .pipe(Effect.flatMap(Schema.decodeEffect(ReceiptJson))); + + yield* fileSystem.writeFileString( + receiptFile, + yield* Schema.encodeEffect(ReceiptJson)({ ...receipt, status: "interrupted" }), + ); + + yield* rejects(acceptArtifact({ root, output, expected: revision })); + yield* fileSystem.remove(receiptFile); + yield* rejects(acceptArtifact({ root, output, expected: revision })); + yield* rejects(acceptArtifact({ root, output, expected: undefined })); + }), + )); + +test("symlinks cannot substitute artifact files, public inputs, or output parents", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { root, output, revision, temporary } = yield* fixture; + + yield* fileSystem.remove(path.resolve(output, "media/guide.md")); + yield* fileSystem.symlink(path.resolve(root, "guide.md"), path.resolve(output, "media/guide.md")); + yield* rejects(acceptArtifact({ root, output, expected: revision })); + yield* fileSystem.symlink(path.resolve(root, "guide.md"), path.resolve(root, "linked.md")); + + const source = yield* readPublicSource({ root, tracked: ["linked.md"] }); + + expect(() => publicFile(source, path.resolve(root, "linked.md"))).toThrow( + "must not traverse a symlink", + ); + + yield* fileSystem.symlink(root, path.resolve(temporary, "alias")); + yield* rejects(outsideOutput({ root, destination: path.resolve(temporary, "alias/generated") })); + yield* rejects(outsideOutput({ root, destination: temporary })); + }), + )); + +test("generated API source links resolve to actual repository source", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = fileURLToPath(new URL("../../", import.meta.url)); + const temporary = yield* temporaryDirectory("placements-source-link-test-"); + const output = path.resolve(temporary, "guide"); + + const rendered = yield* spawner + .exitCode( + ChildProcess.make( + process.execPath, + ["--no-env-file", "tools/placements-docs/placements.ts", "generate", output], + { cwd: root, stdin: "ignore", stdout: "inherit", stderr: "inherit" }, + ), + ) + .pipe(Effect.timeout(Duration.seconds(30))); + + expect(Number(rendered)).toBe(0); + + const links = new Set(); + + for (const file of Object.keys(yield* inventory(output)).filter((file) => + file.endsWith(".html"), + )) { + const html = yield* fileSystem.readFile(path.resolve(output, file)); + + yield* Effect.promise(() => + new HTMLRewriter() + .on("a[href]", { + element(element) { + const href = element.getAttribute("href"); + + if (href?.startsWith("file:") === true) links.add(href); + }, + }) + .transform(new Response(html)) + .arrayBuffer(), + ); + } + + expect( + [...links].some((href) => href.includes("/packages/domain/src/placements/service.ts#L")), + ).toBe(true); + + for (const href of links) { + const url = new URL(href); + const line = Number(url.hash.slice(2)); + const source = yield* fileSystem.readFileString(fileURLToPath(url)); + + expect(Number.isInteger(line) && line > 0 && line <= source.split("\n").length).toBe(true); + } + }), + ), 40000); diff --git a/tools/placements-docs/placements-artifact.ts b/tools/placements-docs/placements-artifact.ts new file mode 100644 index 000000000..194fe1b81 --- /dev/null +++ b/tools/placements-docs/placements-artifact.ts @@ -0,0 +1,326 @@ +/** + * The checks of the Placements documentation: its inputs are tracked public source, its output + * stays outside the repository, and a retained artifact matches its receipt and source revision. + */ +import { createHash } from "node:crypto"; +import { Data, Effect, FileSystem, Option, Path, Schema, Stream } from "effect"; +import { dual } from "effect/Function"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export const receiptName = "source-receipt.json"; + +/** A documentation input, output, or artifact that the checks reject. */ +export class PlacementsDocsFailure extends Data.TaggedError("PlacementsDocsFailure")<{ + readonly message: string; +}> {} + +/** Fails with `message` unless `condition` holds. */ +export const ensure: { + (message: string): (condition: boolean) => Effect.Effect; + (condition: boolean, message: string): Effect.Effect; +} = dual( + 2, + (condition: boolean, message: string): Effect.Effect => + condition ? Effect.void : Effect.fail(new PlacementsDocsFailure({ message })), +); + +/** The trimmed standard output of `git -C root ...args`. */ +export const git = ( + root: string, + ...args: ReadonlyArray +): Effect.Effect => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const [stdout, stderr, status] = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn( + ChildProcess.make("git", ["-C", root, ...args], { stdin: "ignore" }), + ); + + return yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); + }), + ).pipe( + Effect.mapError( + (error) => new PlacementsDocsFailure({ message: `git ${args.join(" ")}: ${error.message}` }), + ), + ); + + if (status !== 0) + return yield* new PlacementsDocsFailure({ + message: `git ${args.join(" ")} failed: ${stderr.trim()}`, + }); + + return stdout.trim(); + }); + +/** The expected revision, when the checkout is clean at exactly that revision. */ +export const cleanRevision = ({ + root, + expected, +}: { + readonly root: string; + readonly expected: string | undefined; +}): Effect.Effect => + Effect.gen(function* () { + if (expected === undefined || !/^[a-f0-9]{40}$/.test(expected)) + return yield* new PlacementsDocsFailure({ + message: "An exact expected Git revision is required", + }); + + yield* ensure((yield* git(root, "rev-parse", "HEAD")) === expected, "Source revision does not match"); + + yield* ensure( + (yield* git(root, "status", "--porcelain", "--untracked-files=all")) === "", + "Source checkout must be clean", + ); + + return expected; + }); + +/** + * The tracked files of a checkout, with whether each is a regular file that no symbolic link + * leads to, read once so that TypeDoc's synchronous input hook can check an input. + */ +export interface PublicSource { + readonly root: string; + readonly path: Path.Path; + /** Tracked paths relative to the root, each with whether it is a regular file on its own path. */ + readonly files: ReadonlyMap; +} + +/** Reads whether each tracked path of `root` is a regular file on its own path. */ +export const readPublicSource = ({ + root, + tracked, +}: { + readonly root: string; + /** Paths relative to the root, as `git ls-files` lists them. */ + readonly tracked: ReadonlyArray; +}): Effect.Effect< + PublicSource, + PlacementsDocsFailure, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const entries = yield* Effect.forEach( + tracked, + (file) => + Effect.gen(function* () { + const absolute = path.resolve(root, file); + const link = yield* Effect.option(fileSystem.readLink(absolute)); + const info = yield* Effect.option(fileSystem.stat(absolute)); + const real = yield* Effect.option(fileSystem.realPath(absolute)); + + const regular = + Option.isNone(link) && + Option.isSome(info) && + info.value.type === "File" && + Option.isSome(real) && + real.value === absolute; + + return [file, regular] as const; + }), + { concurrency: 16 }, + ); + + return { root, path, files: new Map(entries) }; + }); + +/** + * The path of `file` relative to the root, when it is tracked public source: no part starts with + * a dot, and it is a regular file on its own path. Throws otherwise, inside TypeDoc's hook. + */ +export const publicFile: { + (file: string): (source: PublicSource) => string; + (source: PublicSource, file: string): string; +} = dual(2, (source: PublicSource, file: string): string => { + const absolute = source.path.resolve(file); + const relative = source.path.relative(source.root, absolute); + + if ( + !source.files.has(relative) || + relative.split(source.path.sep).some((part) => part.startsWith(".")) + ) + throw new PlacementsDocsFailure({ + message: "Documentation input must be tracked public source: " + relative, + }); + + if (source.files.get(relative) !== true) + throw new PlacementsDocsFailure({ + message: "Documentation input must not traverse a symlink: " + relative, + }); + + return relative; +}); + +/** The output directory, when it stays outside the repository and its parent, without links. */ +export const outsideOutput = ({ + root, + destination, +}: { + readonly root: string; + readonly destination: string; +}): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const output = path.resolve(destination); + + const realPath = (file: string) => + Effect.mapError( + fileSystem.realPath(file), + (error) => new PlacementsDocsFailure({ message: error.message }), + ); + + // Resolve the parent even when the output does not exist yet. + const canonical = path.resolve( + yield* realPath(path.resolve(output, "..")), + output.split(path.sep).at(-1) ?? "", + ); + + const fromRoot = path.relative(yield* realPath(root), canonical); + + yield* ensure( + fromRoot.startsWith(`..${path.sep}`) || path.isAbsolute(fromRoot), + "Documentation output must stay outside the repository and its parent", + ); + + yield* ensure(canonical === output, "Documentation output must not traverse a symlink"); + + return output; + }); + +/** The SHA-256 of every file below `directory`, which must hold only directories and files. */ +export const inventory = ( + directory: string, +): Effect.Effect, PlacementsDocsFailure, FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const kind = (file: string) => + Effect.gen(function* () { + if (Option.isSome(yield* Effect.option(fileSystem.readLink(file)))) return "SymbolicLink"; + + const info = yield* Effect.option(fileSystem.stat(file)); + + return Option.isSome(info) ? info.value.type : "Unknown"; + }); + + yield* ensure( + (yield* kind(directory)) === "Directory", + "Documentation output must be a regular directory", + ); + + const names = yield* Effect.mapError( + fileSystem.readDirectory(directory), + (error) => new PlacementsDocsFailure({ message: error.message }), + ); + + // A Map keeps a file called `__proto__` as an entry. + const result = new Map(); + + for (const name of names.toSorted((a, b) => a.localeCompare(b))) { + const file = path.resolve(directory, name); + const type = yield* kind(file); + + if (type === "Directory") { + for (const [child, digest] of Object.entries(yield* inventory(file))) + result.set(`${name}/${child}`, digest); + } else { + yield* ensure(type === "File", "Documentation output must contain only regular files"); + + const bytes = yield* Effect.mapError( + fileSystem.readFile(file), + (error) => new PlacementsDocsFailure({ message: error.message }), + ); + + result.set(name, createHash("sha256").update(bytes).digest("hex")); + } + } + + return Object.fromEntries(result); + }); + +/** The receipt that a complete documentation run writes beside its output. */ +export const Receipt = Schema.Struct({ + format: Schema.Finite, + status: Schema.String, + revision: Schema.String, + sourceUrl: Schema.String, + files: Schema.Record(Schema.String, Schema.String), +}); + +/** The receipt as JSON text, indented by two spaces. */ +export const ReceiptJson = Schema.fromJsonString(Receipt, { space: 2 }); + +/** Accepts a retained artifact against its receipt and the clean source revision `expected`. */ +export const acceptArtifact = ({ + root, + output, + expected, +}: { + readonly root: string; + readonly output: string; + readonly expected: string | undefined; +}): Effect.Effect< + typeof Receipt.Type, + PlacementsDocsFailure, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const revision = yield* cleanRevision({ root, expected }); + const actual = yield* inventory(output); + + yield* ensure( + actual[receiptName] !== undefined && actual["index.html"] !== undefined, + "Complete documentation output is missing", + ); + + const receipt = yield* fileSystem.readFileString(path.resolve(output, receiptName)).pipe( + Effect.flatMap(Schema.decodeEffect(ReceiptJson)), + Effect.mapError( + (error) => new PlacementsDocsFailure({ message: `Unreadable receipt: ${error.message}` }), + ), + ); + + yield* ensure(receipt.format === 1, "Unknown documentation receipt format"); + yield* ensure(receipt.status === "complete", "Documentation did not complete"); + yield* ensure(receipt.revision === revision, "Retained documentation has a stale source revision"); + + yield* ensure( + receipt.sourceUrl === + `https://github.com/vektorprogrammet/mono-web/blob/${revision}/{path}#L{line}`, + "Documentation source URL is not revision-bound", + ); + + delete actual[receiptName]; + + const listed = Object.entries(receipt.files).toSorted(([a], [b]) => a.localeCompare(b)); + const found = Object.entries(actual).toSorted(([a], [b]) => a.localeCompare(b)); + + yield* ensure( + listed.length === found.length && + listed.every( + ([name, digest], index) => found[index]?.[0] === name && found[index]?.[1] === digest, + ), + "Retained documentation inventory or content changed", + ); + + yield* cleanRevision({ root, expected }); + + return receipt; + }); diff --git a/tools/placements-docs/placements-process.mjs b/tools/placements-docs/placements-process.mjs deleted file mode 100644 index e4dc23064..000000000 --- a/tools/placements-docs/placements-process.mjs +++ /dev/null @@ -1,73 +0,0 @@ -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { setTimeout } from "node:timers/promises"; - -function signalGroup(pid, signal) { - try { - process.kill(-pid, signal); - - return true; - } catch (error) { - if (error.code === "ESRCH") return false; - throw error; - } -} - -async function terminateGroup(pid) { - // The leader can exit before its descendants. Keep ownership of the group. - for (const signal of ["SIGTERM", "SIGKILL"]) { - if (!signalGroup(pid, signal)) return; - const deadline = Date.now() + 1000; - - do { - await setTimeout(20); - - if (!signalGroup(pid, 0)) return; - } while (Date.now() < deadline); - } - - throw new Error("Documentation process group did not stop"); -} - -export async function runDocumentationCommand(command, args, cwd, signal) { - signal.throwIfAborted(); - const child = spawn(command, args, { cwd, stdio: "inherit", detached: true }); - const exited = Promise.withResolvers(); - const aborted = Promise.withResolvers(); - child.once("error", exited.reject); - child.once("exit", (code, signal) => exited.resolve(signal ?? code)); - let cleanup; - - const stop = () => - (cleanup ??= (child.pid ? terminateGroup(child.pid) : Promise.resolve()).then( - () => undefined, - (error) => error, - )); - - const interrupt = () => { - void stop().then(() => aborted.reject(signal.reason)); - }; - - const completed = Promise.race([exited.promise, aborted.promise]); - signal.addEventListener("abort", interrupt, { once: true }); - - if (signal.aborted) interrupt(); - let failure; - - try { - const code = await completed; - signal.throwIfAborted(); - assert.equal(code, 0, "Documentation subprocess failed"); - } catch (error) { - failure = error; - } - - const cleanupError = await stop(); - signal.removeEventListener("abort", interrupt); - - // Cleanup must not replace the original compiler, example, or signal failure. - if (failure) throw failure; - signal.throwIfAborted(); - - if (cleanupError) throw cleanupError; -} diff --git a/tools/placements-docs/placements-process.ts b/tools/placements-docs/placements-process.ts new file mode 100644 index 000000000..ee7e47529 --- /dev/null +++ b/tools/placements-docs/placements-process.ts @@ -0,0 +1,114 @@ +/** + * Runs a documentation subprocess in its own process group and owns that group: when the + * subprocess ends, fails, or the program is interrupted, the whole group stops, also descendants + * that ignore SIGTERM. + */ +import process from "node:process"; +import { Clock, Data, Duration, Effect, Result } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +/** The subprocess ended with another code than 0, or its process group did not stop. */ +export class DocumentationCommandFailed extends Data.TaggedError("DocumentationCommandFailed")<{ + readonly message: string; + /** The exit code, or how the subprocess ended otherwise. */ + readonly exitCode: number | string; +}> {} + +// Whether the group still has a member; ESRCH means that none is left. +const signalGroup = (pid: number, signal: NodeJS.Signals | 0) => + Effect.sync(() => { + try { + process.kill(-pid, signal); + + return true; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ESRCH") return false; + + throw error; + } + }); + +const terminateGroup = Effect.fnUntraced(function* (pid: number) { + // The leader can exit before its descendants. Keep ownership of the group. + for (const signal of ["SIGTERM", "SIGKILL"] as const) { + if (!(yield* signalGroup(pid, signal))) return; + + const deadline = (yield* Clock.currentTimeMillis) + 1000; + + do { + yield* Effect.sleep(Duration.millis(20)); + + if (!(yield* signalGroup(pid, 0))) return; + } while ((yield* Clock.currentTimeMillis) < deadline); + } + + return yield* new DocumentationCommandFailed({ + message: "Documentation process group did not stop", + exitCode: "running", + }); +}); + +/** A documentation subprocess: its program, arguments, and working directory. */ +export interface DocumentationCommand { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string; +} + +/** + * Runs the subprocess with inherited standard streams to its end and stops its process group. + * A failure of the subprocess comes first: a failed cleanup replaces only a success. + */ +export const runDocumentationCommand = ({ + command, + args, + cwd, +}: DocumentationCommand): Effect.Effect< + void, + DocumentationCommandFailed, + ChildProcessSpawner.ChildProcessSpawner +> => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const handle = yield* spawner + .spawn( + ChildProcess.make(command, args, { + cwd, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + detached: true, + }), + ) + .pipe( + Effect.mapError( + (error) => + new DocumentationCommandFailed({ + message: `Documentation subprocess did not start: ${error.message}`, + exitCode: "not started", + }), + ), + ); + + const pid = Number(handle.pid); + + const completed = Effect.gen(function* () { + const ended = yield* Effect.result(handle.exitCode); + const cleanup = yield* Effect.result(terminateGroup(pid)); + + const exitCode = Result.isSuccess(ended) ? Number(ended.success) : ended.failure.message; + + if (exitCode !== 0) + return yield* new DocumentationCommandFailed({ + message: "Documentation subprocess failed", + exitCode, + }); + + if (Result.isFailure(cleanup)) return yield* cleanup.failure; + }); + + yield* Effect.onInterrupt(completed, () => Effect.ignore(terminateGroup(pid))); + }), + ); diff --git a/tools/placements-docs/placements.ts b/tools/placements-docs/placements.ts index 511c4a3c6..d1ef56d63 100644 --- a/tools/placements-docs/placements.ts +++ b/tools/placements-docs/placements.ts @@ -1,220 +1,300 @@ -import { Schema } from "effect"; -import assert from "node:assert/strict"; -import { runDocumentationCommand } from "./placements-process.mjs"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { resolve } from "node:path"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import process from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { Application } from "typedoc"; +import { Config, Console, Effect, Exit, FileSystem, Option, Path, Predicate, Schema } from "effect"; +import * as Runtime from "effect/Runtime"; +import { Application, DeclarationReflection, SignatureReflection } from "typedoc"; import { acceptArtifact, cleanRevision, + ensure, git, inventory, outsideOutput, + PlacementsDocsFailure, publicFile, + readPublicSource, + ReceiptJson, receiptName, -} from "./placements-artifact.mjs"; +} from "./placements-artifact.js"; +import { runDocumentationCommand } from "./placements-process.js"; const root = fileURLToPath(new URL("../../", import.meta.url)); const toolRoot = fileURLToPath(new URL("./", import.meta.url)); -// The Placements context spans the portable domain entry and its database-backed entry. -const packageRoots = ["packages/domain", "packages/database"].map((path) => resolve(root, path)); +const Manifest = Schema.fromJsonString( + Schema.Struct({ exports: Schema.Record(Schema.String, Schema.String) }), +); -const [mode, destination, ...extra] = process.argv.slice(2); +const usage = "Usage: placements.ts "; -assert( - ["generate", "check", "ci", "accept"].includes(mode ?? "") && destination && extra.length === 0, - "Usage: placements.ts ", -); +/** A TypeDoc call. It has no cancellation API, so an interruption waits for the call to finish. */ +const typedoc =
(what: string, call: () => Promise) => + Effect.uninterruptible( + Effect.tryPromise({ + try: call, + catch: (error) => new PlacementsDocsFailure({ message: `${what}: ${String(error)}` }), + }), + ); -const output = await outsideOutput(root, destination); +const program = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; -const expectedRevision = process.env.PLACEMENTS_DOCS_EXPECTED_REVISION; + // The Placements context spans the portable domain entry and its database-backed entry. + const packageRoots = ["packages/domain", "packages/database"].map((packageRoot) => + path.resolve(root, packageRoot), + ); -const revision = - mode === "ci" || mode === "accept" ? cleanRevision(root, expectedRevision) : undefined; + const [mode, destination, ...extra] = process.argv.slice(2); -const tracked = new Set(git(root, "ls-files", "-z").split("\0")); + if ( + mode === undefined || + !["generate", "check", "ci", "accept"].includes(mode) || + destination === undefined || + destination === "" || + extra.length > 0 + ) + return yield* new PlacementsDocsFailure({ message: usage }); -const sourceUrl = revision - ? `https://github.com/vektorprogrammet/mono-web/blob/${revision}/{path}#L{line}` - : `${pathToFileURL(root).href}{path}#L{line}`; + const output = yield* outsideOutput({ root, destination }); -const Manifest = Schema.fromJsonString( - Schema.Struct({ exports: Schema.Record(Schema.String, Schema.String) }), -); + const expectedRevision = Option.getOrUndefined( + yield* Config.option(Config.String("PLACEMENTS_DOCS_EXPECTED_REVISION")), + ); + + const revision = + mode === "ci" || mode === "accept" + ? yield* cleanRevision({ root, expected: expectedRevision }) + : undefined; + + const tracked = (yield* git(root, "ls-files", "-z")).split("\0").filter((file) => file !== ""); + const source = yield* readPublicSource({ root, tracked }); + + const sourceUrl = + revision === undefined + ? `${pathToFileURL(root).href}{path}#L{line}` + : `https://github.com/vektorprogrammet/mono-web/blob/${revision}/{path}#L{line}`; + + const entryPoints = yield* Effect.forEach(packageRoots, (packageRoot) => + Effect.gen(function* () { + const manifest = yield* fileSystem + .readFileString(path.resolve(packageRoot, "package.json")) + .pipe(Effect.flatMap(Schema.decodeEffect(Manifest)), Effect.orDie); + + const entry = manifest.exports["./placements"]; + + if (entry === undefined) + return yield* new PlacementsDocsFailure({ + message: "Expected an explicit ./placements package export", + }); + + const file = path.resolve(packageRoot, entry); + + publicFile(source, file); -const entryPoints = await Promise.all( - packageRoots.map(async (packageRoot) => { - const manifest = Schema.decodeSync(Manifest)( - await readFile(resolve(packageRoot, "package.json"), "utf8"), + return file; + }), + ); + + const run = (args: ReadonlyArray) => + runDocumentationCommand({ command: process.execPath, args: ["--no-env-file", ...args], cwd: root }); + + const render = Effect.fnUntraced(function* (directory: string) { + const app = yield* typedoc("TypeDoc did not start", () => + Application.bootstrap({ + name: "Placements developer guide", + entryPoints, + tsconfig: path.resolve(toolRoot, "tsconfig.json"), + readme: path.resolve(root, "packages/domain/src/placements/README.md"), + basePath: root, + displayBasePath: root, + disableGit: true, + sourceLinkTemplate: sourceUrl, + includeVersion: false, + excludeExternals: true, + cleanOutputDir: false, + validation: { invalidLink: true, notExported: false, notDocumented: false }, + treatWarningsAsErrors: true, + }), ); - const source = manifest.exports["./placements"]; - assert(source, "Expected an explicit ./placements package export"); - const entry = resolve(packageRoot, source); - publicFile(root, tracked, entry); + // TypeDoc registers README and nested include inputs before reading them. + // Guard its existing lifecycle instead of parsing Markdown or TypeScript. + const watchFile = app.watchFile.bind(app); - return entry; - }), -); + app.watchFile = (file, restart) => { + publicFile(source, file); + watchFile(file, restart); + }; -let interrupted = false; - -const controller = new AbortController(); - -const run = (args: ReadonlyArray) => - runDocumentationCommand(process.execPath, ["--no-env-file", ...args], root, controller.signal); - -const render = async (directory: string) => { - const app = await Application.bootstrap({ - name: "Placements developer guide", - entryPoints, - tsconfig: resolve(toolRoot, "tsconfig.json"), - readme: resolve(root, "packages/domain/src/placements/README.md"), - basePath: root, - displayBasePath: root, - disableGit: true, - sourceLinkTemplate: sourceUrl, - includeVersion: false, - excludeExternals: true, - cleanOutputDir: false, - validation: { invalidLink: true, notExported: false, notDocumented: false }, - treatWarningsAsErrors: true, - }); + const project = yield* typedoc("Reference extraction failed", () => app.convert()); - // TypeDoc registers README and nested include inputs before reading them. - // Guard its existing lifecycle instead of parsing Markdown or TypeScript. - const watchFile = app.watchFile.bind(app); - app.watchFile = (path, restart) => { - publicFile(root, tracked, path); - watchFile(path, restart); - }; - - const project = await app.convert(); - assert(!interrupted, "Documentation command interrupted"); - assert(project && !app.logger.hasErrors(), "Reference extraction failed"); - app.validate(project); - assert(!app.logger.hasErrors() && !app.logger.hasWarnings(), "Reference validation failed"); - const sourceLines = new Map(); - let sourceCount = 0; - - for (const reflection of Object.values(project.reflections)) { - if (!("sources" in reflection) || !Array.isArray(reflection.sources)) continue; - // Inherited vendor signatures remain visible, but are not repository source. - reflection.sources = reflection.sources.filter( - (source) => !source.fileName.split("/").includes("node_modules"), + yield* ensure(project !== undefined && !app.logger.hasErrors(), "Reference extraction failed"); + + if (project === undefined) return; + + app.validate(project); + + yield* ensure( + !app.logger.hasErrors() && !app.logger.hasWarnings(), + "Reference validation failed", ); - for (const source of reflection.sources) { - const path = publicFile(root, tracked, resolve(root, source.fileName)); - let lines = sourceLines.get(path); + const sourceLines = new Map(); + let sourceCount = 0; - if (lines === undefined) { - lines = (await readFile(resolve(root, path), "utf8")).split("\n").length; - sourceLines.set(path, lines); - } + for (const reflection of Object.values(project.reflections)) { + if ( + !(reflection instanceof DeclarationReflection || reflection instanceof SignatureReflection) || + reflection.sources === undefined + ) + continue; - assert( - Number.isInteger(source.line) && source.line > 0 && source.line <= lines, - "Invalid source line", + // Inherited vendor signatures remain visible, but are not repository source. + reflection.sources = reflection.sources.filter( + (reflected) => !reflected.fileName.split("/").includes("node_modules"), ); - assert.equal( - source.url, - sourceUrl.replace("{path}", path).replace("{line}", String(source.line)), - "Invalid source link", - ); - sourceCount++; + + for (const reflected of reflection.sources) { + const file = publicFile(source, path.resolve(root, reflected.fileName)); + let lines = sourceLines.get(file); + + if (lines === undefined) { + lines = (yield* Effect.orDie(fileSystem.readFileString(path.resolve(root, file)))).split( + "\n", + ).length; + sourceLines.set(file, lines); + } + + yield* ensure( + Number.isInteger(reflected.line) && reflected.line > 0 && reflected.line <= lines, + "Invalid source line", + ); + + yield* ensure( + reflected.url === + sourceUrl.replace("{path}", file).replace("{line}", String(reflected.line)), + "Invalid source link", + ); + + sourceCount++; + } } - } - assert(sourceCount > 0, "Reference source links are missing"); + yield* ensure(sourceCount > 0, "Reference source links are missing"); - for (const path of project.files.getMediaPaths()) publicFile(root, tracked, path); - await app.generateDocs(project, directory); - assert(!interrupted, "Documentation command interrupted"); - assert(!app.logger.hasErrors() && !app.logger.hasWarnings(), "Documentation rendering failed"); - assert((await inventory(directory))["index.html"], "Documentation index is missing"); - console.log( - `TypeDoc ${Application.VERSION}; documentation compiler ${app.getTypeScriptVersion()}`, - ); -}; + for (const media of project.files.getMediaPaths()) publicFile(source, media); -// TypeDoc has no cancellation API. Finish its current operation before cleanup. -const interrupt = () => { - interrupted = true; - controller.abort(new Error("Documentation command interrupted")); -}; + yield* typedoc("Documentation rendering failed", () => app.generateDocs(project, directory)); -process.on("SIGINT", interrupt); + yield* ensure( + !app.logger.hasErrors() && !app.logger.hasWarnings(), + "Documentation rendering failed", + ); -process.on("SIGTERM", interrupt); + yield* ensure( + (yield* inventory(directory))["index.html"] !== undefined, + "Documentation index is missing", + ); + + yield* Console.log( + `TypeDoc ${Application.VERSION}; documentation compiler ${app.getTypeScriptVersion()}`, + ); + }); -try { if (mode === "accept") { - await acceptArtifact(root, output, expectedRevision); - assert(!interrupted, "Documentation command interrupted"); - console.log("Accepted retained Placements documentation for " + revision); + yield* acceptArtifact({ root, output, expected: expectedRevision }); + yield* Console.log("Accepted retained Placements documentation for " + revision); } else if (mode === "generate" || mode === "ci") { // Only this successful mkdir gives the command ownership of a new directory. - await mkdir(output); + yield* Effect.orDie(fileSystem.makeDirectory(output)); - try { + yield* Effect.gen(function* () { if (mode === "ci") { for (const packageRoot of packageRoots) - await run(["run", "--cwd", packageRoot, "check-types"]); - await run(["run", "--cwd", toolRoot, "docs:examples"]); + yield* run(["run", "--cwd", packageRoot, "check-types"]); + + yield* run(["run", "--cwd", toolRoot, "docs:examples"]); } - await render(output); + yield* render(output); - if (mode === "ci") { - cleanRevision(root, expectedRevision); - await writeFile( - resolve(output, receiptName), - JSON.stringify( - { - format: 1, - status: "complete", - revision, - sourceUrl, - files: await inventory(output), - }, - null, - 2, - ) + "\n", - { flag: "wx" }, + if (mode === "ci" && revision !== undefined) { + yield* cleanRevision({ root, expected: expectedRevision }); + + const receipt = yield* Schema.encodeEffect(ReceiptJson)({ + format: 1, + status: "complete", + revision, + sourceUrl, + files: yield* inventory(output), + }).pipe(Effect.orDie); + + yield* Effect.orDie( + fileSystem.writeFileString(path.resolve(output, receiptName), receipt + "\n", { + flag: "wx", + }), ); - await acceptArtifact(root, output, expectedRevision); - } - assert(!interrupted, "Documentation command interrupted"); - } catch (error) { - await rm(output, { recursive: true, force: true }); - throw error; - } + yield* acceptArtifact({ root, output, expected: expectedRevision }); + } + }).pipe(Effect.onError(() => Effect.ignore(fileSystem.remove(output, { recursive: true, force: true })))); - console.log("Open " + pathToFileURL(resolve(output, "index.html")).href); + yield* Console.log("Open " + pathToFileURL(path.resolve(output, "index.html")).href); } else { - const fresh = await mkdtemp(resolve(tmpdir(), "placements-docs-check-")); - - try { - await render(fresh); - assert.deepEqual( - await inventory(output), - await inventory(fresh), - "Generated documentation is stale", - ); - assert(!interrupted, "Documentation command interrupted"); - console.log("Placements documentation is fresh"); - } finally { - await rm(fresh, { recursive: true, force: true }); - } + yield* Effect.scoped( + Effect.gen(function* () { + const fresh = yield* Effect.orDie( + fileSystem.makeTempDirectoryScoped({ prefix: "placements-docs-check-" }), + ); + + yield* render(fresh); + + const retained = Object.entries(yield* inventory(output)).toSorted(([a], [b]) => + a.localeCompare(b), + ); + + const rendered = Object.entries(yield* inventory(fresh)).toSorted(([a], [b]) => + a.localeCompare(b), + ); + + yield* ensure( + retained.length === rendered.length && + retained.every( + ([name, digest], index) => + rendered[index]?.[0] === name && rendered[index]?.[1] === digest, + ), + "Generated documentation is stale", + ); + + yield* Console.log("Placements documentation is fresh"); + }), + ); } -} finally { - process.removeListener("SIGINT", interrupt); - process.removeListener("SIGTERM", interrupt); -} + + return 0; +}).pipe( + Effect.catchTags({ + PlacementsDocsFailure: ({ message }) => Console.error(message).pipe(Effect.as(1)), + DocumentationCommandFailed: ({ message, exitCode }) => + Console.error(`${message} (${exitCode})`).pipe(Effect.as(1)), + }), +); + +// A program that succeeds with a number exits with that code. +const teardown: Runtime.Teardown = (exit, onExit) => { + if (Exit.isSuccess(exit) && Predicate.isNumber(exit.value)) onExit(exit.value); + else Runtime.defaultTeardown(exit, onExit); +}; + +/** The platform of the documentation commands, which their tests take too. */ +export const PlacementsDocsPlatform = BunServices.layer; + +/** The services of `PlacementsDocsPlatform`. */ +export type PlacementsDocsPlatform = BunServices.BunServices; + +if (import.meta.main) + BunRuntime.runMain(program.pipe(Effect.provide(PlacementsDocsPlatform)), { teardown }); diff --git a/tools/postgres/index.ts b/tools/postgres/index.ts index 20c074581..32a131a27 100644 --- a/tools/postgres/index.ts +++ b/tools/postgres/index.ts @@ -20,17 +20,43 @@ * `reserveLoopbackPorts` is the one way to choose the loopback port of a server that a journey * starts, a cluster included. It reserves below the kernel's ephemeral range and never returns a * port twice in one process, so no other bind takes the port before its server binds it. + * + * The work is Effect programs over `FileSystem`, `Path`, and `ChildProcessSpawner`. Bun programs + * and Node Vitest suites both import this module, so it keeps a Promise API and runs each program + * in one module runtime of the shared Node implementations of those services. The postmaster and + * the synchronous resolution of the manifest and the installation use Node directly (EX-0020). */ -import { type ChildProcess, execFile, spawn, spawnSync } from "node:child_process"; -import { randomInt } from "node:crypto"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0020: the postmaster logs to a descriptor and gets signals alone, and the installation resolves synchronously +import { type ChildProcess as NodeChildProcess, spawn, spawnSync } from "node:child_process"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0020: the postmaster's log is a descriptor, and the manifest and the installation resolve synchronously import { accessSync, closeSync, constants, openSync, readFileSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; -import { tmpdir } from "node:os"; -import { delimiter, join } from "node:path"; -import { setTimeout as sleep } from "node:timers/promises"; -import { promisify } from "node:util"; -import { Redacted, Schema } from "effect"; +import * as BunChildProcessSpawner from "@effect/platform-bun/BunChildProcessSpawner"; +import * as BunFileSystem from "@effect/platform-bun/BunFileSystem"; +import * as BunPath from "@effect/platform-bun/BunPath"; +import { + Cause, + Clock, + Config, + Data, + Deferred, + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + Predicate, + Random, + Redacted, + Schedule, + Schema, + Scope, + Stream, +} from "effect"; +import { dual } from "effect/Function"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const separator = " || "; @@ -49,6 +75,48 @@ const RootManifest = Schema.fromJsonString( Schema.Struct({ engines: Schema.Struct({ postgresql: PostgresMajorSet }) }), ); +/** A cluster, a pooler, or one of their programs that did not start or stop as it must. */ +class DisposablePostgresFailure extends Data.TaggedError("DisposablePostgresFailure")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** + * A rejection of a caller's callback or the reason of an abandoned wait. The Promise API rejects + * with it unchanged, so a caller sees the value that its own code rejected with. + */ +class CallerRejection extends Data.TaggedError("CallerRejection")<{ + readonly rejection: unknown; +}> {} + +const callerPromise = (evaluate: () => Promise) => + Effect.tryPromise({ try: evaluate, catch: (rejection) => new CallerRejection({ rejection }) }); + +/** + * The services that the programs of this module run on: the shared Node implementations of + * `ChildProcessSpawner`, `FileSystem`, and `Path`, which Bun and Node both run. Its test suites + * take the same layer. + */ +export const PostgresPlatformLive = BunChildProcessSpawner.layer.pipe( + Layer.provideMerge(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), +); + +// The Promise API runs its programs here: the construct serves Bun programs and Node suites alike. +const runtime = ManagedRuntime.make(PostgresPlatformLive); + +type PostgresPlatform = Layer.Success; + +/** Runs `program` for the Promise API, which rejects with a caller's rejection unchanged. */ +const settle = (program: Effect.Effect): Promise => + runtime.runPromise( + program.pipe( + Effect.catchIf( + (error): error is Extract => error instanceof CallerRejection, + (error) => Effect.die(error.rejection), + ), + ), + ); + /** Decodes an `engines.postgresql` value into its majors, ascending. Throws on a malformed value. */ export const decodePostgresMajors = (declared: string): ReadonlyArray => Schema.decodeSync(PostgresMajorSet)(declared) @@ -60,10 +128,10 @@ export const decodePostgresMajors = (declared: string): ReadonlyArray => * The major that `requested` (the value of `VEKTOR_POSTGRES_MAJOR`) selects from `supported`. * No value, or an empty one, selects the highest. Throws on a major outside `supported`. */ -export const selectPostgresMajor = ( - supported: ReadonlyArray, - requested: string | undefined, -): number => { +export const selectPostgresMajor: { + (requested: string | undefined): (supported: ReadonlyArray) => number; + (supported: ReadonlyArray, requested: string | undefined): number; +} = dual(2, (supported: ReadonlyArray, requested: string | undefined): number => { if (requested === undefined || requested === "") return Math.max(...supported); const major = supported.find((candidate) => String(candidate) === requested); @@ -74,7 +142,7 @@ export const selectPostgresMajor = ( `VEKTOR_POSTGRES_MAJOR=${requested} is not a supported PostgreSQL major. ` + `package.json engines.postgresql supports ${supported.join(separator)}.`, ); -}; +}); /** The PostgreSQL majors that the root manifest supports, ascending. */ export const supportedPostgresMajors = decodePostgresMajors( @@ -86,12 +154,15 @@ export const supportedPostgresMajors = decodePostgresMajors( /** The highest supported major, which runs when `VEKTOR_POSTGRES_MAJOR` is unset. */ export const defaultPostgresMajor = selectPostgresMajor(supportedPostgresMajors, undefined); +const environmentValue = (name: string): string | undefined => + Option.getOrUndefined(runtime.runSync(Config.option(Config.String(name)))); + /** * The major that `VEKTOR_POSTGRES_MAJOR` selects, or the default. */ export const selectedPostgresMajor = selectPostgresMajor( supportedPostgresMajors, - process.env.VEKTOR_POSTGRES_MAJOR, + environmentValue("VEKTOR_POSTGRES_MAJOR"), ); /** @@ -109,9 +180,9 @@ interface Installation { readonly version: string; } -const executable = (path: string) => { +const executable = (file: string) => { try { - accessSync(path, constants.X_OK); + accessSync(file, constants.X_OK); return true; } catch { @@ -127,20 +198,29 @@ const versionOf = (postgres: string) => { return major === undefined ? undefined : { line, major: Number(major) }; }; -const resolveInstallation = (): Installation => { - const directory = (process.env.PATH ?? "") +/** The directory of the first `program` on `PATH`, if any. */ +const onPath = (program: string) => { + const path = runtime.runSync(Path.Path); + const delimiter = path.sep === "\\" ? ";" : ":"; + + const directory = (environmentValue("PATH") ?? "") .split(delimiter) - .find((entry) => entry !== "" && executable(join(entry, "postgres"))); + .find((entry) => entry !== "" && executable(path.join(entry, program))); - const version = directory === undefined ? undefined : versionOf(join(directory, "postgres")); + return directory === undefined ? undefined : { directory, file: path.join(directory, program) }; +}; - if (directory !== undefined && version?.major === selectedPostgresMajor) - return { directory, version: version.line }; +const resolveInstallation = (): Installation => { + const found = onPath("postgres"); + const version = found === undefined ? undefined : versionOf(found.file); + + if (found !== undefined && version?.major === selectedPostgresMajor) + return { directory: found.directory, version: version.line }; throw new Error( `PostgreSQL ${selectedPostgresMajor} (selected by VEKTOR_POSTGRES_MAJOR from package.json ` + `engines.postgresql ${supportedPostgresMajors.join(separator)}) is required on PATH, which provides ` + - `${directory === undefined ? "no postgres" : `${directory}/postgres (${version?.major ?? "unknown version"})`}. ` + + `${found === undefined ? "no postgres" : `${found.file} (${version?.major ?? "unknown version"})`}. ` + "Run the command inside `devenv shell` with the same VEKTOR_POSTGRES_MAJOR, which provides it.", ); }; @@ -148,7 +228,7 @@ const resolveInstallation = (): Installation => { let installation: Installation | undefined; const programPath = (program: PostgresProgram | ClusterProgram) => - join((installation ??= resolveInstallation()).directory, program); + runtime.runSync(Path.Path).join((installation ??= resolveInstallation()).directory, program); /** * Absolute path of a client program of the selected PostgreSQL major. @@ -202,8 +282,6 @@ export const postgresProgram = (program: PostgresProgram): string => programPath */ export const postgresVersion = (): string => (installation ??= resolveInstallation()).version; -const execute = promisify(execFile); - const loopback = "127.0.0.1"; // A cluster that does not accept connections within this time does not start. @@ -214,17 +292,53 @@ const fastShutdownMs = 30_000; const immediateShutdownMs = 10_000; +// initdb that has not finished within this time fails the start. +const initdbTimeoutMs = 120_000; + +type Environment = Readonly>; + /** * The environment of the cluster programs: `base` in the C locale, without the libpq and server * variables (`PGHOST`, `PGPORT`, `PGDATA`, ...) that `devenv shell` sets for its own server. */ -const programEnvironment = ( - base: Readonly>, -): NodeJS.ProcessEnv => ({ +const programEnvironment = (base: Environment) => ({ ...Object.fromEntries(Object.entries(base).filter(([name]) => !name.startsWith("PG"))), LC_ALL: "C", }); +const failure = (message: string, cause?: unknown) => + new DisposablePostgresFailure({ message, cause }); + +const text = (stream: Stream.Stream) => Stream.mkString(Stream.decodeText(stream)); + +/** Runs `program` in this process group and fails with its standard error on a nonzero exit. */ +const execute = (program: string, args: ReadonlyArray, environment: Environment) => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const handle = yield* spawner.spawn( + ChildProcess.make(program, args, { + env: { ...environment }, + detached: false, + stdin: "ignore", + }), + ); + + const [, stderr, code] = yield* Effect.all( + [text(handle.stdout), text(handle.stderr), handle.exitCode], + { concurrency: "unbounded" }, + ); + + if (code !== 0) + return yield* failure(`Command failed: ${[program, ...args].join(" ")}\n${stderr}`); + }), + ).pipe( + Effect.catchTag("PlatformError", (error) => + Effect.fail(failure(`Command failed: ${program}: ${error.message}`, error)), + ), + ); + // The kernel hands out port 0 binds and outgoing connections from its ephemeral range (Linux: // 32768-60999; other systems use the IANA dynamic range, 49152-65535). A port below both is taken // only by a process that names it. @@ -236,6 +350,15 @@ const reservableEnd = 32_768; // return it. const reservedPorts = new Set(); +const portFree = (port: number): Effect.Effect => + Effect.callback((resume) => { + const server = createServer(); + server.once("error", () => resume(Effect.succeed(false))); + server.listen({ host: loopback, port, exclusive: true }, () => + server.close(() => resume(Effect.succeed(true))), + ); + }); + /** * Whether a listener can bind `port` on loopback now. * @@ -257,28 +380,21 @@ const reservedPorts = new Set(); * * @construct test-harness */ -export const loopbackPortFree = (port: number): Promise => - new Promise((resolve) => { - const server = createServer(); - server.once("error", () => resolve(false)); - server.listen({ host: loopback, port, exclusive: true }, () => - server.close(() => resolve(true)), - ); - }); +export const loopbackPortFree = (port: number): Promise => settle(portFree(port)); -const reserveLoopbackPort = async (): Promise => { +const reserveLoopbackPort = Effect.gen(function* () { for (let attempt = 0; attempt < reservableEnd - reservableFirst; attempt++) { - const port = randomInt(reservableFirst, reservableEnd); + const port = yield* Random.nextIntBetween(reservableFirst, reservableEnd, { halfOpen: true }); - if (!reservedPorts.has(port) && (await loopbackPortFree(port))) { + if (!reservedPorts.has(port) && (yield* portFree(port))) { reservedPorts.add(port); return port; } } - throw new Error(`no free loopback port in ${reservableFirst}-${reservableEnd - 1}`); -}; + return yield* failure(`no free loopback port in ${reservableFirst}-${reservableEnd - 1}`); +}); /** * Reserves `count` distinct loopback ports for the servers that a journey starts: its backend, @@ -306,13 +422,8 @@ const reserveLoopbackPort = async (): Promise => { * * @construct test-harness */ -export const reserveLoopbackPorts = async (count: number): Promise> => { - const ports: Array = []; - - while (ports.length < count) ports.push(await reserveLoopbackPort()); - - return ports; -}; +export const reserveLoopbackPorts = (count: number): Promise> => + settle(Effect.replicateEffect(reserveLoopbackPort, count)); /** Where a client reaches a server: an address or a Unix socket directory, a port, and a role. */ export interface PostgresAddress { @@ -323,76 +434,94 @@ export interface PostgresAddress { // pg_isready exits 0 when the server accepts connections, 1 when it rejects them (it starts up, // shuts down, or recovers: SQLSTATE 57P03), 2 without a response, and 3 on invalid parameters. -const probe = (address: PostgresAddress, environment: NodeJS.ProcessEnv) => { - const { promise, resolve } = Promise.withResolvers<{ - readonly code: number | null; - readonly report: string; - }>(); - - const child = spawn( - postgresProgram("pg_isready"), - [ - "--host", - address.host, - "--port", - String(address.port), - "--username", - address.user, - "--dbname", - "postgres", - "--timeout", - "2", - ], - { env: environment, stdio: ["ignore", "pipe", "pipe"] }, - ); +const probe = (address: PostgresAddress, environment: Environment) => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const handle = yield* spawner.spawn( + ChildProcess.make( + postgresProgram("pg_isready"), + [ + "--host", + address.host, + "--port", + String(address.port), + "--username", + address.user, + "--dbname", + "postgres", + "--timeout", + "2", + ], + { env: { ...environment }, detached: false, stdin: "ignore" }, + ), + ); - let report = ""; + const [report, code] = yield* Effect.all([text(handle.all), handle.exitCode], { + concurrency: "unbounded", + }); - const collect = (chunk: Buffer) => { - report += chunk.toString("utf8"); - }; + return { code: Number(code), report: report.trim() }; + }), + ).pipe( + Effect.catchTag("PlatformError", (error) => + Effect.succeed({ code: undefined, report: error.message }), + ), + ); - child.stdout.on("data", collect); - child.stderr.on("data", collect); - child.once("error", (cause) => resolve({ code: null, report: cause.message })); - child.once("close", (code) => resolve({ code, report: report.trim() })); +/** Succeeds once the server at `address` accepts connections, as `pg_isready` reports. */ +const awaitReady = (address: PostgresAddress, timeoutMs: number) => + Effect.gen(function* () { + const deadline = (yield* Clock.currentTimeMillis) + timeoutMs; + const environment = programEnvironment(process.env); - return promise; -}; + for (;;) { + const { code, report } = yield* probe(address, environment); -/** - * Resolves once the server at `address` accepts connections, as `pg_isready` reports. An open - * port is not enough: a server that starts up, shuts down, or recovers answers on its port and - * rejects every session. Rejects after `timeoutMs`, or with the reason of `abandon` once it aborts. - */ -export const waitForPostgres = async ( - address: PostgresAddress, - timeoutMs: number, - abandon?: AbortSignal, -): Promise => { - const deadline = Date.now() + timeoutMs; - const environment = programEnvironment(process.env); + if (code === 0) return; - for (;;) { - abandon?.throwIfAborted(); + if (code === 3) return yield* failure(`pg_isready rejected its parameters: ${report}`); - const { code, report } = await probe(address, environment); + if ((yield* Clock.currentTimeMillis) >= deadline) + return yield* failure( + `PostgreSQL at ${address.host}:${address.port} did not accept connections within ` + + `${timeoutMs} ms. pg_isready last reported: ${report === "" ? `exit ${code ?? "without a code"}` : report}`, + ); - if (code === 0) return; + yield* Effect.sleep(100); + } + }); - if (code === 3) throw new Error(`pg_isready rejected its parameters: ${report}`); +/** Fails with the reason of `signal` once it aborts. */ +const abandonment = (signal: AbortSignal): Effect.Effect => + Effect.callback((resume) => { + const abandoned = () => resume(Effect.fail(new CallerRejection({ rejection: signal.reason }))); - abandon?.throwIfAborted(); + if (signal.aborted) return abandoned(); - if (Date.now() >= deadline) - throw new Error( - `PostgreSQL at ${address.host}:${address.port} did not accept connections within ` + - `${timeoutMs} ms. pg_isready last reported: ${report === "" ? `exit ${code}` : report}`, - ); + signal.addEventListener("abort", abandoned, { once: true }); - await sleep(100, undefined, { signal: abandon }).catch(() => undefined); - } -}; + return Effect.sync(() => signal.removeEventListener("abort", abandoned)); + }); + +/** + * Resolves once the server at `address` accepts connections, as `pg_isready` reports. An open + * port is not enough: a server that starts up, shuts down, or recovers answers on its port and + * rejects every session. Rejects after `timeoutMs`, or with the reason of `abandon` once it aborts. + */ +export const waitForPostgres: { + (timeoutMs: number, abandon?: AbortSignal): (address: PostgresAddress) => Promise; + (address: PostgresAddress, timeoutMs: number, abandon?: AbortSignal): Promise; +} = dual( + (args) => Predicate.isObject(args[0]), + (address: PostgresAddress, timeoutMs: number, abandon?: AbortSignal): Promise => + settle( + abandon === undefined + ? awaitReady(address, timeoutMs) + : Effect.raceFirst(awaitReady(address, timeoutMs), abandonment(abandon)), + ), +); // The owner holds the sentinel's standard input, so the sentinel reads end of file when the owner // exits, however it exits. `stop` writes `released` instead, after it removed the cluster itself. @@ -405,29 +534,84 @@ const sentinelScript = [ 'rm -rf -- "$3"', ].join("\n"); -const watch = (root: string, data: string, environment: NodeJS.ProcessEnv) => { - const sentinel = spawn( - "/bin/sh", - ["-c", sentinelScript, "vektor-postgres-sentinel", programPath("pg_ctl"), data, root], - { env: environment, stdio: ["pipe", "ignore", "ignore"], detached: true }, - ); +// The owner holds this sentinel's standard input too. When the owner exits without `stop`, the +// sentinel ends the pooler that its pid file names and removes the pooler's directory. +const poolerSentinelScript = [ + "trap '' HUP INT QUIT TERM", + 'if read -r line && [ "$line" = released ]; then exit 0; fi', + 'if [ -f "$1/pgbouncer.pid" ]; then kill -KILL "$(cat "$1/pgbouncer.pid")" 2>/dev/null; fi', + 'rm -rf -- "$1"', +].join("\n"); - const exited = Promise.withResolvers(); - sentinel.once("exit", () => exited.resolve()); - sentinel.once("error", () => exited.resolve()); +/** + * Starts a sentinel shell in its own session whose standard input this process holds, and answers + * the release, which tells the sentinel that `stop` removed what it watches and waits for its exit. + * The sentinel's handle lives in a scope of its own that only the release closes, and it does not + * keep this process alive: a pipe without pending writes holds no event loop. + */ +const sentinel = ( + script: string, + name: string, + args: ReadonlyArray, + environment: Environment | undefined, +) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const scope = yield* Scope.make(); + + const handle = yield* spawner + .spawn( + ChildProcess.make("/bin/sh", ["-c", script, name, ...args], { + env: environment === undefined ? undefined : { ...environment }, + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }), + ) + .pipe(Scope.provide(scope)); + + const reref = yield* handle.unref; + + const release = Effect.gen(function* () { + yield* Effect.ignore(reref); + yield* Effect.ignore(Stream.run(Stream.encodeText(Stream.make("released\n")), handle.stdin)); + yield* Effect.ignore(handle.exitCode); + yield* Scope.close(scope, Exit.void); + }); - sentinel.stdin.on("error", () => undefined); - sentinel.unref(); + return { release }; + }); - return async () => { - sentinel.ref(); - sentinel.stdin.end("released\n"); - await exited.promise; - }; +/** Removes a directory tree, retrying as `rm` with `maxRetries: 3` did. */ +const removeTree = (root: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + + yield* fs + .remove(root, { recursive: true, force: true }) + .pipe(Effect.retry({ times: 3, schedule: Schedule.spaced(100) })); + }); + +/** The last 4000 characters of a log, or `absent` when it cannot be read. */ +const logTail = (file: string, absent: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + + return yield* fs.readFileString(file).pipe( + Effect.map((log) => log.slice(-4_000).trim()), + Effect.orElseSucceed(() => absent), + ); + }); + +/** The message of a failure cause, as an `Error` of it reads. */ +const detailOf = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + + return { error, detail: error instanceof Error ? error.message : String(error) }; }; -const settlesWithin = (settled: Promise, milliseconds: number) => - Promise.race([settled.then(() => true), sleep(milliseconds, false)]); +const waitsWithin = (exited: Deferred.Deferred, milliseconds: number) => + Deferred.await(exited).pipe(Effect.timeoutOption(milliseconds), Effect.map(Option.isSome)); /** How `startDisposablePostgres` sets up a cluster. Every field is optional. */ export interface DisposablePostgresOptions { @@ -485,73 +669,115 @@ export interface DisposablePostgres { } interface Incarnation { - readonly child: ChildProcess; + readonly child: NodeChildProcess; readonly pid: number; - readonly exited: Promise; - readonly abandon: AbortController; - expected: boolean; + /** Completes once the server exits. */ + readonly exited: Deferred.Deferred; + /** Fails once the server exits while nobody stops it. */ + readonly abandoned: Deferred.Deferred; + /** Whether `stop` or `outage` ends the server, so that its exit is no failure. */ + readonly state: { expected: boolean }; +} + +interface PostmasterSetup { + readonly data: string; + readonly port: number; + readonly socketDirectory: string; + readonly listen: "tcp" | "socket"; + readonly maxConnections: number | undefined; + readonly logFile: string; + readonly environment: Environment; } /** - * Starts a fresh cluster of the selected major on a private port and socket directory with trust - * authentication. - * - * @remarks - * It runs `initdb` in a new directory and starts `postgres` on the port of `options.port`, or on - * one that it reserves as `reserveLoopbackPorts` does. It resolves once `pg_isready` reports that - * the server accepts connections, and `options.database` exists when it names one. The cluster - * carries its address and `url`, `createDatabase`, `outage`, which stops and restarts the server - * around a callback, and `unexpectedExit`. `stop` removes the cluster, and a sentinel in its own - * session removes it when this process exits without `stop`, also on a signal or SIGKILL. - * - * @throws Rejects with an `Error` that carries the server log, when `initdb`, the server, or - * `createdb` fails, or when the server accepts no connection within 60 seconds. It removes the - * cluster first. - * - * @sideEffects Creates the cluster directory, starts the server and its sentinel, and binds a - * loopback port and a Unix socket until `stop`. - * - * @example - * ```ts - * const postgres = await startDisposablePostgres({ port: postgresPort, database: "journey" }); - * ``` - * - * @avoid Running `initdb`, `pg_ctl`, `postgres`, or `createdb` yourself, or taking an open port - * for readiness: a server that starts up answers on its port and rejects every session. - * `anti-slop/no-hand-rolled-postgres` rejects the cluster programs outside this construct. - * - * @construct test-harness + * Starts the postmaster in its own process group with the log file as its standard output and + * error, and completes `ended` at its exit or failed start. The server must outlive a crash of + * this process and get its signals alone, and its log must not pass through a pipe of this + * process, which ChildProcess cannot express (EX-0020). */ -export const startDisposablePostgres = async ( - options: DisposablePostgresOptions = {}, -): Promise => { +const spawnPostmaster = ( + setup: PostmasterSetup, + state: { expected: boolean }, + ended: Deferred.Deferred<{ readonly detail: string; readonly expected: boolean }>, +) => + Effect.try({ + try: () => { + const log = openSync(setup.logFile, "a"); + let child: NodeChildProcess; + + try { + child = spawn( + programPath("postgres"), + [ + "-D", + setup.data, + "-p", + String(setup.port), + "-k", + setup.socketDirectory, + "-c", + `listen_addresses=${setup.listen === "tcp" ? loopback : ""}`, + "-F", + ...(setup.maxConnections === undefined + ? [] + : ["-c", `max_connections=${setup.maxConnections}`]), + ], + { env: { ...setup.environment }, stdio: ["ignore", log, log], detached: true }, + ); + } finally { + closeSync(log); + } + + const end = (detail: string) => + Deferred.doneUnsafe(ended, Exit.succeed({ detail, expected: state.expected })); + + child.once("error", (cause) => end(`did not start: ${cause.message}`)); + child.once("exit", (code, signal) => + end(`exited with ${signal === null ? `code ${code}` : signal}`), + ); + child.unref(); + + return child; + }, + catch: (cause) => + failure(`PostgreSQL did not start: ${cause instanceof Error ? cause.message : String(cause)}`, cause), + }); + +/** Sends `signal` to the postmaster alone, which relays the shutdown to its backends. */ +const signalServer = (incarnation: Incarnation, signal: NodeJS.Signals) => + Effect.sync(() => { + incarnation.child.kill(signal); + }); + +const startCluster = Effect.fnUntraced(function* (options: DisposablePostgresOptions) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const user = options.user ?? "postgres"; const listen = options.listen ?? "tcp"; const environment = programEnvironment(options.environment ?? process.env); const version = postgresVersion(); - const port = options.port ?? (await reserveLoopbackPort()); - const root = options.directory ?? (await mkdtemp(join(tmpdir(), "vektor-postgres-"))); + const port = options.port ?? (yield* reserveLoopbackPort); + const root = options.directory ?? (yield* fs.makeTempDirectory({ prefix: "vektor-postgres-" })); + + if (options.directory !== undefined) yield* fs.makeDirectory(root, { mode: 0o700 }); - if (options.directory !== undefined) await mkdir(root, { mode: 0o700 }); + const data = path.join(root, "data"); - const data = join(root, "data"); - const release = watch(root, data, environment); - const socketDirectory = join(root, "socket"); - const logFile = join(root, "postgres.log"); + const { release } = yield* sentinel( + sentinelScript, + "vektor-postgres-sentinel", + [programPath("pg_ctl"), data, root], + environment, + ); + + const socketDirectory = path.join(root, "socket"); + const logFile = path.join(root, "postgres.log"); const host = listen === "tcp" ? loopback : socketDirectory; const address = { host, port, user }; const unexpected = Promise.withResolvers(); let server: Incarnation | undefined; let stopping: Promise | undefined; - const logTail = () => { - try { - return readFileSync(logFile, "utf8").slice(-4_000).trim(); - } catch { - return "(no server log)"; - } - }; - const urlOf = (database: string) => { if (listen === "tcp") return `postgres://${encodeURIComponent(user)}@${loopback}:${port}/${encodeURIComponent(database)}`; @@ -566,110 +792,94 @@ export const startDisposablePostgres = async ( return url.toString(); }; - const launch = (): Incarnation => { - const log = openSync(logFile, "a"); - let child: ChildProcess; - - try { - child = spawn( - programPath("postgres"), - [ - "-D", - data, - "-p", - String(port), - "-k", - socketDirectory, - "-c", - `listen_addresses=${listen === "tcp" ? loopback : ""}`, - "-F", - ...(options.maxConnections === undefined - ? [] - : ["-c", `max_connections=${options.maxConnections}`]), - ], - { env: environment, stdio: ["ignore", log, log], detached: true }, - ); - } finally { - closeSync(log); - } + const launch = Effect.gen(function* () { + const state = { expected: false }; + const ended = yield* Deferred.make<{ readonly detail: string; readonly expected: boolean }>(); + const exited = yield* Deferred.make(); + const abandoned = yield* Deferred.make(); - const abandon = new AbortController(); - const exited = Promise.withResolvers(); + const child = yield* spawnPostmaster( + { + data, + port, + socketDirectory, + listen, + maxConnections: options.maxConnections, + logFile, + environment, + }, + state, + ended, + ); - const incarnation: Incarnation = { - child, - pid: child.pid ?? 0, - exited: exited.promise, - abandon, - expected: false, - }; + // The server's exit completes `exited`; an exit that nobody asked for also fails the start + // that waits for it and settles `unexpectedExit`. + yield* Effect.forkDetach( + Effect.gen(function* () { + const { detail, expected } = yield* Deferred.await(ended); - const ended = (detail: string) => { - exited.resolve(); + yield* Deferred.succeed(exited, undefined); - if (incarnation.expected) return; + if (expected) return; - const failure = new Error(`PostgreSQL ${detail}. Server log:\n${logTail()}`); - abandon.abort(failure); - unexpected.resolve(failure); - }; + const exit = failure( + `PostgreSQL ${detail}. Server log:\n${yield* logTail(logFile, "(no server log)")}`, + ); - child.once("error", (cause) => ended(`did not start: ${cause.message}`)); - child.once("exit", (code, signal) => - ended(`exited with ${signal === null ? `code ${code}` : signal}`), + yield* Deferred.fail(abandoned, exit); + unexpected.resolve(exit); + }), ); - child.unref(); - return incarnation; - }; + return { child, pid: child.pid ?? 0, exited, abandoned, state } satisfies Incarnation; + }); - const run = async () => { - server = launch(); - await waitForPostgres(address, readinessTimeoutMs, server.abandon.signal); - }; + const run = Effect.gen(function* () { + const incarnation = yield* launch; + server = incarnation; - const halt = async (incarnation: Incarnation) => { - incarnation.expected = true; + yield* Effect.raceFirst( + awaitReady(address, readinessTimeoutMs), + Deferred.await(incarnation.abandoned), + ); + }); + + const halt = Effect.fnUntraced(function* (incarnation: Incarnation) { + incarnation.state.expected = true; incarnation.child.ref(); if (incarnation.child.exitCode !== null || incarnation.child.signalCode !== null) return; - incarnation.child.kill("SIGINT"); + yield* signalServer(incarnation, "SIGINT"); - if (await settlesWithin(incarnation.exited, fastShutdownMs)) return; + if (yield* waitsWithin(incarnation.exited, fastShutdownMs)) return; - incarnation.child.kill("SIGQUIT"); + yield* signalServer(incarnation, "SIGQUIT"); - if (await settlesWithin(incarnation.exited, immediateShutdownMs)) return; + if (yield* waitsWithin(incarnation.exited, immediateShutdownMs)) return; - incarnation.child.kill("SIGKILL"); - await incarnation.exited; - }; + yield* signalServer(incarnation, "SIGKILL"); + yield* Deferred.await(incarnation.exited); + }); - const stop = () => - (stopping ??= (async () => { - try { - if (server !== undefined) await halt(server); + const teardown = Effect.gen(function* () { + if (server !== undefined) yield* halt(server); - await rm(root, { recursive: true, force: true, maxRetries: 3 }); - } finally { - await release(); - } - })()); + yield* removeTree(root); + }).pipe(Effect.ensuring(release)); - const createDatabase = async (database: string) => { - await execute( + const stop = () => (stopping ??= settle(teardown)); + + const createDatabase = (database: string) => + execute( programPath("createdb"), ["--host", host, "--port", String(port), "--username", user, database], - { env: environment }, - ); + environment, + ).pipe(Effect.as(urlOf(database))); - return urlOf(database); - }; - - try { - await mkdir(socketDirectory, { mode: 0o700 }); - await execute( + yield* Effect.gen(function* () { + yield* fs.makeDirectory(socketDirectory, { mode: 0o700 }); + yield* execute( programPath("initdb"), [ "--pgdata", @@ -682,22 +892,31 @@ export const startDisposablePostgres = async ( "--no-sync", "--no-instructions", ], - { env: environment, timeout: 120_000 }, + environment, + ).pipe( + Effect.timeoutOrElse({ + duration: initdbTimeoutMs, + orElse: () => Effect.fail(failure(`initdb did not finish within ${initdbTimeoutMs} ms`)), + }), ); - await run(); + yield* run; - if (options.database !== undefined) await createDatabase(options.database); - } catch (cause) { - const detail = cause instanceof Error ? cause.message : String(cause); - const failure = new Error(`Disposable PostgreSQL did not start: ${detail}`, { cause }); - await stop().catch(() => undefined); + if (options.database !== undefined) yield* createDatabase(options.database); + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const { error, detail } = detailOf(cause); - throw failure; - } + yield* Effect.exit(teardown); + + return yield* failure(`Disposable PostgreSQL did not start: ${detail}`, error); + }), + ), + ); const database = options.database ?? "postgres"; - return { + const cluster: DisposablePostgres = { host, port, user, @@ -711,22 +930,61 @@ export const startDisposablePostgres = async ( logFile, unexpectedExit: unexpected.promise, urlOf, - createDatabase, - outage: async (during) => { - if (stopping !== undefined || server === undefined) - throw new Error("Disposable PostgreSQL is stopped"); + createDatabase: (name) => settle(createDatabase(name)), + outage: (during) => + settle( + Effect.gen(function* () { + if (stopping !== undefined || server === undefined) + return yield* failure("Disposable PostgreSQL is stopped"); - await halt(server); + yield* halt(server); - try { - return await during(); - } finally { - await run(); - } - }, + const result = yield* Effect.exit(callerPromise(during)); + + yield* run; + + return yield* result; + }), + ), stop, }; -}; + + return cluster; +}); + +/** + * Starts a fresh cluster of the selected major on a private port and socket directory with trust + * authentication. + * + * @remarks + * It runs `initdb` in a new directory and starts `postgres` on the port of `options.port`, or on + * one that it reserves as `reserveLoopbackPorts` does. It resolves once `pg_isready` reports that + * the server accepts connections, and `options.database` exists when it names one. The cluster + * carries its address and `url`, `createDatabase`, `outage`, which stops and restarts the server + * around a callback, and `unexpectedExit`. `stop` removes the cluster, and a sentinel in its own + * session removes it when this process exits without `stop`, also on a signal or SIGKILL. + * + * @throws Rejects with an `Error` that carries the server log, when `initdb`, the server, or + * `createdb` fails, or when the server accepts no connection within 60 seconds. It removes the + * cluster first. + * + * @sideEffects Creates the cluster directory, starts the server and its sentinel, and binds a + * loopback port and a Unix socket until `stop`. + * + * @example + * ```ts + * const postgres = await startDisposablePostgres({ port: postgresPort, database: "journey" }); + * ``` + * + * @avoid Running `initdb`, `pg_ctl`, `postgres`, or `createdb` yourself, or taking an open port + * for readiness: a server that starts up answers on its port and rejects every session. + * `anti-slop/no-hand-rolled-postgres` rejects the cluster programs outside this construct. + * + * @construct test-harness + */ +export const startDisposablePostgres = ( + options: DisposablePostgresOptions = {}, +): Promise => settle(startCluster(options)); /** * Runs `use` against the database `database` of a fresh cluster, which `startDisposablePostgres` @@ -734,7 +992,7 @@ export const startDisposablePostgres = async ( * * @remarks * `use` receives the connection URL of `database` as a `Redacted` value, so a log of it shows no - * URL. The cluster stops in a `finally`, so the promise settles as `use` settled, after the + * URL. The cluster stops after `use` settles, so the promise settles as `use` settled, after the * cluster is gone. * * @throws Rejects when the cluster does not start, as `startDisposablePostgres` rejects, and @@ -752,59 +1010,34 @@ export const startDisposablePostgres = async ( * * @construct test-harness */ -export const withDisposablePostgres = async ( - database: string, - use: (databaseUrl: Redacted.Redacted) => Promise, -): Promise => { - const cluster = await startDisposablePostgres({ database }); - - try { - return await use(Redacted.make(cluster.url)); - } finally { - await cluster.stop(); - } -}; - -// The owner holds this sentinel's standard input too. When the owner exits without `stop`, the -// sentinel ends the pooler that its pid file names and removes the pooler's directory. -const poolerSentinelScript = [ - "trap '' HUP INT QUIT TERM", - 'if read -r line && [ "$line" = released ]; then exit 0; fi', - 'if [ -f "$1/pgbouncer.pid" ]; then kill -KILL "$(cat "$1/pgbouncer.pid")" 2>/dev/null; fi', - 'rm -rf -- "$1"', -].join("\n"); - -const watchPooler = (root: string) => { - const sentinel = spawn("/bin/sh", ["-c", poolerSentinelScript, "vektor-pgbouncer-sentinel", root], { - stdio: ["pipe", "ignore", "ignore"], - detached: true, - }); - - const exited = Promise.withResolvers(); - sentinel.once("exit", () => exited.resolve()); - sentinel.once("error", () => exited.resolve()); - - sentinel.stdin.on("error", () => undefined); - sentinel.unref(); - - return async () => { - sentinel.ref(); - sentinel.stdin.end("released\n"); - await exited.promise; - }; -}; +export const withDisposablePostgres: { + ( + use: (databaseUrl: Redacted.Redacted) => Promise, + ): (database: string) => Promise; + (database: string, use: (databaseUrl: Redacted.Redacted) => Promise): Promise; +} = dual( + 2, + (database: string, use: (databaseUrl: Redacted.Redacted) => Promise): Promise => + settle( + Effect.gen(function* () { + const cluster = yield* startCluster({ database }); + const result = yield* Effect.exit(callerPromise(() => use(Redacted.make(cluster.url)))); + + yield* callerPromise(cluster.stop); + + return yield* result; + }), + ), +); /** The PgBouncer program on `PATH`, which `devenv shell` provides. */ -const pgbouncerProgram = () => { - const directory = (process.env.PATH ?? "") - .split(delimiter) - .find((entry) => entry !== "" && executable(join(entry, "pgbouncer"))); +const pgbouncerProgram = Effect.suspend(() => { + const found = onPath("pgbouncer"); - if (directory === undefined) - throw new Error("PgBouncer is required on PATH. Run the command inside `devenv shell`."); - - return join(directory, "pgbouncer"); -}; + return found === undefined + ? Effect.fail(failure("PgBouncer is required on PATH. Run the command inside `devenv shell`.")) + : Effect.succeed(found.file); +}); /** How `startDisposablePgBouncer` pools a cluster. Every field is optional. */ export interface DisposablePgBouncerOptions { @@ -832,60 +1065,56 @@ export interface DisposablePgBouncer { readonly stop: () => Promise; } -/** - * Starts PgBouncer on a private loopback port in front of `upstream`, with trust authentication - * and every database of the cluster, in transaction pool mode unless `options` names another. - * It resolves once the pooler accepts connections, as `pg_isready` reports. - * - * @remarks PgBouncer keeps `search_path` per client and restores it on a server connection only - * where the server reports it, as PostgreSQL 18 does. On PostgreSQL 17 a client's startup - * `search_path` does not reach the server, so a test names it on the database. - * @sideEffects Writes a private directory with the configuration and log, starts the pooler in - * its own process group, and starts a sentinel that ends it and removes the directory when this - * process exits without `stop`. - * @example - * const pooler = await startDisposablePgBouncer(cluster, { poolSize: 4 }); - * const url = pooler.urlOf("pilot"); - * await pooler.stop(); - * @avoid Spawning `pgbouncer` elsewhere, and judging readiness by an open port. - */ -export const startDisposablePgBouncer = async ( +/** The pooler's process and the effect that lets it keep this process alive again. */ +interface RunningPooler { + readonly handle: ChildProcessSpawner.ChildProcessHandle; + readonly reref: ChildProcessSpawner.Reref; +} + +const startPooler = Effect.fnUntraced(function* ( upstream: DisposablePostgres, - options: DisposablePgBouncerOptions = {}, -): Promise => { + options: DisposablePgBouncerOptions, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const poolMode = options.poolMode ?? "transaction"; - const port = options.port ?? (await reserveLoopbackPort()); - const root = await mkdtemp(join(tmpdir(), "vektor-pgbouncer-")); - const release = watchPooler(root); - const logFile = join(root, "pgbouncer.log"); - const configFile = join(root, "pgbouncer.ini"); - const exited = Promise.withResolvers(); - const abandon = new AbortController(); - let child: ChildProcess | undefined; + const port = options.port ?? (yield* reserveLoopbackPort); + const root = yield* fs.makeTempDirectory({ prefix: "vektor-pgbouncer-" }); + + const { release } = yield* sentinel( + poolerSentinelScript, + "vektor-pgbouncer-sentinel", + [root], + undefined, + ); + + const logFile = path.join(root, "pgbouncer.log"); + const configFile = path.join(root, "pgbouncer.ini"); + // The pooler outlives this program: its process lives in this scope until `stop` closes it. + const scope = yield* Scope.make(); + let pooler: RunningPooler | undefined; let stopping: Promise | undefined; - const stop = () => - (stopping ??= (async () => { - try { - if (child !== undefined && child.exitCode === null && child.signalCode === null) { - child.ref(); - child.kill("SIGQUIT"); + const teardown = Effect.gen(function* () { + if (pooler !== undefined && (yield* pooler.handle.isRunning)) { + yield* pooler.reref; + yield* Effect.ignore( + pooler.handle.kill({ killSignal: "SIGQUIT", forceKillAfter: immediateShutdownMs }), + ); + } - if (!(await settlesWithin(exited.promise, immediateShutdownMs))) { - child.kill("SIGKILL"); - await exited.promise; - } - } + yield* Scope.close(scope, Exit.void); + yield* removeTree(root); + }).pipe(Effect.ensuring(release)); - await rm(root, { recursive: true, force: true, maxRetries: 3 }); - } finally { - await release(); - } - })()); + const stop = () => (stopping ??= settle(teardown)); - try { - await writeFile(join(root, "users.txt"), `"${upstream.user}" ""\n`, { mode: 0o600 }); - await writeFile( + yield* Effect.gen(function* () { + yield* fs.writeFileString(path.join(root, "users.txt"), `"${upstream.user}" ""\n`, { + mode: 0o600, + }); + yield* fs.writeFileString( configFile, [ "[databases]", @@ -895,52 +1124,63 @@ export const startDisposablePgBouncer = async ( `listen_port = ${port}`, "unix_socket_dir =", "auth_type = trust", - `auth_file = ${join(root, "users.txt")}`, + `auth_file = ${path.join(root, "users.txt")}`, `admin_users = ${upstream.user}`, `pool_mode = ${poolMode}`, `default_pool_size = ${options.poolSize ?? 4}`, "max_client_conn = 200", "track_extra_parameters = IntervalStyle, search_path", `logfile = ${logFile}`, - `pidfile = ${join(root, "pgbouncer.pid")}`, + `pidfile = ${path.join(root, "pgbouncer.pid")}`, "", ].join("\n"), { mode: 0o600 }, ); - child = spawn(pgbouncerProgram(), [configFile], { - env: programEnvironment(process.env), - stdio: "ignore", - detached: true, - }); - child.once("error", (cause) => { - exited.resolve(); - abandon.abort(new Error(`PgBouncer did not start: ${cause.message}`)); - }); - child.once("exit", (code, signal) => { - exited.resolve(); - abandon.abort(new Error(`PgBouncer exited with ${signal ?? `code ${code}`}`)); - }); - child.unref(); - - await waitForPostgres({ host: loopback, port, user: upstream.user }, readinessTimeoutMs, abandon.signal); - } catch (cause) { - const detail = cause instanceof Error ? cause.message : String(cause); + const handle = yield* spawner + .spawn( + ChildProcess.make(yield* pgbouncerProgram, [configFile], { + env: programEnvironment(process.env), + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ) + .pipe( + Scope.provide(scope), + Effect.mapError((error) => failure(`PgBouncer did not start: ${error.message}`, error)), + ); - const log = (() => { - try { - return readFileSync(logFile, "utf8").slice(-4_000).trim(); - } catch { - return "(no pooler log)"; - } - })(); + pooler = { handle, reref: yield* handle.unref }; - await stop().catch(() => undefined); + const exited = handle.exitCode.pipe( + Effect.matchEffect({ + onSuccess: (code) => Effect.fail(failure(`PgBouncer exited with code ${code}`)), + onFailure: (error) => Effect.fail(failure(`PgBouncer exited: ${error.message}`, error)), + }), + ); - throw new Error(`Disposable PgBouncer did not start: ${detail}. Pooler log:\n${log}`, { cause }); - } + yield* Effect.raceFirst( + awaitReady({ host: loopback, port, user: upstream.user }, readinessTimeoutMs), + exited, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + const { error, detail } = detailOf(cause); + const log = yield* logTail(logFile, "(no pooler log)"); + + yield* Effect.exit(teardown); + + return yield* failure( + `Disposable PgBouncer did not start: ${detail}. Pooler log:\n${log}`, + error, + ); + }), + ), + ); - return { + const started: DisposablePgBouncer = { host: loopback, port, user: upstream.user, @@ -950,4 +1190,32 @@ export const startDisposablePgBouncer = async ( `postgres://${encodeURIComponent(upstream.user)}@${loopback}:${port}/${encodeURIComponent(database)}`, stop, }; -}; + + return started; +}); + +/** + * Starts PgBouncer on a private loopback port in front of `upstream`, with trust authentication + * and every database of the cluster, in transaction pool mode unless `options` names another. + * It resolves once the pooler accepts connections, as `pg_isready` reports. + * + * @remarks PgBouncer keeps `search_path` per client and restores it on a server connection only + * where the server reports it, as PostgreSQL 18 does. On PostgreSQL 17 a client's startup + * `search_path` does not reach the server, so a test names it on the database. + * @sideEffects Writes a private directory with the configuration and log, starts the pooler in + * its own process group, and starts a sentinel that ends it and removes the directory when this + * process exits without `stop`. + * @example + * const pooler = await startDisposablePgBouncer(cluster, { poolSize: 4 }); + * const url = pooler.urlOf("pilot"); + * await pooler.stop(); + * @avoid Spawning `pgbouncer` elsewhere, and judging readiness by an open port. + */ +export const startDisposablePgBouncer: { + (options?: DisposablePgBouncerOptions): (upstream: DisposablePostgres) => Promise; + (upstream: DisposablePostgres, options?: DisposablePgBouncerOptions): Promise; +} = dual( + (args) => Predicate.hasProperty(args[0], "socketDirectory"), + (upstream: DisposablePostgres, options: DisposablePgBouncerOptions = {}): Promise => + settle(startPooler(upstream, options)), +); diff --git a/tools/postgres/package.json b/tools/postgres/package.json index ed973c4b4..38fea9d7c 100644 --- a/tools/postgres/package.json +++ b/tools/postgres/package.json @@ -11,9 +11,11 @@ "test": "vitest run" }, "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", "effect": "catalog:effect-v4" }, "devDependencies": { + "@effect/vitest": "catalog:effect-v4", "@types/node": "^22", "typescript": "7.0.2", "vitest": "catalog:effect-v4" diff --git a/tools/postgres/readiness.test.ts b/tools/postgres/readiness.test.ts index d74d6290c..bc245c9b8 100644 --- a/tools/postgres/readiness.test.ts +++ b/tools/postgres/readiness.test.ts @@ -1,6 +1,6 @@ import { createServer, type Server } from "node:net"; -import { Predicate } from "effect"; -import { afterEach, describe, expect, test } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; import { waitForPostgres } from "./index"; type Answer = "starting-up" | "accepting" | "hang-up"; @@ -29,103 +29,114 @@ const accepting = Buffer.concat([ message("Z", Buffer.from("I", "latin1")), ]); -const servers: Array = []; - -afterEach(async () => { - await Promise.all( - servers.splice(0).map((server) => { - const { promise, resolve } = Promise.withResolvers(); - server.close(() => resolve()); - - return promise; +/** Listens on a loopback port that the kernel chooses, and closes when the test's scope closes. */ +const listening = (server: Server) => + Effect.acquireRelease( + Effect.callback((resume) => { + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); }), + (served) => + Effect.callback((resume) => { + served.close(() => resume(Effect.void)); + }), ); -}); /** * A loopback endpoint that speaks the PostgreSQL startup protocol. It refuses encryption and * answers the startup message of connection `n` with `answer(n)`, which it records. */ -const endpoint = async (answer: (attempt: number) => Answer) => { - const answers: Array = []; +const endpoint = (answer: (attempt: number) => Answer) => + Effect.gen(function* () { + const answers: Array = []; - const server = createServer((socket) => { - let pending = Buffer.alloc(0); + const server = yield* listening( + createServer((socket) => { + let pending = Buffer.alloc(0); - socket.on("error", () => undefined); - socket.on("data", (chunk) => { - pending = Buffer.concat([pending, chunk]); + socket.on("error", () => undefined); + socket.on("data", (chunk) => { + pending = Buffer.concat([pending, chunk]); - while (pending.length >= 8 && pending.length >= pending.readInt32BE(0)) { - const code = pending.readInt32BE(4); - pending = pending.subarray(pending.readInt32BE(0)); + while (pending.length >= 8 && pending.length >= pending.readInt32BE(0)) { + const code = pending.readInt32BE(4); + pending = pending.subarray(pending.readInt32BE(0)); - if (code === sslRequest || code === gssEncryptionRequest) { - socket.write("N"); - continue; - } + if (code === sslRequest || code === gssEncryptionRequest) { + socket.write("N"); + continue; + } - const verdict = answer(answers.length); - answers.push(verdict); - - if (verdict === "hang-up") socket.destroy(); - else socket.end(verdict === "accepting" ? accepting : startingUp); - - return; - } - }); - }); + const verdict = answer(answers.length); + answers.push(verdict); - servers.push(server); + if (verdict === "hang-up") socket.destroy(); + else socket.end(verdict === "accepting" ? accepting : startingUp); - const listening = Promise.withResolvers(); - server.listen(0, "127.0.0.1", () => listening.resolve()); - await listening.promise; + return; + } + }); + }), + ); - const address = server.address(); + const address = server.address(); - if (address === null || Predicate.isString(address)) throw new Error("no loopback port"); + if (address === null || Predicate.isString(address)) + return yield* Effect.die(new Error("no loopback port")); - return { address: { host: "127.0.0.1", port: address.port, user: "postgres" }, answers }; -}; + return { address: { host: "127.0.0.1", port: address.port, user: "postgres" }, answers }; + }); describe("waitForPostgres", () => { - test("does not report a server that accepts TCP but still starts up as ready", async () => { - const { address, answers } = await endpoint(() => "starting-up"); - - await expect(waitForPostgres(address, 1_500)).rejects.toThrow( - /did not accept connections within 1500 ms/u, - ); - expect(answers.length).toBeGreaterThan(1); - }); + it.live("does not report a server that accepts TCP but still starts up as ready", () => + Effect.gen(function* () { + const { address, answers } = yield* endpoint(() => "starting-up"); + + yield* Effect.promise(() => + expect(waitForPostgres(address, 1_500)).rejects.toThrow( + /did not accept connections within 1500 ms/u, + ), + ); + expect(answers.length).toBeGreaterThan(1); + }), + ); - test("does not report a server that closes the connection without an answer as ready", async () => { - const { address, answers } = await endpoint(() => "hang-up"); + it.live("does not report a server that closes the connection without an answer as ready", () => + Effect.gen(function* () { + const { address, answers } = yield* endpoint(() => "hang-up"); - await expect(waitForPostgres(address, 1_000)).rejects.toThrow(/did not accept connections/u); - expect(answers.length).toBeGreaterThan(1); - }); + yield* Effect.promise(() => + expect(waitForPostgres(address, 1_000)).rejects.toThrow(/did not accept connections/u), + ); + expect(answers.length).toBeGreaterThan(1); + }), + ); - test("reports ready at the first session that the server accepts", async () => { - const { address, answers } = await endpoint((attempt) => - attempt < 3 ? "starting-up" : "accepting", - ); + it.live("reports ready at the first session that the server accepts", () => + Effect.gen(function* () { + const { address, answers } = yield* endpoint((attempt) => + attempt < 3 ? "starting-up" : "accepting", + ); - await waitForPostgres(address, 30_000); + yield* Effect.promise(() => waitForPostgres(address, 30_000)); - expect(answers).toEqual(["starting-up", "starting-up", "starting-up", "accepting"]); - }); + expect(answers).toEqual(["starting-up", "starting-up", "starting-up", "accepting"]); + }), + ); - test("stops waiting with the reason of an abandoned start", async () => { + it.live("stops waiting with the reason of an abandoned start", () => { const abandon = new AbortController(); const reason = new Error("PostgreSQL exited with code 1"); - const { address } = await endpoint(() => { - abandon.abort(reason); + return Effect.gen(function* () { + const { address } = yield* endpoint(() => { + abandon.abort(reason); - return "starting-up"; - }); + return "starting-up"; + }); - await expect(waitForPostgres(address, 30_000, abandon.signal)).rejects.toBe(reason); + yield* Effect.promise(() => + expect(waitForPostgres(address, 30_000, abandon.signal)).rejects.toBe(reason), + ); + }); }); }); diff --git a/tools/postgres/teardown.test.ts b/tools/postgres/teardown.test.ts index 00eab28ea..c1d515bb5 100644 --- a/tools/postgres/teardown.test.ts +++ b/tools/postgres/teardown.test.ts @@ -3,21 +3,30 @@ * no sentinel. Each case starts a real cluster in a separate Bun owner, ends that owner as the named * killer does, and waits for the sentinel's teardown. */ -import { type ChildProcess, spawn } from "node:child_process"; -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; import { createConnection } from "node:net"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { setTimeout as sleep } from "node:timers/promises"; -import { fileURLToPath } from "node:url"; -import { Schema } from "effect"; -import { describe, expect, test } from "vitest"; +import { expect, layer } from "@effect/vitest"; +import { + Clock, + Data, + Effect, + FileSystem, + Option, + Path, + type PlatformError, + Predicate, + Schema, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { PostgresPlatformLive } from "./index"; + +/** The owner ended, or ran out of time, before it reported its cluster. */ +class OwnerReportMissing extends Data.TaggedError("OwnerReportMissing")<{ + readonly message: string; +}> {} const construct = JSON.stringify(new URL("./index.ts", import.meta.url).href); -const measureJob = fileURLToPath(new URL("../scripts/measure-job.ts", import.meta.url)); - const StartedCluster = Schema.fromJsonString( Schema.Struct({ socketDirectory: Schema.String, port: Schema.Int, pid: Schema.Int }), ); @@ -38,145 +47,155 @@ const owner = (then: "fail" | "wait") => [ ].join("\n"), ]; -const signal = (target: number, name: NodeJS.Signals) => { - try { - process.kill(target, name); - } catch { - // The target is already gone. - } -}; +const signal = (target: number, name: NodeJS.Signals) => + // The target may already be gone. + Effect.ignore(Effect.try(() => process.kill(target, name))); + +/** The process ids under /proc. */ +const processIds = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + + return (yield* fs.readDirectory("/proc")).map(Number).filter((pid) => Number.isInteger(pid)); +}); + +/** The text of a /proc file, or none when its process exited while the table was read. */ +const procText = (file: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + + return yield* fs.readFileString(file).pipe(Effect.option); + }); /** The process ids of `root` and its descendants, from the parent links in /proc. */ -const treeOf = (root: number) => { - const children = new Map>(); +const treeOf = (root: number) => + Effect.gen(function* () { + const children = new Map>(); - for (const entry of readdirSync("/proc")) { - const pid = Number(entry); + for (const pid of yield* processIds) { + const stat = yield* procText(`/proc/${pid}/stat`); - if (!Number.isInteger(pid)) continue; + if (Option.isNone(stat)) continue; - try { - const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); // The command name may hold spaces and parentheses; the state and the parent follow it. - const parent = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]); + const parent = Number(stat.value.slice(stat.value.lastIndexOf(")") + 2).split(" ")[1]); const siblings = children.get(parent); if (siblings === undefined) children.set(parent, [pid]); else siblings.push(pid); - } catch { - // The process exited while the table was read. } - } - const tree: Array = []; - const pending = [root]; + const tree: Array = []; + const pending = [root]; - for (let pid = pending.pop(); pid !== undefined; pid = pending.pop()) { - tree.push(pid); - pending.push(...(children.get(pid) ?? [])); - } + for (let pid = pending.pop(); pid !== undefined; pid = pending.pop()) { + tree.push(pid); + pending.push(...(children.get(pid) ?? [])); + } - return tree; -}; + return tree; + }); /** Live processes whose command line names `root`: the server and the sentinel of a cluster. */ const processesNaming = (root: string) => - readdirSync("/proc").flatMap((entry) => { - const pid = Number(entry); + Effect.gen(function* () { + const naming: Array = []; - if (!Number.isInteger(pid)) return []; + for (const pid of yield* processIds) { + const commandLine = yield* procText(`/proc/${pid}/cmdline`); - try { - return readFileSync(`/proc/${pid}/cmdline`, "utf8").includes(root) ? [pid] : []; - } catch { - return []; + if (Option.isSome(commandLine) && commandLine.value.includes(root)) naming.push(pid); } - }); - -const refusesConnections = (port: number) => { - const { promise, resolve } = Promise.withResolvers(); - const socket = createConnection({ host: "127.0.0.1", port }); - socket.once("connect", () => { - socket.destroy(); - resolve(false); + return naming; }); - socket.once("error", () => resolve(true)); - return promise; -}; +const refusesConnections = (port: number) => + Effect.callback((resume) => { + const socket = createConnection({ host: "127.0.0.1", port }); -const running = (pid: number) => { - try { - process.kill(pid, 0); + socket.once("connect", () => { + socket.destroy(); + resume(Effect.succeed(false)); + }); + socket.once("error", () => resume(Effect.succeed(true))); + }); - return true; - } catch { - return false; - } -}; +const running = (pid: number) => + Effect.try(() => process.kill(pid, 0)).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); /** - * Resolves with the cluster that the owner reports. Rejects once the owner ends without one, or - * after `within` milliseconds, so the case's `finally` still runs before the test times out. + * The cluster that the owner reports on its output. Fails once the owner ends without one, or + * after `within` milliseconds, so the case's cleanup still runs before the test times out. */ -const reported = (launched: ChildProcess, within: number) => { - const { promise, resolve, reject } = Promise.withResolvers(); - let output = ""; - - const fail = (reason: string) => { - clearTimeout(deadline); - reject(new Error(`the owner ${reason}:\n${output}`)); - }; - - const deadline = setTimeout(() => fail(`reported no cluster within ${within} ms`), within); - - const collect = (chunk: Buffer) => { - output += chunk.toString("utf8"); - - const line = /^\{.*\}$/mu.exec(output)?.[0]; - - if (line === undefined) return; - - clearTimeout(deadline); - resolve(Schema.decodeSync(StartedCluster)(line)); - }; +const reported = (launched: ChildProcessSpawner.ChildProcessHandle, within: number) => + Effect.gen(function* () { + let output = ""; + + const line = yield* Stream.decodeText(launched.all).pipe( + Stream.map((chunk) => { + output += chunk; + + return /^\{.*\}$/mu.exec(output)?.[0]; + }), + Stream.filter(Predicate.isNotUndefined), + Stream.runHead, + Effect.timeoutOption(within), + ); + + if (Option.isNone(line)) + return yield* new OwnerReportMissing({ + message: `the owner reported no cluster within ${within} ms:\n${output}`, + }); + + if (Option.isNone(line.value)) + return yield* new OwnerReportMissing({ + message: `the owner ended without starting a cluster:\n${output}`, + }); + + return yield* Schema.decodeEffect(StartedCluster)(line.value.value); + }); - launched.stdout?.on("data", collect); - launched.stderr?.on("data", collect); - launched.once("close", () => fail("ended without starting a cluster")); +const remains = (cluster: StartedCluster, root: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; - return promise; -}; + return { + directory: yield* fs.exists(root), + server: yield* running(cluster.pid), + listener: !(yield* refusesConnections(cluster.port)), + processes: yield* processesNaming(root), + }; + }); -const remains = async (cluster: StartedCluster, root: string) => ({ - directory: existsSync(root), - server: running(cluster.pid), - listener: !(await refusesConnections(cluster.port)), - processes: processesNaming(root), -}); +const lingers = (left: Effect.Success>) => + left.directory || left.server || left.listener || left.processes.length > 0; interface OwnerDeath { readonly name: string; /** The command that runs the owner, in its own process group. */ - readonly command: (ledger: string) => ReadonlyArray; + readonly command: (paths: { readonly ledger: string; readonly measureJob: string }) => ReadonlyArray; /** Ends the owner after it reported its cluster. `pid` leads the owner's process group. */ - readonly end: (pid: number) => Promise; + readonly end: ( + pid: number, + ) => Effect.Effect; } const ownerDeaths: ReadonlyArray = [ // Bun exits on an uncaught failure without an `exit` event, so no handler of the owner runs. - { name: "an uncaught failure", command: () => owner("fail"), end: async () => undefined }, - { name: "SIGKILL of the owner", command: () => owner("wait"), end: async (pid) => signal(pid, "SIGKILL") }, + { name: "an uncaught failure", command: () => owner("fail"), end: () => Effect.void }, + { name: "SIGKILL of the owner", command: () => owner("wait"), end: (pid) => signal(pid, "SIGKILL") }, { name: "SIGKILL of the owner's process group", command: () => owner("wait"), - end: async (pid) => signal(-pid, "SIGKILL"), + end: (pid) => signal(-pid, "SIGKILL"), }, { // `just measure` forwards SIGINT and SIGTERM to the command that it runs. name: "SIGTERM to `just measure`, which forwards it to the owner", - command: (ledger) => [ + command: ({ ledger, measureJob }) => [ "bun", "--no-env-file", measureJob, @@ -187,66 +206,74 @@ const ownerDeaths: ReadonlyArray = [ "--", ...owner("wait"), ], - end: async (pid) => signal(pid, "SIGTERM"), + end: (pid) => signal(pid, "SIGTERM"), }, { // A bash tool timeout and `hub stop` send SIGTERM to every descendant, also to those in other // sessions, and then SIGKILL to the process group. name: "a tool timeout: SIGTERM to every descendant, then SIGKILL to the process group", command: () => owner("wait"), - end: async (pid) => { - for (const member of treeOf(pid)) signal(member, "SIGTERM"); + end: (pid) => + Effect.gen(function* () { + for (const member of yield* treeOf(pid)) yield* signal(member, "SIGTERM"); - await sleep(1_000); - signal(-pid, "SIGKILL"); - }, + yield* Effect.sleep(1_000); + yield* signal(-pid, "SIGKILL"); + }), }, ]; -describe("startDisposablePostgres teardown", () => { - test.each(ownerDeaths)( +layer(PostgresPlatformLive, { excludeTestServices: true })("startDisposablePostgres teardown", (it) => { + it.effect.each(ownerDeaths)( "removes the cluster when its owner ends by $name", - async ({ command, end }) => { - const scratch = await mkdtemp(join(tmpdir(), "vektor-teardown-probe-")); - const [program = "bun", ...args] = command(join(scratch, "ledger.jsonl")); - const launched = spawn(program, args, { detached: true, stdio: ["ignore", "pipe", "pipe"] }); - const leader = launched.pid; - let root: string | undefined; - - try { - if (leader === undefined) throw new Error(`${program} did not start`); - - const cluster = await reported(launched, 30_000); - root = dirname(cluster.socketDirectory); - await end(leader); - - const deadline = Date.now() + 20_000; - let left = await remains(cluster, root); - - // The sentinel tears the cluster down in its own process after the owner is gone, so the - // test polls for the result instead of awaiting an event of this process. - while ( - Date.now() < deadline && - (left.directory || left.server || left.listener || left.processes.length > 0) - ) { - await sleep(100); - left = await remains(cluster, root); - } - - expect(left).toEqual({ directory: false, server: false, listener: false, processes: [] }); - } finally { - // A failing case must not leak what the owner left: stop it and remove its files. - if (leader !== undefined) signal(-leader, "SIGKILL"); - - if (root !== undefined) { - for (const pid of processesNaming(root)) signal(pid, "SIGKILL"); - - await rm(root, { recursive: true, force: true }); - } - - await rm(scratch, { recursive: true, force: true }); - } - }, + ({ command, end }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "vektor-teardown-probe-" }); + const measureJob = yield* path.fromFileUrl(new URL("../scripts/measure-job.ts", import.meta.url)); + const [program = "bun", ...args] = command({ ledger: path.join(scratch, "ledger.jsonl"), measureJob }); + + // The owner leads its own process group, as the killers of the cases expect. + const launched = yield* spawner.spawn( + ChildProcess.make(program, args, { detached: true, stdin: "ignore" }), + ); + + let root: string | undefined; + + yield* Effect.gen(function* () { + const cluster = yield* reported(launched, 30_000); + root = path.dirname(cluster.socketDirectory); + yield* end(Number(launched.pid)); + + const deadline = (yield* Clock.currentTimeMillis) + 20_000; + let left = yield* remains(cluster, root); + + // The sentinel tears the cluster down in its own process after the owner is gone, so the + // test polls for the result instead of awaiting an event of this process. + while ((yield* Clock.currentTimeMillis) < deadline && lingers(left)) { + yield* Effect.sleep(100); + left = yield* remains(cluster, root); + } + + expect(left).toEqual({ directory: false, server: false, listener: false, processes: [] }); + }).pipe( + // A failing case must not leak what the owner left: stop it and remove its files. + Effect.ensuring( + Effect.gen(function* () { + yield* signal(-Number(launched.pid), "SIGKILL"); + + if (root === undefined) return; + + for (const pid of yield* Effect.orDie(processesNaming(root))) + yield* signal(pid, "SIGKILL"); + + yield* Effect.ignore(fs.remove(root, { recursive: true, force: true })); + }), + ), + ); + }), 60_000, ); }); diff --git a/tools/scripts/changelog.ts b/tools/scripts/changelog.ts index 1c549b757..b2945ed50 100644 --- a/tools/scripts/changelog.ts +++ b/tools/scripts/changelog.ts @@ -1,49 +1,56 @@ -import { unlink, rename } from "node:fs/promises"; -import { Console, Effect } from "effect"; - -const check = Bun.argv.includes("--check"); +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import process from "node:process"; +import { Console, Effect, FileSystem, Option } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { exitWithReturnedCode } from "./exit-code.js"; const outputPath = "CHANGELOG.md"; -const temporaryPath = `${outputPath}.tmp-${process.pid}`; - -const processResult = Bun.spawn( - [ - "node_modules/.bin/conventional-changelog", - "--config", - ".changelogrc.mjs", - "--release-count", - "0", - "--outfile", - temporaryPath, - ], - { stderr: "inherit", stdout: "inherit" }, -); - -const exitCode = await processResult.exited; - -if (exitCode !== 0) { - await unlink(temporaryPath).catch(() => undefined); - process.exit(exitCode); -} - -if (check) { - const expected = await Bun.file(outputPath) - .arrayBuffer() - .catch(() => null); - - const actual = await Bun.file(temporaryPath).arrayBuffer(); - await unlink(temporaryPath); - - if (expected === null || !Buffer.from(expected).equals(Buffer.from(actual))) { - await Effect.runPromise( - Console.error("CHANGELOG.md is out of date. Run `just changelog` and commit the result."), - ); - process.exit(1); +const sameBytes = (left: Uint8Array, right: Uint8Array): boolean => + left.length === right.length && left.every((byte, index) => byte === right[index]); + +const program = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const check = process.argv.includes("--check"); + const temporaryPath = `${outputPath}.tmp-${process.pid}`; + + const exitCode = yield* spawner.exitCode( + ChildProcess.make( + "node_modules/.bin/conventional-changelog", + ["--config", ".changelogrc.mjs", "--release-count", "0", "--outfile", temporaryPath], + { stdin: "ignore", stdout: "inherit", stderr: "inherit" }, + ), + ); + + if (exitCode !== 0) { + yield* fileSystem.remove(temporaryPath).pipe(Effect.ignore); + + return exitCode; + } + + if (!check) { + yield* fileSystem.rename(temporaryPath, outputPath); + yield* Console.log("Generated CHANGELOG.md from conventional commits."); + + return 0; } - await Effect.runPromise(Console.log("CHANGELOG.md matches git history.")); -} else { - await rename(temporaryPath, outputPath); - await Effect.runPromise(Console.log("Generated CHANGELOG.md from conventional commits.")); -} + const expected = yield* fileSystem.readFile(outputPath).pipe(Effect.option); + const actual = yield* fileSystem.readFile(temporaryPath); + + yield* fileSystem.remove(temporaryPath); + + if (Option.isNone(expected) || !sameBytes(expected.value, actual)) { + yield* Console.error("CHANGELOG.md is out of date. Run `just changelog` and commit the result."); + + return 1; + } + + yield* Console.log("CHANGELOG.md matches git history."); + + return 0; +}); + +BunRuntime.runMain(program.pipe(Effect.provide(BunServices.layer)), exitWithReturnedCode); diff --git a/tools/scripts/command.ts b/tools/scripts/command.ts new file mode 100644 index 000000000..5a7255886 --- /dev/null +++ b/tools/scripts/command.ts @@ -0,0 +1,32 @@ +/** + * Runs one command to its end and collects its exit code and output, as `spawnSync` did for the + * scripts, through the `ChildProcessSpawner` service that the composition root provides. + */ +import { Effect, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export interface CommandResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +/** The exit code and the text of both output streams of a command that runs to its end. */ +export const runCommand = (command: ChildProcess.Command) => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn(command); + + const [stdout, stderr, status] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); + + return { status, stdout, stderr } satisfies CommandResult; + }), + ); diff --git a/tools/scripts/deploy-preview.ts b/tools/scripts/deploy-preview.ts index c341a93ea..c09ebcb2e 100644 --- a/tools/scripts/deploy-preview.ts +++ b/tools/scripts/deploy-preview.ts @@ -1,94 +1,18 @@ -import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { appendFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { Schema } from "effect"; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); - -const homepageRoot = resolve(root, "apps/homepage"); - -const dashboardRoot = resolve(root, "apps/dashboard"); - -const homepageConfig = resolve(root, "infra/previews/homepage.wrangler.json"); - -const dashboardConfig = resolve(root, "infra/previews/dashboard.wrangler.json"); - -const wrangler = resolve(root, "node_modules/.bin/wrangler"); - -const pullRequestNumber = process.argv[2]; - -const writeLine = (message: string): void => { - process.stdout.write(`${message}\n`); -}; - -const fail: (message: string) => never = (message) => { - process.stderr.write(`${message}\n`); - process.exit(1); -}; - -if (pullRequestNumber === undefined || !/^[1-9]\d*$/.test(pullRequestNumber)) { - fail("Usage: bun tools/scripts/deploy-preview.ts "); -} - -const previewName = `pr-${pullRequestNumber}`; - -const isCi = process.env.CI === "true"; - -const run = ( - command: string, - args: ReadonlyArray, - cwd = root, - env: NodeJS.ProcessEnv = process.env, -): string => { - const result = spawnSync(command, args, { cwd, encoding: "utf8", env }); - - if (result.status !== 0) { - process.stderr.write(result.stdout ?? ""); - process.stderr.write(result.stderr ?? ""); - process.exit(result.status ?? 1); - } - - return result.stdout.trim(); -}; - -const runVisible = ( - command: string, - args: ReadonlyArray, - cwd: string, - env: NodeJS.ProcessEnv, -): void => { - const result = spawnSync(command, args, { cwd, env, stdio: "inherit" }); - - if (result.status !== 0) process.exit(result.status ?? 1); -}; - -const localHead = (): string => { - const branch = run("git", ["branch", "--show-current"]); - - if (branch.length === 0 || branch === "main") { - fail("Preview deployment requires a named non-main branch."); - } - - if (run("git", ["status", "--porcelain", "--untracked-files=no"]).length > 0) { - fail("Refusing preview deployment from a dirty tracked worktree."); - } - - const head = run("git", ["rev-parse", "HEAD"]); - - if (head !== run("git", ["rev-parse", `origin/${branch}`])) { - fail("Refusing preview deployment: HEAD must exactly match the pushed branch revision."); - } - - return head; -}; - -const head = isCi ? process.env.PREVIEW_HEAD_SHA : localHead(); - -if (head === undefined || !/^[0-9a-f]{40}$/.test(head)) { - fail("PREVIEW_HEAD_SHA must be the exact 40-character pull-request revision."); -} +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import process from "node:process"; +import { Config, Console, Data, Effect, FileSystem, Option, Path, Schema } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { runCommand } from "./command.js"; +import { exitWithReturnedCode } from "./exit-code.js"; + +/** A refusal or a failed command; the program prints its message and exits with its code. */ +class PreviewFailure extends Data.TaggedError("PreviewFailure")<{ + readonly message: string; + readonly exitCode: number; +}> {} + +const fail = (message: string) => Effect.fail(new PreviewFailure({ message, exitCode: 1 })); const PreviewResult = Schema.Struct({ deployment: Schema.optionalKey( @@ -101,90 +25,183 @@ const PreviewResult = Schema.Struct({ preview_urls: Schema.optionalKey(Schema.Array(Schema.String)), }); -type PreviewUrls = { - readonly deployment: string; - readonly preview: string; -}; +const decodePreviewResult = Schema.decodeEffect(Schema.fromJsonString(PreviewResult)); -const parsePreviewResult = (output: string): PreviewUrls => { +const parsePreviewResult = Effect.fnUntraced(function* (output: string) { const jsonStart = output.lastIndexOf("\n{"); - const parsed = Schema.decodeSync(Schema.fromJsonString(PreviewResult))( - jsonStart === -1 ? output : output.slice(jsonStart + 1), + const parsed = yield* Effect.orDie( + decodePreviewResult(jsonStart === -1 ? output : output.slice(jsonStart + 1)), ); const preview = parsed.preview?.urls?.[0] ?? parsed.preview_urls?.[0]; const deployment = parsed.deployment?.urls?.[0] ?? parsed.deployment_urls?.[0]; - if (preview === undefined || deployment === undefined) { - throw new Error("Wrangler did not return Preview and deployment URLs."); - } + if (preview === undefined || deployment === undefined) + return yield* Effect.die("Wrangler did not return Preview and deployment URLs."); return { deployment, preview }; -}; - -const buildEnvironment = { - ...process.env, - DASHBOARD_MOUNT: "/", - PREVIEW_HOST_SUFFIX: ".workers.dev", - PREVIEW_STAGE: "worker-preview", -}; - -if (!isCi) { - writeLine("Building homepage Worker Preview"); - runVisible("bun", ["run", "build"], homepageRoot, buildEnvironment); - writeLine("Building dashboard Worker Preview"); - runVisible("bun", ["run", "build"], dashboardRoot, buildEnvironment); -} - -for (const requiredPath of [ - resolve(homepageRoot, "build/server/index.js"), - resolve(homepageRoot, "build/client"), - resolve(dashboardRoot, "build/server/index.js"), - resolve(dashboardRoot, "build/client"), -]) { - if (!existsSync(requiredPath)) { - fail(`Missing Preview build artifact: ${requiredPath}`); +}); + +/** An environment variable, or undefined when it is not set. */ +const variable = (name: string) => + Effect.map(Config.option(Config.String(name)), Option.getOrUndefined); + +const program = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const root = path.resolve(import.meta.dir, "../.."); + const homepageRoot = path.resolve(root, "apps/homepage"); + const dashboardRoot = path.resolve(root, "apps/dashboard"); + const homepageConfig = path.resolve(root, "infra/previews/homepage.wrangler.json"); + const dashboardConfig = path.resolve(root, "infra/previews/dashboard.wrangler.json"); + const wrangler = path.resolve(root, "node_modules/.bin/wrangler"); + const pullRequestNumber = process.argv[2]; + + if (pullRequestNumber === undefined || !/^[1-9]\d*$/.test(pullRequestNumber)) + return yield* fail("Usage: bun tools/scripts/deploy-preview.ts "); + + const previewName = `pr-${pullRequestNumber}`; + const isCi = (yield* variable("CI")) === "true"; + + // Prints the output of a failed command and exits with its code. + const run = Effect.fnUntraced(function* ( + command: string, + args: ReadonlyArray, + cwd = root, + ) { + const result = yield* runCommand(ChildProcess.make(command, args, { cwd, stdin: "ignore" })); + + if (result.status !== 0) + return yield* new PreviewFailure({ + message: `${result.stdout}${result.stderr}`, + exitCode: result.status, + }); + + return result.stdout.trim(); + }); + + const runVisible = Effect.fnUntraced(function* ( + command: string, + args: ReadonlyArray, + cwd: string, + env: Readonly>, + ) { + const status = yield* spawner.exitCode( + ChildProcess.make(command, args, { + cwd, + env, + extendEnv: true, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + detached: false, + }), + ); + + if (status !== 0) return yield* new PreviewFailure({ message: "", exitCode: status }); + }); + + const localHead = Effect.gen(function* () { + const branch = yield* run("git", ["branch", "--show-current"]); + + if (branch.length === 0 || branch === "main") + return yield* fail("Preview deployment requires a named non-main branch."); + + if ((yield* run("git", ["status", "--porcelain", "--untracked-files=no"])).length > 0) + return yield* fail("Refusing preview deployment from a dirty tracked worktree."); + + const head = yield* run("git", ["rev-parse", "HEAD"]); + + if (head !== (yield* run("git", ["rev-parse", `origin/${branch}`]))) + return yield* fail( + "Refusing preview deployment: HEAD must exactly match the pushed branch revision.", + ); + + return head; + }); + + const head = isCi ? yield* variable("PREVIEW_HEAD_SHA") : yield* localHead; + + if (head === undefined || !/^[0-9a-f]{40}$/.test(head)) + return yield* fail("PREVIEW_HEAD_SHA must be the exact 40-character pull-request revision."); + + const buildEnvironment = { + DASHBOARD_MOUNT: "/", + PREVIEW_HOST_SUFFIX: ".workers.dev", + PREVIEW_STAGE: "worker-preview", + }; + + if (!isCi) { + yield* Console.log("Building homepage Worker Preview"); + yield* runVisible("bun", ["run", "build"], homepageRoot, buildEnvironment); + yield* Console.log("Building dashboard Worker Preview"); + yield* runVisible("bun", ["run", "build"], dashboardRoot, buildEnvironment); } -} - -// `wrangler preview` creates a missing Worker as an empty parent with the config's `workers_dev` -// and `preview_urls` settings and no production version (Wrangler 4.125.0+, workers-sdk#15174). -const previewArgs = [ - "preview", - "--name", - previewName, - "--tag", - head.slice(0, 12), - "--message", - `PR #${pullRequestNumber} ${head}`, - "--ignore-base-config", - "--json", -] as const; - -writeLine(`Deploying homepage Worker Preview ${previewName}`); - -const homepage = parsePreviewResult(run(wrangler, [...previewArgs, "--config", homepageConfig])); - -writeLine(`Deploying dashboard Worker Preview ${previewName}`); - -const dashboard = parsePreviewResult(run(wrangler, [...previewArgs, "--config", dashboardConfig])); - -const repository = process.env.GITHUB_REPOSITORY ?? "vektorprogrammet/mono-web"; - -const outputs = { - commit: head, - dashboard_deployment_url: dashboard.deployment, - dashboard_url: dashboard.preview, - homepage_deployment_url: homepage.deployment, - homepage_url: homepage.preview, - source_url: `https://github.com/${repository}/tree/${head}`, -}; - -for (const [name, value] of Object.entries(outputs)) { - writeLine(`${name}: ${value}`); - - if (process.env.GITHUB_OUTPUT !== undefined) { - appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); + + for (const requiredPath of [ + path.resolve(homepageRoot, "build/server/index.js"), + path.resolve(homepageRoot, "build/client"), + path.resolve(dashboardRoot, "build/server/index.js"), + path.resolve(dashboardRoot, "build/client"), + ]) { + if (!(yield* fileSystem.exists(requiredPath))) + return yield* fail(`Missing Preview build artifact: ${requiredPath}`); } -} + + // `wrangler preview` creates a missing Worker as an empty parent with the config's `workers_dev` + // and `preview_urls` settings and no production version (Wrangler 4.125.0+, workers-sdk#15174). + const previewArgs = [ + "preview", + "--name", + previewName, + "--tag", + head.slice(0, 12), + "--message", + `PR #${pullRequestNumber} ${head}`, + "--ignore-base-config", + "--json", + ] as const; + + yield* Console.log(`Deploying homepage Worker Preview ${previewName}`); + + const homepage = yield* parsePreviewResult( + yield* run(wrangler, [...previewArgs, "--config", homepageConfig]), + ); + + yield* Console.log(`Deploying dashboard Worker Preview ${previewName}`); + + const dashboard = yield* parsePreviewResult( + yield* run(wrangler, [...previewArgs, "--config", dashboardConfig]), + ); + + const repository = (yield* variable("GITHUB_REPOSITORY")) ?? "vektorprogrammet/mono-web"; + const githubOutput = yield* variable("GITHUB_OUTPUT"); + + const outputs = { + commit: head, + dashboard_deployment_url: dashboard.deployment, + dashboard_url: dashboard.preview, + homepage_deployment_url: homepage.deployment, + homepage_url: homepage.preview, + source_url: `https://github.com/${repository}/tree/${head}`, + }; + + for (const [name, value] of Object.entries(outputs)) { + yield* Console.log(`${name}: ${value}`); + + if (githubOutput !== undefined) + yield* fileSystem.writeFileString(githubOutput, `${name}=${value}\n`, { flag: "a" }); + } + + return 0; +}).pipe( + Effect.catchTag("PreviewFailure", ({ message, exitCode }) => + (message === "" ? Effect.void : Console.error(message.replace(/\n$/u, ""))).pipe( + Effect.as(exitCode), + ), + ), +); + +BunRuntime.runMain(program.pipe(Effect.provide(BunServices.layer)), exitWithReturnedCode); diff --git a/tools/scripts/dev.ts b/tools/scripts/dev.ts index a1ed39e81..ef80d150b 100644 --- a/tools/scripts/dev.ts +++ b/tools/scripts/dev.ts @@ -1,19 +1,11 @@ -import { spawn } from "node:child_process"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; import { createRequire } from "node:module"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import process from "node:process"; +import { Config, Console, Data, Effect, Match, Option, Path, Schema } from "effect"; +import { jobDisposition, runJob } from "./job-process.js"; -const root = fileURLToPath(new URL("../../", import.meta.url)); - -const fail = (message: string): never => { - process.stderr.write(`Local development: ${message}\n`); - process.exit(1); -}; - -const args = process.argv.slice(2).filter((argument) => argument !== "--"); - -if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) { - process.stdout.write(`Usage: just dev [--help] +const usage = `Usage: just dev [--help] \`devenv up\` starts the devenv PostgreSQL service and then this launcher with BACKEND_PG_URL set to that service's database. @@ -42,99 +34,120 @@ Receipt files and database contents persist across restarts. External delivery is disabled. Queued delivery does not prove mail delivery. Backend package .env files are disabled; provider environment is not inherited. Ctrl+C stops the application tasks owned by Turbo, not existing services. -`); - process.exit(0); -} - -if (args.length !== 0) fail("Unknown argument. Use just dev --help."); - -const postgresUrl = process.env.BACKEND_PG_URL; - -if (!postgresUrl) fail("Set BACKEND_PG_URL to a dedicated local PostgreSQL database URL."); - -let database: URL; - -try { - database = new URL(postgresUrl); -} catch { - fail("BACKEND_PG_URL must be a PostgreSQL URL."); -} - -if ( - !["postgres:", "postgresql:"].includes(database.protocol) || - !["127.0.0.1", "localhost", "[::1]"].includes(database.hostname) || - database.pathname.length < 2 || - database.search !== "" || - database.hash !== "" || - (database.port !== "" && (Number(database.port) < 1 || Number(database.port) > 65_535)) -) { - fail( - "BACKEND_PG_URL must name a loopback PostgreSQL database, without query parameters or fragments.", - ); -} +`; + +/** A refusal; the launcher prints it and exits 1. */ +class DevFailure extends Data.TaggedError("DevFailure")<{ readonly message: string }> {} -const secret = process.env.BETTER_AUTH_SECRET; +const fail = (message: string) => Effect.fail(new DevFailure({ message })); -if (secret === undefined || secret.trim().length < 32) { - fail("Set BETTER_AUTH_SECRET to at least 32 characters."); -} +/** An environment variable, or undefined when it is not set. */ +const variable = (name: string) => + Effect.map(Config.option(Config.String(name)), Option.getOrUndefined); -const port = (name: string, fallback: number): number => { - const raw = process.env[name] ?? String(fallback); - const value = Number(raw); +const encodeOrigins = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); - if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < 1 || value > 65_535) { - fail(`${name} must be an integer from 1 to 65535.`); +const disposition = jobDisposition(); + +const program = Effect.gen(function* () { + const path = yield* Path.Path; + const root = path.join(import.meta.dir, "..", ".."); + const args = process.argv.slice(2).filter((argument) => argument !== "--"); + + if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) { + yield* Console.log(usage.trimEnd()); + + return 0; } - return value; -}; + if (args.length !== 0) return yield* fail("Unknown argument. Use just dev --help."); -const backendPort = port("LOCAL_BACKEND_PORT", 8791); + const postgresUrl = (yield* variable("BACKEND_PG_URL")) ?? ""; -const dashboardPort = port("LOCAL_DASHBOARD_PORT", 5173); + if (postgresUrl === "") + return yield* fail("Set BACKEND_PG_URL to a dedicated local PostgreSQL database URL."); -const homepagePort = port("LOCAL_HOMEPAGE_PORT", 8787); + const database = URL.canParse(postgresUrl) ? new URL(postgresUrl) : undefined; -if (new Set([backendPort, dashboardPort, homepagePort]).size !== 3) { - fail("LOCAL_BACKEND_PORT, LOCAL_DASHBOARD_PORT, and LOCAL_HOMEPAGE_PORT must be distinct."); -} + if (database === undefined) return yield* fail("BACKEND_PG_URL must be a PostgreSQL URL."); + + if ( + !["postgres:", "postgresql:"].includes(database.protocol) || + !["127.0.0.1", "localhost", "[::1]"].includes(database.hostname) || + database.pathname.length < 2 || + database.search !== "" || + database.hash !== "" || + (database.port !== "" && (Number(database.port) < 1 || Number(database.port) > 65_535)) + ) { + return yield* fail( + "BACKEND_PG_URL must name a loopback PostgreSQL database, without query parameters or fragments.", + ); + } + + const secret = yield* variable("BETTER_AUTH_SECRET"); + + if (secret === undefined || secret.trim().length < 32) { + return yield* fail("Set BETTER_AUTH_SECRET to at least 32 characters."); + } + + const port = Effect.fnUntraced(function* (name: string, fallback: number) { + const raw = (yield* variable(name)) ?? String(fallback); + const value = Number(raw); + + if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < 1 || value > 65_535) { + return yield* fail(`${name} must be an integer from 1 to 65535.`); + } + + return value; + }); + + const backendPort = yield* port("LOCAL_BACKEND_PORT", 8791); + const dashboardPort = yield* port("LOCAL_DASHBOARD_PORT", 5173); + const homepagePort = yield* port("LOCAL_HOMEPAGE_PORT", 8787); + + if (new Set([backendPort, dashboardPort, homepagePort]).size !== 3) { + return yield* fail( + "LOCAL_BACKEND_PORT, LOCAL_DASHBOARD_PORT, and LOCAL_HOMEPAGE_PORT must be distinct.", + ); + } -const mount = process.env.DASHBOARD_MOUNT ?? "/dashboard/"; + const mount = (yield* variable("DASHBOARD_MOUNT")) ?? "/dashboard/"; -if (mount !== "/dashboard/" && mount !== "/") fail("DASHBOARD_MOUNT must be /dashboard/ or /."); + if (mount !== "/dashboard/" && mount !== "/") + return yield* fail("DASHBOARD_MOUNT must be /dashboard/ or /."); -const receiptRoot = (name: string, fallback: string): string => { - const value = process.env[name] ?? fallback; + const receiptRoot = Effect.fnUntraced(function* (name: string, fallback: string) { + const value = (yield* variable(name)) ?? fallback; - if (value.trim().length === 0) fail(`${name} must not be empty.`); + if (value.trim().length === 0) return yield* fail(`${name} must not be empty.`); - return resolve(root, value); -}; + return path.resolve(root, value); + }); -const backendOrigin = `http://127.0.0.1:${backendPort}`; + const backendOrigin = `http://127.0.0.1:${backendPort}`; + const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; -const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; + // Inherit terminal/tool discovery only, never provider credentials, remote endpoints, + // PostgreSQL overrides, preload hooks, or release/rehearsal configuration. + const env: Record = {}; -// Inherit terminal/tool discovery only, never provider credentials, remote endpoints, -// PostgreSQL overrides, preload hooks, or release/rehearsal configuration. -const env: NodeJS.ProcessEnv = {}; + for (const key of [ + "PATH", + "HOME", + "TMPDIR", + "LANG", + "LC_ALL", + "TERM", + "COLORTERM", + "NO_COLOR", + "FORCE_COLOR", + ]) { + const value = yield* variable(key); -for (const key of [ - "PATH", - "HOME", - "TMPDIR", - "LANG", - "LC_ALL", - "TERM", - "COLORTERM", - "NO_COLOR", - "FORCE_COLOR", -]) { - if (process.env[key] !== undefined) env[key] = process.env[key]; -} + if (value !== undefined) env[key] = value; + } -Object.assign(env, { + Object.assign(env, { NODE_ENV: "development", TURBO_TELEMETRY_DISABLED: "1", DO_NOT_TRACK: "1", @@ -147,15 +160,15 @@ Object.assign(env, { LOCAL_DASHBOARD_PORT: String(dashboardPort), LOCAL_HOMEPAGE_PORT: String(homepagePort), NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify([dashboardOrigin]), + NATIVE_IDENTITY_TRUSTED_ORIGINS: encodeOrigins([dashboardOrigin]), OAUTH_CANONICAL_ORIGIN: backendOrigin, OAUTH_DASHBOARD_ORIGIN: dashboardOrigin, OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", API_URL: backendOrigin, VITE_API_URL: dashboardOrigin, DASHBOARD_MOUNT: mount, - RECEIPT_STAGING_ROOT: receiptRoot("RECEIPT_STAGING_ROOT", ".cache/local-dev/receipts/staging"), - RECEIPT_COMMITTED_ROOT: receiptRoot( + RECEIPT_STAGING_ROOT: yield* receiptRoot("RECEIPT_STAGING_ROOT", ".cache/local-dev/receipts/staging"), + RECEIPT_COMMITTED_ROOT: yield* receiptRoot( "RECEIPT_COMMITTED_ROOT", ".cache/local-dev/receipts/committed", ), @@ -167,72 +180,63 @@ Object.assign(env, { TEAM_APPLICATION_DELIVERY_MODE: "disabled", }); -// Use the installed native Turbo binary directly. Its JavaScript wrapper uses -// execFileSync, which prevents reliable signal forwarding and can auto-install. -const require = createRequire(import.meta.url); - -const turboRequire = createRequire(require.resolve("turbo/package.json")); - -const platform = process.platform === "win32" ? "windows" : process.platform; - -const architecture = process.arch === "x64" ? "64" : process.arch; - -const executable = process.platform === "win32" ? "turbo.exe" : "turbo"; + // Use the installed native Turbo binary directly. Its JavaScript wrapper uses + // execFileSync, which prevents reliable signal forwarding and can auto-install. + const require = createRequire(import.meta.url); + const turboRequire = createRequire(require.resolve("turbo/package.json")); + const platform = process.platform === "win32" ? "windows" : process.platform; + const architecture = process.arch === "x64" ? "64" : process.arch; + const executable = process.platform === "win32" ? "turbo.exe" : "turbo"; + + const turbo = yield* Effect.try({ + try: () => turboRequire.resolve(`turbo-${platform}-${architecture}/bin/${executable}`), + catch: () => + new DevFailure({ + message: "The installed Turbo binary is missing. Run bun install before just dev.", + }), + }); + + yield* Console.log( + `Homepage: http://127.0.0.1:${homepagePort}\nDashboard: ${dashboardOrigin}${mount}\nBackend: ${backendOrigin}\nExternal delivery: disabled`, + ); -let turbo: string; + // Turbo owns task startup, failure cancellation, and descendant shutdown. A signal that this + // launcher receives goes to Turbo, and the launcher then exits as that signal asks. + const turboExit = yield* runJob({ + command: turbo, + arguments: [ + "run", + "dev", + "--env-mode=loose", + "--ui=stream", + "--no-daemon", + "--filter=@vektorprogrammet/backend", + "--filter=@monoweb/homepage", + "--filter=@monoweb/dashboard", + ], + variables: env, + isolated: true, + cwd: root, + forwardedSignals: ["SIGINT", "SIGTERM"], + }).pipe(Effect.catchTag("JobStartFailure", () => fail("Turbo could not start."))); + + const stoppedBy = turboExit.forwarded ?? turboExit.signal; + + const exitCode = Match.value(stoppedBy).pipe( + Match.when("SIGINT", () => 130), + Match.when("SIGTERM", () => 143), + Match.orElse(() => turboExit.exitCode ?? 1), + ); -try { - turbo = turboRequire.resolve(`turbo-${platform}-${architecture}/bin/${executable}`); -} catch { - fail("The installed Turbo binary is missing. Run bun install before just dev."); -} + yield* disposition.record({ exitCode, signal: null }); -process.stdout.write( - `Homepage: http://127.0.0.1:${homepagePort}\nDashboard: ${dashboardOrigin}${mount}\nBackend: ${backendOrigin}\nExternal delivery: disabled\n`, -); - -const child = spawn( - turbo, - [ - "run", - "dev", - "--env-mode=loose", - "--ui=stream", - "--no-daemon", - "--filter=@vektorprogrammet/backend", - "--filter=@monoweb/homepage", - "--filter=@monoweb/dashboard", - ], - { cwd: root, env, stdio: "inherit" }, + return exitCode; +}).pipe( + Effect.catchTag("DevFailure", ({ message }) => + Console.error(`Local development: ${message}`).pipe(Effect.as(1)), + ), ); -// Turbo owns task startup, failure cancellation, and descendant shutdown. -let interrupted: NodeJS.Signals | undefined; - -const interrupt = (signal: NodeJS.Signals) => { - interrupted ??= signal; - child.kill(signal); -}; - -const sigint = () => interrupt("SIGINT"); - -const sigterm = () => interrupt("SIGTERM"); - -process.on("SIGINT", sigint); - -process.on("SIGTERM", sigterm); - -child.once("error", () => { - process.stderr.write("Local development: Turbo could not start.\n"); - process.exitCode = 1; -}); - -child.once("close", (code, signal) => { - process.off("SIGINT", sigint); - process.off("SIGTERM", sigterm); - const stoppedBy = interrupted ?? signal; - - if (stoppedBy === "SIGINT") process.exitCode = 130; - else if (stoppedBy === "SIGTERM") process.exitCode = 143; - else process.exitCode = code ?? 1; +BunRuntime.runMain(program.pipe(Effect.provide(BunServices.layer)), { + teardown: disposition.teardown, }); diff --git a/tools/scripts/exit-code.ts b/tools/scripts/exit-code.ts new file mode 100644 index 000000000..8da8f982c --- /dev/null +++ b/tools/scripts/exit-code.ts @@ -0,0 +1,14 @@ +/** + * The run options of the scripts' composition roots: a program that succeeds with a number exits + * with that code, and a failure or an interruption exits as `Runtime.defaultTeardown` decides. + */ +import { Exit, Predicate } from "effect"; +import * as Runtime from "effect/Runtime"; + +const teardown: Runtime.Teardown = (exit, onExit) => { + if (Exit.isSuccess(exit) && Predicate.isNumber(exit.value)) onExit(exit.value); + else Runtime.defaultTeardown(exit, onExit); +}; + +/** `BunRuntime.runMain` options that exit with the code that the program returns. */ +export const exitWithReturnedCode = { teardown } as const; diff --git a/tools/scripts/heavy-lock.ts b/tools/scripts/heavy-lock.ts index 1a676d772..43ed91795 100644 --- a/tools/scripts/heavy-lock.ts +++ b/tools/scripts/heavy-lock.ts @@ -11,21 +11,49 @@ * holds, and a waiting process shows the lines of the live holders. * * The lock belongs to the process that takes it. flock(1) locks the open file description of its - * standard input; this process keeps the descriptors open and does not pass them to its job, so - * the lock is released when this process exits, also on a signal. The job's processes inherit - * `VEKTORPROGRAMMET_HEAVY_LOCK`, the holder's process id. A `just measure` or hook slot started - * inside the job, such as the hooks of a commit, finds the live holder and does not take the lock - * again, which would wait for itself. + * standard input; this process keeps the descriptors open in the scope of its program and does + * not pass them to its job, so the lock is released when this process exits, also on a signal. + * The job's processes inherit `VEKTORPROGRAMMET_HEAVY_LOCK`, the holder's process id. A + * `just measure` or hook slot started inside the job, such as the hooks of a commit, finds the + * live holder and does not take the lock again, which would wait for itself. */ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0013: flock(1) locks the descriptor that it gets as standard input, which ChildProcess cannot pass import { spawnSync } from "node:child_process"; -import { closeSync, ftruncateSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs"; -import { join } from "node:path"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0013: flock(1) needs the descriptor of a lock file, which a FileSystem File does not expose +import { closeSync, ftruncateSync, openSync, writeSync } from "node:fs"; +import process from "node:process"; +import { Clock, Config, Data, DateTime, Effect, Exit, FileSystem, Path, Scope } from "effect"; export type HeavyLockMode = "exclusive" | "shared"; /** Names the process that holds the heavy lock for the job's processes. */ export const heavyLockVariable = "VEKTORPROGRAMMET_HEAVY_LOCK"; +/** A lock file could not be opened, read, or written, or flock(1) failed. */ +export class HeavyLockFailure extends Data.TaggedError("HeavyLockFailure")<{ + readonly message: string; +}> {} + +const failure = (error: { readonly message: string }) => + new HeavyLockFailure({ message: error.message }); + +const two = (value: number) => String(value).padStart(2, "0"); + +/** The local date and time of now, as `YYYY-MM-DD` and `HH:MM:SS`. */ +export const localNow: Effect.Effect<{ readonly date: string; readonly time: string }> = + Effect.map(DateTime.now, (now) => { + const parts = DateTime.toParts(DateTime.setZone(now, DateTime.zoneMakeLocal())); + + return { + date: `${parts.year}-${two(parts.month)}-${two(parts.day)}`, + time: `${two(parts.hour)}:${two(parts.minute)}:${two(parts.second)}`, + }; + }); + +/** Whole seconds since a reading of `Clock.currentTimeNanos`. */ +export const secondsSince = (startNanos: bigint): Effect.Effect => + Effect.map(Clock.currentTimeNanos, (now) => Math.round(Number(now - startNanos) / 1e9)); + const alive = (pid: number) => { try { process.kill(pid, 0); @@ -43,179 +71,290 @@ const liveHolder = (line: string) => { return Number.isInteger(pid) && alive(pid); }; -// Waits at most `timeoutSeconds` (0: tries once), or without a limit when it is undefined. -// Returns false when the time ran out. -const flock = (descriptor: number, mode: HeavyLockMode, timeoutSeconds?: number) => { - const { status, signal } = spawnSync( - "flock", - [`--${mode}`, ...(timeoutSeconds === undefined ? [] : ["--timeout", String(timeoutSeconds)]), "0"], - { stdio: [descriptor, "inherit", "inherit"] }, - ); +/** + * Runs flock(1) on a descriptor of this process. It waits at most `timeoutSeconds` (0: tries + * once), or without a limit when it is undefined, and succeeds with false when the time ran out. + */ +export const flockDescriptor = ({ + descriptor, + arguments: flockArguments, + timedOut, +}: { + readonly descriptor: number; + readonly arguments: ReadonlyArray; + /** Whether exit status 1 means that the time ran out rather than a failure. */ + readonly timedOut: boolean; +}): Effect.Effect => + Effect.suspend(() => { + const { status, signal } = spawnSync("flock", [...flockArguments, "0"], { + stdio: [descriptor, "inherit", "inherit"], + }); - if (status === 0) return true; + if (status === 0) return Effect.succeed(true); - if (status === 1 && timeoutSeconds !== undefined) return false; + if (status === 1 && timedOut) return Effect.succeed(false); - throw new Error(`flock exited with ${status ?? signal}.`); -}; + return Effect.fail(new HeavyLockFailure({ message: `flock exited with ${status ?? signal}.` })); + }); + +const flock = (descriptor: number, mode: HeavyLockMode, timeoutSeconds?: number) => + flockDescriptor({ + descriptor, + arguments: [ + `--${mode}`, + ...(timeoutSeconds === undefined ? [] : ["--timeout", String(timeoutSeconds)]), + ], + timedOut: timeoutSeconds !== undefined, + }); /** Whether this process runs inside a job whose holder of the heavy lock is alive. */ -export const insideHeavyLock = () => { - const holder = Number(process.env[heavyLockVariable]); +export const insideHeavyLock: Effect.Effect = Effect.map( + Effect.mapError(Config.withDefault(Config.String(heavyLockVariable), ""), failure), + (value) => { + const holder = Number(value); + + return Number.isInteger(holder) && holder > 0 && alive(holder); + }, +); + +/** `${XDG_RUNTIME_DIR:-/tmp}/vektorprogrammet`, where an empty XDG_RUNTIME_DIR counts as unset. */ +export const lockDirectory: Effect.Effect = Effect.gen( + function* () { + const path = yield* Path.Path; + + const runtime = yield* Effect.mapError( + Config.withDefault(Config.String("XDG_RUNTIME_DIR"), ""), + failure, + ); - return Number.isInteger(holder) && holder > 0 && alive(holder); -}; + return path.join(runtime === "" ? "/tmp" : runtime, "vektorprogrammet"); + }, +); + +/** Opens a lock file for appending; the descriptor stays open until the scope closes. */ +const openLockFile = (path: string): Effect.Effect => + Effect.acquireRelease( + Effect.try({ + try: () => openSync(path, "a"), + catch: (error) => new HeavyLockFailure({ message: String(error) }), + }), + (descriptor) => Effect.sync(() => closeSync(descriptor)), + ); -const lockDirectory = () => join(process.env.XDG_RUNTIME_DIR || "/tmp", "vektorprogrammet"); +/** Replaces the content of a lock file that is open for appending. */ +const replaceLockText = (descriptor: number, content: string): Effect.Effect => + Effect.try({ + try: () => { + ftruncateSync(descriptor, 0); + writeSync(descriptor, content); + }, + catch: (error) => new HeavyLockFailure({ message: String(error) }), + }); + +const noVariables: Readonly> = {}; + +/** What `takeHeavyLock` takes the lock for. */ +export interface HeavyLockRequest { + readonly mode: HeavyLockMode; + readonly jobClass: string; + readonly command: ReadonlyArray; + readonly log: (message: string) => Effect.Effect; +} /** * Takes the heavy lock for this process, unless the process runs inside a job that holds it. - * Shows the holders while it waits. Returns the environment for the job's processes. + * Shows the holders while it waits. Succeeds with the variables that the job's processes add to + * their environment. The lock files stay open, and the lock held, until the scope closes. */ -export const takeHeavyLock = ( - mode: HeavyLockMode, - jobClass: string, - command: ReadonlyArray, - log: (message: string) => void, -): NodeJS.ProcessEnv => { - if (insideHeavyLock()) return process.env; - - const directory = lockDirectory(); - const lockPath = join(directory, "heavy.lock"); - const gatePath = join(directory, "heavy-gate.lock"); - const waitStart = performance.now(); - let waited = false; - - const holderLine = () => { - const now = new Date(); - const pad = (value: number) => String(value).padStart(2, "0"); - const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; - - return `pid ${process.pid}, ${jobClass}, since ${date} ${now.toTimeString().slice(0, 8)}, ${process.cwd()}: ${command.join(" ")}`; - }; - - mkdirSync(directory, { recursive: true }); - - const gate = openSync(gatePath, "a"); - const lock = openSync(lockPath, "a"); - - // A shared holder passes the gate in milliseconds, and an exclusive holder writes the gate file - // as soon as it takes the gate. A wait of more than a second is a wait for that heavy job. - if (!flock(gate, "exclusive", 1)) { - const heavyJob = readFileSync(gatePath, "utf8").trim(); - - log(`waiting for the heavy job ${liveHolder(heavyJob) ? heavyJob : "of an unknown holder"}`); - waited = true; - flock(gate, "exclusive"); - } +export const takeHeavyLock = ({ + mode, + jobClass, + command, + log, +}: HeavyLockRequest): Effect.Effect< + Readonly>, + HeavyLockFailure, + FileSystem.FileSystem | Path.Path | Scope.Scope +> => + Effect.gen(function* () { + if (yield* insideHeavyLock) return noVariables; + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Scope.Scope; + const directory = yield* lockDirectory; + const lockPath = path.join(directory, "heavy.lock"); + const gatePath = path.join(directory, "heavy-gate.lock"); + const waitStart = yield* Clock.currentTimeNanos; + let waited = false; + + const holderLine = Effect.map( + localNow, + ({ date, time }) => + `pid ${process.pid}, ${jobClass}, since ${date} ${time}, ${process.cwd()}: ${command.join(" ")}`, + ); + + const readLockText = (file: string) => Effect.mapError(fileSystem.readFileString(file), failure); - if (mode === "exclusive") { - ftruncateSync(gate, 0); - writeSync(gate, `${holderLine()}\n`); + yield* Effect.mapError(fileSystem.makeDirectory(directory, { recursive: true }), failure); - if (!flock(lock, "exclusive", 0)) { - const hookJobs = readFileSync(lockPath, "utf8").split("\n").filter(liveHolder); + // A shared holder closes the gate as soon as it holds the lock, so the gate has its own scope. + const gateScope = yield* Scope.fork(scope); + const gate = yield* Scope.provide(openLockFile(gatePath), gateScope); + const lock = yield* openLockFile(lockPath); - log( - `waiting for ${hookJobs.length} running hook job${hookJobs.length === 1 ? "" : "s"}:` + - hookJobs.map((hookJob) => `\n ${hookJob}`).join(""), + // A shared holder passes the gate in milliseconds, and an exclusive holder writes the gate + // file as soon as it takes the gate. A wait of more than a second is a wait for that heavy job. + if (!(yield* flock(gate, "exclusive", 1))) { + const heavyJob = (yield* readLockText(gatePath)).trim(); + + yield* log( + `waiting for the heavy job ${liveHolder(heavyJob) ? heavyJob : "of an unknown holder"}`, ); waited = true; - flock(lock, "exclusive"); + yield* flock(gate, "exclusive"); } - ftruncateSync(lock, 0); - writeSync(lock, `${holderLine()}\n`); - } else { - // No exclusive holder can hold the lock while this process holds the gate. - flock(lock, "shared"); + if (mode === "exclusive") { + yield* replaceLockText(gate, `${yield* holderLine}\n`); - const hookJobs = readFileSync(lockPath, "utf8").split("\n").filter(liveHolder); + if (!(yield* flock(lock, "exclusive", 0))) { + const hookJobs = (yield* readLockText(lockPath)).split("\n").filter(liveHolder); - ftruncateSync(lock, 0); - writeSync(lock, `${[...hookJobs, holderLine()].join("\n")}\n`); - closeSync(gate); - } + yield* log( + `waiting for ${hookJobs.length} running hook job${hookJobs.length === 1 ? "" : "s"}:` + + hookJobs.map((hookJob) => `\n ${hookJob}`).join(""), + ); + waited = true; + yield* flock(lock, "exclusive"); + } - if (waited) - log(`got the heavy lock after ${Math.round((performance.now() - waitStart) / 1000)}s`); + yield* replaceLockText(lock, `${yield* holderLine}\n`); + } else { + // No exclusive holder can hold the lock while this process holds the gate. + yield* flock(lock, "shared"); - return { ...process.env, [heavyLockVariable]: String(process.pid) }; -}; + const hookJobs = (yield* readLockText(lockPath)).split("\n").filter(liveHolder); + + yield* replaceLockText(lock, `${[...hookJobs, yield* holderLine].join("\n")}\n`); + yield* Scope.close(gateScope, Exit.void); + } + + if (waited) yield* log(`got the heavy lock after ${yield* secondsSince(waitStart)}s`); + + return { [heavyLockVariable]: String(process.pid) }; + }); + +/** What `takeHookSlot` takes a slot for. */ +export interface HookSlotRequest { + readonly jobClass: string; + readonly log: (message: string) => Effect.Effect; +} /** * Takes one of the N machine-wide hook slots for this process, a flock(1) lock on * ${XDG_RUNTIME_DIR:-/tmp}/vektorprogrammet/hook-slot-<1..N> * N is ${VEKTORPROGRAMMET_HOOK_SLOTS:-5}. If all slots are busy, it shows their holders and waits - * for one. Like the heavy lock, the slot is released when this process exits, also on a signal. - * Take the heavy lock first: a job that waits for a heavy job then holds no slot, so hook jobs - * inside that heavy job, such as the hooks of its commits, still get one. + * for one. Like the heavy lock, the slot is held until the scope closes, and released when this + * process exits, also on a signal. Take the heavy lock first: a job that waits for a heavy job + * then holds no slot, so hook jobs inside that heavy job, such as the hooks of its commits, still + * get one. */ -export const takeHookSlot = (jobClass: string, log: (message: string) => void) => { - // slots = min(memory bound 23.5 GiB / 3.2 GiB = 7, CPU bound 32 / 6 = 5) on - // the development machine. AGENTS.md shows how to derive it for another one. - const slotCount = Number(process.env.VEKTORPROGRAMMET_HOOK_SLOTS || "5"); +export const takeHookSlot = ({ + jobClass, + log, +}: HookSlotRequest): Effect.Effect< + { readonly slot: number; readonly slotCount: number }, + HeavyLockFailure, + FileSystem.FileSystem | Path.Path | Scope.Scope +> => + Effect.gen(function* () { + // slots = min(memory bound 23.5 GiB / 3.2 GiB = 7, CPU bound 32 / 6 = 5) on + // the development machine. AGENTS.md shows how to derive it for another one. + const configured = yield* Effect.mapError( + Config.withDefault(Config.String("VEKTORPROGRAMMET_HOOK_SLOTS"), ""), + failure, + ); - if (!Number.isInteger(slotCount) || slotCount < 1) - throw new Error("VEKTORPROGRAMMET_HOOK_SLOTS must be a positive integer."); + const slotCount = Number(configured === "" ? "5" : configured); - const directory = lockDirectory(); + if (!Number.isInteger(slotCount) || slotCount < 1) + return yield* new HeavyLockFailure({ + message: "VEKTORPROGRAMMET_HOOK_SLOTS must be a positive integer.", + }); - mkdirSync(directory, { recursive: true }); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Scope.Scope; + const directory = yield* lockDirectory; - const slotPath = (slot: number) => join(directory, `hook-slot-${slot}`); + yield* Effect.mapError(fileSystem.makeDirectory(directory, { recursive: true }), failure); - // flock(1) locks the open file description of its standard input. This process keeps the - // descriptor open and passes it to no child, so the lock lasts exactly as long as this process. - const acquire = (slot: number, wait: boolean) => { - const descriptor = openSync(slotPath(slot), "a"); + const slotPath = (slot: number) => path.join(directory, `hook-slot-${slot}`); - const { status, signal } = spawnSync("flock", [...(wait ? [] : ["--nonblock"]), "0"], { - stdio: [descriptor, "inherit", "inherit"], - }); + // flock(1) locks the open file description of its standard input. This process keeps the + // descriptor open and passes it to no child, so the lock lasts as long as the scope. + const acquire = Effect.fnUntraced(function* (slot: number, wait: boolean) { + const slotScope = yield* Scope.fork(scope); + + const file = yield* Scope.provide(openLockFile(slotPath(slot)), slotScope); + + const taken = yield* flockDescriptor({ + descriptor: file, + arguments: wait ? [] : ["--nonblock"], + timedOut: !wait, + }).pipe( + Effect.mapError( + ({ message }) => + new HeavyLockFailure({ message: `${message.replace(/\.$/u, "")} on ${slotPath(slot)}.` }), + ), + Effect.onError(() => Scope.close(slotScope, Exit.void)), + ); - if (status === 0) return descriptor; + if (taken) return file; - closeSync(descriptor); + yield* Scope.close(slotScope, Exit.void); - if (status === 1 && !wait) return undefined; + return undefined; + }); - throw new Error(`flock exited with ${status ?? signal} on ${slotPath(slot)}.`); - }; + let slot = 1; - let slot = 1; + let file = yield* acquire(slot, false); - let descriptor = acquire(slot, false); + while (file === undefined && slot < slotCount) { + slot += 1; + file = yield* acquire(slot, false); + } - while (descriptor === undefined && slot < slotCount) descriptor = acquire(++slot, false); + const waitStart = yield* Clock.currentTimeNanos; - const waitStart = performance.now(); + if (file === undefined) { + // Waiters spread over the slots. Each one waits for one holder to finish. + slot = (process.pid % slotCount) + 1; - if (descriptor === undefined) { - // Waiters spread over the slots. Each one waits for one holder to finish. - slot = (process.pid % slotCount) + 1; + const holders = yield* Effect.forEach( + Array.from({ length: slotCount }, (_, index) => index + 1), + (index) => + Effect.map( + Effect.mapError(fileSystem.readFileString(slotPath(index)), failure), + (holder) => `\n slot ${index}: ${holder.trim() === "" ? "unknown holder" : holder.trim()}`, + ), + ); - const holders = Array.from( - { length: slotCount }, - (_, index) => - `\n slot ${index + 1}: ${readFileSync(slotPath(index + 1), "utf8").trim() || "unknown holder"}`, - ); + yield* log(`waiting for a hook slot; ${slotCount} of ${slotCount} are busy:${holders.join("")}`); - log(`waiting for a hook slot; ${slotCount} of ${slotCount} are busy:${holders.join("")}`); + file = yield* acquire(slot, true); + } - descriptor = acquire(slot, true); - } + if (file === undefined) + return yield* new HeavyLockFailure({ message: `flock gave no hook slot ${slot}.` }); - ftruncateSync(descriptor, 0); + const { time } = yield* localNow; - writeSync( - descriptor, - `pid ${process.pid}, ${jobClass}, since ${new Date().toTimeString().slice(0, 8)}, ${process.cwd()}\n`, - ); + yield* replaceLockText(file, `pid ${process.pid}, ${jobClass}, since ${time}, ${process.cwd()}\n`); - log( - `got hook slot ${slot} of ${slotCount} after ${Math.round((performance.now() - waitStart) / 1000)}s`, - ); + yield* log(`got hook slot ${slot} of ${slotCount} after ${yield* secondsSince(waitStart)}s`); - return { slot, slotCount }; -}; + return { slot, slotCount }; + }); diff --git a/tools/scripts/hook-slot.ts b/tools/scripts/hook-slot.ts index c84c39581..2c78f9c7f 100644 --- a/tools/scripts/hook-slot.ts +++ b/tools/scripts/hook-slot.ts @@ -1,7 +1,9 @@ -import { spawn } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { heavyLockVariable, takeHeavyLock, takeHookSlot } from "./heavy-lock.js"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import process from "node:process"; +import { Console, Data, Effect, FileSystem, Path } from "effect"; +import { heavyLockVariable, localNow, takeHeavyLock, takeHookSlot } from "./heavy-lock.js"; +import { jobDisposition, runJob } from "./job-process.js"; const usage = `Usage: just hook-slot --class -- @@ -19,105 +21,108 @@ waits for one. The locks are released when this process exits, also on a signal. The command runs with CPU affinity to 1/N of the allowed CPUs. `; -// A function declaration lets calls narrow control flow as `never`. -function fail(message: string): never { - process.stderr.write(`hook-slot: ${message}\n`); - process.exit(2); -} +/** A usage error or a failed step; the program prints it and exits 2. */ +class HookSlotFailure extends Data.TaggedError("HookSlotFailure")<{ readonly message: string }> {} -const clock = () => new Date().toTimeString().slice(0, 8); +const fail = (message: string) => Effect.fail(new HookSlotFailure({ message })); -const log = (message: string) => process.stderr.write(`hook-slot: ${clock()} ${message}\n`); +const log = (message: string) => + Effect.flatMap(localNow, ({ time }) => Console.error(`hook-slot: ${time} ${message}`)); -const argv = process.argv.slice(2); +const disposition = jobDisposition(); -const separator = argv.indexOf("--"); +const program = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const argv = process.argv.slice(2); + const separator = argv.indexOf("--"); + const options = separator === -1 ? argv : argv.slice(0, separator); + const command = separator === -1 ? [] : argv.slice(separator + 1); + let jobClass: string | undefined; -const options = separator === -1 ? argv : argv.slice(0, separator); + for (let index = 0; index < options.length; index += 1) { + const option = options[index]; -const command = separator === -1 ? [] : argv.slice(separator + 1); + if (option === "--help" || option === "-h") { + yield* Console.log(usage.trimEnd()); -let jobClass: string | undefined; + return 0; + } else if (option === "--class") { + index += 1; + jobClass = options[index] ?? (yield* fail("--class needs a value.")); + } else return yield* fail(`Unknown argument ${option}. Use --help.`); + } -for (let index = 0; index < options.length; index += 1) { - const option = options[index]; + if (jobClass === undefined) return yield* fail("Pass --class . Use --help."); - if (option === "--help" || option === "-h") { - process.stdout.write(usage); - process.exit(0); - } else if (option === "--class") jobClass = options[++index] ?? fail("--class needs a value."); - else fail(`Unknown argument ${option}. Use --help.`); -} + if (command.length === 0) return yield* fail("Pass the command after --."); -if (jobClass === undefined) fail("Pass --class . Use --help."); + const slotClass = jobClass; -if (command.length === 0) fail("Pass the command after --."); + // The heavy lock comes before the slot: a hook job that waits for a heavy job holds no slot, so + // hook jobs inside that heavy job, such as the hooks of its commits, still get one. + const { variables, slot, slotCount } = yield* Effect.gen(function* () { + const taken = yield* takeHeavyLock({ mode: "shared", jobClass: slotClass, command, log }); + const held = yield* takeHookSlot({ jobClass: slotClass, log }); -// The heavy lock comes before the slot: a hook job that waits for a heavy job holds no slot, so -// hook jobs inside that heavy job, such as the hooks of its commits, still get one. -let jobEnv: NodeJS.ProcessEnv; + return { variables: taken, ...held }; + }).pipe( + Effect.catchTag("HeavyLockFailure", ({ message }) => fail(`The locks failed: ${message}`)), + ); -let slot: number; + // Each slot runs its job on an equal share of the allowed CPUs. Tools that size worker pools + // from the CPU count, such as Oxfmt, Oxlint, and Vitest (vitest.shared.ts), then start fewer + // workers. Explicit settings, such as `--no-file-parallelism`, still apply. + const cpuList = /^Cpus_allowed_list:\s*(\S+)$/m.exec( + yield* Effect.orDie(fileSystem.readFileString("/proc/self/status", "latin1")), + )?.[1]; -let slotCount: number; + if (cpuList === undefined) return yield* fail("/proc/self/status has no Cpus_allowed_list."); -try { - jobEnv = takeHeavyLock("shared", jobClass, command, log); - ({ slot, slotCount } = takeHookSlot(jobClass, log)); -} catch (error) { - fail(`The locks failed: ${error instanceof Error ? error.message : String(error)}`); -} - -// Each slot runs its job on an equal share of the allowed CPUs. Tools that size worker pools -// from the CPU count, such as Oxfmt, Oxlint, and Vitest (vitest.shared.ts), then start fewer -// workers. Explicit settings, such as `--no-file-parallelism`, still apply. -const allowedCpus = ( - /^Cpus_allowed_list:\s*(\S+)$/m.exec(readFileSync("/proc/self/status", "latin1"))?.[1] ?? - fail("/proc/self/status has no Cpus_allowed_list.") -) - .split(",") - .flatMap((range) => { + const allowedCpus = cpuList.split(",").flatMap((range) => { const [first = 0, last = first] = range.split("-").map(Number); return Array.from({ length: last - first + 1 }, (_, index) => first + index); }); -const cpusPerSlot = Math.max(1, Math.floor(allowedCpus.length / slotCount)); - -const slotCpus = Array.from( - { length: cpusPerSlot }, - (_, index) => allowedCpus[((slot - 1) * cpusPerSlot + index) % allowedCpus.length], + const cpusPerSlot = Math.max(1, Math.floor(allowedCpus.length / slotCount)); + + const slotCpus = Array.from( + { length: cpusPerSlot }, + (_, index) => allowedCpus[((slot - 1) * cpusPerSlot + index) % allowedCpus.length], + ); + + yield* log(`the job uses ${cpusPerSlot} of ${allowedCpus.length} CPUs`); + + // From the start of the job to its end, a signal goes to the job and does not stop this program. + const jobExit = yield* Effect.uninterruptible( + runJob({ + command: "taskset", + arguments: [ + "--cpu-list", + slotCpus.join(","), + process.execPath, + "--no-env-file", + path.join(import.meta.dir, "measure-job.ts"), + "--class", + slotClass, + "--", + ...command, + ], + variables, + forwardedSignals: ["SIGINT", "SIGTERM", "SIGHUP"], + }).pipe( + Effect.catchTag("JobStartFailure", ({ message }) => fail(`taskset could not start: ${message}`)), + Effect.tap(disposition.record), + )); + + return jobExit.exitCode ?? 1; +}).pipe( + Effect.catchTag("HookSlotFailure", ({ message }) => + Console.error(`hook-slot: ${message}`).pipe(Effect.as(2)), + ), ); -log(`the job uses ${cpusPerSlot} of ${allowedCpus.length} CPUs`); - -const child = spawn( - "taskset", - [ - "--cpu-list", - slotCpus.join(","), - process.execPath, - "--no-env-file", - join(import.meta.dir, "measure-job.ts"), - "--class", - jobClass, - "--", - ...command, - ], - { stdio: "inherit", env: jobEnv }, -); - -const signalHandlers = (["SIGINT", "SIGTERM", "SIGHUP"] as const).map( - (signal) => [signal, () => child.kill(signal)] as const, -); - -for (const [signal, handler] of signalHandlers) process.on(signal, handler); - -child.once("error", (error) => fail(`taskset could not start: ${error.message}`)); - -child.once("exit", (exitCode, signal) => { - for (const [name, handler] of signalHandlers) process.off(name, handler); - - if (signal !== null) process.kill(process.pid, signal); - else process.exit(exitCode ?? 1); +BunRuntime.runMain(program.pipe(Effect.scoped, Effect.provide(BunServices.layer)), { + teardown: disposition.teardown, }); diff --git a/tools/scripts/job-process.ts b/tools/scripts/job-process.ts new file mode 100644 index 000000000..1cc2f1540 --- /dev/null +++ b/tools/scripts/job-process.ts @@ -0,0 +1,116 @@ +/** + * Runs the job of `just measure` or a hook slot as a child that shares the terminal and the + * process group of this process, forwards the signals that this process receives to it, and + * reports how it ended: its exit code, or the signal that ended it. The program then ends the + * same way, so its caller sees the job's own status. + */ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0015: the job's terminating signal is needed, and a ChildProcess handle reports it only inside an error message +import { spawn } from "node:child_process"; +import process from "node:process"; +import { Data, Effect, Exit, Predicate } from "effect"; +import * as Runtime from "effect/Runtime"; + +/** How a job ended: `signal` is set when a signal ended it, and `exitCode` is null then. */ +export interface JobExit { + readonly exitCode: number | null; + readonly signal: NodeJS.Signals | null; +} + +/** How a job ended, and the first signal that this process forwarded to it, if any. */ +export interface ForwardedJobExit extends JobExit { + readonly forwarded: NodeJS.Signals | null; +} + +/** The job's program could not start. */ +export class JobStartFailure extends Data.TaggedError("JobStartFailure")<{ + readonly message: string; +}> {} + +/** A job, its environment, and the signals forwarded to it. */ +export interface Job { + readonly command: string; + readonly arguments: ReadonlyArray; + /** Added to the environment of this process, or the whole environment with `isolated`. */ + readonly variables: Readonly>; + readonly isolated?: boolean | undefined; + readonly cwd?: string | undefined; + readonly forwardedSignals: ReadonlyArray; +} + +/** + * Runs the job with inherited standard streams to its end. The wait cannot be interrupted: a + * signal that this process receives goes to the job, and the program learns of it from the + * job's exit. + */ +export const runJob = ({ + command, + arguments: jobArguments, + variables, + isolated = false, + cwd, + forwardedSignals, +}: Job): Effect.Effect => + Effect.uninterruptible( + Effect.callback((resume) => { + const child = spawn(command, jobArguments, { + stdio: "inherit", + env: isolated ? { ...variables } : { ...process.env, ...variables }, + cwd, + }); + + let forwarded: NodeJS.Signals | null = null; + + const handlers = forwardedSignals.map( + (signal) => + [ + signal, + () => { + forwarded ??= signal; + child.kill(signal); + }, + ] as const, + ); + + for (const [signal, handler] of handlers) process.on(signal, handler); + + const stop = () => { + for (const [signal, handler] of handlers) process.off(signal, handler); + }; + + child.once("error", (error) => { + stop(); + resume(Effect.fail(new JobStartFailure({ message: error.message }))); + }); + + child.once("exit", (exitCode, signal) => { + stop(); + resume(Effect.succeed({ exitCode, signal, forwarded })); + }); + }), + ); + +/** + * The end of a program that ran a job. `record` keeps the job's exit, and `teardown`, the + * `BunRuntime.runMain` teardown, ends the process with it: by the job's signal, or with its exit + * code. It applies also when a signal interrupted the program, which forwarded it to the job. + * A program that recorded no job exit ends with the number that it returns. + */ +export const jobDisposition = () => { + let recorded: JobExit | undefined; + + const teardown: Runtime.Teardown = (exit, onExit) => { + if (recorded?.signal !== null && recorded?.signal !== undefined) + process.kill(process.pid, recorded.signal); + else if (recorded !== undefined) onExit(recorded.exitCode ?? 1); + else if (Exit.isSuccess(exit) && Predicate.isNumber(exit.value)) onExit(exit.value); + else Runtime.defaultTeardown(exit, onExit); + }; + + return { + record: (jobExit: JobExit): Effect.Effect => + Effect.sync(() => { + recorded = jobExit; + }), + teardown, + }; +}; diff --git a/tools/scripts/land.ts b/tools/scripts/land.ts index eaf3d81ec..24a91bdbf 100644 --- a/tools/scripts/land.ts +++ b/tools/scripts/land.ts @@ -1,4 +1,10 @@ -import { spawnSync } from "node:child_process"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import process from "node:process"; +import { Console, Data, Effect } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { runCommand } from "./command.js"; +import { exitWithReturnedCode } from "./exit-code.js"; const usage = `Usage: just land @@ -15,118 +21,158 @@ Then it removes the branch's worktree, deletes the branch, and prints the landed commit. It does not push. `; -// A function declaration lets calls narrow control flow as `never`. -function fail(message: string): never { - process.stderr.write(`land: ${message}\n`); - process.exit(1); -} -const git = (...gitArguments: Array) => spawnSync("git", gitArguments, { encoding: "utf8" }); +/** A refusal or a failed step; the program prints it and exits 1. */ +class LandFailure extends Data.TaggedError("LandFailure")<{ readonly message: string }> {} -const read = (...gitArguments: Array) => { - const result = git(...gitArguments); +const fail = (message: string) => Effect.fail(new LandFailure({ message })); - return result.status === 0 - ? result.stdout.trim() - : fail(`git ${gitArguments.join(" ")} failed: ${result.stderr.trim()}`); -}; +const git = (...gitArguments: Array) => + runCommand(ChildProcess.make("git", gitArguments, { stdin: "ignore" })); -const isAncestor = (ancestor: string, descendant: string) => { - const { status } = git("merge-base", "--is-ancestor", ancestor, descendant); +const read = (...gitArguments: Array) => + Effect.flatMap(git(...gitArguments), (result) => + result.status === 0 + ? Effect.succeed(result.stdout.trim()) + : fail(`git ${gitArguments.join(" ")} failed: ${result.stderr.trim()}`), + ); - if (status !== 0 && status !== 1) fail(`git merge-base --is-ancestor exited with ${status}.`); +const isAncestor = (ancestor: string, descendant: string) => + Effect.flatMap(git("merge-base", "--is-ancestor", ancestor, descendant), ({ status }) => + status === 0 || status === 1 + ? Effect.succeed(status === 0) + : fail(`git merge-base --is-ancestor exited with ${status}.`), + ); - return status === 0; -}; +const land = Effect.gen(function* () { + const options = process.argv.slice(2); -const options = process.argv.slice(2); + if (options.includes("--help") || options.includes("-h")) { + yield* Console.log(usage.trimEnd()); -if (options.includes("--help") || options.includes("-h")) { - process.stdout.write(usage); - process.exit(0); -} + return 0; + } -const [branch] = options; + const [branch] = options; -if (branch === undefined || options.length !== 1) fail("Pass one branch. Use --help."); + if (branch === undefined || options.length !== 1) return yield* fail("Pass one branch. Use --help."); -if (branch === "main") fail("main cannot land on itself."); + if (branch === "main") return yield* fail("main cannot land on itself."); -const current = git("symbolic-ref", "--quiet", "--short", "HEAD").stdout.trim(); + const current = (yield* git("symbolic-ref", "--quiet", "--short", "HEAD")).stdout.trim(); -if (current !== "main") - fail(`Run just land in the main checkout. This checkout has ${current || "a detached HEAD"}.`); + if (current !== "main") + return yield* fail( + `Run just land in the main checkout. This checkout has ${current === "" ? "a detached HEAD" : current}.`, + ); -if (git("rev-parse", "--verify", "--quiet", `refs/heads/${branch}`).status !== 0) - fail(`${branch} is not a local branch.`); + if ((yield* git("rev-parse", "--verify", "--quiet", `refs/heads/${branch}`)).status !== 0) + return yield* fail(`${branch} is not a local branch.`); -const mainChanges = read("status", "--porcelain"); + const mainChanges = yield* read("status", "--porcelain"); -if (mainChanges !== "") fail(`main has changes. Commit or remove them first:\n${mainChanges}`); + if (mainChanges !== "") + return yield* fail(`main has changes. Commit or remove them first:\n${mainChanges}`); -// `git worktree list --porcelain` separates worktrees by a blank line. -const worktree = read("worktree", "list", "--porcelain") - .split("\n\n") - .map((entry) => entry.split("\n")) - .find((lines) => lines.includes(`branch refs/heads/${branch}`)) - ?.find((line) => line.startsWith("worktree ")) - ?.slice("worktree ".length); + // `git worktree list --porcelain` separates worktrees by a blank line. + const worktree = (yield* read("worktree", "list", "--porcelain")) + .split("\n\n") + .map((entry) => entry.split("\n")) + .find((lines) => lines.includes(`branch refs/heads/${branch}`)) + ?.find((line) => line.startsWith("worktree ")) + ?.slice("worktree ".length); -if (worktree !== undefined) { - const changes = read("-C", worktree, "status", "--porcelain"); + if (worktree !== undefined) { + const changes = yield* read("-C", worktree, "status", "--porcelain"); - if (changes !== "") - fail(`The worktree ${worktree} of ${branch} has changes. Commit or remove them first:\n${changes}`); -} + if (changes !== "") + return yield* fail( + `The worktree ${worktree} of ${branch} has changes. Commit or remove them first:\n${changes}`, + ); + } -if (git("merge-base", "main", branch).status !== 0) fail(`${branch} shares no history with main.`); + if ((yield* git("merge-base", "main", branch)).status !== 0) + return yield* fail(`${branch} shares no history with main.`); -const tip = read("rev-parse", branch); + const tip = yield* read("rev-parse", branch); -// A branch that contains the unlanded commits of another branch would land them as well. -const bases = read("for-each-ref", "--format=%(refname:short)", "refs/heads") - .split("\n") - .filter((other) => other !== "" && other !== branch && other !== "main") - .filter((other) => { - const otherTip = read("rev-parse", other); + // A branch that contains the unlanded commits of another branch would land them as well. + const others = (yield* read("for-each-ref", "--format=%(refname:short)", "refs/heads")) + .split("\n") + .filter((other) => other !== "" && other !== branch && other !== "main"); - return otherTip !== tip && isAncestor(otherTip, tip) && !isAncestor(otherTip, "main"); - }); + const bases = yield* Effect.filter(others, (other) => + Effect.gen(function* () { + const otherTip = yield* read("rev-parse", other); -if (bases.length > 0) - fail( - `${branch} is based on ${bases.join(", ")}, which main does not contain. ` + - `Land ${bases.length === 1 ? "it" : "them"} first, or rebase ${branch} onto main.`, + return ( + otherTip !== tip && + (yield* isAncestor(otherTip, tip)) && + !(yield* isAncestor(otherTip, "main")) + ); + }), ); -const before = read("rev-parse", "--short", "main"); + if (bases.length > 0) + return yield* fail( + `${branch} is based on ${bases.join(", ")}, which main does not contain. ` + + `Land ${bases.length === 1 ? "it" : "them"} first, or rebase ${branch} onto main.`, + ); + + const before = yield* read("rev-parse", "--short", "main"); + + let how: string; -let how: string; + if (yield* isAncestor(tip, "main")) how = "main already contained it"; + else { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; -if (isAncestor(tip, "main")) how = "main already contained it"; -else { - if (spawnSync("git", ["merge", "--no-ff", "--no-edit", branch], { stdio: "inherit" }).status !== 0) { - if (git("rev-parse", "--verify", "--quiet", "MERGE_HEAD").status === 0) read("merge", "--abort"); + const merged = yield* spawner.exitCode( + ChildProcess.make("git", ["merge", "--no-ff", "--no-edit", branch], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }), + ); - fail(`The merge of ${branch} stopped and was aborted. main is unchanged at ${before}.`); + if (merged !== 0) { + if ((yield* git("rev-parse", "--verify", "--quiet", "MERGE_HEAD")).status === 0) + yield* read("merge", "--abort"); + + return yield* fail( + `The merge of ${branch} stopped and was aborted. main is unchanged at ${before}.`, + ); + } + + how = "merge commit"; } - how = "merge commit"; -} + const landed = yield* read("log", "-1", "--format=%h %s", "main"); + + yield* Console.log(`land: main is at ${landed} (${how}; it was at ${before}).`); -const landed = read("log", "-1", "--format=%h %s", "main"); + if (worktree !== undefined) { + const removal = yield* git("worktree", "remove", worktree); -process.stdout.write(`land: main is at ${landed} (${how}; it was at ${before}).\n`); + if (removal.status !== 0) + return yield* fail( + `${branch} landed, but its worktree ${worktree} was not removed: ${removal.stderr.trim()}`, + ); + + yield* Console.log(`land: removed the worktree ${worktree}.`); + } -if (worktree !== undefined) { - const removal = git("worktree", "remove", worktree); + yield* read("branch", "--delete", branch); - if (removal.status !== 0) - fail(`${branch} landed, but its worktree ${worktree} was not removed: ${removal.stderr.trim()}`); + yield* Console.log(`land: deleted ${branch}. Nothing was pushed.`); - process.stdout.write(`land: removed the worktree ${worktree}.\n`); -} + return 0; +}); -read("branch", "--delete", branch); +const program = land.pipe( + Effect.catchTag("LandFailure", ({ message }) => + Console.error(`land: ${message}`).pipe(Effect.as(1)), + ), +); -process.stdout.write(`land: deleted ${branch}. Nothing was pushed.\n`); +BunRuntime.runMain(program.pipe(Effect.provide(BunServices.layer)), exitWithReturnedCode); diff --git a/tools/scripts/measure-job.ts b/tools/scripts/measure-job.ts index 3fc32606f..a50ea08dd 100644 --- a/tools/scripts/measure-job.ts +++ b/tools/scripts/measure-job.ts @@ -1,9 +1,26 @@ -import { spawn, spawnSync } from "node:child_process"; -import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; import { availableParallelism, homedir, hostname } from "node:os"; -import { dirname, join } from "node:path"; -import { Option, Schema } from "effect"; +import process from "node:process"; +import { + Clock, + Config, + Console, + Data, + DateTime, + Duration, + Effect, + Fiber, + FileSystem, + Option, + Path, + Schedule, + Schema, +} from "effect"; +import { ChildProcess } from "effect/unstable/process"; +import { runCommand } from "./command.js"; import { heavyLockVariable, takeHeavyLock } from "./heavy-lock.js"; +import { jobDisposition, runJob } from "./job-process.js"; const usage = `Usage: just measure --class [--ledger ] -- @@ -20,11 +37,10 @@ Appends one JSON line per run to the machine-local ledger: The ledger is runtime evidence. Do not commit it. `; -// A function declaration lets calls narrow control flow as `never`. -function fail(message: string): never { - process.stderr.write(`measure-job: ${message}\n`); - process.exit(2); -} +/** A usage error or a failed step; the program prints it and exits 2. */ +class MeasureFailure extends Data.TaggedError("MeasureFailure")<{ readonly message: string }> {} + +const fail = (message: string) => Effect.fail(new MeasureFailure({ message })); const sampleIntervalMs = 500; @@ -72,75 +88,6 @@ const encodeLine = Schema.encodeSync(LedgerLine); const decodeLine = Schema.decodeUnknownOption(LedgerLine); -// Arguments - -const argv = process.argv.slice(2); - -const separator = argv.indexOf("--"); - -const options = separator === -1 ? argv : argv.slice(0, separator); - -const command = separator === -1 ? [] : argv.slice(separator + 1); - -let jobClass: string | undefined; - -let ledgerPath = join( - process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), - "vektorprogrammet", - "job-ledger.jsonl", -); - -let report = false; - -for (let index = 0; index < options.length; index += 1) { - const option = options[index]; - - if (option === "--help" || option === "-h") { - process.stdout.write(usage); - process.exit(0); - } else if (option === "--report") report = true; - else if (option === "--class") jobClass = options[++index] ?? fail("--class needs a value."); - else if (option === "--ledger") ledgerPath = options[++index] ?? fail("--ledger needs a value."); - else fail(`Unknown argument ${option}. Use --help.`); -} - -if (!existsSync("/proc/self/stat")) fail("Linux /proc is required."); - -const getconf = (name: string): number => { - const value = Number(spawnSync("getconf", [name], { encoding: "utf8" }).stdout?.trim()); - - return Number.isInteger(value) && value > 0 ? value : fail(`getconf ${name} failed.`); -}; - -const clockTicks = getconf("CLK_TCK"); - -const pageSize = getconf("PAGESIZE"); - -const logicalCpus = availableParallelism(); - -// System and process readings - -const readMeminfo = () => { - const fields = new Map(); - - for (const line of readFileSync("/proc/meminfo", "latin1").split("\n")) { - const match = /^(\w+):\s+(\d+) kB$/.exec(line); - - if (match?.[1] !== undefined && match[2] !== undefined) - fields.set(match[1], Number(match[2]) * 1024); - } - - const field = (name: string) => fields.get(name) ?? fail(`/proc/meminfo has no ${name}.`); - - return { - total: field("MemTotal"), - available: field("MemAvailable"), - swapUsed: field("SwapTotal") - field("SwapFree"), - }; -}; - -const readLoad1 = () => Number(readFileSync("/proc/loadavg", "latin1").split(" ")[0]); - type ProcessStat = { readonly parent: number; readonly start: string; @@ -151,15 +98,7 @@ type ProcessStat = { readonly rssPages: number; }; -const readStat = (pid: number | "self"): ProcessStat | undefined => { - let text: string; - - try { - text = readFileSync(`/proc/${pid}/stat`, "latin1"); - } catch { - return undefined; - } - +const parseStat = (text: string): ProcessStat => { // Fields after the parenthesized command name start at field 3 (state). const fields = text.slice(text.lastIndexOf(")") + 2).split(" "); const field = (number: number) => Number(fields[number - 3]); @@ -176,67 +115,6 @@ const readStat = (pid: number | "self"): ProcessStat | undefined => { }; }; -const readProcesses = () => { - const pids = readdirSync("/proc") - .map(Number) - .filter(Number.isInteger) - // Parents usually have lower pids. Reading them first means a child reaped - // during the scan is missed once rather than counted twice. - .sort((left, right) => left - right); - - const processes = new Map(); - - for (const pid of pids) { - const stat = readStat(pid); - - if (stat !== undefined) processes.set(pid, stat); - } - - return processes; -}; - -// The tree is every descendant of this process. Known members stay tracked -// after they are reparented, identified by pid and start time. -let tracked = new Map(); - -const sampleTree = () => { - const processes = readProcesses(); - const children = new Map>(); - - for (const [pid, stat] of processes) { - const siblings = children.get(stat.parent); - - if (siblings === undefined) children.set(stat.parent, [pid]); - else siblings.push(pid); - } - - const pending = [...(children.get(process.pid) ?? [])]; - - for (const [pid, start] of tracked) { - if (processes.get(pid)?.start === start) pending.push(pid); - } - - const members = new Map(); - let rssBytes = 0; - let cpuTicks = 0; - - for (let pid = pending.pop(); pid !== undefined; pid = pending.pop()) { - const stat = processes.get(pid); - - if (stat === undefined || members.has(pid)) continue; - members.set(pid, stat.start); - rssBytes += stat.rssPages * pageSize; - cpuTicks += stat.cpuTicks; - pending.push(...(children.get(pid) ?? [])); - } - - tracked = members; - - return { rssBytes, cpuTicks, processes: members.size }; -}; - -const selfChildTicks = () => readStat("self")?.childTicks ?? fail("/proc/self/stat is unreadable."); - // Report const gib = (bytes: number) => `${(bytes / 2 ** 30).toFixed(1)} GiB`; @@ -256,236 +134,418 @@ const median = (values: ReadonlyArray) => { return sorted.length % 2 === 1 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2; }; -const printReport = () => { - const rows: Array = []; - const invalid: Array = []; +const disposition = jobDisposition(); - if (existsSync(ledgerPath)) { - readFileSync(ledgerPath, "utf8") - .split("\n") - .forEach((line, index) => { - if (line.trim() === "") return; - const row = decodeLine(line); +const program = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; - if (Option.isSome(row)) rows.push(row.value); - else invalid.push(index + 1); - }); - } + // Arguments + + const argv = process.argv.slice(2); + const separator = argv.indexOf("--"); + const options = separator === -1 ? argv : argv.slice(0, separator); + const command = separator === -1 ? [] : argv.slice(separator + 1); + let jobClass: string | undefined; - const memory = readMeminfo(); - const load1 = readLoad1(); - process.stdout.write( - `Ledger: ${ledgerPath}\n` + - `Now: MemAvailable ${gib(memory.available)} of ${gib(memory.total)}, swap used ${gib(memory.swapUsed)}, ` + - `load1 ${load1.toFixed(2)}, logical CPUs ${logicalCpus}\n\n`, + // An empty XDG_STATE_HOME counts as unset. + const stateHome = yield* Config.withDefault(Config.String("XDG_STATE_HOME"), ""); + + let ledgerPath = path.join( + stateHome === "" ? path.join(homedir(), ".local", "state") : stateHome, + "vektorprogrammet", + "job-ledger.jsonl", ); - const classes = new Map>(); - for (const row of rows) { - const runs = classes.get(row.class); + let report = false; - if (runs === undefined) classes.set(row.class, [row]); - else runs.push(row); - } + for (let index = 0; index < options.length; index += 1) { + const option = options[index]; + + if (option === "--help" || option === "-h") { + yield* Console.log(usage.trimEnd()); - const table = [ - [ - "class", - "runs", - "failed", - "median wall", - "max wall", - "max peak RSS", - "max peak cores", - "max mean cores", - ], - ]; - - for (const [name, runs] of [...classes].sort(([left], [right]) => left.localeCompare(right))) { - table.push([ - name, - String(runs.length), - String(runs.filter((run) => run.exitCode !== 0).length), - duration(median(runs.map((run) => run.wallMs))), - duration(Math.max(...runs.map((run) => run.wallMs))), - gib(Math.max(...runs.map((run) => run.peakRssBytes))), - Math.max(...runs.map((run) => run.peakCores)).toFixed(1), - Math.max(...runs.map((run) => run.meanCores)).toFixed(1), - ]); + return 0; + } else if (option === "--report") report = true; + else if (option === "--class") { + index += 1; + jobClass = options[index] ?? (yield* fail("--class needs a value.")); + } else if (option === "--ledger") { + index += 1; + ledgerPath = options[index] ?? (yield* fail("--ledger needs a value.")); + } else return yield* fail(`Unknown argument ${option}. Use --help.`); } - const widths = table[0]!.map((_, column) => - Math.max(...table.map((cells) => cells[column]!.length)), - ); + if (!(yield* Effect.orElseSucceed(fileSystem.exists("/proc/self/stat"), () => false))) + return yield* fail("Linux /proc is required."); - for (const cells of table) { - process.stdout.write( - `${cells.map((cell, column) => (column === 0 ? cell.padEnd(widths[column]!) : cell.padStart(widths[column]!))).join(" ")}\n`, - ); - } + const getconf = Effect.fnUntraced(function* (name: string) { + const result = yield* Effect.option(runCommand(ChildProcess.make("getconf", [name]))); + const value = Option.isSome(result) ? Number(result.value.stdout.trim()) : Number.NaN; - if (rows.length === 0) process.stdout.write("(no measured runs)\n"); + return Number.isInteger(value) && value > 0 ? value : yield* fail(`getconf ${name} failed.`); + }); - if (invalid.length > 0) - process.stderr.write(`measure-job: ignored invalid ledger lines ${invalid.join(", ")}\n`); -}; + const clockTicks = yield* getconf("CLK_TCK"); + const pageSize = yield* getconf("PAGESIZE"); + const logicalCpus = availableParallelism(); + + // System and process readings -if (report) { - if (jobClass !== undefined || command.length > 0) fail("--report takes no class or command."); - printReport(); - process.exit(0); -} + const readProc = (file: string) => fileSystem.readFileString(file, "latin1"); -if (jobClass === undefined) fail("Pass --class or --report. Use --help."); + const readMeminfo = Effect.gen(function* () { + const fields = new Map(); -if (!Schema.is(JobClass)(jobClass)) - fail("The job class must be lowercase words joined by hyphens."); + for (const line of (yield* Effect.orDie(readProc("/proc/meminfo"))).split("\n")) { + const match = /^(\w+):\s+(\d+) kB$/.exec(line); -const [program, ...programArguments] = command; + if (match?.[1] !== undefined && match[2] !== undefined) + fields.set(match[1], Number(match[2]) * 1024); + } -if (program === undefined) fail("Pass the command after --."); + const field = (name: string) => { + const value = fields.get(name); -// The lock is taken before the readings, so the ledger measures the job, not the wait. -let jobEnv: NodeJS.ProcessEnv; + return value === undefined ? fail(`/proc/meminfo has no ${name}.`) : Effect.succeed(value); + }; -try { - jobEnv = takeHeavyLock("exclusive", jobClass, command, (message) => - process.stderr.write(`measure-job: ${message}\n`), + return { + total: yield* field("MemTotal"), + available: yield* field("MemAvailable"), + swapUsed: (yield* field("SwapTotal")) - (yield* field("SwapFree")), + }; + }); + + const readLoad1 = Effect.map(Effect.orDie(readProc("/proc/loadavg")), (text) => + Number(text.split(" ")[0]), ); -} catch (error) { - fail(`The heavy lock failed: ${error instanceof Error ? error.message : String(error)}`); -} -// Measurement + const readStat = (pid: number | "self") => + Effect.map(Effect.option(readProc(`/proc/${pid}/stat`)), Option.map(parseStat)); -const git = (...gitArguments: Array) => { - const result = spawnSync("git", gitArguments, { encoding: "utf8" }); + const readProcesses = Effect.gen(function* () { + const pids = (yield* Effect.orDie(fileSystem.readDirectory("/proc"))) + .map(Number) + .filter(Number.isInteger) + // Parents usually have lower pids. Reading them first means a child reaped + // during the scan is missed once rather than counted twice. + .sort((left, right) => left - right); - return result.status === 0 ? result.stdout.trim() : null; -}; + const processes = new Map(); -const revision = git("rev-parse", "HEAD"); + for (const pid of pids) { + const stat = yield* readStat(pid); -const status = git("status", "--porcelain", "--untracked-files=normal"); + if (Option.isSome(stat)) processes.set(pid, stat.value); + } -const startMemory = readMeminfo(); + return processes; + }); -const load1Start = readLoad1(); + // The tree is every descendant of this process. Known members stay tracked + // after they are reparented, identified by pid and start time. + let tracked = new Map(); -// Reaped descendants add their CPU time to this process's child ticks. -const childTicksStart = selfChildTicks(); + const sampleTree = Effect.gen(function* () { + const processes = yield* readProcesses; + const children = new Map>(); -const startedAt = new Date(); + for (const [pid, stat] of processes) { + const siblings = children.get(stat.parent); -const startedMs = performance.now(); + if (siblings === undefined) children.set(stat.parent, [pid]); + else siblings.push(pid); + } -const child = spawn(program, programArguments, { stdio: "inherit", env: jobEnv }); + const pending = [...(children.get(process.pid) ?? [])]; -const rssSamples: Array = []; + for (const [pid, start] of tracked) { + if (processes.get(pid)?.start === start) pending.push(pid); + } -let peakCores = 0; + const members = new Map(); + let rssBytes = 0; + let cpuTicks = 0; -let peakProcesses = 0; + for (let pid = pending.pop(); pid !== undefined; pid = pending.pop()) { + const stat = processes.get(pid); -let minMemAvailable = startMemory.available; + if (stat === undefined || members.has(pid)) continue; + members.set(pid, stat.start); + rssBytes += stat.rssPages * pageSize; + cpuTicks += stat.cpuTicks; + pending.push(...(children.get(pid) ?? [])); + } -let peakSwapUsed = startMemory.swapUsed; + tracked = members; -let load1Peak = load1Start; + return { rssBytes, cpuTicks, processes: members.size }; + }); -let cumulativeTicks = 0; + const selfChildTicks = Effect.flatMap(readStat("self"), (stat) => + Option.isSome(stat) ? Effect.succeed(stat.value.childTicks) : fail("/proc/self/stat is unreadable."), + ); -let previousMs = startedMs; + const printReport = Effect.gen(function* () { + const rows: Array = []; + const invalid: Array = []; + + if (yield* Effect.orElseSucceed(fileSystem.exists(ledgerPath), () => false)) { + (yield* Effect.orDie(fileSystem.readFileString(ledgerPath))) + .split("\n") + .forEach((line, index) => { + if (line.trim() === "") return; + const row = decodeLine(line); + + if (Option.isSome(row)) rows.push(row.value); + else invalid.push(index + 1); + }); + } + + const memory = yield* readMeminfo; + const load1 = yield* readLoad1; + + yield* Console.log( + `Ledger: ${ledgerPath}\n` + + `Now: MemAvailable ${gib(memory.available)} of ${gib(memory.total)}, swap used ${gib(memory.swapUsed)}, ` + + `load1 ${load1.toFixed(2)}, logical CPUs ${logicalCpus}\n`, + ); -const sample = (running: boolean) => { - const now = performance.now(); - const tree = sampleTree(); - const memory = readMeminfo(); - // Cumulative tree CPU never decreases. A scan that misses a just-reaped - // process must not produce a false spike at the next sample. - const ticks = Math.max(cumulativeTicks, selfChildTicks() - childTicksStart + tree.cpuTicks); - const elapsedMs = now - previousMs; + const classes = new Map>(); + + for (const row of rows) { + const runs = classes.get(row.class); + + if (runs === undefined) classes.set(row.class, [row]); + else runs.push(row); + } + + const table = [ + [ + "class", + "runs", + "failed", + "median wall", + "max wall", + "max peak RSS", + "max peak cores", + "max mean cores", + ], + ]; + + for (const [name, runs] of [...classes].sort(([left], [right]) => left.localeCompare(right))) { + table.push([ + name, + String(runs.length), + String(runs.filter((run) => run.exitCode !== 0).length), + duration(median(runs.map((run) => run.wallMs))), + duration(Math.max(...runs.map((run) => run.wallMs))), + gib(Math.max(...runs.map((run) => run.peakRssBytes))), + Math.max(...runs.map((run) => run.peakCores)).toFixed(1), + Math.max(...runs.map((run) => run.meanCores)).toFixed(1), + ]); + } + + const widths = table[0]!.map((_, column) => + Math.max(...table.map((cells) => cells[column]!.length)), + ); - if (running || elapsedMs >= sampleIntervalMs / 2) { - peakCores = Math.max(peakCores, (ticks - cumulativeTicks) / clockTicks / (elapsedMs / 1000)); - } + for (const cells of table) { + yield* Console.log( + cells + .map((cell, column) => + column === 0 ? cell.padEnd(widths[column]!) : cell.padStart(widths[column]!), + ) + .join(" "), + ); + } + + if (rows.length === 0) yield* Console.log("(no measured runs)"); + + if (invalid.length > 0) + yield* Console.error(`measure-job: ignored invalid ledger lines ${invalid.join(", ")}`); + }); - cumulativeTicks = ticks; - previousMs = now; + if (report) { + if (jobClass !== undefined || command.length > 0) + return yield* fail("--report takes no class or command."); - if (running) { - rssSamples.push(tree.rssBytes); - peakProcesses = Math.max(peakProcesses, tree.processes); + yield* printReport; + + return 0; } - minMemAvailable = Math.min(minMemAvailable, memory.available); - peakSwapUsed = Math.max(peakSwapUsed, memory.swapUsed); - load1Peak = Math.max(load1Peak, readLoad1()); + if (jobClass === undefined) return yield* fail("Pass --class or --report. Use --help."); - return { processes: tree.processes, swapUsed: memory.swapUsed }; -}; + if (!Schema.is(JobClass)(jobClass)) + return yield* fail("The job class must be lowercase words joined by hyphens."); -const timer = setInterval(() => sample(true), sampleIntervalMs); + const [jobProgram, ...programArguments] = command; -const signalHandlers = (["SIGINT", "SIGTERM"] as const).map( - (signal) => [signal, () => child.kill(signal)] as const, -); + if (jobProgram === undefined) return yield* fail("Pass the command after --."); -for (const [signal, handler] of signalHandlers) process.on(signal, handler); + const measuredClass = jobClass; -child.once("error", (error) => { - clearInterval(timer); - fail(`${program} could not start: ${error.message}`); -}); + // The lock is taken before the readings, so the ledger measures the job, not the wait. + const variables = yield* takeHeavyLock({ + mode: "exclusive", + jobClass: measuredClass, + command, + log: (message) => Console.error(`measure-job: ${message}`), + }).pipe( + Effect.catchTag("HeavyLockFailure", ({ message }) => fail(`The heavy lock failed: ${message}`)), + ); -child.once("exit", (exitCode, signal) => { - clearInterval(timer); - const wallMs = performance.now() - startedMs; - const final = sample(false); - const sortedRss = [...rssSamples].sort((left, right) => left - right); - - const row: LedgerRow = { - version: 1, - class: jobClass, - command: [program, ...programArguments], - cwd: process.cwd(), - revision, - dirty: status === null ? null : status !== "", - host: hostname(), - startedAt: startedAt.toISOString(), - wallMs: Math.round(wallMs), - exitCode, - signal, - samples: rssSamples.length, - sampleIntervalMs, - logicalCpus, - memTotalBytes: startMemory.total, - peakRssBytes: sortedRss.at(-1) ?? 0, - p95RssBytes: sortedRss[Math.max(0, Math.ceil(sortedRss.length * 0.95) - 1)] ?? 0, - peakCores: Number(peakCores.toFixed(2)), - meanCores: Number( - ((selfChildTicks() - childTicksStart) / clockTicks / (wallMs / 1000)).toFixed(2), - ), - peakProcesses, - survivingProcesses: final.processes, - memAvailableStartBytes: startMemory.available, - minMemAvailableBytes: minMemAvailable, - swapDeltaBytes: final.swapUsed - startMemory.swapUsed, - swapPeakIncreaseBytes: peakSwapUsed - startMemory.swapUsed, - load1Start, - load1Peak, - }; + // Measurement + + const git = Effect.fnUntraced(function* (...gitArguments: Array) { + const result = yield* Effect.option(runCommand(ChildProcess.make("git", gitArguments))); + + return Option.isSome(result) && result.value.status === 0 ? result.value.stdout.trim() : null; + }); + + const revision = yield* git("rev-parse", "HEAD"); + const status = yield* git("status", "--porcelain", "--untracked-files=normal"); + const startMemory = yield* readMeminfo; + const load1Start = yield* readLoad1; + + // Reaped descendants add their CPU time to this process's child ticks. + const childTicksStart = yield* selfChildTicks; + const startedAt = yield* DateTime.now; + const nowMs = Effect.map(Clock.currentTimeNanos, (nanos) => Number(nanos) / 1e6); + const startedMs = yield* nowMs; + + const rssSamples: Array = []; + let peakCores = 0; + let peakProcesses = 0; + let minMemAvailable = startMemory.available; + let peakSwapUsed = startMemory.swapUsed; + let load1Peak = load1Start; + let cumulativeTicks = 0; + let previousMs = startedMs; + + const sample = Effect.fnUntraced(function* (running: boolean) { + const now = yield* nowMs; + const tree = yield* sampleTree; + const memory = yield* readMeminfo; + + // Cumulative tree CPU never decreases. A scan that misses a just-reaped + // process must not produce a false spike at the next sample. + const ticks = Math.max( + cumulativeTicks, + (yield* selfChildTicks) - childTicksStart + tree.cpuTicks, + ); - mkdirSync(dirname(ledgerPath), { recursive: true }); - appendFileSync(ledgerPath, `${encodeLine(row)}\n`); - process.stderr.write( - `measure-job: ${jobClass} ${signal ?? `exit ${exitCode}`} in ${duration(wallMs)}, ` + - `peak RSS ${gib(row.peakRssBytes)}, peak cores ${row.peakCores}, mean cores ${row.meanCores}` + - `${row.survivingProcesses > 0 ? `, ${row.survivingProcesses} processes still running` : ""}\n`, + const elapsedMs = now - previousMs; + + if (running || elapsedMs >= sampleIntervalMs / 2) { + peakCores = Math.max(peakCores, (ticks - cumulativeTicks) / clockTicks / (elapsedMs / 1000)); + } + + cumulativeTicks = ticks; + previousMs = now; + + if (running) { + rssSamples.push(tree.rssBytes); + peakProcesses = Math.max(peakProcesses, tree.processes); + } + + minMemAvailable = Math.min(minMemAvailable, memory.available); + peakSwapUsed = Math.max(peakSwapUsed, memory.swapUsed); + load1Peak = Math.max(load1Peak, yield* readLoad1); + + return { processes: tree.processes, swapUsed: memory.swapUsed }; + }); + + // From the start of the job to the ledger row, a signal goes to the job and does not stop this + // program: the row records how the job ended. + return yield* Effect.uninterruptible( + Effect.gen(function* () { + const sampler = yield* Effect.forkChild( + Effect.interruptible( + Effect.schedule(sample(true), Schedule.fixed(Duration.millis(sampleIntervalMs))), + ), + ); + + const jobExit = yield* runJob({ + command: jobProgram, + arguments: programArguments, + variables, + forwardedSignals: ["SIGINT", "SIGTERM"], + }).pipe( + Effect.catchTag("JobStartFailure", ({ message }) => + Effect.andThen(Fiber.interrupt(sampler), fail(`${jobProgram} could not start: ${message}`)), + ), + ); + + yield* Fiber.interrupt(sampler); + + const wallMs = (yield* nowMs) - startedMs; + const final = yield* sample(false); + const sortedRss = [...rssSamples].sort((left, right) => left - right); + + const row: LedgerRow = { + version: 1, + class: measuredClass, + command: [jobProgram, ...programArguments], + cwd: process.cwd(), + revision, + dirty: status === null ? null : status !== "", + host: hostname(), + startedAt: DateTime.formatIso(startedAt), + wallMs: Math.round(wallMs), + exitCode: jobExit.exitCode, + signal: jobExit.signal, + samples: rssSamples.length, + sampleIntervalMs, + logicalCpus, + memTotalBytes: startMemory.total, + peakRssBytes: sortedRss.at(-1) ?? 0, + p95RssBytes: sortedRss[Math.max(0, Math.ceil(sortedRss.length * 0.95) - 1)] ?? 0, + peakCores: Number(peakCores.toFixed(2)), + meanCores: Number( + (((yield* selfChildTicks) - childTicksStart) / clockTicks / (wallMs / 1000)).toFixed(2), + ), + peakProcesses, + survivingProcesses: final.processes, + memAvailableStartBytes: startMemory.available, + minMemAvailableBytes: minMemAvailable, + swapDeltaBytes: final.swapUsed - startMemory.swapUsed, + swapPeakIncreaseBytes: peakSwapUsed - startMemory.swapUsed, + load1Start, + load1Peak, + }; + + yield* Effect.orDie(fileSystem.makeDirectory(path.dirname(ledgerPath), { recursive: true })); + yield* Effect.orDie( + fileSystem.writeFileString(ledgerPath, `${encodeLine(row)}\n`, { flag: "a" }), + ); + + yield* Console.error( + `measure-job: ${measuredClass} ${jobExit.signal ?? `exit ${jobExit.exitCode}`} in ${duration(wallMs)}, ` + + `peak RSS ${gib(row.peakRssBytes)}, peak cores ${row.peakCores}, mean cores ${row.meanCores}` + + `${row.survivingProcesses > 0 ? `, ${row.survivingProcesses} processes still running` : ""}`, + ); + + yield* disposition.record(jobExit); + + return jobExit.exitCode ?? 1; + }), ); +}).pipe( + Effect.catchTag("MeasureFailure", ({ message }) => + Console.error(`measure-job: ${message}`).pipe(Effect.as(2)), + ), +); - for (const [name, handler] of signalHandlers) process.off(name, handler); +/** The platform of the scripts, which their tests take too. */ +export const ScriptsPlatform = BunServices.layer; - if (signal !== null) process.kill(process.pid, signal); - else process.exit(exitCode ?? 1); -}); +/** The services of `ScriptsPlatform`. */ +export type ScriptsPlatform = BunServices.BunServices; + +if (import.meta.main) + BunRuntime.runMain(program.pipe(Effect.scoped, Effect.provide(ScriptsPlatform)), { + teardown: disposition.teardown, + }); diff --git a/tools/scripts/model.ts b/tools/scripts/model.ts index 335ee6586..b626b830e 100644 --- a/tools/scripts/model.ts +++ b/tools/scripts/model.ts @@ -1,17 +1,13 @@ -import { spawnSync } from "node:child_process"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; import { createHash } from "node:crypto"; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - renameSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { join } from "node:path"; +import { homedir } from "node:os"; +import process from "node:process"; +import { Config, Console, Data, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { runCommand } from "./command.js"; +import { exitWithReturnedCode } from "./exit-code.js"; // The Context Mapper CLI release and the SHA-256 that Maven Central publishes for its archive. const contextMapperVersion = "6.12.0"; @@ -37,103 +33,125 @@ Both are heavy jobs. \`just model\` runs them through \`just measure\`, so they wait for the heavy lock. `; -// A function declaration lets calls narrow control flow as `never`. -function fail(message: string): never { - process.stderr.write(`model: ${message}\n`); - process.exit(1); -} +/** A refusal or a failed step; the program prints it and exits 1. */ +class ModelFailure extends Data.TaggedError("ModelFailure")<{ readonly message: string }> {} -const root = join(import.meta.dir, "..", ".."); +const fail = (message: string) => Effect.fail(new ModelFailure({ message })); -const nixVersion = (attribute: string) => { - const result = spawnSync("nix", ["eval", "--raw", `nixpkgs#${attribute}.version`], { - encoding: "utf8", - }); +const nixVersion = (attribute: string) => + Effect.map( + Effect.option(runCommand(ChildProcess.make("nix", ["eval", "--raw", `nixpkgs#${attribute}.version`]))), + (result) => + Option.isSome(result) && result.value.status === 0 ? result.value.stdout.trim() : "unknown", + ); + +/** The exit code of a command whose output goes to this terminal, or how it ended otherwise. */ +const exitStatus = Effect.fnUntraced(function* (command: ChildProcess.Command) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - return result.status === 0 ? result.stdout.trim() : "unknown"; -}; + return yield* Effect.match(spawner.exitCode(command), { + onFailure: (error) => ({ ok: false, text: error.message }), + onSuccess: (code) => ({ ok: code === 0, text: String(code) }), + }); +}); -const check = () => { - const modelPath = join(root, "docs/model/authority.als"); +const check = Effect.fnUntraced(function* (root: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const modelPath = path.join(root, "docs/model/authority.als"); - const lines = readFileSync(modelPath, "utf8") + const lines = (yield* fileSystem.readFileString(modelPath)) .split("\n") .filter((line) => /^\s*(check|run)\b/.test(line)); - const commands = lines.map((line) => { + const commands = yield* Effect.forEach(lines, (line) => { const match = /^\s*(check|run)\s+(\w+)\b.*\bexpect\s+([01])\s*$/.exec(line); return match?.[1] !== undefined && match[2] !== undefined && match[3] !== undefined - ? { kind: match[1], name: match[2], expectsInstance: match[3] === "1" } - : fail(`Every command states its expected result as \`expect 0\` or \`expect 1\`: ${line.trim()}`); + ? Effect.succeed({ kind: match[1], name: match[2], expectsInstance: match[3] === "1" }) + : fail( + `Every command states its expected result as \`expect 0\` or \`expect 1\`: ${line.trim()}`, + ); }); const names = new Set(commands.map((command) => command.name)); - if (names.size !== commands.length) fail("Two commands have the same name."); + if (names.size !== commands.length) return yield* fail("Two commands have the same name."); - const output = mkdtempSync(join(tmpdir(), "vektorprogrammet-alloy-")); - const version = nixVersion("alloy6"); + const output = yield* fileSystem.makeTempDirectory({ prefix: "vektorprogrammet-alloy-" }); + const version = yield* nixVersion("alloy6"); - process.stderr.write( - `model: running ${commands.length} commands of docs/model/authority.als with Alloy ${version}\n`, + yield* Console.error( + `model: running ${commands.length} commands of docs/model/authority.als with Alloy ${version}`, ); // The command of the model header. Alloy prints one line per command, writes // -solution-.txt for each instance it finds, and reports a result that breaks `expect`. - const alloy = spawnSync( - "nice", - [ - "-n", - "10", - "nix", - "shell", - "nixpkgs#alloy6", - "--command", - "alloy6", - "exec", - "-s", - "glucose", - "-c", - "*", - "-t", - "text", - "-o", - output, - "-f", - modelPath, - ], - { stdio: ["ignore", "inherit", "inherit"] }, + const alloy = yield* exitStatus( + ChildProcess.make( + "nice", + [ + "-n", + "10", + "nix", + "shell", + "nixpkgs#alloy6", + "--command", + "alloy6", + "exec", + "-s", + "glucose", + "-c", + "*", + "-t", + "text", + "-o", + output, + "-f", + modelPath, + ], + { stdin: "ignore", stdout: "inherit", stderr: "inherit", detached: false }, + ), ); const found = new Set(); - for (const file of readdirSync(output)) { + for (const file of yield* fileSystem.readDirectory(output)) { const name = /^(\w+)-solution-\d+\.txt$/.exec(file)?.[1]; if (name === undefined) continue; - if (!names.has(name)) fail(`Alloy wrote ${file}, which names no command. The output is ${output}.`); + if (!names.has(name)) + return yield* fail(`Alloy wrote ${file}, which names no command. The output is ${output}.`); found.add(name); } - const unexpected = commands.filter((command) => found.has(command.name) !== command.expectsInstance); - const checks = commands.filter((command) => command.kind === "check" && !command.name.includes("mutant")); - const mutants = commands.filter((command) => command.kind === "check" && command.name.includes("mutant")); + const unexpected = commands.filter( + (command) => found.has(command.name) !== command.expectsInstance, + ); + + const checks = commands.filter( + (command) => command.kind === "check" && !command.name.includes("mutant"), + ); + + const mutants = commands.filter( + (command) => command.kind === "check" && command.name.includes("mutant"), + ); + const scenarios = commands.filter((command) => command.kind === "run"); - process.stdout.write( + yield* Console.log( `model: authority.als, Alloy ${version}: ${commands.length} commands; ` + `${checks.filter((command) => !found.has(command.name)).length} of ${checks.length} checks hold, ` + `${mutants.filter((command) => found.has(command.name)).length} of ${mutants.length} mutants caught, ` + `${scenarios.filter((command) => found.has(command.name)).length} of ${scenarios.length} scenarios found, ` + - `${unexpected.length} unexpected\n`, + `${unexpected.length} unexpected`, ); - if (unexpected.length > 0 || alloy.status !== 0) - fail( - `Alloy exited with ${alloy.status ?? alloy.signal}; the output is kept in ${output}.` + + if (unexpected.length > 0 || !alloy.ok) + return yield* fail( + `Alloy exited with ${alloy.text}; the output is kept in ${output}.` + unexpected .map( (command) => @@ -143,88 +161,126 @@ const check = () => { .join(""), ); - rmSync(output, { recursive: true, force: true }); -}; + yield* fileSystem.remove(output, { recursive: true, force: true }); +}); -const validate = async () => { +const validate = Effect.fnUntraced(function* (root: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const version = contextMapperVersion; - const cache = join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "vektorprogrammet"); - const cli = join(cache, `context-mapper-cli-${version}`); - const cm = join(cli, "bin", "cm"); - if (!existsSync(cm)) { + // An empty XDG_CACHE_HOME counts as unset. + const cacheHome = yield* Config.withDefault(Config.String("XDG_CACHE_HOME"), ""); + + const cache = path.join( + cacheHome === "" ? path.join(homedir(), ".cache") : cacheHome, + "vektorprogrammet", + ); + + const cli = path.join(cache, `context-mapper-cli-${version}`); + const cm = path.join(cli, "bin", "cm"); + + if (!(yield* fileSystem.exists(cm))) { const url = `https://repo1.maven.org/maven2/org/contextmapper/context-mapper-cli/${version}/context-mapper-cli-${version}.zip`; - const response = await fetch(url); + const response = yield* HttpClient.get(url); - if (!response.ok) fail(`${url} answered ${response.status}.`); + if (response.status < 200 || response.status > 299) + return yield* fail(`${url} answered ${response.status}.`); - const archive = Buffer.from(await response.arrayBuffer()); + const archive = new Uint8Array(yield* response.arrayBuffer); const digest = createHash("sha256").update(archive).digest("hex"); if (digest !== contextMapperSha256) - fail(`${url} has SHA-256 ${digest}, not the pinned ${contextMapperSha256}.`); + return yield* fail(`${url} has SHA-256 ${digest}, not the pinned ${contextMapperSha256}.`); // Extract beside the cache and move into place, so an interrupted run leaves no partial CLI. // The staging directory shares the cache's file system: a rename cannot cross devices. - mkdirSync(cache, { recursive: true }); - const staging = mkdtempSync(join(cache, ".context-mapper-staging-")); - const zip = join(staging, "cli.zip"); + yield* fileSystem.makeDirectory(cache, { recursive: true }); + + const staging = yield* fileSystem.makeTempDirectory({ + directory: cache, + prefix: ".context-mapper-staging-", + }); + + const zip = path.join(staging, "cli.zip"); - writeFileSync(zip, archive); + yield* fileSystem.writeFile(zip, archive); - const unzip = spawnSync( - "nix", - ["shell", "nixpkgs#unzip", "--command", "unzip", "-q", zip, "-d", staging], - { stdio: "inherit" }, + const unzip = yield* exitStatus( + ChildProcess.make( + "nix", + ["shell", "nixpkgs#unzip", "--command", "unzip", "-q", zip, "-d", staging], + { stdin: "inherit", stdout: "inherit", stderr: "inherit", detached: false }, + ), ); - if (unzip.status !== 0) fail(`unzip exited with ${unzip.status ?? unzip.signal}.`); + if (!unzip.ok) return yield* fail(`unzip exited with ${unzip.text}.`); - renameSync(join(staging, `context-mapper-cli-${version}`), cli); - rmSync(staging, { recursive: true, force: true }); + yield* fileSystem.rename(path.join(staging, `context-mapper-cli-${version}`), cli); + yield* fileSystem.remove(staging, { recursive: true, force: true }); } - const java = nixVersion("jdk17_headless"); - - const result = spawnSync( - "nice", - [ - "-n", - "10", - "nix", - "shell", - "nixpkgs#jdk17_headless", - "--command", - cm, - "validate", - "-i", - join(root, "docs/model/contexts.cml"), - ], - { encoding: "utf8" }, + const java = yield* nixVersion("jdk17_headless"); + + const result = yield* runCommand( + ChildProcess.make( + "nice", + [ + "-n", + "10", + "nix", + "shell", + "nixpkgs#jdk17_headless", + "--command", + cm, + "validate", + "-i", + path.join(root, "docs/model/contexts.cml"), + ], + { stdin: "ignore" }, + ), ); - const text = `${result.stdout ?? ""}${result.stderr ?? ""}`; + const text = `${result.stdout}${result.stderr}`; - process.stdout.write(text); + yield* Console.log(text.replace(/\n$/u, "")); // The CLI exits with 0 also when it reports errors. if (result.status !== 0 || /\bERROR\b/.test(text) || !text.includes("validated without errors")) - fail(`contexts.cml did not validate (Context Mapper CLI ${version}, OpenJDK ${java}).`); + return yield* fail( + `contexts.cml did not validate (Context Mapper CLI ${version}, OpenJDK ${java}).`, + ); - process.stdout.write( - `model: contexts.cml validated without errors (Context Mapper CLI ${version}, OpenJDK ${java})\n`, + yield* Console.log( + `model: contexts.cml validated without errors (Context Mapper CLI ${version}, OpenJDK ${java})`, ); -}; +}); + +const program = Effect.gen(function* () { + const path = yield* Path.Path; + const root = path.join(import.meta.dir, "..", ".."); + const [action, ...rest] = process.argv.slice(2); + + if (action === "--help" || action === "-h") { + yield* Console.log(usage.trimEnd()); + + return 0; + } -const [action, ...rest] = process.argv.slice(2); + if (rest.length > 0) return yield* fail("Pass one action: check or validate."); -if (action === "--help" || action === "-h") { - process.stdout.write(usage); - process.exit(0); -} + if (action === "check") yield* check(root); + else if (action === "validate") yield* validate(root); + else return yield* fail("Pass one action: check or validate. Use --help."); -if (rest.length > 0) fail("Pass one action: check or validate."); + return 0; +}).pipe( + Effect.catchTag("ModelFailure", ({ message }) => + Console.error(`model: ${message}`).pipe(Effect.as(1)), + ), +); -if (action === "check") check(); -else if (action === "validate") await validate(); -else fail("Pass one action: check or validate. Use --help."); +BunRuntime.runMain( + program.pipe(Effect.provide(Layer.merge(BunServices.layer, FetchHttpClient.layer))), + exitWithReturnedCode, +); diff --git a/tools/scripts/package.json b/tools/scripts/package.json index e465e8036..0b34c3a19 100644 --- a/tools/scripts/package.json +++ b/tools/scripts/package.json @@ -5,5 +5,9 @@ "type": "module", "scripts": { "test": "bun test tests" + }, + "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", + "effect": "catalog:effect-v4" } } diff --git a/tools/scripts/require-legacy-data-profile.ts b/tools/scripts/require-legacy-data-profile.ts index 773111a35..5ebf7ceb9 100644 --- a/tools/scripts/require-legacy-data-profile.ts +++ b/tools/scripts/require-legacy-data-profile.ts @@ -1,9 +1,20 @@ +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import { Config, Console, Effect } from "effect"; +import { exitWithReturnedCode } from "./exit-code.js"; + // Legacy data rehearsals start MariaDB or the PHP CLI, which only the devenv `legacy-data` // profile provides; it sets VEKTOR_LEGACY_DATA. The default shell has neither. -if (process.env.VEKTOR_LEGACY_DATA !== "1") { - process.stderr.write( +const program = Effect.gen(function* () { + const legacyData = yield* Config.String("VEKTOR_LEGACY_DATA").pipe(Config.withDefault("")); + + if (legacyData === "1") return 0; + + yield* Console.error( "This command needs the legacy data tools (MariaDB, PHP CLI). " + - "Run it inside `devenv --profile legacy-data shell`.\n", + "Run it inside `devenv --profile legacy-data shell`.", ); - process.exit(1); -} + + return 1; +}); + +BunRuntime.runMain(program, exitWithReturnedCode); diff --git a/tools/scripts/tests/heavy-lock.test.ts b/tools/scripts/tests/heavy-lock.test.ts index c3d1ac7b0..9118f36e1 100644 --- a/tools/scripts/tests/heavy-lock.test.ts +++ b/tools/scripts/tests/heavy-lock.test.ts @@ -1,80 +1,105 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { type ChildProcess, spawn } from "node:child_process"; -import { once } from "node:events"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { Buffer } from "node:buffer"; +import { afterAll, describe, expect, test } from "bun:test"; import process from "node:process"; +import { Effect, Fiber, FileSystem, Path, Scope, Stream, SubscriptionRef } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { heavyLockVariable } from "../heavy-lock.js"; +import { ScriptsPlatform } from "../measure-job.js"; // The tests run real measure-job and hook-slot processes against their own lock directory, so the // machine's heavy lock is not involved. Each job's command holds the lock for a real number of // seconds, because the waits under test are waits of other processes for flock(2). A command -// prints its start and end in epoch milliseconds. - -const scripts = join(import.meta.dir, ".."); - -const runtime = mkdtempSync("/tmp/heavy-lock-test-"); - -// The tests may run inside a hook job, which holds the machine's lock and names itself here. -const env = Object.fromEntries( - Object.entries({ - ...process.env, - XDG_RUNTIME_DIR: runtime, - XDG_STATE_HOME: runtime, - VEKTORPROGRAMMET_HOOK_SLOTS: "2", - }).filter(([name]) => name !== heavyLockVariable), +// prints its start and end in epoch milliseconds. Each test's scope kills the process groups of +// its jobs when it closes. + +const run = (effect: Effect.Effect): Promise => + Effect.runPromise(effect.pipe(Effect.scoped, Effect.provide(ScriptsPlatform))); + +const { scripts, runtime } = await Effect.runPromise( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + return { + scripts: path.join(import.meta.dir, ".."), + runtime: yield* fileSystem.makeTempDirectory({ + directory: "/tmp", + prefix: "heavy-lock-test-", + }), + }; + }).pipe(Effect.provide(ScriptsPlatform)), ); +// The tests may run inside a hook job, which holds the machine's lock and names itself in +// VEKTORPROGRAMMET_HEAVY_LOCK; an empty value names no holder. +const variables = { + XDG_RUNTIME_DIR: runtime, + XDG_STATE_HOME: runtime, + VEKTORPROGRAMMET_HOOK_SLOTS: "2", + [heavyLockVariable]: "", +}; + interface Job { - readonly process: ChildProcess; readonly pid: number; /** Standard output and error so far. */ - readonly output: () => string; - /** Resolves when the output contains the text. */ - readonly printed: (text: string) => Promise; - /** Resolves with the exit code when the job's output is closed. */ - readonly closed: Promise; + readonly output: Effect.Effect; + /** Succeeds when the output contains the text. */ + readonly printed: (text: string) => Effect.Effect; + /** Succeeds with the exit code when the job has exited and its output is closed. */ + readonly closed: Effect.Effect; + /** Succeeds when the job's own process has exited, however it ended. */ + readonly exited: Effect.Effect; } -const jobs: Array = []; - -const run = (argv: ReadonlyArray): Job => { - // Its own process group, so the cleanup stops the whole tree of a job. - const child = spawn(process.execPath, argv, { env, detached: true, stdio: ["ignore", "pipe", "pipe"] }); - const waiters: Array<{ readonly text: string; readonly resolve: () => void }> = []; - const closed = Promise.withResolvers(); - let output = ""; - - const append = (chunk: Buffer) => { - output += chunk.toString(); - - for (const waiter of waiters) if (output.includes(waiter.text)) waiter.resolve(); - }; - - child.stdout?.on("data", append); - child.stderr?.on("data", append); - child.once("close", closed.resolve); - - const job = { - process: child, - pid: child.pid ?? 0, - output: () => output, - printed: (text: string) => { - const printed = Promise.withResolvers(); - - if (output.includes(text)) printed.resolve(); - else waiters.push({ text, resolve: printed.resolve }); - - return printed.promise; - }, - closed: closed.promise, - }; - - jobs.push(job); - - return job; -}; +const start = (argv: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + // Its own process group, so the cleanup stops the whole tree of a job. + const handle = yield* spawner.spawn( + ChildProcess.make(process.execPath, argv, { + env: variables, + extendEnv: true, + detached: true, + stdin: "ignore", + }), + ); + + const pid = Number(handle.pid); + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + try { + process.kill(-pid, "SIGKILL"); + } catch { + // The job and its processes have exited. + } + }), + ); + + const output = yield* SubscriptionRef.make(""); + + const reader = yield* Effect.forkScoped( + Stream.runForEach(Stream.decodeText(handle.all), (chunk) => + SubscriptionRef.update(output, (text) => text + chunk), + ), + ); + + const job: Job = { + pid, + output: SubscriptionRef.get(output), + printed: (text) => + Effect.asVoid( + Stream.runHead( + Stream.filter(SubscriptionRef.changes(output), (current) => current.includes(text)), + ), + ), + closed: Effect.andThen(Effect.ignore(Fiber.join(reader)), handle.exitCode), + exited: Effect.ignore(handle.exitCode), + }; + + return job; + }); const recorded = (seconds: number) => [ "sh", @@ -83,107 +108,154 @@ const recorded = (seconds: number) => [ ]; const heavyJob = (seconds: number) => - run(["--no-env-file", join(scripts, "measure-job.ts"), "--class", "demo", "--", ...recorded(seconds)]); + start([ + "--no-env-file", + `${scripts}/measure-job.ts`, + "--class", + "demo", + "--", + ...recorded(seconds), + ]); const hookJob = (seconds: number) => - run(["--no-env-file", join(scripts, "hook-slot.ts"), "--class", "hook-demo", "--", ...recorded(seconds)]); + start([ + "--no-env-file", + `${scripts}/hook-slot.ts`, + "--class", + "hook-demo", + "--", + ...recorded(seconds), + ]); // NaN when the job did not print the event. -const instant = (job: Job, event: "start" | "end") => - Number(new RegExp(`^${event} (\\d+)$`, "m").exec(job.output())?.[1]); - -afterEach(() => { - for (const job of jobs.splice(0)) { - try { - process.kill(-job.pid, "SIGKILL"); - } catch { - // The job and its processes have exited. - } - } -}); - -afterAll(() => rmSync(runtime, { recursive: true, force: true })); - -describe("the heavy lock", () => { - test("serializes two heavy jobs that start together and shows the holder", async () => { - const started = [heavyJob(2), heavyJob(2)]; +const instant = (output: string, event: "start" | "end") => + Number(new RegExp(`^${event} (\\d+)$`, "m").exec(output)?.[1]); - for (const job of started) expect(await job.closed).toBe(0); +afterAll(() => + Effect.runPromise( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; - const [first, second] = started.sort((left, right) => instant(left, "start") - instant(right, "start")); - - expect(instant(first!, "end")).toBeLessThanOrEqual(instant(second!, "start")); - expect(second!.output()).toContain(`waiting for the heavy job pid ${first!.pid}, demo, since `); - }, 30_000); - - test("keeps a hook job waiting while a heavy job runs", async () => { - const heavy = heavyJob(2); - - await heavy.printed("start "); - - const hook = hookJob(0); - - expect(await heavy.closed).toBe(0); - expect(await hook.closed).toBe(0); - expect(instant(hook, "start")).toBeGreaterThanOrEqual(instant(heavy, "end")); - expect(hook.output()).toContain(`waiting for the heavy job pid ${heavy.pid}, demo, since `); - }, 30_000); - - test("starts a heavy job after the running hook jobs and before hook jobs that arrive later", async () => { - const running = hookJob(2); - - await running.printed("start "); - - const heavy = heavyJob(1); - - await heavy.printed("waiting for 1 running hook job:"); - - const later = hookJob(0); - - for (const job of [running, heavy, later]) expect(await job.closed).toBe(0); - - expect(heavy.output()).toContain(`\n pid ${running.pid}, hook-demo, since `); - expect(instant(heavy, "start")).toBeGreaterThanOrEqual(instant(running, "end")); - expect(later.output()).toContain(`waiting for the heavy job pid ${heavy.pid}, demo, since `); - expect(instant(later, "start")).toBeGreaterThanOrEqual(instant(heavy, "end")); - }, 30_000); - - test("lets a heavy job and a hook job inside a heavy job run without waiting for it", async () => { - const outer = run([ - "--no-env-file", - join(scripts, "measure-job.ts"), - "--class", - "outer", - "--", - process.execPath, - "--no-env-file", - join(scripts, "measure-job.ts"), - "--class", - "inner", - "--", - process.execPath, - "--no-env-file", - join(scripts, "hook-slot.ts"), - "--class", - "hook-inner", - "--", - ...recorded(0), - ]); - - expect(await outer.closed).toBe(0); - expect(instant(outer, "end")).toBeGreaterThan(0); - }, 30_000); - - test("is released when its holder is killed, while the holder's job keeps running", async () => { - const killed = heavyJob(60); - - await killed.printed("start "); - killed.process.kill("SIGKILL"); - await once(killed.process, "exit"); - - const next = heavyJob(0); + yield* fileSystem.remove(runtime, { recursive: true, force: true }); + }).pipe(Effect.provide(ScriptsPlatform)), + ), +); - expect(await next.closed).toBe(0); - expect(instant(killed, "end")).toBeNaN(); - }, 30_000); +describe("the heavy lock", () => { + test("serializes two heavy jobs that start together and shows the holder", () => + run( + Effect.gen(function* () { + const started = [yield* heavyJob(2), yield* heavyJob(2)]; + + for (const job of started) expect(yield* job.closed).toBe(0); + + const outputs = yield* Effect.forEach(started, (job) => + Effect.map(job.output, (output) => ({ pid: job.pid, output })), + ); + + const [first, second] = outputs.sort( + (left, right) => instant(left.output, "start") - instant(right.output, "start"), + ); + + expect(instant(first!.output, "end")).toBeLessThanOrEqual( + instant(second!.output, "start"), + ); + expect(second!.output).toContain( + `waiting for the heavy job pid ${first!.pid}, demo, since `, + ); + }), + ), 30_000); + + test("keeps a hook job waiting while a heavy job runs", () => + run( + Effect.gen(function* () { + const heavy = yield* heavyJob(2); + + yield* heavy.printed("start "); + + const hook = yield* hookJob(0); + + expect(yield* heavy.closed).toBe(0); + expect(yield* hook.closed).toBe(0); + + const heavyOutput = yield* heavy.output; + const hookOutput = yield* hook.output; + + expect(instant(hookOutput, "start")).toBeGreaterThanOrEqual(instant(heavyOutput, "end")); + expect(hookOutput).toContain(`waiting for the heavy job pid ${heavy.pid}, demo, since `); + }), + ), 30_000); + + test("starts a heavy job after the running hook jobs and before hook jobs that arrive later", () => + run( + Effect.gen(function* () { + const running = yield* hookJob(2); + + yield* running.printed("start "); + + const heavy = yield* heavyJob(1); + + yield* heavy.printed("waiting for 1 running hook job:"); + + const later = yield* hookJob(0); + + for (const job of [running, heavy, later]) expect(yield* job.closed).toBe(0); + + const runningOutput = yield* running.output; + const heavyOutput = yield* heavy.output; + const laterOutput = yield* later.output; + + expect(heavyOutput).toContain(`\n pid ${running.pid}, hook-demo, since `); + expect(instant(heavyOutput, "start")).toBeGreaterThanOrEqual( + instant(runningOutput, "end"), + ); + expect(laterOutput).toContain(`waiting for the heavy job pid ${heavy.pid}, demo, since `); + expect(instant(laterOutput, "start")).toBeGreaterThanOrEqual(instant(heavyOutput, "end")); + }), + ), 30_000); + + test("lets a heavy job and a hook job inside a heavy job run without waiting for it", () => + run( + Effect.gen(function* () { + const outer = yield* start([ + "--no-env-file", + `${scripts}/measure-job.ts`, + "--class", + "outer", + "--", + process.execPath, + "--no-env-file", + `${scripts}/measure-job.ts`, + "--class", + "inner", + "--", + process.execPath, + "--no-env-file", + `${scripts}/hook-slot.ts`, + "--class", + "hook-inner", + "--", + ...recorded(0), + ]); + + expect(yield* outer.closed).toBe(0); + expect(instant(yield* outer.output, "end")).toBeGreaterThan(0); + }), + ), 30_000); + + test("is released when its holder is killed, while the holder's job keeps running", () => + run( + Effect.gen(function* () { + const killed = yield* heavyJob(60); + + yield* killed.printed("start "); + process.kill(killed.pid, "SIGKILL"); + yield* killed.exited; + + const next = yield* heavyJob(0); + + expect(yield* next.closed).toBe(0); + expect(instant(yield* killed.output, "end")).toBeNaN(); + }), + ), 30_000); }); diff --git a/tools/scripts/tests/land.test.ts b/tools/scripts/tests/land.test.ts index 6436d4f48..b3657d5f7 100644 --- a/tools/scripts/tests/land.test.ts +++ b/tools/scripts/tests/land.test.ts @@ -1,14 +1,16 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; import process from "node:process"; +import { Data, Effect, FileSystem, Path, Scope } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import { ChildProcess } from "effect/unstable/process"; +import { type CommandResult, runCommand } from "../command.js"; +import { ScriptsPlatform } from "../measure-job.js"; // Each test lands a branch in its own repository: a main checkout and a linked worktree of the // branch `feature`. Git must not see the variables of a hook that runs the tests, nor the user's // configuration. -const land = join(import.meta.dir, "..", "land.ts"); +const land = `${import.meta.dir}/../land.ts`; const identity = { name: "Land Test", email: "land@example.invalid" }; @@ -22,128 +24,177 @@ const env = { GIT_COMMITTER_EMAIL: identity.email, }; -const roots: Array = []; +class GitFailure extends Data.TaggedError("GitFailure")<{ readonly message: string }> {} -const git = (cwd: string, ...gitArguments: Array) => { - const result = spawnSync("git", gitArguments, { cwd, env, encoding: "utf8" }); +const git = Effect.fnUntraced(function* (cwd: string, ...gitArguments: Array) { + const result = yield* runCommand( + ChildProcess.make("git", gitArguments, { cwd, env, extendEnv: false, stdin: "ignore" }), + ); - if (result.status !== 0) throw new Error(`git ${gitArguments.join(" ")}: ${result.stderr}`); + if (result.status !== 0) + return yield* new GitFailure({ message: `git ${gitArguments.join(" ")}: ${result.stderr}` }); return result.stdout.trim(); -}; +}); -const commit = (cwd: string, file: string, text: string) => { - writeFileSync(join(cwd, file), text); - git(cwd, "add", file); - git(cwd, "commit", "--quiet", "--message", `change ${file}`); -}; +const write = Effect.fnUntraced(function* (file: string, text: string) { + const fileSystem = yield* FileSystem.FileSystem; -const repository = () => { - const root = mkdtempSync("/tmp/land-test-"); - const main = join(root, "main"); - const feature = join(root, "feature"); + yield* fileSystem.writeFileString(file, text); +}); - roots.push(root); - git(root, "init", "--quiet", "--initial-branch=main", main); - commit(main, "base.txt", "base\n"); - git(main, "worktree", "add", "--quiet", "-b", "feature", feature); - commit(feature, "feature.txt", "feature\n"); +const exists = Effect.fnUntraced(function* (file: string) { + const fileSystem = yield* FileSystem.FileSystem; - return { root, main, feature }; -}; + return yield* fileSystem.exists(file); +}); + +const commit = Effect.fnUntraced(function* (cwd: string, file: string, text: string) { + yield* write(`${cwd}/${file}`, text); + yield* git(cwd, "add", file); + yield* git(cwd, "commit", "--quiet", "--message", `change ${file}`); +}); -const runLand = (cwd: string, branch: string) => - spawnSync(process.execPath, ["--no-env-file", land, branch], { cwd, env, encoding: "utf8" }); +// The repository is removed when the test's scope closes. +const repository = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ directory: "/tmp", prefix: "land-test-" }); + const main = path.join(root, "main"); + const feature = path.join(root, "feature"); -afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + yield* git(root, "init", "--quiet", "--initial-branch=main", main); + yield* commit(main, "base.txt", "base\n"); + yield* git(main, "worktree", "add", "--quiet", "-b", "feature", feature); + yield* commit(feature, "feature.txt", "feature\n"); + + return { root, main, feature }; }); +const runLand = ( + cwd: string, + branch: string, +): Effect.Effect => + runCommand( + ChildProcess.make(process.execPath, ["--no-env-file", land, branch], { + cwd, + env, + extendEnv: false, + stdin: "ignore", + }), + ); + +const run = (effect: Effect.Effect): Promise => + Effect.runPromise(effect.pipe(Effect.scoped, Effect.provide(ScriptsPlatform))); + describe("just land", () => { - test("records a merge commit even when main can fast-forward", () => { - const { main, feature } = repository(); - const parents = [git(main, "rev-parse", "HEAD"), git(feature, "rev-parse", "HEAD")]; - const result = runLand(main, "feature"); - - expect(result.status).toBe(0); - expect(result.stdout).toContain("merge commit"); - expect(git(main, "rev-list", "--parents", "--max-count=1", "main").split(" ").slice(1)).toEqual(parents); - expect(existsSync(feature)).toBe(false); - expect(git(main, "branch", "--list", "feature")).toBe(""); - }); - - test("a failing pre-merge-commit hook blocks a fast-forwardable branch", () => { - const { main, feature } = repository(); - const before = git(main, "rev-parse", "HEAD"); - const hook = join(main, ".git", "hooks", "pre-merge-commit"); - writeFileSync(hook, "#!/bin/sh\nexit 1\n"); - chmodSync(hook, 0o755); - - const result = runLand(main, "feature"); - expect(result.status).toBe(1); - expect(git(main, "rev-parse", "HEAD")).toBe(before); - expect(existsSync(feature)).toBe(true); - }); - - test("records a merge commit when main has moved on", () => { - const { main, feature } = repository(); - - commit(main, "main.txt", "main\n"); - - const parents = [git(main, "rev-parse", "HEAD"), git(feature, "rev-parse", "HEAD")]; - const result = runLand(main, "feature"); - - expect(result.status).toBe(0); - expect(result.stdout).toContain("merge commit"); - expect(git(main, "rev-list", "--parents", "--max-count=1", "main").split(" ").slice(1)).toEqual( - parents, - ); - expect(existsSync(feature)).toBe(false); - }); - - test("refuses a branch whose worktree has an untracked file and changes nothing", () => { - const { main, feature } = repository(); - const before = git(main, "rev-parse", "main"); - - writeFileSync(join(feature, "notes.txt"), "unfinished\n"); - - const result = runLand(main, "feature"); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("notes.txt"); - expect(git(main, "rev-parse", "main")).toBe(before); - expect(existsSync(join(feature, "notes.txt"))).toBe(true); - expect(git(main, "branch", "--list", "feature")).not.toBe(""); - }); - - test("refuses a branch that contains the commits of another unlanded branch", () => { - const { root, main } = repository(); - const stacked = join(root, "stacked"); - const before = git(main, "rev-parse", "main"); - - git(main, "worktree", "add", "--quiet", "-b", "stacked", stacked, "feature"); - commit(stacked, "stacked.txt", "stacked\n"); - - const result = runLand(main, "stacked"); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("stacked is based on feature"); - expect(git(main, "rev-parse", "main")).toBe(before); - expect(existsSync(stacked)).toBe(true); - }); - - test("aborts a merge that stops on a conflict and leaves main unchanged", () => { - const { main, feature } = repository(); - - commit(main, "feature.txt", "main's version\n"); - - const before = git(main, "rev-parse", "main"); - const result = runLand(main, "feature"); - - expect(result.status).toBe(1); - expect(result.stderr).toContain("was aborted"); - expect(git(main, "rev-parse", "main")).toBe(before); - expect(git(main, "status", "--porcelain")).toBe(""); - expect(existsSync(feature)).toBe(true); - }); + test("records a merge commit even when main can fast-forward", () => + run( + Effect.gen(function* () { + const { main, feature } = yield* repository; + const parents = [yield* git(main, "rev-parse", "HEAD"), yield* git(feature, "rev-parse", "HEAD")]; + const result = yield* runLand(main, "feature"); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("merge commit"); + expect( + (yield* git(main, "rev-list", "--parents", "--max-count=1", "main")).split(" ").slice(1), + ).toEqual(parents); + expect(yield* exists(feature)).toBe(false); + expect(yield* git(main, "branch", "--list", "feature")).toBe(""); + }), + )); + + test("a failing pre-merge-commit hook blocks a fast-forwardable branch", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { main, feature } = yield* repository; + const before = yield* git(main, "rev-parse", "HEAD"); + const hook = `${main}/.git/hooks/pre-merge-commit`; + + yield* write(hook, "#!/bin/sh\nexit 1\n"); + yield* fileSystem.chmod(hook, 0o755); + + const result = yield* runLand(main, "feature"); + + expect(result.status).toBe(1); + expect(yield* git(main, "rev-parse", "HEAD")).toBe(before); + expect(yield* exists(feature)).toBe(true); + }), + )); + + test("records a merge commit when main has moved on", () => + run( + Effect.gen(function* () { + const { main, feature } = yield* repository; + + yield* commit(main, "main.txt", "main\n"); + + const parents = [yield* git(main, "rev-parse", "HEAD"), yield* git(feature, "rev-parse", "HEAD")]; + const result = yield* runLand(main, "feature"); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("merge commit"); + expect( + (yield* git(main, "rev-list", "--parents", "--max-count=1", "main")).split(" ").slice(1), + ).toEqual(parents); + expect(yield* exists(feature)).toBe(false); + }), + )); + + test("refuses a branch whose worktree has an untracked file and changes nothing", () => + run( + Effect.gen(function* () { + const { main, feature } = yield* repository; + const before = yield* git(main, "rev-parse", "main"); + + yield* write(`${feature}/notes.txt`, "unfinished\n"); + + const result = yield* runLand(main, "feature"); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("notes.txt"); + expect(yield* git(main, "rev-parse", "main")).toBe(before); + expect(yield* exists(`${feature}/notes.txt`)).toBe(true); + expect(yield* git(main, "branch", "--list", "feature")).not.toBe(""); + }), + )); + + test("refuses a branch that contains the commits of another unlanded branch", () => + run( + Effect.gen(function* () { + const { root, main } = yield* repository; + const stacked = `${root}/stacked`; + const before = yield* git(main, "rev-parse", "main"); + + yield* git(main, "worktree", "add", "--quiet", "-b", "stacked", stacked, "feature"); + yield* commit(stacked, "stacked.txt", "stacked\n"); + + const result = yield* runLand(main, "stacked"); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("stacked is based on feature"); + expect(yield* git(main, "rev-parse", "main")).toBe(before); + expect(yield* exists(stacked)).toBe(true); + }), + )); + + test("aborts a merge that stops on a conflict and leaves main unchanged", () => + run( + Effect.gen(function* () { + const { main, feature } = yield* repository; + + yield* commit(main, "feature.txt", "main's version\n"); + + const before = yield* git(main, "rev-parse", "main"); + const result = yield* runLand(main, "feature"); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("was aborted"); + expect(yield* git(main, "rev-parse", "main")).toBe(before); + expect(yield* git(main, "status", "--porcelain")).toBe(""); + expect(yield* exists(feature)).toBe(true); + }), + )); }); diff --git a/tools/scripts/vitest-admission.ts b/tools/scripts/vitest-admission.ts index 7c3bb84d9..d2775b3c3 100644 --- a/tools/scripts/vitest-admission.ts +++ b/tools/scripts/vitest-admission.ts @@ -4,28 +4,54 @@ * slot, `just measure`, or another admitted run). A heavy job then waits for running test runs, * and a test run that starts later waits for the heavy job. * - * The locks belong to the Vitest main process and are released when it exits, also on a signal. - * Its processes inherit `VEKTORPROGRAMMET_HEAVY_LOCK`, so a nested run does not wait for itself. + * The locks belong to the Vitest main process and are released when it exits, also on a signal: + * the setup takes them in a scope that it never closes. Its processes inherit + * `VEKTORPROGRAMMET_HEAVY_LOCK`, so a nested run does not wait for itself. * Watch mode is not admitted: it would hold the slot until it is stopped and keep heavy jobs out. */ +import * as BunFileSystem from "@effect/platform-bun/BunFileSystem"; +import * as BunPath from "@effect/platform-bun/BunPath"; import process from "node:process"; -import { heavyLockVariable, insideHeavyLock, takeHeavyLock, takeHookSlot } from "./heavy-lock.js"; +import { Console, Effect, Layer, Scope } from "effect"; +import { + heavyLockVariable, + insideHeavyLock, + localNow, + takeHeavyLock, + takeHookSlot, +} from "./heavy-lock.js"; /** The part of Vitest's TestProject that the setup reads. */ interface Project { readonly config: { readonly watch: boolean }; } +// Vitest may run the setup under Node, whose runtime these layers share with Bun. +const AdmissionPlatform = Layer.merge(BunFileSystem.layer, BunPath.layer); + const log = (message: string) => - process.stderr.write(`vitest-admission: ${new Date().toTimeString().slice(0, 8)} ${message}\n`); + Effect.flatMap(localNow, ({ time }) => Console.error(`vitest-admission: ${time} ${message}`)); -export const setup = (project: Project) => { - if (project.config.watch || insideHeavyLock()) return; +const admission = Effect.gen(function* () { + if (yield* insideHeavyLock) return; const command = ["vitest", ...process.argv.slice(2)]; - takeHeavyLock("shared", "vitest", command, log); - takeHookSlot("vitest", log); + const variables = yield* takeHeavyLock({ mode: "shared", jobClass: "vitest", command, log }); + + yield* takeHookSlot({ jobClass: "vitest", log }); + + return variables[heavyLockVariable] ?? String(process.pid); +}); - process.env[heavyLockVariable] = String(process.pid); -}; +export const setup = (project: Project): Promise | undefined => + project.config.watch + ? undefined + : // The scope stays open for the life of the Vitest main process, which holds the locks. + Effect.runPromise( + admission.pipe(Scope.provide(Scope.makeUnsafe()), Effect.provide(AdmissionPlatform)), + ).then((holder) => { + // Vitest starts its workers with the environment of its main process. + // oxlint-disable-next-line effecttsgo/process-env -- EX-0014: Vitest workers learn the holder of the lock only from the environment of the main process + if (holder !== undefined) process.env[heavyLockVariable] = holder; + }); diff --git a/tools/source-safety/package.json b/tools/source-safety/package.json index 83921ccda..6ad09c5e6 100644 --- a/tools/source-safety/package.json +++ b/tools/source-safety/package.json @@ -10,5 +10,9 @@ "devDependencies": { "@types/node": "^22", "typescript": "7.0.2" + }, + "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", + "effect": "catalog:effect-v4" } } diff --git a/tools/source-safety/src/check.ts b/tools/source-safety/src/check.ts index acb92c3f1..84398feed 100644 --- a/tools/source-safety/src/check.ts +++ b/tools/source-safety/src/check.ts @@ -4,7 +4,13 @@ * so the pre-commit hook checks only the index entries that differ from HEAD (`--changed`); the * merge hook and `just check` check every entry, so no task cache can skip it. */ -import { spawnSync } from "node:child_process"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import process from "node:process"; +import { Console, Data, Effect, Exit, Predicate, Stream } from "effect"; +import * as Runtime from "effect/Runtime"; +import { dual } from "effect/Function"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { isTextualSourcePath, sourcePathSafetyReason, @@ -22,104 +28,185 @@ export interface SourceSafetyScan { readonly findings: readonly SourceSafetyFinding[]; } -const git = (root: string, args: readonly string[], input?: string): Buffer => { - const result = spawnSync("git", ["-C", root, ...args], { - input, - maxBuffer: 2 ** 31 - 1, - }); +/** A Git command that failed, or whose output the scan could not read. */ +export class SourceSafetyGitError extends Data.TaggedError("SourceSafetyGitError")<{ + readonly message: string; +}> {} - if (result.error !== undefined) throw result.error; +const concatenate = (chunks: ReadonlyArray): Uint8Array => { + const bytes = new Uint8Array(chunks.reduce((length, chunk) => length + chunk.length, 0)); + let offset = 0; - if (result.status !== 0) - throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString().trim()}`); + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } - return result.stdout; + return bytes; }; +const utf8 = new TextDecoder(); + +// The exit code and the output bytes of one Git command; `input` is written to its standard input. +const runGit = (root: string, args: readonly string[], input?: string) => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const handle = yield* spawner.spawn( + ChildProcess.make("git", ["-C", root, ...args], { + stdin: input === undefined ? "ignore" : Stream.make(new TextEncoder().encode(input)), + }), + ); + + const [stdout, stderr, status] = yield* Effect.all( + [Stream.runCollect(handle.stdout), Stream.runCollect(handle.stderr), handle.exitCode], + { concurrency: "unbounded" }, + ); + + return { status, stdout: concatenate(stdout), stderr: utf8.decode(concatenate(stderr)) }; + }), + ).pipe( + Effect.mapError( + (error) => + new SourceSafetyGitError({ message: `git ${args.join(" ")} failed: ${error.message}` }), + ), + ); + +const git = (root: string, args: readonly string[], input?: string) => + Effect.flatMap(runGit(root, args, input), (result) => + result.status === 0 + ? Effect.succeed(result.stdout) + : Effect.fail( + new SourceSafetyGitError({ + message: `git ${args.join(" ")} failed: ${result.stderr.trim()}`, + }), + ), + ); + /** `index` checks every entry of the index; `changed` only the entries that differ from HEAD. */ export type SourceSafetyScope = "index" | "changed"; +const isScope = (value: unknown): value is SourceSafetyScope => + value === "index" || value === "changed"; + // Git's empty tree, which a repository without commits compares against. const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; /** The mode, object id, and path of each index entry in the scope, submodules excluded. */ -const indexEntries = (root: string, scope: SourceSafetyScope) => { - if (scope === "index") - // Each entry is ` \t`. - return git(root, ["ls-files", "--stage", "-z"]) - .toString("utf8") - .split("\0") - .flatMap((entry) => { - const match = entry.match(/^(\d{6}) ([0-9a-f]+) \d\t(.+)$/su); - - return match === null ? [] : [{ mode: match[1], objectId: match[2], path: match[3] }]; - }); - - const head = spawnSync("git", ["-C", root, "rev-parse", "--verify", "--quiet", "HEAD"]); - - // Each entry is `: \0\0`; deletions are gone. - const fields = git(root, [ - "diff-index", - "--cached", - "--no-renames", - "--diff-filter=d", - "-z", - head.status === 0 ? "HEAD" : emptyTree, - ]) - .toString("utf8") - .split("\0"); - - return Array.from({ length: Math.floor(fields.length / 2) }, (_, index) => { - const header = fields[index * 2] ?? ""; - const match = /^:\d{6} (\d{6}) [0-9a-f]+ ([0-9a-f]+) [A-Z]\d*$/u.exec(header); - - if (match === null) throw new Error("git diff-index returned an invalid entry: " + header); - - return { mode: match[1], objectId: match[2], path: fields[index * 2 + 1] }; +const indexEntries = (root: string, scope: SourceSafetyScope) => + Effect.gen(function* () { + if (scope === "index") + // Each entry is ` \t`. + return utf8 + .decode(yield* git(root, ["ls-files", "--stage", "-z"])) + .split("\0") + .flatMap((entry) => { + const match = entry.match(/^(\d{6}) ([0-9a-f]+) \d\t(.+)$/su); + + return match === null ? [] : [{ mode: match[1], objectId: match[2], path: match[3] }]; + }); + + const head = yield* runGit(root, ["rev-parse", "--verify", "--quiet", "HEAD"]); + + // Each entry is `: \0\0`; deletions are gone. + const fields = utf8 + .decode( + yield* git(root, [ + "diff-index", + "--cached", + "--no-renames", + "--diff-filter=d", + "-z", + head.status === 0 ? "HEAD" : emptyTree, + ]), + ) + .split("\0"); + + return yield* Effect.forEach( + Array.from({ length: Math.floor(fields.length / 2) }, (_, index) => index), + (index) => { + const header = fields[index * 2] ?? ""; + const match = /^:\d{6} (\d{6}) [0-9a-f]+ ([0-9a-f]+) [A-Z]\d*$/u.exec(header); + + return match === null + ? Effect.fail( + new SourceSafetyGitError({ + message: "git diff-index returned an invalid entry: " + header, + }), + ) + : Effect.succeed({ mode: match[1], objectId: match[2], path: fields[index * 2 + 1] }); + }, + ); }); -}; -/** Returns the findings for every path and textual blob in the scope of the index at `root`. */ -export const scanIndex = (root: string, scope: SourceSafetyScope = "index"): SourceSafetyScan => { - const findings: SourceSafetyFinding[] = []; - const textual: { readonly path: string; readonly objectId: string }[] = []; - let files = 0; - - // Submodules (mode 160000) have no blob. - for (const { mode, objectId = "", path = "" } of indexEntries(root, scope)) { - if (mode === "160000" || path === "") continue; - files += 1; - - if (sourcePathSafetyReason(path) !== null) findings.push({ path, reason: "UNSAFE_SOURCE" }); - else if (isTextualSourcePath(path)) textual.push({ path, objectId }); - } +const scan = (root: string, scope: SourceSafetyScope = "index") => + Effect.gen(function* () { + const findings: SourceSafetyFinding[] = []; + const textual: { readonly path: string; readonly objectId: string }[] = []; + let files = 0; + + // Submodules (mode 160000) have no blob. + for (const { mode, objectId = "", path = "" } of yield* indexEntries(root, scope)) { + if (mode === "160000" || path === "") continue; + files += 1; + + if (sourcePathSafetyReason(path) !== null) findings.push({ path, reason: "UNSAFE_SOURCE" }); + else if (isTextualSourcePath(path)) textual.push({ path, objectId }); + } + + const blobs = yield* git( + root, + ["cat-file", "--batch"], + textual.map(({ objectId }) => objectId).join("\n"), + ); - const blobs = git( - root, - ["cat-file", "--batch"], - textual.map(({ objectId }) => objectId).join("\n"), - ); + // `--batch` answers each object ID with ` \n\n`, in input order. + let offset = 0; - // `--batch` answers each object ID with ` \n\n`, in input order. - let offset = 0; + for (const { path } of textual) { + const headerEnd = blobs.indexOf(0x0a, offset); + const size = Number(utf8.decode(blobs.subarray(offset, headerEnd)).split(" ")[2]); - for (const { path } of textual) { - const headerEnd = blobs.indexOf(0x0a, offset); - const size = Number(blobs.subarray(offset, headerEnd).toString("utf8").split(" ")[2]); + if (!Number.isSafeInteger(size)) + return yield* new SourceSafetyGitError({ + message: `git cat-file returned no blob for ${path}`, + }); - if (!Number.isSafeInteger(size)) throw new Error(`git cat-file returned no blob for ${path}`); + const reason = sourceTextSafetyReason( + path, + blobs.subarray(headerEnd + 1, headerEnd + 1 + size), + ); - const reason = sourceTextSafetyReason( - path, - blobs.subarray(headerEnd + 1, headerEnd + 1 + size), - ); + if (reason !== null) findings.push({ path, reason }); + offset = headerEnd + 1 + size + 1; + } - if (reason !== null) findings.push({ path, reason }); - offset = headerEnd + 1 + size + 1; - } + return { files, findings } satisfies SourceSafetyScan; + }); - return { files, findings }; -}; +/** Returns the findings for every path and textual blob in the scope of the index at `root`. */ +export const scanIndex: { + ( + scope?: SourceSafetyScope, + ): ( + root: string, + ) => Effect.Effect< + SourceSafetyScan, + SourceSafetyGitError, + ChildProcessSpawner.ChildProcessSpawner + >; + ( + root: string, + scope?: SourceSafetyScope, + ): Effect.Effect; +} = dual((args) => args.length === 2 || (args.length === 1 && !isScope(args[0])), scan); + +/** The platform of the scan: the Bun services that this composition root provides. */ +export const SourceSafetyPlatform = BunServices.layer; + +/** The services of `SourceSafetyPlatform`. */ +export type SourceSafetyPlatform = BunServices.BunServices; const REASON_TEXT: Record = { INVALID_UTF8: "textual file is not valid UTF-8", @@ -127,25 +214,34 @@ const REASON_TEXT: Record = { "path or content looks like a credential, personal data, or database material; remove it, or record a reviewed exception in tools/source-safety/src/source-safety.ts", }; -if (import.meta.main) { +const main = Effect.gen(function* () { const options = process.argv.slice(2); if (options.some((option) => option !== "--changed")) { - process.stderr.write( - "Usage: just source-safety [--changed]\n--changed checks only the index entries that differ from HEAD.\n", + yield* Console.error( + "Usage: just source-safety [--changed]\n--changed checks only the index entries that differ from HEAD.", ); - process.exit(2); + + return 2; } const scope: SourceSafetyScope = options.includes("--changed") ? "changed" : "index"; - const root = git(process.cwd(), ["rev-parse", "--show-toplevel"]).toString("utf8").trim(); - const { files, findings } = scanIndex(root, scope); + const root = utf8.decode(yield* git(process.cwd(), ["rev-parse", "--show-toplevel"])).trim(); + const { files, findings } = yield* scanIndex(root, scope); - for (const { path, reason } of findings) - process.stderr.write(`${path}: ${REASON_TEXT[reason]}\n`); + for (const { path, reason } of findings) yield* Console.error(`${path}: ${REASON_TEXT[reason]}`); - process.stdout.write( - `source-safety: ${files} ${scope === "index" ? "indexed" : "changed"} files, ${findings.length} unsafe${findings.length === 0 ? "" : " (see above)"}\n`, + yield* Console.log( + `source-safety: ${files} ${scope === "index" ? "indexed" : "changed"} files, ${findings.length} unsafe${findings.length === 0 ? "" : " (see above)"}`, ); - process.exitCode = findings.length === 0 ? 0 : 1; -} + + return findings.length === 0 ? 0 : 1; +}); + +if (import.meta.main) + BunRuntime.runMain(main.pipe(Effect.provide(SourceSafetyPlatform)), { + teardown: (exit, onExit) => { + if (Exit.isSuccess(exit) && Predicate.isNumber(exit.value)) onExit(exit.value); + else Runtime.defaultTeardown(exit, onExit); + }, + }); diff --git a/tools/source-safety/src/source-safety.ts b/tools/source-safety/src/source-safety.ts index 3bfceeef4..41381f629 100644 --- a/tools/source-safety/src/source-safety.ts +++ b/tools/source-safety/src/source-safety.ts @@ -5,6 +5,7 @@ * data. Other textual files must be valid UTF-8. The rules are heuristics; reviewed exceptions * are exact paths or exact file digests, each with a recorded reason. */ +import { dual } from "effect/Function"; import { createHash } from "node:crypto"; export type SourceSafetyReason = "INVALID_UTF8" | "UNSAFE_SOURCE"; @@ -926,7 +927,10 @@ const isAllowedTestValue = ( }; /** Returns a sanitized failure for concrete sensitive values in dotenv assignments. */ -export const unsafeEnvSourceTextReason = (text: string, path = ""): "UNSAFE_SOURCE" | null => { +export const unsafeEnvSourceTextReason: { + (path: string): (text: string) => "UNSAFE_SOURCE" | null; + (text: string, path: string): "UNSAFE_SOURCE" | null; +} = dual(2, (text: string, path: string): "UNSAFE_SOURCE" | null => { for (const line of text.split(/\r?\n/u)) { const trimmed = line.trim(); @@ -950,7 +954,7 @@ export const unsafeEnvSourceTextReason = (text: string, path = ""): "UNSAFE_SOUR } return null; -}; +}); /** Returns a sanitized failure for literal data or sensitive values in SQL source. */ export const unsafeSqlSourceTextReason = (text: string): "UNSAFE_SOURCE" | null => { @@ -983,10 +987,10 @@ export const unsafeSqlSourceTextReason = (text: string): "UNSAFE_SOURCE" | null }; /** Validates the bytes of a textual source file. */ -export const sourceTextSafetyReason = ( - path: string, - value: Uint8Array, -): SourceSafetyReason | null => { +export const sourceTextSafetyReason: { + (value: Uint8Array): (path: string) => SourceSafetyReason | null; + (path: string, value: Uint8Array): SourceSafetyReason | null; +} = dual(2, (path: string, value: Uint8Array): SourceSafetyReason | null => { if (!isTextualSourcePath(path)) return null; let text: string; @@ -1011,7 +1015,7 @@ export const sourceTextSafetyReason = ( return "UNSAFE_SOURCE"; return null; -}; +}); /** Reserved example and test domains are fixture addresses, not personal data. */ const hasUnreservedEmail = (text: string): boolean => diff --git a/tools/source-safety/tests/source-safety.test.ts b/tools/source-safety/tests/source-safety.test.ts index 7bdf68d07..0b5da7156 100644 --- a/tools/source-safety/tests/source-safety.test.ts +++ b/tools/source-safety/tests/source-safety.test.ts @@ -1,8 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { scanIndex } from "../src/check.js"; +import { Effect, FileSystem, Path, Predicate } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { scanIndex, SourceSafetyPlatform } from "../src/check.js"; import { sourcePathSafetyReason, sourceTextSafetyReason, @@ -10,29 +9,90 @@ import { unsafeSqlSourceTextReason, } from "../src/source-safety.js"; -const repoRoot = join(import.meta.dir, "../../.."); +const run = (effect: Effect.Effect): Promise => + Effect.runPromise(effect.pipe(Effect.provide(SourceSafetyPlatform))); -const withGitFixture = (run: (root: string) => void): void => { - const root = mkdtempSync("/tmp/source-safety-git-"); +const git = (root: string, args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - try { - execFileSync("git", ["-C", root, "init", "-q"]); - run(root); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}; + const status = yield* spawner.exitCode( + ChildProcess.make("git", ["-C", root, ...args], { + stdin: "ignore", + stdout: "ignore", + stderr: "inherit", + }), + ); -const put = (root: string, path: string, contents: string | Uint8Array): void => { - const target = join(root, path); - mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, contents); -}; + if (status !== 0) return yield* Effect.die(`git ${args.join(" ")} exited with ${status}`); + }); + +// The repository files that the rules must admit, read once before the tests. +const repositoryPaths = [ + "packages/database/migrations/0027-native-oauth-provider.sql", + "packages/database/migrations/0029-native-http-semantics.sql", + "packages/database/migrations/0059-school-service-person-intervals.sql", + "packages/database/migrations/0012-native-recruitment-invitation-response.sql", + "apps/dashboard/e2e/native-team-interest-mailing-list-seed.sql", + "packages/database/migrations/tutor/0001-tutor-event-store.sql", +] as const; + +const repositoryFiles = new Map( + await run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = path.join(import.meta.dir, "../../.."); + + return yield* Effect.forEach(repositoryPaths, (file) => + Effect.map( + fileSystem.readFile(path.join(repoRoot, file)), + (bytes) => [file, bytes] as const, + ), + ); + }), + ), +); -const stage = (root: string): void => { - execFileSync("git", ["-C", root, "add", "--all"]); +const repositoryFile = (file: string): Uint8Array => { + const bytes = repositoryFiles.get(file); + + expect(bytes).toBeInstanceOf(Uint8Array); + + return bytes ?? new Uint8Array(); }; +const withGitFixture = (use: (root: string) => Effect.Effect) => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + const root = yield* fileSystem.makeTempDirectoryScoped({ + directory: "/tmp", + prefix: "source-safety-git-", + }); + + yield* git(root, ["init", "-q"]); + + return yield* use(root); + }), + ); + +const put = (root: string, file: string, contents: string | Uint8Array) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = path.join(root, file); + + yield* fileSystem.makeDirectory(path.dirname(target), { recursive: true }); + yield* fileSystem.writeFile( + target, + Predicate.isString(contents) ? new TextEncoder().encode(contents) : contents, + ); + }); + +const stage = (root: string) => git(root, ["add", "--all"]); + describe("paths", () => { test("blocks credential, backup, and database path classes", () => { for (const path of [ @@ -138,7 +198,7 @@ describe("SQL", () => { "packages/database/migrations/0029-native-http-semantics.sql", "packages/database/migrations/0059-school-service-person-intervals.sql", ]) { - const migration = readFileSync(join(repoRoot, migrationPath)); + const migration = repositoryFile(migrationPath); const migrationText = new TextDecoder().decode(migration); expect(unsafeSqlSourceTextReason(migrationText)).toBe("UNSAFE_SOURCE"); expect(sourceTextSafetyReason(migrationPath, migration)).toBeNull(); @@ -155,7 +215,7 @@ describe("SQL", () => { const migrationPath = "packages/database/migrations/0012-native-recruitment-invitation-response.sql"; - const migration = readFileSync(join(repoRoot, migrationPath)); + const migration = repositoryFile(migrationPath); expect(sourceTextSafetyReason(migrationPath, migration)).toBeNull(); expect( @@ -220,7 +280,7 @@ describe("SQL", () => { `), ).toBeNull(); const seedPath = "apps/dashboard/e2e/native-team-interest-mailing-list-seed.sql"; - expect(sourceTextSafetyReason(seedPath, readFileSync(join(repoRoot, seedPath)))).toBeNull(); + expect(sourceTextSafetyReason(seedPath, repositoryFile(seedPath))).toBeNull(); }); test("rejects literal, VALUES, stacked, and malformed recordset INSERTs", () => { @@ -262,9 +322,7 @@ describe("SQL", () => { test("allows real migration DDL and placeholders but rejects literal secrets and personal data", () => { const migrationPath = "packages/database/migrations/tutor/0001-tutor-event-store.sql"; - expect( - sourceTextSafetyReason(migrationPath, readFileSync(join(repoRoot, migrationPath))), - ).toBeNull(); + expect(sourceTextSafetyReason(migrationPath, repositoryFile(migrationPath))).toBeNull(); expect(unsafeSqlSourceTextReason("UPDATE users SET password = '${PASSWORD}';")).toBeNull(); expect( unsafeSqlSourceTextReason("UPDATE users SET password = '${PASSWORD}'; # password = 'secret'"), @@ -290,74 +348,84 @@ describe("SQL", () => { }); describe("index scan", () => { - test("rejects unsafe staged paths, dotenv, SQL, and invalid UTF-8", () => { - for (const [path, contents] of [ - ["apps/example/.env.test", "DATABASE_URL=mysql://vektor:concrete-secret@db/app\n"], - ["apps/example/.env.production", "APP_SECRET=@correct-horse-battery-staple@\n"], - [ - "packages/database/migrations/0100-malicious.sql", - "INSERT INTO users (email) VALUES ('alice@university.no');\n", - ], - ["var/backups/latest.dump", "binary"], - ["apps/example/.env.test", new Uint8Array([0x41, 0xff, 0xfe])], - ] as const) { - withGitFixture((root) => { - put(root, "README.md", "safe\n"); - put(root, path, contents); - stage(root); - expect(scanIndex(root)).toEqual({ - files: 2, - findings: [ - { - path, - reason: contents instanceof Uint8Array ? "INVALID_UTF8" : "UNSAFE_SOURCE", - }, + test("rejects unsafe staged paths, dotenv, SQL, and invalid UTF-8", () => + run( + Effect.forEach( + [ + ["apps/example/.env.test", "DATABASE_URL=mysql://vektor:concrete-secret@db/app\n"], + ["apps/example/.env.production", "APP_SECRET=@correct-horse-battery-staple@\n"], + [ + "packages/database/migrations/0100-malicious.sql", + "INSERT INTO users (email) VALUES ('alice@university.no');\n", ], - }); - }); - } - }); + ["var/backups/latest.dump", "binary"], + ["apps/example/.env.test", new Uint8Array([0x41, 0xff, 0xfe])], + ] as const, + ([path, contents]) => + withGitFixture((root) => + Effect.gen(function* () { + yield* put(root, "README.md", "safe\n"); + yield* put(root, path, contents); + yield* stage(root); + expect(yield* scanIndex(root)).toEqual({ + files: 2, + findings: [ + { + path, + reason: contents instanceof Uint8Array ? "INVALID_UTF8" : "UNSAFE_SOURCE", + }, + ], + }); + }), + ), + { discard: true }, + ), + )); - test("checks the staged bytes, not the working tree", () => { - withGitFixture((root) => { - const path = "apps/example/.env.production"; + test("checks the staged bytes, not the working tree", () => + run( + withGitFixture((root) => + Effect.gen(function* () { + const path = "apps/example/.env.production"; - put(root, path, "APP_SECRET=\n"); - stage(root); - put(root, path, "APP_SECRET=concrete-unstaged-secret\n"); - expect(scanIndex(root).findings).toEqual([]); + yield* put(root, path, "APP_SECRET=\n"); + yield* stage(root); + yield* put(root, path, "APP_SECRET=concrete-unstaged-secret\n"); + expect((yield* scanIndex(root)).findings).toEqual([]); - stage(root); - put(root, path, "APP_SECRET=\n"); - expect(scanIndex(root).findings).toEqual([{ path, reason: "UNSAFE_SOURCE" }]); - }); - }); - test("scans only changed staged blobs and ignores unstaged edits", () => { - withGitFixture((root) => { - const staged = "apps/example/.env.production"; - const unstaged = "apps/example/.env.test"; - put(root, staged, "APP_SECRET=" + String.fromCharCode(10)); - put(root, unstaged, "APP_SECRET=" + String.fromCharCode(10)); - stage(root); - execFileSync("git", [ - "-C", - root, - "-c", - "user.name=Safety Test", - "-c", - "user.email=test@example.invalid", - "commit", - "-qm", - "base", - ]); - put(root, staged, "APP_SECRET=staged-unsafe" + String.fromCharCode(10)); - stage(root); - put(root, staged, "APP_SECRET=" + String.fromCharCode(10)); - put(root, unstaged, "APP_SECRET=unstaged-unsafe" + String.fromCharCode(10)); - expect(scanIndex(root, "changed")).toEqual({ - files: 1, - findings: [{ path: staged, reason: "UNSAFE_SOURCE" }], - }); - }); - }); + yield* stage(root); + yield* put(root, path, "APP_SECRET=\n"); + expect((yield* scanIndex(root)).findings).toEqual([{ path, reason: "UNSAFE_SOURCE" }]); + }), + ), + )); + test("scans only changed staged blobs and ignores unstaged edits", () => + run( + withGitFixture((root) => + Effect.gen(function* () { + const staged = "apps/example/.env.production"; + const unstaged = "apps/example/.env.test"; + yield* put(root, staged, "APP_SECRET=" + String.fromCharCode(10)); + yield* put(root, unstaged, "APP_SECRET=" + String.fromCharCode(10)); + yield* stage(root); + yield* git(root, [ + "-c", + "user.name=Safety Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-qm", + "base", + ]); + yield* put(root, staged, "APP_SECRET=staged-unsafe" + String.fromCharCode(10)); + yield* stage(root); + yield* put(root, staged, "APP_SECRET=" + String.fromCharCode(10)); + yield* put(root, unstaged, "APP_SECRET=unstaged-unsafe" + String.fromCharCode(10)); + expect(yield* scanIndex(root, "changed")).toEqual({ + files: 1, + findings: [{ path: staged, reason: "UNSAFE_SOURCE" }], + }); + }), + ), + )); }); diff --git a/tools/verification/application-worker.test.ts b/tools/verification/application-worker.test.ts index 54fc5585a..68f4253ec 100644 --- a/tools/verification/application-worker.test.ts +++ b/tools/verification/application-worker.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, it } from "vitest"; +import { expect, layer } from "@effect/vitest"; import { Deferred, Effect, Fiber } from "effect"; import { TestClock } from "effect/testing"; import { @@ -9,14 +9,9 @@ import { import { Database } from "@vektorprogrammet/database"; import { DatabaseTestLive } from "@vektorprogrammet/database/test-support/platform"; import { executePublicApplicationCommand } from "@vektorprogrammet/database/application"; -import { makeControlledTestRuntime } from "./controlled-test-runtime.js"; import { runPublicApplicationOutboxWorker } from "@vektorprogrammet/backend/application/worker"; import { journeyClock } from "../e2e/journey-clock.js"; -const runtime = makeControlledTestRuntime(DatabaseTestLive()); - -afterAll(() => runtime.dispose()); - // The command's clock. The semester and the admission period are open around it, and the worker // runs one minute later; an offset of -721 minutes lands on midnight UTC. const commandClock = journeyClock("2031-09-15T12:01:00.000Z"); @@ -60,125 +55,128 @@ const outboxReferenceFixture = Effect.gen(function* () { `); }); -describe("public application delivery worker", () => { - it("releases an interrupted applicant worker claim before shutdown", async () => { - let starts = 0; - let stops = 0; +layer(DatabaseTestLive(), { excludeTestServices: true, timeout: "30 seconds" })( + "public application delivery worker", + (it) => { + it.effect("releases an interrupted applicant worker claim before shutdown", () => + Effect.gen(function* () { + let starts = 0; + let stops = 0; - const evidence = await runtime.runPromise( - Effect.scoped( - Effect.gen(function* () { - const database = yield* Database; - yield* outboxReferenceFixture; - yield* executePublicApplicationCommand( - { - commandId: "worker-application-submit", - departmentId: "outbox-department", - firstName: "Grace", - lastName: "Hopper", - phone: "+47 87654321", - email: "grace.worker@example.invalid", - gender: 0, - fieldOfStudyId: "outbox-field", - yearOfStudy: 4, - availability: { - mondayUnavailable: false, - tuesdayUnavailable: true, - wednesdayUnavailable: false, - thursdayUnavailable: false, - fridayUnavailable: false, - positionWeeks: 4, - preferredGroup: "all", - language: "Norsk", + const evidence = yield* Effect.scoped( + Effect.gen(function* () { + const database = yield* Database; + yield* outboxReferenceFixture; + yield* executePublicApplicationCommand( + { + commandId: "worker-application-submit", + departmentId: "outbox-department", + firstName: "Grace", + lastName: "Hopper", + phone: "+47 87654321", + email: "grace.worker@example.invalid", + gender: 0, + fieldOfStudyId: "outbox-field", + yearOfStudy: 4, + availability: { + mondayUnavailable: false, + tuesdayUnavailable: true, + wednesdayUnavailable: false, + thursdayUnavailable: false, + fridayUnavailable: false, + positionWeeks: 4, + preferredGroup: "all", + language: "Norsk", + }, }, - }, - { - now: commandClock.now, - applicantId: ApplicantIdSchema.make("worker-applicant"), - applicationId: PublicApplicationIdSchema.make("worker-application"), - activationToken: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq", - }, - ); - yield* database` + { + now: commandClock.now, + applicantId: ApplicantIdSchema.make("worker-applicant"), + applicationId: PublicApplicationIdSchema.make("worker-application"), + activationToken: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq", + }, + ); + yield* database` UPDATE admission_application_outbox SET status = 'Processing', claim_id = 'stale-worker-claim', claimed_at = '2031-09-15T10:00:00.000Z' WHERE command_id = 'worker-application-submit' AND ordinal = 0 `; - const deliveryStarted = yield* Deferred.make(); + const deliveryStarted = yield* Deferred.make(); - const interpreter = { - deliver: ( - request: PublicApplicationOutboxRequest, - ordinal: number, - attempts: number, - ) => - request.commandId === "worker-application-submit" - ? Deferred.succeed(deliveryStarted, undefined).pipe(Effect.andThen(Effect.never)) - : Effect.succeed({ - effectId: request.effectId, - kind: request._tag, - ordinal, - attempts, - status: "Delivered" as const, - }), - }; + const interpreter = { + deliver: ( + request: PublicApplicationOutboxRequest, + ordinal: number, + attempts: number, + ) => + request.commandId === "worker-application-submit" + ? Deferred.succeed(deliveryStarted, undefined).pipe(Effect.andThen(Effect.never)) + : Effect.succeed({ + effectId: request.effectId, + kind: request._tag, + ordinal, + attempts, + status: "Delivered" as const, + }), + }; - const fiber = yield* Effect.forkScoped( - TestClock.setTime(Date.parse(commandClock.fromNow(0, 1))).pipe( - Effect.andThen( - runPublicApplicationOutboxWorker(interpreter, { - workerId: "database-test-worker", - pollIntervalMilliseconds: 5, - staleClaimMilliseconds: 60_000, - onStart: () => { - starts += 1; - }, - onStop: () => { - stops += 1; - }, - }), + const fiber = yield* Effect.forkScoped( + TestClock.setTime(Date.parse(commandClock.fromNow(0, 1))).pipe( + Effect.andThen( + runPublicApplicationOutboxWorker(interpreter, { + workerId: "database-test-worker", + pollIntervalMilliseconds: 5, + staleClaimMilliseconds: 60_000, + onStart: () => { + starts += 1; + }, + onStop: () => { + stops += 1; + }, + }), + ), + Effect.provide(TestClock.layer()), ), - Effect.provide(TestClock.layer()), - ), - ); + ); - yield* Deferred.await(deliveryStarted); + yield* Deferred.await(deliveryStarted); - const processing = yield* database<{ - readonly status: string; - readonly claim_id: string | null; - }>` + const processing = yield* database<{ + readonly status: string; + readonly claim_id: string | null; + }>` SELECT status, claim_id FROM admission_application_outbox WHERE command_id = 'worker-application-submit' AND ordinal = 0 `; - yield* Fiber.interrupt(fiber); + yield* Fiber.interrupt(fiber); - const released = yield* database<{ - readonly status: string; - readonly claim_id: string | null; - readonly last_failure_tag: string | null; - }>` + const released = yield* database<{ + readonly status: string; + readonly claim_id: string | null; + readonly last_failure_tag: string | null; + }>` SELECT status, claim_id, last_failure_tag FROM admission_application_outbox WHERE command_id = 'worker-application-submit' AND ordinal = 0 `; - return { processing: processing[0], released: released[0] }; - }), - ), - ); + return { processing: processing[0], released: released[0] }; + }), + ); - expect(evidence.processing?.status).toBe("Processing"); - expect(evidence.processing?.claim_id).toMatch(/^database-test-worker:/); - expect(evidence.released).toEqual({ - status: "Pending", - claim_id: null, - last_failure_tag: "InterruptedPublicApplicationOutboxClaim", - }); - expect({ starts, stops }).toEqual({ starts: 1, stops: 1 }); - }); -}); + expect(evidence.processing?.status).toBe("Processing"); + expect(evidence.processing?.claim_id).toMatch(/^database-test-worker:/); + expect(evidence.released).toEqual({ + status: "Pending", + claim_id: null, + last_failure_tag: "InterruptedPublicApplicationOutboxClaim", + }); + expect({ starts, stops }).toEqual({ starts: 1, stops: 1 }); + }), + ); + }, +); diff --git a/tools/verification/completion-receipt-postgres-proof-main.ts b/tools/verification/completion-receipt-postgres-proof-main.ts index 1a2df799c..2c3894515 100644 --- a/tools/verification/completion-receipt-postgres-proof-main.ts +++ b/tools/verification/completion-receipt-postgres-proof-main.ts @@ -1,10 +1,8 @@ +import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; import * as BunServices from "@effect/platform-bun/BunServices"; -import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import { Buffer } from "node:buffer"; -import process from "node:process"; import { deliverJson } from "@vektorprogrammet/backend/delivery/http"; import { AdmissionsLive } from "@vektorprogrammet/database/admissions"; -import { Database } from "@vektorprogrammet/database"; import { NotificationGateway } from "@vektorprogrammet/domain/notification"; import { OrganizationLive } from "@vektorprogrammet/database/organization"; import { ProfileLive } from "@vektorprogrammet/database/profile"; @@ -17,9 +15,17 @@ import { deliverNextRecruitmentInterviewCompletion, releaseRecruitmentInterviewCompletion, } from "@vektorprogrammet/database/recruitment"; -import { Schema, Predicate, Effect, Layer, Redacted } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; +import { Config, Console, Data, Effect, Layer, Option, Predicate, Redacted, Schema } from "effect"; +import { + FetchHttpClient, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http"; import { DatabaseLive } from "@vektorprogrammet/database/live"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { indentedJsonText } from "../acceptance/acceptance-process.js"; +import { jsonText } from "../acceptance/journey-step.js"; interface CapturedRequest { readonly idempotencyKey: string; @@ -27,179 +33,166 @@ interface CapturedRequest { readonly status: number; } -const readBody = async (request: IncomingMessage): Promise => { - const chunks: Buffer[] = []; - - for await (const chunk of request) chunks.push(Buffer.from(chunk)); - - return Buffer.concat(chunks).toString("utf8"); -}; +/** A gate of the proof that its observations did not meet. */ +class CompletionReceiptProofFailed extends Data.TaggedError("CompletionReceiptProofFailed")<{ + readonly message: string; +}> {} -const listen = (server: Server): Promise => - new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Json)); - if (address === null || Predicate.isString(address)) { - reject(new Error("loopback receiver did not expose a TCP port")); - - return; - } - - resolve(address.port); - }); - }); - -const close = (server: Server): Promise => - new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - -const main = async (): Promise => { - const pgUrl = process.env.COMPLETION_RECEIPT_PG_URL; +const proof = Effect.gen(function* () { + const pgUrl = Option.getOrUndefined( + yield* Config.option(Config.String("COMPLETION_RECEIPT_PG_URL")), + ); if (pgUrl === undefined || pgUrl.trim() === "") - throw new Error("COMPLETION_RECEIPT_PG_URL is required"); + return yield* new CompletionReceiptProofFailed({ + message: "COMPLETION_RECEIPT_PG_URL is required", + }); const captured: CapturedRequest[] = []; let rejectNext = true; const token = "completion-receipt-0108-loopback-token"; - const server = createServer(async (request: IncomingMessage, response: ServerResponse) => { + const receive = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if ( request.method !== "POST" || request.url !== "/effects" || - request.headers.authorization !== `Bearer ${token}` - ) { - response.statusCode = 404; - response.end(); - - return; - } + request.headers["authorization"] !== `Bearer ${token}` + ) + return HttpServerResponse.empty({ status: 404 }); - const body = Schema.decodeSync(Schema.fromJsonString(Schema.Json))(await readBody(request)); + const body = yield* request.text.pipe(Effect.flatMap(decodeJson)); const status = rejectNext ? 503 : 204; rejectNext = false; captured.push({ - idempotencyKey: Predicate.isString(request.headers["idempotency-key"]) - ? request.headers["idempotency-key"] - : "", + idempotencyKey: request.headers["idempotency-key"] ?? "", body, status, }); - response.statusCode = status; - response.end(); - }); - const port = await listen(server); + return HttpServerResponse.empty({ status }); + }); - try { - const databaseLayer = DatabaseLive({ - url: Redacted.make(pgUrl), - applicationName: "interview-completion-receipt-0108", - maxConnections: 1, - }).pipe(Layer.provide(BunServices.layer)); + yield* HttpServer.serveEffect(receive); - const admissionsLayer = AdmissionsLive.pipe(Layer.provide(databaseLayer)); - const organizationLayer = OrganizationLive.pipe(Layer.provide(databaseLayer)); + const { address } = yield* HttpServer.HttpServer; - const profileLayer = ProfileLive.pipe( - Layer.provide(Layer.merge(databaseLayer, organizationLayer)), - ); + if (Predicate.isTagged(address, "UnixPathAddress")) + return yield* new CompletionReceiptProofFailed({ + message: "loopback receiver did not expose a TCP port", + }); - const authorityLayers = Layer.mergeAll( - databaseLayer, - admissionsLayer, - organizationLayer, - profileLayer, - ); + const port = address.port; - const transport = { - endpoint: new URL(`http://127.0.0.1:${port}/effects`), - token, - deliveryTimeoutMilliseconds: 2_000, - }; + const databaseLayer = DatabaseLive({ + url: Redacted.make(pgUrl), + applicationName: "interview-completion-receipt-0108", + maxConnections: 1, + }); - const gateway = Layer.succeed( - NotificationGateway, - NotificationGateway.of({ - deliverInterviewCompletionReceipt: (request) => - deliverJson(request, transport, { - "idempotency-key": request.effectId, - }).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.map(() => - RecruitmentNotificationEvidenceSchema.make({ - effectId: request.effectId, - deliveredAt: "2026-09-20T12:10:00.000Z", - providerReference: `loopback:${request.effectId}`, - }), - ), - Effect.mapError( - () => - new RecruitmentNotificationDeliveryError({ - effectId: request.effectId, - message: "loopback interview completion receipt delivery unavailable", - }), - ), - ), - deliverInterviewInvitation: (request) => - Effect.fail( - new RecruitmentNotificationDeliveryError({ + const admissionsLayer = AdmissionsLive.pipe(Layer.provide(databaseLayer)); + const organizationLayer = OrganizationLive.pipe(Layer.provide(databaseLayer)); + + const profileLayer = ProfileLive.pipe( + Layer.provide(Layer.merge(databaseLayer, organizationLayer)), + ); + + const authorityLayers = Layer.mergeAll( + databaseLayer, + admissionsLayer, + organizationLayer, + profileLayer, + ); + + const transport = { + endpoint: new URL(`http://127.0.0.1:${port}/effects`), + token, + deliveryTimeoutMilliseconds: 2_000, + }; + + const gateway = Layer.succeed( + NotificationGateway, + NotificationGateway.of({ + deliverInterviewCompletionReceipt: (request) => + deliverJson(request, transport, { + "idempotency-key": request.effectId, + }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.map(() => + RecruitmentNotificationEvidenceSchema.make({ effectId: request.effectId, - message: "invitation delivery is outside completion receipt proof", + deliveredAt: "2026-09-20T12:10:00.000Z", + providerReference: `loopback:${request.effectId}`, }), ), - deliverInterviewInvitationResponse: (request) => - Effect.fail( - new RecruitmentNotificationDeliveryError({ + Effect.mapError(() => + RecruitmentNotificationDeliveryError.make({ effectId: request.effectId, - message: "invitation response delivery is outside completion receipt proof", + message: "loopback interview completion receipt delivery unavailable", }), ), - }), + ), + deliverInterviewInvitation: (request) => + Effect.fail( + RecruitmentNotificationDeliveryError.make({ + effectId: request.effectId, + message: "invitation delivery is outside completion receipt proof", + }), + ), + deliverInterviewInvitationResponse: (request) => + Effect.fail( + RecruitmentNotificationDeliveryError.make({ + effectId: request.effectId, + message: "invitation response delivery is outside completion receipt proof", + }), + ), + }), + ); + + const program = Effect.gen(function* () { + const database = yield* SqlClient.SqlClient; + + const winningClaim = yield* claimNextRecruitmentInterviewCompletion( + "completion-0108-winning-claim", + "2026-09-20T12:07:00.000Z", ); - const program = Effect.gen(function* () { - const database = yield* Database; + if (winningClaim === undefined) + return yield* Effect.die(new Error("completion concurrency proof found no winning claim")); - const winningClaim = yield* claimNextRecruitmentInterviewCompletion( - "completion-0108-winning-claim", - "2026-09-20T12:07:00.000Z", - ); - - if (winningClaim === undefined) - return yield* Effect.die(new Error("completion concurrency proof found no winning claim")); - - const losingClaim = yield* claimNextRecruitmentInterviewCompletion( - "completion-0108-losing-claim", - "2026-09-20T12:07:00.000Z", - ); + const losingClaim = yield* claimNextRecruitmentInterviewCompletion( + "completion-0108-losing-claim", + "2026-09-20T12:07:00.000Z", + ); - yield* releaseRecruitmentInterviewCompletion(winningClaim); + yield* releaseRecruitmentInterviewCompletion(winningClaim); - const first = yield* deliverNextRecruitmentInterviewCompletion( - "completion-0108-failed-claim", - "2026-09-20T12:08:00.000Z", - ); + const first = yield* deliverNextRecruitmentInterviewCompletion( + "completion-0108-failed-claim", + "2026-09-20T12:08:00.000Z", + ); - const [afterFailure] = yield* database<{ - readonly status: string; - readonly attempts: number; - readonly payloadRetained: boolean; - readonly envelope: unknown; - }>` + const [afterFailure] = yield* database<{ + readonly status: string; + readonly attempts: number; + readonly payloadRetained: boolean; + readonly envelope: unknown; + }>` SELECT status, attempts, payload_json <> '{}'::jsonb AS "payloadRetained", delivery_envelope AS envelope FROM public.recruitment_interview_completion_outbox `; - const second = yield* deliverNextRecruitmentInterviewCompletion( - "completion-0108-retry-claim", - "2026-09-20T12:09:00.000Z", - ); + const second = yield* deliverNextRecruitmentInterviewCompletion( + "completion-0108-retry-claim", + "2026-09-20T12:09:00.000Z", + ); - yield* database` + yield* database` INSERT INTO public.recruitment_interview_completion_outbox ( effect_id, effect_type, command_id, interview_id, application_id, interview_revision, payload_json @@ -215,34 +208,34 @@ const main = async (): Promise => { LIMIT 1 `; - const quarantined = yield* deliverNextRecruitmentInterviewCompletion( - "completion-0108-quarantine-claim", - "2026-09-20T12:11:00.000Z", - ); + const quarantined = yield* deliverNextRecruitmentInterviewCompletion( + "completion-0108-quarantine-claim", + "2026-09-20T12:11:00.000Z", + ); - const [afterQuarantine] = yield* database<{ - readonly status: string; - readonly failureTag: string | null; - }>` + const [afterQuarantine] = yield* database<{ + readonly status: string; + readonly failureTag: string | null; + }>` SELECT status, last_failure_tag AS "failureTag" FROM public.recruitment_interview_completion_outbox WHERE effect_id = 'tampered-completion-0108' `; - const idle = yield* deliverNextRecruitmentInterviewCompletion( - "completion-0108-idle-claim", - "2026-09-20T12:12:00.000Z", - ); - - const [afterSuccess] = yield* database<{ - readonly effectId: string; - readonly status: string; - readonly attempts: number; - readonly payloadCleared: boolean; - readonly envelope: unknown; - readonly providerReference: string | null; - readonly deliveredAt: string | null; - }>` + const idle = yield* deliverNextRecruitmentInterviewCompletion( + "completion-0108-idle-claim", + "2026-09-20T12:12:00.000Z", + ); + + const [afterSuccess] = yield* database<{ + readonly effectId: string; + readonly status: string; + readonly attempts: number; + readonly payloadCleared: boolean; + readonly envelope: unknown; + readonly providerReference: string | null; + readonly deliveredAt: string | null; + }>` SELECT effect_id AS "effectId", status, attempts, payload_json = '{}'::jsonb AS "payloadCleared", delivery_envelope AS envelope, provider_reference AS "providerReference", @@ -251,70 +244,79 @@ const main = async (): Promise => { WHERE status = 'Delivered' `; - return { - winningClaim, - losingClaim, - first, - afterFailure, - second, - quarantined, - afterQuarantine, - idle, - afterSuccess, - }; - }).pipe(Effect.provide([gateway, authorityLayers]), Effect.scoped); - - const observed = await Effect.runPromise(program); + return { + winningClaim, + losingClaim, + first, + afterFailure, + second, + quarantined, + afterQuarantine, + idle, + afterSuccess, + }; + }).pipe(Effect.provide([gateway, authorityLayers]), Effect.scoped); - if ( - observed.winningClaim.claimId !== "completion-0108-winning-claim" || - observed.losingClaim !== undefined || - !Predicate.isTagged(observed.first, "Failed") || - observed.afterFailure?.status !== "Failed" || - observed.afterFailure.attempts !== 2 || - observed.afterFailure.payloadRetained !== true || - !Predicate.isTagged(observed.second, "Delivered") || - !Predicate.isTagged(observed.quarantined, "Idle") || - observed.afterQuarantine?.status !== "Quarantined" || - observed.afterQuarantine.failureTag !== "AuthorityEnvelopeMismatch" || - !Predicate.isTagged(observed.idle, "Idle") || - observed.afterSuccess?.status !== "Delivered" || - observed.afterSuccess.attempts !== 3 || - observed.afterSuccess.payloadCleared !== true || - observed.afterSuccess.providerReference !== `loopback:${observed.afterSuccess.effectId}` || - captured.length !== 2 || - captured[0]?.idempotencyKey !== observed.afterSuccess.effectId || - captured[1]?.idempotencyKey !== observed.afterSuccess.effectId || - JSON.stringify(captured[0]?.body) !== JSON.stringify(captured[1]?.body) || - JSON.stringify(observed.afterFailure.envelope) !== - JSON.stringify(observed.afterSuccess.envelope) - ) - throw new Error(`completion receipt proof failed: ${JSON.stringify({ observed, captured })}`); - const body = captured[0]?.body; - - if (body === null || !(body === null || Predicate.isObjectOrArray(body))) - throw new Error("completion body is not an object"); - const keys = Object.keys(body).sort(); - - const expectedKeys = [ - "_tag", - "applicantDisplayName", - "applicantEmail", - "applicationId", - "commandId", - "effectId", - "interviewId", - "interviewRevision", - "interviewerDisplayName", - "interviewerEmail", - ].sort(); - - if (JSON.stringify(keys) !== JSON.stringify(expectedKeys)) - throw new Error(`completion envelope fields changed: ${JSON.stringify(keys)}`); - process.stdout.write(`${JSON.stringify({ result: "Passed", observed, captured }, null, 2)}\n`); - } finally { - await close(server); - } -}; - -await main(); + const observed = yield* program; + + // The texts compare only after the gates before them hold, as the short-circuit of `||` did. + const sameJsonText = (left: A, right: A) => + Effect.all([jsonText(left), jsonText(right)]).pipe( + Effect.map(([leftText, rightText]) => leftText === rightText), + ); + + if ( + observed.winningClaim.claimId !== "completion-0108-winning-claim" || + observed.losingClaim !== undefined || + !Predicate.isTagged(observed.first, "Failed") || + observed.afterFailure?.status !== "Failed" || + observed.afterFailure.attempts !== 2 || + observed.afterFailure.payloadRetained !== true || + !Predicate.isTagged(observed.second, "Delivered") || + !Predicate.isTagged(observed.quarantined, "Idle") || + observed.afterQuarantine?.status !== "Quarantined" || + observed.afterQuarantine.failureTag !== "AuthorityEnvelopeMismatch" || + !Predicate.isTagged(observed.idle, "Idle") || + observed.afterSuccess?.status !== "Delivered" || + observed.afterSuccess.attempts !== 3 || + observed.afterSuccess.payloadCleared !== true || + observed.afterSuccess.providerReference !== `loopback:${observed.afterSuccess.effectId}` || + captured.length !== 2 || + captured[0]?.idempotencyKey !== observed.afterSuccess.effectId || + captured[1]?.idempotencyKey !== observed.afterSuccess.effectId || + !(yield* sameJsonText(captured[0]?.body, captured[1]?.body)) || + !(yield* sameJsonText(observed.afterFailure.envelope, observed.afterSuccess.envelope)) + ) + return yield* new CompletionReceiptProofFailed({ + message: `completion receipt proof failed: ${yield* jsonText({ observed, captured })}`, + }); + const body = captured[0]?.body; + + if (body === null || !(body === null || Predicate.isObjectOrArray(body))) + return yield* new CompletionReceiptProofFailed({ + message: "completion body is not an object", + }); + const keys = Object.keys(body).sort(); + + const expectedKeys = [ + "_tag", + "applicantDisplayName", + "applicantEmail", + "applicationId", + "commandId", + "effectId", + "interviewId", + "interviewRevision", + "interviewerDisplayName", + "interviewerEmail", + ].sort(); + + if (!(yield* sameJsonText(keys, expectedKeys))) + return yield* new CompletionReceiptProofFailed({ + message: `completion envelope fields changed: ${yield* jsonText(keys)}`, + }); + + yield* Console.log(yield* indentedJsonText({ result: "Passed", observed, captured })); +}).pipe(Effect.scoped, Effect.provide(BunHttpServer.layer({ port: 0, hostname: "127.0.0.1" }))); + +BunRuntime.runMain(proof.pipe(Effect.provide(BunServices.layer))); diff --git a/tools/verification/controlled-test-runtime.test.ts b/tools/verification/controlled-test-runtime.test.ts index 4b8e6ea8c..e07283cc2 100644 --- a/tools/verification/controlled-test-runtime.test.ts +++ b/tools/verification/controlled-test-runtime.test.ts @@ -1,34 +1,42 @@ +import { describe, expect, it } from "@effect/vitest"; import { Context, Effect, Layer } from "effect"; -import { describe, expect, it } from "vitest"; import { makeControlledTestRuntime } from "./controlled-test-runtime.js"; class RuntimeProbe extends Context.Service()( - "@monoweb/verification/RuntimeProbe", + "@monoweb/verification/controlled-test-runtime.test/RuntimeProbe", ) {} describe("controlled test runtime lifecycle", () => { - it("releases its layer once and rejects execution after disposal", async () => { - let releases = 0; - - const layer = Layer.effect( - RuntimeProbe, - Effect.acquireRelease(Effect.succeed({ value: "ready" }), () => - Effect.sync(() => void (releases += 1)), - ), - ); - - const runtime = makeControlledTestRuntime(layer); - - await expect( - runtime.runPromise(RuntimeProbe.use(({ value }) => Effect.succeed(value))), - ).resolves.toBe("ready"); - - await runtime.dispose(); - await runtime.dispose(); - - expect(releases).toBe(1); - await expect( - runtime.runPromise(RuntimeProbe.use(({ value }) => Effect.succeed(value))), - ).rejects.toThrow("controlled test runtime is already disposed"); - }); + it.live("releases its layer once and rejects execution after disposal", () => + Effect.gen(function* () { + let releases = 0; + + const layer = Layer.effect( + RuntimeProbe, + Effect.acquireRelease(Effect.succeed({ value: "ready" }), () => + Effect.sync(() => void (releases += 1)), + ), + ); + + const runtime = makeControlledTestRuntime(layer); + const probe = RuntimeProbe.use(({ value }) => Effect.succeed(value)); + + expect(yield* Effect.promise(() => runtime.runPromise(probe))).toBe("ready"); + + yield* Effect.promise(() => runtime.dispose()); + yield* Effect.promise(() => runtime.dispose()); + + expect(releases).toBe(1); + + const afterDisposal = yield* Effect.flip( + Effect.tryPromise({ + try: () => runtime.runPromise(probe), + catch: (error) => + error instanceof Error ? error.message : "a rejection without an Error", + }), + ); + + expect(afterDisposal).toBe("controlled test runtime is already disposed"); + }), + ); }); diff --git a/tools/verification/controlled-test-runtime.ts b/tools/verification/controlled-test-runtime.ts index 641fd0d8b..215eb5b1c 100644 --- a/tools/verification/controlled-test-runtime.ts +++ b/tools/verification/controlled-test-runtime.ts @@ -22,10 +22,11 @@ export const makeControlledTestRuntime = ( return runtime.runPromise(effect, options); }, - dispose: async () => { - if (disposed) return; + dispose: () => { + if (disposed) return Promise.resolve(); disposed = true; - await runtime.dispose(); + + return runtime.dispose(); }, }; }; diff --git a/tools/verification/current-assignment-cohort-cli.test.ts b/tools/verification/current-assignment-cohort-cli.test.ts index 20291ad39..09eb576e8 100644 --- a/tools/verification/current-assignment-cohort-cli.test.ts +++ b/tools/verification/current-assignment-cohort-cli.test.ts @@ -1,45 +1,49 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect, Layer } from "effect"; +import { CurrentAssignmentFailure } from "@vektorprogrammet/database/placements"; +import { TestPlatform } from "@vektorprogrammet/database/test-support/platform"; import { disposableCurrentAssignmentDatabaseUrl, runCurrentAssignmentCohortCli, } from "./current-assignment-cohort-cli.js"; describe("synthetic assignment CLI custody", () => { - it("rejects ambient provider configuration before opening the synthetic CLI boundary", async () => { - for (const [key, value] of [ - ["PUBLIC_APPLICATION_EFFECT_MODE", "disabled"], - ["PUBLIC_APPLICATION_EFFECT_TOKEN", "synthetic-provider-configuration"], - ["RECEIPT_DELIVERY_URL", "https://provider.example.invalid/delivery"], - ["RECEIPT_DELIVERY_TOKEN", "synthetic-provider-configuration"], - ] as const) { - const previous = { - mode: process.env.CURRENT_ASSIGNMENT_MODE, - deployment: process.env.NATIVE_IDENTITY_DEPLOYMENT, - provider: process.env[key], - argv: process.argv, - }; - - process.env.CURRENT_ASSIGNMENT_MODE = "synthetic"; - process.env.NATIVE_IDENTITY_DEPLOYMENT = "local"; - process.env[key] = value; - process.argv = process.argv.slice(0, 2); - - try { - await expect(runCurrentAssignmentCohortCli()).rejects.toThrow("InvalidSnapshot"); - } finally { - process.argv = previous.argv; - - for (const [environmentKey, previousValue] of [ - ["CURRENT_ASSIGNMENT_MODE", previous.mode], - ["NATIVE_IDENTITY_DEPLOYMENT", previous.deployment], - [key, previous.provider], + it.effect( + "rejects ambient provider configuration before opening the synthetic CLI boundary", + () => + Effect.gen(function* () { + for (const [key, value] of [ + ["PUBLIC_APPLICATION_EFFECT_MODE", "disabled"], + ["PUBLIC_APPLICATION_EFFECT_TOKEN", "synthetic-provider-configuration"], + ["RECEIPT_DELIVERY_URL", "https://provider.example.invalid/delivery"], + ["RECEIPT_DELIVERY_TOKEN", "synthetic-provider-configuration"], + ["RECEIPT_DELIVERY_TOKEN", ""], ] as const) { - if (previousValue === undefined) delete process.env[environmentKey]; - else process.env[environmentKey] = previousValue; + const failure = yield* Effect.flip( + runCurrentAssignmentCohortCli(["bun", "current-assignment-cohort-main.ts"]).pipe( + Effect.provide( + Layer.merge( + TestPlatform, + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + CURRENT_ASSIGNMENT_MODE: "synthetic", + NATIVE_IDENTITY_DEPLOYMENT: "local", + [key]: value, + }, + preserveEmptyStrings: true, + }), + ), + ), + ), + ), + ); + + expect(failure).toBeInstanceOf(CurrentAssignmentFailure); + expect(failure).toHaveProperty("code", "InvalidSnapshot"); } - } - } - }); + }), + ); it("requires numeric loopback and the disposable current assignment namespace", () => { expect( diff --git a/tools/verification/current-assignment-cohort-cli.ts b/tools/verification/current-assignment-cohort-cli.ts index 79874f29b..8b7507214 100644 --- a/tools/verification/current-assignment-cohort-cli.ts +++ b/tools/verification/current-assignment-cohort-cli.ts @@ -1,6 +1,4 @@ -import process from "node:process"; -import * as BunServices from "@effect/platform-bun/BunServices"; -import { Effect, Layer, Redacted } from "effect"; +import { Config, Console, Effect, Option, Redacted, Schema } from "effect"; import { Pool } from "pg"; import { parseDisposableCohortDatabaseUrl, @@ -46,51 +44,77 @@ export const currentAssignmentForbiddenAmbientConfigurationKeys = [ "GATEWAY_API_TOKEN", ] as const; -export const rejectCurrentAssignmentAmbientConfiguration = ( - environment: Readonly>, - invalid: () => Error, -): void => { - if ( - currentAssignmentForbiddenAmbientConfigurationKeys.some((key) => environment[key] !== undefined) - ) - throw invalid(); -}; - export const disposableCurrentAssignmentDatabaseUrl = (value: string | undefined): string => parseDisposableCohortDatabaseUrl(value, /^\/current_assignment_rehearsal$/, invalidSnapshot); -export const runCurrentAssignmentCohortCli = async (): Promise => { +// A variable that the environment may set; an unreadable source counts as an invalid snapshot. +const optionalVariable = (key: string) => + Config.option(Config.String(key)).pipe(Effect.mapError(invalidSnapshot)); + +/** A failure of the CLI that carries no current assignment failure code. */ +export class CurrentAssignmentImportFailed extends Schema.TaggedError()( + "CurrentAssignmentImportFailed", + { cause: Schema.Defect() }, +) {} + +// A synchronous step of the boundary keeps its coded failure; anything else is an import failure. +const boundaryStep = (run: () => A) => + Effect.try({ + try: run, + catch: (cause) => + cause instanceof CurrentAssignmentFailure + ? cause + : CurrentAssignmentImportFailed.make({ cause }), + }); + +/** + * The guarded synthetic import: it takes the process arguments, reads its configuration from the + * environment, and writes the persisted report to standard output. + */ +export const runCurrentAssignmentCohortCli = Effect.fn("runCurrentAssignmentCohortCli")(function* ( + argv: ReadonlyArray, +) { + const mode = yield* optionalVariable("CURRENT_ASSIGNMENT_MODE"); + const deployment = yield* optionalVariable("NATIVE_IDENTITY_DEPLOYMENT"); + if ( - process.argv.length !== 2 || - process.env.CURRENT_ASSIGNMENT_MODE !== "synthetic" || - process.env.NATIVE_IDENTITY_DEPLOYMENT !== "local" + argv.length !== 2 || + !Option.contains(mode, "synthetic") || + !Option.contains(deployment, "local") ) - throw invalidSnapshot(); - rejectCurrentAssignmentAmbientConfiguration(process.env, invalidSnapshot); + return yield* invalidSnapshot(); + + const ambient = yield* Effect.forEach( + currentAssignmentForbiddenAmbientConfigurationKeys, + optionalVariable, + ); - const input = decodeCurrentAssignmentSnapshot( - await Effect.runPromise( - readPrivateCohortJson(process.env.CURRENT_ASSIGNMENT_INPUT, invalidSnapshot), - ), + if (ambient.some(Option.isSome)) return yield* invalidSnapshot(); + + const inputPath = yield* optionalVariable("CURRENT_ASSIGNMENT_INPUT"); + + const json = yield* readPrivateCohortJson(Option.getOrUndefined(inputPath), invalidSnapshot); + const input = yield* boundaryStep(() => decodeCurrentAssignmentSnapshot(json)); + + const databaseUrl = yield* optionalVariable("CURRENT_ASSIGNMENT_PG_URL"); + + const url = yield* boundaryStep(() => + disposableCurrentAssignmentDatabaseUrl(Option.getOrUndefined(databaseUrl)), ); - const url = disposableCurrentAssignmentDatabaseUrl(process.env.CURRENT_ASSIGNMENT_PG_URL); - await Effect.runPromise( - databaseHealth.pipe( - Effect.provide( - DatabaseLive({ url: Redacted.make(url), maxConnections: 1 }).pipe( - Layer.provide(BunServices.layer), - ), - ), - ), + yield* databaseHealth.pipe( + Effect.provide(DatabaseLive({ url: Redacted.make(url), maxConnections: 1 })), ); - const pool = new Pool({ connectionString: url, max: 2 }); - - try { - process.stdout.write( - JSON.stringify(await Effect.runPromise(importCurrentAssignmentCohort(pool, input))) + "\n", - ); - } finally { - await pool.end(); - } -}; + + const report = yield* Effect.acquireUseRelease( + Effect.sync(() => new Pool({ connectionString: url, max: 2 })), + (pool) => importCurrentAssignmentCohort(pool, input), + (pool) => Effect.promise(() => pool.end()), + ); + + const reportText = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(report).pipe( + Effect.orDie, + ); + + yield* Console.log(reportText); +}); diff --git a/tools/verification/current-assignment-cohort-main.ts b/tools/verification/current-assignment-cohort-main.ts index 99dd87843..6719375ec 100644 --- a/tools/verification/current-assignment-cohort-main.ts +++ b/tools/verification/current-assignment-cohort-main.ts @@ -1,14 +1,23 @@ -import { runCurrentAssignmentCohortCli } from "./current-assignment-cohort-cli.js"; +import process from "node:process"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import { Cause, ConfigProvider, Console, Effect, Layer } from "effect"; import { CurrentAssignmentFailure } from "@vektorprogrammet/database/placements"; +import { runCurrentAssignmentCohortCli } from "./current-assignment-cohort-cli.js"; + +const failureCode = (failure: ReturnType) => + failure instanceof CurrentAssignmentFailure ? failure.code : "CurrentAssignmentImportFailed"; + +// An empty variable counts as set, so an empty provider variable is still refused. +const environment = ConfigProvider.layer(ConfigProvider.fromEnv({ preserveEmptyStrings: true })); -try { - await runCurrentAssignmentCohortCli(); -} catch (cause) { - process.stderr.write( - JSON.stringify({ - error: - cause instanceof CurrentAssignmentFailure ? cause.code : "CurrentAssignmentImportFailed", - }) + "\n", - ); - process.exitCode = 1; -} +// A failure writes one JSON line with its code to standard error, and the runtime exits 1. +BunRuntime.runMain( + runCurrentAssignmentCohortCli(process.argv).pipe( + Effect.tapCause((cause) => + Console.error(JSON.stringify({ error: failureCode(Cause.squash(cause)) })), + ), + Effect.provide(Layer.merge(BunServices.layer, environment)), + ), + { disableErrorReporting: true }, +); diff --git a/tools/verification/current-assignment-cohort-rehearsal.ts b/tools/verification/current-assignment-cohort-rehearsal.ts index ad8df8a81..7cb69779e 100644 --- a/tools/verification/current-assignment-cohort-rehearsal.ts +++ b/tools/verification/current-assignment-cohort-rehearsal.ts @@ -1,19 +1,25 @@ /** Owned synthetic PostgreSQL current assistant assignment reconciliation journey. */ +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; import * as BunServices from "@effect/platform-bun/BunServices"; import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; import process from "node:process"; -import { Schema, flow, Match, Effect, Layer, Redacted } from "effect"; -import { Pool } from "pg"; import { - type DisposablePostgres, - postgresProgram, - startDisposablePostgres, -} from "@monoweb/postgres"; + Cause, + Config, + Console, + Effect, + Exit, + FileSystem, + Match, + Option, + Path, + Redacted, + Schema, + flow, +} from "effect"; +import { Pool, type QueryResultRow } from "pg"; +import { postgresProgram, startDisposablePostgres } from "@monoweb/postgres"; import { CurrentAssignmentSnapshot } from "@vektorprogrammet/domain/placements"; import { databaseHealth } from "@vektorprogrammet/database"; import { @@ -24,34 +30,18 @@ import { import { CurrentAssignmentFailure, currentAssignmentPlacementId, - importCurrentAssignmentCohort as importCurrentAssignmentCohortEffect, + importCurrentAssignmentCohort, } from "@vektorprogrammet/database/placements"; import { currentAssignmentForbiddenAmbientConfigurationKeys } from "./current-assignment-cohort-cli.js"; +import { commandOutput, indentedJsonText } from "../acceptance/acceptance-process.js"; +import { jsonText, step, surfaceStepFailure } from "../acceptance/journey-step.js"; import { journeyClock } from "../e2e/journey-clock.js"; import { DatabaseLive } from "@vektorprogrammet/database/live"; -import { importPersonCohort as importPersonCohortEffect } from "@vektorprogrammet/database/person-cohort"; - -const importPersonCohort = flow(importPersonCohortEffect, Effect.runPromise); - -const importCurrentAssignmentCohort = flow(importCurrentAssignmentCohortEffect, Effect.runPromise); - -const root = resolve(import.meta.dirname, "../.."); +import { importPersonCohort } from "@vektorprogrammet/database/person-cohort"; // The synthetic autumn 2026 semester, 1 August through 31 December, as offsets from its start. const autumnSemester = journeyClock("2026-08-01T00:00:00Z"); -const command = (name: string, args: ReadonlyArray) => - execFileSync(name, args, { - cwd: root, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: 60_000, - }); - -const sourceRevision = command("git", ["rev-parse", "HEAD"]).trim(); - -assert.equal(command("git", ["status", "--porcelain=v1"]).trim(), "", "worktree must be clean"); - const digest = flow(Schema.decodeUnknownSync(Schema.Json), canonicalJsonBytes, sha256Hex); const snapshotWithDigest = < @@ -66,40 +56,85 @@ const snapshotWithDigest = < return { ...unsignedSnapshot, snapshotDigest: digest(unsignedSnapshot) }; }; -for (const key of [ - "CURRENT_ASSIGNMENT_PG_URL", - "CURRENT_ASSIGNMENT_INPUT", - ...currentAssignmentForbiddenAmbientConfigurationKeys, -]) - assert.equal(process.env[key], undefined, `${key} ambient configuration prohibited`); +/** One `pg` query of the rehearsal as a step; a rejection fails the step with the original error. */ +// oxlint-disable-next-line typescript/no-explicit-any -- pg types an untyped row as any, as the calls did +const query = ( + target: Pool, + text: string, + values?: ReadonlyArray, +) => step(() => target.query(text, values === undefined ? undefined : [...values])); + +/** Asserts that the import fails with a `CurrentAssignmentFailure` of `code`, as `assert.rejects` did. */ +const rejectsWithCode = (effect: Effect.Effect, code: string) => + Effect.exit(effect).pipe( + Effect.map((exit) => { + assert.ok(Exit.isFailure(exit), "Missing expected rejection."); + + const cause = Cause.squash(exit.cause); + + assert.ok( + cause instanceof CurrentAssignmentFailure && cause.code === code, + 'The validation function is expected to return "true". Received false', + ); + }), + ); -const artifacts = await mkdtemp(join(tmpdir(), "vektor-current-assignment-0109-")); +const rehearsal = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.resolve(import.meta.dirname, "../.."); -const inputFile = join(artifacts, "current-assignment.json"); + const command = (name: string, args: ReadonlyArray) => + commandOutput({ command: name, args, cwd: root, deadline: "60 seconds", stderr: "pipe" }); -const backup = join(artifacts, "current-assignment.dump"); + const sourceRevision = (yield* command("git", ["rev-parse", "HEAD"])).trim(); -let postgres: DisposablePostgres | undefined; + assert.equal( + (yield* command("git", ["status", "--porcelain=v1"])).trim(), + "", + "worktree must be clean", + ); -let pool: Pool | undefined; + for (const key of [ + "CURRENT_ASSIGNMENT_PG_URL", + "CURRENT_ASSIGNMENT_INPUT", + ...currentAssignmentForbiddenAmbientConfigurationKeys, + ]) + assert.equal( + Option.getOrUndefined(yield* Config.option(Config.String(key))), + undefined, + `${key} ambient configuration prohibited`, + ); -let evidence: Record | undefined; + return yield* Effect.scoped( + Effect.gen(function* () { + const artifacts = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "vektor-current-assignment-0109-", + }); -try { - postgres = await startDisposablePostgres({ database: "current_assignment_rehearsal" }); + const inputFile = path.join(artifacts, "current-assignment.json"); - const databaseUrl = postgres.url; - pool = new Pool({ connectionString: databaseUrl, max: 4 }); - await Effect.runPromise( - databaseHealth.pipe( - Effect.provide( - DatabaseLive({ url: Redacted.make(databaseUrl), maxConnections: 1 }).pipe( - Layer.provide(BunServices.layer), - ), - ), - ), - ); - await pool.query(` + const backup = path.join(artifacts, "current-assignment.dump"); + + const postgres = yield* Effect.acquireRelease( + step(() => startDisposablePostgres({ database: "current_assignment_rehearsal" })), + (owned) => Effect.promise(() => owned.stop().catch(() => undefined)), + ); + + const databaseUrl = postgres.url; + + const pool = yield* Effect.acquireRelease( + Effect.sync(() => new Pool({ connectionString: databaseUrl, max: 4 })), + (owned) => Effect.promise(() => owned.end().catch(() => undefined)), + ); + + yield* databaseHealth.pipe( + Effect.provide(DatabaseLive({ url: Redacted.make(databaseUrl), maxConnections: 1 })), + ); + + yield* query( + pool, + ` INSERT INTO public.organization_departments (department_id, name, short_name, email, city) VALUES @@ -118,225 +153,236 @@ try { VALUES ('person-missing-evidence', 'Unreconciled', 'Volunteer'); INSERT INTO public.person_contact_profiles (person_id, email, phone) VALUES ('person-missing-evidence', 'missing-evidence@example.invalid', '+47 900 20 001') - `); - - const schools = ( - await pool.query<{ school_id: string; name: string }>( - `SELECT school_id::text, name FROM public.schools_directory_schools ORDER BY school_id`, - ) - ).rows; - - const schoolA = Number(schools[0]!.school_id); - const schoolB = Number(schools[1]!.school_id); - - const reconciledSources = [ - "valid", - "inactive", - "missing-reference", - "school-mismatch", - "duplicate-source", - "duplicate-target", - "source-mismatch", - "unowned", - "concurrent", - "rollback", - "colon-left", - "colon-right", - ]; - - const personReport = await importPersonCohort(pool, { - sourceRepository: "synthetic-legacy", - sourceRevision: "synthetic-person-source-0109", - snapshotId: "person-cohort-0109", - transformationRevision: "0109-v1", - sourceKind: "Synthetic", - occurrences: reconciledSources.map((sourceUserId) => ({ - occurrenceId: `person-${sourceUserId}`, - row: { - sourceUserId, - active: true, - firstName: "Synthetic", - lastName: sourceUserId, - email: `${sourceUserId}@example.invalid`, - phone: "+47 999 00 000", - }, - })), - mappings: reconciledSources.map((sourceUserId) => ({ - _tag: "CreatePerson" as const, - sourceUserId, - personId: Match.value(sourceUserId).pipe( - Match.when("colon-left", () => `person:${schoolB}`), - Match.when("colon-right", () => "person" as const), - Match.orElse(() => `person-${sourceUserId}`), - ), - emailOwnership: { - email: `${sourceUserId}@example.invalid`, - attestedBy: "synthetic-operator", - evidenceRef: `person-${sourceUserId}`, - }, - })), - }); - - assert.equal(personReport.accepted, reconciledSources.length); - await pool.query(` + `, + ); + + const schools = (yield* query<{ school_id: string; name: string }>( + pool, + `SELECT school_id::text, name FROM public.schools_directory_schools ORDER BY school_id`, + )).rows; + + const schoolA = Number(schools[0]!.school_id); + const schoolB = Number(schools[1]!.school_id); + + const reconciledSources = [ + "valid", + "inactive", + "missing-reference", + "school-mismatch", + "duplicate-source", + "duplicate-target", + "source-mismatch", + "unowned", + "concurrent", + "rollback", + "colon-left", + "colon-right", + ]; + + const personReport = yield* importPersonCohort(pool, { + sourceRepository: "synthetic-legacy", + sourceRevision: "synthetic-person-source-0109", + snapshotId: "person-cohort-0109", + transformationRevision: "0109-v1", + sourceKind: "Synthetic", + occurrences: reconciledSources.map((sourceUserId) => ({ + occurrenceId: `person-${sourceUserId}`, + row: { + sourceUserId, + active: true, + firstName: "Synthetic", + lastName: sourceUserId, + email: `${sourceUserId}@example.invalid`, + phone: "+47 999 00 000", + }, + })), + mappings: reconciledSources.map((sourceUserId) => ({ + _tag: "CreatePerson" as const, + sourceUserId, + personId: Match.value(sourceUserId).pipe( + Match.when("colon-left", () => `person:${schoolB}`), + Match.when("colon-right", () => "person" as const), + Match.orElse(() => `person-${sourceUserId}`), + ), + emailOwnership: { + email: `${sourceUserId}@example.invalid`, + attestedBy: "synthetic-operator", + evidenceRef: `person-${sourceUserId}`, + }, + })), + }); + + assert.equal(personReport.accepted, reconciledSources.length); + yield* query( + pool, + ` INSERT INTO public.organization_volunteer_affiliations (person_id, department_id, status, revision) VALUES ('person-unowned', 'department-a', 'Active', 1) - `); - - type SourceRow = { - sourceAssignmentId: string; - sourceRowDigest: string; - sourceUserId: string; - sourceDepartmentId: string; - sourceSemesterId: string; - sourceSchoolId: string; - affiliationEvidenceRef: string; - placementEvidenceRef: string; - day: "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday"; - workdays: number; - block: "1" | "2" | "Both"; - active: boolean; - }; - - type Mapping = { - sourceAssignmentId: string; - sourceUserId: string; - sourceDepartmentId: string; - sourceSemesterId: string; - sourceSchoolId: string; - personId: string; - departmentId: string; - semesterId: string; - schoolId: number; - }; - - const sourceRow = ( - sourceAssignmentId: string, - sourceUserId: string, - overrides: Partial> = {}, - ): SourceRow => { - const row = { - sourceAssignmentId, - sourceUserId, - sourceDepartmentId: "legacy-department-a", - sourceSemesterId: "legacy-semester-2026-autumn", - sourceSchoolId: "legacy-school-a", - affiliationEvidenceRef: `affiliation-${sourceAssignmentId}`, - placementEvidenceRef: `placement-${sourceAssignmentId}`, - day: "Monday" as const, - workdays: 4, - block: "1" as const, - active: true, - ...overrides, - }; - - return { ...row, sourceRowDigest: digest(row) }; - }; - - const mapping = ( - row: SourceRow, - personId: string, - overrides: Partial = {}, - ): Mapping => ({ - sourceAssignmentId: row.sourceAssignmentId, - sourceUserId: row.sourceUserId, - sourceDepartmentId: row.sourceDepartmentId, - sourceSemesterId: row.sourceSemesterId, - sourceSchoolId: row.sourceSchoolId, - personId, - departmentId: "department-a", - semesterId: "semester-2026-autumn", - schoolId: schoolA, - ...overrides, - }); - - const occurrences: Array<{ occurrenceId: string; row: unknown }> = []; - const mappings: Mapping[] = []; - - const add = ( - sourceAssignmentId: string, - sourceUserId: string, - personId: string, - rowOverrides: Partial> = {}, - mappingOverrides: Partial = {}, - ) => { - const row = sourceRow(sourceAssignmentId, sourceUserId, rowOverrides); - occurrences.push({ occurrenceId: `occ-${sourceAssignmentId}`, row }); - mappings.push(mapping(row, personId, mappingOverrides)); - - return row; - }; - - add("assignment-one", "valid", "person-valid"); - add( - "assignment-two", - "valid", - "person-valid", - { block: "2", day: "Friday", workdays: 8, sourceSchoolId: "legacy-school-b" }, - { schoolId: schoolB }, - ); - occurrences.push({ occurrenceId: "occ-invalid", row: { sourceAssignmentId: "invalid" } }); - add("inactive", "inactive", "person-inactive", { active: false }); - occurrences.push({ occurrenceId: "occ-no-mapping", row: sourceRow("no-mapping", "valid") }); - const ambiguous = add("ambiguous", "valid", "person-valid", { block: "Both" }); - mappings.push(mapping(ambiguous, "person-valid")); - add("missing-person-evidence", "missing-evidence", "person-missing-evidence", { block: "Both" }); - add( - "missing-reference", - "missing-reference", - "person-missing-reference", - { block: "Both" }, - { departmentId: "department-missing" }, - ); - add( - "school-mismatch", - "school-mismatch", - "person-school-mismatch", - { block: "Both" }, - { departmentId: "department-b" }, - ); + `, + ); - const duplicateSource = add("duplicate-source", "duplicate-source", "person-duplicate-source", { - block: "Both", - }); - - occurrences.push({ occurrenceId: "occ-duplicate-source-second", row: { ...duplicateSource } }); - add("duplicate-target-a", "duplicate-target", "person-duplicate-target", { block: "Both" }); - add("duplicate-target-b", "duplicate-target", "person-duplicate-target", { block: "Both" }); - const sourceMismatch = sourceRow("source-mismatch", "source-mismatch", { block: "Both" }); - occurrences.push({ occurrenceId: "occ-source-mismatch", row: sourceMismatch }); - mappings.push( - mapping(sourceMismatch, "person-source-mismatch", { sourceSchoolId: "wrong-school" }), - ); - add("unowned", "unowned", "person-unowned", { block: "Both" }, { schoolId: schoolB }); - - const snapshot = snapshotWithDigest({ - sourceRepository: "synthetic-legacy", - sourceRevision: "synthetic-current-source-0109", - snapshotId: "current-assignment-0109", - sourceWatermark: "synthetic-watermark-0109", - transformationRevision: "0109-v1", - synthetic: true, - occurrences, - mappings, - }); - - const importSnapshot = ( - input: Omit & { - readonly snapshotDigest?: string; - }, - ) => importCurrentAssignmentCohort(pool!, snapshotWithDigest(input)); - - await writeFile(inputFile, JSON.stringify(snapshot), { mode: 0o600 }); - await chmod(inputFile, 0o600); - - const excludedAuthorityFacts = async ( - databasePool: Pool, - ): Promise> => { - const tables = ( - await databasePool.query<{ table_name: string }>( - `SELECT format('%I.%I', schemaname, tablename) AS table_name + type SourceRow = { + sourceAssignmentId: string; + sourceRowDigest: string; + sourceUserId: string; + sourceDepartmentId: string; + sourceSemesterId: string; + sourceSchoolId: string; + affiliationEvidenceRef: string; + placementEvidenceRef: string; + day: "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday"; + workdays: number; + block: "1" | "2" | "Both"; + active: boolean; + }; + + type Mapping = { + sourceAssignmentId: string; + sourceUserId: string; + sourceDepartmentId: string; + sourceSemesterId: string; + sourceSchoolId: string; + personId: string; + departmentId: string; + semesterId: string; + schoolId: number; + }; + + const sourceRow = ( + sourceAssignmentId: string, + sourceUserId: string, + overrides: Partial> = {}, + ): SourceRow => { + const row = { + sourceAssignmentId, + sourceUserId, + sourceDepartmentId: "legacy-department-a", + sourceSemesterId: "legacy-semester-2026-autumn", + sourceSchoolId: "legacy-school-a", + affiliationEvidenceRef: `affiliation-${sourceAssignmentId}`, + placementEvidenceRef: `placement-${sourceAssignmentId}`, + day: "Monday" as const, + workdays: 4, + block: "1" as const, + active: true, + ...overrides, + }; + + return { ...row, sourceRowDigest: digest(row) }; + }; + + const mapping = ( + row: SourceRow, + personId: string, + overrides: Partial = {}, + ): Mapping => ({ + sourceAssignmentId: row.sourceAssignmentId, + sourceUserId: row.sourceUserId, + sourceDepartmentId: row.sourceDepartmentId, + sourceSemesterId: row.sourceSemesterId, + sourceSchoolId: row.sourceSchoolId, + personId, + departmentId: "department-a", + semesterId: "semester-2026-autumn", + schoolId: schoolA, + ...overrides, + }); + + const occurrences: Array<{ occurrenceId: string; row: unknown }> = []; + const mappings: Mapping[] = []; + + const add = ( + sourceAssignmentId: string, + sourceUserId: string, + personId: string, + rowOverrides: Partial> = {}, + mappingOverrides: Partial = {}, + ) => { + const row = sourceRow(sourceAssignmentId, sourceUserId, rowOverrides); + occurrences.push({ occurrenceId: `occ-${sourceAssignmentId}`, row }); + mappings.push(mapping(row, personId, mappingOverrides)); + + return row; + }; + + add("assignment-one", "valid", "person-valid"); + add( + "assignment-two", + "valid", + "person-valid", + { block: "2", day: "Friday", workdays: 8, sourceSchoolId: "legacy-school-b" }, + { schoolId: schoolB }, + ); + occurrences.push({ occurrenceId: "occ-invalid", row: { sourceAssignmentId: "invalid" } }); + add("inactive", "inactive", "person-inactive", { active: false }); + occurrences.push({ occurrenceId: "occ-no-mapping", row: sourceRow("no-mapping", "valid") }); + const ambiguous = add("ambiguous", "valid", "person-valid", { block: "Both" }); + mappings.push(mapping(ambiguous, "person-valid")); + add("missing-person-evidence", "missing-evidence", "person-missing-evidence", { + block: "Both", + }); + add( + "missing-reference", + "missing-reference", + "person-missing-reference", + { block: "Both" }, + { departmentId: "department-missing" }, + ); + add( + "school-mismatch", + "school-mismatch", + "person-school-mismatch", + { block: "Both" }, + { departmentId: "department-b" }, + ); + + const duplicateSource = add( + "duplicate-source", + "duplicate-source", + "person-duplicate-source", + { + block: "Both", + }, + ); + + occurrences.push({ + occurrenceId: "occ-duplicate-source-second", + row: { ...duplicateSource }, + }); + add("duplicate-target-a", "duplicate-target", "person-duplicate-target", { block: "Both" }); + add("duplicate-target-b", "duplicate-target", "person-duplicate-target", { block: "Both" }); + const sourceMismatch = sourceRow("source-mismatch", "source-mismatch", { block: "Both" }); + occurrences.push({ occurrenceId: "occ-source-mismatch", row: sourceMismatch }); + mappings.push( + mapping(sourceMismatch, "person-source-mismatch", { sourceSchoolId: "wrong-school" }), + ); + add("unowned", "unowned", "person-unowned", { block: "Both" }, { schoolId: schoolB }); + + const snapshot = snapshotWithDigest({ + sourceRepository: "synthetic-legacy", + sourceRevision: "synthetic-current-source-0109", + snapshotId: "current-assignment-0109", + sourceWatermark: "synthetic-watermark-0109", + transformationRevision: "0109-v1", + synthetic: true, + occurrences, + mappings, + }); + + const importSnapshot = ( + input: Omit & { + readonly snapshotDigest?: string; + }, + ) => importCurrentAssignmentCohort(pool, snapshotWithDigest(input)); + + yield* fileSystem.writeFileString(inputFile, yield* jsonText(snapshot), { mode: 0o600 }); + yield* fileSystem.chmod(inputFile, 0o600); + + const excludedAuthorityFacts = Effect.fnUntraced(function* (databasePool: Pool) { + const tables = (yield* query<{ table_name: string }>( + databasePool, + `SELECT format('%I.%I', schemaname, tablename) AS table_name FROM pg_tables WHERE schemaname IN ('public', 'auth') AND NOT ( @@ -351,27 +397,27 @@ try { ) ) ORDER BY schemaname, tablename`, - ) - ).rows; + )).rows; - const allFacts: Record = {}; + const allFacts: Record = {}; - for (const { table_name: tableName } of tables) { - const rows = await databasePool.query<{ rows: unknown }>( - `SELECT COALESCE(jsonb_agg(to_jsonb(value) ORDER BY to_jsonb(value)::text), '[]'::jsonb) AS rows + for (const { table_name: tableName } of tables) { + const rows = yield* query<{ rows: unknown }>( + databasePool, + `SELECT COALESCE(jsonb_agg(to_jsonb(value) ORDER BY to_jsonb(value)::text), '[]'::jsonb) AS rows FROM ${tableName} value`, - ); + ); - allFacts[tableName] = Schema.decodeUnknownSync(Schema.Json)(rows.rows[0]!.rows); - } + allFacts[tableName] = yield* Schema.decodeUnknownEffect(Schema.Json)(rows.rows[0]!.rows); + } - return allFacts; - }; + return allFacts; + }); - const facts = async (databasePool: Pool) => { - const canonical = ( - await databasePool.query<{ facts: Record }>( - `SELECT jsonb_build_object( + const facts = Effect.fnUntraced(function* (databasePool: Pool) { + const canonical = (yield* query<{ facts: Record }>( + databasePool, + `SELECT jsonb_build_object( 'canonicalAffiliations', (SELECT jsonb_agg(a ORDER BY person_id, department_id) FROM public.organization_volunteer_affiliations a), 'canonicalPlacements', (SELECT jsonb_agg(p ORDER BY placement_id) FROM public.assistant_placements p), 'snapshots', (SELECT jsonb_agg(s ORDER BY snapshot_key) FROM public.current_assignment_snapshots s), @@ -379,393 +425,412 @@ try { 'imports', (SELECT jsonb_agg(i ORDER BY source_repository, source_assignment_id) FROM public.current_assignment_imports i), 'affiliationImports', (SELECT jsonb_agg(i ORDER BY source_repository, person_id, department_id) FROM public.current_assignment_affiliation_imports i) ) AS facts`, - ) - ).rows[0]!.facts; - - return { ...canonical, excluded: await excludedAuthorityFacts(databasePool) }; - }; - - const excludedBefore = digest((await facts(pool)).excluded); - - const runCli = (): Schema.Json => - Schema.decodeSync(Schema.fromJsonString(Schema.Json))( - execFileSync( - process.execPath, - ["run", "tools/verification/current-assignment-cohort-main.ts"], - { - cwd: root, - encoding: "utf8", - timeout: 60_000, - env: { - ...process.env, - CURRENT_ASSIGNMENT_MODE: "synthetic", - NATIVE_IDENTITY_DEPLOYMENT: "local", - CURRENT_ASSIGNMENT_PG_URL: databaseUrl, - CURRENT_ASSIGNMENT_INPUT: inputFile, - }, + )).rows[0]!.facts; + + return { ...canonical, excluded: yield* excludedAuthorityFacts(databasePool) }; + }); + + const excludedBefore = digest((yield* facts(pool)).excluded); + + const runCli = commandOutput({ + command: process.execPath, + args: ["run", "tools/verification/current-assignment-cohort-main.ts"], + cwd: root, + deadline: "60 seconds", + env: { + CURRENT_ASSIGNMENT_MODE: "synthetic", + NATIVE_IDENTITY_DEPLOYMENT: "local", + CURRENT_ASSIGNMENT_PG_URL: databaseUrl, + CURRENT_ASSIGNMENT_INPUT: inputFile, }, - ), - ); - - const cliReport = runCli(); - const report = await importSnapshot(snapshot); - assert.deepEqual(cliReport, report, "guarded CLI returns the persisted report"); - assert.equal(report.accepted, 2); - assert.equal(report.quarantined, occurrences.length - 2); - assert.equal(report.currentState, "EstablishedThroughCanonicalAffiliationAndPlacement"); - assert.equal(report.audit, "ImportProvenanceOnly"); - assert.deepEqual(runCli(), report, "exact CLI replay is byte-equivalent"); - assert.deepEqual( - ( - await pool.query( - `SELECT person_id, department_id, status, revision + }).pipe(Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(Schema.Json)))); + + const cliReport = yield* runCli; + const report = yield* importSnapshot(snapshot); + assert.deepEqual(cliReport, report, "guarded CLI returns the persisted report"); + assert.equal(report.accepted, 2); + assert.equal(report.quarantined, occurrences.length - 2); + assert.equal(report.currentState, "EstablishedThroughCanonicalAffiliationAndPlacement"); + assert.equal(report.audit, "ImportProvenanceOnly"); + assert.deepEqual(yield* runCli, report, "exact CLI replay is byte-equivalent"); + assert.deepEqual( + (yield* query( + pool, + `SELECT person_id, department_id, status, revision FROM public.organization_volunteer_affiliations WHERE person_id = 'person-valid'`, - ) - ).rows, - [{ person_id: "person-valid", department_id: "department-a", status: "Active", revision: 1 }], - ); - assert.deepEqual( - ( - await pool.query( - `SELECT placement_id, day, workdays, block, active, revision + )).rows, + [ + { + person_id: "person-valid", + department_id: "department-a", + status: "Active", + revision: 1, + }, + ], + ); + assert.deepEqual( + (yield* query( + pool, + `SELECT placement_id, day, workdays, block, active, revision FROM public.assistant_placements WHERE person_id = 'person-valid' ORDER BY placement_id`, - ) - ).rows, - [ - { - placement_id: currentAssignmentPlacementId("synthetic-legacy", "assignment-one"), - day: "Monday", - workdays: 4, - block: "1", - active: true, - revision: 1, - }, - { - placement_id: currentAssignmentPlacementId("synthetic-legacy", "assignment-two"), - day: "Friday", - workdays: 8, - block: "2", - active: true, - revision: 1, - }, - ].sort((left, right) => left.placement_id.localeCompare(right.placement_id)), - ); - assert.equal( - Number( - (await pool.query(`SELECT count(*) FROM public.organization_volunteer_affiliation_audit`)) - .rows[0].count, - ), - 0, - "import fabricated a human affiliation audit action", - ); - assert.equal( - Number( - (await pool.query(`SELECT count(*) FROM public.assistant_placement_audit`)).rows[0].count, - ), - 0, - "import fabricated a human placement audit action", - ); - assert.equal( - digest((await facts(pool)).excluded), - excludedBefore, - "import changed excluded authority", - ); - assert.equal( - Number( - ( - await pool.query( - `SELECT count(*) FROM public.current_assignment_affiliation_imports WHERE person_id = 'person-unowned'`, - ) - ).rows[0].count, - ), - 0, - "unowned canonical affiliation was adopted", - ); - assert.deepEqual( - ( - await pool.query(`SELECT reason FROM public.current_assignment_occurrences ORDER BY reason`) - ).rows.map(({ reason }) => reason), - [ - "DuplicateSource", - "DuplicateSource", - "DuplicateTarget", - "DuplicateTarget", - "Imported", - "Imported", - "Inactive", - "InvalidRow", - "MappingAmbiguous", - "MappingMissing", - "NativeReferenceMissing", - "PersonReconciliationMissing", - "SchoolDepartmentMismatch", - "SourceReferenceMismatch", - "TargetConflict", - ].sort(), - ); + )).rows, + [ + { + placement_id: currentAssignmentPlacementId("synthetic-legacy", "assignment-one"), + day: "Monday", + workdays: 4, + block: "1", + active: true, + revision: 1, + }, + { + placement_id: currentAssignmentPlacementId("synthetic-legacy", "assignment-two"), + day: "Friday", + workdays: 8, + block: "2", + active: true, + revision: 1, + }, + ].sort((left, right) => left.placement_id.localeCompare(right.placement_id)), + ); + assert.equal( + Number( + (yield* query( + pool, + `SELECT count(*) FROM public.organization_volunteer_affiliation_audit`, + )).rows[0].count, + ), + 0, + "import fabricated a human affiliation audit action", + ); + assert.equal( + Number( + (yield* query(pool, `SELECT count(*) FROM public.assistant_placement_audit`)).rows[0] + .count, + ), + 0, + "import fabricated a human placement audit action", + ); + assert.equal( + digest((yield* facts(pool)).excluded), + excludedBefore, + "import changed excluded authority", + ); + assert.equal( + Number( + (yield* query( + pool, + `SELECT count(*) FROM public.current_assignment_affiliation_imports WHERE person_id = 'person-unowned'`, + )).rows[0].count, + ), + 0, + "unowned canonical affiliation was adopted", + ); + assert.deepEqual( + (yield* query( + pool, + `SELECT reason FROM public.current_assignment_occurrences ORDER BY reason`, + )).rows.map(({ reason }) => reason), + [ + "DuplicateSource", + "DuplicateSource", + "DuplicateTarget", + "DuplicateTarget", + "Imported", + "Imported", + "Inactive", + "InvalidRow", + "MappingAmbiguous", + "MappingMissing", + "NativeReferenceMissing", + "PersonReconciliationMissing", + "SchoolDepartmentMismatch", + "SourceReferenceMismatch", + "TargetConflict", + ].sort(), + ); - const collisionSemester = "semester-collision"; - const colonCollisionSemester = `${schoolA}:${collisionSemester}`; - await pool.query( - `INSERT INTO public.admission_period_semesters (semester_id, start_at, end_at) + const collisionSemester = "semester-collision"; + const colonCollisionSemester = `${schoolA}:${collisionSemester}`; + yield* query( + pool, + `INSERT INTO public.admission_period_semesters (semester_id, start_at, end_at) VALUES ($1, '${autumnSemester.now}', '${autumnSemester.fromNow(152)}'), ($2, '${autumnSemester.now}', '${autumnSemester.fromNow(152)}')`, - [collisionSemester, colonCollisionSemester], - ); - const collisionLeft = sourceRow("assignment-colon-left", "colon-left"); - - const collisionRight = sourceRow("assignment-colon-right", "colon-right", { - sourceSchoolId: "legacy-school-b", - }); - - const colonCollision = await importSnapshot({ - ...snapshot, - sourceRevision: "synthetic-current-source-0109-colon", - snapshotId: "current-assignment-0109-colon", - occurrences: [ - { occurrenceId: "occ-assignment-colon-left", row: collisionLeft }, - { occurrenceId: "occ-assignment-colon-right", row: collisionRight }, - ], - mappings: [ - mapping(collisionLeft, `person:${schoolB}`, { semesterId: collisionSemester }), - mapping(collisionRight, "person", { - schoolId: schoolB, - semesterId: colonCollisionSemester, - }), - ], - }); - - assert.deepEqual(colonCollision.occurrences, [ - { occurrenceId: "occ-assignment-colon-left", disposition: "Accepted", reason: "Imported" }, - { occurrenceId: "occ-assignment-colon-right", disposition: "Accepted", reason: "Imported" }, - ]); - assert.equal( - Number( - ( - await pool.query( - `SELECT count(*) FROM public.assistant_placements + [collisionSemester, colonCollisionSemester], + ); + const collisionLeft = sourceRow("assignment-colon-left", "colon-left"); + + const collisionRight = sourceRow("assignment-colon-right", "colon-right", { + sourceSchoolId: "legacy-school-b", + }); + + const colonCollision = yield* importSnapshot({ + ...snapshot, + sourceRevision: "synthetic-current-source-0109-colon", + snapshotId: "current-assignment-0109-colon", + occurrences: [ + { occurrenceId: "occ-assignment-colon-left", row: collisionLeft }, + { occurrenceId: "occ-assignment-colon-right", row: collisionRight }, + ], + mappings: [ + mapping(collisionLeft, `person:${schoolB}`, { semesterId: collisionSemester }), + mapping(collisionRight, "person", { + schoolId: schoolB, + semesterId: colonCollisionSemester, + }), + ], + }); + + assert.deepEqual(colonCollision.occurrences, [ + { occurrenceId: "occ-assignment-colon-left", disposition: "Accepted", reason: "Imported" }, + { occurrenceId: "occ-assignment-colon-right", disposition: "Accepted", reason: "Imported" }, + ]); + assert.equal( + Number( + (yield* query( + pool, + `SELECT count(*) FROM public.assistant_placements WHERE person_id IN ($1, $2) AND semester_id IN ($3, $4)`, - [`person:${schoolB}`, "person", collisionSemester, colonCollisionSemester], - ) - ).rows[0].count, - ), - 2, - "colon-containing target identities collapsed distinct placements", - ); - const factsBeforeConflict = digest(await facts(pool)); - await assert.rejects( - importSnapshot({ ...snapshot, sourceRevision: "changed-source" }), - (cause) => cause instanceof CurrentAssignmentFailure && cause.code === "SnapshotConflict", - ); - const changedRow = sourceRow("assignment-one", "valid", { day: "Tuesday" }); - await assert.rejects( - importSnapshot({ - ...snapshot, - snapshotId: "current-assignment-0109-changed-source", - occurrences: [{ occurrenceId: "occ-assignment-one-changed", row: changedRow }], - mappings: [mapping(changedRow, "person-valid")], - }), - (cause) => cause instanceof CurrentAssignmentFailure && cause.code === "SourceIdentityConflict", - ); - assert.equal( - digest(await facts(pool)), - factsBeforeConflict, - "changed replay altered persisted facts", - ); + [`person:${schoolB}`, "person", collisionSemester, colonCollisionSemester], + )).rows[0].count, + ), + 2, + "colon-containing target identities collapsed distinct placements", + ); + const factsBeforeConflict = digest(yield* facts(pool)); + yield* rejectsWithCode( + importSnapshot({ ...snapshot, sourceRevision: "changed-source" }), + "SnapshotConflict", + ); + const changedRow = sourceRow("assignment-one", "valid", { day: "Tuesday" }); + yield* rejectsWithCode( + importSnapshot({ + ...snapshot, + snapshotId: "current-assignment-0109-changed-source", + occurrences: [{ occurrenceId: "occ-assignment-one-changed", row: changedRow }], + mappings: [mapping(changedRow, "person-valid")], + }), + "SourceIdentityConflict", + ); + assert.equal( + digest(yield* facts(pool)), + factsBeforeConflict, + "changed replay altered persisted facts", + ); - const overlapRow = sourceRow("assignment-overlap", "valid", { block: "Both" }); - - const overlap = await importSnapshot({ - ...snapshot, - snapshotId: "current-assignment-0109-overlap", - occurrences: [{ occurrenceId: "occ-assignment-overlap", row: overlapRow }], - mappings: [mapping(overlapRow, "person-valid")], - }); - - assert.deepEqual(overlap.occurrences, [ - { - occurrenceId: "occ-assignment-overlap", - disposition: "Quarantined", - reason: "PlacementOverlap", - }, - ]); - - await pool.query( - `INSERT INTO public.assistant_placements + const overlapRow = sourceRow("assignment-overlap", "valid", { block: "Both" }); + + const overlap = yield* importSnapshot({ + ...snapshot, + snapshotId: "current-assignment-0109-overlap", + occurrences: [{ occurrenceId: "occ-assignment-overlap", row: overlapRow }], + mappings: [mapping(overlapRow, "person-valid")], + }); + + assert.deepEqual(overlap.occurrences, [ + { + occurrenceId: "occ-assignment-overlap", + disposition: "Quarantined", + reason: "PlacementOverlap", + }, + ]); + + yield* query( + pool, + `INSERT INTO public.assistant_placements (placement_id, person_id, department_id, semester_id, school_id, day, workdays, block, active, revision) VALUES ($1, 'person-valid', 'department-a', 'semester-2026-autumn', $2, 'Monday', 1, 'Both', true, 1)`, - [`placement-${"d".repeat(64)}`, schoolB], - ); + [`placement-${"d".repeat(64)}`, schoolB], + ); + + const reverseOverlapRow = sourceRow("assignment-reverse-overlap", "valid", { + sourceSchoolId: "legacy-school-b", + }); - const reverseOverlapRow = sourceRow("assignment-reverse-overlap", "valid", { - sourceSchoolId: "legacy-school-b", - }); - - const reverseOverlap = await importSnapshot({ - ...snapshot, - snapshotId: "current-assignment-0109-reverse-overlap", - occurrences: [{ occurrenceId: "occ-assignment-reverse-overlap", row: reverseOverlapRow }], - mappings: [mapping(reverseOverlapRow, "person-valid", { schoolId: schoolB })], - }); - - assert.deepEqual(reverseOverlap.occurrences, [ - { - occurrenceId: "occ-assignment-reverse-overlap", - disposition: "Quarantined", - reason: "PlacementOverlap", - }, - ]); - const concurrentRow = sourceRow("assignment-concurrent", "concurrent", { block: "Both" }); - - const concurrentSnapshot = { - ...snapshot, - sourceRevision: "synthetic-current-source-0109-concurrent", - snapshotId: "current-assignment-0109-concurrent", - occurrences: [{ occurrenceId: "occ-assignment-concurrent", row: concurrentRow }], - mappings: [mapping(concurrentRow, "person-concurrent", { schoolId: schoolB })], - }; - - const concurrent = await Promise.all([ - importSnapshot(concurrentSnapshot), - importSnapshot(concurrentSnapshot), - ]); - - assert.deepEqual(concurrent[0], concurrent[1]); - assert.equal(concurrent[0].accepted, 1); - assert.deepEqual( - ( - await pool.query( - `SELECT + const reverseOverlap = yield* importSnapshot({ + ...snapshot, + snapshotId: "current-assignment-0109-reverse-overlap", + occurrences: [{ occurrenceId: "occ-assignment-reverse-overlap", row: reverseOverlapRow }], + mappings: [mapping(reverseOverlapRow, "person-valid", { schoolId: schoolB })], + }); + + assert.deepEqual(reverseOverlap.occurrences, [ + { + occurrenceId: "occ-assignment-reverse-overlap", + disposition: "Quarantined", + reason: "PlacementOverlap", + }, + ]); + const concurrentRow = sourceRow("assignment-concurrent", "concurrent", { block: "Both" }); + + const concurrentSnapshot = { + ...snapshot, + sourceRevision: "synthetic-current-source-0109-concurrent", + snapshotId: "current-assignment-0109-concurrent", + occurrences: [{ occurrenceId: "occ-assignment-concurrent", row: concurrentRow }], + mappings: [mapping(concurrentRow, "person-concurrent", { schoolId: schoolB })], + }; + + const concurrent = yield* Effect.all( + [importSnapshot(concurrentSnapshot), importSnapshot(concurrentSnapshot)], + { concurrency: "unbounded" }, + ); + + assert.deepEqual(concurrent[0], concurrent[1]); + assert.equal(concurrent[0].accepted, 1); + assert.deepEqual( + (yield* query( + pool, + `SELECT (SELECT count(*) FROM public.organization_volunteer_affiliations WHERE person_id = 'person-concurrent') AS affiliations, (SELECT count(*) FROM public.assistant_placements WHERE person_id = 'person-concurrent') AS placements, (SELECT count(*) FROM public.current_assignment_imports WHERE source_assignment_id = 'assignment-concurrent') AS imports`, - ) - ).rows[0], - { affiliations: "1", placements: "1", imports: "1" }, - ); + )).rows[0], + { affiliations: "1", placements: "1", imports: "1" }, + ); - await pool.query(` + yield* query( + pool, + ` CREATE FUNCTION public.fail_current_assignment_import() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic failure'; END $$; CREATE TRIGGER fail_current_assignment_import BEFORE INSERT ON public.current_assignment_imports FOR EACH ROW EXECUTE FUNCTION public.fail_current_assignment_import() - `); - const rollbackRow = sourceRow("assignment-rollback", "rollback", { block: "Both" }); - await assert.rejects( - importSnapshot({ - ...snapshot, - snapshotId: "current-assignment-0109-rollback", - occurrences: [{ occurrenceId: "occ-assignment-rollback", row: rollbackRow }], - mappings: [mapping(rollbackRow, "person-rollback", { schoolId: schoolB })], - }), - (cause) => cause instanceof CurrentAssignmentFailure && cause.code === "PersistenceFailure", - ); - assert.deepEqual( - ( - await pool.query( - `SELECT + `, + ); + const rollbackRow = sourceRow("assignment-rollback", "rollback", { block: "Both" }); + yield* rejectsWithCode( + importSnapshot({ + ...snapshot, + snapshotId: "current-assignment-0109-rollback", + occurrences: [{ occurrenceId: "occ-assignment-rollback", row: rollbackRow }], + mappings: [mapping(rollbackRow, "person-rollback", { schoolId: schoolB })], + }), + "PersistenceFailure", + ); + assert.deepEqual( + (yield* query( + pool, + `SELECT (SELECT count(*) FROM public.current_assignment_snapshots WHERE snapshot_id = 'current-assignment-0109-rollback') AS snapshots, (SELECT count(*) FROM public.organization_volunteer_affiliations WHERE person_id = 'person-rollback') AS affiliations, (SELECT count(*) FROM public.assistant_placements WHERE person_id = 'person-rollback') AS placements, (SELECT count(*) FROM public.current_assignment_imports WHERE source_assignment_id = 'assignment-rollback') AS imports`, - ) - ).rows[0], - { snapshots: "0", affiliations: "0", placements: "0", imports: "0" }, - ); - await pool.query(` + )).rows[0], + { snapshots: "0", affiliations: "0", placements: "0", imports: "0" }, + ); + yield* query( + pool, + ` DROP TRIGGER fail_current_assignment_import ON public.current_assignment_imports; DROP FUNCTION public.fail_current_assignment_import() - `); - await assert.rejects( - pool.query(`UPDATE public.current_assignment_imports SET affiliation_evidence_ref = 'changed'`), - /append-only/, - ); + `, + ); + yield* step(() => + assert.rejects( + pool.query( + `UPDATE public.current_assignment_imports SET affiliation_evidence_ref = 'changed'`, + ), + /append-only/, + ), + ); - const appendOnlyStatements = [ - "UPDATE public.current_assignment_snapshots SET source_revision = source_revision", - "UPDATE public.current_assignment_occurrences SET reason = reason", - "UPDATE public.current_assignment_imports SET affiliation_evidence_ref = affiliation_evidence_ref", - "UPDATE public.current_assignment_affiliation_imports SET source_assignment_id = source_assignment_id", - ] as const; - - const appendOnlyTables = [ - "current_assignment_snapshots", - "current_assignment_occurrences", - "current_assignment_imports", - "current_assignment_affiliation_imports", - ] as const; - - const assertAppendOnlyProvenance = async (databasePool: Pool): Promise => { - for (const statement of appendOnlyStatements) - await assert.rejects(databasePool.query(statement), /append-only/); - - for (const table of appendOnlyTables) - await assert.rejects(databasePool.query(`DELETE FROM public.${table}`), /append-only/); - - for (const table of appendOnlyTables) - await assert.rejects(databasePool.query(`TRUNCATE public.${table} CASCADE`), /append-only/); - }; - - await assertAppendOnlyProvenance(pool); - - const restoredFactsExpected = await facts(pool); - command(postgresProgram("pg_dump"), [ - "--dbname", - databaseUrl, - "--format=custom", - "--file", - backup, - ]); - assert.ok((await stat(backup)).size > 0); - - const backupChecksum = createHash("sha256") - .update(await readFile(backup)) - .digest("hex"); - - const restoredUrl = await postgres.createDatabase("current_assignment_restored"); - command(postgresProgram("pg_restore"), ["--dbname", restoredUrl, backup]); - const restored = new Pool({ connectionString: restoredUrl }); - assert.deepEqual(await facts(restored), restoredFactsExpected); - await assertAppendOnlyProvenance(restored); - await restored.end(); + const appendOnlyStatements = [ + "UPDATE public.current_assignment_snapshots SET source_revision = source_revision", + "UPDATE public.current_assignment_occurrences SET reason = reason", + "UPDATE public.current_assignment_imports SET affiliation_evidence_ref = affiliation_evidence_ref", + "UPDATE public.current_assignment_affiliation_imports SET source_assignment_id = source_assignment_id", + ] as const; + + const appendOnlyTables = [ + "current_assignment_snapshots", + "current_assignment_occurrences", + "current_assignment_imports", + "current_assignment_affiliation_imports", + ] as const; + + const assertAppendOnlyProvenance = Effect.fnUntraced(function* (databasePool: Pool) { + for (const statement of appendOnlyStatements) + yield* step(() => assert.rejects(databasePool.query(statement), /append-only/)); + + for (const table of appendOnlyTables) + yield* step(() => + assert.rejects(databasePool.query(`DELETE FROM public.${table}`), /append-only/), + ); + + for (const table of appendOnlyTables) + yield* step(() => + assert.rejects(databasePool.query(`TRUNCATE public.${table} CASCADE`), /append-only/), + ); + }); + + yield* assertAppendOnlyProvenance(pool); + + const restoredFactsExpected = yield* facts(pool); + yield* command(postgresProgram("pg_dump"), [ + "--dbname", + databaseUrl, + "--format=custom", + "--file", + backup, + ]); + assert.ok((yield* fileSystem.stat(backup)).size > 0); + + const backupChecksum = createHash("sha256") + .update(yield* fileSystem.readFile(backup)) + .digest("hex"); + + const restoredUrl = yield* step(() => postgres.createDatabase("current_assignment_restored")); + yield* command(postgresProgram("pg_restore"), ["--dbname", restoredUrl, backup]); + const restored = new Pool({ connectionString: restoredUrl }); + assert.deepEqual(yield* facts(restored), restoredFactsExpected); + yield* assertAppendOnlyProvenance(restored); + yield* step(() => restored.end()); + + assert.equal( + (yield* command("git", ["rev-parse", "HEAD"])).trim(), + sourceRevision, + "source revision changed", + ); + assert.equal( + (yield* command("git", ["status", "--porcelain=v1"])).trim(), + "", + "worktree changed", + ); - assert.equal( - command("git", ["rev-parse", "HEAD"]).trim(), - sourceRevision, - "source revision changed", + return { + contract: "0109", + sourceRevision, + report: canonicalJsonValue(report), + oneAffiliationForTwoPlacements: true, + deterministicPlacementIdentity: true, + colonDelimitedTargetsIndependent: true, + quarantinesPreserveCanonicalState: true, + unownedCanonicalTargetNotAdopted: true, + exactReplayByteStable: true, + changedSnapshotRejected: true, + changedAcceptedSourceRejected: true, + concurrentFirstImport: true, + forcedRollback: true, + appendOnlyProvenance: true, + excludedAuthoritiesUnchanged: true, + noHumanOperationalAuditFabricated: true, + backup: { nonempty: true, restored: true, sha256: backupChecksum }, + productionEffects: "none", + } satisfies Record; + }), ); - assert.equal(command("git", ["status", "--porcelain=v1"]).trim(), "", "worktree changed"); - - evidence = { - contract: "0109", - sourceRevision, - report: canonicalJsonValue(report), - oneAffiliationForTwoPlacements: true, - deterministicPlacementIdentity: true, - colonDelimitedTargetsIndependent: true, - quarantinesPreserveCanonicalState: true, - unownedCanonicalTargetNotAdopted: true, - exactReplayByteStable: true, - changedSnapshotRejected: true, - changedAcceptedSourceRejected: true, - concurrentFirstImport: true, - forcedRollback: true, - appendOnlyProvenance: true, - excludedAuthoritiesUnchanged: true, - noHumanOperationalAuditFabricated: true, - backup: { nonempty: true, restored: true, sha256: backupChecksum }, - productionEffects: "none", - }; -} finally { - if (pool) await pool.end().catch(() => undefined); - - await postgres?.stop().catch(() => undefined); - await rm(artifacts, { recursive: true, force: true }); -} - -assert.ok(evidence, "rehearsal must complete before evidence is emitted"); - -process.stdout.write(JSON.stringify(evidence, null, 2) + "\n"); +}); + +BunRuntime.runMain( + rehearsal.pipe( + Effect.flatMap(indentedJsonText), + Effect.flatMap(Console.log), + surfaceStepFailure, + Effect.provide(BunServices.layer), + ), +); diff --git a/tools/verification/onboarding-delivery.test.ts b/tools/verification/onboarding-delivery.test.ts index 008f43f52..0c963456c 100644 --- a/tools/verification/onboarding-delivery.test.ts +++ b/tools/verification/onboarding-delivery.test.ts @@ -1,185 +1,175 @@ +import { administratorDepartmentReach } from "@vektorprogrammet/domain/organization/authority-fixtures"; import { randomBytes } from "node:crypto"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { expect, layer } from "@effect/vitest"; import { Database } from "@vektorprogrammet/database"; import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; import { claimOnboarding, commandOnboarding } from "@vektorprogrammet/database/onboarding"; -import { Crypto, Effect, Predicate } from "effect"; +import { Crypto, DateTime, Effect, Layer, Predicate } from "effect"; import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/unstable/http"; import { DatabaseTestLive } from "@vektorprogrammet/database/test-support/platform"; -import { makeControlledTestRuntime } from "./controlled-test-runtime.js"; import { provisionOnboardingAccount } from "@vektorprogrammet/database/onboarding-account"; import { drainOnboardingDelivery, expireOnboardingSecrets, } from "@vektorprogrammet/backend/onboarding/delivery"; -const runtime = makeControlledTestRuntime(DatabaseTestLive()); - -afterAll(() => runtime.dispose()); - const dept = DepartmentId.make("onboarding-dept"); const actor = PersonId.make("onboarding-leader"); +const coordinator = administratorDepartmentReach("onboarding-leader", "admissions.outcomes", dept); + // The drain draws its claim identity from Crypto; no digest belongs to this lifecycle. const testCrypto = Crypto.make({ randomBytes, digest: () => Effect.die("unexpected digest") }); -const now = new Date().toISOString(); - -async function setup() { - await runtime.runPromise( - Database.use((sql) => - Effect.gen(function* () { - yield* sql`INSERT INTO admission_period_departments(department_id,name) VALUES(${dept},'Onboarding')`; - yield* sql`INSERT INTO admission_period_semesters VALUES('onboarding-semester','2026-01-01','2027-01-01')`; - yield* sql`INSERT INTO admission_periods VALUES('onboarding-period',${dept},'onboarding-semester','2026-01-01','2027-01-01',0,'onboarding-seed')`; - yield* sql`INSERT INTO admission_period_fields_of_study VALUES('onboarding-fos',${dept},'Math',true)`; - yield* sql`INSERT INTO person_profiles(person_id,first_name,last_name) VALUES(${actor},'Cora','Coordinator')`; - - for (let i = 0; i < 7; i++) { - yield* sql`INSERT INTO admission_applicants(applicant_id,normalized_email,email,first_name,last_name,phone,gender,field_of_study_id,year_of_study) VALUES(${"onboard-applicant-" + i},${"applicant" + i + "@example.invalid"},${"applicant" + i + "@example.invalid"},'Ada','Applicant','12345678',0,'onboarding-fos',2)`; - yield* sql`INSERT INTO admission_applications(application_id,applicant_id,admission_period_id,department_id,field_of_study_id,year_of_study,submitted_at) VALUES(${"onboard-app-" + i},${"onboard-applicant-" + i},'onboarding-period',${dept},'onboarding-fos',2,${now})`; - } - }), - ), - ); -} +const now = DateTime.formatIso(DateTime.nowUnsafe()); + +const setup = Database.use((sql) => + Effect.gen(function* () { + yield* sql`INSERT INTO admission_period_departments(department_id,name) VALUES(${dept},'Onboarding')`; + yield* sql`INSERT INTO admission_period_semesters VALUES('onboarding-semester','2026-01-01','2027-01-01')`; + yield* sql`INSERT INTO admission_periods VALUES('onboarding-period',${dept},'onboarding-semester','2026-01-01','2027-01-01',0,'onboarding-seed')`; + yield* sql`INSERT INTO admission_period_fields_of_study VALUES('onboarding-fos',${dept},'Math',true)`; + yield* sql`INSERT INTO person_profiles(person_id,first_name,last_name) VALUES(${actor},'Cora','Coordinator')`; + + for (let i = 0; i < 7; i++) { + yield* sql`INSERT INTO admission_applicants(applicant_id,normalized_email,email,first_name,last_name,phone,gender,field_of_study_id,year_of_study) VALUES(${"onboard-applicant-" + i},${"applicant" + i + "@example.invalid"},${"applicant" + i + "@example.invalid"},'Ada','Applicant','12345678',0,'onboarding-fos',2)`; + yield* sql`INSERT INTO admission_applications(application_id,applicant_id,admission_period_id,department_id,field_of_study_id,year_of_study,submitted_at) VALUES(${"onboard-app-" + i},${"onboard-applicant-" + i},'onboarding-period',${dept},'onboarding-fos',2,${now})`; + } + }), +); const issue = (i: number) => - runtime.runPromise( - Database.use((sql) => - sql.withTransaction( - commandOnboarding({ - departmentId: dept, - command: { - applicationId: PublicApplicationIdSchema.make("onboard-app-" + i), - action: "Issue", - }, - actor, - now, - invitationId: "onboard-invite-" + i, - token: "onboard_" + String(i).repeat(64), - digest: String(i).repeat(64), - }), - ), + Database.use((sql) => + sql.withTransaction( + commandOnboarding({ + coordinator, + command: { + applicationId: PublicApplicationIdSchema.make("onboard-app-" + i), + action: "Issue", + }, + now, + invitationId: "onboard-invite-" + i, + token: "onboard_" + String(i).repeat(64), + digest: String(i).repeat(64), + }), ), ); const claim = (i: number, personId: string, provision = provisionOnboardingAccount) => - runtime.runPromise( - claimOnboarding({ - digest: String(i).repeat(64), - now, - identity: { - mode: "NewAccount", - personId: PersonId.make(personId), - passwordHash: "synthetic-hash", - }, - provision, - }), - ); - -beforeAll(setup, 15000); - -describe("onboarding delivery lifecycle", () => { - it("retains immutable envelope through failure, clears secret on ACK and reports lost custody accurately", async () => { - await issue(4); - const envelopes: string[] = []; - - const config = { - sender: "sender@example.invalid", - claimUrl: new URL("http://127.0.0.1:5174/konto-aktivering"), - transport: { - endpoint: new URL("http://127.0.0.1:1111/mail"), - token: "synthetic", - deliveryTimeoutMilliseconds: 1000, - }, - }; - - const drain = ( - applicationId: string, - deliveryConfig: typeof config | undefined, - respond: Parameters[0], - ) => - runtime.runPromise( - drainOnboardingDelivery(applicationId, deliveryConfig).pipe( - Effect.provideService(HttpClient.HttpClient, HttpClient.make(respond)), - Effect.provideService(Crypto.Crypto, testCrypto), - ), - ); - - const answer = (request: HttpClientRequest.HttpClientRequest, status: number) => { - if (!Predicate.isTagged(request.body, "Uint8Array")) throw new Error("Expected a JSON body"); - envelopes.push(new TextDecoder().decode(request.body.body)); - - return HttpClientResponse.fromWeb(request, new Response(null, { status })); - }; - - expect(await drain("onboard-app-4", undefined, () => Effect.die("unexpected delivery"))).toBe( - "Pending", - ); - expect( - await drain("onboard-app-4", config, (request) => Effect.sync(() => answer(request, 503))), - ).toBe("Pending"); - expect( - await drain("onboard-app-4", config, (request) => Effect.sync(() => answer(request, 204))), - ).toBe("Delivered"); - expect(envelopes[0]).toBe(envelopes[1]); - - const rows = await runtime.runPromise( - Database.use( - (sql) => - sql`SELECT state,secret,envelope FROM applicant_account_delivery WHERE invitation_id='onboard-invite-4'`, - ), - ); - - expect(rows[0]).toEqual({ state: "Delivered", secret: null, envelope: null }); - await issue(5); - expect( - await drain("onboard-app-5", config, (request) => - Effect.promise(() => - runtime.runPromise( - Database.use( - (sql) => - sql`UPDATE applicant_account_delivery SET state='Cancelled',secret=NULL,envelope=NULL,claim_id=NULL,claimed_at=NULL WHERE invitation_id='onboard-invite-5'`, - ), - ), - ).pipe(Effect.as(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))), - ), - ).toBe("BusyOrComplete"); + claimOnboarding({ + digest: String(i).repeat(64), + now, + identity: { + mode: "NewAccount", + personId: PersonId.make(personId), + passwordHash: "synthetic-hash", + }, + provision, }); - it("expired claims fail immediately and expiry sweep erases retained delivery material", async () => { - await runtime.runPromise( - Database.use((sql) => - sql.withTransaction( - commandOnboarding({ - departmentId: dept, - command: { - applicationId: PublicApplicationIdSchema.make("onboard-app-5"), - action: "Issue", + +const OnboardingTestLive = Layer.effectDiscard(setup).pipe(Layer.provideMerge(DatabaseTestLive())); + +layer(OnboardingTestLive, { excludeTestServices: true, timeout: "15 seconds" })( + "onboarding delivery lifecycle", + (it) => { + it.effect( + "retains immutable envelope through failure, clears secret on ACK and reports lost custody accurately", + () => + Effect.gen(function* () { + const database = yield* Database; + yield* issue(4); + const envelopes: string[] = []; + + const config = { + sender: "sender@example.invalid", + claimUrl: new URL("http://127.0.0.1:5174/konto-aktivering"), + transport: { + endpoint: new URL("http://127.0.0.1:1111/mail"), + token: "synthetic", + deliveryTimeoutMilliseconds: 1000, }, - actor, - now: "2000-01-01T00:00:00.000Z", - invitationId: "expired-invitation", - token: "onboard_" + "8".repeat(64), - digest: "8".repeat(64), - }), - ), - ), - ); - await expect(claim(8, "expired-person")).rejects.toMatchObject({ - code: "onboarding.claim-invalid", - }); - await runtime.runPromise(expireOnboardingSecrets); - - const rows = await runtime.runPromise( - Database.use( - (sql) => - sql`SELECT state,secret,envelope FROM applicant_account_delivery WHERE invitation_id='expired-invitation'`, - ), + }; + + const drain = ( + applicationId: string, + deliveryConfig: typeof config | undefined, + respond: Parameters[0], + ) => + drainOnboardingDelivery(applicationId, deliveryConfig).pipe( + Effect.provideService(HttpClient.HttpClient, HttpClient.make(respond)), + Effect.provideService(Crypto.Crypto, testCrypto), + ); + + const answer = (request: HttpClientRequest.HttpClientRequest, status: number) => { + if (!Predicate.isTagged(request.body, "Uint8Array")) + throw new Error("Expected a JSON body"); + envelopes.push(new TextDecoder().decode(request.body.body)); + + return HttpClientResponse.fromWeb(request, new Response(null, { status })); + }; + + expect( + yield* drain("onboard-app-4", undefined, () => Effect.die("unexpected delivery")), + ).toBe("Pending"); + expect( + yield* drain("onboard-app-4", config, (request) => + Effect.sync(() => answer(request, 503)), + ), + ).toBe("Pending"); + expect( + yield* drain("onboard-app-4", config, (request) => + Effect.sync(() => answer(request, 204)), + ), + ).toBe("Delivered"); + expect(envelopes[0]).toBe(envelopes[1]); + + const rows = + yield* database`SELECT state,secret,envelope FROM applicant_account_delivery WHERE invitation_id='onboard-invite-4'`; + + expect(rows[0]).toEqual({ state: "Delivered", secret: null, envelope: null }); + yield* issue(5); + expect( + yield* drain("onboard-app-5", config, (request) => + database`UPDATE applicant_account_delivery SET state='Cancelled',secret=NULL,envelope=NULL,claim_id=NULL,claimed_at=NULL WHERE invitation_id='onboard-invite-5'`.pipe( + Effect.orDie, + Effect.as(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))), + ), + ), + ).toBe("BusyOrComplete"); + }), ); + it.effect( + "expired claims fail immediately and expiry sweep erases retained delivery material", + () => + Effect.gen(function* () { + const database = yield* Database; + yield* Database.use((sql) => + sql.withTransaction( + commandOnboarding({ + coordinator, + command: { + applicationId: PublicApplicationIdSchema.make("onboard-app-5"), + action: "Issue", + }, + now: "2000-01-01T00:00:00.000Z", + invitationId: "expired-invitation", + token: "onboard_" + "8".repeat(64), + digest: "8".repeat(64), + }), + ), + ); + expect(yield* Effect.flip(claim(8, "expired-person"))).toMatchObject({ + code: "onboarding.claim-invalid", + }); + yield* expireOnboardingSecrets; - expect(rows[0]).toEqual({ state: "Cancelled", secret: null, envelope: null }); - }); -}); + const rows = + yield* database`SELECT state,secret,envelope FROM applicant_account_delivery WHERE invitation_id='expired-invitation'`; + + expect(rows[0]).toEqual({ state: "Cancelled", secret: null, envelope: null }); + }), + ); + }, +); diff --git a/tools/verification/organization-import-rehearsal-main.ts b/tools/verification/organization-import-rehearsal-main.ts index b8626c523..f5ad301c0 100644 --- a/tools/verification/organization-import-rehearsal-main.ts +++ b/tools/verification/organization-import-rehearsal-main.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; -import type * as GeneratedSdkModule from "../../packages/sdk/src/effect-client.js"; import { addressesAnyRoute, legacyRoutes } from "../../apps/dashboard/e2e/request-routes.js"; -import { SessionResponse } from "@vektorprogrammet/http-api"; +import { isNativeRpcPath } from "@vektorprogrammet/rpc"; +import { nativeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; import * as BunHttpPlatform from "@effect/platform-bun/BunHttpPlatform"; import * as BunServices from "@effect/platform-bun/BunServices"; import { randomBytes } from "node:crypto"; @@ -85,6 +85,7 @@ import { Effect, Layer, ManagedRuntime, + Match, Option, Redacted, Result, @@ -95,7 +96,7 @@ import type { SqlError } from "effect/unstable/sql/SqlError"; import { backendHttpHandler, decodeBackendConfig, - ExternalNativeApiRouterLive, + ExternalNativeRpcRouterLive, nativeRouterWebHandler, type BackendConfig, } from "@vektorprogrammet/backend"; @@ -108,6 +109,7 @@ import { import { OrganizationImportRehearsalArtifactSchema, NATIVE_BROWSER_JOURNEY_REQUIREMENTS, + nativeBrowserJourneyRequirementOfRpc, SPEC_0067, SPEC_0067_PREREQUISITES, decodeFrozenOrganizationSnapshot, @@ -195,8 +197,6 @@ const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); const dashboardRoot = join(repositoryRoot, "apps/dashboard"); -const sdkRoot = join(repositoryRoot, "packages/sdk"); - const dashboardPort = 5_174; const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; @@ -217,8 +217,6 @@ export const ORGANIZATION_IMPORT_DASHBOARD_BUILD_ARGUMENTS = ["run", "build"] as export const ORGANIZATION_IMPORT_DASHBOARD_SERVE_ARGUMENTS = ["server.mjs"] as const; export const ORGANIZATION_IMPORT_GENERATED_OUTPUT_PATHS = [ - "packages/sdk/dist", - "packages/sdk/tsconfig.tsbuildinfo", "apps/dashboard/.react-router", "apps/dashboard/build", ] as const; @@ -467,6 +465,8 @@ const makeChildToolEnvironment = (runnerTempRoot: string): NodeJS.ProcessEnv => "PATH", "LANG", "LC_ALL", + // A machine without Playwright's own browser names its Chromium, as the golden harness allows. + "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", "NIX_SSL_CERT_FILE", "SSL_CERT_FILE", "SSL_CERT_DIR", @@ -992,6 +992,42 @@ const requestBodyBytes = async (request: IncomingMessage): Promise => { return Buffer.concat(chunks); }; +/** The native RPC request that one request body carries. */ +const RpcRequestBody = Schema.fromJsonString(Schema.Struct({ tag: Schema.String })); + +/** The tag of the native RPC that one request body carries, if any. */ +const rpcTagOf = (bytes: Buffer): string | undefined => + Option.getOrUndefined(Schema.decodeOption(RpcRequestBody)(bytes.toString("utf8")))?.tag; + +/** The answer that ends one RPC request: a success, or one failure's problem status. */ +const RpcAnswer = Schema.fromJsonString( + Schema.Tuple([ + Schema.Struct({ + exit: Schema.Union([ + Schema.TaggedStruct("Success", { value: Schema.Unknown }), + Schema.TaggedStruct("Failure", { + cause: Schema.Tuple([Schema.Struct({ error: Schema.Struct({ status: Schema.Int }) })]), + }), + ]), + }), + ]), +); + +/** + * The status that one RPC answer had under the HTTP contract: 200 for a success, and the registry + * status of the problem that a failure carries. An RPC always answers 200 over HTTP. + */ +const rpcAnswerStatus = (bytes: Buffer): number => + Option.match(Schema.decodeOption(RpcAnswer)(bytes.toString("utf8")), { + onNone: () => 502, + onSome: ([{ exit }]) => + Match.value(exit).pipe( + Match.tag("Success", () => 200), + Match.tag("Failure", ({ cause: [{ error }] }) => error.status), + Match.exhaustive, + ), + }); + const startRecordingProxy = async ( targetOrigin: string, dashboardAllowedOrigin: string, @@ -1001,8 +1037,18 @@ const startRecordingProxy = async ( const records: ProxyRequestObservation[] = []; const server: HttpServer = createHttpServer(async (request, response) => { - const method = request.method ?? "GET"; - const path = new URL(request.url ?? "/", targetOrigin).pathname; + const transportMethod = request.method ?? "GET"; + const transportPath = new URL(request.url ?? "/", targetOrigin).pathname; + const requestBytes = await requestBodyBytes(request); + + // A native RPC of the journey is recorded as the read it serves: the route that it replaced. + const rpcRequirement = + transportMethod === "POST" && isNativeRpcPath(transportPath) + ? nativeBrowserJourneyRequirementOfRpc(rpcTagOf(requestBytes)) + : undefined; + + const method = rpcRequirement === undefined ? transportMethod : "GET"; + const path = rpcRequirement?.path ?? transportPath; const cookie = request.headers.cookie ?? ""; const sessionCookieAuth = cookie @@ -1042,7 +1088,6 @@ const startRecordingProxy = async ( return; } - const requestBytes = await requestBodyBytes(request); const headers = new Headers(); for (const [name, value] of Object.entries(request.headers)) { @@ -1062,14 +1107,21 @@ const startRecordingProxy = async ( try { const upstream = await guard.fetchLoopback(new URL(request.url ?? "/", targetOrigin), { - method, + method: transportMethod, headers, - body: method === "GET" || method === "HEAD" ? undefined : requestBytes, + body: transportMethod === "GET" || transportMethod === "HEAD" ? undefined : requestBytes, redirect: "manual", }); const responseBytes = Buffer.from(await upstream.arrayBuffer()); - records.push({ method, path, status: upstream.status, sessionCookieAuth, requestSource }); + + records.push({ + method, + path, + status: rpcRequirement === undefined ? upstream.status : rpcAnswerStatus(responseBytes), + sessionCookieAuth, + requestSource, + }); response.statusCode = upstream.status; for (const [name, value] of upstream.headers.entries()) { @@ -1117,6 +1169,25 @@ const startRecordingProxy = async ( }; }; +const JsonText = Schema.fromJsonString(Schema.Unknown); + +/** A decoded RPC value as the JSON that its answer carried. */ +const jsonOf = (value: A): Schema.Json => + Schema.decodeSync(Schema.fromJsonString(Schema.Json))(Schema.encodeSync(JsonText)(value)); + +/** An own-profile answer names its person, bare or beside its entity tag. */ +const ProfileAnswer = Schema.Union([ + Schema.Struct({ personId: Schema.String }), + Schema.Struct({ profile: Schema.Struct({ personId: Schema.String }) }), +]); + +/** The person that an own-profile answer names, if it names one. */ +const profilePersonId = (value: Schema.Json): string | undefined => + Option.match(Schema.decodeUnknownOption(ProfileAnswer)(value), { + onNone: () => undefined, + onSome: (profile) => ("profile" in profile ? profile.profile.personId : profile.personId), + }); + const sanitizeProjection = (value: Schema.Json): Schema.Json => { if (Arr.isArray(value)) return value.map(sanitizeProjection); @@ -1181,13 +1252,6 @@ const importResultEvidence = (result: OrganizationImportResult) => { }; }; -const decodeJsonResponse = async ( - response: Response, -): Promise<{ readonly status: number; readonly body: Schema.Json }> => ({ - status: response.status, - body: Schema.decodeUnknownSync(Schema.Json)(await response.json()), -}); - const sanitizeFailure = (cause: unknown, sensitiveValues: ReadonlyArray): string => { let message = cause instanceof Error ? cause.message : String(cause); @@ -1462,7 +1526,8 @@ const makeRehearsalRuntime = ( const routerLayer = HttpRouter.layer; const httpLayer = Layer.merge(platformLayer, routerLayer); - const nativeApiLayer = ExternalNativeApiRouterLive(config, { + const nativeApiLayer = ExternalNativeRpcRouterLive({ + config, now: () => SPEC_0067.authorizationInstant, }).pipe( HttpRouter.provideRequest(Layer.merge(servicesLayer, requestPlatformLayer)), @@ -1642,7 +1707,7 @@ const runRehearsal = async ( await mkdir(childToolEnvironment.TMPDIR!, { recursive: true }); await mkdir(childToolEnvironment.XDG_CACHE_HOME!, { recursive: true }); - for (const root of [sdkRoot, dashboardRoot]) { + for (const root of [dashboardRoot]) { for (const name of [".env", ".env.local", ".env.development", ".env.development.local"]) { assert.equal( await pathExists(join(root, name)), @@ -2063,65 +2128,102 @@ const runRehearsal = async ( guard.addHttp(dashboardOrigin, "dashboard-loopback"); const cookieHeader = `${SPEC_0067.sessionCookieName}=${sessionCookie}`; - const fetchObservation = async ( + const native = nativeScriptClient(backendOrigin); + const anonymousHeaders = { origin: dashboardOrigin }; + const administratorHeaders = { cookie: cookieHeader, origin: dashboardOrigin }; + + /** One native read, recorded as the route that its RPC replaced, with its answer as JSON. */ + const observe = async ( path: string, authenticated: boolean, + call: Promise>, ): Promise<{ readonly status: number; readonly body: Schema.Json }> => { - const response = await guard.fetchLoopback(`${backendOrigin}${path}`, { - headers: authenticated ? { cookie: cookieHeader } : undefined, - }); + const result = await call; - const decoded = await decodeJsonResponse(response); backendRequests.push({ method: "GET", path, - status: decoded.status, + status: result.status, sessionCookieAuth: authenticated, }); - return decoded; + return { + status: result.status, + body: jsonOf(result.ok ? result.value : "problem" in result ? result.problem : null), + }; }; - const departmentsHttp = await fetchObservation("/api/departments", false); - const teamsHttp = await fetchObservation("/api/teams", false); - const sessionHttp = await fetchObservation("/api/session", true); - const missingSessionHttp = await fetchObservation("/api/session", false); - const profileHttp = await fetchObservation("/api/profile", true); - const peopleHttp = await fetchObservation("/api/people", true); - assert.equal(departmentsHttp.status, 200, "GET /api/departments did not return 200"); - assert.equal(teamsHttp.status, 200, "GET /api/teams did not return 200"); - assert.equal(sessionHttp.status, 200, "authenticated GET /api/session did not return 200"); + const departmentsRpc = await observe( + "/api/departments", + false, + native.call(anonymousHeaders, (client) => client["organization.listDepartments"]()), + ); + + const teamsRpc = await observe( + "/api/teams", + false, + native.call(anonymousHeaders, (client) => client["organization.listTeams"]()), + ); + + const sessionRpc = await observe( + "/api/session", + true, + native.call(administratorHeaders, (client) => client["system.readSession"]()), + ); + + const missingSessionRpc = await observe( + "/api/session", + false, + native.call(anonymousHeaders, (client) => client["system.readSession"]()), + ); + + const profileRpc = await observe( + "/api/profile", + true, + native.call(administratorHeaders, (client) => client["profile.readOwnProfile"]()), + ); + + const peopleRpc = await observe( + "/api/people", + true, + native.call(administratorHeaders, (client) => client["directory.listPeople"]()), + ); + + await native.dispose(); + assert.equal(departmentsRpc.status, 200, "organization.listDepartments did not succeed"); + assert.equal(teamsRpc.status, 200, "organization.listTeams did not succeed"); + assert.equal(sessionRpc.status, 200, "an authenticated system.readSession did not succeed"); assert.equal( - missingSessionHttp.status, + missingSessionRpc.status, 401, - "unauthenticated GET /api/session did not return 401", + "an unauthenticated system.readSession did not answer 401", ); - assert.equal(profileHttp.status, 200, "authenticated GET /api/profile did not return 200"); - assert.equal(peopleHttp.status, 200, "GET /api/people did not return 200"); + assert.equal(profileRpc.status, 200, "an authenticated profile.readOwnProfile did not succeed"); + assert.equal(peopleRpc.status, 200, "directory.listPeople did not succeed"); - const sessionProjection = Schema.decodeUnknownSync(SessionResponse)(sessionHttp.body, { - onExcessProperty: "error", - }); + const sessionProjection = sessionRpc.body; + assert.ok( + Predicate.isObjectOrArray(sessionProjection) && !Array.isArray(sessionProjection), + "system.readSession must answer one session", + ); assert.equal(sessionProjection.current, true); assert.equal(sessionProjection.expiresAt, SPEC_0067.sessionExpiresAt); assert.equal(sessionProjection.personId, SPEC_0067.administratorPersonId); assert.ok( Predicate.isString(sessionProjection.sessionId) && sessionProjection.sessionId !== "", ); - assert.ok( - profileHttp.body !== null && - (profileHttp.body === null || Predicate.isObjectOrArray(profileHttp.body)) && - "personId" in profileHttp.body && - profileHttp.body.personId === SPEC_0067.administratorPersonId, - "GET /api/profile must bind the session to the administrator PersonId", + assert.equal( + profilePersonId(profileRpc.body), + SPEC_0067.administratorPersonId, + "profile.readOwnProfile must bind the session to the administrator PersonId", ); assert.ok( - missingSessionHttp.body !== null && - (missingSessionHttp.body === null || Predicate.isObjectOrArray(missingSessionHttp.body)) && - "code" in missingSessionHttp.body && - missingSessionHttp.body.code === "credential.missing", - "unauthenticated GET /api/session must return the RFC 9457 missing-credential problem", + missingSessionRpc.body !== null && + Predicate.isObjectOrArray(missingSessionRpc.body) && + "code" in missingSessionRpc.body && + missingSessionRpc.body.code === "credential.missing", + "an unauthenticated system.readSession must answer the missing-credential problem", ); const processEnvironment: NodeJS.ProcessEnv = { @@ -2136,41 +2238,25 @@ const runRehearsal = async ( delete processEnvironment.API_MODE; delete processEnvironment.VITE_API_MODE; - await runCommand("bun", ["run", "build"], { - cwd: sdkRoot, - env: processEnvironment, - label: "spec 0067 SDK build", - observations: processObservations, - processEffects, - }); - - // The SDK output is built during this rehearsal, so it cannot be imported before the build. - const sdk: typeof GeneratedSdkModule = await import( - new URL("../../packages/sdk/dist/effect-client.js", import.meta.url).href - ); - - const client = sdk.createEffectClient(proxy.origin, { - cookie: cookieHeader, - fetch: guard.fetchLoopback, - }); - const departmentsSdk = await Effect.runPromise( - client.organization.listDepartments({ headers: {} }), - ); - - const teamsSdk = await Effect.runPromise(client.organization.listTeams({ headers: {} })); - const peopleSdk = await Effect.runPromise(client.directory.listPeople()); - assert.ok(departmentsSdk.body !== undefined, "SDK departments response must contain a body"); - assert.ok(teamsSdk.body !== undefined, "SDK teams response must contain a body"); - assert.deepEqual(departmentsSdk.body, departmentsHttp.body); - assert.deepEqual(teamsSdk.body, teamsHttp.body); - assert.deepEqual(peopleSdk.body, peopleHttp.body); + const departments = departmentsRpc.body; + const teams = teamsRpc.body; + assert.ok(Array.isArray(departments), "organization.listDepartments must answer a list"); + assert.ok(Array.isArray(teams), "organization.listTeams must answer a list"); assert.deepEqual( - departmentsSdk.body.map(({ departmentId, name }) => ({ departmentId, name })), + departments.map((department) => + Predicate.isObjectOrArray(department) && !Array.isArray(department) + ? { departmentId: department.departmentId, name: department.name } + : department, + ), [{ departmentId: "6701", name: "Spec 0067 Department" }], ); assert.deepEqual( - teamsSdk.body.map(({ teamId, departmentId, name }) => ({ teamId, departmentId, name })), + teams.map((team) => + Predicate.isObjectOrArray(team) && !Array.isArray(team) + ? { teamId: team.teamId, departmentId: team.departmentId, name: team.name } + : team, + ), [{ teamId: "6711", departmentId: "6701", name: "Spec 0067 Team" }], ); artifactCore.http = Schema.decodeUnknownSync( @@ -2180,12 +2266,13 @@ const runRehearsal = async ( status: "Observed", backendRequests, strictNative: { - departments: sanitizeProjection(departmentsSdk.body), - teams: sanitizeProjection(teamsSdk.body), - session: sanitizeProjection(sessionHttp.body), - missingSession: sanitizeProjection(missingSessionHttp.body), - administratorDirectory: sanitizeProjection(peopleSdk.body), + departments: sanitizeProjection(departments), + teams: sanitizeProjection(teams), + session: sanitizeProjection(sessionRpc.body), + missingSession: sanitizeProjection(missingSessionRpc.body), + administratorDirectory: sanitizeProjection(peopleRpc.body), }, + // The typed native RPC client decoded every read with its contract schema. sdkDecoded: true, fixtureMode: false, }, @@ -2324,7 +2411,6 @@ const runRehearsal = async ( "playwright-results", ), ORGANIZATION_IMPORT_REHEARSAL_AUTHORIZATION_INSTANT: SPEC_0067.authorizationInstant, - ORGANIZATION_IMPORT_REHEARSAL_SDK_EFFECT_PATH: join(sdkRoot, "dist/effect-client.js"), ORGANIZATION_IMPORT_REHEARSAL_NATIVE_API_PATHS: JSON.stringify(NATIVE_BROWSER_JOURNEY_PATHS), }; diff --git a/tools/verification/organization-import-rehearsal-postgres.ts b/tools/verification/organization-import-rehearsal-postgres.ts index 3c3129853..da50d76a1 100644 --- a/tools/verification/organization-import-rehearsal-postgres.ts +++ b/tools/verification/organization-import-rehearsal-postgres.ts @@ -5,6 +5,7 @@ import { sha256Hex, } from "@vektorprogrammet/domain/shared-kernel"; import { Schema, Record as EffectRecord, Effect } from "effect"; +import { dual } from "effect/Function"; import type { SqlError } from "effect/unstable/sql/SqlError"; export interface StableTableProjection { @@ -92,19 +93,30 @@ export const stableByteSetEvidence = (state: OrganizationImportStableState) => tables: byteSet.tables, })); -export const compareStableByteSets = ( - before: OrganizationImportStableState, - after: OrganizationImportStableState, -): StableByteSetComparison => - EffectRecord.map(before.byteSets, (left, name) => { - const right = after.byteSets[name]; +export const compareStableByteSets: { + ( + after: OrganizationImportStableState, + ): (before: OrganizationImportStableState) => StableByteSetComparison; + ( + before: OrganizationImportStableState, + after: OrganizationImportStableState, + ): StableByteSetComparison; +} = dual( + 2, + ( + before: OrganizationImportStableState, + after: OrganizationImportStableState, + ): StableByteSetComparison => + EffectRecord.map(before.byteSets, (left, name) => { + const right = after.byteSets[name]; - return { - byteLengthEqual: left.byteLength === right.byteLength, - sha256Equal: left.sha256 === right.sha256, - directBytesEqual: Buffer.from(left.bytes).equals(Buffer.from(right.bytes)), - }; - }); + return { + byteLengthEqual: left.byteLength === right.byteLength, + sha256Equal: left.sha256 === right.sha256, + directBytesEqual: Buffer.from(left.bytes).equals(Buffer.from(right.bytes)), + }; + }), +); export const readOrganizationImportStableState = ( sql: DatabaseOperations, diff --git a/tools/verification/organization-import-rehearsal.test.ts b/tools/verification/organization-import-rehearsal.test.ts index cd649797c..1d8ca54d6 100644 --- a/tools/verification/organization-import-rehearsal.test.ts +++ b/tools/verification/organization-import-rehearsal.test.ts @@ -20,6 +20,7 @@ import { import { makeControlledTestRuntime } from "./controlled-test-runtime.js"; import { NATIVE_BROWSER_JOURNEY_REQUIREMENTS, + nativeBrowserJourneyRequirementOfRpc, SPEC_0067, decodeFrozenOrganizationSnapshot, decodeOrganizationImportRehearsalArtifact, @@ -190,6 +191,11 @@ describe("spec 0067 runtime capability contracts", () => { expect(isNativeBrowserJourneyRequestAllowed("GET", "/api/profile/extra")).toBe(false); expect(isNativeBrowserJourneyRequestAllowed("POST", "/api/teams")).toBe(false); expect(isNativeBrowserJourneyRequestAllowed("GET", "/api/unexpected")).toBe(false); + // Each journey read is one native RPC, recorded as the route that it replaced. + expect(nativeBrowserJourneyRequirementOfRpc("organization.listTeams")?.path).toBe("/api/teams"); + expect(nativeBrowserJourneyRequirementOfRpc("directory.listPeople")?.path).toBe("/api/people"); + expect(nativeBrowserJourneyRequirementOfRpc("organization.createTeam")).toBeUndefined(); + expect(nativeBrowserJourneyRequirementOfRpc(undefined)).toBeUndefined(); expect( boundedCookieCapabilityFailure({ cookieName: SPEC_0067.sessionCookieName, @@ -734,11 +740,15 @@ describe("spec 0067 artifact boundary", () => { }); it("enforces exact production evidence and native authority paths", async () => { - const nativePathObservations = NATIVE_BROWSER_JOURNEY_REQUIREMENTS.map((requirement) => ({ - ...requirement, - status: 200, - sessionCookieAuth: true, - })); + const nativePathObservations = NATIVE_BROWSER_JOURNEY_REQUIREMENTS.map( + ({ path, access, requestSource }) => ({ + path, + access, + requestSource, + status: 200, + sessionCookieAuth: true, + }), + ); const backendProxyRequests = nativePathObservations.map( ({ path, status, sessionCookieAuth, requestSource }) => ({ diff --git a/tools/verification/organization-import-rehearsal.ts b/tools/verification/organization-import-rehearsal.ts index 5c0e469eb..fd6fcb98d 100644 --- a/tools/verification/organization-import-rehearsal.ts +++ b/tools/verification/organization-import-rehearsal.ts @@ -12,6 +12,7 @@ import { sha256Hex, } from "@vektorprogrammet/domain/shared-kernel"; import { flow, Predicate, Data, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import type { DatabaseOperations } from "@vektorprogrammet/database"; import { journeyClock } from "../e2e/journey-clock.js"; @@ -38,14 +39,45 @@ export const SPEC_0067 = { } as const; // The pages that the Chromium journey reads: the team-application chooser and the people directory. +// Each read is one native RPC; `path` is the HTTP route that the RPC replaced, which the evidence +// keeps as the name of the read. export const NATIVE_BROWSER_JOURNEY_REQUIREMENTS = [ - { path: "/api/departments", access: "Public", requestSource: "DashboardSsr" }, - { path: "/api/people", access: "BoundedSession", requestSource: "DashboardSsr" }, - { path: "/api/profile", access: "BoundedSession", requestSource: "DashboardSsr" }, - { path: "/api/session", access: "BoundedSession", requestSource: "DashboardSsr" }, - { path: "/api/teams", access: "Public", requestSource: "DashboardSsr" }, + { + path: "/api/departments", + rpc: "organization.listDepartments", + access: "Public", + requestSource: "DashboardSsr", + }, + { + path: "/api/people", + rpc: "directory.listPeople", + access: "BoundedSession", + requestSource: "DashboardSsr", + }, + { + path: "/api/profile", + rpc: "profile.readOwnProfile", + access: "BoundedSession", + requestSource: "DashboardSsr", + }, + { + path: "/api/session", + rpc: "system.readSession", + access: "BoundedSession", + requestSource: "DashboardSsr", + }, + { + path: "/api/teams", + rpc: "organization.listTeams", + access: "Public", + requestSource: "DashboardSsr", + }, ] as const; +/** The journey read that one native RPC tag names, if the journey reads it. */ +export const nativeBrowserJourneyRequirementOfRpc = (tag: string | undefined) => + NATIVE_BROWSER_JOURNEY_REQUIREMENTS.find((requirement) => requirement.rpc === tag); + export const SPEC_0067_PREREQUISITES = { persons: [ { @@ -583,13 +615,16 @@ const observeStatement = ( }; /** Evidence-only proxy: results, failures, and transaction ownership stay with DatabaseLive. */ -export const observeOrganizationImportSql = ( - sql: DatabaseOperations, - state: OrganizationImportSqlObserverState, -): DatabaseOperations => - observePostgresStatements(sql, (statement, text, values) => { - return observeStatement(statement, text, state, values); - }); +export const observeOrganizationImportSql: { + (state: OrganizationImportSqlObserverState): (sql: DatabaseOperations) => DatabaseOperations; + (sql: DatabaseOperations, state: OrganizationImportSqlObserverState): DatabaseOperations; +} = dual( + 2, + (sql: DatabaseOperations, state: OrganizationImportSqlObserverState): DatabaseOperations => + observePostgresStatements(sql, (statement, text, values) => + observeStatement(statement, text, state, values), + ), +); const NotObservedSectionSchema = Schema.Struct({ status: Schema.Literal("NotObservedDueToFailure"), diff --git a/tools/verification/package.json b/tools/verification/package.json index 7f1a1c7e0..e676f7deb 100644 --- a/tools/verification/package.json +++ b/tools/verification/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "test": "vitest run --no-file-parallelism", - "check-types": "tsc --noEmit", + "check-types": "tsc --noEmit && tsc --noEmit -p tsconfig.bun.json", "rehearsal:organization-import": "bun run organization-import-rehearsal-main.ts", "rehearsal:receipt-import": "bun run receipt-import-rehearsal.ts", "rehearsal:account-cohort": "bun run identity-cohort-rehearsal.ts", @@ -19,13 +19,13 @@ "@vektorprogrammet/backend": "workspace:*", "@vektorprogrammet/database": "workspace:*", "@vektorprogrammet/domain": "workspace:*", - "@vektorprogrammet/http-api": "workspace:*", - "@vektorprogrammet/sdk": "workspace:*", + "@vektorprogrammet/rpc": "workspace:*", "better-auth": "1.7.1", "effect": "catalog:effect-v4", "pg": "8" }, "devDependencies": { + "@effect/vitest": "catalog:effect-v4", "@types/node": "^22", "@types/pg": "^8.23.1", "typescript": "7.0.2", diff --git a/tools/verification/receipt-delivery-observation.ts b/tools/verification/receipt-delivery-observation.ts index 1c343d017..42ce4d1f3 100644 --- a/tools/verification/receipt-delivery-observation.ts +++ b/tools/verification/receipt-delivery-observation.ts @@ -1,311 +1,378 @@ -import { Schema, Predicate } from "effect"; /** 0097 extends the owned 0095 PostgreSQL/API rehearsal after its zero-effect import window. */ import assert from "node:assert/strict"; -import { observeReceiptReopening } from "./receipt-reopen-observation.js"; -import { createServer } from "node:http"; -import { spawn } from "node:child_process"; +import { Buffer } from "node:buffer"; import { randomBytes, randomUUID } from "node:crypto"; +import { Config, Data, Deferred, type Duration, Effect, Option, Predicate, Schema } from "effect"; +import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { ChildProcess } from "effect/unstable/process"; +import { ReceiptId } from "@vektorprogrammet/rpc"; +import { IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; +import { observeReceiptReopening } from "./receipt-reopen-observation.js"; +import { jsonText, step } from "../acceptance/journey-step.js"; import type { Pool } from "pg"; -export const observeReceiptDelivery = async (options: { - pool: Pool; - env: NodeJS.ProcessEnv; - origin: string; - dashboardOrigin: string; - cookie: string; - approverCookie: string; - root: string; - artifactDirectory: string; - registerSecret: (secret: string) => void; - restart: (env: NodeJS.ProcessEnv) => Promise; -}) => { - const { pool, origin, dashboardOrigin, cookie, approverCookie } = options; - const token = randomBytes(24).toString("hex"); - options.registerSecret(token); - let mode: "accept" | "reject" | "ambiguous" | "redirect" = "accept"; - - const attempts: Array<{ - deliveryId: string; - from: string; - to: string; - subject: string; - text: string; - }> = []; - - const accepted = new Map(); - let redirected = 0; - - const sink = createServer(async (req, res) => { - if (req.url === "/uncontrolled") { - redirected++; - res.writeHead(500).end(); - - return; - } - - if (req.url !== "/accept" || req.headers.authorization !== `Bearer ${token}`) { - res.writeHead(401).end(); - - return; - } - - const chunks: Buffer[] = []; - - for await (const chunk of req) chunks.push(Buffer.from(chunk)); - const raw = Buffer.concat(chunks).toString(); - const body = JSON.parse(raw); - assert.equal(req.headers["idempotency-key"], body.deliveryId); - assert.ok(!/ciphertext|paymentAccount|objectKey|fileRef|synthetic:/.test(raw)); - attempts.push(body); - - if (mode === "redirect") { - res.writeHead(307, { location: "/uncontrolled" }).end(); - - return; - } - - if (mode === "reject") { - res.writeHead(503).end(); - - return; - } - - if (accepted.has(body.deliveryId) && accepted.get(body.deliveryId) !== raw) { - res.writeHead(409).end(); - - return; - } - - accepted.set(body.deliveryId, raw); - - if (mode === "ambiguous") return; // accepted remotely; intentionally withhold acknowledgement - res.writeHead(202).end(); - }); - - await new Promise((resolve) => sink.listen(0, "127.0.0.1", resolve)); - const address = sink.address(); - assert.ok(address !== null && !Predicate.isString(address)); - const port = address.port; - - const env = { - ...options.env, - RECEIPT_DELIVERY_URL: `http://127.0.0.1:${port}/accept`, - RECEIPT_DELIVERY_TOKEN: token, - RECEIPT_DELIVERY_TIMEOUT_MS: "100", - RECEIPT_DELIVERY_SENDER: "economy0097@example.invalid", - RECEIPT_DELIVERY_ECONOMY_RECIPIENTS: JSON.stringify({ - "receipt-department-0095": "finance0097@example.invalid", - }), - }; - - const operatorDrain = ( - receiptId: string, - selectedEnv: Readonly> = env, - ) => - new Promise((resolve, reject) => { - const child = spawn("bun", ["run", "apps/backend/src/receipt/drain-main.ts", receiptId], { - cwd: options.root, - env: selectedEnv, - stdio: "ignore", - }); - - const timer = setTimeout(() => { - child.kill("SIGTERM"); - reject(new Error("bounded drain timeout")); - }, 30_000); +/** A bounded operator drain that outlived its deadline. */ +class ReceiptDrainTimeout extends Data.TaggedError("ReceiptDrainTimeout")<{ + readonly message: string; +}> {} + +/** The envelope fields that the observation reads; the sink keeps every field of the body. */ +const DeliveryEnvelope = Schema.Struct({ + deliveryId: Schema.String, + from: Schema.String, + to: Schema.String, + subject: Schema.String, + text: Schema.String, +}); + +const decodeEnvelope = Schema.decodeEffect(Schema.fromJsonString(DeliveryEnvelope)); + +const decodeBody = Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Json)), +); + +const drainDeadline: Duration.Input = "30 seconds"; - child.on("error", reject); - child.on("exit", (code) => { - clearTimeout(timer); - resolve(code ?? 1); +/** The options of the delivery observation, from the 0095 rehearsal that owns the runtime. */ +export interface ReceiptDeliveryOptions { + readonly pool: Pool; + readonly env: Readonly>; + readonly origin: string; + readonly dashboardOrigin: string; + readonly cookie: string; + readonly approverCookie: string; + readonly root: string; + readonly artifactDirectory: string; + readonly registerSecret: (secret: string) => void; + /** Restarts the owned backend with the environment given, once it answers its health check. */ + readonly restart: ( + env: Readonly>, + ) => Effect.Effect; +} + +/** + * Observes acknowledged receipt delivery through a loopback sink on the `HttpServer` that the + * entry provides, operator drains, and approvals, then the 0102 reopening when it is enabled. + */ +export const observeReceiptDelivery = (options: ReceiptDeliveryOptions) => + Effect.scoped( + Effect.gen(function* () { + const { pool, origin, dashboardOrigin, cookie, approverCookie } = options; + const token = randomBytes(24).toString("hex"); + options.registerSecret(token); + let mode: "accept" | "reject" | "ambiguous" | "redirect" = "accept"; + + const attempts: Array>> = + []; + + const accepted = new Map(); + let redirected = 0; + + // A request that breaks a sink assertion answers 500; the observation fails on it at the end. + const sinkFailures: string[] = []; + + const withheld = yield* Deferred.make(); + + const receive = Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + + if (req.url === "/uncontrolled") { + redirected++; + + return HttpServerResponse.empty({ status: 500 }); + } + + if (req.url !== "/accept" || req.headers["authorization"] !== `Bearer ${token}`) + return HttpServerResponse.empty({ status: 401 }); + + const raw = Buffer.from(yield* req.arrayBuffer).toString(); + const envelope = yield* decodeEnvelope(raw); + const body = { ...(yield* decodeBody(raw)), ...envelope }; + assert.equal(req.headers["idempotency-key"], body.deliveryId); + assert.ok(!/ciphertext|paymentAccount|objectKey|fileRef|synthetic:/.test(raw)); + attempts.push(body); + + if (mode === "redirect") + return HttpServerResponse.empty({ status: 307, headers: { location: "/uncontrolled" } }); + + if (mode === "reject") return HttpServerResponse.empty({ status: 503 }); + + if (accepted.has(body.deliveryId) && accepted.get(body.deliveryId) !== raw) + return HttpServerResponse.empty({ status: 409 }); + + accepted.set(body.deliveryId, raw); + + // Accepted remotely; intentionally withhold acknowledgement until the observation ends, + // long after the backend gave up on the request. + if (mode === "ambiguous") { + yield* Deferred.await(withheld); + + return HttpServerResponse.empty({ status: 202 }); + } + + return HttpServerResponse.empty({ status: 202 }); + }).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + sinkFailures.push(String(cause)); + + return HttpServerResponse.empty({ status: 500 }); + }), + ), + ); + + yield* HttpServer.serveEffect(receive); + + // Runs before the sink stops serving, so that no withheld request keeps the server open. + yield* Effect.addFinalizer(() => Deferred.succeed(withheld, undefined)); + + const { address } = yield* HttpServer.HttpServer; + + if (Predicate.isTagged(address, "UnixPathAddress")) + assert.fail("loopback sink binds a TCP port"); + + const port = address.port; + + const env = { + ...options.env, + RECEIPT_DELIVERY_URL: `http://127.0.0.1:${port}/accept`, + RECEIPT_DELIVERY_TOKEN: token, + RECEIPT_DELIVERY_TIMEOUT_MS: "100", + RECEIPT_DELIVERY_SENDER: "economy0097@example.invalid", + RECEIPT_DELIVERY_ECONOMY_RECIPIENTS: yield* jsonText({ + "receipt-department-0095": "finance0097@example.invalid", + }), + }; + + /** The exit code of one bounded operator drain; an exit on a signal counts as 1. */ + const operatorDrain = ( + receiptId: string, + selectedEnv: Readonly> = env, + ) => + Effect.scoped( + Effect.gen(function* () { + const child = yield* ChildProcess.make( + "bun", + ["run", "apps/backend/src/receipt/drain-main.ts", receiptId], + { + cwd: options.root, + env: { ...selectedEnv }, + extendEnv: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ); + + return yield* child.exitCode.pipe(Effect.orElseSucceed(() => 1)); + }), + ).pipe( + Effect.timeoutOrElse({ + duration: drainDeadline, + orElse: () => + Effect.fail(new ReceiptDrainTimeout({ message: "bounded drain timeout" })), + }), + ); + + const query = (text: string, values?: ReadonlyArray) => + step(() => pool.query(text, values === undefined ? undefined : [...values])); + + const outbox = (id: string) => + query( + "SELECT effect_id,status,attempts,delivery_envelope,payload_json FROM economy_receipt_outbox WHERE receipt_id=$1 ORDER BY ordinal", + [id], + ).pipe(Effect.map((result) => result.rows)); + + const keys = new Map(); + + const identity = (key: string) => { + if (!keys.has(key)) keys.set(key, randomUUID()); + + return keys.get(key)!; + }; + + const native = yield* Effect.acquireRelease( + Effect.sync(() => nativeScriptClient(origin)), + (client) => Effect.promise(() => client.dispose()), + ); + + const as = (session: string) => ({ cookie: session, origin: dashboardOrigin }); + + /** One approval or rejection, as the session given, answered with its registry status. */ + const transition = ( + action: "approve" | "reject", + session: string, + key: string, + receipt: { readonly id: string; readonly etag: string }, + ) => + step(() => + native.call(as(session), (client) => { + const payload = { + receiptId: ReceiptId.make(receipt.id), + idempotencyKey: IdempotencyKey.make(identity(key)), + ifMatch: StrongETag.make(receipt.etag), + }; + + return action === "approve" + ? client["receipts.approveReceipt"](payload) + : client["receipts.rejectReceipt"](payload); + }), + ); + + const submit = Effect.fnUntraced(function* (key: string) { + const result = yield* step(() => + native.call(as(cookie), (client) => + client["receipts.submitReceipt"]({ + idempotencyKey: IdempotencyKey.make(identity(key)), + departmentId: "receipt-department-0095", + request: { + description: "Synthetic acknowledged delivery", + amountOre: 500, + receiptDate: "2026-09-06", + file: { + contentType: "application/pdf", + bytes: new TextEncoder().encode("%PDF-1.4\nSynthetic 0097\n%%EOF"), + }, + }, + }), + ), + ); + + assert.ok(result.ok, `synthetic submission answered ${result.status}`); + assert.ok(result.value.receiptId.length > 0); + + return { id: result.value.receiptId, etag: result.value.etag }; }); - }); - const outbox = async (id: string) => - ( - await pool.query( - "SELECT effect_id,status,attempts,delivery_envelope,payload_json FROM economy_receipt_outbox WHERE receipt_id=$1 ORDER BY ordinal", - [id], + yield* query( + "INSERT INTO person_contact_profiles(person_id,email,phone) VALUES ('receipt-owner-0095','owner0095@example.invalid','90000000'),('receipt-foreign-0095','foreign0095@example.invalid','90000001')", + ); + yield* query( + "INSERT INTO organization_teams(team_id,department_id,name) VALUES ('receipt0097-team','receipt-department-0095','Synthetic economy team')", + ); + yield* query( + "INSERT INTO organization_memberships(membership_id,person_id,team_id,start_at,end_at,position_id,is_team_leader) VALUES ('receipt0097-owner-membership','receipt-owner-0095','receipt0097-team','2026-01-01',NULL,NULL,FALSE),('receipt0097-approver-membership','receipt-foreign-0095','receipt0097-team','2026-01-01',NULL,NULL,FALSE)", + ); + yield* query( + `INSERT INTO economy_payment_authorities(payment_authority_id,person_id,department_id,payment_account_ciphertext,start_at,revision) VALUES ('receipt0097-submit','receipt-owner-0095','receipt-department-0095','synthetic:0097','2026-01-01',0)`, + ); + yield* query( + `INSERT INTO economy_receipt_approval_grants(approval_grant_id,person_id,scope,department_id,start_at,revision) VALUES ('receipt0097-approve','receipt-foreign-0095','Department','receipt-department-0095','2026-01-01',0)`, + ); + // First observe the unconfigured composition: successful business write cannot fake delivery. + const missing = yield* submit("receipt0097-missing"); + assert.ok((yield* outbox(missing.id)).some((r) => r.status === "Failed")); + assert.equal(attempts.length, 0); + yield* options.restart(env); + assert.equal(yield* operatorDrain("nonexistent-0097"), 1); + assert.equal(yield* operatorDrain(missing.id, { ...env, RECEIPT_COMMITTED_ROOT: "" }), 1); + assert.equal(yield* operatorDrain(missing.id), 0); + assert.ok((yield* outbox(missing.id)).every((r) => r.status === "Delivered")); + assert.equal(attempts[0]!.to, "finance0097@example.invalid"); + + const commandCount = Number( + (yield* query("SELECT count(*) FROM economy_receipt_command_receipts WHERE receipt_id=$1", [ + missing.id, + ])).rows[0].count, + ); + + assert.equal(commandCount, 1); + mode = "reject"; + const failed = yield* submit("receipt0097-rejection"); + assert.ok((yield* outbox(failed.id)).some((r) => r.status === "Failed")); + const first = attempts.at(-1)!; + + const changedEnv = { + ...env, + RECEIPT_DELIVERY_ECONOMY_RECIPIENTS: yield* jsonText({ + "receipt-department-0095": "changed@example.invalid", + }), + }; + + mode = "accept"; + yield* options.restart(changedEnv); + assert.equal(yield* operatorDrain(failed.id, changedEnv), 0); + assert.deepEqual(attempts.at(-1), first); + yield* step(() => + assert.rejects( + pool.query( + "UPDATE economy_receipt_outbox SET delivery_envelope=NULL WHERE effect_id=$1", + [first.deliveryId], + ), + ), + ); + // Approval uses owner contact, then freezes it across ambiguous acceptance and restart. + mode = "ambiguous"; + + const approval = yield* transition("approve", approverCookie, "receipt0097-approve", failed); + + assert.equal(approval.status, 200, yield* jsonText(approval)); + assert.ok((yield* outbox(failed.id)).some((r) => r.status === "Failed")); + const approvalEnvelope = attempts.at(-1)!; + assert.equal(approvalEnvelope.to, "owner0095@example.invalid"); + assert.equal( + (yield* outbox(failed.id)).find((row) => row.effect_id === approvalEnvelope.deliveryId) + ?.payload_json._tag, + "NotifyReceiptApproved", + ); + assert.match(approvalEnvelope.text, /5.00 NOK/); + assert.match(approvalEnvelope.text, /Synthetic acknowledged delivery/); + assert.match(approvalEnvelope.text, /2026-09-06/); + yield* query( + "UPDATE person_contact_profiles SET email='changed-owner@example.invalid' WHERE person_id='receipt-owner-0095'", + ); + mode = "accept"; + yield* options.restart(env); + assert.equal(yield* operatorDrain(failed.id), 0); + assert.deepEqual(attempts.at(-1), approvalEnvelope); + assert.equal( + [...accepted.keys()].filter((id) => id === approvalEnvelope.deliveryId).length, + 1, + ); + // Concurrent bounded drains converge without repeating the underlying receipt mutation. + mode = "reject"; + const concurrent = yield* submit("receipt0097-concurrent"); + yield* query( + "UPDATE economy_receipt_outbox SET status='Processing',claim_id='crashed0097',claimed_at=date_trunc('milliseconds',now(),'UTC')-interval '2 minutes' WHERE receipt_id=$1 AND status='Failed'", + [concurrent.id], + ); + mode = "accept"; + + const concurrentResults = yield* Effect.all( + [operatorDrain(concurrent.id), operatorDrain(concurrent.id)], + { concurrency: "unbounded" }, + ); + + assert.ok(concurrentResults.some((code) => code === 0)); + assert.ok((yield* outbox(concurrent.id)).every((r) => r.status === "Delivered")); + mode = "redirect"; + + const rejected = yield* transition( + "reject", + approverCookie, + "receipt0097-reject", + concurrent, + ); + + assert.equal(rejected.status, 200, yield* jsonText(rejected)); + assert.ok((yield* outbox(concurrent.id)).some((r) => r.status === "Failed")); + assert.equal(redirected, 0); + mode = "accept"; + assert.equal( + yield* operatorDrain(concurrent.id, { ...env, RECEIPT_DELIVERY_TOKEN: "wrong" }), + 1, + ); + assert.equal(yield* operatorDrain(concurrent.id), 0); + assert.equal(attempts.at(-1)!.to, "changed-owner@example.invalid"); + assert.match(attempts.at(-1)!.subject, /avvist/); + assert.match(attempts.at(-1)!.text, /Kontakt økonomiansvarlig/); + + const reopening = Option.contains( + yield* Config.option(Config.String("RECEIPT_REOPEN_REHEARSAL")), + "1", ) - ).rows; - - const keys = new Map(); - - const identity = (key: string) => { - if (!keys.has(key)) keys.set(key, randomUUID()); - - return keys.get(key)!; - }; - - const headers = (session: string, key: string, etag?: string) => { - const result = new Headers({ - cookie: session, - origin: dashboardOrigin, - "idempotency-key": identity(key), - }); - - if (etag) { - result.set("if-match", etag); - result.set("content-type", "application/json"); - } - - return result; - }; - - const submit = async (key: string) => { - const form = new FormData(); - form.set("description", "Synthetic acknowledged delivery"); - form.set("amountOre", "500"); - form.set("receiptDate", "2026-09-06"); - form.set( - "file", - new File(["%PDF-1.4\nSynthetic 0097\n%%EOF"], "receipt.pdf", { type: "application/pdf" }), - ); - - const response = await fetch(`${origin}/api/receipts?departmentId=receipt-department-0095`, { - method: "POST", - headers: headers(cookie, key), - body: form, - }); - - assert.equal(response.status, 201, await response.clone().text()); - - const body = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json))( - await response.json(), - ); - - assert.ok(Predicate.isString(body.receiptId) && body.receiptId.length > 0); - - return { id: body.receiptId, etag: response.headers.get("etag")! }; - }; - - try { - await pool.query( - "INSERT INTO person_contact_profiles(person_id,email,phone) VALUES ('receipt-owner-0095','owner0095@example.invalid','90000000'),('receipt-foreign-0095','foreign0095@example.invalid','90000001')", - ); - await pool.query( - "INSERT INTO organization_teams(team_id,department_id,name) VALUES ('receipt0097-team','receipt-department-0095','Synthetic economy team')", - ); - await pool.query( - "INSERT INTO organization_memberships(membership_id,person_id,team_id,start_at,end_at,position_id,is_team_leader) VALUES ('receipt0097-owner-membership','receipt-owner-0095','receipt0097-team','2026-01-01',NULL,NULL,FALSE),('receipt0097-approver-membership','receipt-foreign-0095','receipt0097-team','2026-01-01',NULL,NULL,FALSE)", - ); - await pool.query( - `INSERT INTO economy_payment_authorities(payment_authority_id,person_id,department_id,payment_account_ciphertext,start_at,revision) VALUES ('receipt0097-submit','receipt-owner-0095','receipt-department-0095','synthetic:0097','2026-01-01',0)`, - ); - await pool.query( - `INSERT INTO economy_receipt_approval_grants(approval_grant_id,person_id,scope,department_id,start_at,revision) VALUES ('receipt0097-approve','receipt-foreign-0095','Department','receipt-department-0095','2026-01-01',0)`, - ); - // First observe the unconfigured composition: successful business write cannot fake delivery. - const missing = await submit("receipt0097-missing"); - assert.ok((await outbox(missing.id)).some((r) => r.status === "Failed")); - assert.equal(attempts.length, 0); - await options.restart(env); - assert.equal(await operatorDrain("nonexistent-0097"), 1); - assert.equal(await operatorDrain(missing.id, { ...env, RECEIPT_COMMITTED_ROOT: "" }), 1); - assert.equal(await operatorDrain(missing.id), 0); - assert.ok((await outbox(missing.id)).every((r) => r.status === "Delivered")); - assert.equal(attempts[0]!.to, "finance0097@example.invalid"); - - const commandCount = Number( - ( - await pool.query( - "SELECT count(*) FROM economy_receipt_command_receipts WHERE receipt_id=$1", - [missing.id], - ) - ).rows[0].count, - ); - - assert.equal(commandCount, 1); - mode = "reject"; - const failed = await submit("receipt0097-rejection"); - assert.ok((await outbox(failed.id)).some((r) => r.status === "Failed")); - const first = attempts.at(-1)!; - - const changedEnv = { - ...env, - RECEIPT_DELIVERY_ECONOMY_RECIPIENTS: JSON.stringify({ - "receipt-department-0095": "changed@example.invalid", - }), - }; - - mode = "accept"; - await options.restart(changedEnv); - assert.equal(await operatorDrain(failed.id, changedEnv), 0); - assert.deepEqual(attempts.at(-1), first); - await assert.rejects( - pool.query("UPDATE economy_receipt_outbox SET delivery_envelope=NULL WHERE effect_id=$1", [ - first.deliveryId, - ]), - ); - // Approval uses owner contact, then freezes it across ambiguous acceptance and restart. - mode = "ambiguous"; - - const approval = await fetch(`${origin}/api/receipts/${failed.id}:approve`, { - method: "POST", - headers: headers(approverCookie, "receipt0097-approve", failed.etag), - body: "{}", - }); - - assert.equal(approval.status, 200, await approval.clone().text()); - assert.ok((await outbox(failed.id)).some((r) => r.status === "Failed")); - const approvalEnvelope = attempts.at(-1)!; - assert.equal(approvalEnvelope.to, "owner0095@example.invalid"); - assert.equal( - (await outbox(failed.id)).find((row) => row.effect_id === approvalEnvelope.deliveryId) - ?.payload_json._tag, - "NotifyReceiptApproved", - ); - assert.match(approvalEnvelope.text, /5.00 NOK/); - assert.match(approvalEnvelope.text, /Synthetic acknowledged delivery/); - assert.match(approvalEnvelope.text, /2026-09-06/); - await pool.query( - "UPDATE person_contact_profiles SET email='changed-owner@example.invalid' WHERE person_id='receipt-owner-0095'", - ); - mode = "accept"; - await options.restart(env); - assert.equal(await operatorDrain(failed.id), 0); - assert.deepEqual(attempts.at(-1), approvalEnvelope); - assert.equal([...accepted.keys()].filter((id) => id === approvalEnvelope.deliveryId).length, 1); - // Concurrent bounded drains converge without repeating the underlying receipt mutation. - mode = "reject"; - const concurrent = await submit("receipt0097-concurrent"); - await pool.query( - "UPDATE economy_receipt_outbox SET status='Processing',claim_id='crashed0097',claimed_at=date_trunc('milliseconds',now(),'UTC')-interval '2 minutes' WHERE receipt_id=$1 AND status='Failed'", - [concurrent.id], - ); - mode = "accept"; - - const concurrentResults = await Promise.all([ - operatorDrain(concurrent.id), - operatorDrain(concurrent.id), - ]); - - assert.ok(concurrentResults.some((code) => code === 0)); - assert.ok((await outbox(concurrent.id)).every((r) => r.status === "Delivered")); - mode = "redirect"; - - const rejected = await fetch(`${origin}/api/receipts/${concurrent.id}:reject`, { - method: "POST", - headers: headers(approverCookie, "receipt0097-reject", concurrent.etag), - body: "{}", - }); - - assert.equal(rejected.status, 200, await rejected.clone().text()); - assert.ok((await outbox(concurrent.id)).some((r) => r.status === "Failed")); - assert.equal(redirected, 0); - mode = "accept"; - assert.equal( - await operatorDrain(concurrent.id, { ...env, RECEIPT_DELIVERY_TOKEN: "wrong" }), - 1, - ); - assert.equal(await operatorDrain(concurrent.id), 0); - assert.equal(attempts.at(-1)!.to, "changed-owner@example.invalid"); - assert.match(attempts.at(-1)!.subject, /avvist/); - assert.match(attempts.at(-1)!.text, /Kontakt økonomiansvarlig/); - - const reopening = - process.env.RECEIPT_REOPEN_REHEARSAL === "1" - ? await observeReceiptReopening({ + ? yield* observeReceiptReopening({ pool, origin, dashboardOrigin, @@ -321,55 +388,50 @@ export const observeReceiptDelivery = async (options: { }) : undefined; - const countBefore = Number( - (await pool.query("SELECT count(*) FROM economy_receipt_command_receipts")).rows[0].count, - ); - - await pool.query( - "UPDATE economy_receipt_approval_grants SET end_at=date_trunc('milliseconds',now(),'UTC'),revision=revision+1 WHERE approval_grant_id='receipt0097-approve'", - ); - - const denied = await fetch(`${origin}/api/receipts/${missing.id}:reject`, { - method: "POST", - headers: headers(approverCookie, "receipt0097-denied", missing.etag), - body: "{}", - }); - - assert.equal(denied.status, 403); - assert.equal( - Number( - (await pool.query("SELECT count(*) FROM economy_receipt_command_receipts")).rows[0].count, - ), - countBefore, - ); - const audit = (await pool.query("SELECT count(*) FROM economy_receipt_audit")).rows[0].count; - assert.equal(Number(audit), countBefore); - - return { - specId: "0097", - reopening, - transportAttempts: attempts.length, - distinctAccepted: accepted.size, - submissionEconomyRecipient: true, - approvalOwnerRecipient: true, - rejectionOwnerRecipient: true, - missingConfigurationPending: true, - forcedRejectionRetry: true, - ambiguousAcceptanceStableRetryAfterRestart: true, - immutableEnvelope: true, - staleCrashClaimRecovered: true, - concurrentDrains: concurrentResults, - redirectDestinationsReached: redirected, - invalidTokenRejected: true, - revokedApprovalDenied: denied.status, - businessCommands: countBefore, - sqlAuditFacts: Number(audit), - historicalImportWindowAttempts: 0, - scope: - "local synthetic; 2xx means transport acceptance, not human receipt; receiver deduplication required; at-least-once retry", - }; - } finally { - sink.closeAllConnections(); - await new Promise((resolve) => sink.close(() => resolve())); - } -}; + const countBefore = Number( + (yield* query("SELECT count(*) FROM economy_receipt_command_receipts")).rows[0].count, + ); + + yield* query( + "UPDATE economy_receipt_approval_grants SET end_at=date_trunc('milliseconds',now(),'UTC'),revision=revision+1 WHERE approval_grant_id='receipt0097-approve'", + ); + + const denied = yield* transition("reject", approverCookie, "receipt0097-denied", missing); + + assert.equal(denied.status, 403); + assert.equal( + Number( + (yield* query("SELECT count(*) FROM economy_receipt_command_receipts")).rows[0].count, + ), + countBefore, + ); + const audit = (yield* query("SELECT count(*) FROM economy_receipt_audit")).rows[0].count; + assert.equal(Number(audit), countBefore); + + assert.deepEqual(sinkFailures, [], "every sink request met its assertions"); + + return { + specId: "0097", + reopening, + transportAttempts: attempts.length, + distinctAccepted: accepted.size, + submissionEconomyRecipient: true, + approvalOwnerRecipient: true, + rejectionOwnerRecipient: true, + missingConfigurationPending: true, + forcedRejectionRetry: true, + ambiguousAcceptanceStableRetryAfterRestart: true, + immutableEnvelope: true, + staleCrashClaimRecovered: true, + concurrentDrains: concurrentResults, + redirectDestinationsReached: redirected, + invalidTokenRejected: true, + revokedApprovalDenied: denied.status, + businessCommands: countBefore, + sqlAuditFacts: Number(audit), + historicalImportWindowAttempts: 0, + scope: + "local synthetic; 2xx means transport acceptance, not human receipt; receiver deduplication required; at-least-once retry", + }; + }), + ); diff --git a/tools/verification/receipt-import-rehearsal.ts b/tools/verification/receipt-import-rehearsal.ts index b8a4e6c4b..749b2819c 100644 --- a/tools/verification/receipt-import-rehearsal.ts +++ b/tools/verification/receipt-import-rehearsal.ts @@ -1,40 +1,42 @@ +/** 0095: owned local PostgreSQL/auth/RPC/files import and restore rehearsal. */ +import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; import { canonicalJsonBytes, canonicalJsonValue, sha256Hex, } from "@vektorprogrammet/domain/shared-kernel"; import { ReceiptOutboxRequestSchema } from "@vektorprogrammet/domain/receipt"; -/** 0095: owned local PostgreSQL/auth/SDK/files import and restore rehearsal. */ import assert from "node:assert/strict"; -import { observeReceiptDelivery } from "./receipt-delivery-observation.js"; -import { spawn, execFileSync, type ChildProcess } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { - mkdtemp, - mkdir, - readFile, - writeFile, - rm, - cp, - symlink, - chmod, - readdir, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; import { Buffer } from "node:buffer"; -import process from "node:process"; -import { Pool } from "pg"; +import { randomBytes } from "node:crypto"; +import { observeReceiptDelivery } from "./receipt-delivery-observation.js"; +import { Pool, type QueryResultRow } from "pg"; +import { postgresProgram, reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; import { - type DisposablePostgres, - postgresProgram, - reserveLoopbackPorts, - startDisposablePostgres, -} from "@monoweb/postgres"; -import * as BunServices from "@effect/platform-bun/BunServices"; -import { Console, Schema, Cause, Predicate, Effect, Layer, Redacted, Struct } from "effect"; + Cause, + Config, + Console, + Data, + DateTime, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Predicate, + Redacted, + Schema, + Scope, + Stream, + Struct, +} from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { ChildProcess, type ChildProcessSpawner } from "effect/unstable/process"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import { DatabaseLive } from "@vektorprogrammet/database/live"; -import { Database, databaseHealth } from "@vektorprogrammet/database"; import { storeReceiptImportResult, reconcileReceiptImport, @@ -47,7 +49,7 @@ import { import { ReceiptFileService } from "@vektorprogrammet/domain/receipt"; import type { ReceiptImportResult } from "@vektorprogrammet/domain/receipt"; import { ReceiptId } from "@vektorprogrammet/domain/receipt"; -import { createPromiseClient } from "../../packages/sdk/src/promise.js"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { ReceiptFileStoreResource, ReceiptFileStoreLive, @@ -58,796 +60,897 @@ import { rowDigest, prepareReceiptSnapshot, } from "@vektorprogrammet/backend/receipt/import-snapshot"; +import { commandOutput, indentedJsonText } from "../acceptance/acceptance-process.js"; +import { firstSetCookie, jsonText, step, surfaceStepFailure } from "../acceptance/journey-step.js"; + +/** The rehearsal failed; its sanitized evidence names the cause. */ +class ReceiptRehearsalFailed extends Data.TaggedError("ReceiptRehearsalFailed")<{ + readonly message: string; +}> {} + +/** A step of the rehearsal that outlived its bound. */ +class ReceiptRehearsalTimeout extends Data.TaggedError("ReceiptRehearsalTimeout")<{ + readonly message: string; +}> {} + +/** One `pg` query as a step; a rejection fails the step with the original error. */ +// oxlint-disable-next-line typescript/no-explicit-any -- pg types an untyped row as any, as the calls did +const query = ( + target: Pool, + text: string, + values?: ReadonlyArray, +) => step(() => target.query(text, values === undefined ? undefined : [...values])); + +/** Asserts that the effect fails or dies, as `assert.rejects` did for its promise. */ +const rejects = (effect: Effect.Effect) => + Effect.exit(effect).pipe( + Effect.map((exit) => assert.ok(Exit.isFailure(exit), "Missing expected rejection.")), + ); -const root = resolve(import.meta.dirname, "../.."); +/** Whether the owned backend answers its health check with 200; a failure fails the check. */ +const healthAnswers200 = (origin: string) => + HttpClient.get(`${origin}/health`).pipe( + Effect.map((response) => assert.equal(response.status, 200)), + ); -const command = (name: string, args: string[], env = process.env) => - execFileSync(name, args, { - cwd: root, - env, - encoding: "utf8", - timeout: 60_000, - stdio: ["ignore", "pipe", "pipe"], - }); +/** Retries the check every 100 ms, 150 times, as the owned runtime starts. */ +const wait = (check: Effect.Effect) => + Effect.gen(function* () { + for (let n = 0; n < 150; n++) { + const exit = yield* Effect.exit(check); -const revision = command("git", ["rev-parse", "HEAD"]).trim(); + if (Exit.isSuccess(exit)) return; -assert.equal( - command("git", ["status", "--porcelain"]).trim(), - "", - "committed clean artifact required", -); + yield* Effect.sleep("100 millis"); + } -for (const key of [ - "RECEIPT_DELIVERY_URL", - "RECEIPT_DELIVERY_TOKEN", - "CONTACT_DELIVERY_URL", - "CONTACT_DELIVERY_TOKEN", - "PUBLIC_APPLICATION_EFFECT_ENDPOINT", - "PUBLIC_APPLICATION_EFFECT_TOKEN", -]) - assert.ok(!process.env[key], `unset provider configuration: ${key}`); + return yield* new ReceiptRehearsalTimeout({ message: "owned runtime startup timed out" }); + }); -const artifacts = await mkdtemp(join(tmpdir(), "vektor-receipt-0095-")); +type OwnedChild = ChildProcessSpawner.ChildProcessHandle; -const storage = join(artifacts, "storage"); +const rehearsal = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const join = path.join; + const root = path.resolve(import.meta.dirname, "../.."); -const fixtureRoot = join(artifacts, "source"); + const command = ( + name: string, + args: ReadonlyArray, + env: Readonly> = {}, + ) => + commandOutput({ command: name, args, cwd: root, env, deadline: "60 seconds", stderr: "pipe" }); -await mkdir(fixtureRoot); + const revision = (yield* command("git", ["rev-parse", "HEAD"])).trim(); -await mkdir(storage); + assert.equal( + (yield* command("git", ["status", "--porcelain"])).trim(), + "", + "committed clean artifact required", + ); -const children: ChildProcess[] = []; + for (const key of [ + "RECEIPT_DELIVERY_URL", + "RECEIPT_DELIVERY_TOKEN", + "CONTACT_DELIVERY_URL", + "CONTACT_DELIVERY_TOKEN", + "PUBLIC_APPLICATION_EFFECT_ENDPOINT", + "PUBLIC_APPLICATION_EFFECT_TOKEN", + ]) + assert.ok( + Option.getOrElse(yield* Config.option(Config.String(key)), () => "") === "", + `unset provider configuration: ${key}`, + ); -const logs: string[] = []; + const reopenRehearsal = Option.contains( + yield* Config.option(Config.String("RECEIPT_REOPEN_REHEARSAL")), + "1", + ); -const secretValues: string[] = []; + const artifacts = yield* fileSystem.makeTempDirectory({ prefix: "vektor-receipt-0095-" }); -const safe = (value: string) => - secretValues.reduce((text, secret) => text.replaceAll(secret, "[redacted]"), value); + const storage = join(artifacts, "storage"); -const pause = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const fixtureRoot = join(artifacts, "source"); -const start = (name: string, args: string[], env: NodeJS.ProcessEnv) => { - const child = spawn(name, args, { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] }); - children.push(child); - child.stdout?.on("data", () => {}); - child.stderr?.on("data", (b) => { - logs.push(String(b)); + yield* fileSystem.makeDirectory(fixtureRoot); - if (logs.length > 30) logs.shift(); - }); + yield* fileSystem.makeDirectory(storage); - return child; -}; - -const stop = async (child: ChildProcess) => { - if (child.exitCode !== null || child.signalCode !== null) return; - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => child.kill("SIGKILL"), 5000); - const bound = setTimeout(() => reject(new Error("owned child did not exit")), 15000); - child.once("exit", () => { - clearTimeout(timeout); - clearTimeout(bound); - resolve(); - }); - child.kill("SIGTERM"); - }); -}; + const logs: string[] = []; -const wait = async (check: () => Promise) => { - for (let n = 0; n < 150; n++) { - try { - await check(); + const secretValues: string[] = []; - return; - } catch { - await pause(100); - } - } + const safe = (value: string) => + secretValues.reduce((text, secret) => text.replaceAll(secret, "[redacted]"), value); - throw new Error("owned runtime startup timed out"); -}; + const main = Effect.gen(function* () { + const rehearsalScope = yield* Effect.scope; -let postgres: DisposablePostgres | undefined; + /** Starts an owned child in the rehearsal scope; it keeps the last 30 chunks of standard error. */ + const start = ( + name: string, + args: ReadonlyArray, + env: Readonly>, + ) => + Effect.gen(function* () { + const child = yield* ChildProcess.make(name, args, { + cwd: root, + env: { ...env }, + extendEnv: true, + stdin: "ignore", + forceKillAfter: "5 seconds", + }); + + yield* child.stdout.pipe(Stream.runDrain, Effect.ignore, Effect.forkScoped); + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((text) => + Effect.sync(() => { + logs.push(text); -let pool: InstanceType | undefined; + if (logs.length > 30) logs.shift(); + }), + ), + Effect.ignore, + Effect.forkScoped, + ); -let backend: ChildProcess | undefined; + return child; + }).pipe(Effect.provideService(Scope.Scope, rehearsalScope)); -let evidence: Schema.Json | undefined; + /** Stops an owned child: SIGTERM, SIGKILL after five seconds, and a failure after fifteen. */ + const stop = (child: OwnedChild) => + Effect.gen(function* () { + if (!(yield* child.isRunning)) return; -let cleanupOkay = false; + yield* child.kill({ forceKillAfter: "5 seconds" }).pipe( + Effect.timeoutOrElse({ + duration: "15 seconds", + orElse: () => + Effect.fail(new ReceiptRehearsalTimeout({ message: "owned child did not exit" })), + }), + ); + }); -try { - const [backendPort, dashboardPort] = await reserveLoopbackPorts(2); + let backend: OwnedChild | undefined; - const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; + const [backendPort, dashboardPort] = yield* step(() => reserveLoopbackPorts(2)); - postgres = await startDisposablePostgres({ database: "receipt_0095" }); - const pgUrl = postgres.url; - pool = new Pool({ connectionString: pgUrl }); + const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; - const databaseLayer = DatabaseLive({ url: Redacted.make(pgUrl), maxConnections: 2 }).pipe( - Layer.provide(BunServices.layer), - ); + const postgres = yield* Effect.acquireRelease( + step(() => startDisposablePostgres({ database: "receipt_0095" })), + (owned) => Effect.promise(() => owned.stop()), + ); - const run = (program: Effect.Effect) => - Effect.runPromise(program.pipe(Effect.provide(databaseLayer))); - - await run(databaseHealth); - await Effect.runPromise(Console.log("0095 database migrated")); - const backendOrigin = `http://127.0.0.1:${backendPort}`; - const password = randomBytes(24).toString("hex"); - secretValues.push(password); - - const persons = [ - { - personId: "receipt-owner-0095", - firstName: "Synthetic", - lastName: "Owner", - email: "owner0095@example.invalid", - password, - }, - { - personId: "receipt-foreign-0095", - firstName: "Synthetic", - lastName: "Other", - email: "foreign0095@example.invalid", - password, - }, - ]; - - const env = { - ...process.env, - BACKEND_HOST: "127.0.0.1", - BACKEND_PORT: String(backendPort), - BACKEND_PG_URL: pgUrl, - BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), - NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify([dashboardOrigin]), - OAUTH_CANONICAL_ORIGIN: backendOrigin, - OAUTH_DASHBOARD_ORIGIN: dashboardOrigin, - OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - PUBLIC_APPLICATION_EFFECT_MODE: "disabled", - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_MODE: "disabled", - RECEIPT_STAGING_ROOT: join(storage, "staging"), - RECEIPT_COMMITTED_ROOT: join(storage, "committed"), - }; - - secretValues.push(env.BETTER_AUTH_SECRET); - command("bun", ["run", "packages/database/runtime/identity-seed-main.ts"], { - ...env, - IDENTITY_SEED_PG_URL: pgUrl, - IDENTITY_SEED_PERSONS: JSON.stringify(persons), - }); + const pgUrl = postgres.url; - for (const person of persons) { - const result: import("pg").QueryResult<{ - id: string; - email: string; - emailVerified: boolean; - providerId: string; - }> = await pool.query( - 'SELECT u.id,u.email,u."emailVerified",a."providerId" FROM auth."user" u JOIN auth."account" a ON a."userId"=u.id WHERE u.id=$1', - [person.personId], + const pool = yield* Effect.acquireRelease( + Effect.sync(() => new Pool({ connectionString: pgUrl })), + (owned) => Effect.promise(() => owned.end()), ); - assert.deepEqual(result.rows, [ - { id: person.personId, email: person.email, emailVerified: true, providerId: "credential" }, - ]); - } + const databaseLayer = DatabaseLive({ url: Redacted.make(pgUrl), maxConnections: 2 }); - await pool.query( - "INSERT INTO organization_departments(department_id,name,short_name,email,city,active) VALUES ('receipt-department-0095','Synthetic0095','Synthetic0095','dept0095@example.invalid','Synthetic',true)", - ); + const run = (program: Effect.Effect) => + program.pipe(Effect.provide(databaseLayer)); - const tables = [ - "economy_receipts", - "economy_receipt_import_ledger", - "economy_receipt_command_receipts", - "economy_receipt_outbox", - "economy_receipt_audit", - "person_profiles", - "organization_departments", - "economy_payment_authorities", - "economy_receipt_approval_grants", - ]; - - const snapshot = async (p = pool!) => - Object.fromEntries( - await Promise.all( - tables.map(async (table) => { - const rows = ( - await p.query(`SELECT to_jsonb(t) AS row FROM ${table} t ORDER BY to_jsonb(t)::text`) - ).rows; - - return [table, sha256Hex(canonicalJsonBytes(rows))]; - }), - ), - ); + // The database health check of the migrated schema. + yield* run(SqlClient.SqlClient.use((sql) => sql`SELECT 1 AS ready`)); + yield* Console.log("0095 database migrated"); + const backendOrigin = `http://127.0.0.1:${backendPort}`; + const password = randomBytes(24).toString("hex"); + secretValues.push(password); - const credentialDigest = async (p = pool!) => - sha256Hex( - canonicalJsonBytes( - ( - await p.query( - 'SELECT u.id,u.email,u."emailVerified",a."providerId",a.password FROM auth."user" u JOIN auth."account" a ON a."userId"=u.id ORDER BY u.id', - ) - ).rows, - ), + const persons = [ + { + personId: "receipt-owner-0095", + firstName: "Synthetic", + lastName: "Owner", + email: "owner0095@example.invalid", + password, + }, + { + personId: "receipt-foreign-0095", + firstName: "Synthetic", + lastName: "Other", + email: "foreign0095@example.invalid", + password, + }, + ]; + + const env = { + BACKEND_HOST: "127.0.0.1", + BACKEND_PORT: String(backendPort), + BACKEND_PG_URL: pgUrl, + BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), + NATIVE_IDENTITY_DEPLOYMENT: "local", + NATIVE_IDENTITY_TRUSTED_ORIGINS: yield* jsonText([dashboardOrigin]), + OAUTH_CANONICAL_ORIGIN: backendOrigin, + OAUTH_DASHBOARD_ORIGIN: dashboardOrigin, + OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", + PUBLIC_APPLICATION_EFFECT_MODE: "disabled", + PASSWORD_RESET_DELIVERY_MODE: "disabled", + RECEIPT_DELIVERY_MODE: "disabled", + RECEIPT_STAGING_ROOT: join(storage, "staging"), + RECEIPT_COMMITTED_ROOT: join(storage, "committed"), + }; + + secretValues.push(env.BETTER_AUTH_SECRET); + yield* command("bun", ["run", "packages/database/runtime/identity-seed-main.ts"], { + ...env, + IDENTITY_SEED_PG_URL: pgUrl, + IDENTITY_SEED_PERSONS: yield* jsonText(persons), + }); + + for (const person of persons) { + const result = yield* query<{ + id: string; + email: string; + emailVerified: boolean; + providerId: string; + }>( + pool, + + 'SELECT u.id,u.email,u."emailVerified",a."providerId" FROM auth."user" u JOIN auth."account" a ON a."userId"=u.id WHERE u.id=$1', + [person.personId], + ); + + assert.deepEqual(result.rows, [ + { id: person.personId, email: person.email, emailVerified: true, providerId: "credential" }, + ]); + } + + yield* query( + pool, + "INSERT INTO organization_departments(department_id,name,short_name,email,city,active) VALUES ('receipt-department-0095','Synthetic0095','Synthetic0095','dept0095@example.invalid','Synthetic',true)", ); - const files = await Effect.runPromise( - ReceiptFileStoreResource.pipe( + const tables = [ + "economy_receipts", + "economy_receipt_import_ledger", + "economy_receipt_command_receipts", + "economy_receipt_outbox", + "economy_receipt_audit", + "person_profiles", + "organization_departments", + "economy_payment_authorities", + "economy_receipt_approval_grants", + ]; + + const snapshot = (p: Pool = pool) => + Effect.forEach( + tables, + (table) => + query(p, `SELECT to_jsonb(t) AS row FROM ${table} t ORDER BY to_jsonb(t)::text`).pipe( + Effect.map((result) => [table, sha256Hex(canonicalJsonBytes(result.rows))] as const), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.map((entries) => Object.fromEntries(entries))); + + const credentialDigest = (p: Pool = pool) => + query( + p, + 'SELECT u.id,u.email,u."emailVerified",a."providerId",a.password FROM auth."user" u JOIN auth."account" a ON a."userId"=u.id ORDER BY u.id', + ).pipe(Effect.map((result) => sha256Hex(canonicalJsonBytes(result.rows)))); + + const files = yield* ReceiptFileStoreResource.pipe( Effect.provide( ReceiptFileStoreLive({ stagingRoot: env.RECEIPT_STAGING_ROOT, committedRoot: env.RECEIPT_COMMITTED_ROOT, - }).pipe(Layer.provide(BunServices.layer)), + }), ), - ), - ); + ); - const baselineBytes = Buffer.from( - "%PDF-1.4\nPre-existing native synthetic receipt 0095\n%%EOF\n", - ); + const baselineBytes = Buffer.from( + "%PDF-1.4\nPre-existing native synthetic receipt 0095\n%%EOF\n", + ); - const baselineFile = ( - await Effect.runPromise( - files.stageBytes( - new File([baselineBytes], "baseline.pdf", { type: "application/pdf" }), - "0095-native-baseline", - "application/pdf", - 10 * 1024 * 1024, - ), - ) - ).file; + const baselineFile = (yield* files.stageBytes( + new File([baselineBytes], "baseline.pdf", { type: "application/pdf" }), + "0095-native-baseline", + "application/pdf", + 10 * 1024 * 1024, + )).file; - await Effect.runPromise( - files.service.apply( + yield* files.service.apply( ReceiptOutboxRequestSchema.cases.PromoteReceiptFile.make({ effectId: "0095-native-baseline-promote", commandId: "0095-native-baseline", receiptId: "receipt-0095-baseline", file: baselineFile, }), - ), - ); - // Explicit pre-existing native fixture, before the measured historical import window. - await pool.query( - `INSERT INTO economy_receipts(receipt_id,visual_id,owner_person_id,department_id,amount_ore,currency,description,receipt_date,submitted_at,status,approved_at,payment_account_ciphertext,file_ref,file_object_key,file_content_type,file_byte_length,file_sha256,revision) + ); + // Explicit pre-existing native fixture, before the measured historical import window. + yield* query( + pool, + `INSERT INTO economy_receipts(receipt_id,visual_id,owner_person_id,department_id,amount_ore,currency,description,receipt_date,submitted_at,status,approved_at,payment_account_ciphertext,file_ref,file_object_key,file_content_type,file_byte_length,file_sha256,revision) VALUES ('receipt-0095-baseline','SYN-0095-baseline',$1,'receipt-department-0095',500,'NOK','Pre-existing synthetic native receipt','2026-08-01','2026-08-01T12:00:00Z','Pending',NULL,'synthetic:baseline',$2,$3,$4,$5,$6,0)`, - [ - persons[0]!.personId, - baselineFile.fileRef, - baselineFile.objectKey, - baselineFile.contentType, - baselineFile.byteLength, - baselineFile.sha256, - ], - ); - await Effect.runPromise(Console.log("0095 native baseline seeded")); - - const baseline = await snapshot(), - baselineCredentials = await credentialDigest(); - - command(postgresProgram("pg_dump"), [ - "--format=custom", - "--file", - join(artifacts, "baseline.dump"), - pgUrl, - ]); - await chmod(join(artifacts, "baseline.dump"), 0o600); - await cp(storage, join(artifacts, "baseline-files"), { recursive: true }); - const bytes = Buffer.from("%PDF-1.4\nSynthetic receipt 0095 only\n%%EOF\n"); - await writeFile(join(fixtureRoot, "receipt.pdf"), bytes); - await writeFile(join(artifacts, "outside.pdf"), bytes); - await symlink(join(artifacts, "outside.pdf"), join(fixtureRoot, "outside-link.pdf")); - - const file = { - path: "receipt.pdf", - sha256: digest(bytes), - byteLength: bytes.length, - contentType: "application/pdf", - }; - - const valid = { - sourcePrimaryKey: "pending", - destinationIdentity: "receipt-0095-pending", - sourceUser: "legacy-owner", - sourceDepartment: "legacy-department", - visualId: "SYN-0095-pending", - amountDecimal: "123.45", - description: "Synthetic imported expense", - receiptDate: "2026-08-20", - submittedAt: "2026-08-21T12:00:00.000Z", - status: "pending", - refundDate: null, - file, - }; - - const variants = [ - {}, - { sourcePrimaryKey: "refunded", status: "refunded", refundDate: "2026-08-22T12:00:00.000Z" }, - { sourcePrimaryKey: "rejected", status: "rejected" }, - { sourcePrimaryKey: "amount", amountDecimal: "1.234" }, - { sourcePrimaryKey: "date", receiptDate: "not-date" }, - { sourcePrimaryKey: "status", status: "unknown" }, - { sourcePrimaryKey: "owner", sourceUser: "unmapped" }, - { sourcePrimaryKey: "department", sourceDepartment: "unmapped" }, - { sourcePrimaryKey: "missing", file: null }, - { sourcePrimaryKey: "unreadable", file: { ...file, path: "missing.pdf" } }, - { sourcePrimaryKey: "digest", file: { ...file, sha256: "0".repeat(64) } }, - { sourcePrimaryKey: "traversal", file: { ...file, path: "../outside.pdf" } }, - { sourcePrimaryKey: "symlink", file: { ...file, path: "outside-link.pdf" } }, - { sourcePrimaryKey: "mime", file: { ...file, contentType: "text/html" } }, - { sourcePrimaryKey: "duplicate", visualId: "DUP" }, - { sourcePrimaryKey: "duplicate", visualId: "DUP" }, - ]; - - const rows: Array<{ - sourcePrimaryKey: string; - destinationIdentity: string; - data: unknown; - rowDigest: string; - }> = variants.map((change, index) => { - const source = { + [ + persons[0]!.personId, + baselineFile.fileRef, + baselineFile.objectKey, + baselineFile.contentType, + baselineFile.byteLength, + baselineFile.sha256, + ], + ); + yield* Console.log("0095 native baseline seeded"); + + const baseline = yield* snapshot(), + baselineCredentials = yield* credentialDigest(); + + yield* command(postgresProgram("pg_dump"), [ + "--format=custom", + "--file", + join(artifacts, "baseline.dump"), + pgUrl, + ]); + yield* fileSystem.chmod(join(artifacts, "baseline.dump"), 0o600); + yield* fileSystem.copy(storage, join(artifacts, "baseline-files")); + const bytes = Buffer.from("%PDF-1.4\nSynthetic receipt 0095 only\n%%EOF\n"); + yield* fileSystem.writeFile(join(fixtureRoot, "receipt.pdf"), bytes); + yield* fileSystem.writeFile(join(artifacts, "outside.pdf"), bytes); + yield* fileSystem.symlink( + join(artifacts, "outside.pdf"), + join(fixtureRoot, "outside-link.pdf"), + ); + + const file = { + path: "receipt.pdf", + sha256: digest(bytes), + byteLength: bytes.length, + contentType: "application/pdf", + }; + + const valid = { + sourcePrimaryKey: "pending", + destinationIdentity: "receipt-0095-pending", + sourceUser: "legacy-owner", + sourceDepartment: "legacy-department", + visualId: "SYN-0095-pending", + amountDecimal: "123.45", + description: "Synthetic imported expense", + receiptDate: "2026-08-20", + submittedAt: "2026-08-21T12:00:00.000Z", + status: "pending", + refundDate: null, + file, + }; + + const variants = [ + {}, + { sourcePrimaryKey: "refunded", status: "refunded", refundDate: "2026-08-22T12:00:00.000Z" }, + { sourcePrimaryKey: "rejected", status: "rejected" }, + { sourcePrimaryKey: "amount", amountDecimal: "1.234" }, + { sourcePrimaryKey: "date", receiptDate: "not-date" }, + { sourcePrimaryKey: "status", status: "unknown" }, + { sourcePrimaryKey: "owner", sourceUser: "unmapped" }, + { sourcePrimaryKey: "department", sourceDepartment: "unmapped" }, + { sourcePrimaryKey: "missing", file: null }, + { sourcePrimaryKey: "unreadable", file: { ...file, path: "missing.pdf" } }, + { sourcePrimaryKey: "digest", file: { ...file, sha256: "0".repeat(64) } }, + { sourcePrimaryKey: "traversal", file: { ...file, path: "../outside.pdf" } }, + { sourcePrimaryKey: "symlink", file: { ...file, path: "outside-link.pdf" } }, + { sourcePrimaryKey: "mime", file: { ...file, contentType: "text/html" } }, + { sourcePrimaryKey: "duplicate", visualId: "DUP" }, + { sourcePrimaryKey: "duplicate", visualId: "DUP" }, + ]; + + const rows: Array<{ + sourcePrimaryKey: string; + destinationIdentity: string; + data: unknown; + rowDigest: string; + }> = variants.map((change, index) => { + const source = { + ...valid, + ...change, + destinationIdentity: `receipt-0095-${index}`, + visualId: change.visualId ?? `SYN-0095-${index}`, + }; + + const { sourcePrimaryKey, destinationIdentity, ...data } = source; + + return { sourcePrimaryKey, destinationIdentity, data, rowDigest: rowDigest(source) }; + }); + + const malformedSource = { ...valid, - ...change, - destinationIdentity: `receipt-0095-${index}`, - visualId: change.visualId ?? `SYN-0095-${index}`, + sourcePrimaryKey: "malformed", + destinationIdentity: "receipt-0095-malformed", + description: 42, }; - const { sourcePrimaryKey, destinationIdentity, ...data } = source; + const { + sourcePrimaryKey: malformedKey, + destinationIdentity: malformedDestination, + ...malformedData + } = malformedSource; + + rows.push({ + sourcePrimaryKey: malformedKey, + destinationIdentity: malformedDestination, + data: malformedData, + rowDigest: rowDigest(malformedSource), + }); - return { sourcePrimaryKey, destinationIdentity, data, rowDigest: rowDigest(source) }; - }); + const manifest = decodeSnapshot({ + kind: "synthetic-receipt-import-0095", + sourceRepository: "synthetic-legacy", + sourceRevision: "fixture-0095-v1", + snapshotId: "immutable-0095-v1", + sourceWatermark: "synthetic:0", + transformationRevision: "0095-v1", + persons: [ + { + sourceUser: "legacy-owner", + personId: persons[0]!.personId, + syntheticPaymentAccount: "synthetic:0095:not-a-payment-account", + }, + ], + departments: [ + { sourceDepartment: "legacy-department", departmentId: "receipt-department-0095" }, + ], + rows, + }); - const malformedSource = { - ...valid, - sourcePrimaryKey: "malformed", - destinationIdentity: "receipt-0095-malformed", - description: 42, - }; - - const { - sourcePrimaryKey: malformedKey, - destinationIdentity: malformedDestination, - ...malformedData - } = malformedSource; - - rows.push({ - sourcePrimaryKey: malformedKey, - destinationIdentity: malformedDestination, - data: malformedData, - rowDigest: rowDigest(malformedSource), - }); + yield* fileSystem.writeFileString( + join(artifacts, "manifest.json"), + yield* indentedJsonText(manifest), + ); - const manifest = decodeSnapshot({ - kind: "synthetic-receipt-import-0095", - sourceRepository: "synthetic-legacy", - sourceRevision: "fixture-0095-v1", - snapshotId: "immutable-0095-v1", - sourceWatermark: "synthetic:0", - transformationRevision: "0095-v1", - persons: [ - { - sourceUser: "legacy-owner", - personId: persons[0]!.personId, - syntheticPaymentAccount: "synthetic:0095:not-a-payment-account", - }, - ], - departments: [ - { sourceDepartment: "legacy-department", departmentId: "receipt-department-0095" }, - ], - rows, - }); + const prepared = yield* prepareReceiptSnapshot(manifest, fixtureRoot, files); - await writeFile(join(artifacts, "manifest.json"), JSON.stringify(manifest, null, 2)); + const accepted = prepared.results.filter( + (r): r is Extract => + Predicate.isTagged(r, "AcceptedReceiptImport"), + ); - const prepared = await Effect.runPromise( - prepareReceiptSnapshot(manifest, fixtureRoot, files).pipe(Effect.provide(BunServices.layer)), - ); + assert.equal(accepted.length, 3); + assert.equal(prepared.results.length, rows.length); + let effectAttempts = 0; + + const guard = ReceiptAuxiliaryEffects.of({ + apply: (request) => + Effect.sync(() => { + effectAttempts++; + }).pipe( + Effect.andThen( + Effect.fail(ReceiptAuxiliaryEffectConflict.make({ effectId: request.effectId })), + ), + ), + }); - const accepted = prepared.results.filter( - (r): r is Extract => - Predicate.isTagged(r, "AcceptedReceiptImport"), - ); + const observeNoEffects = Effect.fnUntraced(function* () { + const claimedAt = DateTime.formatIso(yield* DateTime.now); - assert.equal(accepted.length, 3); - assert.equal(prepared.results.length, rows.length); - let effectAttempts = 0; - - const guard = ReceiptAuxiliaryEffects.of({ - apply: (request) => - Effect.sync(() => { - effectAttempts++; - }).pipe( - Effect.andThen( - Effect.fail(new ReceiptAuxiliaryEffectConflict({ effectId: request.effectId })), + const result = yield* run( + deliverNextReceiptOutbox({ claimId: "0095-guard", claimedAt }).pipe( + Effect.provideService(ReceiptAuxiliaryEffects, guard), + Effect.provideService(ReceiptFileService, files.service), ), - ), - }); + ); - const observeNoEffects = async () => { - const result = await run( - deliverNextReceiptOutbox("0095-guard", new Date().toISOString()).pipe( - Effect.provideService(ReceiptAuxiliaryEffects, guard), - Effect.provideService(ReceiptFileService, files.service), - ), - ); + assert.equal(result._tag, "Idle"); + assert.equal(effectAttempts, 0); + assert.equal( + (yield* query(pool, "SELECT count(*)::int AS n FROM economy_receipt_outbox")).rows[0].n, + 0, + ); + }); - assert.equal(result._tag, "Idle"); - assert.equal(effectAttempts, 0); + // Staged bytes precede the atomic receipt+ledger commit. Force its second insert to fail. + yield* query( + pool, + "CREATE FUNCTION fail_0095() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION '0095 injected ledger failure'; END $$; CREATE TRIGGER fail_0095 BEFORE INSERT ON economy_receipt_import_ledger FOR EACH ROW EXECUTE FUNCTION fail_0095()", + ); + yield* storeReceiptImportResult(accepted[0]!).pipe(run, rejects); assert.equal( - (await pool!.query("SELECT count(*)::int AS n FROM economy_receipt_outbox")).rows[0].n, - 0, + (yield* query(pool, "SELECT count(*)::int AS n FROM economy_receipts")).rows[0].n, + 1, + ); + yield* observeNoEffects(); + yield* query( + pool, + "DROP TRIGGER fail_0095 ON economy_receipt_import_ledger; DROP FUNCTION fail_0095()", ); - }; - - // Staged bytes precede the atomic receipt+ledger commit. Force its second insert to fail. - await pool.query( - "CREATE FUNCTION fail_0095() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION '0095 injected ledger failure'; END $$; CREATE TRIGGER fail_0095 BEFORE INSERT ON economy_receipt_import_ledger FOR EACH ROW EXECUTE FUNCTION fail_0095()", - ); - await assert.rejects(run(storeReceiptImportResult(accepted[0]!))); - assert.equal((await pool.query("SELECT count(*)::int AS n FROM economy_receipts")).rows[0].n, 1); - await observeNoEffects(); - await pool.query( - "DROP TRIGGER fail_0095 ON economy_receipt_import_ledger; DROP FUNCTION fail_0095()", - ); - const importAll = async () => { - for (const result of prepared.results) { - if (Predicate.isTagged(result, "AcceptedReceiptImport")) - await Effect.runPromise( - files.service.apply( + const importAll = Effect.fnUntraced(function* () { + for (const result of prepared.results) { + if (Predicate.isTagged(result, "AcceptedReceiptImport")) + yield* files.service.apply( ReceiptOutboxRequestSchema.cases.PromoteReceiptFile.make({ effectId: `0095:${result.sourcePrimaryKey}:promote`, commandId: `0095:${result.sourcePrimaryKey}`, receiptId: result.receipt.receiptId, file: result.receipt.file, }), - ), - ); - await run(storeReceiptImportResult(result)); + ); + yield* run(storeReceiptImportResult(result)); + } + }); + + yield* importAll(); + yield* observeNoEffects(); + yield* Console.log("0095 import and failure/retry passed"); + + // Staging objects owned by rejected occurrences have no committed references. + for (const file of prepared.staged) { + if ( + ((yield* query( + pool, + "SELECT 1 FROM economy_receipts WHERE file_ref=$1 OR file_object_key=$2", + [file.fileRef, file.objectKey], + )).rowCount ?? 0) === 0 + ) + yield* files.cleanupStage(file); } - }; - - await importAll(); - await observeNoEffects(); - await Effect.runPromise(Console.log("0095 import and failure/retry passed")); - - // Staging objects owned by rejected occurrences have no committed references. - for (const file of prepared.staged) { - if ( - !( - await pool.query("SELECT 1 FROM economy_receipts WHERE file_ref=$1 OR file_object_key=$2", [ - file.fileRef, - file.objectKey, - ]) - ).rowCount - ) - await Effect.runPromise(files.cleanupStage(file)); - } - backend = start("bun", ["run", "apps/backend/src/main.ts"], env); - await wait(async () => { - const response = await fetch(`${backendOrigin}/health`); - assert.equal(response.status, 200); - }); + backend = yield* start("bun", ["run", "apps/backend/src/main.ts"], env); + yield* wait(healthAnswers200(backendOrigin)); - const signIn = async (email: string) => { - const response = await fetch(`${backendOrigin}/api/auth/sign-in/email`, { - method: "POST", - headers: { "content-type": "application/json", origin: dashboardOrigin }, - body: JSON.stringify({ email, password }), - }); + const signIn = Effect.fnUntraced(function* (email: string) { + const response = yield* HttpClient.execute( + HttpClientRequest.post(`${backendOrigin}/api/auth/sign-in/email`).pipe( + HttpClientRequest.setHeader("origin", dashboardOrigin), + HttpClientRequest.bodyText(yield* jsonText({ email, password }), "application/json"), + ), + ); - assert.equal(response.status, 200); - const cookie = response.headers.get("set-cookie")?.split(";")[0]; - assert.ok(cookie); + assert.equal(response.status, 200); + const cookie = firstSetCookie(response); + assert.ok(cookie !== undefined && cookie.length > 0); - return cookie; - }; + return cookie; + }); - await Effect.runPromise(Console.log("0095 backend ready")); + yield* Console.log("0095 backend ready"); - const cookie = await signIn(persons[0]!.email), - foreign = await signIn(persons[1]!.email); + const cookie = yield* signIn(persons[0]!.email), + foreign = yield* signIn(persons[1]!.email); - secretValues.push( - cookie, - foreign, - ...[cookie, foreign].map((value) => value.slice(value.indexOf("=") + 1)), - ); - const client = createPromiseClient(backendOrigin, { cookie, origin: dashboardOrigin }); + secretValues.push( + cookie, + foreign, + ...[cookie, foreign].map((value) => value.slice(value.indexOf("=") + 1)), + ); + const native = nativeScriptClient(backendOrigin); - const reconciliationDiagnostics: Array<{ - sourcePrimaryKey: string; - phase: string; - reason: string; - }> = []; + const ownerHeaders = { cookie, origin: dashboardOrigin }; - const reconcile = async (result: (typeof accepted)[number]) => { - let phase = "persisted fact comparison"; + const reconciliationDiagnostics: Array<{ + sourcePrimaryKey: string; + phase: string; + reason: string; + }> = []; - const observed = await run( - reconcileReceiptImport(result, () => - Effect.tryPromise({ - try: async () => { + const reconcile = Effect.fnUntraced(function* (result: (typeof accepted)[number]) { + let phase = "persisted fact comparison"; + + const observed = yield* run( + reconcileReceiptImport(result, () => + Effect.gen(function* () { phase = "native owner projection"; - const list = await client.receipts.listReceipts({ query: {} }); - const item = list.body.items.find((i) => i.receiptId === result.receipt.receiptId); + + const list = yield* step(() => + native.call(ownerHeaders, (client) => client["receipts.listReceipts"]({})), + ); + + assert.ok( + list.ok, + `listReceipts answered ${list.status}: ${list.ok ? "" : "defect" in list ? list.defect : list.code}`, + ); + const item = list.value.items.find((i) => i.receiptId === result.receipt.receiptId); assert.ok(item); assert.equal(item.amountOre, result.receipt.amountOre); assert.equal(item.status, result.receipt.status); assert.equal(item.visualId, result.receipt.visualId); - assert.ok(!JSON.stringify(item).includes("synthetic:0095:not-a-payment-account")); - assert.ok(!JSON.stringify(item).includes(result.receipt.file.objectKey)); + const itemText = yield* jsonText(item); + assert.ok(!itemText.includes("synthetic:0095:not-a-payment-account")); + assert.ok(!itemText.includes(result.receipt.file.objectKey)); phase = "native private byte download"; - const downloaded = await client.receipts.readReceiptFile({ - params: { receiptId: ReceiptId.make(result.receipt.receiptId) }, - }); - - return digest(downloaded.body) === result.receipt.file.sha256; - }, - catch: (cause) => { - const reason = safe(String(cause)); - reconciliationDiagnostics.push({ - sourcePrimaryKey: result.sourcePrimaryKey, - phase, - reason, - }); - logs.push(`reconciliation ${result.sourcePrimaryKey} ${phase}: ${reason}`); - - return new Cause.UnknownError(cause, "fresh observation failed"); - }, - }).pipe(Effect.orElseSucceed(() => false)), - ), - ); - - if (!observed && phase === "persisted fact comparison") { - reconciliationDiagnostics.push({ - sourcePrimaryKey: result.sourcePrimaryKey, - phase, - reason: "persisted canonical fact differs from source", - }); - logs.push( - `reconciliation ${result.sourcePrimaryKey}: persisted canonical fact differs from source`, + const downloaded = yield* step(() => + native.call(ownerHeaders, (client) => + client["receipts.readReceiptFile"]({ + receiptId: ReceiptId.make(result.receipt.receiptId), + }), + ), + ); + + assert.ok(downloaded.ok, `readReceiptFile answered ${downloaded.status}`); + + return digest(downloaded.value.bytes) === result.receipt.file.sha256; + }).pipe( + surfaceStepFailure, + // A failed observation, an assertion included, records its phase and counts as false. + Effect.catchCause((cause) => + Effect.sync(() => { + const reason = safe(String(Cause.squash(cause))); + reconciliationDiagnostics.push({ + sourcePrimaryKey: result.sourcePrimaryKey, + phase, + reason, + }); + logs.push(`reconciliation ${result.sourcePrimaryKey} ${phase}: ${reason}`); + + return false; + }), + ), + ), + ), ); - } - - return observed; - }; - const reconciliations = []; + if (!observed && phase === "persisted fact comparison") { + reconciliationDiagnostics.push({ + sourcePrimaryKey: result.sourcePrimaryKey, + phase, + reason: "persisted canonical fact differs from source", + }); + logs.push( + `reconciliation ${result.sourcePrimaryKey}: persisted canonical fact differs from source`, + ); + } - for (const result of accepted) { - assert.equal(await reconcile(result), true); - reconciliations.push({ - sourcePrimaryKey: result.sourcePrimaryKey, - sourceOccurrence: result.sourceOccurrence, - sourceDigest: result.provenance.sourceDigest, - reconciled: true, + return observed; }); - for (const deniedCookie of [foreign, undefined]) { - const r = await fetch(`${backendOrigin}/api/receipts/${result.receipt.receiptId}/file`, { - headers: deniedCookie ? { cookie: deniedCookie } : {}, + const reconciliations = []; + + for (const result of accepted) { + assert.equal(yield* reconcile(result), true); + reconciliations.push({ + sourcePrimaryKey: result.sourcePrimaryKey, + sourceOccurrence: result.sourceOccurrence, + sourceDigest: result.provenance.sourceDigest, + reconciled: true, }); - assert.equal(r.status, deniedCookie ? 404 : 401); - assert.ok(!Buffer.from(await r.arrayBuffer()).equals(bytes)); + for (const deniedCookie of [foreign, undefined]) { + const denied = yield* step(() => + native.call( + deniedCookie === undefined + ? { origin: dashboardOrigin } + : { cookie: deniedCookie, origin: dashboardOrigin }, + (client) => + client["receipts.readReceiptFile"]({ + receiptId: ReceiptId.make(result.receipt.receiptId), + }), + ), + ); + + // A foreign owner is told the file does not exist; no credential is told to present one. + assert.equal(denied.ok, false); + assert.equal(denied.status, deniedCookie === undefined ? 401 : 404); + } } - } - await Effect.runPromise(Console.log("0095 owner reads and denials passed")); - - const collision = { - ...accepted[0]!, - sourcePrimaryKey: "destination-collision", - receipt: Struct.assign(accepted[0]!.receipt, { - receiptId: ReceiptId.make("receipt-0095-baseline"), - }), - provenance: { - ...accepted[0]!.provenance, - destinationIdentity: "receipt-0095-baseline", - sourceDigest: digest("synthetic-destination-collision-0095"), - }, - }; - - await run(storeReceiptImportResult(collision)); - - const collisionLedger = ( - await pool.query( + yield* Console.log("0095 owner reads and denials passed"); + + const collision = { + ...accepted[0]!, + sourcePrimaryKey: "destination-collision", + receipt: Struct.assign(accepted[0]!.receipt, { + receiptId: ReceiptId.make("receipt-0095-baseline"), + }), + provenance: { + ...accepted[0]!.provenance, + destinationIdentity: "receipt-0095-baseline", + sourceDigest: digest("synthetic-destination-collision-0095"), + }, + }; + + yield* run(storeReceiptImportResult(collision)); + + const collisionLedger = (yield* query( + pool, "SELECT result,reasons_json FROM economy_receipt_import_ledger WHERE source_primary_key='destination-collision'", - ) - ).rows; + )).rows; + + assert.equal(collisionLedger[0]?.result, "Quarantined"); + assert.ok(collisionLedger[0]?.reasons_json.reasons.includes("DestinationIdentityCollision")); + + const invalidBearer = yield* step(() => + native.call( + { cookie, authorization: "Bearer invalid-synthetic-0095", origin: dashboardOrigin }, + (client) => + client["receipts.readReceiptFile"]({ + receiptId: ReceiptId.make(accepted[0]!.receipt.receiptId), + }), + ), + ); - assert.equal(collisionLedger[0]?.result, "Quarantined"); - assert.ok(collisionLedger[0]?.reasons_json.reasons.includes("DestinationIdentityCollision")); + assert.equal(invalidBearer.status, 401); + const stable = yield* snapshot(); - const invalidBearer = await fetch( - `${backendOrigin}/api/receipts/${accepted[0]!.receipt.receiptId}/file`, - { headers: { cookie, authorization: "Bearer invalid-synthetic-0095" } }, - ); + const fileDigest = Effect.fnUntraced(function* () { + const values = []; - assert.equal(invalidBearer.status, 401); - const stable = await snapshot(); + for (const result of accepted) + values.push( + digest( + yield* fileSystem.readFile( + join(env.RECEIPT_COMMITTED_ROOT, result.receipt.file.objectKey), + ), + ), + ); - const fileDigest = async () => { - const values = []; + return values; + }); - for (const result of accepted) - values.push( - digest(await readFile(join(env.RECEIPT_COMMITTED_ROOT, result.receipt.file.objectKey))), - ); + const stableFiles = yield* fileDigest(); + yield* importAll(); + assert.deepEqual(yield* snapshot(), stable); + assert.deepEqual(yield* fileDigest(), stableFiles); + yield* Effect.all( + [run(storeReceiptImportResult(accepted[0]!)), run(storeReceiptImportResult(accepted[0]!))], + { concurrency: "unbounded" }, + ); + assert.deepEqual(yield* snapshot(), stable); + yield* storeReceiptImportResult({ + ...accepted[0]!, + provenance: { ...accepted[0]!.provenance, sourceDigest: "f".repeat(64) }, + }).pipe(run, rejects); + const tamper = accepted[0]!; + const copiedPath = join(env.RECEIPT_COMMITTED_ROOT, tamper.receipt.file.objectKey); + yield* fileSystem.writeFileString(copiedPath, "tampered"); + assert.equal(yield* reconcile(tamper), false); + yield* fileSystem.writeFile(copiedPath, bytes); + assert.equal(yield* reconcile(tamper), true); + yield* query(pool, "UPDATE economy_receipts SET amount_ore=amount_ore+1 WHERE receipt_id=$1", [ + tamper.receipt.receiptId, + ]); + assert.equal(yield* reconcile(tamper), false); + yield* query(pool, "UPDATE economy_receipts SET amount_ore=amount_ore-1 WHERE receipt_id=$1", [ + tamper.receipt.receiptId, + ]); + assert.equal(yield* reconcile(tamper), true); + // Every reconciliation reads the owner projection through the script client, so it lives until + // the last one; a disposed client fails each read, which a tamper check would count as detection. + yield* step(() => native.dispose()); + yield* observeNoEffects(); + const authorityTables = ["economy_payment_authorities", "economy_receipt_approval_grants"]; + const current = yield* snapshot(); + + for (const table of authorityTables) assert.equal(current[table], baseline[table]); + yield* Console.log("0095 replay and tamper observations passed"); + + const observeDelivery = () => + observeReceiptDelivery({ + pool, + env, + origin: backendOrigin, + dashboardOrigin, + cookie, + approverCookie: foreign, + root, + artifactDirectory: artifacts, + registerSecret: (secret) => { + secretValues.push(secret); + }, + restart: (nextEnv) => + Effect.gen(function* () { + if (backend !== undefined) yield* stop(backend); + backend = yield* start("bun", ["run", "apps/backend/src/main.ts"], nextEnv); + yield* wait(healthAnswers200(backendOrigin)); + }), + }).pipe(Effect.provide(BunHttpServer.layer({ port: 0, hostname: "127.0.0.1" }))); + + const deliveryObservation = Option.contains( + yield* Config.option(Config.String("RECEIPT_DELIVERY_REHEARSAL")), + "1", + ) + ? yield* observeDelivery() + : null; + + if (backend !== undefined) yield* stop(backend); + backend = undefined; + const restoredUrl = yield* step(() => postgres.createDatabase("receipt_0095_restored")); + yield* command(postgresProgram("pg_restore"), [ + "--exit-on-error", + "--dbname", + restoredUrl, + join(artifacts, "baseline.dump"), + ]); + yield* Effect.acquireUseRelease( + Effect.sync(() => new Pool({ connectionString: restoredUrl })), + (restored) => + Effect.gen(function* () { + assert.deepEqual(yield* snapshot(restored), baseline); + assert.equal(yield* credentialDigest(restored), baselineCredentials); + }), + (restored) => Effect.promise(() => restored.end()), + ); - return values; - }; - - const stableFiles = await fileDigest(); - await importAll(); - assert.deepEqual(await snapshot(), stable); - assert.deepEqual(await fileDigest(), stableFiles); - await Promise.all([ - run(storeReceiptImportResult(accepted[0]!)), - run(storeReceiptImportResult(accepted[0]!)), - ]); - assert.deepEqual(await snapshot(), stable); - await assert.rejects( - run( - storeReceiptImportResult({ - ...accepted[0]!, - provenance: { ...accepted[0]!.provenance, sourceDigest: "f".repeat(64) }, - }), - ), - ); - const tamper = accepted[0]!; - const copiedPath = join(env.RECEIPT_COMMITTED_ROOT, tamper.receipt.file.objectKey); - await writeFile(copiedPath, "tampered"); - assert.equal(await reconcile(tamper), false); - await writeFile(copiedPath, bytes); - assert.equal(await reconcile(tamper), true); - await pool.query("UPDATE economy_receipts SET amount_ore=amount_ore+1 WHERE receipt_id=$1", [ - tamper.receipt.receiptId, - ]); - assert.equal(await reconcile(tamper), false); - await pool.query("UPDATE economy_receipts SET amount_ore=amount_ore-1 WHERE receipt_id=$1", [ - tamper.receipt.receiptId, - ]); - assert.equal(await reconcile(tamper), true); - await observeNoEffects(); - const authorityTables = ["economy_payment_authorities", "economy_receipt_approval_grants"]; - const current = await snapshot(); - - for (const table of authorityTables) assert.equal(current[table], baseline[table]); - await Effect.runPromise(Console.log("0095 replay and tamper observations passed")); - let deliveryObservation: Awaited> | null = null; - - if (process.env.RECEIPT_DELIVERY_REHEARSAL === "1") { - deliveryObservation = await observeReceiptDelivery({ - pool, - env, - origin: backendOrigin, - dashboardOrigin, - cookie, - approverCookie: foreign, - root, - artifactDirectory: artifacts, - registerSecret: (secret) => { - secretValues.push(secret); + yield* fileSystem.copy(join(artifacts, "baseline-files"), join(artifacts, "restored-files")); + assert.equal( + digest( + yield* fileSystem.readFile( + join(artifacts, "restored-files", "committed", baselineFile.objectKey), + ), + ), + digest(baselineBytes), + ); + + return canonicalJsonValue({ + specId: "0095", + deliveryObservation, + revision, + manifestDigest: sha256Hex(canonicalJsonBytes(manifest)), + counts: { + input: rows.length, + accepted: accepted.length, + quarantined: rows.length - accepted.length, }, - restart: async (nextEnv) => { - if (backend) await stop(backend); - backend = start("bun", ["run", "apps/backend/src/main.ts"], nextEnv); - await wait(async () => { - const r = await fetch(`${backendOrigin}/health`); - assert.equal(r.status, 200); - }); + quarantine: prepared.results + .filter((r) => Predicate.isTagged(r, "QuarantinedReceiptImport")) + .map((r) => ({ + sourcePrimaryKey: r.sourcePrimaryKey, + sourceOccurrence: r.sourceOccurrence, + reasons: r.reasons, + sourceDigest: r.provenance.sourceDigest, + })), + fileRejections: prepared.fileFailures, + reconciliations, + reconciliationDiagnostics, + denials: { foreign: 404, anonymous: 401, invalidBearerWithOwnerCookie: 401 }, + supplementalDestinationCollision: { + input: 1, + quarantined: 1, + reason: "DestinationIdentityCollision", }, + effectAttempts, + commandOutboxEffects: 0, + replay: { + factsLedgerFilesUnchanged: true, + concurrentReplay: true, + conflictingReplayRejected: true, + }, + failureRetry: { ledgerFailureAfterStaging: true, noVisibleFactAfterFailure: true }, + tamper: { bytesInvalidate: true, factsInvalidate: true }, + restore: { + databaseBaseline: true, + filesBaseline: true, + nonemptyBaselineFileSha256: digest(baselineBytes), + credentialsBaseline: true, + backupSha256: digest(yield* fileSystem.readFile(join(artifacts, "baseline.dump"))), + }, + scope: + deliveryObservation === null + ? "synthetic historical import; zero notification attempts; no password migration, production or cutover claim" + : "synthetic historical import with zero notification attempts, followed by 0097 loopback transport acceptance; no real provider, human receipt, password migration, production or cutover claim", }); - } - - await stop(backend); - backend = undefined; - const restoredUrl = await postgres.createDatabase("receipt_0095_restored"); - command(postgresProgram("pg_restore"), [ - "--exit-on-error", - "--dbname", - restoredUrl, - join(artifacts, "baseline.dump"), - ]); - const restored = new Pool({ connectionString: restoredUrl }); - - try { - assert.deepEqual(await snapshot(restored), baseline); - assert.equal(await credentialDigest(restored), baselineCredentials); - } finally { - await restored.end(); - } - - await cp(join(artifacts, "baseline-files"), join(artifacts, "restored-files"), { - recursive: true, - }); - assert.equal( - digest(await readFile(join(artifacts, "restored-files", "committed", baselineFile.objectKey))), - digest(baselineBytes), - ); - evidence = canonicalJsonValue({ - specId: "0095", - deliveryObservation, - revision, - manifestDigest: sha256Hex(canonicalJsonBytes(manifest)), - counts: { - input: rows.length, - accepted: accepted.length, - quarantined: rows.length - accepted.length, - }, - quarantine: prepared.results - .filter((r) => Predicate.isTagged(r, "QuarantinedReceiptImport")) - .map((r) => ({ - sourcePrimaryKey: r.sourcePrimaryKey, - sourceOccurrence: r.sourceOccurrence, - reasons: r.reasons, - sourceDigest: r.provenance.sourceDigest, - })), - fileRejections: prepared.fileFailures, - reconciliations, - reconciliationDiagnostics, - denials: { foreign: 404, anonymous: 401, invalidBearerWithOwnerCookie: 401 }, - supplementalDestinationCollision: { - input: 1, - quarantined: 1, - reason: "DestinationIdentityCollision", - }, - effectAttempts, - commandOutboxEffects: 0, - replay: { - factsLedgerFilesUnchanged: true, - concurrentReplay: true, - conflictingReplayRejected: true, - }, - failureRetry: { ledgerFailureAfterStaging: true, noVisibleFactAfterFailure: true }, - tamper: { bytesInvalidate: true, factsInvalidate: true }, - restore: { - databaseBaseline: true, - filesBaseline: true, - nonemptyBaselineFileSha256: digest(baselineBytes), - credentialsBaseline: true, - backupSha256: digest(await readFile(join(artifacts, "baseline.dump"))), - }, - scope: - deliveryObservation === null - ? "synthetic historical import; zero notification attempts; no password migration, production or cutover claim" - : "synthetic historical import with zero notification attempts, followed by 0097 loopback transport acceptance; no real provider, human receipt, password migration, production or cutover claim", }); -} catch (cause) { - await writeFile( - join(artifacts, "failure.json"), - JSON.stringify( - { revision, error: safe(String(cause)), backendDiagnostics: safe(logs.join("")) }, - null, - 2, + + const outcome = yield* Effect.exit( + Effect.scoped( + main.pipe( + surfaceStepFailure, + Effect.tapCause((cause) => + Effect.gen(function* () { + yield* fileSystem.writeFileString( + join(artifacts, "failure.json"), + yield* indentedJsonText({ + revision, + error: safe(String(Cause.squash(cause))), + backendDiagnostics: safe(logs.join("")), + }), + ); + yield* Console.error(`0095 failure diagnostics: ${join(artifacts, "failure.json")}`); + }), + ), + ), ), ); - await Effect.runPromise( - Console.error(`0095 failure diagnostics: ${join(artifacts, "failure.json")}`), - ); - throw new Error("Receipt rehearsal failed; inspect sanitized failure evidence"); -} finally { - if (pool) await pool.end(); - for (const child of [...children].reverse()) await stop(child); - await postgres?.stop(); - await rm(storage, { recursive: true, force: true }); + yield* fileSystem.remove(storage, { recursive: true, force: true }); - if (process.env.RECEIPT_REOPEN_REHEARSAL === "1") { - for (const entry of await readdir(artifacts)) { + if (reopenRehearsal) { + for (const entry of yield* fileSystem.readDirectory(artifacts)) { if ( ![ "failure.json", @@ -857,29 +960,36 @@ try { "0102-browser-failure.png", ].includes(entry) ) - await rm(join(artifacts, entry), { recursive: true, force: true }); + yield* fileSystem.remove(join(artifacts, entry), { recursive: true, force: true }); } } - cleanupOkay = true; -} - -assert.ok(cleanupOkay); + if (Exit.isFailure(outcome)) + return yield* new ReceiptRehearsalFailed({ + message: "Receipt rehearsal failed; inspect sanitized failure evidence", + }); -const encodedEvidence = JSON.stringify( - { - ...Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json))(evidence), + const encodedEvidence = yield* indentedJsonText({ + ...(yield* Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Json))( + outcome.value, + )), passed: true, cleanup: "owned processes exited; disposable PostgreSQL and active private storage removed; synthetic evidence retained", - }, - null, - 2, -); + }); + + if (safe(encodedEvidence) !== encodedEvidence) + return yield* new ReceiptRehearsalFailed({ + message: "Retained evidence contains a credential", + }); -if (safe(encodedEvidence) !== encodedEvidence) - throw new Error("Retained evidence contains a credential"); + yield* fileSystem.writeFileString(join(artifacts, "evidence.json"), encodedEvidence, { + mode: 0o600, + }); -await writeFile(join(artifacts, "evidence.json"), encodedEvidence, { mode: 0o600 }); + yield* Console.log(`0095 passed: ${join(artifacts, "evidence.json")}`); +}); -await Effect.runPromise(Console.log(`0095 passed: ${join(artifacts, "evidence.json")}`)); +BunRuntime.runMain( + rehearsal.pipe(Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer))), +); diff --git a/tools/verification/receipt-reopen-observation.ts b/tools/verification/receipt-reopen-observation.ts index 5e4f86b21..9fa2daf57 100644 --- a/tools/verification/receipt-reopen-observation.ts +++ b/tools/verification/receipt-reopen-observation.ts @@ -1,569 +1,624 @@ /** 0102: extends the owned 0095/0097 runtime, sharing their local acknowledged transport. */ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { spawn, type ChildProcess } from "node:child_process"; import { createRequire } from "node:module"; -import { createServer } from "node:net"; -import { join } from "node:path"; import type { Pool } from "pg"; -import { Schema } from "effect"; -import { ReceiptResource } from "@vektorprogrammet/http-api"; -import { StrongETag, IdempotencyKey } from "@vektorprogrammet/http-api/http-semantics"; -import { createPromiseClient } from "../../packages/sdk/src/promise.js"; - -export async function observeReceiptReopening(options: { - pool: Pool; - origin: string; - dashboardOrigin: string; - cookie: string; - approverCookie: string; - root: string; - artifactDirectory: string; - attempts: () => number; - accepted: () => number; - setDeliveryAvailable: (available: boolean) => void; -}) { - const { pool, origin, dashboardOrigin, cookie, approverCookie } = options; - const client = createPromiseClient(origin, { cookie: approverCookie, origin: dashboardOrigin }); - - const headers = (session: string, etag?: string, key: string = randomUUID()) => { - const result = new Headers({ - cookie: session, - origin: dashboardOrigin, - "content-type": "application/json", - "idempotency-key": key, - }); - - if (etag) { - result.set("if-match", etag); - } - - return result; - }; - - const request = ( - id: string, - action: string, - etag?: string, - session = approverCookie, - key: string = randomUUID(), - body = "{}", - ) => - fetch(`${origin}/api/receipts/${id}:${action}`, { - method: "POST", - headers: headers(session, etag, key), - body, - }); - - const row = async (id: string) => - (await pool.query("SELECT * FROM economy_receipts WHERE receipt_id=$1", [id])).rows[0]; - - const snapshot = async (id: string) => ({ - receipt: await row(id), - audit: ( - await pool.query( - "SELECT * FROM economy_receipt_audit WHERE receipt_id=$1 ORDER BY receipt_revision", - [id], - ) - ).rows, - commands: ( - await pool.query( - "SELECT * FROM economy_receipt_command_receipts WHERE receipt_id=$1 ORDER BY command_id", - [id], - ) - ).rows, - outbox: ( - await pool.query( - "SELECT * FROM economy_receipt_outbox WHERE receipt_id=$1 ORDER BY effect_id", - [id], - ) - ).rows, - }); - - const submit = async () => { - const form = new FormData(); - form.set("description", "Synthetic correction 0102"); - form.set("amountOre", "500"); - form.set("receiptDate", "2026-09-06"); - form.set( - "file", - new File(["%PDF-1.4\nSynthetic 0102\n%%EOF"], "receipt.pdf", { type: "application/pdf" }), - ); - - const response = await fetch(`${origin}/api/receipts?departmentId=receipt-department-0095`, { - method: "POST", - headers: { cookie, origin: dashboardOrigin, "idempotency-key": randomUUID() }, - body: form, - }); - - assert.equal(response.status, 201, "synthetic submission"); - const resource = Schema.decodeUnknownSync(ReceiptResource)(await response.json()); - - return { id: resource.receiptId, etag: resource.etag }; - }; - - const rejected = async () => { - const receipt = await submit(); - const response = await request(receipt.id, "reject", receipt.etag); - assert.equal(response.status, 200); - - return { - ...receipt, - initialEtag: receipt.etag, - etag: Schema.decodeUnknownSync(StrongETag)(response.headers.get("etag")), - }; - }; - - options.setDeliveryAvailable(false); - const target = await rejected(); - options.setDeliveryAvailable(true); - const before = await snapshot(target.id); - assert.ok(before.outbox.some((item: any) => item.status === "Failed")); - const attemptsBefore = options.attempts(); - - // Invalid commands must have no persisted footprint. - for (const [name, response, expected] of [ - ["owner", await request(target.id, "reopen", target.etag, cookie), 403], - ["missing revision", await request(target.id, "reopen"), 428], - ["stale revision", await request(target.id, "reopen", target.initialEtag), 412], - [ - "extra authority", - await request( - target.id, - "reopen", - target.etag, - approverCookie, - randomUUID(), - '{"actor":{"approvalScope":"Global"}}', - ), - 422, - ], - ] as const) - assert.equal(response.status, expected, name); - assert.deepEqual(await snapshot(target.id), before); - - for (const mutation of [ - "UPDATE economy_receipt_approval_grants SET end_at=date_trunc('milliseconds',now(),'UTC'),revision=revision+1 WHERE approval_grant_id='receipt0097-approve'", - "UPDATE organization_memberships SET end_at=date_trunc('milliseconds',now(),'UTC') WHERE membership_id='receipt0097-approver-membership'", - ]) { - await pool.query(mutation); - assert.equal((await request(target.id, "reopen", target.etag)).status, 403); - await pool.query( - "UPDATE economy_receipt_approval_grants SET end_at=NULL,revision=revision+1 WHERE approval_grant_id='receipt0097-approve'", - ); - await pool.query( - "UPDATE organization_memberships SET end_at=NULL WHERE membership_id='receipt0097-approver-membership'", - ); - } - - await pool.query( - "INSERT INTO organization_departments(department_id,name,short_name,email,city,active) VALUES ('receipt-wrong-0102','Wrong0102','Wrong0102','wrong0102@example.invalid','Synthetic',true)", - ); - await pool.query( - "UPDATE economy_receipt_approval_grants SET department_id='receipt-wrong-0102',revision=revision+1 WHERE approval_grant_id='receipt0097-approve'", - ); - assert.equal((await request(target.id, "reopen", target.etag)).status, 403); - await pool.query( - "UPDATE economy_receipt_approval_grants SET department_id='receipt-department-0095',revision=revision+1 WHERE approval_grant_id='receipt0097-approve'", - ); - // Real database failure after mutation but before commit must roll the entire command back. - await pool.query( - "CREATE FUNCTION fail_reopen_0102() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='RejectedReceiptReopened' THEN RAISE EXCEPTION 'synthetic0102'; END IF; RETURN NEW; END $$", - ); - await pool.query( - "CREATE TRIGGER fail_reopen_0102 BEFORE INSERT ON economy_receipt_audit FOR EACH ROW EXECUTE FUNCTION fail_reopen_0102()", - ); - const retryKey = IdempotencyKey.make(randomUUID()); - assert.equal( - (await request(target.id, "reopen", target.etag, approverCookie, retryKey)).status, - 503, - ); - assert.deepEqual(await snapshot(target.id), before); - await pool.query("DROP TRIGGER fail_reopen_0102 ON economy_receipt_audit"); - await pool.query("DROP FUNCTION fail_reopen_0102()"); - - const reopened = await client.receipts.reopenReceipt({ - params: { receiptId: target.id }, - headers: { "if-match": target.etag, "idempotency-key": retryKey }, - payload: {}, - }); - - assert.equal(reopened.body.status, "Pending"); - const after = await snapshot(target.id); - assert.deepEqual(after.receipt, { - ...before.receipt, - status: "Pending", - revision: before.receipt.revision + 1, - }); - assert.equal(after.audit.length, before.audit.length + 1); - assert.equal(after.commands.length, before.commands.length + 1); - assert.deepEqual(after.outbox, before.outbox); - assert.equal(options.attempts(), attemptsBefore); - - const replay = await client.receipts.reopenReceipt({ - params: { receiptId: target.id }, - headers: { "if-match": target.etag, "idempotency-key": retryKey }, - payload: {}, - }); - - assert.deepEqual(replay.body, reopened.body); - assert.deepEqual(await snapshot(target.id), after); - assert.equal( - (await request(target.id, "reopen", reopened.body.etag, approverCookie, retryKey)).status, - 409, - ); - await pool.query( - "UPDATE economy_receipt_approval_grants SET end_at=date_trunc('milliseconds',now(),'UTC'),revision=revision+1 WHERE approval_grant_id='receipt0097-approve'", - ); - assert.equal( - (await request(target.id, "reopen", target.etag, approverCookie, retryKey)).status, - 403, - ); - await pool.query( - "UPDATE economy_receipt_approval_grants SET end_at=NULL,revision=revision+1 WHERE approval_grant_id='receipt0097-approve'", - ); - assert.equal((await request(target.id, "reopen", reopened.body.etag)).status, 409); - const race = await rejected(); - - const results = await Promise.all([ - request(race.id, "reopen", race.etag), - request(race.id, "reopen", race.etag), - ]); - - const concurrentStatuses = results.map((r) => r.status).sort((a, b) => a - b); - assert.equal(concurrentStatuses.filter((status) => status === 200).length, 1); - assert.ok( - concurrentStatuses - .filter((status) => status !== 200) - .every((status) => status === 412 || status === 503), - ); - assert.equal( - Number( - ( - await pool.query( - "SELECT count(*) FROM economy_receipt_audit WHERE receipt_id=$1 AND action='RejectedReceiptReopened'", - [race.id], - ) - ).rows[0].count, - ), - 1, - ); - assert.equal((await request(race.id, "approve", race.etag)).status, 412); - - for (const action of ["approve", "withdraw"]) { - const receipt = await submit(); - - const closed = await request( - receipt.id, - action, - receipt.etag, - action === "withdraw" ? cookie : approverCookie, - ); - - assert.equal(closed.status, 200); - const closedBefore = await snapshot(receipt.id); - assert.equal((await request(receipt.id, "reopen", closed.headers.get("etag")!)).status, 409); - assert.deepEqual(await snapshot(receipt.id), closedBefore); - } - - // Browser journey uses real sessions from this driver's native sign-in and the built dashboard. - const browserTarget = await rejected(); - const browserBefore = await snapshot(browserTarget.id); - const requireDashboard = createRequire(join(options.root, "apps/dashboard/package.json")); - const { chromium, expect } = requireDashboard("@playwright/test"); - const { default: AxeBuilder } = requireDashboard("@axe-core/playwright"); - - const checkAxe = async (page: any, gate: string) => { - await page.evaluate( - "Promise.all(document.getAnimations().map(animation => animation.finished.catch(() => {})))", - ); - - const violations = (await new AxeBuilder({ page }).analyze()).violations.map((v: any) => ({ - id: v.id, - nodes: v.nodes.map((node: any) => ({ target: node.target, summary: node.failureSummary })), - })); - - if (violations.length > 0) { - await page.screenshot({ - path: join(options.artifactDirectory, "0102-browser-failure.png"), - fullPage: true, +import { Config, Data, Effect, Match, Option, Path, Predicate, Stream } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { ChildProcess } from "effect/unstable/process"; +import { loopbackPortFree } from "@monoweb/postgres"; +import { ReceiptId } from "@vektorprogrammet/rpc"; +import { StrongETag, IdempotencyKey } from "@vektorprogrammet/rpc/problem"; +import { nativeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; +import { auditSettledPage } from "../../apps/dashboard/e2e/settled-axe.js"; +import { jsonText, step } from "../acceptance/journey-step.js"; + +type ReceiptAction = "approve" | "reject" | "reopen" | "withdraw"; + +/** A gate of the reopening observation that failed, with what it observed. */ +class ReceiptReopenObservationFailed extends Data.TaggedError("ReceiptReopenObservationFailed")<{ + readonly message: string; +}> {} + +/** The resource of a successful receipt command; any other answer fails the observation. */ +const resourceOf = (result: ScriptCallResult, label: string): A => { + assert.ok(result.ok, `${label} answered ${result.status}`); + + return result.value; +}; + +/** The options of the reopening observation, from the 0097 delivery observation. */ +export interface ReceiptReopeningOptions { + readonly pool: Pool; + readonly origin: string; + readonly dashboardOrigin: string; + readonly cookie: string; + readonly approverCookie: string; + readonly root: string; + readonly artifactDirectory: string; + readonly attempts: () => number; + readonly accepted: () => number; + readonly setDeliveryAvailable: (available: boolean) => void; +} + +/** Observes the reopening of rejected receipts through the API and the production dashboard. */ +export const observeReceiptReopening = (options: ReceiptReopeningOptions) => + Effect.scoped( + Effect.gen(function* () { + const { pool, origin, dashboardOrigin, cookie, approverCookie } = options; + const path = yield* Path.Path; + + const native = yield* Effect.acquireRelease( + Effect.sync(() => nativeScriptClient(origin)), + (client) => Effect.promise(() => client.dispose()), + ); + + const as = (session: string) => ({ cookie: session, origin: dashboardOrigin }); + + const query = (text: string, values?: ReadonlyArray) => + step(() => pool.query(text, values === undefined ? undefined : [...values])); + + /** One receipt transition, as the session given, answered with its registry status. */ + const request = ( + id: string, + action: ReceiptAction, + etag: string, + session = approverCookie, + key: string = randomUUID(), + ) => + step(() => + native.call(as(session), (client) => { + const payload = { + receiptId: ReceiptId.make(id), + idempotencyKey: IdempotencyKey.make(key), + ifMatch: StrongETag.make(etag), + }; + + return Match.value(action).pipe( + Match.when("approve", () => client["receipts.approveReceipt"](payload)), + Match.when("reject", () => client["receipts.rejectReceipt"](payload)), + Match.when("reopen", () => client["receipts.reopenReceipt"](payload)), + Match.when("withdraw", () => client["receipts.withdrawReceipt"](payload)), + Match.exhaustive, + ); + }), + ); + + const row = (id: string) => + query("SELECT * FROM economy_receipts WHERE receipt_id=$1", [id]).pipe( + Effect.map((result) => result.rows[0]), + ); + + const snapshot = Effect.fnUntraced(function* (id: string) { + return { + receipt: yield* row(id), + audit: (yield* query( + "SELECT * FROM economy_receipt_audit WHERE receipt_id=$1 ORDER BY receipt_revision", + [id], + )).rows, + commands: (yield* query( + "SELECT * FROM economy_receipt_command_receipts WHERE receipt_id=$1 ORDER BY command_id", + [id], + )).rows, + outbox: (yield* query( + "SELECT * FROM economy_receipt_outbox WHERE receipt_id=$1 ORDER BY effect_id", + [id], + )).rows, + }; }); - throw new Error(JSON.stringify({ gate, violations })); - } - }; - - const reservation = createServer(); - await new Promise((resolve, reject) => { - reservation.once("error", reject); - reservation.listen(Number(new URL(dashboardOrigin).port), "127.0.0.1", resolve); - }); - await new Promise((resolve) => reservation.close(() => resolve())); - - const dashboardRoot = join(options.root, "apps/dashboard"); - - const environment = { - ...process.env, - API_URL: origin, - VITE_API_URL: dashboardOrigin, - DASHBOARD_ORIGIN: dashboardOrigin, - DASHBOARD_MOUNT: "/", - HOST: "127.0.0.1", - PORT: new URL(dashboardOrigin).port, - NODE_ENV: "production", - }; - - const children: ChildProcess[] = []; - const startupDiagnostics: string[] = []; - - const captureDiagnostics = (chunk: Buffer) => { - startupDiagnostics.push(String(chunk)); - - if (startupDiagnostics.length > 20) startupDiagnostics.shift(); - }; - - let browser: any; - const errors: string[] = []; - const mutations: Array<{ path: string; status: number }> = []; - - try { - for (const cwd of [join(options.root, "packages/sdk"), dashboardRoot]) { - const build = spawn("bun", ["run", "build"], { - cwd, - env: environment, - stdio: ["ignore", "ignore", "pipe"], + + const submit = Effect.fnUntraced(function* () { + const resource = resourceOf( + yield* step(() => + native.call(as(cookie), (client) => + client["receipts.submitReceipt"]({ + idempotencyKey: IdempotencyKey.make(randomUUID()), + departmentId: "receipt-department-0095", + request: { + description: "Synthetic correction 0102", + amountOre: 500, + receiptDate: "2026-09-06", + file: { + contentType: "application/pdf", + bytes: new TextEncoder().encode("%PDF-1.4\nSynthetic 0102\n%%EOF"), + }, + }, + }), + ), + ), + "synthetic submission", + ); + + return { id: resource.receiptId, etag: resource.etag }; }); - children.push(build); - build.stderr?.on("data", captureDiagnostics); - await new Promise((resolve, reject) => { - build.once("error", reject); - build.once("exit", (code) => { - if (code === 0) resolve(); - else - reject( - new Error( - `Production proof build failed in ${cwd}: exit=${code}; ${startupDiagnostics.join("").slice(-4000)}`, - ), - ); - }); + const rejected = Effect.fnUntraced(function* () { + const receipt = yield* submit(); + + const response = resourceOf( + yield* request(receipt.id, "reject", receipt.etag), + "rejection", + ); + + return { ...receipt, initialEtag: receipt.etag, etag: response.etag }; }); - } - const dashboard = spawn("bun", ["server.mjs"], { - cwd: dashboardRoot, - env: environment, - stdio: ["ignore", "ignore", "pipe"], - }); + options.setDeliveryAvailable(false); + const target = yield* rejected(); + options.setDeliveryAvailable(true); + const before = yield* snapshot(target.id); + assert.ok(before.outbox.some((item: any) => item.status === "Failed")); + const attemptsBefore = options.attempts(); + + // Invalid commands must have no persisted footprint. The payload schema requires If-Match and + // has no member for authority, so a missing revision or an extra authority cannot be sent. + for (const [name, response, expected] of [ + ["owner", yield* request(target.id, "reopen", target.etag, cookie), 403], + ["stale revision", yield* request(target.id, "reopen", target.initialEtag), 412], + ] as const) + assert.equal(response.status, expected, name); + assert.deepEqual(yield* snapshot(target.id), before); + + for (const mutation of [ + "UPDATE economy_receipt_approval_grants SET end_at=date_trunc('milliseconds',now(),'UTC'),revision=revision+1 WHERE approval_grant_id='receipt0097-approve'", + "UPDATE organization_memberships SET end_at=date_trunc('milliseconds',now(),'UTC') WHERE membership_id='receipt0097-approver-membership'", + ]) { + yield* query(mutation); + assert.equal((yield* request(target.id, "reopen", target.etag)).status, 403); + yield* query( + "UPDATE economy_receipt_approval_grants SET end_at=NULL,revision=revision+1 WHERE approval_grant_id='receipt0097-approve'", + ); + yield* query( + "UPDATE organization_memberships SET end_at=NULL WHERE membership_id='receipt0097-approver-membership'", + ); + } - children.push(dashboard); - dashboard.stderr?.on("data", captureDiagnostics); - let ready = false; + yield* query( + "INSERT INTO organization_departments(department_id,name,short_name,email,city,active) VALUES ('receipt-wrong-0102','Wrong0102','Wrong0102','wrong0102@example.invalid','Synthetic',true)", + ); + yield* query( + "UPDATE economy_receipt_approval_grants SET department_id='receipt-wrong-0102',revision=revision+1 WHERE approval_grant_id='receipt0097-approve'", + ); + assert.equal((yield* request(target.id, "reopen", target.etag)).status, 403); + yield* query( + "UPDATE economy_receipt_approval_grants SET department_id='receipt-department-0095',revision=revision+1 WHERE approval_grant_id='receipt0097-approve'", + ); + // Real database failure after mutation but before commit must roll the entire command back. + yield* query( + "CREATE FUNCTION fail_reopen_0102() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='RejectedReceiptReopened' THEN RAISE EXCEPTION 'synthetic0102'; END IF; RETURN NEW; END $$", + ); + yield* query( + "CREATE TRIGGER fail_reopen_0102 BEFORE INSERT ON economy_receipt_audit FOR EACH ROW EXECUTE FUNCTION fail_reopen_0102()", + ); + const retryKey = IdempotencyKey.make(randomUUID()); + assert.equal( + (yield* request(target.id, "reopen", target.etag, approverCookie, retryKey)).status, + 503, + ); + assert.deepEqual(yield* snapshot(target.id), before); + yield* query("DROP TRIGGER fail_reopen_0102 ON economy_receipt_audit"); + yield* query("DROP FUNCTION fail_reopen_0102()"); - for (let n = 0; n < 100; n++) { - if (dashboard.exitCode !== null || dashboard.signalCode !== null) break; + const reopened = resourceOf( + yield* request(target.id, "reopen", target.etag, approverCookie, retryKey), + "reopening", + ); - try { - if ((await fetch(`${dashboardOrigin}/login`, { redirect: "manual" })).status === 200) { - ready = true; - break; - } - } catch {} - - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - assert.ok( - ready, - `production dashboard startup: exit=${dashboard.exitCode}; ${startupDiagnostics.join("").slice(-4000)}`, - ); - browser = await chromium.launch({ - executablePath: - process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ?? - "/etc/profiles/per-user/nori/bin/chromium-browser", - headless: true, - }); - - const context = async (session: string) => { - const value = await browser.newContext({ viewport: { width: 390, height: 844 } }); - const equals = session.indexOf("="); - await value.addCookies([ - { - name: session.slice(0, equals), - value: session.slice(equals + 1), - url: dashboardOrigin, - httpOnly: true, - sameSite: "Lax", - }, - ]); - const page = await value.newPage(); - page.on("response", (response: any) => { - if (response.request().method() === "POST") - mutations.push({ path: new URL(response.url()).pathname, status: response.status() }); + assert.equal(reopened.status, "Pending"); + const after = yield* snapshot(target.id); + assert.deepEqual(after.receipt, { + ...before.receipt, + status: "Pending", + revision: before.receipt.revision + 1, }); - page.on("pageerror", () => errors.push("browser runtime error")); - page.on("console", (message: any) => { - if (message.type() === "error") { - const location = message.location(); - const path = location.url ? new URL(location.url).pathname : "unknown"; - const text = message.text(); - errors.push( - JSON.stringify({ - kind: "browser console error", - path, - // Project common network diagnostics without arbitrary browser text or URLs. - networkStatus: text.match(/server responded with a status of (\d+)/)?.[1] ?? null, - hydration: /hydrat/i.test(text), + assert.equal(after.audit.length, before.audit.length + 1); + assert.equal(after.commands.length, before.commands.length + 1); + assert.deepEqual(after.outbox, before.outbox); + assert.equal(options.attempts(), attemptsBefore); + + const replay = resourceOf( + yield* request(target.id, "reopen", target.etag, approverCookie, retryKey), + "reopening replay", + ); + + assert.deepEqual(replay, reopened); + assert.deepEqual(yield* snapshot(target.id), after); + assert.equal( + (yield* request(target.id, "reopen", reopened.etag, approverCookie, retryKey)).status, + 409, + ); + yield* query( + "UPDATE economy_receipt_approval_grants SET end_at=date_trunc('milliseconds',now(),'UTC'),revision=revision+1 WHERE approval_grant_id='receipt0097-approve'", + ); + assert.equal( + (yield* request(target.id, "reopen", target.etag, approverCookie, retryKey)).status, + 403, + ); + yield* query( + "UPDATE economy_receipt_approval_grants SET end_at=NULL,revision=revision+1 WHERE approval_grant_id='receipt0097-approve'", + ); + assert.equal((yield* request(target.id, "reopen", reopened.etag)).status, 409); + const race = yield* rejected(); + + const results = yield* Effect.all( + [request(race.id, "reopen", race.etag), request(race.id, "reopen", race.etag)], + { concurrency: "unbounded" }, + ); + + const concurrentStatuses = results.map((r) => r.status).sort((a, b) => a - b); + assert.equal(concurrentStatuses.filter((status) => status === 200).length, 1); + assert.ok( + concurrentStatuses + .filter((status) => status !== 200) + .every((status) => status === 412 || status === 503), + ); + assert.equal( + Number( + (yield* query( + "SELECT count(*) FROM economy_receipt_audit WHERE receipt_id=$1 AND action='RejectedReceiptReopened'", + [race.id], + )).rows[0].count, + ), + 1, + ); + assert.equal((yield* request(race.id, "approve", race.etag)).status, 412); + + for (const action of ["approve", "withdraw"] as const) { + const receipt = yield* submit(); + + const closed = yield* request( + receipt.id, + action, + receipt.etag, + action === "withdraw" ? cookie : approverCookie, + ); + + const closedEtag = resourceOf(closed, `${action} of a pending receipt`).etag; + const closedBefore = yield* snapshot(receipt.id); + assert.equal((yield* request(receipt.id, "reopen", closedEtag)).status, 409); + assert.deepEqual(yield* snapshot(receipt.id), closedBefore); + } + + // Browser journey uses real sessions from this driver's native sign-in and the built dashboard. + const browserTarget = yield* rejected(); + const browserBefore = yield* snapshot(browserTarget.id); + + const requireDashboard = createRequire( + path.join(options.root, "apps/dashboard/package.json"), + ); + + const { chromium, expect } = requireDashboard("@playwright/test"); + + const checkAxe = Effect.fnUntraced(function* (page: any, gate: string) { + const violations = yield* step(() => auditSettledPage(page)); + + if (violations.length > 0) { + yield* step(() => + page.screenshot({ + path: path.join(options.artifactDirectory, "0102-browser-failure.png"), + fullPage: true, }), ); + + return yield* new ReceiptReopenObservationFailed({ + message: yield* jsonText({ gate, violations }), + }); + } + }); + + // The production dashboard binds the reserved port of this rehearsal; it must still be free. + const dashboardPort = new URL(dashboardOrigin).port; + + assert.ok( + yield* step(() => loopbackPortFree(Number(dashboardPort))), + `dashboard port ${dashboardPort} is free`, + ); + + const dashboardRoot = path.join(options.root, "apps/dashboard"); + + const environment = { + API_URL: origin, + VITE_API_URL: dashboardOrigin, + DASHBOARD_ORIGIN: dashboardOrigin, + DASHBOARD_MOUNT: "/", + HOST: "127.0.0.1", + PORT: dashboardPort, + NODE_ENV: "production", + }; + + const startupDiagnostics: string[] = []; + + const captureDiagnostics = (chunk: string) => { + startupDiagnostics.push(chunk); + + if (startupDiagnostics.length > 20) startupDiagnostics.shift(); + }; + + // The build and the server run in this scope, which stops each process group, SIGTERM first + // and SIGKILL after five seconds. + const owned = (args: ReadonlyArray) => + ChildProcess.make("bun", args, { + cwd: dashboardRoot, + env: environment, + extendEnv: true, + stdin: "ignore", + stdout: "ignore", + forceKillAfter: "5 seconds", + }); + + const build = yield* owned(["run", "build"]); + + const [, buildCode] = yield* Effect.all( + [ + build.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((text) => Effect.sync(() => captureDiagnostics(text))), + ), + build.exitCode, + ], + { concurrency: "unbounded" }, + ); + + if (buildCode !== 0) + return yield* new ReceiptReopenObservationFailed({ + message: `Production proof build failed in ${dashboardRoot}: exit=${buildCode}; ${startupDiagnostics.join("").slice(-4000)}`, + }); + + const dashboard = yield* owned(["server.mjs"]); + + yield* dashboard.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((text) => Effect.sync(() => captureDiagnostics(text))), + Effect.ignore, + Effect.forkScoped, + ); + + const client = yield* HttpClient.HttpClient; + + const loginAnswers = client.get(`${dashboardOrigin}/login`).pipe( + Effect.map((response) => response.status === 200), + Effect.orElseSucceed(() => false), + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + ); + + let ready = false; + + for (let n = 0; n < 100; n++) { + if (!(yield* dashboard.isRunning)) break; + + if (yield* loginAnswers) { + ready = true; + break; } + + yield* Effect.sleep("100 millis"); + } + + const dashboardExit = (yield* dashboard.isRunning) + ? "null" + : String(yield* dashboard.exitCode.pipe(Effect.orElseSucceed(() => "null"))); + + assert.ok( + ready, + `production dashboard startup: exit=${dashboardExit}; ${startupDiagnostics.join("").slice(-4000)}`, + ); + + const executablePath = Option.getOrElse( + yield* Config.option(Config.String("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH")), + () => "/etc/profiles/per-user/nori/bin/chromium-browser", + ); + + const browser = yield* Effect.acquireRelease( + step(() => chromium.launch({ executablePath, headless: true })), + (launched: any) => Effect.promise(() => launched.close()), + ); + + const errors: string[] = []; + const mutations: Array<{ path: string; status: number }> = []; + + const context = Effect.fnUntraced(function* (session: string) { + const value = yield* step(() => + browser.newContext({ viewport: { width: 390, height: 844 } }), + ); + + const equals = session.indexOf("="); + yield* step(() => + value.addCookies([ + { + name: session.slice(0, equals), + value: session.slice(equals + 1), + url: dashboardOrigin, + httpOnly: true, + sameSite: "Lax", + }, + ]), + ); + const page = yield* step(() => value.newPage()); + page.on("response", (response: any) => { + if (response.request().method() === "POST") + mutations.push({ path: new URL(response.url()).pathname, status: response.status() }); + }); + page.on("pageerror", () => errors.push("browser runtime error")); + page.on("console", (message: any) => { + if (message.type() === "error") { + const location = message.location(); + + const path = + Predicate.isString(location.url) && location.url.length > 0 + ? new URL(location.url).pathname + : "unknown"; + + const text = message.text(); + errors.push( + JSON.stringify({ + kind: "browser console error", + path, + // Project common network diagnostics without arbitrary browser text or URLs. + networkStatus: text.match(/server responded with a status of (\d+)/)?.[1] ?? null, + hydration: /hydrat/i.test(text), + }), + ); + } + }); + + return page; }); - return page; - }; - - const ownerPage = await context(cookie), - approverPage = await context(approverCookie); - - const ownedRow = ownerPage.locator(`tr[data-receipt-id="${browserTarget.id}"]`); - await ownerPage.goto(`${dashboardOrigin}/dashboard/mine-utlegg`); - await expect(ownedRow.locator('[data-status="Rejected"]')).toBeVisible(); - await expect(ownedRow.getByRole("button", { name: "Rediger", exact: true })).toHaveCount(0); - await approverPage.goto(`${dashboardOrigin}/dashboard/utlegg?status=Rejected`); - const approvalRow = approverPage.locator(`tr[data-receipt-id="${browserTarget.id}"]`); - await expect(approvalRow).toBeVisible(); - const button = approvalRow.getByRole("button", { name: "Åpne for korrigering", exact: true }); - await button.focus(); - await approverPage.keyboard.press("Enter"); - await expect(approverPage.getByRole("alertdialog")).toBeVisible(); - await checkAxe(approverPage, "approval accessibility"); - await approverPage.screenshot({ - path: join(options.artifactDirectory, "0102-reopen-mobile.png"), - fullPage: true, - }); - const browserAttempts = options.attempts(); - await approverPage.getByRole("button", { name: "Bekreft gjenåpning", exact: true }).click(); - - try { - await expect( - approverPage.locator('[role="status"][data-action-intent="reopen"]'), - ).toContainText("åpnet for korrigering"); - } catch { - await approverPage.screenshot({ - path: join(options.artifactDirectory, "0102-browser-failure.png"), - fullPage: true, + const ownerPage = yield* context(cookie), + approverPage = yield* context(approverCookie); + + const ownedRow = ownerPage.locator(`tr[data-receipt-id="${browserTarget.id}"]`); + yield* step(() => ownerPage.goto(`${dashboardOrigin}/dashboard/mine-utlegg`)); + yield* step(() => expect(ownedRow.locator('[data-status="Rejected"]')).toBeVisible()); + yield* step(() => + expect(ownedRow.getByRole("button", { name: "Rediger", exact: true })).toHaveCount(0), + ); + yield* step(() => approverPage.goto(`${dashboardOrigin}/dashboard/utlegg?status=Rejected`)); + const approvalRow = approverPage.locator(`tr[data-receipt-id="${browserTarget.id}"]`); + yield* step(() => expect(approvalRow).toBeVisible()); + const button = approvalRow.getByRole("button", { name: "Åpne for korrigering", exact: true }); + yield* step(() => button.focus()); + yield* step(() => approverPage.keyboard.press("Enter")); + yield* step(() => expect(approverPage.getByRole("alertdialog")).toBeVisible()); + yield* checkAxe(approverPage, "approval accessibility"); + yield* step(() => + approverPage.screenshot({ + path: path.join(options.artifactDirectory, "0102-reopen-mobile.png"), + fullPage: true, + }), + ); + const browserAttempts = options.attempts(); + yield* step(() => + approverPage.getByRole("button", { name: "Bekreft gjenåpning", exact: true }).click(), + ); + + yield* step(() => + expect(approverPage.locator('[role="status"][data-action-intent="reopen"]')).toContainText( + "åpnet for korrigering", + ), + ).pipe( + Effect.catch(() => + Effect.gen(function* () { + yield* step(() => + approverPage.screenshot({ + path: path.join(options.artifactDirectory, "0102-browser-failure.png"), + fullPage: true, + }), + ); + + return yield* new ReceiptReopenObservationFailed({ + message: yield* jsonText({ + gate: "browser reopen confirmation", + mutations, + alerts: yield* step(() => approverPage.locator('[role="alert"]').allTextContents()), + path: new URL(approverPage.url()).pathname, + sqlState: (yield* row(browserTarget.id)).status, + browserErrors: errors, + }), + }); + }), + ), + ); + + yield* step(() => expect(approvalRow).toHaveCount(0)); + assert.equal(options.attempts(), browserAttempts); + const browserReopened = yield* snapshot(browserTarget.id); + assert.deepEqual(browserReopened.receipt, { + ...browserBefore.receipt, + status: "Pending", + revision: 2, }); - throw new Error( - JSON.stringify({ - gate: "browser reopen confirmation", - mutations, - alerts: await approverPage.locator('[role="alert"]').allTextContents(), - path: new URL(approverPage.url()).pathname, - sqlState: (await row(browserTarget.id)).status, - browserErrors: errors, + assert.deepEqual(browserReopened.outbox, browserBefore.outbox); + yield* step(() => ownerPage.reload()); + yield* step(() => ownedRow.getByRole("button", { name: "Rediger", exact: true }).click()); + + const edit = ownerPage + .locator('[data-receipt-form="revise"]') + .filter({ has: ownerPage.locator(`input[name="receiptId"][value="${browserTarget.id}"]`) }); + + yield* step(() => edit.locator('[name="description"]').fill("Corrected same claim 0102")); + yield* step(() => edit.locator('[name="amountNok"]').fill("6,00")); + yield* checkAxe(ownerPage, "owner correction accessibility"); + const editorBounds = yield* step(() => edit.boundingBox()); + assert.ok( + editorBounds !== null && + editorBounds !== undefined && + editorBounds.x >= 0 && + editorBounds.x + editorBounds.width <= 390, + "mobile correction editor fits viewport", + ); + yield* step(() => + ownerPage.screenshot({ + path: path.join(options.artifactDirectory, "0102-correction-mobile.png"), + fullPage: true, }), ); - } - - await expect(approvalRow).toHaveCount(0); - assert.equal(options.attempts(), browserAttempts); - const browserReopened = await snapshot(browserTarget.id); - assert.deepEqual(browserReopened.receipt, { - ...browserBefore.receipt, - status: "Pending", - revision: 2, - }); - assert.deepEqual(browserReopened.outbox, browserBefore.outbox); - await ownerPage.reload(); - await ownedRow.getByRole("button", { name: "Rediger", exact: true }).click(); - - const edit = ownerPage - .locator('[data-receipt-form="revise"]') - .filter({ has: ownerPage.locator(`input[name="receiptId"][value="${browserTarget.id}"]`) }); - - await edit.locator('[name="description"]').fill("Corrected same claim 0102"); - await edit.locator('[name="amountNok"]').fill("6,00"); - await checkAxe(ownerPage, "owner correction accessibility"); - const editorBounds = await edit.boundingBox(); - assert.ok( - editorBounds && editorBounds.x >= 0 && editorBounds.x + editorBounds.width <= 390, - "mobile correction editor fits viewport", - ); - await ownerPage.screenshot({ - path: join(options.artifactDirectory, "0102-correction-mobile.png"), - fullPage: true, - }); - await edit.getByRole("button", { name: "Lagre endringer", exact: true }).click(); - await expect(ownedRow).toContainText("Corrected same claim 0102"); - await approverPage.goto(`${dashboardOrigin}/dashboard/utlegg?status=Pending`); - await expect(approvalRow).toContainText("Corrected same claim 0102"); - await approvalRow.getByRole("button", { name: "Avvis", exact: true }).click(); - await checkAxe(approverPage, "approval accessibility"); - await approverPage.getByRole("button", { name: "Bekreft avvisning", exact: true }).hover(); - await checkAxe(approverPage, "destructive hover accessibility"); - const acceptedBefore = options.accepted(); - await approverPage.getByRole("button", { name: "Bekreft avvisning", exact: true }).click(); - await expect( - approverPage.locator('[role="status"][data-action-intent="reject"]'), - ).toContainText("avvist"); - assert.equal(options.accepted(), acceptedBefore + 1); - await ownerPage.reload(); - await expect(ownedRow.locator('[data-status="Rejected"]')).toBeVisible(); - await expect(ownedRow.getByRole("button", { name: "Rediger", exact: true })).toHaveCount(0); - await approverPage.setViewportSize({ width: 1440, height: 1000 }); - await approverPage.goto(`${dashboardOrigin}/dashboard/utlegg?status=Rejected`); - await expect(approvalRow).toContainText("Corrected same claim 0102"); - await checkAxe(approverPage, "approval accessibility"); - await approverPage.screenshot({ - path: join(options.artifactDirectory, "0102-rejected-desktop.png"), - fullPage: true, - }); - const final = await snapshot(browserTarget.id); - assert.equal(final.receipt.receipt_id, browserTarget.id); - assert.equal(final.receipt.amount_ore, "600"); - assert.equal(final.receipt.revision, 4); - assert.deepEqual( - final.audit.map((r: any) => r.action), - [ - "ReceiptSubmitted", - "ReceiptRejected", - "RejectedReceiptReopened", - "PendingReceiptRevised", - "ReceiptRejected", - ], - ); - assert.ok(final.outbox.every((r: any) => r.status === "Delivered")); - assert.deepEqual(errors, []); - - return { - specId: "0102", - browser: true, - mobileKeyboard: true, - axe: "zero violations in reopen and correction views", - sameIdentityContentOnReopen: true, - noReopenEffects: true, - finalAcknowledgedRejection: true, - authorityAndTerminalDenials: true, - exactReplayAndRevokedReplay: true, - concurrentSingleWinner: concurrentStatuses, - persistenceFailureRollbackRetry: true, - auditActions: final.audit.map((r: any) => r.action), - scope: "synthetic loopback; transport acknowledgement, no human delivery or payment claim", - }; - } finally { - if (browser) await browser.close(); - - for (const child of children) { - if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) continue; - - const exited = new Promise((resolve) => child.once("exit", resolve)); - child.kill("SIGTERM"); - const timer = setTimeout(() => child.kill("SIGKILL"), 5000); - - try { - await exited; - } finally { - clearTimeout(timer); - } - } - } -} + yield* step(() => edit.getByRole("button", { name: "Lagre endringer", exact: true }).click()); + yield* step(() => expect(ownedRow).toContainText("Corrected same claim 0102")); + yield* step(() => approverPage.goto(`${dashboardOrigin}/dashboard/utlegg?status=Pending`)); + yield* step(() => expect(approvalRow).toContainText("Corrected same claim 0102")); + yield* step(() => approvalRow.getByRole("button", { name: "Avvis", exact: true }).click()); + yield* checkAxe(approverPage, "approval accessibility"); + yield* step(() => + approverPage.getByRole("button", { name: "Bekreft avvisning", exact: true }).hover(), + ); + yield* checkAxe(approverPage, "destructive hover accessibility"); + const acceptedBefore = options.accepted(); + yield* step(() => + approverPage.getByRole("button", { name: "Bekreft avvisning", exact: true }).click(), + ); + yield* step(() => + expect(approverPage.locator('[role="status"][data-action-intent="reject"]')).toContainText( + "avvist", + ), + ); + assert.equal(options.accepted(), acceptedBefore + 1); + yield* step(() => ownerPage.reload()); + yield* step(() => expect(ownedRow.locator('[data-status="Rejected"]')).toBeVisible()); + yield* step(() => + expect(ownedRow.getByRole("button", { name: "Rediger", exact: true })).toHaveCount(0), + ); + yield* step(() => approverPage.setViewportSize({ width: 1440, height: 1000 })); + yield* step(() => approverPage.goto(`${dashboardOrigin}/dashboard/utlegg?status=Rejected`)); + yield* step(() => expect(approvalRow).toContainText("Corrected same claim 0102")); + yield* checkAxe(approverPage, "approval accessibility"); + yield* step(() => + approverPage.screenshot({ + path: path.join(options.artifactDirectory, "0102-rejected-desktop.png"), + fullPage: true, + }), + ); + const final = yield* snapshot(browserTarget.id); + assert.equal(final.receipt.receipt_id, browserTarget.id); + assert.equal(final.receipt.amount_ore, "600"); + assert.equal(final.receipt.revision, 4); + assert.deepEqual( + final.audit.map((r: any) => r.action), + [ + "ReceiptSubmitted", + "ReceiptRejected", + "RejectedReceiptReopened", + "PendingReceiptRevised", + "ReceiptRejected", + ], + ); + assert.ok(final.outbox.every((r: any) => r.status === "Delivered")); + assert.deepEqual(errors, []); + + return { + specId: "0102", + browser: true, + mobileKeyboard: true, + axe: "zero violations in reopen and correction views", + sameIdentityContentOnReopen: true, + noReopenEffects: true, + finalAcknowledgedRejection: true, + authorityAndTerminalDenials: true, + exactReplayAndRevokedReplay: true, + concurrentSingleWinner: concurrentStatuses, + persistenceFailureRollbackRetry: true, + auditActions: final.audit.map((r: any) => r.action), + scope: "synthetic loopback; transport acknowledgement, no human delivery or payment claim", + }; + }), + ); diff --git a/tools/verification/recommendation-preupgrade-fixture.ts b/tools/verification/recommendation-preupgrade-fixture.ts index 6e3b9eaff..fd54b22c9 100644 --- a/tools/verification/recommendation-preupgrade-fixture.ts +++ b/tools/verification/recommendation-preupgrade-fixture.ts @@ -1,22 +1,36 @@ /** A real previous-schema fixture: execute canonical migrations through0036, seed immutable history, then caller applies0037 normally. */ -import { readFile } from "node:fs/promises"; -import { execFileSync } from "node:child_process"; -import { resolve } from "node:path"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; import * as PgClient from "@effect/sql-pg/PgClient"; -import { Schema, Effect, Redacted } from "effect"; +import { Config, Effect, FileSystem, Option, Path, Redacted, Schema, Stream } from "effect"; import * as Migrator from "effect/unstable/sql/Migrator"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { ChildProcess } from "effect/unstable/process"; import { Pool } from "pg"; import { databaseMigrationDefinitions } from "@vektorprogrammet/database/migrations"; -const url = process.env.JOURNEY_SEED_PG_URL!; - -if (new URL(url).hostname !== "127.0.0.1") throw new Error("Loopback fixture only"); +/** A step of the fixture that failed, with what it observed. */ +class PreupgradeFixtureFailure extends Schema.TaggedError()( + "PreupgradeFixtureFailure", + { message: Schema.String }, +) {} type MigrationDefinition = { readonly id: string; readonly url: URL }; -const applyMigrations = async (definitions: ReadonlyArray): Promise => { - await Effect.runPromise( +const program = Effect.gen(function* () { + const url = yield* Config.String("JOURNEY_SEED_PG_URL"); + + const throughMigration0038 = yield* Config.option( + Config.String("RECOMMENDATION_PREUPGRADE_THROUGH_0038"), + ); + + if (new URL(url).hostname !== "127.0.0.1") + return yield* PreupgradeFixtureFailure.make({ message: "Loopback fixture only" }); + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const applyMigrations = (definitions: ReadonlyArray) => Migrator.make({})({ table: "vektorprogrammet_schema_migrations", loader: Migrator.fromRecord( @@ -25,34 +39,66 @@ const applyMigrations = async (definitions: ReadonlyArray): d.id, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const source = yield* Effect.promise(() => readFile(d.url, "utf8")); + + const source = yield* path + .fromFileUrl(d.url) + .pipe(Effect.flatMap(fileSystem.readFileString), Effect.orDie); + yield* sql.unsafe(source).raw; }), ]), ), ), - }).pipe(Effect.provide(PgClient.layer({ url: Redacted.make(url) }))), + }).pipe(Effect.provide(PgClient.layer({ url: Redacted.make(url) }))); + + // The conduct seed runs with this environment and skips identity; its output is kept for a failure. + const runConductSeed = Effect.scoped( + Effect.gen(function* () { + const seed = yield* ChildProcess.make( + "bun", + ["apps/dashboard/e2e/native-conduct-journey-seed.mjs"], + { + cwd: path.resolve(import.meta.dirname, "../.."), + env: { CONDUCT_SEED_SKIP_IDENTITY: "1" }, + extendEnv: true, + stdin: "ignore", + }, + ); + + const [output, exitCode] = yield* Effect.all( + [seed.all.pipe(Stream.decodeText(), Stream.mkString), seed.exitCode], + { concurrency: "unbounded" }, + ); + + if (exitCode !== 0) + return yield* PreupgradeFixtureFailure.make({ + message: `conduct seed exited ${exitCode}: ${output}`, + }); + }), ); -}; -const previous = databaseMigrationDefinitions.filter((d) => Number(d.id.split("_")[0]) < 37); + const previous = databaseMigrationDefinitions.filter((d) => Number(d.id.split("_")[0]) < 37); -await applyMigrations(previous); + yield* applyMigrations(previous); -const pool = new Pool({ connectionString: url }); + const pool = yield* Effect.acquireRelease( + Effect.sync(() => new Pool({ connectionString: url })), + (owned) => Effect.promise(() => owned.end()), + ); + + const query = (text: string, values?: ReadonlyArray) => + Effect.tryPromise({ + try: () => pool.query(text, values === undefined ? undefined : [...values]), + catch: (cause) => PreupgradeFixtureFailure.make({ message: String(cause) }), + }); -try { - await pool.query( + yield* query( `INSERT INTO public.person_profiles(person_id,first_name,last_name,revision) VALUES ('journey-conduct-leader-0063','Lina','Lagleder',0), ('journey-conduct-applicant-0063','Sofie','Gjennomfører',0)`, ); - execFileSync("bun", ["apps/dashboard/e2e/native-conduct-journey-seed.mjs"], { - cwd: resolve(import.meta.dirname, "../.."), - env: { ...process.env, CONDUCT_SEED_SKIP_IDENTITY: "1" }, - stdio: "pipe", - }); - await pool.query("BEGIN"); + yield* runConductSeed; + yield* query("BEGIN"); for (const suffix of ["maybe", "no", "history", "self", "link-race", "read-race"] as const) { const applicant = `applicant-recommendation-${suffix}`, @@ -60,33 +106,33 @@ try { interview = `interview-recommendation-${suffix}`, invitation = `invitation-recommendation-${suffix}`; - const clone = async (table: string, where: string, values: Record) => - pool.query( + const clone = (table: string, where: string, values: Record) => + query( `INSERT INTO public.${table} SELECT (jsonb_populate_record(NULL::public.${table},to_jsonb(source)||$1::jsonb)).* FROM public.${table} source WHERE ${where}`, [JSON.stringify(values)], ); - await clone("admission_applicants", "applicant_id='applicant-native-conduct-a-0063'", { + yield* clone("admission_applicants", "applicant_id='applicant-native-conduct-a-0063'", { applicant_id: applicant, email: `${suffix}@example.invalid`, normalized_email: `${suffix}@example.invalid`, first_name: suffix, last_name: "Recommendation", }); - await clone("admission_applications", "application_id='application-native-conduct-a-0063'", { + yield* clone("admission_applications", "application_id='application-native-conduct-a-0063'", { application_id: application, applicant_id: applicant, }); - await clone("recruitment_interviews", "interview_id='interview-native-conduct-a-0063'", { + yield* clone("recruitment_interviews", "interview_id='interview-native-conduct-a-0063'", { interview_id: interview, application_id: application, }); - await clone( + yield* clone( "recruitment_interview_schedules", "interview_id='interview-native-conduct-a-0063'", { interview_id: interview }, ); - await clone("recruitment_invitations", "invitation_id='invitation-native-conduct-a-0063'", { + yield* clone("recruitment_invitations", "invitation_id='invitation-native-conduct-a-0063'", { invitation_id: invitation, interview_id: interview, capability_sha256: { @@ -98,7 +144,7 @@ try { "read-race": "8", }[suffix]!.repeat(64), }); - await clone( + yield* clone( "recruitment_invitation_response_audit", "invitation_id='invitation-native-conduct-a-0063'", { @@ -106,24 +152,24 @@ try { interview_id: interview, }, ); - await clone( + yield* clone( "recruitment_interview_question_snapshots", "interview_id='interview-native-conduct-a-0063'", { interview_id: interview }, ); } - await pool.query( + yield* query( `INSERT INTO public.recruitment_interview_conducts(interview_id,answers,explanatory_power,role_model,suitability,finalized_by_person_id,finalized_at,interview_revision) VALUES('interview-recommendation-history','[]',7,8,9,'journey-conduct-leader-0063',date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC'),1)`, ); // Ordinary assigned member is the runtime subject, not an accidentally privileged fixture. - await pool.query( + yield* query( `UPDATE public.organization_memberships SET is_team_leader=false,position_id='member' WHERE membership_id='membership-native-conduct-leader-0063'`, ); - await pool.query("COMMIT"); + yield* query("COMMIT"); - if (process.env.RECOMMENDATION_PREUPGRADE_THROUGH_0038 === "1") { - await applyMigrations( + if (Option.contains(throughMigration0038, "1")) { + yield* applyMigrations( databaseMigrationDefinitions.filter((d) => { const id = Number(d.id.split("_")[0]); @@ -131,6 +177,6 @@ try { }), ); } -} finally { - await pool.end(); -} +}); + +BunRuntime.runMain(program.pipe(Effect.scoped, Effect.provide(BunServices.layer))); diff --git a/tools/verification/tsconfig.bun.json b/tools/verification/tsconfig.bun.json new file mode 100644 index 000000000..fcbf24407 --- /dev/null +++ b/tools/verification/tsconfig.bun.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "bun"] + }, + "files": [ + "completion-receipt-postgres-proof-main.ts", + "receipt-import-rehearsal.ts", + "unattended-delivery-recovery.ts" + ], + "include": [], + "exclude": [] +} diff --git a/tools/verification/tsconfig.json b/tools/verification/tsconfig.json index 3e6fea709..946983221 100644 --- a/tools/verification/tsconfig.json +++ b/tools/verification/tsconfig.json @@ -4,5 +4,11 @@ "noEmit": true, "types": ["node"] }, - "include": ["*.ts"] + "include": ["*.ts"], + // These entries serve loopback receivers on a Bun HTTP server, whose types tsconfig.bun.json adds. + "exclude": [ + "completion-receipt-postgres-proof-main.ts", + "receipt-import-rehearsal.ts", + "unattended-delivery-recovery.ts" + ] } diff --git a/tools/verification/unattended-delivery-recovery.ts b/tools/verification/unattended-delivery-recovery.ts index 7245f3ea2..6933eab3c 100644 --- a/tools/verification/unattended-delivery-recovery.ts +++ b/tools/verification/unattended-delivery-recovery.ts @@ -1,751 +1,929 @@ -import { Predicate, Schema } from "effect"; -import { setTimeout as pause } from "node:timers/promises"; -import { createServer as createHttpServer } from "node:http"; +import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; import assert from "node:assert/strict"; -import { execFileSync, spawn, type ChildProcess } from "node:child_process"; import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import process from "node:process"; +import { + Clock, + Config, + Console, + Data, + DateTime, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + Option, + Path, + Predicate, + Schema, + Stream, +} from "effect"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http"; +import { ChildProcess, type ChildProcessSpawner } from "effect/unstable/process"; import { Pool } from "pg"; +import { IdempotencyKey } from "@vektorprogrammet/rpc/problem"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { type DisposablePostgres, loopbackPortFree, reserveLoopbackPorts, startDisposablePostgres, } from "@monoweb/postgres"; +import { runCommand } from "../scripts/command.js"; +import { indentedJsonText } from "../acceptance/acceptance-process.js"; +import { jsonText, step, surfaceStepFailure } from "../acceptance/journey-step.js"; -const root = new URL("../../", import.meta.url).pathname; +/** A bound of the proof that an observation or a process outlived. */ +class RecoveryTimeout extends Data.TaggedError("RecoveryTimeout")<{ readonly message: string }> {} -const safeEnvironment: NodeJS.ProcessEnv = Object.fromEntries( - ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "LD_LIBRARY_PATH"].flatMap((key) => - process.env[key] === undefined ? [] : [[key, process.env[key]!]], - ), -); +/** A command of the proof that exited with a code other than 0. */ +class RecoveryCommandFailed extends Data.TaggedError("RecoveryCommandFailed")<{ + readonly message: string; +}> {} + +const DeliveryBody = Schema.fromJsonString(Schema.Struct({ deliveryId: Schema.String })); + +type Environment = Readonly>; -const run = (command: string, args: string[], env = safeEnvironment) => - execFileSync(command, args, { - cwd: root, - env, - encoding: "utf8", - timeout: 60_000, - stdio: ["ignore", "pipe", "pipe"], +interface NativeProcess { + readonly child: ChildProcessSpawner.ChildProcessHandle; + readonly logs: string[]; + /** The exit code, or null for an exit on a signal. */ + readonly exited: Effect.Effect; +} + +/** Sends a signal to the process itself, as `ChildProcess.kill` of Node did. */ +const signal = (owned: NativeProcess, name: NodeJS.Signals) => + Effect.sync(() => { + process.kill(owned.child.pid, name); }); -assert.equal(run("git", ["status", "--porcelain"]).trim(), "", "requires committed source"); +/** The exit code of the process within ten seconds, else the proof fails with `message`. */ +const exitWithin = (owned: NativeProcess, message: string) => + owned.exited.pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.fail(new RecoveryTimeout({ message })), + }), + ); + +const proof = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const join = path.join; + const root = new URL("../../", import.meta.url).pathname; -const revision = run("git", ["rev-parse", "HEAD"]).trim(); + const safeEnvironment: Record = {}; -const sourceTree = run("git", ["rev-parse", "HEAD^{tree}"]).trim(); + for (const key of ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "LD_LIBRARY_PATH"]) { + const value = yield* Config.option(Config.String(key)); -const artifacts = await mkdtemp(join(tmpdir(), "vektor-delivery-recovery-")); + if (Option.isSome(value)) safeEnvironment[key] = value.value; + } -const privateRoot = join(artifacts, "private"); + /** Runs a command with exactly `env`, for at most a minute, and answers its standard output. */ + const run = (command: string, args: ReadonlyArray, env: Environment = safeEnvironment) => + runCommand( + ChildProcess.make(command, args, { + cwd: root, + env: { ...env }, + extendEnv: false, + stdin: "ignore", + }), + ).pipe( + Effect.timeoutOrElse({ + duration: "60 seconds", + orElse: () => Effect.fail(new RecoveryTimeout({ message: `${command} timed out` })), + }), + Effect.flatMap((result) => + result.status === 0 + ? Effect.succeed(result.stdout) + : Effect.fail( + new RecoveryCommandFailed({ + message: `${command} exited with ${result.status}: ${result.stderr}`, + }), + ), + ), + ); -await mkdir(privateRoot, { mode: 0o700 }); + assert.equal( + (yield* run("git", ["status", "--porcelain"])).trim(), + "", + "requires committed source", + ); -process.stdout.write(`evidence: ${artifacts}\n`); + const revision = (yield* run("git", ["rev-parse", "HEAD"])).trim(); -const eventually = async (label: string, inspect: () => Promise, timeout = 20_000) => { - const deadline = Date.now() + timeout; + const sourceTree = (yield* run("git", ["rev-parse", "HEAD^{tree}"])).trim(); - while (Date.now() < deadline) { - if (await inspect()) return; - await pause(50); - } + const artifacts = yield* fileSystem.makeTempDirectory({ prefix: "vektor-delivery-recovery-" }); - throw new Error(`Observation timeout: ${label}`); -}; + const privateRoot = join(artifacts, "private"); -interface NativeProcess { - child: ChildProcess; - logs: string[]; - exited: Promise; -} + yield* fileSystem.makeDirectory(privateRoot, { mode: 0o700 }); -const processes: NativeProcess[] = []; + yield* Console.log(`evidence: ${artifacts}`); -const start = (env: NodeJS.ProcessEnv) => { - const child = spawn(process.execPath, ["--no-env-file", "apps/backend/src/main.ts"], { - cwd: root, - env, - stdio: ["ignore", "pipe", "pipe"], - }); + const eventually = ( + label: string, + inspect: Effect.Effect, + timeout = 20_000, + ) => + Effect.gen(function* () { + const deadline = (yield* Clock.currentTimeMillis) + timeout; - const logs: string[] = []; - child.stdout!.on("data", (chunk) => logs.push(String(chunk))); - child.stderr!.on("data", (chunk) => logs.push(String(chunk))); - const exit = Promise.withResolvers(); - child.once("error", exit.reject); - child.once("exit", exit.resolve); - const exited = exit.promise; - const owned = { child, logs, exited }; - processes.push(owned); - - return owned; -}; - -const stop = async (process: NativeProcess, signal: NodeJS.Signals = "SIGTERM") => { - if (process.child.exitCode !== null || process.child.signalCode !== null) return; - process.child.kill(signal); - - const code = await Promise.race([ - process.exited, - pause(10_000).then(() => { - throw new Error("Native shutdown timeout"); - }), - ]); + while ((yield* Clock.currentTimeMillis) < deadline) { + if (yield* inspect) return; + yield* Effect.sleep("50 millis"); + } - if (signal === "SIGTERM") assert.equal(code, 0, "graceful shutdown"); -}; + return yield* new RecoveryTimeout({ message: `Observation timeout: ${label}` }); + }); -// The replacement main of the recovery binds the fourth port. -const ownedPorts = [...(await reserveLoopbackPorts(4))]; + const processes: NativeProcess[] = []; -const [pgPort, apiPort, dashboardPort, replacementPort] = ownedPorts; + const client = yield* HttpClient.HttpClient; -const pgDirectory = join(privateRoot, "postgres"); + const postJson = Effect.fnUntraced(function* (url: string, value: Schema.Json) { + return yield* client.execute( + HttpClientRequest.post(url).pipe( + HttpClientRequest.setHeader("origin", dashboardOrigin), + HttpClientRequest.bodyText(yield* jsonText(value), "application/json"), + ), + ); + }); -const postgresUrl = `postgres://postgres@127.0.0.1:${pgPort}/postgres`; + // The replacement main of the recovery binds the fourth port. + const ownedPorts = [...(yield* step(() => reserveLoopbackPorts(4)))]; -const origin = `http://127.0.0.1:${apiPort}`; + const [pgPort, apiPort, dashboardPort, replacementPort] = ownedPorts; -const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; + const pgDirectory = join(privateRoot, "postgres"); -const providerToken = randomBytes(24).toString("hex"); + const postgresUrl = `postgres://postgres@127.0.0.1:${pgPort}/postgres`; -const secret = randomBytes(32).toString("hex"); + const origin = `http://127.0.0.1:${apiPort}`; -const password = randomBytes(24).toString("hex"); + const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; -const person = "delivery-recovery-person"; + const providerToken = randomBytes(24).toString("hex"); -const email = "delivery-recovery@example.invalid"; + const secret = randomBytes(32).toString("hex"); -const department = "delivery-recovery-department"; + const password = randomBytes(24).toString("hex"); -type Mode = "failure" | "accept" | "hold"; + const person = "delivery-recovery-person"; -let resetMode: Mode = "failure"; + const email = "delivery-recovery@example.invalid"; -let receiptMode: Mode = "failure"; + const department = "delivery-recovery-department"; -const attempts: Array<{ kind: "reset" | "receipt"; id: string; hash: string; accepted: boolean }> = - []; + type Mode = "failure" | "accept" | "hold"; -const held: Array<() => void> = []; + let resetMode: Mode = "failure"; -const poison = new Set(); + let receiptMode: Mode = "failure"; -const provider = createHttpServer(async (request, response) => { - assert.equal(request.headers.authorization, `Bearer ${providerToken}`); - let payload = ""; + const attempts: Array<{ + kind: "reset" | "receipt"; + id: string; + hash: string; + accepted: boolean; + }> = []; - for await (const chunk of request) payload += String(chunk); + const held: Array> = []; - const body = Schema.decodeSync( - Schema.fromJsonString(Schema.Struct({ deliveryId: Schema.String })), - )(payload); + const releaseHeld = Effect.suspend(() => + Effect.forEach(held.splice(0), (release) => Deferred.succeed(release, undefined)), + ); - const kind = request.url === "/reset" ? "reset" : "receipt"; - const mode = poison.has(body.deliveryId) ? "failure" : kind === "reset" ? resetMode : receiptMode; - assert.equal(request.headers["idempotency-key"], body.deliveryId); - attempts.push({ - kind, - id: body.deliveryId, - hash: createHash("sha256").update(payload).digest("hex"), - accepted: mode === "accept", - }); + const poison = new Set(); - if (mode === "hold") { - const pending = Promise.withResolvers(); - held.push(pending.resolve); - await pending.promise; - } + const pool = new Pool({ + connectionString: postgresUrl, + application_name: "delivery-recovery-observer", + }); - response.writeHead(mode === "failure" ? 503 : 204); - response.end(); -}); + const query = (text: string, values?: ReadonlyArray) => + step(() => pool.query(text, values === undefined ? undefined : [...values])); -const providerReady = Promise.withResolvers(); - -provider.once("error", providerReady.reject); - -provider.listen(0, "127.0.0.1", providerReady.resolve); - -await providerReady.promise; - -const providerAddress = provider.address(); - -assert.ok(providerAddress && !Predicate.isString(providerAddress)); - -const providerPort = providerAddress.port; - -const env: NodeJS.ProcessEnv = { - ...safeEnvironment, - BACKEND_HOST: "127.0.0.1", - BACKEND_PORT: String(apiPort), - BACKEND_PG_URL: postgresUrl, - BETTER_AUTH_SECRET: secret, - NATIVE_IDENTITY_DEPLOYMENT: "local", - NATIVE_IDENTITY_TRUSTED_ORIGINS: JSON.stringify([dashboardOrigin]), - OAUTH_CANONICAL_ORIGIN: origin, - OAUTH_DASHBOARD_ORIGIN: dashboardOrigin, - OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", - PUBLIC_APPLICATION_EFFECT_MODE: "disabled", - RECRUITMENT_NOTIFICATION_MODE: "disabled", - SCHOOL_SERVICE_NOTIFICATION_MODE: "disabled", - PASSWORD_RESET_DELIVERY_MODE: "http", - PASSWORD_RESET_DELIVERY_POLL_MS: "2000", - MAIL_SENDER: "recovery@example.invalid", - MAIL_DELIVERY_URL: `http://127.0.0.1:${providerPort}/reset`, - MAIL_DELIVERY_TOKEN: providerToken, - MAIL_DELIVERY_TIMEOUT_MS: "30000", - RECEIPT_DELIVERY_MODE: "http", - RECEIPT_DELIVERY_POLL_MS: "10000", - RECEIPT_DELIVERY_URL: `http://127.0.0.1:${providerPort}/receipt`, - RECEIPT_DELIVERY_TOKEN: providerToken, - RECEIPT_DELIVERY_TIMEOUT_MS: "30000", - RECEIPT_DELIVERY_SENDER: "receipts@example.invalid", - RECEIPT_DELIVERY_ECONOMY_RECIPIENTS: JSON.stringify({ [department]: "economy@example.invalid" }), - RECEIPT_STAGING_ROOT: join(privateRoot, "staging"), - RECEIPT_COMMITTED_ROOT: join(privateRoot, "committed"), -}; - -const pool = new Pool({ - connectionString: postgresUrl, - application_name: "delivery-recovery-observer", -}); + const checks: string[] = []; -const checks: string[] = []; + let postgres: DisposablePostgres | undefined; -let postgres: DisposablePostgres | undefined; + let completed = false; -let completed = false; + let providerPort = 0; -let sourceUnchanged = false; + // A provider request that breaks an assertion answers 500; the proof fails on it at the end. + const providerFailures: string[] = []; -ownedPorts.push(providerPort); + const provide = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + assert.equal(request.headers["authorization"], `Bearer ${providerToken}`); + const payload = yield* request.text; + const body = yield* Schema.decodeEffect(DeliveryBody)(payload); + const kind = request.url === "/reset" ? "reset" : "receipt"; -let current: NativeProcess | undefined; + const mode = poison.has(body.deliveryId) + ? "failure" + : kind === "reset" + ? resetMode + : receiptMode; -let cookie = ""; + assert.equal(request.headers["idempotency-key"], body.deliveryId); + attempts.push({ + kind, + id: body.deliveryId, + hash: createHash("sha256").update(payload).digest("hex"), + accepted: mode === "accept", + }); -const boot = async (changes: NodeJS.ProcessEnv = {}) => { - current = start({ ...env, ...changes }); - await eventually("native listener", async () => { - assert.equal( - current!.child.exitCode, - null, - current!.logs - .join("") - .replaceAll(secret, "[redacted]") - .replaceAll(providerToken, "[redacted]") - .replaceAll(password, "[redacted]") - .replaceAll(email, "[redacted]"), - ); - - try { - const response = await fetch(`${origin}/health`); - - return changes.BACKEND_INGRESS === "internal" - ? response.status === 404 && current!.logs.join("").includes("internal backend listening") - : response.ok; - } catch { - return false; + if (mode === "hold") { + const pending = yield* Deferred.make(); + held.push(pending); + yield* Deferred.await(pending); } - }); - return current; -}; - -const resetRows = async () => - ( - await pool.query( - "SELECT effect_id,verification_id,status,attempts,claim_id,payload_sha256,last_failure_code FROM auth.password_reset_email_outbox ORDER BY created_at,effect_id", - ) - ).rows; - -const receiptRows = async () => - ( - await pool.query( - "SELECT effect_id,receipt_id,status,attempts,claim_id,payload_json,delivery_envelope FROM economy_receipt_outbox ORDER BY receipt_id,ordinal", - ) - ).rows; - -const resetRequest = async () => { - const response = await fetch(`${origin}/api/auth/request-password-reset`, { - method: "POST", - headers: { origin: dashboardOrigin, "content-type": "application/json" }, - body: JSON.stringify({ email, redirectTo: `${dashboardOrigin}/tilbakestill-passord` }), - }); + return HttpServerResponse.empty({ status: mode === "failure" ? 503 : 204 }); + }).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + providerFailures.push(String(cause)); - assert.equal(response.status, 200, "real reset request accepted"); + return HttpServerResponse.empty({ status: 500 }); + }), + ), + ); + + let current: NativeProcess | undefined; + + let cookie = ""; + + const inner = Effect.gen(function* () { + yield* HttpServer.serveEffect(provide); + + const { address } = yield* HttpServer.HttpServer; + + if (Predicate.isTagged(address, "UnixPathAddress")) assert.fail("provider binds a TCP port"); + + providerPort = address.port; + + const env = { + ...safeEnvironment, + BACKEND_HOST: "127.0.0.1", + BACKEND_PORT: String(apiPort), + BACKEND_PG_URL: postgresUrl, + BETTER_AUTH_SECRET: secret, + NATIVE_IDENTITY_DEPLOYMENT: "local", + NATIVE_IDENTITY_TRUSTED_ORIGINS: yield* jsonText([dashboardOrigin]), + OAUTH_CANONICAL_ORIGIN: origin, + OAUTH_DASHBOARD_ORIGIN: dashboardOrigin, + OAUTH_NATIVE_API_RESOURCE: "urn:vektorprogrammet:native-api", + PUBLIC_APPLICATION_EFFECT_MODE: "disabled", + RECRUITMENT_NOTIFICATION_MODE: "disabled", + SCHOOL_SERVICE_NOTIFICATION_MODE: "disabled", + PASSWORD_RESET_DELIVERY_MODE: "http", + PASSWORD_RESET_DELIVERY_POLL_MS: "2000", + MAIL_SENDER: "recovery@example.invalid", + MAIL_DELIVERY_URL: `http://127.0.0.1:${providerPort}/reset`, + MAIL_DELIVERY_TOKEN: providerToken, + MAIL_DELIVERY_TIMEOUT_MS: "30000", + RECEIPT_DELIVERY_MODE: "http", + RECEIPT_DELIVERY_POLL_MS: "10000", + RECEIPT_DELIVERY_URL: `http://127.0.0.1:${providerPort}/receipt`, + RECEIPT_DELIVERY_TOKEN: providerToken, + RECEIPT_DELIVERY_TIMEOUT_MS: "30000", + RECEIPT_DELIVERY_SENDER: "receipts@example.invalid", + RECEIPT_DELIVERY_ECONOMY_RECIPIENTS: yield* jsonText({ + [department]: "economy@example.invalid", + }), + RECEIPT_STAGING_ROOT: join(privateRoot, "staging"), + RECEIPT_COMMITTED_ROOT: join(privateRoot, "committed"), + } satisfies Environment; + + ownedPorts.push(providerPort); + + const start = Effect.fnUntraced(function* (childEnv: Environment) { + const child = yield* ChildProcess.make( + process.execPath, + ["--no-env-file", "apps/backend/src/main.ts"], + { cwd: root, env: { ...childEnv }, extendEnv: false, stdin: "ignore" }, + ); + + const logs: string[] = []; + + yield* child.all.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => Effect.sync(() => logs.push(chunk))), + Effect.ignore, + Effect.forkScoped, + ); + + const exited = yield* child.exitCode.pipe( + Effect.map((code): number | null => Number(code)), + Effect.orElseSucceed(() => null), + Effect.cached, + ); + + const owned: NativeProcess = { child, logs, exited }; + processes.push(owned); + + return owned; + }); - return (await resetRows()).at(-1)!; -}; + const stop = Effect.fnUntraced(function* ( + owned: NativeProcess, + name: NodeJS.Signals = "SIGTERM", + ) { + if (!(yield* owned.child.isRunning)) return; + yield* signal(owned, name); -const receiptRequest = async () => { - const form = new FormData(); - form.set("description", "Disposable delivery recovery proof"); - form.set("amountOre", "500"); - form.set("receiptDate", new Date().toISOString().slice(0, 10)); - form.set( - "file", - new File(["%PDF-1.4\nDisposable receipt\n%%EOF"], "receipt.pdf", { type: "application/pdf" }), - ); + const code = yield* exitWithin(owned, "Native shutdown timeout"); - const response = await fetch(`${origin}/api/receipts?departmentId=${department}`, { - method: "POST", - headers: { cookie, origin: dashboardOrigin, "idempotency-key": randomUUID() }, - body: form, - }); + if (name === "SIGTERM") assert.equal(code, 0, "graceful shutdown"); + }); + + const boot = Effect.fnUntraced(function* (changes: Environment = {}) { + const booted = yield* start({ ...env, ...changes }); + current = booted; + yield* eventually( + "native listener", + Effect.gen(function* () { + assert.ok( + yield* booted.child.isRunning, + booted.logs + .join("") + .replaceAll(secret, "[redacted]") + .replaceAll(providerToken, "[redacted]") + .replaceAll(password, "[redacted]") + .replaceAll(email, "[redacted]"), + ); + + return yield* client.get(`${origin}/health`).pipe( + Effect.map((response) => + changes["BACKEND_INGRESS"] === "internal" + ? response.status === 404 && + booted.logs.join("").includes("internal backend listening") + : response.status >= 200 && response.status <= 299, + ), + Effect.orElseSucceed(() => false), + ); + }), + ); + + return booted; + }); + + const resetRows = () => + query( + "SELECT effect_id,verification_id,status,attempts,claim_id,payload_sha256,last_failure_code FROM auth.password_reset_email_outbox ORDER BY created_at,effect_id", + ).pipe(Effect.map((result) => result.rows)); + + const receiptRows = () => + query( + "SELECT effect_id,receipt_id,status,attempts,claim_id,payload_json,delivery_envelope FROM economy_receipt_outbox ORDER BY receipt_id,ordinal", + ).pipe(Effect.map((result) => result.rows)); - assert.equal(response.status, 201, "real receipt submission accepted"); + const resetRequest = Effect.fnUntraced(function* () { + const response = yield* postJson(`${origin}/api/auth/request-password-reset`, { + email, + redirectTo: `${dashboardOrigin}/tilbakestill-passord`, + }); - return Schema.decodeUnknownSync(Schema.Struct({ receiptId: Schema.String }))( - await response.json(), - ).receiptId; -}; + assert.equal(response.status, 200, "real reset request accepted"); -const business = async () => - ( - await pool.query(`SELECT (SELECT count(*)::int FROM economy_receipts) AS receipts, + return (yield* resetRows()).at(-1)!; + }); + + const receiptRequest = () => + Effect.acquireUseRelease( + Effect.sync(() => nativeScriptClient(origin)), + (native) => + Effect.gen(function* () { + const receiptDate = DateTime.formatIso(yield* DateTime.now).slice(0, 10); + + const result = yield* step(() => + native.call({ cookie, origin: dashboardOrigin }, (client) => + client["receipts.submitReceipt"]({ + idempotencyKey: IdempotencyKey.make(randomUUID()), + departmentId: department, + request: { + description: "Disposable delivery recovery proof", + amountOre: 500, + receiptDate, + file: { + contentType: "application/pdf", + bytes: new TextEncoder().encode("%PDF-1.4\nDisposable receipt\n%%EOF"), + }, + }, + }), + ), + ); + + assert.ok(result.ok, "real receipt submission accepted"); + + return result.value.receiptId; + }), + (native) => Effect.promise(() => native.dispose()), + ); + + const business = () => + query(`SELECT (SELECT count(*)::int FROM economy_receipts) AS receipts, (SELECT count(*)::int FROM economy_receipt_command_receipts) AS commands, (SELECT count(*)::int FROM economy_receipt_audit) AS audit, (SELECT count(*)::int FROM auth.identity_security_audit WHERE event_kind='password-reset-request-accepted') AS resets, - (SELECT count(*)::int FROM auth.verification WHERE identifier LIKE 'reset-password:%') AS verifications`) - ).rows[0]; - -try { - postgres = await startDisposablePostgres({ - port: pgPort, - directory: pgDirectory, - environment: safeEnvironment, - }); - assert.equal(postgres.url, postgresUrl, "owned PostgreSQL serves the configured URL"); - run(process.execPath, ["--no-env-file", "packages/database/runtime/identity-seed-main.ts"], { - ...env, - IDENTITY_SEED_PG_URL: postgresUrl, - IDENTITY_SEED_PERSONS: JSON.stringify([ - { personId: person, firstName: "Recovery", lastName: "Proof", email, password }, - ]), - }); - // Prerequisites only: no receipt, reset verification, command receipt, or delivery outcome is seeded. - await pool.query( - "INSERT INTO organization_departments(department_id,name,short_name,email,city) VALUES($1,'Recovery proof','RP','department@example.invalid','Trondheim')", - [department], - ); - await pool.query( - "INSERT INTO organization_teams(team_id,department_id,name) VALUES('delivery-recovery-team',$1,'Recovery proof')", - [department], - ); - await pool.query( - "INSERT INTO organization_memberships(membership_id,person_id,team_id,start_at,is_team_leader) VALUES('delivery-recovery-membership',$1,'delivery-recovery-team',date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')-INTERVAL '24 hours',false)", - [person], - ); - await pool.query( - "INSERT INTO person_contact_profiles(person_id,email,phone) VALUES($1,$2,'90000000')", - [person, email], - ); - await pool.query( - "INSERT INTO economy_payment_authorities(payment_authority_id,person_id,department_id,payment_account_ciphertext,start_at,revision) VALUES('delivery-recovery-payment',$1,$2,'synthetic:delivery-recovery',date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')-INTERVAL '24 hours',0)", - [person, department], - ); - await boot(); + (SELECT count(*)::int FROM auth.verification WHERE identifier LIKE 'reset-password:%') AS verifications`).pipe( + Effect.map((result) => result.rows[0]), + ); + + const started = yield* step(() => + startDisposablePostgres({ + port: pgPort, + directory: pgDirectory, + environment: safeEnvironment, + }), + ); - const login = await fetch(`${origin}/api/auth/sign-in/email`, { - method: "POST", - headers: { origin: dashboardOrigin, "content-type": "application/json" }, - body: JSON.stringify({ email, password }), - }); + postgres = started; + assert.equal(started.url, postgresUrl, "owned PostgreSQL serves the configured URL"); + yield* run( + process.execPath, + ["--no-env-file", "packages/database/runtime/identity-seed-main.ts"], + { + ...env, + IDENTITY_SEED_PG_URL: postgresUrl, + IDENTITY_SEED_PERSONS: yield* jsonText([ + { personId: person, firstName: "Recovery", lastName: "Proof", email, password }, + ]), + }, + ); + // Prerequisites only: no receipt, reset verification, command receipt, or delivery outcome is seeded. + yield* query( + "INSERT INTO organization_departments(department_id,name,short_name,email,city) VALUES($1,'Recovery proof','RP','department@example.invalid','Trondheim')", + [department], + ); + yield* query( + "INSERT INTO organization_teams(team_id,department_id,name) VALUES('delivery-recovery-team',$1,'Recovery proof')", + [department], + ); + yield* query( + "INSERT INTO organization_memberships(membership_id,person_id,team_id,start_at,is_team_leader) VALUES('delivery-recovery-membership',$1,'delivery-recovery-team',date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')-INTERVAL '24 hours',false)", + [person], + ); + yield* query( + "INSERT INTO person_contact_profiles(person_id,email,phone) VALUES($1,$2,'90000000')", + [person, email], + ); + yield* query( + "INSERT INTO economy_payment_authorities(payment_authority_id,person_id,department_id,payment_account_ciphertext,start_at,revision) VALUES('delivery-recovery-payment',$1,$2,'synthetic:delivery-recovery',date_trunc('milliseconds',CURRENT_TIMESTAMP,'UTC')-INTERVAL '24 hours',0)", + [person, department], + ); + yield* boot(); + + const login = yield* postJson(`${origin}/api/auth/sign-in/email`, { email, password }); + + assert.equal(login.status, 200); + cookie = Object.values(login.cookies.cookies) + .map((value) => `${value.name}=${value.valueEncoded}`) + .join("; "); + const reset = yield* resetRequest(); + const receiptId = yield* receiptRequest(); + yield* eventually( + "both durable failures", + Effect.gen(function* () { + return ( + (yield* resetRows()).some( + (row) => row.effect_id === reset.effect_id && row.status === "Failed", + ) && + (yield* receiptRows()).some( + (row) => row.receipt_id === receiptId && row.status === "Failed", + ) + ); + }), + ); + yield* stop(current!); + const before = yield* business(); + const failedReset = (yield* resetRows()).find((row) => row.effect_id === reset.effect_id)!; + + const failedReceipt = (yield* receiptRows()).find( + (row) => row.receipt_id === receiptId && row.status === "Failed", + )!; + + assert.equal(failedReset.attempts, 1); + assert.equal(failedReceipt.attempts, 1); + resetMode = "accept"; + receiptMode = "accept"; + yield* boot({ + PASSWORD_RESET_DELIVERY_POLL_MS: "100", + RECEIPT_DELIVERY_POLL_MS: "100", + RECEIPT_DELIVERY_SENDER: "changed@example.invalid", + }); + yield* eventually( + "unattended recovery", + Effect.gen(function* () { + return ( + (yield* resetRows()).find((row) => row.effect_id === reset.effect_id)?.status === + "Delivered" && + (yield* receiptRows()) + .filter((row) => row.receipt_id === receiptId) + .every((row) => row.status === "Delivered") + ); + }), + ); + assert.deepEqual(yield* business(), before); - assert.equal(login.status, 200); - cookie = login.headers - .getSetCookie() - .map((value) => value.split(";")[0]) - .join("; "); - const reset = await resetRequest(); - const receiptId = await receiptRequest(); - await eventually( - "both durable failures", - async () => - (await resetRows()).some( - (row) => row.effect_id === reset.effect_id && row.status === "Failed", - ) && - (await receiptRows()).some((row) => row.receipt_id === receiptId && row.status === "Failed"), - ); - await stop(current!); - const before = await business(); - const failedReset = (await resetRows()).find((row) => row.effect_id === reset.effect_id)!; - - const failedReceipt = (await receiptRows()).find( - (row) => row.receipt_id === receiptId && row.status === "Failed", - )!; - - assert.equal(failedReset.attempts, 1); - assert.equal(failedReceipt.attempts, 1); - resetMode = "accept"; - receiptMode = "accept"; - await boot({ - PASSWORD_RESET_DELIVERY_POLL_MS: "100", - RECEIPT_DELIVERY_POLL_MS: "100", - RECEIPT_DELIVERY_SENDER: "changed@example.invalid", - }); - await eventually( - "unattended recovery", - async () => - (await resetRows()).find((row) => row.effect_id === reset.effect_id)?.status === - "Delivered" && - (await receiptRows()) - .filter((row) => row.receipt_id === receiptId) - .every((row) => row.status === "Delivered"), - ); - assert.deepEqual(await business(), before); + for (const id of [reset.effect_id, failedReceipt.effect_id]) { + const deliveries = attempts.filter((attempt) => attempt.id === id); + assert.equal(deliveries.length, 2); + assert.equal(deliveries[0]!.hash, deliveries[1]!.hash); + assert.equal(deliveries[1]!.accepted, true); + } - for (const id of [reset.effect_id, failedReceipt.effect_id]) { - const deliveries = attempts.filter((attempt) => attempt.id === id); - assert.equal(deliveries.length, 2); - assert.equal(deliveries[0]!.hash, deliveries[1]!.hash); - assert.equal(deliveries[1]!.accepted, true); - } + assert.equal( + (yield* resetRows()).find((row) => row.effect_id === reset.effect_id)!.attempts, + 2, + ); + assert.equal( + (yield* receiptRows()).find((row) => row.effect_id === failedReceipt.effect_id)!.attempts, + 2, + ); + checks.push( + "reset and receipt: real request -> durable failure -> stopped native process -> unattended restart success; stable effect/payload, attempts=2, unchanged business facts", + ); + yield* stop(current!); + + receiptMode = "failure"; + yield* boot({ RECEIPT_DELIVERY_MODE: "disabled", PASSWORD_RESET_DELIVERY_MODE: "disabled" }); + const poisonedReceipt = yield* receiptRequest(); + const otherReceipt = yield* receiptRequest(); + + const poisonedEffect = (yield* receiptRows()).find( + (row) => row.receipt_id === poisonedReceipt && row.status === "Failed", + )!; + + poison.add(poisonedEffect.effect_id); + const beforeDisabled = yield* receiptRows(); + yield* Effect.sleep("300 millis"); + assert.deepEqual(yield* receiptRows(), beforeDisabled); + yield* stop(current!); + yield* boot({ + BACKEND_INGRESS: "internal", + OAUTH_INTERNAL_SOURCE_NETWORKS: "127.0.0.1/32", + PASSWORD_RESET_DELIVERY_POLL_MS: "100", + RECEIPT_DELIVERY_POLL_MS: "100", + }); + yield* Effect.sleep("300 millis"); + assert.deepEqual(yield* receiptRows(), beforeDisabled); + yield* stop(current!); + receiptMode = "accept"; + yield* boot({ RECEIPT_DELIVERY_POLL_MS: "100" }); + yield* eventually( + "unrelated receipt progresses past poison", + Effect.gen(function* () { + return (yield* receiptRows()) + .filter((row) => row.receipt_id === otherReceipt) + .every((row) => row.status === "Delivered"); + }), + ); + assert.equal( + (yield* receiptRows()).find((row) => row.effect_id === poisonedEffect.effect_id)!.status, + "Failed", + ); + poison.clear(); + yield* eventually( + "poison recovery", + Effect.gen(function* () { + return (yield* receiptRows()) + .filter((row) => row.receipt_id === poisonedReceipt) + .every((row) => row.status === "Delivered"); + }), + ); + yield* stop(current!); + checks.push( + "disabled and internal workers do not claim; a failed receipt does not starve another receipt; predecessor order remains enforced", + ); - assert.equal((await resetRows()).find((row) => row.effect_id === reset.effect_id)!.attempts, 2); - assert.equal( - (await receiptRows()).find((row) => row.effect_id === failedReceipt.effect_id)!.attempts, - 2, - ); - checks.push( - "reset and receipt: real request -> durable failure -> stopped native process -> unattended restart success; stable effect/payload, attempts=2, unchanged business facts", - ); - await stop(current!); - - receiptMode = "failure"; - await boot({ RECEIPT_DELIVERY_MODE: "disabled", PASSWORD_RESET_DELIVERY_MODE: "disabled" }); - const poisonedReceipt = await receiptRequest(); - const otherReceipt = await receiptRequest(); - - const poisonedEffect = (await receiptRows()).find( - (row) => row.receipt_id === poisonedReceipt && row.status === "Failed", - )!; - - poison.add(poisonedEffect.effect_id); - const beforeDisabled = await receiptRows(); - await pause(300); - assert.deepEqual(await receiptRows(), beforeDisabled); - await stop(current!); - await boot({ - BACKEND_INGRESS: "internal", - OAUTH_INTERNAL_SOURCE_NETWORKS: "127.0.0.1/32", - PASSWORD_RESET_DELIVERY_POLL_MS: "100", - RECEIPT_DELIVERY_POLL_MS: "100", - }); - await pause(300); - assert.deepEqual(await receiptRows(), beforeDisabled); - await stop(current!); - receiptMode = "accept"; - await boot({ RECEIPT_DELIVERY_POLL_MS: "100" }); - await eventually("unrelated receipt progresses past poison", async () => - (await receiptRows()) - .filter((row) => row.receipt_id === otherReceipt) - .every((row) => row.status === "Delivered"), - ); - assert.equal( - (await receiptRows()).find((row) => row.effect_id === poisonedEffect.effect_id)!.status, - "Failed", - ); - poison.clear(); - await eventually("poison recovery", async () => - (await receiptRows()) - .filter((row) => row.receipt_id === poisonedReceipt) - .every((row) => row.status === "Delivered"), - ); - await stop(current!); - checks.push( - "disabled and internal workers do not claim; a failed receipt does not starve another receipt; predecessor order remains enforced", - ); + // A changed reset payload must never reuse an idempotency key with a different body. + resetMode = "failure"; + yield* boot(); + const drift = yield* resetRequest(); + yield* eventually( + "reset drift baseline", + Effect.gen(function* () { + return ( + (yield* resetRows()).find((row) => row.effect_id === drift.effect_id)?.status === "Failed" + ); + }), + ); + yield* stop(current!); + resetMode = "accept"; + yield* boot({ MAIL_SENDER: "changed@example.invalid", PASSWORD_RESET_DELIVERY_POLL_MS: "100" }); + yield* eventually( + "reset drift quarantine", + Effect.gen(function* () { + return ( + (yield* resetRows()).find((row) => row.effect_id === drift.effect_id)?.status === + "Quarantined" + ); + }), + ); + assert.equal(attempts.filter((attempt) => attempt.id === drift.effect_id).length, 1); + checks.push("reset payload drift quarantines without a second provider request"); + yield* stop(current!); + + resetMode = "hold"; + yield* boot({ PASSWORD_RESET_DELIVERY_POLL_MS: "100" }); + const interrupted = yield* resetRequest(); + yield* eventually( + "reset in flight", + Effect.sync(() => attempts.some((attempt) => attempt.id === interrupted.effect_id)), + ); + yield* stop(current!); + + const interruptedRow = (yield* resetRows()).find( + (row) => row.effect_id === interrupted.effect_id, + )!; + + assert.equal(interruptedRow.status, "Quarantined"); + assert.equal(interruptedRow.claim_id, null); + assert.equal(interruptedRow.last_failure_code, "delivery-timeout"); + yield* releaseHeld; + checks.push( + "SIGTERM aborts reset provider delivery, joins fenced quarantine, and releases the native pool", + ); - // A changed reset payload must never reuse an idempotency key with a different body. - resetMode = "failure"; - await boot(); - const drift = await resetRequest(); - await eventually( - "reset drift baseline", - async () => - (await resetRows()).find((row) => row.effect_id === drift.effect_id)?.status === "Failed", - ); - await stop(current!); - resetMode = "accept"; - await boot({ MAIL_SENDER: "changed@example.invalid", PASSWORD_RESET_DELIVERY_POLL_MS: "100" }); - await eventually( - "reset drift quarantine", - async () => - (await resetRows()).find((row) => row.effect_id === drift.effect_id)?.status === - "Quarantined", - ); - assert.equal(attempts.filter((attempt) => attempt.id === drift.effect_id).length, 1); - checks.push("reset payload drift quarantines without a second provider request"); - await stop(current!); - - resetMode = "hold"; - await boot({ PASSWORD_RESET_DELIVERY_POLL_MS: "100" }); - const interrupted = await resetRequest(); - await eventually("reset in flight", async () => - attempts.some((attempt) => attempt.id === interrupted.effect_id), - ); - await stop(current!); - - const interruptedRow = (await resetRows()).find( - (row) => row.effect_id === interrupted.effect_id, - )!; - - assert.equal(interruptedRow.status, "Quarantined"); - assert.equal(interruptedRow.claim_id, null); - assert.equal(interruptedRow.last_failure_code, "delivery-timeout"); - held.splice(0).forEach((release) => release()); - checks.push( - "SIGTERM aborts reset provider delivery, joins fenced quarantine, and releases the native pool", - ); + // Freeze an actual native owner, let its lease become stale, and start a replacement owner. + resetMode = "hold"; + receiptMode = "hold"; + yield* boot({ PASSWORD_RESET_DELIVERY_POLL_MS: "100", RECEIPT_DELIVERY_POLL_MS: "100" }); + const staleReset = yield* resetRequest(); - // Freeze an actual native owner, let its lease become stale, and start a replacement owner. - resetMode = "hold"; - receiptMode = "hold"; - await boot({ PASSWORD_RESET_DELIVERY_POLL_MS: "100", RECEIPT_DELIVERY_POLL_MS: "100" }); - const staleReset = await resetRequest(); - const staleReceiptRequest = receiptRequest().catch(() => undefined); - await eventually( - "two held provider claims", - async () => - attempts.some((attempt) => attempt.id === staleReset.effect_id) && - (await receiptRows()).some( - (row) => row.status === "Processing" && row.delivery_envelope !== null, - ), - ); - const oldReset = (await resetRows()).find((row) => row.effect_id === staleReset.effect_id)!; - - const oldReceipt = (await receiptRows()).find( - (row) => row.status === "Processing" && row.delivery_envelope !== null, - )!; - - const frozen = current!; - frozen.child.kill("SIGSTOP"); - await pause(61_000); - resetMode = "accept"; - receiptMode = "accept"; - // A second main uses a separate listener, but the same durable queue and filesystem. - current = start({ - ...env, - BACKEND_PORT: String(replacementPort), - PASSWORD_RESET_DELIVERY_POLL_MS: "100", - RECEIPT_DELIVERY_POLL_MS: "100", - }); - await eventually( - "stale recovery", - async () => - (await resetRows()).find((row) => row.effect_id === oldReset.effect_id)?.status === - "Quarantined" && - (await receiptRows()).find((row) => row.effect_id === oldReceipt.effect_id)?.status === - "Delivered", - ); + const staleReceiptRequest = yield* receiptRequest().pipe( + Effect.catchCause(() => Effect.void), + Effect.forkScoped, + ); - const replacementReceipt = (await receiptRows()).find( - (row) => row.effect_id === oldReceipt.effect_id, - )!; - - assert.equal(replacementReceipt.attempts, oldReceipt.attempts + 1); - assert.deepEqual(replacementReceipt.delivery_envelope, oldReceipt.delivery_envelope); - assert.equal(attempts.filter((attempt) => attempt.id === oldReset.effect_id).length, 1); - const factsAfterRecovery = await business(); - frozen.child.kill("SIGCONT"); - held.splice(0).forEach((release) => release()); - await pause(500); - await stop(frozen); - await staleReceiptRequest; - assert.equal( - (await resetRows()).find((row) => row.effect_id === oldReset.effect_id)!.status, - "Quarantined", - ); - assert.equal( - (await receiptRows()).find((row) => row.effect_id === oldReceipt.effect_id)!.status, - "Delivered", - ); - assert.deepEqual(await business(), factsAfterRecovery); - await stop(current!); - checks.push( - "stale reset owner cannot acknowledge quarantine; stale receipt owner cannot overwrite replacement delivery; receipt envelope and business facts remain unchanged", - ); + yield* eventually( + "two held provider claims", + Effect.gen(function* () { + return ( + attempts.some((attempt) => attempt.id === staleReset.effect_id) && + (yield* receiptRows()).some( + (row) => row.status === "Processing" && row.delivery_envelope !== null, + ) + ); + }), + ); + const oldReset = (yield* resetRows()).find((row) => row.effect_id === staleReset.effect_id)!; + + const oldReceipt = (yield* receiptRows()).find( + (row) => row.status === "Processing" && row.delivery_envelope !== null, + )!; + + const frozen = current!; + yield* signal(frozen, "SIGSTOP"); + yield* Effect.sleep("61000 millis"); + resetMode = "accept"; + receiptMode = "accept"; + // A second main uses a separate listener, but the same durable queue and filesystem. + current = yield* start({ + ...env, + BACKEND_PORT: String(replacementPort), + PASSWORD_RESET_DELIVERY_POLL_MS: "100", + RECEIPT_DELIVERY_POLL_MS: "100", + }); + yield* eventually( + "stale recovery", + Effect.gen(function* () { + return ( + (yield* resetRows()).find((row) => row.effect_id === oldReset.effect_id)?.status === + "Quarantined" && + (yield* receiptRows()).find((row) => row.effect_id === oldReceipt.effect_id)?.status === + "Delivered" + ); + }), + ); - // A real command creates pending notification work; only DDL injects the storage fault. - await boot({ - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_URL: undefined, - RECEIPT_DELIVERY_TOKEN: undefined, - RECEIPT_DELIVERY_TIMEOUT_MS: undefined, - RECEIPT_DELIVERY_SENDER: undefined, - RECEIPT_DELIVERY_ECONOMY_RECIPIENTS: undefined, - }); - const faultReceipt = await receiptRequest(); - await stop(current!); + const replacementReceipt = (yield* receiptRows()).find( + (row) => row.effect_id === oldReceipt.effect_id, + )!; + + assert.equal(replacementReceipt.attempts, oldReceipt.attempts + 1); + assert.deepEqual(replacementReceipt.delivery_envelope, oldReceipt.delivery_envelope); + assert.equal(attempts.filter((attempt) => attempt.id === oldReset.effect_id).length, 1); + const factsAfterRecovery = yield* business(); + yield* signal(frozen, "SIGCONT"); + yield* releaseHeld; + yield* Effect.sleep("500 millis"); + yield* stop(frozen); + yield* Fiber.join(staleReceiptRequest); + assert.equal( + (yield* resetRows()).find((row) => row.effect_id === oldReset.effect_id)!.status, + "Quarantined", + ); + assert.equal( + (yield* receiptRows()).find((row) => row.effect_id === oldReceipt.effect_id)!.status, + "Delivered", + ); + assert.deepEqual(yield* business(), factsAfterRecovery); + yield* stop(current!); + checks.push( + "stale reset owner cannot acknowledge quarantine; stale receipt owner cannot overwrite replacement delivery; receipt envelope and business facts remain unchanged", + ); - const faultEffect = (await receiptRows()).find( - (row) => row.receipt_id === faultReceipt && row.status === "Failed", - )!; + // A real command creates pending notification work; only DDL injects the storage fault. + yield* boot({ + PASSWORD_RESET_DELIVERY_MODE: "disabled", + RECEIPT_DELIVERY_MODE: "disabled", + RECEIPT_DELIVERY_URL: undefined, + RECEIPT_DELIVERY_TOKEN: undefined, + RECEIPT_DELIVERY_TIMEOUT_MS: undefined, + RECEIPT_DELIVERY_SENDER: undefined, + RECEIPT_DELIVERY_ECONOMY_RECIPIENTS: undefined, + }); + const faultReceipt = yield* receiptRequest(); + yield* stop(current!); - assert.equal(faultEffect.delivery_envelope, null); - await pool.query( - "CREATE FUNCTION delivery_recovery_envelope_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic envelope persistence fault'; END $$; CREATE TRIGGER delivery_recovery_envelope_fault BEFORE UPDATE OF delivery_envelope ON economy_receipt_outbox FOR EACH ROW EXECUTE FUNCTION delivery_recovery_envelope_fault()", - ); - current = start({ - ...env, - PASSWORD_RESET_DELIVERY_MODE: "disabled", - RECEIPT_DELIVERY_POLL_MS: "100", - }); + const faultEffect = (yield* receiptRows()).find( + (row) => row.receipt_id === faultReceipt && row.status === "Failed", + )!; - const interpreterCode = await Promise.race([ - current.exited, - pause(10_000).then(() => { - throw new Error("Receipt interpreter SQL failure timeout"); - }), - ]); + assert.equal(faultEffect.delivery_envelope, null); + yield* query( + "CREATE FUNCTION delivery_recovery_envelope_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic envelope persistence fault'; END $$; CREATE TRIGGER delivery_recovery_envelope_fault BEFORE UPDATE OF delivery_envelope ON economy_receipt_outbox FOR EACH ROW EXECUTE FUNCTION delivery_recovery_envelope_fault()", + ); - assert.equal(interpreterCode, 1); - assert.ok(current.logs.join("").includes("receipt delivery worker failed")); - assert.equal(attempts.filter((attempt) => attempt.id === faultEffect.effect_id).length, 0); - assert.equal( - (await receiptRows()).find((row) => row.effect_id === faultEffect.effect_id)!.status, - "Processing", - ); - await pool.query( - "DROP TRIGGER delivery_recovery_envelope_fault ON economy_receipt_outbox; DROP FUNCTION delivery_recovery_envelope_fault()", - ); - checks.push( - "receipt envelope persistence failure stops root without a provider request or retryable failure acknowledgment", - ); + const interpreter = yield* start({ + ...env, + PASSWORD_RESET_DELIVERY_MODE: "disabled", + RECEIPT_DELIVERY_POLL_MS: "100", + }); - resetMode = "hold"; - await boot({ PASSWORD_RESET_DELIVERY_POLL_MS: "100", RECEIPT_DELIVERY_MODE: "disabled" }); - const shutdownFailure = await resetRequest(); - await eventually("reset finalization fault prerequisite", async () => - attempts.some((attempt) => attempt.id === shutdownFailure.effect_id), - ); - await pool.query( - "ALTER TABLE auth.password_reset_email_outbox RENAME TO password_reset_email_outbox_shutdown_fault", - ); - current!.child.kill("SIGTERM"); + current = interpreter; - const shutdownCode = await Promise.race([ - current!.exited, - pause(10_000).then(() => { - throw new Error("Finalization failure shutdown timeout"); - }), - ]); + const interpreterCode = yield* exitWithin( + interpreter, + "Receipt interpreter SQL failure timeout", + ); - assert.equal(shutdownCode, 1, "failed interruption finalization must not exit cleanly"); - await pool.query( - "ALTER TABLE auth.password_reset_email_outbox_shutdown_fault RENAME TO password_reset_email_outbox", - ); - held.splice(0).forEach((release) => release()); - checks.push("reset interruption finalization SQL failure preserves nonzero root exit"); - - // Fault injection affects availability, not durable business outcomes. - for (const kind of ["reset", "receipt"] as const) { - await boot({ - PASSWORD_RESET_DELIVERY_MODE: kind === "reset" ? "http" : "disabled", - RECEIPT_DELIVERY_MODE: kind === "receipt" ? "http" : "disabled", - PASSWORD_RESET_DELIVERY_POLL_MS: "100", - RECEIPT_DELIVERY_POLL_MS: "100", - }); - const table = kind === "reset" ? "auth.password_reset_email_outbox" : "economy_receipt_outbox"; + assert.equal(interpreterCode, 1); + assert.ok(interpreter.logs.join("").includes("receipt delivery worker failed")); + assert.equal(attempts.filter((attempt) => attempt.id === faultEffect.effect_id).length, 0); + assert.equal( + (yield* receiptRows()).find((row) => row.effect_id === faultEffect.effect_id)!.status, + "Processing", + ); + yield* query( + "DROP TRIGGER delivery_recovery_envelope_fault ON economy_receipt_outbox; DROP FUNCTION delivery_recovery_envelope_fault()", + ); + checks.push( + "receipt envelope persistence failure stops root without a provider request or retryable failure acknowledgment", + ); - const renamed = - kind === "reset" ? "password_reset_email_outbox_fault" : "economy_receipt_outbox_fault"; + resetMode = "hold"; + yield* boot({ PASSWORD_RESET_DELIVERY_POLL_MS: "100", RECEIPT_DELIVERY_MODE: "disabled" }); + const shutdownFailure = yield* resetRequest(); + yield* eventually( + "reset finalization fault prerequisite", + Effect.sync(() => attempts.some((attempt) => attempt.id === shutdownFailure.effect_id)), + ); + yield* query( + "ALTER TABLE auth.password_reset_email_outbox RENAME TO password_reset_email_outbox_shutdown_fault", + ); + yield* signal(current!, "SIGTERM"); - await pool.query(`ALTER TABLE ${table} RENAME TO ${renamed}`); + const shutdownCode = yield* exitWithin(current!, "Finalization failure shutdown timeout"); - const code: number | null = await Promise.race([ - current!.exited, - pause(10_000).then(() => { - throw new Error("Root worker failure timeout"); - }), - ]); - - assert.equal(code, 1); - assert.ok( - current!.logs - .join("") - .includes( - kind === "reset" - ? "password reset delivery worker failed" - : "receipt delivery worker failed", - ), - ); - await pool.query( - `ALTER TABLE ${kind === "reset" ? "auth." : ""}${renamed} RENAME TO ${table.split(".").at(-1)}`, - ); - checks.push(`${kind}: root SQL failure stops actual native main with exit 1`); - } + assert.equal(shutdownCode, 1, "failed interruption finalization must not exit cleanly"); + yield* query( + "ALTER TABLE auth.password_reset_email_outbox_shutdown_fault RENAME TO password_reset_email_outbox", + ); + yield* releaseHeld; + checks.push("reset interruption finalization SQL failure preserves nonzero root exit"); + + // Fault injection affects availability, not durable business outcomes. + for (const kind of ["reset", "receipt"] as const) { + yield* boot({ + PASSWORD_RESET_DELIVERY_MODE: kind === "reset" ? "http" : "disabled", + RECEIPT_DELIVERY_MODE: kind === "receipt" ? "http" : "disabled", + PASSWORD_RESET_DELIVERY_POLL_MS: "100", + RECEIPT_DELIVERY_POLL_MS: "100", + }); + + const table = + kind === "reset" ? "auth.password_reset_email_outbox" : "economy_receipt_outbox"; + + const renamed = + kind === "reset" ? "password_reset_email_outbox_fault" : "economy_receipt_outbox_fault"; + + yield* query(`ALTER TABLE ${table} RENAME TO ${renamed}`); + + const code: number | null = yield* exitWithin(current!, "Root worker failure timeout"); + + assert.equal(code, 1); + assert.ok( + current!.logs + .join("") + .includes( + kind === "reset" + ? "password reset delivery worker failed" + : "receipt delivery worker failed", + ), + ); + yield* query( + `ALTER TABLE ${kind === "reset" ? "auth." : ""}${renamed} RENAME TO ${table.split(".").at(-1)}`, + ); + checks.push(`${kind}: root SQL failure stops actual native main with exit 1`); + } - const sessions = await pool.query( - "SELECT count(*)::int AS count FROM pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid()", - ); + const sessions = yield* query( + "SELECT count(*)::int AS count FROM pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid()", + ); + + assert.equal(sessions.rows[0].count, 0); + checks.push("all native processes exited; no native PostgreSQL sessions remain"); + assert.equal(checks.length, 10); + assert.deepEqual(providerFailures, [], "every provider request met its assertions"); + completed = true; + }); - assert.equal(sessions.rows[0].count, 0); - checks.push("all native processes exited; no native PostgreSQL sessions remain"); - assert.equal(checks.length, 10); - completed = true; -} finally { - held.splice(0).forEach((release) => release()); - - for (const process of processes) { - if (process.child.exitCode === null && process.child.signalCode === null) { - process.child.kill("SIGCONT"); - process.child.kill("SIGKILL"); - await process.exited; + // Every owned native process exits before the pool, the provider, and the cluster close. + const cleanup = Effect.gen(function* () { + yield* releaseHeld; + + for (const owned of processes) { + if (yield* owned.child.isRunning) { + yield* signal(owned, "SIGCONT"); + yield* signal(owned, "SIGKILL"); + yield* owned.exited; + } } - } - await pool.end(); - provider.closeAllConnections(); - const providerClosed = Promise.withResolvers(); - provider.close(() => providerClosed.resolve()); - await providerClosed.promise; + yield* Effect.promise(() => pool.end()); + }); + + const outcome = yield* Effect.exit( + Effect.scoped(inner.pipe(Effect.ensuring(Effect.orDie(cleanup)))).pipe( + Effect.provide(BunHttpServer.layer({ port: 0, hostname: "127.0.0.1" })), + ), + ); + + const stopped = postgres; - await postgres?.stop(); - await rm(privateRoot, { recursive: true, force: true }); + if (stopped !== undefined) yield* Effect.promise(() => stopped.stop()); + yield* fileSystem.remove(privateRoot, { recursive: true, force: true }); for (const expected of ownedPorts) - assert.ok(await loopbackPortFree(expected), `owned listener ${expected} released`); - const postRunRevision = run("git", ["rev-parse", "HEAD"]).trim(); - const postRunTree = run("git", ["rev-parse", "HEAD^{tree}"]).trim(); - sourceUnchanged = + assert.ok(yield* step(() => loopbackPortFree(expected)), `owned listener ${expected} released`); + const postRunRevision = (yield* run("git", ["rev-parse", "HEAD"])).trim(); + const postRunTree = (yield* run("git", ["rev-parse", "HEAD^{tree}"])).trim(); + + const sourceUnchanged = postRunRevision === revision && postRunTree === sourceTree && - run("git", ["status", "--porcelain"]).trim() === ""; - await writeFile( + (yield* run("git", ["status", "--porcelain"])).trim() === ""; + + yield* fileSystem.writeFileString( join(artifacts, "evidence.json"), - JSON.stringify( - { - revision, - sourceTree, - status: completed && sourceUnchanged ? "passed" : "failed", - postRunRevision, - postRunTree, - sourceUnchanged, - ownedPids: [...processes.map((owned) => owned.child.pid), postgres?.pid], - ownedPorts, - runtime: process.versions.bun, - checks, - cleanup: { privateResourcesRemoved: true, providersStopped: true, processesStopped: true }, - scope: "Synthetic local PostgreSQL and loopback provider only; no exactly-once claim", - }, - null, - 2, - ) + "\n", + (yield* indentedJsonText({ + revision, + sourceTree, + status: completed && sourceUnchanged ? "passed" : "failed", + postRunRevision, + postRunTree, + sourceUnchanged, + ownedPids: [...processes.map((owned) => owned.child.pid), stopped?.pid], + ownedPorts, + runtime: process.versions.bun, + checks, + cleanup: { privateResourcesRemoved: true, providersStopped: true, processesStopped: true }, + scope: "Synthetic local PostgreSQL and loopback provider only; no exactly-once claim", + })) + "\n", { mode: 0o600 }, ); -} -assert.ok(completed && sourceUnchanged, "proof and source identity must pass"); + if (Exit.isFailure(outcome)) return yield* Effect.failCause(outcome.cause); -process.stdout.write(`PASS ${checks.length} recovery observations; sanitized evidence retained\n`); + assert.ok(completed && sourceUnchanged, "proof and source identity must pass"); + + yield* Console.log(`PASS ${checks.length} recovery observations; sanitized evidence retained`); +}); + +BunRuntime.runMain( + proof.pipe( + surfaceStepFailure, + Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer)), + ), +); diff --git a/turbo.json b/turbo.json index 8d5edc493..77bce73ec 100644 --- a/turbo.json +++ b/turbo.json @@ -5,7 +5,7 @@ "globalPassThroughEnv": ["VEKTORPROGRAMMET_HEAVY_LOCK"], "tasks": { "build": { - "dependsOn": ["^build", "generate"], + "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", ".env*"], "outputs": ["dist/**"] }, @@ -16,14 +16,7 @@ "dependsOn": ["^lint"] }, "check-types": { - "dependsOn": ["^check-types", "generate"] - }, - "api:spec": { - "cache": false - }, - "generate": { - "dependsOn": ["^generate"], - "outputs": ["openapi.json", "native-api-operations.json"] + "dependsOn": ["^check-types"] }, "dev": { "cache": false,